diff --git a/packages/editor/src/browser/editor-preferences.ts b/packages/editor/src/browser/editor-preferences.ts index 0a0652e0fab18..456bb7938bd46 100644 --- a/packages/editor/src/browser/editor-preferences.ts +++ b/packages/editor/src/browser/editor-preferences.ts @@ -58,6 +58,8 @@ export const EDITOR_MODEL_DEFAULTS = { largeFileOptimizations: true }; +export const DEFAULT_WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?'; + /* eslint-disable max-len */ /* eslint-disable no-null/no-null */ @@ -1171,7 +1173,7 @@ const codeEditorPreferenceProperties = { 'editor.wordSeparators': { 'description': 'Characters that will be used as word separators when doing word related navigations or operations.', 'type': 'string', - 'default': '`~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?' + 'default': DEFAULT_WORD_SEPARATORS }, 'editor.wordWrap': { 'markdownEnumDescriptions': [ diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts index a93d3a4f9e0e2..6c6d963d53016 100644 --- a/packages/editor/src/browser/editor.ts +++ b/packages/editor/src/browser/editor.ts @@ -31,6 +31,10 @@ export type TextEditorProvider = (uri: URI) => Promise; export interface TextEditorDocument extends lsp.TextDocument, Saveable, Disposable { getLineContent(lineNumber: number): string; getLineMaxColumn(lineNumber: number): number; + /** + * @since 1.8.0 + */ + findMatches?(options: FindMatchesOptions): FindMatch[]; } // Refactoring @@ -150,6 +154,46 @@ export const enum EncodingMode { Decode } +/** + * Options for searching in an editor. + */ +export interface FindMatchesOptions { + /** + * The string used to search. If it is a regular expression, set `isRegex` to true. + */ + searchString: string; + /** + * Used to indicate that `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. + */ + matchWholeWord: boolean; + /** + * Limit the number of results. + */ + limitResultCount?: number; +} + +/** + * Representation of a find match. + */ +export interface FindMatch { + /** + * The textual match. + */ + readonly matches: string[]; + /** + * The range for the given match. + */ + readonly range: Range; +} + export interface TextEditor extends Disposable, TextEditorSelection, Navigatable { readonly node: HTMLElement; diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index 53f3031114701..0117eea654043 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -16,7 +16,7 @@ import { Position } from 'vscode-languageserver-types'; import { TextDocumentSaveReason, TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol'; -import { TextEditorDocument, EncodingMode } from '@theia/editor/lib/browser'; +import { TextEditorDocument, EncodingMode, FindMatchesOptions, FindMatch, EditorPreferences, DEFAULT_WORD_SEPARATORS } from '@theia/editor/lib/browser'; import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable'; import { Emitter, Event } from '@theia/core/lib/common/event'; import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common/cancellation'; @@ -83,7 +83,8 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument { protected readonly resource: Resource, protected readonly m2p: MonacoToProtocolConverter, protected readonly p2m: ProtocolToMonacoConverter, - protected readonly logger?: ILogger + protected readonly logger?: ILogger, + protected readonly editorPreferences?: EditorPreferences ) { this.toDispose.push(resource); this.toDispose.push(this.toDisposeOnAutoSave); @@ -279,6 +280,36 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument { return this.model; } + /** + * Find all matches in an editor for the given options. + * @param options the options for finding matches. + * + * @returns the list of matches. + */ + findMatches(options: FindMatchesOptions): FindMatch[] { + const wordSeparators = this.editorPreferences ? this.editorPreferences['editor.wordSeparators'] : DEFAULT_WORD_SEPARATORS; + const results: monaco.editor.FindMatch[] = this.model.findMatches( + options.searchString, + false, + options.isRegex, + options.matchCase, + // eslint-disable-next-line no-null/no-null + options.matchWholeWord ? wordSeparators : null, + true, + options.limitResultCount + ); + const extractedMatches: FindMatch[] = []; + results.forEach(r => { + if (r.matches) { + extractedMatches.push({ + matches: r.matches, + range: Range.create(r.range.startLineNumber, r.range.startColumn, r.range.endLineNumber, r.range.endColumn) + }); + } + }); + return extractedMatches; + } + async load(): Promise { await this.resolveModel; return this; diff --git a/packages/monaco/src/browser/monaco-text-model-service.ts b/packages/monaco/src/browser/monaco-text-model-service.ts index ecaa9e5c3458e..c77648d8362ea 100644 --- a/packages/monaco/src/browser/monaco-text-model-service.ts +++ b/packages/monaco/src/browser/monaco-text-model-service.ts @@ -128,7 +128,7 @@ export class MonacoTextModelService implements monaco.editor.ITextModelService { protected createModel(resource: Resource): MaybePromise { const factory = this.factories.getContributions().find(({ scheme }) => resource.uri.scheme === scheme); - return factory ? factory.createModel(resource) : new MonacoEditorModel(resource, this.m2p, this.p2m, this.logger); + return factory ? factory.createModel(resource) : new MonacoEditorModel(resource, this.m2p, this.p2m, this.logger, this.editorPreferences); } protected readonly modelOptions: { [name: string]: (keyof monaco.editor.ITextModelUpdateOptions | undefined) } = { diff --git a/packages/search-in-workspace/package.json b/packages/search-in-workspace/package.json index 2c9bd954adf22..0feb4222500c6 100644 --- a/packages/search-in-workspace/package.json +++ b/packages/search-in-workspace/package.json @@ -9,6 +9,7 @@ "@theia/navigator": "^1.7.0", "@theia/process": "^1.7.0", "@theia/workspace": "^1.7.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..4de480a0dbd9c 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,10 @@ 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'; +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 +45,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'; @@ -199,9 +203,150 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { }); } + /** + * 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 lineText: string = widget.editor.document.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 + }); + }); + + 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 { + // The leading to make the pattern matches in all directories. + const globalPrefix = '**/'; + if (pattern.startsWith(globalPrefix)) { + return pattern; + } + if (pattern.startsWith('./')) { + if (workspaceRootUri === undefined) { + return pattern; + } + return workspaceRootUri.toString().concat(pattern.replace('./', '/')); + } + return globalPrefix.concat(pattern); + } + + /** + * Find the list of editors which meet the filtering criteria. + * @param editors the list of editors to filter. + * @param searchOptions the search options to apply. + */ + protected findMatchedEditors(editors: EditorWidget[], searchOptions: SearchInWorkspaceOptions): EditorWidget[] { + if (!editors.length) { + return []; + } + + const ignoredPatterns = this.getExcludeGlobs(searchOptions.exclude); + editors = editors.filter(widget => !ignoredPatterns.some(pattern => minimatch( + widget.editor.uri.toString(), + this.convertPatternToGlob(this.workspaceService.getWorkspaceRootUri(widget.editor.uri), pattern), + { dot: true, matchBase: true }))); + + // Only include widgets that in `files to include`. + if (searchOptions.include && searchOptions.include.length > 0) { + const includePatterns: string[] = searchOptions.include; + editors = editors.filter(widget => includePatterns.some(pattern => minimatch( + widget.editor.uri.toString(), + this.convertPatternToGlob(this.workspaceService.getWorkspaceRootUri(widget.editor.uri), pattern), + { dot: true, matchBase: true }))); + } + + return editors; + } + + /** + * 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[] = []; + const editors = this.findMatchedEditors(this.editorManager.all, searchOptions); + editors.forEach(async widget => { + const matches = this.findMatches(searchTerm, widget, searchOptions); + numberOfResults += matches.length; + const fileUri: string = widget.editor.uri.toString(); + const root: string = this.workspaceService.getWorkspaceRootUri(widget.editor.uri)!.toString(); + searchResults.push({ root, fileUri, matches }); + }); + + return { + numberOfResults, + matches: searchResults + }; + } + + /** + * Append search results to the result tree. + * @param result Search result. + */ + protected appendToResultTree(result: SearchInWorkspaceResult): void { + if (result.matches.length <= 0) { + return; + } + 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); + } + 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) @@ -226,6 +371,23 @@ export class SearchInWorkspaceResultTreeWidget extends TreeWidget { this.cancelIndicator = undefined; this.changeEmitter.fire(this.resultTree); }); + + // Collect search results for opened editors which otherwise may not be found by ripgrep (ex: dirty editors). + const { numberOfResults: monacoNumberOfResults, matches: monacoMatches } = this.searchInOpenEditors(searchTerm, searchOptions); + monacoMatches.forEach(m => { + this.appendToResultTree(m); + // Exclude pattern beginning with './' works after the fix of #8469. + const { name, path } = this.filenameAndPath(m.root, m.fileUri); + const excludePath: string = path === '' ? './' + name : path + '/' + name; + // Exclude files already covered by searching individual editors. + searchOptions.exclude = (searchOptions.exclude) ? searchOptions.exclude.concat(excludePath) : [excludePath]; + }); + + // Reduce `maxResults` due to editor results. + if (searchOptions.maxResults) { + searchOptions.maxResults -= monacoNumberOfResults; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any let pendingRefreshTimeout: any; const searchId = await this.searchService.search(searchTerm, { @@ -233,25 +395,7 @@ 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); - } - } - this.collapseFileNode(fileNode, collapseValue); + this.appendToResultTree(result); if (pendingRefreshTimeout) { clearTimeout(pendingRefreshTimeout); } diff --git a/packages/search-in-workspace/src/browser/search-in-workspace-widget.tsx b/packages/search-in-workspace/src/browser/search-in-workspace-widget.tsx index 228f89af0818b..4a5bb95c05f47 100644 --- a/packages/search-in-workspace/src/browser/search-in-workspace-widget.tsx +++ b/packages/search-in-workspace/src/browser/search-in-workspace-widget.tsx @@ -25,6 +25,7 @@ import { WorkspaceService } from '@theia/workspace/lib/browser'; import { SearchInWorkspaceContextKeyService } from './search-in-workspace-context-key-service'; import { CancellationTokenSource } from '@theia/core'; import { ProgressBarFactory } from '@theia/core/lib/browser/progress-bar-factory'; +import { EditorManager } from '@theia/editor/lib/browser'; export interface SearchFieldState { className: string; @@ -86,6 +87,8 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge @inject(ProgressBarFactory) protected readonly progressBarFactory: ProgressBarFactory; + @inject(EditorManager) protected readonly editorManager: EditorManager; + @postConstruct() protected init(): void { this.id = SearchInWorkspaceWidget.ID; @@ -334,9 +337,9 @@ export class SearchInWorkspaceWidget extends BaseWidget implements StatefulWidge } protected renderNotification(): React.ReactNode { - if (this.workspaceService.tryGetRoots().length <= 0) { + if (this.workspaceService.tryGetRoots().length <= 0 && this.editorManager.all.length <= 0) { return
-
Cannot search without an active workspace present.
+
You have not opened or specified a folder. Only open files are currently searched.
; } return