Skip to content

Commit 4f7b636

Browse files
NikkiAungvogella
authored andcommitted
Add global shortcuts to navigate search results from the editor
Adds two commands (globalNextSearchEntry, globalPreviousSearchEntry) implemented by GlobalNextPrevSearchEntryHandler. The handler invokes gotoNextMatch() / gotoPreviousMatch() on the active AbstractTextSearchViewPage. Key bindings: ALT+. — next search result ALT+, — previous search result CMD+OPT+. / CMD+OPT+, — macOS alternatives Allows navigating search results without leaving the editor. Fixes: #3240
1 parent d8e65ca commit 4f7b636

File tree

7 files changed

+417
-1
lines changed

7 files changed

+417
-1
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Eclipse Foundation and others.
3+
*
4+
* This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* which accompanies this distribution, and is available at
7+
* https://www.eclipse.org/legal/epl-2.0/
8+
*
9+
* SPDX-License-Identifier: EPL-2.0
10+
*
11+
* Contributors:
12+
* Eclipse Foundation - initial API and implementation
13+
*******************************************************************************/
14+
package org.eclipse.search2.internal.ui.basic.views;
15+
16+
import org.eclipse.core.commands.AbstractHandler;
17+
import org.eclipse.core.commands.ExecutionEvent;
18+
import org.eclipse.core.commands.ExecutionException;
19+
import org.eclipse.core.runtime.CoreException;
20+
import org.eclipse.core.runtime.IConfigurationElement;
21+
import org.eclipse.core.runtime.IExecutableExtension;
22+
23+
import org.eclipse.search.ui.ISearchResultPage;
24+
import org.eclipse.search.ui.ISearchResultViewPart;
25+
import org.eclipse.search.ui.NewSearchUI;
26+
import org.eclipse.search.ui.text.AbstractTextSearchViewPage;
27+
28+
/**
29+
* Global handler for navigating to next/previous search results without
30+
* requiring focus on the Search view. Navigates directly through the active
31+
* search result page, keeping focus in the editor throughout.
32+
* <p>
33+
* Configured via the {@code :next} or {@code :previous} data suffix in
34+
* {@code plugin.xml}, e.g.:
35+
* {@code defaultHandler="...GlobalNextPrevSearchEntryHandler:previous"}
36+
* </p>
37+
*/
38+
public class GlobalNextPrevSearchEntryHandler extends AbstractHandler implements IExecutableExtension {
39+
40+
private boolean navigateNext = true;
41+
42+
@Override
43+
public Object execute(ExecutionEvent event) throws ExecutionException {
44+
ISearchResultViewPart viewPart = NewSearchUI.getSearchResultView();
45+
if (viewPart == null) {
46+
return null; // No search has been run yet
47+
}
48+
ISearchResultPage page = viewPart.getActivePage();
49+
if (page instanceof AbstractTextSearchViewPage textPage) {
50+
if (navigateNext) {
51+
textPage.gotoNextMatch();
52+
} else {
53+
textPage.gotoPreviousMatch();
54+
}
55+
}
56+
return null;
57+
}
58+
59+
@Override
60+
public void setInitializationData(IConfigurationElement config, String propertyName, Object data)
61+
throws CoreException {
62+
navigateNext = !"previous".equals(data); //$NON-NLS-1$
63+
}
64+
}

bundles/org.eclipse.search/plugin.properties

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,8 @@ textSearchQueryProvider="Text Search Query Provider"
8383

8484
match_highlight.label= Match highlight background color
8585
match_highlight.description= The background color used to highlight matches in the Search view when colored labels are enabled.
86+
87+
GlobalNextSearchEntryAction_label= Next Search Result
88+
GlobalNextSearchEntryAction_description= Navigate to the next search result from anywhere in the workbench without switching focus to the Search view
89+
GlobalPreviousSearchEntryAction_label= Previous Search Result
90+
GlobalPreviousSearchEntryAction_description= Navigate to the previous search result from anywhere in the workbench without switching focus to the Search view

bundles/org.eclipse.search/plugin.xml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@
104104
name="%command.performTextSearchFile.name"
105105
description="%command.performTextSearchFile.description"
106106
/>
107+
<command
108+
categoryId="org.eclipse.ui.category.navigate"
109+
id="org.eclipse.search.ui.globalNextSearchEntry"
110+
name="%GlobalNextSearchEntryAction_label"
111+
description="%GlobalNextSearchEntryAction_description"
112+
defaultHandler="org.eclipse.search2.internal.ui.basic.views.GlobalNextPrevSearchEntryHandler:next"
113+
/>
114+
<command
115+
categoryId="org.eclipse.ui.category.navigate"
116+
id="org.eclipse.search.ui.globalPreviousSearchEntry"
117+
name="%GlobalPreviousSearchEntryAction_label"
118+
description="%GlobalPreviousSearchEntryAction_description"
119+
defaultHandler="org.eclipse.search2.internal.ui.basic.views.GlobalNextPrevSearchEntryHandler:previous"
120+
/>
107121

108122
</extension>
109123

@@ -162,6 +176,32 @@
162176
commandId="org.eclipse.search.ui.performTextSearchWorkspace"
163177
schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"
164178
sequence="M1+M3+T"/>
179+
<key
180+
commandId="org.eclipse.search.ui.globalNextSearchEntry"
181+
contextId="org.eclipse.ui.contexts.window"
182+
schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"
183+
sequence="ALT+.">
184+
</key>
185+
<key
186+
commandId="org.eclipse.search.ui.globalNextSearchEntry"
187+
contextId="org.eclipse.ui.contexts.window"
188+
schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"
189+
platform="cocoa"
190+
sequence="M1+M3+.">
191+
</key>
192+
<key
193+
commandId="org.eclipse.search.ui.globalPreviousSearchEntry"
194+
contextId="org.eclipse.ui.contexts.window"
195+
schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"
196+
sequence="ALT+,">
197+
</key>
198+
<key
199+
commandId="org.eclipse.search.ui.globalPreviousSearchEntry"
200+
contextId="org.eclipse.ui.contexts.window"
201+
schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"
202+
platform="cocoa"
203+
sequence="M1+M3+,">
204+
</key>
165205

166206
</extension>
167207

tests/org.eclipse.search.tests/src/org/eclipse/search/tests/AllSearchTests.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323
@SelectClasses({
2424
AllFileSearchTests.class,
2525
AllSearchModelTests.class,
26-
TextSearchRegistryTest.class
26+
TextSearchRegistryTest.class,
27+
GlobalNextPrevSearchEntryHandlerTest.class,
28+
GlobalNextPrevSearchEntryHandlerIntegrationTest.class
2729
})
2830
public class AllSearchTests {
2931
// see @SelectClasses
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Eclipse Foundation and others.
3+
*
4+
* This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* which accompanies this distribution, and is available at
7+
* https://www.eclipse.org/legal/epl-2.0/
8+
*
9+
* SPDX-License-Identifier: EPL-2.0
10+
*
11+
* Contributors:
12+
* Eclipse Foundation - initial API and implementation
13+
*******************************************************************************/
14+
package org.eclipse.search.tests;
15+
16+
import static org.junit.jupiter.api.Assertions.assertEquals;
17+
import static org.junit.jupiter.api.Assertions.assertNotNull;
18+
import static org.junit.jupiter.api.Assertions.assertTrue;
19+
20+
import org.eclipse.core.commands.ExecutionEvent;
21+
import org.eclipse.core.runtime.jobs.IJobManager;
22+
import org.eclipse.core.runtime.jobs.Job;
23+
import org.eclipse.swt.widgets.Display;
24+
import org.eclipse.swt.widgets.Table;
25+
26+
import org.eclipse.jface.viewers.TableViewer;
27+
28+
import org.eclipse.ui.IWorkbenchWindow;
29+
import org.eclipse.ui.PlatformUI;
30+
31+
import org.eclipse.search.internal.ui.text.FileSearchPage;
32+
import org.eclipse.search.internal.ui.text.FileSearchQuery;
33+
import org.eclipse.search.tests.filesearch.JUnitSourceSetup;
34+
import org.eclipse.search.ui.ISearchResultViewPart;
35+
import org.eclipse.search.ui.NewSearchUI;
36+
import org.eclipse.search.ui.text.AbstractTextSearchViewPage;
37+
import org.eclipse.search.ui.text.FileTextSearchScope;
38+
import org.eclipse.search2.internal.ui.basic.views.GlobalNextPrevSearchEntryHandler;
39+
import org.junit.jupiter.api.AfterEach;
40+
import org.junit.jupiter.api.BeforeEach;
41+
import org.junit.jupiter.api.Test;
42+
import org.junit.jupiter.api.extension.RegisterExtension;
43+
44+
/**
45+
* Integration tests for {@link GlobalNextPrevSearchEntryHandler} that verify
46+
* actual navigation behaviour against a real search result in the workbench.
47+
*
48+
* <p>The navigation logic in {@link AbstractTextSearchViewPage} tracks match
49+
* position via an internal {@code fCurrentMatchIndex} field that is reset to
50+
* {@code -1} only by the JFace selection-changed listener, not by raw SWT
51+
* {@code table.setSelection()}. The tests here use the same approach as
52+
* {@code SearchResultPageTest.testTableNavigation()}: start with the viewer
53+
* selection at row 0 (leaving the internal match index at its initial value of
54+
* 0), then navigate backwards to reliably arrive at the last element, and then
55+
* navigate forwards to reliably arrive back at the first element.
56+
* </p>
57+
*/
58+
public class GlobalNextPrevSearchEntryHandlerIntegrationTest {
59+
60+
@RegisterExtension
61+
static JUnitSourceSetup fgJUnitSource = new JUnitSourceSetup();
62+
63+
private FileSearchPage fPage;
64+
private Table fTable;
65+
private GlobalNextPrevSearchEntryHandler fNextHandler;
66+
private GlobalNextPrevSearchEntryHandler fPrevHandler;
67+
68+
@BeforeEach
69+
public void setUp() throws Exception {
70+
SearchTestUtil.ensureWelcomePageClosed();
71+
72+
String[] fileNamePatterns = { "*.java" }; //$NON-NLS-1$
73+
FileTextSearchScope scope = FileTextSearchScope.newWorkspaceScope(fileNamePatterns, false);
74+
FileSearchQuery query = new FileSearchQuery("Test", false, true, scope); //$NON-NLS-1$
75+
NewSearchUI.runQueryInForeground(null, query);
76+
77+
ISearchResultViewPart viewPart = NewSearchUI.getSearchResultView();
78+
assertNotNull(viewPart, "Search result view must be open after running a query");
79+
80+
fPage = (FileSearchPage) viewPart.getActivePage();
81+
fPage.setLayout(AbstractTextSearchViewPage.FLAG_LAYOUT_FLAT);
82+
fTable = ((TableViewer) fPage.getViewer()).getTable();
83+
consumeEvents();
84+
85+
assertTrue(fTable.getItemCount() > 1,
86+
"JUnit source project must produce at least 2 results for navigation tests");
87+
88+
fNextHandler = new GlobalNextPrevSearchEntryHandler();
89+
// default is already "next" but be explicit
90+
fNextHandler.setInitializationData(null, "command", "next"); //$NON-NLS-1$ //$NON-NLS-2$
91+
92+
fPrevHandler = new GlobalNextPrevSearchEntryHandler();
93+
fPrevHandler.setInitializationData(null, "command", "previous"); //$NON-NLS-1$ //$NON-NLS-2$
94+
}
95+
96+
@AfterEach
97+
public void tearDown() {
98+
// Drain all pending UpdateUIJobs for this page so they don't fire during
99+
// subsequent tests' consumeEvents() calls and corrupt their table state.
100+
if (fPage != null) {
101+
consumeEvents();
102+
}
103+
// Close any editors opened by showCurrentMatch() to leave the workbench clean.
104+
IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow();
105+
if (window != null && window.getActivePage() != null) {
106+
window.getActivePage().closeAllEditors(false);
107+
}
108+
}
109+
110+
/**
111+
* Going backward from the initial selection (row 0, internal match index 0)
112+
* decrements the match index to -1, which causes
113+
* {@link AbstractTextSearchViewPage#gotoPreviousMatch()} to wrap around to the
114+
* last result. Then going forward exhausts the last result's matches and wraps
115+
* back to the first result.
116+
*
117+
* <p>This mirrors the logic verified by
118+
* {@code SearchResultPageTest.testTableNavigation()}, but exercises the
119+
* handlers rather than direct page calls.
120+
* </p>
121+
*/
122+
@Test
123+
public void testPreviousWrapsToLastThenNextWrapsToFirst() throws Exception {
124+
// Start at the first element (initial state after query).
125+
fTable.setSelection(0);
126+
fTable.showSelection();
127+
consumeEvents();
128+
129+
// Previous from initial match index (0) decrements to -1 → wraps to last.
130+
fPrevHandler.execute(new ExecutionEvent());
131+
consumeEvents();
132+
133+
assertEquals(fTable.getItemCount() - 1, fTable.getSelectionIndex(),
134+
"previous handler should wrap from the first result to the last");
135+
136+
// Next from the last result's final match increments beyond the end → wraps
137+
// to the first result.
138+
fNextHandler.execute(new ExecutionEvent());
139+
consumeEvents();
140+
141+
assertEquals(0, fTable.getSelectionIndex(),
142+
"next handler should wrap from the last result to the first");
143+
}
144+
145+
/**
146+
* Two independent handler instances must not share internal state. Configuring
147+
* one as "previous" must not affect one configured as "next".
148+
*/
149+
@Test
150+
public void testNextAndPreviousHandlersAreIndependent() throws Exception {
151+
// Sanity-check: the two handlers are distinct objects.
152+
assertTrue(fNextHandler != fPrevHandler,
153+
"next and previous handlers must be separate instances");
154+
155+
// Drive to last element via previous handler.
156+
fTable.setSelection(0);
157+
fTable.showSelection();
158+
consumeEvents();
159+
fPrevHandler.execute(new ExecutionEvent());
160+
consumeEvents();
161+
int lastIndex = fTable.getItemCount() - 1;
162+
assertEquals(lastIndex, fTable.getSelectionIndex(),
163+
"previous handler should reach last result");
164+
165+
// Drive back to first element via next handler.
166+
fNextHandler.execute(new ExecutionEvent());
167+
consumeEvents();
168+
assertEquals(0, fTable.getSelectionIndex(),
169+
"next handler should reach first result");
170+
}
171+
172+
/**
173+
* Drains the SWT event queue and waits for all pending {@code UpdateUIJob}s
174+
* belonging to the current page to complete. This is necessary because
175+
* {@code UpdateUIJob} can reschedule itself with a 500 ms delay; a plain
176+
* {@code Display.readAndDispatch()} loop would not wait for those deferred
177+
* runs and could leave stale async work that pollutes subsequent tests.
178+
*/
179+
private void consumeEvents() {
180+
IJobManager manager = Job.getJobManager();
181+
// Drain immediately-queued display events first.
182+
while (Display.getDefault().readAndDispatch()) {
183+
// keep dispatching
184+
}
185+
// Then wait for all UpdateUIJobs that belong to this page to finish
186+
// (they identify themselves via belongsTo(AbstractTextSearchViewPage.this)).
187+
while (fPage != null && manager.find(fPage).length > 0) {
188+
Display.getDefault().readAndDispatch();
189+
}
190+
// Final drain for any events triggered by the completed jobs.
191+
while (Display.getDefault().readAndDispatch()) {
192+
// keep dispatching
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)