Skip to content

Commit

Permalink
siw: Perform workspace search in all open editors
Browse files Browse the repository at this point in the history
+ `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 from all open editors.
+ Added `minimatch` as a dependency in `siw`.
+ Change alert message in search view when no workspace present.
+ Only display alert in search view when neither workspace nor editor present.
+ Introduce `findMatches` method to `TextEditorDocument` interface.

Co-authored-by: fangnx <[email protected]>
Co-authored-by: vince-fugnitto <[email protected]>
Signed-off-by: DukeNgn <[email protected]>
  • Loading branch information
3 people committed Oct 27, 2020
1 parent deccec6 commit fe18673
Show file tree
Hide file tree
Showing 8 changed files with 256 additions and 28 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Change Log

## v1.7.0

- [editor] add `findMatches` method to `TextEditorDocument` interface
- [git] the changes in the commit details (opened from the history view) and in the diff view (opened with 'Compare With...' on a folder's context menu) are now switchable between 'list' and 'tree' modes [#8084](https://github.com/eclipse-theia/theia/pull/8084)
- [scm] show in the commit textbox the branch to which the commit will go [#6156](https://github.com/eclipse-theia/theia/pull/6156)
- [filesystem] file watchers refactoring:
Expand Down
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.7.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.6.0",
"@theia/process": "^1.6.0",
"@theia/workspace": "^1.6.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,153 @@ 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;
}
const result = workspaceRootUri.toString().concat(pattern.replace('./', '/'));
console.log(result);
// return workspaceRootUriStr.toString().concat(pattern.replace('./', ''));
return result;
}
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 +374,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

0 comments on commit fe18673

Please sign in to comment.