diff --git a/packages/search-in-workspace/package.json b/packages/search-in-workspace/package.json index 0f1cecf479610..a6656e42ace04 100644 --- a/packages/search-in-workspace/package.json +++ b/packages/search-in-workspace/package.json @@ -9,6 +9,7 @@ "@theia/navigator": "^0.8.0", "@theia/process": "^0.8.0", "@theia/workspace": "^0.8.0", + "minimatch": "^3.0.4", "vscode-ripgrep": "^1.2.4" }, "publishConfig": { 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 e5e0ef2f3a1aa..79774036ced0d 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 @@ -39,10 +39,11 @@ import { WorkspaceService } from '@theia/workspace/lib/browser'; import { FileResourceResolver } from '@theia/filesystem/lib/browser'; import { SearchInWorkspaceResult, SearchInWorkspaceOptions } from '../common/search-in-workspace-interface'; import { SearchInWorkspaceService } from './search-in-workspace-service'; +import { SearchInWorkspacePreferences } from './search-in-workspace-preferences'; import { MEMORY_TEXT } from './in-memory-text-resource'; import URI from '@theia/core/lib/common/uri'; +import * as minimatch from 'minimatch'; import * as React from 'react'; -import { SearchInWorkspacePreferences } from './search-in-workspace-preferences'; const ROOT_ID = 'ResultTree'; @@ -100,6 +101,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { protected _showReplaceButtons = false; protected _replaceTerm = ''; protected searchTerm = ''; + protected dirtyFileUris = new Set(); protected appliedDecorations = new Map(); @@ -196,9 +198,164 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { }); } + /** + * Returns the root folder URI that a file belongs to. + * In the case that a file belongs to more than one root folders, returns the root folder closest to the file. + * If the file is not from the current workspace, returns an empty URI. + * @param {string} filePath - path of the file. + * @param {stirng[]} rootUris - URIs of the root folders in the workspace + * @returns URI of the root folder. + */ + getRoot(filePath: string, rootUris: string[]): URI { + const roots = rootUris.filter(root => new URI(root).withScheme('file').isEqualOrParent(new URI(filePath).withScheme('file'))); + if (roots.length > 0) { + return new URI(roots.sort((r1, r2) => r2.length - r1.length)[0]); + } + return new URI(); + } + + /** + * Returns all matches in a dirty file. + */ + findMatches(searchTerm: string, fileContent: string, options: SearchInWorkspaceOptions): { lineNumber: number, character: number, lineText: string }[] { + const matches = []; + const allLines = fileContent.split(/[\r\n]+/); + + for (let i = 0; i < allLines.length; i += 1) { + const currLine = allLines[i]; + // Check if RegEx search is supported. + // Since RegEx is still used for result matching, if RegEx search option is off, all the special characters need to be escaped. + if (!options.useRegExp) { + searchTerm = searchTerm.replace(/[\-\\\{\}\*\+\?\|\^\$\.\[\]\(\)\#]/g, '\\$&'); + } + // Check if results should be matched case sensitively. + const reFlags = options.matchCase ? 'g' : 'gi'; + // Check if only whole words should be matched. + if (options.matchWholeWord) { + searchTerm = '\\b' + searchTerm + '\\b'; + } + const re = RegExp(searchTerm, reFlags); + let reMatch: RegExpExecArray | null; + while (reMatch = re.exec(currLine)) { + const match = { + lineNumber: i + 1, + character: reMatch.index, + lineText: currLine + }; + matches.push(match); + } + } + return matches; + } + + /** + * Adds a search result to the result tree. + * If result is from a dirty file, it will be added to the `dirtyFileUris`. + * @param {SearchInWorkspaceResult} result - the search result. + * @param {boolean} [isDirtyResult] - whether the search result is from a dirty file. + */ + addToResultTree(result: SearchInWorkspaceResult, isDirtyResult?: boolean): void { + const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults']; + const { name, path } = this.filenameAndPath(result.root, result.fileUri); + const tree = this.resultTree; + const rootFolderNode = tree.get(result.root); + + if (rootFolderNode) { + const fileNode = rootFolderNode.children.find(f => f.fileUri === result.fileUri); + if (fileNode) { + if (isDirtyResult) { + this.dirtyFileUris.add(fileNode.fileUri); + } + + const line = this.createResultLineNode(result, fileNode); + if (fileNode.children.findIndex(lineNode => lineNode.id === line.id) < 0) { + fileNode.children.push(line); + } + this.collapseFileNode(fileNode, collapseValue); + } else { + const newFileNode = this.createFileNode(result.root, name, path, result.fileUri, rootFolderNode); + this.collapseFileNode(newFileNode, collapseValue); + if (isDirtyResult) { + this.dirtyFileUris.add(newFileNode.fileUri); + } + + const line = this.createResultLineNode(result, newFileNode); + newFileNode.children.push(line); + rootFolderNode.children.push(newFileNode); + } + + } else { + const newRootFolderNode = this.createRootFolderNode(result.root); + tree.set(result.root, newRootFolderNode); + const newFileNode = this.createFileNode(result.root, name, path, result.fileUri, newRootFolderNode); + this.collapseFileNode(newFileNode, collapseValue); + if (isDirtyResult) { + this.dirtyFileUris.add(newFileNode.fileUri); + } + + const line = this.createResultLineNode(result, newFileNode); + newFileNode.children.push(line); + newRootFolderNode.children.push(newFileNode); + } + } + + /** + * Searches in all dirty editors in the current workspace. + * @returns The number of match results in dirty files. + */ + searchInDirtyFiles(searchTerm: string, searchOptions: SearchInWorkspaceOptions): number { + // Gets all dirty editor widgets. + let dirtyWidgets: EditorWidget[] = this.editorManager.all.filter(w => w.saveable.dirty); + // Filter to only search dirty widgets in `files to include`. + if (searchOptions.include && searchOptions.include.length > 0) { + const includedPatterns: string[] = searchOptions.include; + dirtyWidgets = dirtyWidgets.filter(widget => includedPatterns.some(pattern => minimatch(widget.title.label, pattern))); + } + // Filter to only search dirty widgets that are not in `files to exclude`. + if (searchOptions.exclude && searchOptions.exclude.length) { + const excludedPatterns: string[] = searchOptions.exclude; + dirtyWidgets = dirtyWidgets.filter(widget => !excludedPatterns.some(pattern => minimatch(widget.title.label, pattern))); + } + // TODO: support includeIgnored option. + // if (!searchOptions.includeIgnored) {} + + let numberOfResults = 0; + dirtyWidgets.forEach(async w => { + const fileUri: string = w.editor.uri.toString(); + const roots = await this.workspaceService.roots; + const root: string = this.getRoot(w.editor.uri.path.toString(), roots.map(r => r.uri)).toString(); + const fileContent: string = w.editor.document.getText(); + const matches = this.findMatches(searchTerm, fileContent, searchOptions); + + if (matches.length) { + // Gets all match results in a file. + const dirtyResults: SearchInWorkspaceResult[] = matches.map(match => ({ + fileUri, + root, + line: match.lineNumber, + character: match.character, + length: searchTerm.length, + lineText: match.lineText.replace(/[\r\n]+$/, ''), + })); + // Check if the number of match results exceed the maximum amount. + if (searchOptions.maxResults && numberOfResults + matches.length >= searchOptions.maxResults) { + dirtyResults.slice(0, searchOptions.maxResults - numberOfResults).forEach(result => this.addToResultTree(result, true)); + return searchOptions.maxResults; + } + dirtyResults.forEach(result => this.addToResultTree(result, true)); + numberOfResults += matches.length; + } + }); + return numberOfResults; + } + + /** + * Seaches in all files in the current workspace. + */ async search(searchTerm: string, searchOptions: SearchInWorkspaceOptions): Promise { this.searchTerm = searchTerm; - const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults']; + // Stores URIs of the dirty editors to avoid duplicated search results. + this.dirtyFileUris = new Set(); this.resultTree.clear(); this.cancelIndicator.cancel(); this.cancelIndicator = new CancellationTokenSource(); @@ -207,39 +364,23 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { this.refreshModelChildren(); return; } + + // Searches in the dirty editors first. + const numberOfDirtyResults = this.searchInDirtyFiles(searchTerm, searchOptions); + if (searchOptions.maxResults) { + searchOptions.maxResults -= numberOfDirtyResults; + } + const searchId = await this.searchService.search(searchTerm, { onResult: (aSearchId: number, result: SearchInWorkspaceResult) => { if (token.isCancellationRequested || aSearchId !== searchId) { return; } - const { name, path } = this.filenameAndPath(result.root, result.fileUri); - const tree = this.resultTree; - const rootFolderNode = tree.get(result.root); - - if (rootFolderNode) { - const fileNode = rootFolderNode.children.find(f => f.fileUri === result.fileUri); - if (fileNode) { - const line = this.createResultLineNode(result, fileNode); - if (fileNode.children.findIndex(lineNode => lineNode.id === line.id) < 0) { - fileNode.children.push(line); - } - this.collapseFileNode(fileNode, collapseValue); - } else { - const newFileNode = this.createFileNode(result.root, name, path, result.fileUri, rootFolderNode); - this.collapseFileNode(newFileNode, collapseValue); - const line = this.createResultLineNode(result, newFileNode); - newFileNode.children.push(line); - rootFolderNode.children.push(newFileNode); - } - - } else { - const newRootFolderNode = this.createRootFolderNode(result.root); - tree.set(result.root, newRootFolderNode); - const newFileNode = this.createFileNode(result.root, name, path, result.fileUri, newRootFolderNode); - this.collapseFileNode(newFileNode, collapseValue); - newFileNode.children.push(this.createResultLineNode(result, newFileNode)); - newRootFolderNode.children.push(newFileNode); + // Breaks if the match is from a dirty file (already searched). + if (this.dirtyFileUris.has(result.fileUri)) { + return; } + this.addToResultTree(result); }, onDone: () => { if (token.isCancellationRequested) {