From afabae9789818012938cbbbcde9c393559f0ab96 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Fri, 25 Sep 2020 18:04:16 -0400 Subject: [PATCH] siw: Perform workspace search in dirty file content + `Search In Workspace` can now search content in dirty files and display the results in siw view. + Utilized the `findMatches` function from `monaco editor` to get the search matches. + Added `minimatch` as a dependency in `siw` Co-authored-by: fangnx Co-authored-by: vince-fugnitto Signed-off-by: DukeNgn --- packages/editor/src/browser/editor.ts | 59 +++++++ packages/monaco/src/browser/monaco-editor.ts | 31 +++- packages/search-in-workspace/package.json | 1 + ...search-in-workspace-result-tree-widget.tsx | 155 +++++++++++++++--- 4 files changed, 224 insertions(+), 22 deletions(-) diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts index a93d3a4f9e0e2..22ab2d091b018 100644 --- a/packages/editor/src/browser/editor.ts +++ b/packages/editor/src/browser/editor.ts @@ -150,6 +150,54 @@ export const enum EncodingMode { Decode } +/** + * Options for searching matches in a model. + */ +export interface FindMatchesOptions { + /** + * The string used to search. If it is a regular expression, set `isRegex` to true. + */ + searchString: string; + /** + * Limit the searching to only search inside the editable range of the model. + */ + searchOnlyEditableRange: boolean; + /** + * Used to indicate the `searchString` is a regular expression. + */ + isRegex: boolean; + /** + * Force the matching to match lower/upper case exactly. + */ + matchCase: boolean; + /** + * Force the matching to match entire words only. Pass null otherwise. + */ + wordSeparators: string | null; + /** + * The result will contain the captured groups. + */ + captureMatches: boolean; + /** + * Limit the number of results. + */ + limitResultCount?: number; +} + +/** + * The result of searching matches in a model. + */ +export interface FindMatch { + /** + * The matched string. Null if nothing has been found. + */ + readonly matches: string[] | null; + /** + * The ranges where the matches are. It is empty if no matches have been found. + */ + readonly range: Range; +} + export interface TextEditor extends Disposable, TextEditorSelection, Navigatable { readonly node: HTMLElement; @@ -237,6 +285,17 @@ export interface TextEditor extends Disposable, TextEditorSelection, Navigatable setEncoding(encoding: string, mode: EncodingMode): void; readonly onEncodingChanged: Event; + + /** + * Find matches in an editor model. + * @param options: options for finding matches. + */ + findMatches?(options: FindMatchesOptions): FindMatch[]; + + /** + * Get the text for a certain line. + */ + getLineContent?(lineNumber: number): string; } export interface Dimension { diff --git a/packages/monaco/src/browser/monaco-editor.ts b/packages/monaco/src/browser/monaco-editor.ts index c6a3cf602001c..800f39a361d00 100644 --- a/packages/monaco/src/browser/monaco-editor.ts +++ b/packages/monaco/src/browser/monaco-editor.ts @@ -34,7 +34,7 @@ import { ReplaceTextParams, EditorDecoration, EditorMouseEvent, - EncodingMode + EncodingMode, FindMatchesOptions, FindMatch } from '@theia/editor/lib/browser'; import { MonacoEditorModel } from './monaco-editor-model'; import { MonacoToProtocolConverter } from './monaco-to-protocol-converter'; @@ -121,6 +121,35 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor { return this.document.setEncoding(encoding, mode); } + findMatches(options: FindMatchesOptions): FindMatch[] { + const model = this.editor.getModel(); + if (!model) { + return []; + } + const results: monaco.editor.FindMatch[] = model.findMatches( + options.searchString, + options.searchOnlyEditableRange, + options.isRegex, + options.matchCase, + options.wordSeparators, + options.captureMatches, + options.limitResultCount + ); + const extractedMatches: FindMatch[] = results.map(r => ({ + matches: r.matches, + range: Range.create(r.range.startLineNumber, r.range.startColumn, r.range.endLineNumber, r.range.endColumn) + })); + return extractedMatches; + } + + getLineContent(lineNumber: number): string { + const model = this.editor.getModel(); + if (!model) { + return ''; + } + return model.getLineContent(lineNumber); + } + protected create(options?: IStandaloneEditorConstructionOptions, override?: monaco.editor.IEditorOverrideServices): Disposable { return this.editor = monaco.editor.create(this.node, { ...options, diff --git a/packages/search-in-workspace/package.json b/packages/search-in-workspace/package.json index 2a1cfd3d4b83b..f0b11eedd71ef 100644 --- a/packages/search-in-workspace/package.json +++ b/packages/search-in-workspace/package.json @@ -9,6 +9,7 @@ "@theia/navigator": "^1.6.0", "@theia/process": "^1.6.0", "@theia/workspace": "^1.6.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 08290fe69778f..4a8a82b36ed06 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 @@ -31,7 +31,8 @@ import { DiffUris } from '@theia/core/lib/browser'; import { CancellationTokenSource, Emitter, Event } from '@theia/core'; -import { EditorManager, EditorDecoration, TrackedRangeStickiness, OverviewRulerLane, EditorWidget, ReplaceOperation, EditorOpenerOptions } from '@theia/editor/lib/browser'; +// eslint-disable-next-line max-len +import { EditorManager, EditorDecoration, TrackedRangeStickiness, OverviewRulerLane, EditorWidget, ReplaceOperation, EditorOpenerOptions, FindMatch } from '@theia/editor/lib/browser'; import { WorkspaceService } from '@theia/workspace/lib/browser'; import { FileResourceResolver, FileSystemPreferences } from '@theia/filesystem/lib/browser'; import { SearchInWorkspaceResult, SearchInWorkspaceOptions, SearchMatch } from '../common/search-in-workspace-interface'; @@ -42,6 +43,7 @@ import * as React from 'react'; import { SearchInWorkspacePreferences } from './search-in-workspace-preferences'; import { ProgressService } from '@theia/core'; import { ColorRegistry } from '@theia/core/lib/browser/color-registry'; +import * as minimatch from 'minimatch'; const ROOT_ID = 'ResultTree'; @@ -102,6 +104,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { protected _showReplaceButtons = false; protected _replaceTerm = ''; protected searchTerm = ''; + protected dirtyFileUris = new Set(); protected appliedDecorations = new Map(); @@ -117,10 +120,10 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { @inject(ApplicationShell) protected readonly shell: ApplicationShell; @inject(WorkspaceService) protected readonly workspaceService: WorkspaceService; @inject(TreeExpansionService) protected readonly expansionService: TreeExpansionService; + @inject(FileSystemPreferences) protected readonly fileSystemPreferences: FileSystemPreferences; @inject(SearchInWorkspacePreferences) protected readonly searchInWorkspacePreferences: SearchInWorkspacePreferences; @inject(ProgressService) protected readonly progressService: ProgressService; @inject(ColorRegistry) protected readonly colorRegistry: ColorRegistry; - @inject(FileSystemPreferences) protected readonly filesystemPreferences: FileSystemPreferences; constructor( @inject(TreeProps) readonly props: TreeProps, @@ -199,13 +202,133 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { }); } + /** + * Find matches in a single widget. + * @param searchTerm The search keyword. + * @param widget The widget that is being searched. + * @param searchOptions The options for search operation. + */ + findMatches(searchTerm: string, widget: EditorWidget, searchOptions: SearchInWorkspaceOptions): SearchMatch[] { + const matches: SearchMatch[] = []; + if (!widget.editor.findMatches || !widget.editor.getLineContent) { + return []; + } + const results: FindMatch[] = widget.editor.findMatches({ + searchString: searchTerm, + searchOnlyEditableRange: true, + isRegex: !!searchOptions.useRegExp, + matchCase: !!searchOptions.matchCase, + // eslint-disable-next-line no-null/no-null + wordSeparators: searchOptions.matchWholeWord ? searchTerm : null, + captureMatches: true, + limitResultCount: searchOptions.maxResults + }); + if (results.length > 0) { + results.forEach(r => { + if (!widget.editor.getLineContent) { + return []; + } + const lineText: string = widget.editor.getLineContent(r.range.start.line); + matches.push({ + line: r.range.start.line, + character: r.range.start.character, + length: r.range.end.character - r.range.start.character, + lineText + }); + }); + if (searchOptions.maxResults) { + searchOptions.maxResults -= results.length; + } + } + return matches; + } + + filterEditorWidgets(widgets: EditorWidget[], searchOptions: SearchInWorkspaceOptions): EditorWidget[] { + if (!widgets.length) { + return []; + } + // Exclude dirty widgets that should be ignored in glob. + if (!searchOptions.includeIgnored) { + const ignoredPatterns = this.fileSystemPreferences.get('files.exclude'); + widgets = widgets.filter(widget => { + for (const pattern in ignoredPatterns) { + if (ignoredPatterns[pattern] && minimatch(widget.editor.uri.toString(), pattern)) { + return false; + } + } + return true; + }); + } + // Only include dirty widgets that in `files to include`. + if (searchOptions.include && searchOptions.include.length > 0) { + const includePatterns: string[] = searchOptions.include; + widgets = widgets.filter(widget => includePatterns.some(pattern => minimatch(widget.editor.uri.toString(), pattern))); + } + // Exclude dirty widgets that are in `files to exclude` + if (searchOptions.exclude && searchOptions.exclude.length > 0) { + const excludePatterns: string[] = searchOptions.exclude; + widgets = widgets.filter(widget => !excludePatterns.some(pattern => minimatch(widget.editor.uri.toString(), pattern))); + } + return widgets; + } + + /** + * Perform searching in all dirty editors. + * @param searchTerm The search keyword. + * @param searchOptions The options for search operation. + * @returns The number of matches in dirty editors. + */ + searchInDirtyEditors(searchTerm: string, searchOptions: SearchInWorkspaceOptions): void { + let dirtyWidgets: EditorWidget[] = this.editorManager.all.filter(widget => widget.saveable.dirty); + dirtyWidgets = this.filterEditorWidgets(dirtyWidgets, searchOptions); + dirtyWidgets.forEach(async widget => { + const matches = this.findMatches(searchTerm, widget, searchOptions); + if (matches?.length) { + const fileUri: string = widget.editor.uri.toString(); + const root: string = this.workspaceService.getWorkspaceRootUri(widget.editor.uri)?.toString()!; + this.appendToResultTree({ root, fileUri, matches }, true); + } + }); + } + + /** + * Append search results to the result tree + * @param result Search results + * @param isDirtyResult Whether the search results are from a dirty file + */ + appendToResultTree(result: SearchInWorkspaceResult, isDirtyResult?: boolean): void { + const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults']; + const { path } = this.filenameAndPath(result.root, result.fileUri); + 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); + } + if (isDirtyResult) { + this.dirtyFileUris.add(fileNode.fileUri); + } + 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); + } + async search(searchTerm: string, searchOptions: SearchInWorkspaceOptions): Promise { this.searchTerm = searchTerm; - const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults']; searchOptions = { ...searchOptions, exclude: this.getExcludeGlobs(searchOptions.exclude) }; + this.dirtyFileUris.clear(); this.resultTree.clear(); if (this.cancelIndicator) { this.cancelIndicator.cancel(); @@ -226,6 +349,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { this.cancelIndicator = undefined; this.changeEmitter.fire(this.resultTree); }); + this.searchInDirtyEditors(searchTerm, searchOptions); // eslint-disable-next-line @typescript-eslint/no-explicit-any let pendingRefreshTimeout: any; const searchId = await this.searchService.search(searchTerm, { @@ -233,25 +357,14 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { if (token.isCancellationRequested || aSearchId !== searchId) { return; } - const { path } = this.filenameAndPath(result.root, result.fileUri); - 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); + if (this.dirtyFileUris.has(result.fileUri)) { + // Give back one result counter to maxResults since this is a duplicate + if (searchOptions.maxResults) { + searchOptions.maxResults++; } + return; } - this.collapseFileNode(fileNode, collapseValue); + this.appendToResultTree(result); if (pendingRefreshTimeout) { clearTimeout(pendingRefreshTimeout); } @@ -785,7 +898,7 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { * @returns the list of exclude globs. */ protected getExcludeGlobs(excludeOptions?: string[]): string[] { - const excludePreferences = this.filesystemPreferences['files.exclude']; + const excludePreferences = this.fileSystemPreferences['files.exclude']; const excludePreferencesGlobs = Object.keys(excludePreferences).filter(key => !!excludePreferences[key]); return [...new Set([...excludePreferencesGlobs, ...excludeOptions])]; }