diff --git a/src/vs/base/common/map.ts b/src/vs/base/common/map.ts index 8977b6d4b32..af4b2e52cf0 100644 --- a/src/vs/base/common/map.ts +++ b/src/vs/base/common/map.ts @@ -48,6 +48,14 @@ export class SimpleMap { return keys; } + public values(): T[] { + var values: T[] = []; + for (let key in this.map) { + values.push(this.map[key].value); + } + return values; + } + public entries(): Entry[] { var entries: Entry[] = []; for (let key in this.map) { diff --git a/src/vs/workbench/parts/search/browser/replaceService.ts b/src/vs/workbench/parts/search/browser/replaceService.ts index 546febfef28..b802b5789b9 100644 --- a/src/vs/workbench/parts/search/browser/replaceService.ts +++ b/src/vs/workbench/parts/search/browser/replaceService.ts @@ -37,8 +37,8 @@ class EditorInputCache { editorInputPromise= this.createInput(fileMatch); this.cache.set(fileMatch.resource(), editorInputPromise); this.refreshInput(fileMatch, text, true); - fileMatch.addListener2('disposed', fileMatch => this.disposeInput(fileMatch)); - fileMatch.addListener2('changed', fileMatch => this.refreshInput(fileMatch, this.replaceTextcache.get(fileMatch.resource()), false)); + fileMatch.onDispose(() => this.disposeInput(fileMatch)); + fileMatch.onChange((modelChange) => this.refreshInput(fileMatch, this.replaceTextcache.get(fileMatch.resource()), modelChange)); } return editorInputPromise; } diff --git a/src/vs/workbench/parts/search/browser/searchActions.ts b/src/vs/workbench/parts/search/browser/searchActions.ts index 2ec2b93cadb..6c652b3baad 100644 --- a/src/vs/workbench/parts/search/browser/searchActions.ts +++ b/src/vs/workbench/parts/search/browser/searchActions.ts @@ -131,7 +131,7 @@ export class RemoveAction extends Action { super('remove', nls.localize('RemoveAction.label', "Remove"), 'action-remove'); } - public run(completely:boolean= true): TPromise { + public run(): TPromise { if (this.element === this.viewer.getFocus()) { let nextFocusElement= this.getNextFocusElement(); if (nextFocusElement) { @@ -148,7 +148,7 @@ export class RemoveAction extends Action { elementToRefresh= parent; } else { let parent: FileMatch= this.element.parent(); - parent.remove(this.element, completely); + parent.remove(this.element); elementToRefresh= parent.count() === 0 ? parent.parent() : parent; } @@ -185,8 +185,7 @@ export class ReplaceAllAction extends Action { public run(): TPromise { this.telemetryService.publicLog('replaceAll.action.selected'); - return this.replaceService.replace([this.fileMatch], this.fileMatch.parent().replaceText).then(() => { - new RemoveAction(this.viewer, this.fileMatch).run(); + return this.fileMatch.parent().replace(this.fileMatch, this.fileMatch.parent().searchModel.replaceText).then(() => { this.viewlet.open(this.fileMatch); }); } @@ -207,8 +206,7 @@ export class ReplaceAction extends Action { public run(): TPromise { this.telemetryService.publicLog('replace.action.selected'); - return this.replaceService.replace(this.element, this.element.parent().parent().replaceText).then(() => { - new RemoveAction(this.viewer, this.element).run(false); + return this.element.parent().replace(this.element, this.element.parent().parent().searchModel.replaceText).then(() => { this.viewlet.open(this.element); }); } diff --git a/src/vs/workbench/parts/search/browser/searchResultsView.ts b/src/vs/workbench/parts/search/browser/searchResultsView.ts index 0e1d019a9af..177b35e3b5b 100644 --- a/src/vs/workbench/parts/search/browser/searchResultsView.ts +++ b/src/vs/workbench/parts/search/browser/searchResultsView.ts @@ -91,19 +91,21 @@ class SearchActionProvider extends ContributableActionProvider { } public hasActions(tree: ITree, element: any): boolean { - return element instanceof FileMatch || (tree.getInput().isReplaceActive() || element instanceof Match) || super.hasActions(tree, element); + let input= tree.getInput(); + return element instanceof FileMatch || (input.searchModel.isReplaceActive() || element instanceof Match) || super.hasActions(tree, element); } public getActions(tree: ITree, element: any): TPromise { return super.getActions(tree, element).then(actions => { + let input= tree.getInput(); if (element instanceof FileMatch) { actions.unshift(new RemoveAction(tree, element)); - if (tree.getInput().isReplaceActive() && element.count() > 0) { + if (input.searchModel.isReplaceActive() && element.count() > 0) { actions.unshift(this.instantiationService.createInstance(ReplaceAllAction, tree, element, this.viewlet)); } } if (element instanceof Match && !(element instanceof EmptyMatch)) { - if (tree.getInput().isReplaceActive()) { + if (input.searchModel.isReplaceActive()) { actions.unshift(this.instantiationService.createInstance(ReplaceAction, tree, element, this.viewlet), new RemoveAction(tree, element)); } } @@ -173,12 +175,12 @@ export class SearchRenderer extends ActionsRenderer { elements.push(strings.escape(preview.before)); let input= tree.getInput(); - let showReplaceText= input.hasReplaceText(); + let showReplaceText= input.searchModel.hasReplaceText(); elements.push(''); elements.push(strings.escape(preview.inside)); if (showReplaceText) { elements.push(''); - elements.push(strings.escape(input.replaceText)); + elements.push(strings.escape(input.searchModel.replaceText)); } elements.push(''); elements.push(strings.escape(preview.after)); @@ -186,7 +188,7 @@ export class SearchRenderer extends ActionsRenderer { $('a.plain') .innerHtml(elements.join(strings.empty)) - .title((preview.before + (input.isReplaceActive() ? input.replaceText : preview.inside) + preview.after).trim().substr(0, 999)) + .title((preview.before + (input.searchModel.isReplaceActive() ? input.searchModel.replaceText : preview.inside) + preview.after).trim().substr(0, 999)) .appendTo(domElement); } @@ -212,9 +214,9 @@ export class SearchAccessibilityProvider implements IAccessibilityProvider { if (element instanceof Match) { let input= tree.getInput(); - if (input.isReplaceActive()) { + if (input.searchModel.isReplaceActive()) { let preview = element.preview(); - return nls.localize('replacePreviewResultAria', "Replace preview result, {0}", preview.before + input.replaceText + preview.after); + return nls.localize('replacePreviewResultAria', "Replace preview result, {0}", preview.before + input.searchModel.replaceText + preview.after); } return nls.localize('searchResultAria', "{0}, Search result", element.text()); } @@ -248,10 +250,11 @@ export class SearchController extends DefaultController { } private onDelete(tree: ITree, event: IKeyboardEvent): boolean { + let input= tree.getInput(); let result = false; let element = tree.getFocus(); if (element instanceof FileMatch || - (element instanceof Match && tree.getInput().isReplaceActive() && !(element instanceof EmptyMatch))) { + (element instanceof Match && input.searchModel.isReplaceActive() && !(element instanceof EmptyMatch))) { new RemoveAction(tree, element).run().done(null, errors.onUnexpectedError); result = true; } @@ -260,9 +263,10 @@ export class SearchController extends DefaultController { } private onReplace(tree: ITree, event: IKeyboardEvent): boolean { + let input= tree.getInput(); let result = false; let element = tree.getFocus(); - if (element instanceof Match && tree.getInput().isReplaceActive() && !(element instanceof EmptyMatch)) { + if (element instanceof Match && input.searchModel.isReplaceActive() && !(element instanceof EmptyMatch)) { this.instantiationService.createInstance(ReplaceAction, tree, element, this.viewlet).run().done(null, errors.onUnexpectedError); result = true; } diff --git a/src/vs/workbench/parts/search/browser/searchViewlet.ts b/src/vs/workbench/parts/search/browser/searchViewlet.ts index 0353ad21390..48751bb3450 100644 --- a/src/vs/workbench/parts/search/browser/searchViewlet.ts +++ b/src/vs/workbench/parts/search/browser/searchViewlet.ts @@ -20,7 +20,6 @@ import strings = require('vs/base/common/strings'); import dom = require('vs/base/browser/dom'); import {IAction, Action} from 'vs/base/common/actions'; import {StandardKeyboardEvent} from 'vs/base/browser/keyboardEvent'; -import timer = require('vs/base/common/timer'); import {Dimension, Builder, $} from 'vs/base/browser/builder'; import { FindInput } from 'vs/base/browser/ui/findinput/findInput'; import {ITree} from 'vs/base/parts/tree/browser/tree'; @@ -32,11 +31,11 @@ import {IEditorGroupService} from 'vs/workbench/services/group/common/groupServi import {getOutOfWorkspaceEditorResources} from 'vs/workbench/common/editor'; import {FileChangeType, FileChangesEvent, EventType as FileEventType} from 'vs/platform/files/common/files'; import {Viewlet} from 'vs/workbench/browser/viewlet'; -import {Match, EmptyMatch, FileMatch, SearchResult, FileMatchOrMatch} from 'vs/workbench/parts/search/common/searchModel'; +import {Match, EmptyMatch, FileMatch, SearchModel, FileMatchOrMatch} from 'vs/workbench/parts/search/common/searchModel'; import {getExcludes, QueryBuilder} from 'vs/workbench/parts/search/common/searchQuery'; import {VIEWLET_ID} from 'vs/workbench/parts/search/common/constants'; import {MessageType, InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; -import {ISearchProgressItem, IFileMatch, ISearchComplete, ISearchQuery, IQueryOptions, ISearchConfiguration} from 'vs/platform/search/common/search'; +import {ISearchProgressItem, ISearchComplete, ISearchQuery, IQueryOptions, ISearchConfiguration} from 'vs/platform/search/common/search'; import {IWorkbenchEditorService} from 'vs/workbench/services/editor/common/editorService'; import {IStorageService, StorageScope} from 'vs/platform/storage/common/storage'; import {IConfigurationService} from 'vs/platform/configuration/common/configuration'; @@ -67,10 +66,9 @@ export class SearchViewlet extends Viewlet { private currentRequest: PPromise; private loading: boolean; private queryBuilder: QueryBuilder; - private viewModel: SearchResult; + private viewModel: SearchModel; private callOnModelChange: lifecycle.IDisposable[]; - private replacingAll:boolean= false; private viewletVisible: IKeybindingContextKey; private actionRegistry: { [key: string]: Action; }; private tree: ITree; @@ -128,6 +126,7 @@ export class SearchViewlet extends Viewlet { public create(parent: Builder): TPromise { super.create(parent); + this.viewModel= this.instantiationService.createInstance(SearchModel); let builder: Builder; this.domNode = parent.div({ 'class': 'search-viewlet' @@ -312,21 +311,21 @@ export class SearchViewlet extends Viewlet { private refreshInputs(): void { if (this.viewModel) { - this.viewModel.matches().forEach((fileMatch) => { + this.viewModel.searchResult.matches().forEach((fileMatch) => { this.replaceService.refreshInput(fileMatch, this.viewModel.replaceText); }); } } private replaceAll(): void { - if (this.viewModel.count() === 0) { + if (this.viewModel.searchResult.count() === 0) { return; } let progressRunner= this.progressService.show(100); - let occurrences= this.viewModel.count(); - let fileCount= this.viewModel.fileCount(); + let occurrences= this.viewModel.searchResult.count(); + let fileCount= this.viewModel.searchResult.fileCount(); let replaceValue= this.searchWidget.getReplaceValue() || ''; let afterReplaceAllMessage= replaceValue ? nls.localize('replaceAll.message', "Replaced {0} occurrences across {1} files with {2}.", occurrences, fileCount, replaceValue) : nls.localize('removeAll.message', "Removed {0} occurrences across {1} files.", occurrences, fileCount); @@ -339,20 +338,12 @@ export class SearchViewlet extends Viewlet { }; if (this.messageService.confirm(confirmation)) { - let replaceAllTimer = this.telemetryService.timedPublicLog('replaceAll.started'); - this.replacingAll= true; - this.replaceService.replace(this.viewModel.matches(), replaceValue, progressRunner).then(() => { - replaceAllTimer.stop(); - this.replacingAll= false; - setTimeout(() => { - progressRunner.done(); - this.showEmptyStage(); - this.showMessage(afterReplaceAllMessage); - }, 200); - }, (error) => { - replaceAllTimer.stop(); + this.viewModel.searchResult.replaceAll(replaceValue, progressRunner).then(() => { + progressRunner.done(); + this.searchWidget.setReplaceAllActionState(false); + this.showMessage(afterReplaceAllMessage); + }, (error) => { progressRunner.done(); - this.replacingAll= false; errors.isPromiseCanceledError(error); this.messageService.show(Severity.Error, error); }); @@ -381,7 +372,9 @@ export class SearchViewlet extends Viewlet { ariaLabel: nls.localize('treeAriaLabel', "Search Results") }); + this.tree.setInput(this.viewModel.searchResult); this.toUnbind.push(renderer); + this.toUnbind.push(this.viewModel.searchResult.onChange((element) => this.tree.refresh(element))); this.toUnbind.push(this.tree.addListener2('selection', (event: any) => { let element: any; @@ -444,7 +437,7 @@ export class SearchViewlet extends Viewlet { // Enable highlights if there are searchresults if (this.viewModel) { - this.viewModel.toggleHighlights(visible); + this.viewModel.searchResult.toggleHighlights(visible); } // Open focused element from results in case the editor area is otherwise empty @@ -508,7 +501,7 @@ export class SearchViewlet extends Viewlet { } public clearSearchResults(): void { - this.disposeModel(); + this.viewModel.searchResult.clear(); this.showEmptyStage(); this.searchWidget.clear(); if (this.currentRequest) { @@ -673,14 +666,6 @@ export class SearchViewlet extends Viewlet { } private onQueryTriggered(query: ISearchQuery, excludePattern: string, includePattern: string): void { - if (this.currentRequest) { - this.currentRequest.cancel(); - this.currentRequest = null; - } - - let progressTimer = this.telemetryService.timedPublicLog('searchResultsFirstRender'); - let doneTimer = this.telemetryService.timedPublicLog('searchResultsFinished'); - // Progress total is 100% let progressTotal = 100; let progressRunner = this.progressService.show(progressTotal); @@ -688,7 +673,6 @@ export class SearchViewlet extends Viewlet { this.loading = true; this.searchWidget.searchInput.clearMessage(); - this.disposeModel(); this.showEmptyStage(); let handledMatches: { [id: string]: boolean } = Object.create(null); @@ -696,29 +680,25 @@ export class SearchViewlet extends Viewlet { // Auto-expand / collapse based on number of matches: // - alwaysExpandIfOneResult: expand file results if we have just one file result and less than 50 matches on a file // - expand file results if we have more than one file result and less than 10 matches on a file - if (this.viewModel) { - let matches = this.viewModel.matches(); - matches.forEach((match) => { - if (handledMatches[match.id()]) { - return; // if we once handled a result, do not do it again to keep results stable (the user might have expanded/collapsed meanwhile) - } + let matches = this.viewModel.searchResult.matches(); + matches.forEach((match) => { + if (handledMatches[match.id()]) { + return; // if we once handled a result, do not do it again to keep results stable (the user might have expanded/collapsed meanwhile) + } - handledMatches[match.id()] = true; + handledMatches[match.id()] = true; - let length = match.matches().length; - if (length < 10 || (alwaysExpandIfOneResult && matches.length === 1 && length < 50)) { - this.tree.expand(match).done(null, errors.onUnexpectedError); - } else { - this.tree.collapse(match).done(null, errors.onUnexpectedError); - } - }); - } + let length = match.matches().length; + if (length < 10 || (alwaysExpandIfOneResult && matches.length === 1 && length < 50)) { + this.tree.expand(match).done(null, errors.onUnexpectedError); + } else { + this.tree.collapse(match).done(null, errors.onUnexpectedError); + } + }); }; - let timerEvent = timer.start(timer.Topic.WORKBENCH, 'Search'); let isDone = false; let onComplete = (completed?: ISearchComplete) => { - timerEvent.stop(); isDone = true; // Complete up to 100% as needed @@ -729,23 +709,14 @@ export class SearchViewlet extends Viewlet { progressRunner.done(); } - // Show the final results - if (!this.viewModel) { - this.viewModel = this.instantiationService.createInstance(SearchResult, query.contentPattern); - - if (completed) { - this.viewModel.append(completed.results); - } - } this.viewModel.replaceText= this.searchWidget.getReplaceValue(); this.tree.refresh().then(() => { autoExpand(true); }).done(undefined, errors.onUnexpectedError); - let hasResults = !this.viewModel.isEmpty(); + let hasResults = !this.viewModel.searchResult.isEmpty(); this.loading = false; - this.telemetryService.publicLog('searchResultsShown', { count: this.viewModel.count(), fileCount: this.viewModel.fileCount() }); this.actionRegistry['refresh'].enabled = true; this.actionRegistry['vs.tree.collapse'].enabled = hasResults; @@ -817,14 +788,13 @@ export class SearchViewlet extends Viewlet { }); } } else { - this.viewModel.toggleHighlights(true); // show highlights + this.viewModel.searchResult.toggleHighlights(true); // show highlights // Indicate as status to ARIA - aria.status(nls.localize('ariaSearchResultsStatus', "Search returned {0} results in {1} files", this.viewModel.count(), this.viewModel.fileCount())); + aria.status(nls.localize('ariaSearchResultsStatus', "Search returned {0} results in {1} files", this.viewModel.searchResult.count(), this.viewModel.searchResult.fileCount())); } - doneTimer.stop(); - this.searchWidget.setReplaceAllActionState(this.viewModel.count() > 0); + this.searchWidget.setReplaceAllActionState(this.viewModel.searchResult.count() > 0); }; let onError = (e: any) => { @@ -834,9 +804,6 @@ export class SearchViewlet extends Viewlet { this.loading = false; isDone = true; progressRunner.done(); - progressTimer.stop(); - doneTimer.stop(); - this.messageService.show(2 /* ERROR */, e); } }; @@ -844,41 +811,14 @@ export class SearchViewlet extends Viewlet { let total: number = 0; let worked: number = 0; let visibleMatches = 0; - let matches: IFileMatch[] = []; let onProgress = (p: ISearchProgressItem) => { - // Progress if (p.total) { total = p.total; } - if (p.worked) { worked = p.worked; } - - // Results - if (p.resource) { - matches.push(p); - - // Create view model - if (!this.viewModel) { - this.viewModel = this.instantiationService.createInstance(SearchResult, query.contentPattern); - this.tree.setInput(this.viewModel).then(() => { - autoExpand(false); - this.callOnModelChange.push(this.viewModel.addListener2('changed', (e: any) => { - if (!this.replacingAll) { - this.tree.refresh(e, true); - if (e instanceof FileMatch) { - this.replaceService.refreshInput(e, this.viewModel.replaceText, true); - } - } - })); - }).done(null, errors.onUnexpectedError); - } - - this.viewModel.append([p]); - progressTimer.stop(); - } }; // Handle UI updates in an interval to show frequent progress and results @@ -904,15 +844,15 @@ export class SearchViewlet extends Viewlet { progressWorked++; progressRunner.worked(1); } - // Search result tree update - if (visibleMatches !== matches.length) { - visibleMatches = matches.length; - + let count= this.viewModel.searchResult.fileCount(); + if (visibleMatches !== count) { + visibleMatches= count; this.tree.refresh().then(() => { autoExpand(false); }).done(null, errors.onUnexpectedError); - + } + if (count > 0) { // since we have results now, enable some actions if (!this.actionRegistry['vs.tree.collapse'].enabled) { this.actionRegistry['vs.tree.collapse'].enabled = true; @@ -922,8 +862,8 @@ export class SearchViewlet extends Viewlet { this.searchWidget.setReplaceAllActionState(false); this.replaceService.disposeAllInputs(); - this.currentRequest = this.searchService.search(query); - this.currentRequest.then(onComplete, onError, onProgress); + this.currentRequest = this.viewModel.search(query); + this.currentRequest.done(onComplete, onError, onProgress); } private showEmptyStage(): void { @@ -936,7 +876,6 @@ export class SearchViewlet extends Viewlet { // clean up ui this.replaceService.disposeAllInputs(); this.messages.hide(); - this.tree.setInput(this.instantiationService.createInstance(SearchResult, null)).done(null, errors.onUnexpectedError); this.results.show(); this.tree.onVisible(); } @@ -1009,10 +948,10 @@ export class SearchViewlet extends Viewlet { return; } - let matches = this.viewModel.matches(); + let matches = this.viewModel.searchResult.matches(); for (let i = 0, len = matches.length; i < len; i++) { if (e.resource.toString() === matches[i].resource().toString()) { - this.viewModel.remove(matches[i]); + this.viewModel.searchResult.remove(matches[i]); } } } @@ -1022,11 +961,11 @@ export class SearchViewlet extends Viewlet { return; } - let matches = this.viewModel.matches(); + let matches = this.viewModel.searchResult.matches(); for (let i = 0, len = matches.length; i < len; i++) { if (e.contains(matches[i].resource(), FileChangeType.DELETED)) { - this.viewModel.remove(matches[i]); + this.viewModel.searchResult.remove(matches[i]); } } } @@ -1052,17 +991,8 @@ export class SearchViewlet extends Viewlet { this.inputPatternIncludes.dispose(); this.inputPatternExclusions.dispose(); - this.disposeModel(); + this.viewModel.dispose(); super.dispose(); } - - - private disposeModel(): void { - if (this.viewModel) { - this.viewModel.dispose(); - this.viewModel = null; - } - this.callOnModelChange = lifecycle.dispose(this.callOnModelChange); - } } \ No newline at end of file diff --git a/src/vs/workbench/parts/search/common/searchModel.ts b/src/vs/workbench/parts/search/common/searchModel.ts index 0643c099b81..abe0ad55e24 100644 --- a/src/vs/workbench/parts/search/common/searchModel.ts +++ b/src/vs/workbench/parts/search/common/searchModel.ts @@ -2,32 +2,38 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -'use strict'; -import {RunOnceScheduler} from 'vs/base/common/async'; -import strings = require('vs/base/common/strings'); -import URI from 'vs/base/common/uri'; -import * as Set from 'vs/base/common/set'; +import * as timer from 'vs/base/common/timer'; import paths = require('vs/base/common/paths'); -import lifecycle = require('vs/base/common/lifecycle'); -import collections = require('vs/base/common/collections'); -import {EventEmitter} from 'vs/base/common/eventEmitter'; -import {IModel, ITextModel, IModelDeltaDecoration, OverviewRulerLane, TrackedRangeStickiness, IModelDecorationOptions} from 'vs/editor/common/editorCommon'; -import {Range} from 'vs/editor/common/core/range'; -import {IModelService} from 'vs/editor/common/services/modelService'; +import strings = require('vs/base/common/strings'); +import errors = require('vs/base/common/errors'); +import { RunOnceScheduler } from 'vs/base/common/async'; +import { IDisposable, Disposable } from 'vs/base/common/lifecycle'; +import { TPromise, PPromise } from 'vs/base/common/winjs.base'; +import URI from 'vs/base/common/uri'; +import { SimpleMap } from 'vs/base/common/map'; +import { ArraySet } from 'vs/base/common/set'; +import Event, { Emitter } from 'vs/base/common/event'; import * as Search from 'vs/platform/search/common/search'; +import { ISearchProgressItem, ISearchComplete, ISearchQuery } from 'vs/platform/search/common/search'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { Range } from 'vs/editor/common/core/range'; +import { IModel, IModelDeltaDecoration, OverviewRulerLane, TrackedRangeStickiness, IModelDecorationOptions } from 'vs/editor/common/editorCommon'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IModelService } from 'vs/editor/common/services/modelService'; +import { ISearchService } from 'vs/platform/search/common/search'; +import { IReplaceService } from 'vs/workbench/parts/search/common/replace'; +import { IProgressRunner } from 'vs/platform/progress/common/progress'; export class Match { - private _parent: FileMatch; private _lineText: string; private _id: string; private _range: Range; - constructor(parent: FileMatch, text: string, lineNumber: number, offset: number, length: number) { - this._parent = parent; + constructor(private _parent: FileMatch, text: string, lineNumber: number, offset: number, length: number) { this._lineText = text; - this._id = parent.id() + '>' + lineNumber + '>' + offset; + this._id = this._parent.id() + '>' + lineNumber + '>' + offset; this._range = new Range(1 + lineNumber, 1 + offset, 1 + lineNumber, 1 + offset + length); } @@ -69,75 +75,7 @@ export class EmptyMatch extends Match { } } -export class FileMatch extends EventEmitter implements lifecycle.IDisposable { - - private _parent: SearchResult; - private _resource: URI; - _removedMatches: Set.ArraySet; - _matches: { [key: string]: Match }; - - constructor(parent: SearchResult, resource: URI) { - super(); - this._resource = resource; - this._parent = parent; - this._matches = Object.create(null); - this._removedMatches= new Set.ArraySet(); - } - - public dispose(): void { - this.emit('disposed', this); - } - - public id(): string { - return this.resource().toString(); - } - - public parent(): SearchResult { - return this._parent; - } - - public add(match: Match): void { - this._matches[match.id()] = match; - this.emit('changed', this); - } - - public remove(match: Match, completely:boolean= true): void { - delete this._matches[match.id()]; - if (completely) { - this._removedMatches.set(match.id()); - } - if (this.count() === 0) { - this.add(new EmptyMatch(this)); - } - this.emit('changed', this); - } - - public matches(): Match[] { - return collections.values(this._matches); - } - - public count(): number { - let result = 0; - for (let key in this._matches) { - if (!(this._matches[key] instanceof EmptyMatch)) { - result += 1; - } - } - return result; - } - - public resource(): URI { - return this._resource; - } - - public name(): string { - return paths.basename(this.resource().fsPath); - } -} - -export type FileMatchOrMatch = FileMatch | Match; - -export class LiveFileMatch extends FileMatch implements lifecycle.IDisposable { +export class FileMatch extends Disposable { private static DecorationOption: IModelDecorationOptions = { stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, @@ -149,105 +87,292 @@ export class LiveFileMatch extends FileMatch implements lifecycle.IDisposable { } }; + private _onChange= this._register(new Emitter()); + public onChange: Event = this._onChange.event; + + private _onDispose= this._register(new Emitter()); + public onDispose: Event = this._onDispose.event; + + private _resource: URI; private _model: IModel; - private _query: Search.IPatternInfo; + private _modelListener: IDisposable; + private _matches: SimpleMap; + private _removedMatches: ArraySet; + private _updateScheduler: RunOnceScheduler; private _modelDecorations: string[] = []; - private _unbind: lifecycle.IDisposable[] = []; - _diskFileMatch: FileMatch; - constructor(parent: SearchResult, resource: URI, query: Search.IPatternInfo, model: IModel, fileMatch: FileMatch) { - super(parent, resource); + constructor(private _query: Search.IPatternInfo, private _parent: SearchResult, private rawMatch: Search.IFileMatch, + @IModelService private modelService: IModelService, @IReplaceService private replaceService: IReplaceService) { + super(); + this._resource = this.rawMatch.resource; + this._matches = new SimpleMap(); + this._removedMatches= new ArraySet(); + this._updateScheduler = new RunOnceScheduler(this.updateMatches.bind(this), 250); - this._query = query; - this._model = model; - this._diskFileMatch = fileMatch; - this._removedMatches= fileMatch._removedMatches; - this._updateScheduler = new RunOnceScheduler(this._updateMatches.bind(this), 250); - this._unbind.push(this._model.onDidChangeContent(_ => this._updateScheduler.schedule())); - this._updateMatches(); + this.createMatches(); + this.registerListeners(); } - public dispose(): void { - this._unbind = lifecycle.dispose(this._unbind); - if (!this._isTextModelDisposed()) { - this._model.deltaDecorations(this._modelDecorations, []); + private createMatches(): void { + let model = this.modelService ? this.modelService.getModel(this._resource) : null; + if (model) { + this.bindModel(model); + this.updateMatches(); + } else { + this.rawMatch.lineMatches.forEach((rawLineMatch) => { + rawLineMatch.offsetAndLengths.forEach(offsetAndLength => { + let match = new Match(this, rawLineMatch.preview, rawLineMatch.lineNumber, offsetAndLength[0], offsetAndLength[1]); + this._matches.set(match.id(), match); + }); + }); } - super.dispose(); } - private _updateMatches(): void { + private registerListeners(): void { + if (this.modelService) { + this._register(this.modelService.onModelAdded((model: IModel) => { + if (model.uri.toString() === this._resource.toString()) { + this.bindModel(model); + } + })); + } + + this._register(this.onChange(() => { + if (this.count() === 0) { + let emptyMatch = new EmptyMatch(this); + this._matches.set(emptyMatch.id(), emptyMatch); + } + })); + } + + private bindModel(model: IModel): 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.updateMatches(); + this.unbindModel(); + } + + private unbindModel(): void { + if (this._model) { + this._updateScheduler.cancel(); + this._model.deltaDecorations(this._modelDecorations, []); + this._model= null; + this._modelListener.dispose(); + } + } + + private updateMatches(): void { // this is called from a timeout and might fire // after the model has been disposed - if (this._isTextModelDisposed()) { + if (!this._model) { return; } - this._matches = Object.create(null); + this._matches = new SimpleMap(); let matches = this._model .findMatches(this._query.pattern, this._model.getFullModelRange(), this._query.isRegExp, this._query.isCaseSensitive, this._query.isWordMatch); matches.forEach(range => { let match= new Match(this, this._model.getLineContent(range.startLineNumber), range.startLineNumber - 1, range.startColumn - 1, range.endColumn - range.startColumn); if (!this._removedMatches.contains(match.id())) { - this.add(match); + this._matches.set(match.id(), match); } }); - if (this.count() === 0) { - this.add(new EmptyMatch(this)); - } - - this.parent().emit('changed', this); + this._onChange.fire(true); this.updateHighlights(); } - updateHighlights(): void { - - if ((this._model).isDisposed()) { + public updateHighlights(): void { + if (!this._model) { return; } - if (this.parent()._showHighlights) { + if (this.parent().showHighlights) { this._modelDecorations = this._model.deltaDecorations(this._modelDecorations, this.matches().filter(match => !(match instanceof EmptyMatch)).map(match => { range: match.range(), - options: LiveFileMatch.DecorationOption + options: FileMatch.DecorationOption })); } else { this._modelDecorations = this._model.deltaDecorations(this._modelDecorations, []); } } - private _isTextModelDisposed(): boolean { - return !this._model || (this._model).isDisposed(); + public id(): string { + return this.resource().toString(); } - public remove(match: Match, completely: boolean= true): void { - super.remove(match, completely); - this._diskFileMatch.remove(match, completely); + public parent(): SearchResult { + return this._parent; + } + + public matches(): Match[] { + return this._matches.values(); + } + + public remove(match: Match): void { + this._matches.delete(match.id()); + this._removedMatches.set(match.id()); + this._onChange.fire(false); + } + + public replace(match: Match, replaceText: string): TPromise { + return this.replaceService.replace(match, replaceText).then(() => { + this._matches.delete(match.id()); + this._onChange.fire(false); + }); + } + + public count(): number { + let result = 0; + this.matches().forEach(element => { + if (!(element instanceof EmptyMatch)) { + result += 1; + } + }); + return result; + } + + public resource(): URI { + return this._resource; + } + + public name(): string { + return paths.basename(this.resource().fsPath); + } + + public dispose(): void { + this.unbindModel(); + this._onDispose.fire(); + super.dispose(); } } -export class SearchResult extends EventEmitter { +export class SearchResult extends Disposable { - private _modelService: IModelService; - private _query: Search.IPatternInfo; - private _replace: string= null; - private _disposables: lifecycle.IDisposable[] = []; - private _matches: { [key: string]: FileMatch; } = Object.create(null); + private _onChange= this._register(new Emitter()); + public onChange: Event = this._onChange.event; - _showHighlights: boolean; + private _fileMatches: SimpleMap; + private _showHighlights: boolean; + private _replacingAll: boolean= false; - constructor(query: Search.IPatternInfo, @IModelService modelService: IModelService) { + constructor(private _searchModel: SearchModel, @IReplaceService private replaceService: IReplaceService, @ITelemetryService private telemetryService: ITelemetryService, + @IInstantiationService private instantiationService: IInstantiationService) { super(); - this._modelService = modelService; - this._query = query; + this._fileMatches= new SimpleMap(); + } - if (this._query) { - this._modelService.onModelAdded(this._onModelAdded, this, this._disposables); - this._modelService.onModelRemoved(this._onModelRemoved, this, this._disposables); + public get searchModel(): SearchModel { + return this._searchModel; + } + + public add(query: Search.IPatternInfo, raw: Search.IFileMatch[], silent:boolean= false): void { + raw.forEach((rawFileMatch) => { + if (!this._fileMatches.has(rawFileMatch.resource)){ + let fileMatch= this.instantiationService.createInstance(FileMatch, query, this, rawFileMatch); + this._fileMatches.set(rawFileMatch.resource, fileMatch); + let disposable= fileMatch.onChange(() => this.onFileChange(fileMatch)); + fileMatch.onDispose(() => disposable.dispose()); + } + }); + if (!silent) { + this._onChange.fire(this); } } + public clear(): void { + this.matches().forEach((fileMatch: FileMatch) => fileMatch.dispose()); + this._fileMatches.clear(); + this._onChange.fire(this); + } + + public remove(match: FileMatch): void { + this._fileMatches.delete(match.resource()); + match.dispose(); + this._onChange.fire(this); + } + + public replace(match: FileMatch, replaceText: string): TPromise { + return this.replaceService.replace([match], replaceText).then(() => { + this.remove(match); + }); + } + + public replaceAll(replaceText: string, progressRunner: IProgressRunner): TPromise { + this._replacingAll= true; + let replaceAllTimer = this.telemetryService.timedPublicLog('replaceAll.started'); + return this.replaceService.replace(this.matches(), replaceText, progressRunner).then(() => { + replaceAllTimer.stop(); + this._replacingAll= false; + this.clear(); + }, () => { + this._replacingAll= false; + replaceAllTimer.stop(); + }); + } + + public matches(): FileMatch[] { + return this._fileMatches.values(); + } + + public isEmpty(): boolean { + return this.fileCount() === 0; + } + + public fileCount(): number { + return this._fileMatches.size; + } + + public count(): number { + return this.matches().reduce((prev, match) => prev + match.count(), 0); + } + + public get showHighlights(): boolean { + return this._showHighlights; + } + + public toggleHighlights(value: boolean): void { + if (this._showHighlights === value) { + return; + } + this._showHighlights = value; + this.matches().forEach((fileMatch: FileMatch) => { + fileMatch.updateHighlights(); + }); + } + + private onFileChange(file: FileMatch): void { + if (!this._replacingAll) { + this._onChange.fire(file); + } + } +} + +export class SearchModel extends Disposable { + + private _searchResult: SearchResult; + private _searchQuery: ISearchQuery= null; + private _replaceText: string= null; + + private currentRequest: PPromise; + private progressTimer: timer.ITimerEvent; + private doneTimer: timer.ITimerEvent; + private timerEvent: timer.ITimerEvent; + + constructor(@ISearchService private searchService, @ITelemetryService private telemetryService: ITelemetryService, @IInstantiationService private instantiationService: IInstantiationService) { + super(); + this._searchResult= this.instantiationService.createInstance(SearchResult, this); + } + /** * Return true if replace is enabled otherwise false */ @@ -261,11 +386,15 @@ export class SearchResult extends EventEmitter { * Can be empty. */ public get replaceText(): string { - return this._replace; + return this._replaceText; } public set replaceText(replace: string) { - this._replace= replace; + this._replaceText= replace; + } + + public get searchResult():SearchResult { + return this._searchResult; } /** @@ -276,101 +405,59 @@ export class SearchResult extends EventEmitter { return this.isReplaceActive() && !!this.replaceText; } - private _onModelAdded(model: IModel): void { - let resource = model.uri, - fileMatch = this._matches[resource.toString()]; + public search(query: ISearchQuery, silent: boolean= true): PPromise { + this.cancelSearch(); + this.searchResult.clear(); - if (fileMatch) { - let liveMatch = new LiveFileMatch(this, resource, this._query, model, fileMatch); - liveMatch.updateHighlights(); - this._matches[resource.toString()] = liveMatch; - this.emit('changed', this); + this._searchQuery= query; + this.progressTimer = this.telemetryService.timedPublicLog('searchResultsFirstRender'); + this.doneTimer = this.telemetryService.timedPublicLog('searchResultsFinished'); + this.timerEvent = timer.start(timer.Topic.WORKBENCH, 'Search'); + this.currentRequest = this.searchService.search(this._searchQuery); + + this.currentRequest.then(value => this.onSearchCompleted(value, silent), + e => this.onSearchError(e, silent), + p => this.onSearchProgress(p, silent)); + + return this.currentRequest; + } + + private onSearchCompleted(completed: ISearchComplete, silent: boolean): ISearchComplete { + this.timerEvent.stop(); + this._searchResult.add(this._searchQuery.contentPattern, completed.results, silent); + this.telemetryService.publicLog('searchResultsShown', { count: this._searchResult.count(), fileCount: this._searchResult.fileCount() }); + this.doneTimer.stop(); + return completed; + } + + private onSearchError(e: any, silent: boolean): void { + if (errors.isPromiseCanceledError(e)) { + this.onSearchCompleted(null, silent); + } else { + this.progressTimer.stop(); + this.doneTimer.stop(); } } - private _onModelRemoved(model: IModel): void { - - let resource = model.uri, - fileMatch = this._matches[resource.toString()]; - - if (fileMatch instanceof LiveFileMatch) { - this.deferredEmit(() => { - this.remove(fileMatch); - this._matches[resource.toString()] = fileMatch._diskFileMatch; - }); + private onSearchProgress(p: ISearchProgressItem, silent: boolean): void { + if (p.resource) { + this._searchResult.add(this._searchQuery.contentPattern, [p], silent); + this.progressTimer.stop(); } } - public append(raw: Search.IFileMatch[]): void { - raw.forEach((rawFileMatch) => { - - let fileMatch = this._getOrAdd(rawFileMatch); - - if (fileMatch instanceof LiveFileMatch) { - fileMatch = (fileMatch)._diskFileMatch; - } - - rawFileMatch.lineMatches.forEach((rawLineMatch) => { - rawLineMatch.offsetAndLengths.forEach(offsetAndLength => { - let match = new Match(fileMatch, rawLineMatch.preview, rawLineMatch.lineNumber, offsetAndLength[0], offsetAndLength[1]); - fileMatch.add(match); - }); - }); - }); - } - - private _getOrAdd(raw: Search.IFileMatch): FileMatch { - return collections.lookupOrInsert(this._matches, raw.resource.toString(), () => { - - let model = this._modelService.getModel(raw.resource), - fileMatch = new FileMatch(this, raw.resource); - - if (model && this._query) { - fileMatch = new LiveFileMatch(this, raw.resource, this._query, model, fileMatch); - } - return fileMatch; - }); - } - - public remove(match: FileMatch): void { - delete this._matches[match.resource().toString()]; - match.dispose(); - this.emit('changed', this); - } - - public matches(): FileMatch[] { - return collections.values(this._matches); - } - - public isEmpty(): boolean { - return this.fileCount() === 0; - } - - public fileCount(): number { - return Object.keys(this._matches).length; - } - - public count(): number { - return this.matches().reduce((prev, match) => prev + match.count(), 0); - } - - public toggleHighlights(value: boolean): void { - if (this._showHighlights === value) { - return; - } - this._showHighlights = value; - - for (let resource in this._matches) { - let match = this._matches[resource]; - if (match instanceof LiveFileMatch) { - match.updateHighlights(); - } + public cancelSearch(): void{ + if (this.currentRequest) { + this.currentRequest.cancel(); + this.currentRequest = null; } } public dispose(): void { - this._disposables = lifecycle.dispose(this._disposables); - lifecycle.dispose(this.matches()); + this.cancelSearch(); + this.searchResult.dispose(); super.dispose(); } -} \ No newline at end of file +} + +export type FileMatchOrMatch = FileMatch | Match; \ No newline at end of file diff --git a/src/vs/workbench/parts/search/test/browser/searchViewlet.test.ts b/src/vs/workbench/parts/search/test/browser/searchViewlet.test.ts index 44b0a5a74eb..e7a57dae2ae 100644 --- a/src/vs/workbench/parts/search/test/browser/searchViewlet.test.ts +++ b/src/vs/workbench/parts/search/test/browser/searchViewlet.test.ts @@ -8,33 +8,30 @@ import * as assert from 'assert'; import uri from 'vs/base/common/uri'; import {Match, FileMatch, SearchResult} from 'vs/workbench/parts/search/common/searchModel'; import {IInstantiationService} from 'vs/platform/instantiation/common/instantiation'; -import {IRequestService} from 'vs/platform/request/common/request'; -import {IWorkspaceContextService} from 'vs/platform/workspace/common/workspace'; -import {IModelService} from 'vs/editor/common/services/modelService'; import {ServiceCollection} from 'vs/platform/instantiation/common/serviceCollection'; import {InstantiationService} from 'vs/platform/instantiation/common/instantiationService'; import {SearchSorter, SearchDataSource} from 'vs/workbench/parts/search/browser/searchResultsView'; -import {TestContextService} from 'vs/workbench/test/common/servicesTestUtils'; +import {IFileMatch, ILineMatch} from 'vs/platform/search/common/search'; + +function aFileMatch(path: string, searchResult?: SearchResult, ...lineMatches: ILineMatch[]): FileMatch { + let rawMatch: IFileMatch= { + resource: uri.file('C:\\' + path), + lineMatches: lineMatches + }; + return new FileMatch(null, searchResult, rawMatch, null, null); +} suite('Search - Viewlet', () => { let instantiation: IInstantiationService; setup(() => { - let services = new ServiceCollection(); - services.set(IWorkspaceContextService, new TestContextService()); - services.set(IRequestService, { - getRequestUrl: () => 'file:///folder/file.txt' - }); - services.set(IModelService, { - getModel: () => null - }); - instantiation = new InstantiationService(services); + instantiation = new InstantiationService(new ServiceCollection()); }); test('Data Source', function () { let ds = new SearchDataSource(); let result = instantiation.createInstance(SearchResult, null); - result.append([{ + result.add(null, [{ resource: uri.parse('file:///c:/foo'), lineMatches: [{ lineNumber: 1, preview: 'bar', offsetAndLengths: [[0, 1]] }] }]); @@ -53,9 +50,9 @@ suite('Search - Viewlet', () => { }); test('Sorter', function () { - let fileMatch1 = new FileMatch(null, uri.file('C:\\foo')); - let fileMatch2 = new FileMatch(null, uri.file('C:\\with\\path')); - let fileMatch3 = new FileMatch(null, uri.file('C:\\with\\path\\foo')); + let fileMatch1 = aFileMatch('C:\\foo'); + let fileMatch2 = aFileMatch('C:\\with\\path'); + let fileMatch3 = aFileMatch('C:\\with\\path\\foo'); let lineMatch1 = new Match(fileMatch1, 'bar', 1, 1, 1); let lineMatch2 = new Match(fileMatch1, 'bar', 2, 1, 1); let lineMatch3 = new Match(fileMatch1, 'bar', 2, 1, 1); diff --git a/src/vs/workbench/parts/search/test/common/searchModel.test.ts b/src/vs/workbench/parts/search/test/common/searchModel.test.ts index 0e4716793de..69c91e90dfc 100644 --- a/src/vs/workbench/parts/search/test/common/searchModel.test.ts +++ b/src/vs/workbench/parts/search/test/common/searchModel.test.ts @@ -6,49 +6,29 @@ import * as assert from 'assert'; import {Match, FileMatch, SearchResult, EmptyMatch} from 'vs/workbench/parts/search/common/searchModel'; -import {Model} from 'vs/editor/common/model/model'; -import {Emitter} from 'vs/base/common/event'; -import {IModel} from 'vs/editor/common/editorCommon'; import URI from 'vs/base/common/uri'; -import {IRequestService} from 'vs/platform/request/common/request'; -import {IWorkspaceContextService} from 'vs/platform/workspace/common/workspace'; -import {IModelService} from 'vs/editor/common/services/modelService'; import {ServiceCollection} from 'vs/platform/instantiation/common/serviceCollection'; import {InstantiationService} from 'vs/platform/instantiation/common/instantiationService'; -import {TestContextService} from 'vs/workbench/test/common/servicesTestUtils'; import {IInstantiationService} from 'vs/platform/instantiation/common/instantiation'; -import {IFileMatch} from 'vs/platform/search/common/search'; +import {IFileMatch, ILineMatch} from 'vs/platform/search/common/search'; -function toUri(path: string): URI { - return URI.file('C:\\' + path); +function aFileMatch(path: string, searchResult?: SearchResult, ...lineMatches: ILineMatch[]): FileMatch { + let rawMatch: IFileMatch= { + resource: URI.file('C:\\' + path), + lineMatches: lineMatches + }; + return new FileMatch(null, searchResult, rawMatch, null, null); } suite('Search - Model', () => { let instantiation: IInstantiationService; - let oneModel: IModel; setup(() => { - let emitter = new Emitter(); - - oneModel = Model.createFromString('line1\nline2\nline3', undefined, undefined, URI.parse('file:///folder/file.txt')); - let services = new ServiceCollection(); - services.set(IWorkspaceContextService, new TestContextService()); - services.set(IRequestService, { - getRequestUrl: () => 'file:///folder/file.txt' - }); - services.set(IModelService, { - getModel: () => oneModel, - onModelAdded: emitter.event - }); - instantiation = new InstantiationService(services); - }); - - teardown(() => { - oneModel.dispose(); + instantiation = new InstantiationService(new ServiceCollection()); }); test('Line Match', function () { - let fileMatch = new FileMatch(null, toUri('folder\\file.txt')); + let fileMatch = aFileMatch('folder\\file.txt'); let lineMatch = new Match(fileMatch, 'foo bar', 1, 0, 3); assert.equal(lineMatch.text(), 'foo bar'); assert.equal(lineMatch.range().startLineNumber, 2); @@ -58,24 +38,24 @@ suite('Search - Model', () => { }); test('Line Match - Remove', function () { - - let fileMatch = new FileMatch(null, toUri('folder\\file.txt')); - let lineMatch = new Match(fileMatch, 'foo bar', 1, 0, 3); - fileMatch.add(lineMatch); - assert.equal(fileMatch.matches().length, 1); + let fileMatch = aFileMatch('folder\\file.txt', null, ...[{ + preview: 'foo bar', + lineNumber: 1, + offsetAndLengths: [[0, 3]] + }]); + let lineMatch = fileMatch.matches()[0]; fileMatch.remove(lineMatch); assert.equal(fileMatch.matches().length, 1); assert.ok(fileMatch.matches()[0] instanceof EmptyMatch); }); test('File Match', function () { - - let fileMatch = new FileMatch(null, toUri('folder\\file.txt')); + let fileMatch = aFileMatch('folder\\file.txt'); assert.equal(fileMatch.matches(), 0); assert.equal(fileMatch.resource().toString(), 'file:///c%3A/folder/file.txt'); assert.equal(fileMatch.name(), 'file.txt'); - fileMatch = new FileMatch(null, toUri('file.txt')); + fileMatch = aFileMatch('file.txt'); assert.equal(fileMatch.matches(), 0); assert.equal(fileMatch.resource().toString(), 'file:///c%3A/file.txt'); assert.equal(fileMatch.name(), 'file.txt'); @@ -97,7 +77,7 @@ suite('Search - Model', () => { }] }); } - searchResult.append(raw); + searchResult.add(null, raw); assert.equal(searchResult.isEmpty(), false); assert.equal(searchResult.matches().length, 10); @@ -106,7 +86,7 @@ suite('Search - Model', () => { test('Alle Drei Zusammen', function () { let searchResult = instantiation.createInstance(SearchResult, null); - let fileMatch = new FileMatch(searchResult, toUri('far\\boo')); + let fileMatch = aFileMatch('far\\boo', searchResult); let lineMatch = new Match(fileMatch, 'foo bar', 1, 0, 3); assert(lineMatch.parent() === fileMatch);