src/vs/workbench/contrib/search/common/searchModel.ts (878 lines of code) (raw):

/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { RunOnceScheduler } from 'vs/base/common/async'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; import * as errors from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { getBaseLabel } from 'vs/base/common/labels'; import { Disposable, IDisposable, DisposableStore } from 'vs/base/common/lifecycle'; import { ResourceMap, TernarySearchTree, values } from 'vs/base/common/map'; import * as objects from 'vs/base/common/objects'; import { lcut } from 'vs/base/common/strings'; import { URI } from 'vs/base/common/uri'; import { Range } from 'vs/editor/common/core/range'; import { FindMatch, IModelDeltaDecoration, ITextModel, OverviewRulerLane, TrackedRangeStickiness } from 'vs/editor/common/model'; import { ModelDecorationOptions } from 'vs/editor/common/model/textModel'; import { IModelService } from 'vs/editor/common/services/modelService'; import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IProgress, IProgressStep } from 'vs/platform/progress/common/progress'; import { ReplacePattern } from 'vs/workbench/services/search/common/replace'; import { IFileMatch, IPatternInfo, ISearchComplete, ISearchProgressItem, ISearchService, ITextQuery, ITextSearchPreviewOptions, ITextSearchMatch, ITextSearchStats, resultIsMatch, ISearchRange, OneLineRange } from 'vs/workbench/services/search/common/search'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { overviewRulerFindMatchForeground } from 'vs/platform/theme/common/colorRegistry'; import { themeColorFromId } from 'vs/platform/theme/common/themeService'; import { IReplaceService } from 'vs/workbench/contrib/search/common/replace'; import { editorMatchesToTextSearchResults } from 'vs/workbench/services/search/common/searchHelpers'; import { withNullAsUndefined } from 'vs/base/common/types'; export class Match { private static readonly MAX_PREVIEW_CHARS = 250; private _id: string; private _range: Range; private _oneLinePreviewText: string; private _rangeInPreviewText: ISearchRange; // For replace private _fullPreviewRange: ISearchRange; constructor(private _parent: FileMatch, private _fullPreviewLines: string[], _fullPreviewRange: ISearchRange, _documentRange: ISearchRange) { this._oneLinePreviewText = _fullPreviewLines[_fullPreviewRange.startLineNumber]; const adjustedEndCol = _fullPreviewRange.startLineNumber === _fullPreviewRange.endLineNumber ? _fullPreviewRange.endColumn : this._oneLinePreviewText.length; this._rangeInPreviewText = new OneLineRange(1, _fullPreviewRange.startColumn + 1, adjustedEndCol + 1); this._range = new Range( _documentRange.startLineNumber + 1, _documentRange.startColumn + 1, _documentRange.endLineNumber + 1, _documentRange.endColumn + 1); this._fullPreviewRange = _fullPreviewRange; this._id = this._parent.id() + '>' + this._range + this.getMatchString(); } id(): string { return this._id; } parent(): FileMatch { return this._parent; } text(): string { return this._oneLinePreviewText; } range(): Range { return this._range; } preview(): { before: string; inside: string; after: string; } { let before = this._oneLinePreviewText.substring(0, this._rangeInPreviewText.startColumn - 1), inside = this.getMatchString(), after = this._oneLinePreviewText.substring(this._rangeInPreviewText.endColumn - 1); before = lcut(before, 26); before = before.trimLeft(); let charsRemaining = Match.MAX_PREVIEW_CHARS - before.length; inside = inside.substr(0, charsRemaining); charsRemaining -= inside.length; after = after.substr(0, charsRemaining); return { before, inside, after, }; } get replaceString(): string { const searchModel = this.parent().parent().searchModel; if (!searchModel.replacePattern) { throw new Error('searchModel.replacePattern must be set before accessing replaceString'); } const fullMatchText = this.fullMatchText(); let replaceString = searchModel.replacePattern.getReplaceString(fullMatchText); // If match string is not matching then regex pattern has a lookahead expression if (replaceString === null) { const fullMatchTextWithTrailingContent = this.fullMatchText(true); replaceString = searchModel.replacePattern.getReplaceString(fullMatchTextWithTrailingContent); // Search/find normalize line endings - check whether \r prevents regex from matching if (replaceString === null) { const fullMatchTextWithoutCR = fullMatchTextWithTrailingContent.replace(/\r\n/g, '\n'); replaceString = searchModel.replacePattern.getReplaceString(fullMatchTextWithoutCR); } } // Match string is still not matching. Could be unsupported matches (multi-line). if (replaceString === null) { replaceString = searchModel.replacePattern.pattern; } return replaceString; } fullMatchText(includeTrailing = false): string { let thisMatchPreviewLines: string[]; if (includeTrailing) { thisMatchPreviewLines = this._fullPreviewLines.slice(this._fullPreviewRange.startLineNumber); } else { thisMatchPreviewLines = this._fullPreviewLines.slice(this._fullPreviewRange.startLineNumber, this._fullPreviewRange.endLineNumber + 1); thisMatchPreviewLines[thisMatchPreviewLines.length - 1] = thisMatchPreviewLines[thisMatchPreviewLines.length - 1].slice(0, this._fullPreviewRange.endColumn); } thisMatchPreviewLines[0] = thisMatchPreviewLines[0].slice(this._fullPreviewRange.startColumn); return thisMatchPreviewLines.join('\n'); } fullPreviewLines(): string[] { return this._fullPreviewLines.slice(this._fullPreviewRange.startLineNumber, this._fullPreviewRange.endLineNumber + 1); } getMatchString(): string { return this._oneLinePreviewText.substring(this._rangeInPreviewText.startColumn - 1, this._rangeInPreviewText.endColumn - 1); } } export class FileMatch extends Disposable { private static readonly _CURRENT_FIND_MATCH = ModelDecorationOptions.register({ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, zIndex: 13, className: 'currentFindMatch', overviewRuler: { color: themeColorFromId(overviewRulerFindMatchForeground), position: OverviewRulerLane.Center } }); private static readonly _FIND_MATCH = ModelDecorationOptions.register({ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, className: 'findMatch', overviewRuler: { color: themeColorFromId(overviewRulerFindMatchForeground), position: OverviewRulerLane.Center } }); private static getDecorationOption(selected: boolean): ModelDecorationOptions { return (selected ? FileMatch._CURRENT_FIND_MATCH : FileMatch._FIND_MATCH); } private _onChange = this._register(new Emitter<boolean>()); readonly onChange: Event<boolean> = this._onChange.event; private _onDispose = this._register(new Emitter<void>()); readonly onDispose: Event<void> = this._onDispose.event; private _resource: URI; private _model: ITextModel | null; private _modelListener: IDisposable; private _matches: Map<string, Match>; private _removedMatches: Set<string>; private _selectedMatch: Match | null; private _updateScheduler: RunOnceScheduler; private _modelDecorations: string[] = []; constructor(private _query: IPatternInfo, private _previewOptions: ITextSearchPreviewOptions, private _maxResults: number, private _parent: BaseFolderMatch, private rawMatch: IFileMatch, @IModelService private readonly modelService: IModelService, @IReplaceService private readonly replaceService: IReplaceService ) { super(); this._resource = this.rawMatch.resource; this._matches = new Map<string, Match>(); this._removedMatches = new Set<string>(); this._updateScheduler = new RunOnceScheduler(this.updateMatchesForModel.bind(this), 250); this.createMatches(); } private createMatches(): void { const model = this.modelService.getModel(this._resource); if (model) { this.bindModel(model); this.updateMatchesForModel(); } else { this.rawMatch.results! .filter(resultIsMatch) .forEach(rawMatch => { textSearchResultToMatches(rawMatch, this) .forEach(m => this.add(m)); }); } } bindModel(model: ITextModel): void { this._model = model; this._modelListener = this._model.onDidChangeContent(() => { this._updateScheduler.schedule(); }); this._model.onWillDispose(() => this.onModelWillDispose()); this.updateHighlights(); } private onModelWillDispose(): void { // Update matches because model might have some dirty changes this.updateMatchesForModel(); this.unbindModel(); } private unbindModel(): void { if (this._model) { this._updateScheduler.cancel(); this._model.deltaDecorations(this._modelDecorations, []); this._model = null; this._modelListener.dispose(); } } private updateMatchesForModel(): void { // this is called from a timeout and might fire // after the model has been disposed if (!this._model) { return; } this._matches = new Map<string, Match>(); const wordSeparators = this._query.isWordMatch && this._query.wordSeparators ? this._query.wordSeparators : null; const matches = this._model .findMatches(this._query.pattern, this._model.getFullModelRange(), !!this._query.isRegExp, !!this._query.isCaseSensitive, wordSeparators, false, this._maxResults); this.updateMatches(matches, true); } private updatesMatchesForLineAfterReplace(lineNumber: number, modelChange: boolean): void { if (!this._model) { return; } const range = { startLineNumber: lineNumber, startColumn: this._model.getLineMinColumn(lineNumber), endLineNumber: lineNumber, endColumn: this._model.getLineMaxColumn(lineNumber) }; const oldMatches = values(this._matches).filter(match => match.range().startLineNumber === lineNumber); oldMatches.forEach(match => this._matches.delete(match.id())); const wordSeparators = this._query.isWordMatch && this._query.wordSeparators ? this._query.wordSeparators : null; const matches = this._model.findMatches(this._query.pattern, range, !!this._query.isRegExp, !!this._query.isCaseSensitive, wordSeparators, false, this._maxResults); this.updateMatches(matches, modelChange); } private updateMatches(matches: FindMatch[], modelChange: boolean): void { if (!this._model) { return; } const textSearchResults = editorMatchesToTextSearchResults(matches, this._model, this._previewOptions); textSearchResults.forEach(textSearchResult => { textSearchResultToMatches(textSearchResult, this).forEach(match => { if (!this._removedMatches.has(match.id())) { this.add(match); if (this.isMatchSelected(match)) { this._selectedMatch = match; } } }); }); this._onChange.fire(modelChange); this.updateHighlights(); } updateHighlights(): void { if (!this._model) { return; } if (this.parent().showHighlights) { this._modelDecorations = this._model.deltaDecorations(this._modelDecorations, this.matches().map(match => <IModelDeltaDecoration>{ range: match.range(), options: FileMatch.getDecorationOption(this.isMatchSelected(match)) })); } else { this._modelDecorations = this._model.deltaDecorations(this._modelDecorations, []); } } id(): string { return this.resource().toString(); } parent(): BaseFolderMatch { return this._parent; } matches(): Match[] { return values(this._matches); } remove(match: Match): void { this.removeMatch(match); this._removedMatches.add(match.id()); this._onChange.fire(false); } replace(toReplace: Match): Promise<void> { return this.replaceService.replace(toReplace) .then(() => this.updatesMatchesForLineAfterReplace(toReplace.range().startLineNumber, false)); } setSelectedMatch(match: Match | null): void { if (match) { if (!this._matches.has(match.id())) { return; } if (this.isMatchSelected(match)) { return; } } this._selectedMatch = match; this.updateHighlights(); } getSelectedMatch(): Match | null { return this._selectedMatch; } isMatchSelected(match: Match): boolean { return !!this._selectedMatch && this._selectedMatch.id() === match.id(); } count(): number { return this.matches().length; } resource(): URI { return this._resource; } name(): string { return getBaseLabel(this.resource()); } add(match: Match, trigger?: boolean) { this._matches.set(match.id(), match); if (trigger) { this._onChange.fire(true); } } private removeMatch(match: Match) { this._matches.delete(match.id()); if (this.isMatchSelected(match)) { this.setSelectedMatch(null); } else { this.updateHighlights(); } } dispose(): void { this.setSelectedMatch(null); this.unbindModel(); this._onDispose.fire(); super.dispose(); } } export interface IChangeEvent { elements: (FileMatch | FolderMatch | SearchResult)[]; added?: boolean; removed?: boolean; } export class BaseFolderMatch extends Disposable { private _onChange = this._register(new Emitter<IChangeEvent>()); readonly onChange: Event<IChangeEvent> = this._onChange.event; private _onDispose = this._register(new Emitter<void>()); readonly onDispose: Event<void> = this._onDispose.event; private _fileMatches: ResourceMap<FileMatch>; private _unDisposedFileMatches: ResourceMap<FileMatch>; private _replacingAll: boolean = false; constructor(protected _resource: URI | null, private _id: string, private _index: number, private _query: ITextQuery, private _parent: SearchResult, private _searchModel: SearchModel, @IReplaceService private readonly replaceService: IReplaceService, @IInstantiationService private readonly instantiationService: IInstantiationService ) { super(); this._fileMatches = new ResourceMap<FileMatch>(); this._unDisposedFileMatches = new ResourceMap<FileMatch>(); } get searchModel(): SearchModel { return this._searchModel; } get showHighlights(): boolean { return this._parent.showHighlights; } set replacingAll(b: boolean) { this._replacingAll = b; } id(): string { return this._id; } resource(): URI | null { return this._resource; } index(): number { return this._index; } name(): string { return getBaseLabel(withNullAsUndefined(this.resource())) || ''; } parent(): SearchResult { return this._parent; } hasResource(): boolean { return !!this._resource; } bindModel(model: ITextModel): void { const fileMatch = this._fileMatches.get(model.uri); if (fileMatch) { fileMatch.bindModel(model); } } add(raw: IFileMatch[], silent: boolean): void { const added: FileMatch[] = []; const updated: FileMatch[] = []; raw.forEach(rawFileMatch => { const existingFileMatch = this._fileMatches.get(rawFileMatch.resource); if (existingFileMatch) { rawFileMatch .results! .filter(resultIsMatch) .forEach(m => { textSearchResultToMatches(m, existingFileMatch) .forEach(m => existingFileMatch.add(m)); }); updated.push(existingFileMatch); } else { const fileMatch = this.instantiationService.createInstance(FileMatch, this._query.contentPattern, this._query.previewOptions, this._query.maxResults, this, rawFileMatch); this.doAdd(fileMatch); added.push(fileMatch); const disposable = fileMatch.onChange(() => this.onFileChange(fileMatch)); fileMatch.onDispose(() => disposable.dispose()); } }); const elements = [...added, ...updated]; if (!silent && elements.length) { this._onChange.fire({ elements, added: !!added.length }); } } clear(): void { const changed: FileMatch[] = this.matches(); this.disposeMatches(); this._onChange.fire({ elements: changed, removed: true }); } remove(match: FileMatch): void { this.doRemove(match); } replace(match: FileMatch): Promise<any> { return this.replaceService.replace([match]).then(() => { this.doRemove(match, false, true); }); } replaceAll(): Promise<any> { const matches = this.matches(); return this.replaceService.replace(matches).then(() => { matches.forEach(match => this.doRemove(match, false, true)); }); } matches(): FileMatch[] { return this._fileMatches.values(); } isEmpty(): boolean { return this.fileCount() === 0; } fileCount(): number { return this._fileMatches.size; } count(): number { return this.matches().reduce<number>((prev, match) => prev + match.count(), 0); } private onFileChange(fileMatch: FileMatch): void { let added: boolean = false; let removed: boolean = false; if (!this._fileMatches.has(fileMatch.resource())) { this.doAdd(fileMatch); added = true; } if (fileMatch.count() === 0) { this.doRemove(fileMatch, false, false); added = false; removed = true; } if (!this._replacingAll) { this._onChange.fire({ elements: [fileMatch], added: added, removed: removed }); } } private doAdd(fileMatch: FileMatch): void { this._fileMatches.set(fileMatch.resource(), fileMatch); if (this._unDisposedFileMatches.has(fileMatch.resource())) { this._unDisposedFileMatches.delete(fileMatch.resource()); } } private doRemove(fileMatch: FileMatch, dispose: boolean = true, trigger: boolean = true): void { this._fileMatches.delete(fileMatch.resource()); if (dispose) { fileMatch.dispose(); } else { this._unDisposedFileMatches.set(fileMatch.resource(), fileMatch); } if (trigger) { this._onChange.fire({ elements: [fileMatch], removed: true }); } } private disposeMatches(): void { this._fileMatches.values().forEach((fileMatch: FileMatch) => fileMatch.dispose()); this._unDisposedFileMatches.values().forEach((fileMatch: FileMatch) => fileMatch.dispose()); this._fileMatches.clear(); this._unDisposedFileMatches.clear(); } dispose(): void { this.disposeMatches(); this._onDispose.fire(); super.dispose(); } } /** * BaseFolderMatch => optional resource ("other files" node) * FolderMatch => required resource (normal folder node) */ export class FolderMatch extends BaseFolderMatch { constructor(_resource: URI, _id: string, _index: number, _query: ITextQuery, _parent: SearchResult, _searchModel: SearchModel, @IReplaceService replaceService: IReplaceService, @IInstantiationService instantiationService: IInstantiationService ) { super(_resource, _id, _index, _query, _parent, _searchModel, replaceService, instantiationService); } resource(): URI { return this._resource!; } } /** * Compares instances of the same match type. Different match types should not be siblings * and their sort order is undefined. */ export function searchMatchComparer(elementA: RenderableMatch, elementB: RenderableMatch): number { if (elementA instanceof BaseFolderMatch && elementB instanceof BaseFolderMatch) { return elementA.index() - elementB.index(); } if (elementA instanceof FileMatch && elementB instanceof FileMatch) { return elementA.resource().fsPath.localeCompare(elementB.resource().fsPath) || elementA.name().localeCompare(elementB.name()); } if (elementA instanceof Match && elementB instanceof Match) { return Range.compareRangesUsingStarts(elementA.range(), elementB.range()); } return 0; } export class SearchResult extends Disposable { private _onChange = this._register(new Emitter<IChangeEvent>()); readonly onChange: Event<IChangeEvent> = this._onChange.event; private _folderMatches: FolderMatch[] = []; private _otherFilesMatch: BaseFolderMatch; private _folderMatchesMap: TernarySearchTree<FolderMatch> = TernarySearchTree.forPaths<FolderMatch>(); private _showHighlights: boolean; private _query: ITextQuery; private _rangeHighlightDecorations: RangeHighlightDecorations; constructor( private _searchModel: SearchModel, @IReplaceService private readonly replaceService: IReplaceService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IModelService private readonly modelService: IModelService, ) { super(); this._rangeHighlightDecorations = this.instantiationService.createInstance(RangeHighlightDecorations); this._register(this.modelService.onModelAdded(model => this.onModelAdded(model))); } get query(): ITextQuery { return this._query; } set query(query: ITextQuery) { // When updating the query we could change the roots, so ensure we clean up the old roots first. this.clear(); this._folderMatches = (query.folderQueries || []) .map(fq => fq.folder) .map((resource, index) => this.createFolderMatch(resource, resource.toString(), index, query)); this._folderMatches.forEach(fm => this._folderMatchesMap.set(fm.resource().toString(), fm)); this._otherFilesMatch = this.createOtherFilesFolderMatch('otherFiles', this._folderMatches.length + 1, query); this._query = query; } private onModelAdded(model: ITextModel): void { const folderMatch = this._folderMatchesMap.findSubstr(model.uri.toString()); if (folderMatch) { folderMatch.bindModel(model); } } private createFolderMatch(resource: URI, id: string, index: number, query: ITextQuery): FolderMatch { return <FolderMatch>this._createBaseFolderMatch(FolderMatch, resource, id, index, query); } private createOtherFilesFolderMatch(id: string, index: number, query: ITextQuery): BaseFolderMatch { return this._createBaseFolderMatch(BaseFolderMatch, null, id, index, query); } private _createBaseFolderMatch(folderMatchClass: typeof BaseFolderMatch | typeof FolderMatch, resource: URI | null, id: string, index: number, query: ITextQuery): BaseFolderMatch { const folderMatch = this.instantiationService.createInstance(folderMatchClass, resource, id, index, query, this, this._searchModel); const disposable = folderMatch.onChange((event) => this._onChange.fire(event)); folderMatch.onDispose(() => disposable.dispose()); return folderMatch; } get searchModel(): SearchModel { return this._searchModel; } add(allRaw: IFileMatch[], silent: boolean = false): void { // Split up raw into a list per folder so we can do a batch add per folder. const rawPerFolder = new ResourceMap<IFileMatch[]>(); const otherFileMatches: IFileMatch[] = []; this._folderMatches.forEach(fm => rawPerFolder.set(fm.resource(), [])); allRaw.forEach(rawFileMatch => { const folderMatch = this.getFolderMatch(rawFileMatch.resource); if (!folderMatch) { // foldermatch was previously removed by user or disposed for some reason return; } const resource = folderMatch.resource(); if (resource) { rawPerFolder.get(resource)!.push(rawFileMatch); } else { otherFileMatches.push(rawFileMatch); } }); rawPerFolder.forEach((raw) => { if (!raw.length) { return; } const folderMatch = this.getFolderMatch(raw[0].resource); if (folderMatch) { folderMatch.add(raw, silent); } }); this._otherFilesMatch!.add(otherFileMatches, silent); } clear(): void { this.folderMatches().forEach((folderMatch) => folderMatch.clear()); this.disposeMatches(); } remove(match: FileMatch | FolderMatch): void { if (match instanceof FileMatch) { this.getFolderMatch(match.resource()).remove(match); } else { match.clear(); } } replace(match: FileMatch): Promise<any> { return this.getFolderMatch(match.resource()).replace(match); } replaceAll(progress: IProgress<IProgressStep>): Promise<any> { this.replacingAll = true; const promise = this.replaceService.replace(this.matches(), progress); const onDone = Event.stopwatch(Event.fromPromise(promise)); /* __GDPR__ "replaceAll.started" : { "duration" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true } } */ onDone(duration => this.telemetryService.publicLog('replaceAll.started', { duration })); return promise.then(() => { this.replacingAll = false; this.clear(); }, () => { this.replacingAll = false; }); } folderMatches(): BaseFolderMatch[] { return this._otherFilesMatch ? [ ...this._folderMatches, this._otherFilesMatch ] : [ ...this._folderMatches ]; } matches(): FileMatch[] { const matches: FileMatch[][] = []; this.folderMatches().forEach(folderMatch => { matches.push(folderMatch.matches()); }); return (<FileMatch[]>[]).concat(...matches); } isEmpty(): boolean { return this.folderMatches().every((folderMatch) => folderMatch.isEmpty()); } fileCount(): number { return this.folderMatches().reduce<number>((prev, match) => prev + match.fileCount(), 0); } count(): number { return this.matches().reduce<number>((prev, match) => prev + match.count(), 0); } get showHighlights(): boolean { return this._showHighlights; } toggleHighlights(value: boolean): void { if (this._showHighlights === value) { return; } this._showHighlights = value; let selectedMatch: Match | null = null; this.matches().forEach((fileMatch: FileMatch) => { fileMatch.updateHighlights(); if (!selectedMatch) { selectedMatch = fileMatch.getSelectedMatch(); } }); if (this._showHighlights && selectedMatch) { // TS? this._rangeHighlightDecorations.highlightRange( (<Match>selectedMatch).parent().resource(), (<Match>selectedMatch).range() ); } else { this._rangeHighlightDecorations.removeHighlightRange(); } } get rangeHighlightDecorations(): RangeHighlightDecorations { return this._rangeHighlightDecorations; } private getFolderMatch(resource: URI): BaseFolderMatch { const folderMatch = this._folderMatchesMap.findSubstr(resource.toString()); return folderMatch ? folderMatch : this._otherFilesMatch; } private set replacingAll(running: boolean) { this.folderMatches().forEach((folderMatch) => { folderMatch.replacingAll = running; }); } private disposeMatches(): void { this.folderMatches().forEach(folderMatch => folderMatch.dispose()); this._folderMatches = []; this._folderMatchesMap = TernarySearchTree.forPaths<FolderMatch>(); this._rangeHighlightDecorations.removeHighlightRange(); } dispose(): void { this.disposeMatches(); this._rangeHighlightDecorations.dispose(); super.dispose(); } } export class SearchModel extends Disposable { private _searchResult: SearchResult; private _searchQuery: ITextQuery | null = null; private _replaceActive: boolean = false; private _replaceString: string | null = null; private _replacePattern: ReplacePattern | null = null; private readonly _onReplaceTermChanged: Emitter<void> = this._register(new Emitter<void>()); readonly onReplaceTermChanged: Event<void> = this._onReplaceTermChanged.event; private currentCancelTokenSource: CancellationTokenSource; constructor( @ISearchService private readonly searchService: ISearchService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IInstantiationService private readonly instantiationService: IInstantiationService ) { super(); this._searchResult = this.instantiationService.createInstance(SearchResult, this); } isReplaceActive(): boolean { return this._replaceActive; } set replaceActive(replaceActive: boolean) { this._replaceActive = replaceActive; } get replacePattern(): ReplacePattern | null { return this._replacePattern; } get replaceString(): string { return this._replaceString || ''; } set replaceString(replaceString: string) { this._replaceString = replaceString; if (this._searchQuery) { this._replacePattern = new ReplacePattern(replaceString, this._searchQuery.contentPattern); } this._onReplaceTermChanged.fire(); } get searchResult(): SearchResult { return this._searchResult; } search(query: ITextQuery, onProgress?: (result: ISearchProgressItem) => void): Promise<ISearchComplete> { this.cancelSearch(); this._searchQuery = query; this.searchResult.clear(); this._searchResult.query = this._searchQuery; const progressEmitter = new Emitter<void>(); this._replacePattern = new ReplacePattern(this.replaceString, this._searchQuery.contentPattern); const tokenSource = this.currentCancelTokenSource = new CancellationTokenSource(); const currentRequest = this.searchService.textSearch(this._searchQuery, this.currentCancelTokenSource.token, p => { progressEmitter.fire(); this.onSearchProgress(p); if (onProgress) { onProgress(p); } }); const dispose = () => tokenSource.dispose(); currentRequest.then(dispose, dispose); const onDone = Event.fromPromise(currentRequest); const onFirstRender = Event.any<any>(onDone, progressEmitter.event); const onFirstRenderStopwatch = Event.stopwatch(onFirstRender); /* __GDPR__ "searchResultsFirstRender" : { "duration" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true } } */ onFirstRenderStopwatch(duration => this.telemetryService.publicLog('searchResultsFirstRender', { duration })); const onDoneStopwatch = Event.stopwatch(onDone); const start = Date.now(); /* __GDPR__ "searchResultsFinished" : { "duration" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true } } */ onDoneStopwatch(duration => this.telemetryService.publicLog('searchResultsFinished', { duration })); currentRequest.then( value => this.onSearchCompleted(value, Date.now() - start), e => this.onSearchError(e, Date.now() - start)); return currentRequest; } private onSearchCompleted(completed: ISearchComplete | null, duration: number): ISearchComplete | null { if (!this._searchQuery) { throw new Error('onSearchCompleted must be called after a search is started'); } const options: IPatternInfo = objects.assign({}, this._searchQuery.contentPattern); delete options.pattern; const stats = completed && completed.stats as ITextSearchStats; const fileSchemeOnly = this._searchQuery.folderQueries.every(fq => fq.folder.scheme === 'file'); const otherSchemeOnly = this._searchQuery.folderQueries.every(fq => fq.folder.scheme !== 'file'); const scheme = fileSchemeOnly ? 'file' : otherSchemeOnly ? 'other' : 'mixed'; /* __GDPR__ "searchResultsShown" : { "count" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "fileCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, "options": { "${inline}": [ "${IPatternInfo}" ] }, "duration": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "isMeasurement": true }, "type" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, "scheme" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } } */ this.telemetryService.publicLog('searchResultsShown', { count: this._searchResult.count(), fileCount: this._searchResult.fileCount(), options, duration, type: stats && stats.type, scheme }); return completed; } private onSearchError(e: any, duration: number): void { if (errors.isPromiseCanceledError(e)) { this.onSearchCompleted(null, duration); } } private onSearchProgress(p: ISearchProgressItem): void { if ((<IFileMatch>p).resource) { this._searchResult.add([<IFileMatch>p], true); } } cancelSearch(): boolean { if (this.currentCancelTokenSource) { this.currentCancelTokenSource.cancel(); return true; } return false; } dispose(): void { this.cancelSearch(); this.searchResult.dispose(); super.dispose(); } } export type FileMatchOrMatch = FileMatch | Match; export type RenderableMatch = BaseFolderMatch | FolderMatch | FileMatch | Match; export class SearchWorkbenchService implements ISearchWorkbenchService { _serviceBrand: any; private _searchModel: SearchModel; constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { } get searchModel(): SearchModel { if (!this._searchModel) { this._searchModel = this.instantiationService.createInstance(SearchModel); } return this._searchModel; } } export const ISearchWorkbenchService = createDecorator<ISearchWorkbenchService>('searchWorkbenchService'); export interface ISearchWorkbenchService { _serviceBrand: any; readonly searchModel: SearchModel; } /** * Can add a range highlight decoration to a model. * It will automatically remove it when the model has its decorations changed. */ export class RangeHighlightDecorations implements IDisposable { private _decorationId: string | null = null; private _model: ITextModel | null = null; private readonly _modelDisposables = new DisposableStore(); constructor( @IModelService private readonly _modelService: IModelService ) { } removeHighlightRange() { if (this._model && this._decorationId) { this._model.deltaDecorations([this._decorationId], []); } this._decorationId = null; } highlightRange(resource: URI | ITextModel, range: Range, ownerId: number = 0): void { let model: ITextModel | null; if (URI.isUri(resource)) { model = this._modelService.getModel(resource); } else { model = resource; } if (model) { this.doHighlightRange(model, range); } } private doHighlightRange(model: ITextModel, range: Range) { this.removeHighlightRange(); this._decorationId = model.deltaDecorations([], [{ range: range, options: RangeHighlightDecorations._RANGE_HIGHLIGHT_DECORATION }])[0]; this.setModel(model); } private setModel(model: ITextModel) { if (this._model !== model) { this.clearModelListeners(); this._model = model; this._modelDisposables.add(this._model.onDidChangeDecorations((e) => { this.clearModelListeners(); this.removeHighlightRange(); this._model = null; })); this._modelDisposables.add(this._model.onWillDispose(() => { this.clearModelListeners(); this.removeHighlightRange(); this._model = null; })); } } private clearModelListeners() { this._modelDisposables.clear(); } dispose() { if (this._model) { this.removeHighlightRange(); this._modelDisposables.dispose(); this._model = null; } } private static readonly _RANGE_HIGHLIGHT_DECORATION = ModelDecorationOptions.register({ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, className: 'rangeHighlight', isWholeLine: true }); } function textSearchResultToMatches(rawMatch: ITextSearchMatch, fileMatch: FileMatch): Match[] { const previewLines = rawMatch.preview.text.split('\n'); if (Array.isArray(rawMatch.ranges)) { return rawMatch.ranges.map((r, i) => { const previewRange: ISearchRange = (<ISearchRange[]>rawMatch.preview.matches)[i]; return new Match(fileMatch, previewLines, previewRange, r); }); } else { const previewRange = <ISearchRange>rawMatch.preview.matches; const match = new Match(fileMatch, previewLines, previewRange, rawMatch.ranges); return [match]; } }