// ***************************************************************************** // Copyright (C) 2018 TypeFox 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 { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { TreeWidget, CompositeTreeNode, ConfirmDialog, ContextMenuRenderer, ExpandableTreeNode, SelectableTreeNode, TreeModel, TreeNode, NodeProps, TreeProps, TreeExpansionService, ApplicationShell, DiffUris, TREE_NODE_INFO_CLASS, codicon, TopDownTreeIterator } from '@theia/core/lib/browser'; import { CancellationTokenSource, Emitter, EOL, Event, ProgressService } from '@theia/core'; import { EditorManager, EditorDecoration, TrackedRangeStickiness, OverviewRulerLane, EditorWidget, EditorOpenerOptions, FindMatch, Position } from '@theia/editor/lib/browser'; import { WorkspaceSearchFilterService, WorkspaceService } from '@theia/workspace/lib/browser'; import { FileResourceResolver } from '@theia/filesystem/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { SearchInWorkspaceResult, SearchInWorkspaceOptions, SearchMatch } from '../common/search-in-workspace-interface'; import { SearchInWorkspaceService } from './search-in-workspace-service'; import { MEMORY_TEXT } from '@theia/core/lib/common'; import URI from '@theia/core/lib/common/uri'; import * as React from '@theia/core/shared/react'; import { SearchInWorkspacePreferences } from '../common/search-in-workspace-preferences'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; import { minimatch, type MinimatchOptions } from 'minimatch'; import { DisposableCollection } from '@theia/core/lib/common/disposable'; import debounce = require('@theia/core/shared/lodash.debounce'); import { nls } from '@theia/core/lib/common/nls'; const ROOT_ID = 'ResultTree'; export interface SearchInWorkspaceRoot extends CompositeTreeNode { children: SearchInWorkspaceRootFolderNode[]; } export namespace SearchInWorkspaceRoot { export function is(node: unknown): node is SearchInWorkspaceRoot { return CompositeTreeNode.is(node) && node.id === ROOT_ID; } } export interface SearchInWorkspaceRootFolderNode extends ExpandableTreeNode, SelectableTreeNode { // root folder node name?: undefined icon?: undefined children: SearchInWorkspaceFileNode[]; parent: SearchInWorkspaceRoot; path: string; folderUri: string; uri: URI; } export namespace SearchInWorkspaceRootFolderNode { export function is(node: unknown): node is SearchInWorkspaceRootFolderNode { return ExpandableTreeNode.is(node) && SelectableTreeNode.is(node) && 'path' in node && 'folderUri' in node && !('fileUri' in node); } } export interface SearchInWorkspaceFileNode extends ExpandableTreeNode, SelectableTreeNode { // file node name?: undefined icon?: undefined children: SearchInWorkspaceResultLineNode[]; parent: SearchInWorkspaceRootFolderNode; path: string; fileUri: string; uri: URI; } export namespace SearchInWorkspaceFileNode { export function is(node: unknown): node is SearchInWorkspaceFileNode { return ExpandableTreeNode.is(node) && SelectableTreeNode.is(node) && 'path' in node && 'fileUri' in node && !('folderUri' in node); } } export interface SearchInWorkspaceResultLineNode extends SelectableTreeNode, SearchInWorkspaceResult, SearchMatch { // line node parent: SearchInWorkspaceFileNode } export namespace SearchInWorkspaceResultLineNode { export function is(node: unknown): node is SearchInWorkspaceResultLineNode { return SelectableTreeNode.is(node) && 'line' in node && 'character' in node && 'lineText' in node; } } @injectable() export class SearchInWorkspaceResultTreeWidget extends TreeWidget { protected resultTree: Map; protected _showReplaceButtons = false; protected _replaceTerm = ''; protected searchTerm = ''; protected searchOptions: SearchInWorkspaceOptions; protected readonly startSearchOnModification = (activeEditor: EditorWidget) => debounce( () => this.searchActiveEditor(activeEditor, this.searchTerm, this.searchOptions), this.searchOnEditorModificationDelay ); protected readonly searchOnEditorModificationDelay = 300; protected readonly toDisposeOnActiveEditorChanged = new DisposableCollection(); // The default root name to add external search results in the case that a workspace is opened. protected readonly defaultRootName = nls.localizeByDefault('Other files'); protected forceVisibleRootNode = false; protected appliedDecorations = new Map(); cancelIndicator?: CancellationTokenSource; protected changeEmitter = new Emitter>(); protected onExpansionChangedEmitter = new Emitter(); readonly onExpansionChanged: Event = this.onExpansionChangedEmitter.event; // eslint-disable-next-line @typescript-eslint/no-explicit-any protected focusInputEmitter = new Emitter(); @inject(SearchInWorkspaceService) protected readonly searchService: SearchInWorkspaceService; @inject(EditorManager) protected readonly editorManager: EditorManager; @inject(FileResourceResolver) protected readonly fileResourceResolver: FileResourceResolver; @inject(ApplicationShell) protected readonly shell: ApplicationShell; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(TreeExpansionService) protected readonly expansionService: TreeExpansionService; @inject(SearchInWorkspacePreferences) protected readonly searchInWorkspacePreferences: SearchInWorkspacePreferences; @inject(ProgressService) protected readonly progressService: ProgressService; @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry; @inject(WorkspaceSearchFilterService) protected readonly searchFilterService: WorkspaceSearchFilterService; @inject(FileService) protected readonly fileService: FileService; constructor( @inject(TreeProps) props: TreeProps, @inject(TreeModel) model: TreeModel, @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer ) { super(props, model, contextMenuRenderer); model.root = { id: ROOT_ID, parent: undefined, visible: false, children: [] } as SearchInWorkspaceRoot; this.toDispose.push(model.onSelectionChanged(nodes => { const node = nodes[0]; if (SearchInWorkspaceResultLineNode.is(node)) { this.doOpen(node, true, true); } })); this.toDispose.push(model.onOpenNode(node => { if (SearchInWorkspaceResultLineNode.is(node)) { this.doOpen(node, true, false); } })); this.resultTree = new Map(); this.toDispose.push(model.onNodeRefreshed(() => this.changeEmitter.fire(this.resultTree))); } @postConstruct() protected override init(): void { super.init(); this.addClass('resultContainer'); this.toDispose.push(this.changeEmitter); this.toDispose.push(this.focusInputEmitter); this.toDispose.push(this.editorManager.onActiveEditorChanged(activeEditor => { this.updateCurrentEditorDecorations(); this.toDisposeOnActiveEditorChanged.dispose(); this.toDispose.push(this.toDisposeOnActiveEditorChanged); if (activeEditor) { this.toDisposeOnActiveEditorChanged.push(activeEditor.editor.onDocumentContentChanged(() => { if (this.searchTerm !== '' && this.searchInWorkspacePreferences['search.searchOnEditorModification']) { this.startSearchOnModification(activeEditor)(); } })); } })); this.toDispose.push(this.searchInWorkspacePreferences.onPreferenceChanged(() => { this.update(); })); this.toDispose.push(this.fileService.onDidFilesChange(event => { if (event.gotDeleted()) { event.getDeleted().forEach(deletedFile => { const fileNodes = this.getFileNodesByUri(deletedFile.resource); fileNodes.forEach(node => this.removeFileNode(node)); }); this.model.refresh(); } })); this.toDispose.push(this.model.onExpansionChanged(() => { this.onExpansionChangedEmitter.fire(undefined); })); } get fileNumber(): number { let num = 0; for (const rootFolderNode of this.resultTree.values()) { num += rootFolderNode.children.length; } return num; } set showReplaceButtons(srb: boolean) { this._showReplaceButtons = srb; this.update(); } set replaceTerm(rt: string) { this._replaceTerm = rt; this.update(); } get isReplacing(): boolean { return this._replaceTerm !== '' && this._showReplaceButtons; } get onChange(): Event> { return this.changeEmitter.event; } get onFocusInput(): Event { return this.focusInputEmitter.event; } collapseAll(): void { for (const rootFolderNode of this.resultTree.values()) { for (const fileNode of rootFolderNode.children) { this.expansionService.collapseNode(fileNode); } if (rootFolderNode.visible) { this.expansionService.collapseNode(rootFolderNode); } } } expandAll(): void { for (const rootFolderNode of this.resultTree.values()) { for (const fileNode of rootFolderNode.children) { this.expansionService.expandNode(fileNode); } if (rootFolderNode.visible) { this.expansionService.expandNode(rootFolderNode); } } } areResultsCollapsed(): boolean { for (const rootFolderNode of this.resultTree.values()) { for (const fileNode of rootFolderNode.children) { if (!ExpandableTreeNode.isCollapsed(fileNode)) { return false; } } } 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. * @param widget the editor widget. * @param searchOptions the search options to apply. * * @returns the list of matches. */ protected findMatches(searchTerm: string, widget: EditorWidget, searchOptions: SearchInWorkspaceOptions): SearchMatch[] { if (!widget.editor.document.findMatches) { return []; } const results: FindMatch[] = widget.editor.document.findMatches({ searchString: searchTerm, isRegex: !!searchOptions.useRegExp, matchCase: !!searchOptions.matchCase, matchWholeWord: !!searchOptions.matchWholeWord, limitResultCount: searchOptions.maxResults }); const matches: SearchMatch[] = []; results.forEach(r => { const numberOfLines = searchTerm.split('\n').length; const lineTexts = []; for (let i = 0; i < numberOfLines; i++) { lineTexts.push(widget.editor.document.getLineContent(r.range.start.line + i)); } matches.push({ line: r.range.start.line, character: r.range.start.character, length: searchTerm.length, lineText: lineTexts.join('\n') }); }); return matches; } /** * Convert a pattern to match all directories. * @param workspaceRootUri the uri of the current workspace root. * @param pattern the pattern to be converted. */ protected convertPatternToGlob(workspaceRootUri: URI | undefined, pattern: string): string { if (pattern.startsWith('**/')) { return pattern; } if (pattern.startsWith('./')) { if (workspaceRootUri === undefined) { return pattern; } return workspaceRootUri.toString() + pattern.replace('./', '/'); } return pattern.startsWith('/') ? '**' + pattern : '**/' + pattern; } /** * Determine if the URI matches any of the patterns. * @param uri the editor URI. * @param patterns the glob patterns to verify. */ protected inPatternList(uri: URI, patterns: string[]): boolean { const opts: MinimatchOptions = { dot: true, matchBase: true }; return patterns.some(pattern => minimatch( uri.toString(), this.convertPatternToGlob(this.workspaceService.getWorkspaceRootUri(uri), pattern), opts )); } /** * Determine if the given editor satisfies the filtering criteria. * An editor should be searched only if: * - it is not excluded through the `excludes` list. * - it is not explicitly present in a non-empty `includes` list. */ protected shouldApplySearch(editorWidget: EditorWidget, searchOptions: SearchInWorkspaceOptions): boolean { const excludePatterns = this.getExcludeGlobs(searchOptions.exclude, !searchOptions.includeIgnored); if (this.inPatternList(editorWidget.editor.uri, excludePatterns)) { return false; } const includePatterns = searchOptions.include; if (!!includePatterns?.length && !this.inPatternList(editorWidget.editor.uri, includePatterns)) { return false; } return true; } /** * Search the active editor only and update the tree with those results. */ protected searchActiveEditor(activeEditor: EditorWidget, searchTerm: string, searchOptions: SearchInWorkspaceOptions): void { const includesExternalResults = () => !!this.resultTree.get(this.defaultRootName); // Check if outside workspace results are present before searching. const hasExternalResultsBefore = includesExternalResults(); // Collect search results for the given editor. const results = this.searchInEditor(activeEditor, searchTerm, searchOptions); // Update the tree by removing the result node, and add new results if applicable. this.getFileNodesByUri(activeEditor.editor.uri).forEach(fileNode => this.removeFileNode(fileNode)); if (results) { this.appendToResultTree(results); } // Check if outside workspace results are present after searching. const hasExternalResultsAfter = includesExternalResults(); // Redo a search to update the tree node visibility if: // + `Other files` node was present, now it is not. // + `Other files` node was not present, now it is. if (hasExternalResultsBefore ? !hasExternalResultsAfter : hasExternalResultsAfter) { this.search(this.searchTerm, this.searchOptions); return; } this.handleSearchCompleted(); } /** * Perform a search in all open editors. * @param searchTerm the search term. * @param searchOptions the search options to apply. * * @returns the tuple of result count, and the list of search results. */ protected searchInOpenEditors(searchTerm: string, searchOptions: SearchInWorkspaceOptions): { numberOfResults: number, matches: SearchInWorkspaceResult[] } { // Track the number of results found. let numberOfResults = 0; const searchResults: SearchInWorkspaceResult[] = []; this.editorManager.all.forEach(e => { const editorResults = this.searchInEditor(e, searchTerm, searchOptions); if (editorResults) { numberOfResults += editorResults.matches.length; searchResults.push(editorResults); } }); return { numberOfResults, matches: searchResults }; } /** * Perform a search in the target editor. * @param editorWidget the editor widget. * @param searchTerm the search term. * @param searchOptions the search options to apply. * * @returns the search results from the given editor, undefined if the editor is either filtered or has no matches found. */ protected searchInEditor(editorWidget: EditorWidget, searchTerm: string, searchOptions: SearchInWorkspaceOptions): SearchInWorkspaceResult | undefined { if (!this.shouldApplySearch(editorWidget, searchOptions)) { return undefined; } const matches: SearchMatch[] = this.findMatches(searchTerm, editorWidget, searchOptions); if (matches.length <= 0) { return undefined; } const fileUri = editorWidget.editor.uri.toString(); const root: string | undefined = this.workspaceService.getWorkspaceRootUri(editorWidget.editor.uri)?.toString(); return { root: root ?? this.defaultRootName, fileUri, matches }; } /** * Append search results to the result tree. * @param result Search result. */ protected appendToResultTree(result: SearchInWorkspaceResult): void { const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults']; let path: string; if (result.root === this.defaultRootName) { path = new URI(result.fileUri).path.dir.fsPath(); } else { path = this.filenameAndPath(result.root, result.fileUri).path; } const tree = this.resultTree; let rootFolderNode = tree.get(result.root); if (!rootFolderNode) { rootFolderNode = this.createRootFolderNode(result.root); tree.set(result.root, rootFolderNode); } let fileNode = rootFolderNode.children.find(f => f.fileUri === result.fileUri); if (!fileNode) { fileNode = this.createFileNode(result.root, path, result.fileUri, rootFolderNode); rootFolderNode.children.push(fileNode); } for (const match of result.matches) { const line = this.createResultLineNode(result, match, fileNode); if (fileNode.children.findIndex(lineNode => lineNode.id === line.id) < 0) { fileNode.children.push(line); } } this.collapseFileNode(fileNode, collapseValue); } /** * Handle when searching completed. */ protected handleSearchCompleted(cancelIndicator?: CancellationTokenSource): void { if (cancelIndicator) { cancelIndicator.cancel(); } this.sortResultTree(); this.refreshModelChildren(); } /** * Sort the result tree by URIs. */ protected sortResultTree(): void { // Sort the result map by folder URI. const entries = [...this.resultTree.entries()]; entries.sort(([, a], [, b]) => this.compare(a.folderUri, b.folderUri)); this.resultTree = new Map(entries); // Update the list of children nodes, sorting them by their file URI. entries.forEach(([, folder]) => { folder.children.sort((a, b) => this.compare(a.fileUri, b.fileUri)); }); } /** * Search and populate the result tree with matches. * @param searchTerm the search term. * @param searchOptions the search options to apply. */ async search(searchTerm: string, searchOptions: SearchInWorkspaceOptions): Promise { this.searchTerm = searchTerm; this.searchOptions = searchOptions; searchOptions = { ...searchOptions, exclude: this.getExcludeGlobs(searchOptions.exclude, !searchOptions.includeIgnored) }; this.resultTree.clear(); this.forceVisibleRootNode = false; if (this.cancelIndicator) { this.cancelIndicator.cancel(); } if (searchTerm === '') { this.refreshModelChildren(); return; } this.cancelIndicator = new CancellationTokenSource(); const cancelIndicator = this.cancelIndicator; const token = this.cancelIndicator.token; const progress = await this.progressService.showProgress({ text: `search: ${searchTerm}`, options: { location: 'search' } }); // Collect search results for opened editors which otherwise may not be found by ripgrep (ex: dirty editors). const { numberOfResults, matches } = this.searchInOpenEditors(searchTerm, searchOptions); // The root node is visible if outside workspace results are found and workspace root(s) are present. this.forceVisibleRootNode = matches.some(m => m.root === this.defaultRootName) && this.workspaceService.opened; matches.forEach(m => this.appendToResultTree(m)); // Exclude files already covered by searching open editors. this.editorManager.all.forEach(e => { const excludePath: string = e.editor.uri.path.toString(); searchOptions.exclude = searchOptions.exclude ? searchOptions.exclude.concat(excludePath) : [excludePath]; }); // Reduce `maxResults` due to editor results. if (searchOptions.maxResults) { searchOptions.maxResults -= numberOfResults; } let pendingRefreshTimeout: number | undefined; const searchId = await this.searchService.search(searchTerm, { onResult: (aSearchId: number, result: SearchInWorkspaceResult) => { if (token.isCancellationRequested || aSearchId !== searchId) { return; } this.appendToResultTree(result); if (pendingRefreshTimeout) { clearTimeout(pendingRefreshTimeout); } // convert type as we are in browser context pendingRefreshTimeout = setTimeout(() => this.refreshModelChildren(), 100) as unknown as number; }, onDone: () => { this.handleSearchCompleted(cancelIndicator); } }, searchOptions).catch(() => { this.handleSearchCompleted(cancelIndicator); }); token.onCancellationRequested(() => { progress.cancel(); if (typeof searchId === 'number') { this.searchService.cancel(searchId); } this.cancelIndicator = undefined; this.changeEmitter.fire(this.resultTree); }); } focusFirstResult(): void { if (SearchInWorkspaceRoot.is(this.model.root) && this.model.root.children.length > 0) { const node = this.model.root.children[0]; if (SelectableTreeNode.is(node)) { this.node.focus(); this.model.selectNode(node); } } } /** * Collapse the search-in-workspace file node * based on the preference value. */ protected collapseFileNode(node: SearchInWorkspaceFileNode, preferenceValue: string): void { if (preferenceValue === 'auto' && node.children.length >= 10) { node.expanded = false; } else if (preferenceValue === 'alwaysCollapse') { node.expanded = false; } else if (preferenceValue === 'alwaysExpand') { node.expanded = true; } } protected override handleUp(event: KeyboardEvent): void { if (!this.model.getPrevSelectableNode(this.model.getFocusedNode())) { this.focusInputEmitter.fire(true); } else { super.handleUp(event); } } protected async refreshModelChildren(): Promise { if (SearchInWorkspaceRoot.is(this.model.root)) { this.model.root.children = Array.from(this.resultTree.values()); this.model.refresh(); this.updateCurrentEditorDecorations(); } } protected updateCurrentEditorDecorations(): void { this.shell.allTabBars.forEach(tb => { const currentTitle = tb.currentTitle; if (currentTitle && currentTitle.owner instanceof EditorWidget) { const widget = currentTitle.owner; const fileNodes = this.getFileNodesByUri(widget.editor.uri); if (fileNodes.length > 0) { fileNodes.forEach(node => { this.decorateEditor(node, widget); }); } else { this.decorateEditor(undefined, widget); } } }); const currentWidget = this.editorManager.currentEditor; if (currentWidget) { const fileNodes = this.getFileNodesByUri(currentWidget.editor.uri); fileNodes.forEach(node => { this.decorateEditor(node, currentWidget); }); } } protected createRootFolderNode(rootUri: string): SearchInWorkspaceRootFolderNode { const uri = new URI(rootUri); return { selected: false, path: uri.path.fsPath(), folderUri: rootUri, uri: new URI(rootUri), children: [], expanded: true, id: rootUri, parent: this.model.root as SearchInWorkspaceRoot, visible: this.forceVisibleRootNode || this.workspaceService.isMultiRootWorkspaceOpened }; } protected createFileNode(rootUri: string, path: string, fileUri: string, parent: SearchInWorkspaceRootFolderNode): SearchInWorkspaceFileNode { return { selected: false, path, children: [], expanded: true, id: `${rootUri}::${fileUri}`, parent, fileUri, uri: new URI(fileUri), }; } protected createResultLineNode(result: SearchInWorkspaceResult, match: SearchMatch, fileNode: SearchInWorkspaceFileNode): SearchInWorkspaceResultLineNode { return { ...result, ...match, selected: false, id: result.fileUri + '-' + match.line + '-' + match.character + '-' + match.length, name: typeof match.lineText === 'string' ? match.lineText : match.lineText.text, parent: fileNode }; } protected getFileNodesByUri(uri: URI): SearchInWorkspaceFileNode[] { const nodes: SearchInWorkspaceFileNode[] = []; const fileUri = uri.withScheme('file').toString(); for (const rootFolderNode of this.resultTree.values()) { const rootUri = new URI(rootFolderNode.path).withScheme('file'); if (rootUri.isEqualOrParent(uri) || rootFolderNode.id === this.defaultRootName) { for (const fileNode of rootFolderNode.children) { if (fileNode.fileUri === fileUri) { nodes.push(fileNode); } } } } return nodes; } protected filenameAndPath(rootUriStr: string, uriStr: string): { name: string, path: string } { const uri: URI = new URI(uriStr); const relativePath = new URI(rootUriStr).relative(uri.parent); return { name: this.labelProvider.getName(uri), path: relativePath ? relativePath.fsPath() : '' }; } protected override getDepthPadding(depth: number): number { return super.getDepthPadding(depth) + 5; } protected override renderCaption(node: TreeNode, props: NodeProps): React.ReactNode { if (SearchInWorkspaceRootFolderNode.is(node)) { return this.renderRootFolderNode(node); } else if (SearchInWorkspaceFileNode.is(node)) { return this.renderFileNode(node); } else if (SearchInWorkspaceResultLineNode.is(node)) { return this.renderResultLineNode(node); } return ''; } protected override renderTailDecorations(node: TreeNode, props: NodeProps): React.ReactNode { return
{this._showReplaceButtons && this.renderReplaceButton(node)} {this.renderRemoveButton(node)}
; } protected doReplace(node: TreeNode, e: React.MouseEvent): void { const selection = SelectableTreeNode.isSelected(node) ? (this.selectionService.selection as SelectableTreeNode[]) : [node]; selection.forEach(n => this.replace(n)); e.stopPropagation(); } protected renderReplaceButton(node: TreeNode): React.ReactNode { const isResultLineNode = SearchInWorkspaceResultLineNode.is(node); return this.doReplace(node, e)} title={isResultLineNode ? nls.localizeByDefault('Replace') : nls.localizeByDefault('Replace All') }>; } protected getFileCount(node: TreeNode): number { if (SearchInWorkspaceRoot.is(node)) { return node.children.reduce((acc, current) => acc + this.getFileCount(current), 0); } else if (SearchInWorkspaceRootFolderNode.is(node)) { return node.children.length; } else if (SearchInWorkspaceFileNode.is(node)) { return 1; } return 0; } protected getResultCount(node: TreeNode): number { if (SearchInWorkspaceRoot.is(node)) { return node.children.reduce((acc, current) => acc + this.getResultCount(current), 0); } else if (SearchInWorkspaceRootFolderNode.is(node)) { return node.children.reduce((acc, current) => acc + this.getResultCount(current), 0); } else if (SearchInWorkspaceFileNode.is(node)) { return node.children.length; } else if (SearchInWorkspaceResultLineNode.is(node)) { return 1; } return 0; } /** * Replace results under the node passed into the function. If node is undefined, replace all results. * @param node Node in the tree widget where the "replace all" operation is performed */ async replace(node: TreeNode | undefined): Promise { const replaceForNode = node || this.model.root!; const needConfirm = !SearchInWorkspaceFileNode.is(node) && !SearchInWorkspaceResultLineNode.is(node); const replacementText = this._replaceTerm; if (!needConfirm || await this.confirmReplaceAll(this.getResultCount(replaceForNode), this.getFileCount(replaceForNode), replacementText)) { (node ? [node] : Array.from(this.resultTree.values())).forEach(n => { this.replaceResult(n, !!node, replacementText); this.removeNode(n); }); } } protected confirmReplaceAll(resultNumber: number, fileNumber: number, replacementText: string): Promise { return new ConfirmDialog({ title: nls.localizeByDefault('Replace All'), msg: this.buildReplaceAllConfirmationMessage(resultNumber, fileNumber, replacementText) }).open(); } protected buildReplaceAllConfirmationMessage(occurrences: number, fileCount: number, replaceValue: string): string { if (occurrences === 1) { if (fileCount === 1) { if (replaceValue) { return nls.localizeByDefault( "Replace {0} occurrence across {1} file with '{2}'?", occurrences, fileCount, replaceValue); } return nls.localizeByDefault( 'Replace {0} occurrence across {1} file?', occurrences, fileCount); } if (replaceValue) { return nls.localizeByDefault( "Replace {0} occurrence across {1} files with '{2}'?", occurrences, fileCount, replaceValue); } return nls.localizeByDefault( 'Replace {0} occurrence across {1} files?', occurrences, fileCount); } if (fileCount === 1) { if (replaceValue) { return nls.localizeByDefault( "Replace {0} occurrences across {1} file with '{2}'?", occurrences, fileCount, replaceValue); } return nls.localizeByDefault( 'Replace {0} occurrences across {1} file?', occurrences, fileCount); } if (replaceValue) { return nls.localizeByDefault( "Replace {0} occurrences across {1} files with '{2}'?", occurrences, fileCount, replaceValue); } return nls.localizeByDefault( 'Replace {0} occurrences across {1} files?', occurrences, fileCount); } protected updateRightResults(node: SearchInWorkspaceResultLineNode): void { const fileNode = node.parent; const rightPositionedNodes = fileNode.children.filter(rl => rl.line === node.line && rl.character > node.character); const diff = this._replaceTerm.length - this.searchTerm.length; rightPositionedNodes.forEach(r => r.character += diff); } /** * Replace text either in all search matches under a node or in all search matches, and save the changes. * @param node - node in the tree widget in which the "replace all" is performed. * @param {boolean} replaceOne - whether the function is to replace all matches under a node. If it is false, replace all. * @param replacementText - text to be used for all replacements in the current replacement cycle. */ protected async replaceResult(node: TreeNode, replaceOne: boolean, replacementText: string): Promise { const toReplace: SearchInWorkspaceResultLineNode[] = []; if (SearchInWorkspaceRootFolderNode.is(node)) { node.children.forEach(fileNode => this.replaceResult(fileNode, replaceOne, replacementText)); } else if (SearchInWorkspaceFileNode.is(node)) { toReplace.push(...node.children); } else if (SearchInWorkspaceResultLineNode.is(node)) { toReplace.push(node); this.updateRightResults(node); } if (toReplace.length > 0) { // Store the state of all tracked editors before another editor widget might be created for text replacing. const trackedEditors: EditorWidget[] = this.editorManager.all; // Open the file only if the function is called to replace all matches under a specific node. const widget: EditorWidget = replaceOne ? await this.doOpen(toReplace[0]) : await this.doGetWidget(toReplace[0]); const source: string = widget.editor.document.getText(); const replaceOperations = toReplace.map(resultLineNode => ({ text: replacementText, range: { start: { line: resultLineNode.line - 1, character: resultLineNode.character - 1 }, end: this.findEndCharacterPosition(resultLineNode), } })); // Replace the text. await widget.editor.replaceText({ source, replaceOperations }); // Save the text replacement changes in the editor. await widget.saveable.save(); // Dispose the widget if it is not opened but created for `replaceAll`. if (!replaceOne) { if (trackedEditors.indexOf(widget) === -1) { widget.dispose(); } } } } protected readonly remove = (node: TreeNode, e: React.MouseEvent) => this.doRemove(node, e); protected doRemove(node: TreeNode, e: React.MouseEvent): void { const selection = SelectableTreeNode.isSelected(node) ? (this.selectionService.selection as SelectableTreeNode[]) : [node]; selection.forEach(n => this.removeNode(n)); e.stopPropagation(); } protected renderRemoveButton(node: TreeNode): React.ReactNode { return this.remove(node, e)} title='Dismiss'>; } removeNode(node: TreeNode): void { if (SearchInWorkspaceRootFolderNode.is(node)) { this.removeRootFolderNode(node); } else if (SearchInWorkspaceFileNode.is(node)) { this.removeFileNode(node); } else if (SearchInWorkspaceResultLineNode.is(node)) { this.removeResultLineNode(node); } this.refreshModelChildren(); } private removeRootFolderNode(node: SearchInWorkspaceRootFolderNode): void { for (const rootUri of this.resultTree.keys()) { if (rootUri === node.folderUri) { this.resultTree.delete(rootUri); break; } } } private removeFileNode(node: SearchInWorkspaceFileNode): void { const rootFolderNode = node.parent; const index = rootFolderNode.children.findIndex(fileNode => fileNode.id === node.id); if (index > -1) { rootFolderNode.children.splice(index, 1); } if (this.getFileCount(rootFolderNode) === 0) { this.removeRootFolderNode(rootFolderNode); } } private removeResultLineNode(node: SearchInWorkspaceResultLineNode): void { const fileNode = node.parent; const index = fileNode.children.findIndex(n => n.fileUri === node.fileUri && n.line === node.line && n.character === node.character); if (index > -1) { fileNode.children.splice(index, 1); if (this.getResultCount(fileNode) === 0) { this.removeFileNode(fileNode); } } } private findEndCharacterPosition(node: SearchInWorkspaceResultLineNode): Position { const lineText = typeof node.lineText === 'string' ? node.lineText : node.lineText.text; const lines = lineText.split('\n'); const line = node.line + lines.length - 2; let character = node.character - 1 + node.length; if (lines.length > 1) { character = node.length - lines[0].length + node.character - lines.length; if (lines.length > 2) { for (const lineNum of Array(lines.length - 2).keys()) { character -= lines[lineNum + 1].length; } } } return { line, character }; } protected renderRootFolderNode(node: SearchInWorkspaceRootFolderNode): React.ReactNode { const isRoot = node.path === '/' || node.path === `/${this.defaultRootName}`; const name = this.toNodeName(node); return
{name} {!isRoot && {node.path} }
{this.getFileCount(node)}
; } protected renderFileNode(node: SearchInWorkspaceFileNode): React.ReactNode { return
{this.toNodeName(node)} {node.path}
{this.getResultCount(node)}
; } protected renderResultLineNode(node: SearchInWorkspaceResultLineNode): React.ReactNode { const character = typeof node.lineText === 'string' ? node.character : node.lineText.character; const lineText = typeof node.lineText === 'string' ? node.lineText : node.lineText.text; let start = Math.max(0, character - 26); const wordBreak = /\b/g; while (start > 0 && wordBreak.test(lineText) && wordBreak.lastIndex < character) { if (character - wordBreak.lastIndex < 26) { break; } start = wordBreak.lastIndex; wordBreak.lastIndex++; } const before = lineText.slice(start, character - 1).trimStart(); const lineCount = lineText.split('\n').length; return <>
{this.searchInWorkspacePreferences['search.lineNumbers'] && {node.line}} {before} {this.renderMatchLinePart(node)} {lineCount > 1 || {lineText.slice(node.character + node.length - 1, 250 - before.length + node.length)} }
{lineCount > 1 &&
+{lineCount - 1}
} ; } protected renderMatchLinePart(node: SearchInWorkspaceResultLineNode): React.ReactNode { const replaceTermLines = this._replaceTerm.split('\n'); const replaceTerm = this.isReplacing ? {replaceTermLines[0]} : ''; const className = `match${this.isReplacing ? ' strike-through' : ''}`; const text = typeof node.lineText === 'string' ? node.lineText : node.lineText.text; const match = text.substring(node.character - 1, node.character + node.length - 1); const matchLines = match.split('\n'); return {matchLines[0]} {replaceTerm} ; } /** * Get the editor widget by the node. * @param {SearchInWorkspaceResultLineNode} node - the node representing a match in the search results. * @returns The editor widget to which the text replace will be done. */ protected async doGetWidget(node: SearchInWorkspaceResultLineNode): Promise { const fileUri = new URI(node.fileUri); const editorWidget = await this.editorManager.getOrCreateByUri(fileUri); return editorWidget; } protected async doOpen(node: SearchInWorkspaceResultLineNode, asDiffWidget = false, preview = false): Promise { let fileUri: URI; const resultNode = node.parent; if (resultNode && this.isReplacing && asDiffWidget) { const leftUri = new URI(node.fileUri); const rightUri = await this.createReplacePreview(resultNode); fileUri = DiffUris.encode(leftUri, rightUri); } else { fileUri = new URI(node.fileUri); } const opts: EditorOpenerOptions = { selection: { start: { line: node.line - 1, character: node.character - 1 }, end: this.findEndCharacterPosition(node), }, mode: preview ? 'reveal' : 'activate', preview, }; const editorWidget = await this.editorManager.open(fileUri, opts); if (!DiffUris.isDiffUri(fileUri)) { this.decorateEditor(resultNode, editorWidget); } return editorWidget; } protected async createReplacePreview(node: SearchInWorkspaceFileNode): Promise { const fileUri = new URI(node.fileUri).withScheme('file'); const openedEditor = this.editorManager.all.find(({ editor }) => editor.uri.toString() === fileUri.toString()); let content: string; if (openedEditor) { content = openedEditor.editor.document.getText(); } else { const resource = await this.fileResourceResolver.resolve(fileUri); content = await resource.readContents(); } const searchTermRegExp = new RegExp(this.searchTerm, 'g'); return fileUri.withScheme(MEMORY_TEXT).withQuery(content.replace(searchTermRegExp, this._replaceTerm)); } protected decorateEditor(node: SearchInWorkspaceFileNode | undefined, editorWidget: EditorWidget): void { if (!DiffUris.isDiffUri(editorWidget.editor.uri)) { const key = `${editorWidget.editor.uri.toString()}#search-in-workspace-matches`; const oldDecorations = this.appliedDecorations.get(key) || []; const newDecorations = this.createEditorDecorations(node); const appliedDecorations = editorWidget.editor.deltaDecorations({ newDecorations, oldDecorations, }); this.appliedDecorations.set(key, appliedDecorations); } } protected createEditorDecorations(resultNode: SearchInWorkspaceFileNode | undefined): EditorDecoration[] { const decorations: EditorDecoration[] = []; if (resultNode) { resultNode.children.forEach(res => { decorations.push({ range: { start: { line: res.line - 1, character: res.character - 1 }, end: { line: res.line - 1, character: res.character - 1 + res.length } }, options: { overviewRuler: { color: { id: 'editor.findMatchHighlightBackground' }, position: OverviewRulerLane.Center }, className: res.selected ? 'current-search-in-workspace-editor-match' : 'search-in-workspace-editor-match', stickiness: TrackedRangeStickiness.GrowsOnlyWhenTypingBefore } }); }); } return decorations; } /** * Get the list of exclude globs. * @param excludeOptions the exclude search option. * * @returns the list of exclude globs. */ protected getExcludeGlobs(excludeOptions?: string[], useExcludeSettings = true): string[] { if (!useExcludeSettings) { return excludeOptions || []; } const exclusionGlobs = this.searchFilterService.getExclusionGlobs(); return [...new Set([...exclusionGlobs, ...excludeOptions || []])]; } /** * Compare two normalized strings. * * @param a {string} the first string. * @param b {string} the second string. */ private compare(a: string, b: string): number { const itemA: string = a.toLowerCase().trim(); const itemB: string = b.toLowerCase().trim(); return itemA.localeCompare(itemB); } /** * @param recursive if true, all child nodes will be included in the stringified result. */ nodeToString(node: TreeNode, recursive: boolean): string { if (SearchInWorkspaceFileNode.is(node) || SearchInWorkspaceRootFolderNode.is(node)) { if (recursive) { return this.nodeIteratorToString(new TopDownTreeIterator(node, { pruneSiblings: true })); } return this.labelProvider.getLongName(node.uri); } if (SearchInWorkspaceResultLineNode.is(node)) { return ` ${node.line}:${node.character}: ${node.lineText}`; } return ''; } treeToString(): string { return this.nodeIteratorToString(this.getVisibleNodes()); } protected *getVisibleNodes(): IterableIterator { for (const { node } of this.rows.values()) { yield node; } } protected nodeIteratorToString(nodes: Iterable): string { const strings = []; for (const node of nodes) { const string = this.nodeToString(node, false); if (string.length !== 0) { strings.push(string); } } return strings.join(EOL); } } export namespace SearchInWorkspaceResultTreeWidget { export namespace Menus { export const BASE = ['siw-tree-context-menu']; /** Dismiss command, or others that only affect the widget itself */ export const INTERNAL = [...BASE, '1_internal']; /** Copy a stringified representation of content */ export const COPY = [...BASE, '2_copy']; /** Commands that lead out of the widget, like revealing a file in the navigator */ export const EXTERNAL = [...BASE, '3_external']; } }