Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

search-in-workspace: Perform workspace search in dirty file content #8579

Merged
merged 1 commit into from
Oct 30, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/editor/src/browser/editor-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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': [
Expand Down
44 changes: 44 additions & 0 deletions packages/editor/src/browser/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ export type TextEditorProvider = (uri: URI) => Promise<TextEditor>;
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
Expand Down Expand Up @@ -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;

Expand Down
35 changes: 33 additions & 2 deletions packages/monaco/src/browser/monaco-editor-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<MonacoEditorModel> {
await this.resolveModel;
return this;
Expand Down
2 changes: 1 addition & 1 deletion packages/monaco/src/browser/monaco-text-model-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export class MonacoTextModelService implements monaco.editor.ITextModelService {

protected createModel(resource: Resource): MaybePromise<MonacoEditorModel> {
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) } = {
Expand Down
1 change: 1 addition & 0 deletions packages/search-in-workspace/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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<void> {
this.searchTerm = searchTerm;
const collapseValue: string = this.searchInWorkspacePreferences['search.collapseResults'];
searchOptions = {
...searchOptions,
exclude: this.getExcludeGlobs(searchOptions.exclude)
Expand All @@ -226,32 +371,31 @@ 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, {
onResult: (aSearchId: number, result: SearchInWorkspaceResult) => {
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);
}
Expand Down
Loading