diff --git a/packages/core/src/browser/tree/test/mock-selectable-tree-model.ts b/packages/core/src/browser/tree/test/mock-selectable-tree-model.ts new file mode 100644 index 0000000000000..7a6c263c14096 --- /dev/null +++ b/packages/core/src/browser/tree/test/mock-selectable-tree-model.ts @@ -0,0 +1,109 @@ +// ***************************************************************************** +// Copyright (C) 2023 Ericsson and others. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { CompositeTreeNode } from '../tree'; +import { SelectableTreeNode } from '../tree-selection'; +import { ExpandableTreeNode } from '../tree-expansion'; + +export namespace MockSelectableTreeModel { + + export interface SelectableNode { + readonly id: string; + readonly selected: boolean; + readonly focused?: boolean; + readonly children?: SelectableNode[]; + } + + export namespace SelectableNode { + export function toTreeNode(root: SelectableNode, parent?: SelectableTreeNode & CompositeTreeNode): SelectableTreeNode { + const { id } = root; + const name = id; + const selected = false; + const focus = false; + const expanded = true; + const node: CompositeTreeNode & SelectableTreeNode = { + id, + name, + selected, + focus, + parent: parent, + children: [] + }; + const children = (root.children || []).map(child => SelectableNode.toTreeNode(child, node)); + if (children.length === 0) { + return node; + } else { + node.children = children; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (node as any).expanded = expanded; + return node as CompositeTreeNode & SelectableTreeNode & ExpandableTreeNode; + } + } + } + + export const HIERARCHICAL_MOCK_ROOT = () => SelectableNode.toTreeNode({ + 'id': '1', + 'selected': false, + 'children': [ + { + 'id': '1.1', + 'selected': false, + 'children': [ + { + 'id': '1.1.1', + 'selected': false, + }, + { + 'id': '1.1.2', + 'selected': false, + } + ] + }, + { + 'id': '1.2', + 'selected': false, + 'children': [ + { + 'id': '1.2.1', + 'selected': false, + 'children': [ + { + 'id': '1.2.1.1', + 'selected': false, + }, + { + 'id': '1.2.1.2', + 'selected': false, + } + ] + }, + { + 'id': '1.2.2', + 'selected': false, + }, + { + 'id': '1.2.3', + 'selected': false, + } + ] + }, + { + 'id': '1.3', + 'selected': false, + } + ] + }); +} diff --git a/packages/core/src/browser/tree/tree-model.ts b/packages/core/src/browser/tree/tree-model.ts index da808e5b2f0f7..621617e73aa33 100644 --- a/packages/core/src/browser/tree/tree-model.ts +++ b/packages/core/src/browser/tree/tree-model.ts @@ -99,21 +99,41 @@ export interface TreeModel extends Tree, TreeSelectionService, TreeExpansionServ */ navigateBackward(): Promise; + /** + * Selects the previous tree node, regardless of its selection or visibility state. + */ + selectPrev(): void; + /** * Selects the previous node relatively to the currently selected one. This method takes the expansion state of the tree into consideration. */ selectPrevNode(type?: TreeSelection.SelectionType): void; + /** + * Returns the previous tree node, regardless of its selection or visibility state. + */ + getPrevNode(node?: TreeNode): TreeNode | undefined; + /** * Returns the previous selectable tree node. */ getPrevSelectableNode(node?: TreeNode): SelectableTreeNode | undefined; + /** + * Selects the next tree node, regardless of its selection or visibility state. + */ + selectNext(): void; + /** * Selects the next node relatively to the currently selected one. This method takes the expansion state of the tree into consideration. */ selectNextNode(type?: TreeSelection.SelectionType): void; + /** + * Returns the next tree node, regardless of its selection or visibility state. + */ + getNextNode(node?: TreeNode): TreeNode | undefined; + /** * Returns the next selectable tree node. */ @@ -294,6 +314,11 @@ export class TreeModelImpl implements TreeModel, SelectionProvider(iterator: TreeIterator, criterion: (node: TreeNode) => node is T): T | undefined { // Skip the first item. // TODO: clean this up, and skip the first item in a different way without loading everything. iterator.next(); @@ -338,6 +390,17 @@ export class TreeModelImpl implements TreeModel, SelectionProvider { + let model: TreeModel; + function assertNodeRetrieval(method: () => TreeNode | undefined, sequence: string[]): void { + for (const expectedNodeId of sequence) { + const actualNode = method(); + const expectedNode = retrieveNode(expectedNodeId); + expect(actualNode?.id).to.be.equal(expectedNode.id); + model.addSelection(expectedNode); + } + } + function assertNodeSelection(method: () => void, sequence: string[]): void { + for (const expectedNodeId of sequence) { + method(); + const node = retrieveNode(expectedNodeId); + expect(node.selected).to.be.true; + } + } + describe('Get and Set Next Nodes Methods', () => { + const uncollapsedSelectionOrder = ['1.1', '1.1.1', '1.1.2', '1.2', '1.2.1', '1.2.1.1', '1.2.1.2', '1.2.2', '1.2.3', '1.3']; + const collapsedSelectionOrder = ['1.1', '1.2', '1.2.1', '1.2.2', '1.2.3', '1.3']; + beforeEach(() => { + model = createTreeModel(); + model.root = MockSelectableTreeModel.HIERARCHICAL_MOCK_ROOT(); + model.addSelection(retrieveNode('1')); + + }); + it('`getNextNode()` should select each node in sequence (uncollapsed)', done => { + assertNodeRetrieval(model.getNextNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`getNextNode()` should select each node in sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeRetrieval(model.getNextNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`getNextSelectableNode()` should select each node in sequence (uncollapsed)', done => { + assertNodeRetrieval(model.getNextSelectableNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`getNextSelectableNode()` should select each node in sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeRetrieval(model.getNextSelectableNode.bind(model), collapsedSelectionOrder); + done(); + }); + it('`selectNext()` should select each node in sequence (uncollapsed)', done => { + assertNodeSelection(model.selectNext.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`selectNext()` should select each node in sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeSelection(model.selectNext.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`selectNextNode()` should select each node in sequence (uncollapsed)', done => { + assertNodeSelection(model.selectNextNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`selectNextNode()` should select each node in sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeSelection(model.selectNextNode.bind(model), collapsedSelectionOrder); + done(); + }); + }); + + describe('Get and Set Previous Nodes Methods', () => { + const uncollapsedSelectionOrder = ['1.2.3', '1.2.2', '1.2.1.2', '1.2.1.1', '1.2.1', '1.2', '1.1.2', '1.1.1', '1.1']; + const collapsedSelectionOrder = ['1.2.3', '1.2.2', '1.2.1', '1.2', '1.1']; + beforeEach(() => { + model = createTreeModel(); + model.root = MockSelectableTreeModel.HIERARCHICAL_MOCK_ROOT(); + model.addSelection(retrieveNode('1.3')); + }); + it('`getPrevNode()` should select each node in reverse sequence (uncollapsed)', done => { + assertNodeRetrieval(model.getPrevNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`getPrevNode()` should select each node in reverse sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeRetrieval(model.getPrevNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`getPrevSelectableNode()` should select each node in reverse sequence (uncollapsed)', done => { + assertNodeRetrieval(model.getPrevSelectableNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`getPrevSelectableNode()` should select each node in reverse sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeRetrieval(model.getPrevSelectableNode.bind(model), collapsedSelectionOrder); + done(); + }); + it('`selectPrev()` should select each node in reverse sequence (uncollapsed)', done => { + assertNodeSelection(model.selectPrev.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`selectPrev()` should select each node in reverse sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeSelection(model.selectPrev.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`selectPrevNode()` should select each node in reverse sequence (uncollapsed)', done => { + assertNodeSelection(model.selectPrevNode.bind(model), uncollapsedSelectionOrder); + done(); + }); + it('`selectPrevNode()` should select each node in reverse sequence (collapsed)', done => { + collapseNode('1.1', '1.2.1'); + assertNodeSelection(model.selectPrevNode.bind(model), collapsedSelectionOrder); + done(); + }); + }); + + const findNode = (id: string) => model.getNode(id); + function createTreeModel(): TreeModel { + const container = createTreeTestContainer(); + return container.get(TreeModel); + } + function retrieveNode(id: string): Readonly { + const readonlyNode: Readonly = model.getNode(id) as T; + return readonlyNode; + } + function collapseNode(...ids: string[]): void { + ids.map(findNode).filter(notEmpty).filter(ExpandableTreeNode.is).forEach(node => { + model.collapseNode(node); + expect(node).to.have.property('expanded', false); + }); + } + +}); diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts b/packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts index 754c2020e434f..19f041953eb22 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts +++ b/packages/search-in-workspace/src/browser/search-in-workspace-frontend-contribution.ts @@ -57,6 +57,16 @@ export namespace SearchInWorkspaceCommands { category: SEARCH_CATEGORY, label: 'Find in Folder...' }); + export const FOCUS_NEXT_RESULT = Command.toDefaultLocalizedCommand({ + id: 'search.action.focusNextSearchResult', + category: SEARCH_CATEGORY, + label: 'Focus Next Search Result' + }); + export const FOCUS_PREV_RESULT = Command.toDefaultLocalizedCommand({ + id: 'search.action.focusPreviousSearchResult', + category: SEARCH_CATEGORY, + label: 'Focus Previous Search Result' + }); export const REFRESH_RESULTS = Command.toDefaultLocalizedCommand({ id: 'search-in-workspace.refresh', category: SEARCH_CATEGORY, @@ -169,6 +179,22 @@ export class SearchInWorkspaceFrontendContribution extends AbstractViewContribut } }); + commands.registerCommand(SearchInWorkspaceCommands.FOCUS_NEXT_RESULT, { + isEnabled: () => this.withWidget(undefined, widget => widget.hasResultList()), + execute: async () => { + const widget = await this.openView({ reveal: true }); + widget.resultTreeWidget.selectNextResult(); + } + }); + + commands.registerCommand(SearchInWorkspaceCommands.FOCUS_PREV_RESULT, { + isEnabled: () => this.withWidget(undefined, widget => widget.hasResultList()), + execute: async () => { + const widget = await this.openView({ reveal: true }); + widget.resultTreeWidget.selectPreviousResult(); + } + }); + commands.registerCommand(SearchInWorkspaceCommands.FIND_IN_FOLDER, this.newMultiUriAwareCommandHandler({ execute: async uris => { const resources: string[] = []; @@ -343,6 +369,16 @@ export class SearchInWorkspaceFrontendContribution extends AbstractViewContribut keybinding: 'shift+alt+f', when: 'explorerResourceIsFolder' }); + keybindings.registerKeybinding({ + command: SearchInWorkspaceCommands.FOCUS_NEXT_RESULT.id, + keybinding: 'f4', + when: 'hasSearchResult' + }); + keybindings.registerKeybinding({ + command: SearchInWorkspaceCommands.FOCUS_PREV_RESULT.id, + keybinding: 'shift+f4', + when: 'hasSearchResult' + }); keybindings.registerKeybinding({ command: SearchInWorkspaceCommands.DISMISS_RESULT.id, keybinding: isOSX ? 'cmd+backspace' : 'del', diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx index b2e4bcda5d117..a89262dbbdcb5 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx +++ b/packages/search-in-workspace/src/browser/search-in-workspace-result-tree-widget.tsx @@ -281,6 +281,79 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { return true; } + selectNextResult(): void { + if (!this.model.getFocusedNode()) { + return this.selectFirstResult(); + } + let foundNextResult = false; + while (!foundNextResult) { + const nextNode = this.model.getNextNode(); + if (!nextNode) { + return this.selectFirstResult(); + } else if (SearchInWorkspaceResultLineNode.is(nextNode)) { + foundNextResult = true; + this.selectExpandOpenResultNode(nextNode); + } else { + this.model.selectNext(); + } + } + } + + selectPreviousResult(): void { + if (!this.model.getFocusedNode()) { + return this.selectLastResult(); + } + let foundSelectedNode = false; + while (!foundSelectedNode) { + const prevNode = this.model.getPrevNode(); + if (!prevNode) { + return this.selectLastResult(); + } else if (SearchInWorkspaceResultLineNode.is(prevNode)) { + foundSelectedNode = true; + this.selectExpandOpenResultNode(prevNode); + } else if (prevNode.id === 'ResultTree') { + return this.selectLastResult(); + } else { + this.model.selectPrev(); + } + } + } + + protected selectExpandOpenResultNode(node: SearchInWorkspaceResultLineNode): void { + this.model.expandNode(node.parent.parent); + this.model.expandNode(node.parent); + this.model.selectNode(node); + this.model.openNode(node); + } + + protected selectFirstResult(): void { + for (const rootFolder of this.resultTree.values()) { + for (const file of rootFolder.children) { + for (const result of file.children) { + if (SelectableTreeNode.is(result)) { + return this.selectExpandOpenResultNode(result); + } + } + } + } + } + + protected selectLastResult(): void { + const rootFolders = Array.from(this.resultTree.values()); + for (let i = rootFolders.length - 1; i >= 0; i--) { + const rootFolder = rootFolders[i]; + for (let j = rootFolder.children.length - 1; j >= 0; j--) { + const file = rootFolder.children[j]; + for (let k = file.children.length - 1; k >= 0; k--) { + const result = file.children[k]; + if (SelectableTreeNode.is(result)) { + return this.selectExpandOpenResultNode(result); + } + } + } + } + } + /** * Find matches for the given editor. * @param searchTerm the search term.