diff --git a/src/vs/workbench/browser/labels.ts b/src/vs/workbench/browser/labels.ts index c76599112600104e1fb52b4534401a70d88d0d1c..b22809bdb69f0e4dec2a44a6986ae6890813ef27 100644 --- a/src/vs/workbench/browser/labels.ts +++ b/src/vs/workbench/browser/labels.ts @@ -12,7 +12,7 @@ import { IExtensionService } from 'vs/platform/extensions/common/extensions'; import { IModeService } from 'vs/editor/common/services/modeService'; import { IEditorInput } from 'vs/platform/editor/common/editor'; import { toResource } from 'vs/workbench/common/editor'; -import { getPathLabel } from 'vs/base/common/labels'; +import { getPathLabel, IRootProvider } from 'vs/base/common/labels'; import { PLAINTEXT_MODE_ID } from 'vs/editor/common/modes/modesRegistry'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; @@ -168,6 +168,7 @@ export class EditorLabel extends ResourceLabel { export interface IFileLabelOptions extends IResourceLabelOptions { hideLabel?: boolean; hidePath?: boolean; + root?: uri; } export class FileLabel extends ResourceLabel { @@ -188,11 +189,14 @@ export class FileLabel extends ResourceLabel { public setFile(resource: uri, options: IFileLabelOptions = Object.create(null)): void { const hidePath = options.hidePath || (resource.scheme === Schemas.untitled && !this.untitledEditorService.hasAssociatedFilePath(resource)); - + const rootProvider: IRootProvider = options.root ? { + getRoot(): uri { return options.root; }, + getWorkspace(): { roots: uri[]; } { return { roots: [options.root] }; }, + } : this.contextService; this.setLabel({ resource, name: !options.hideLabel ? paths.basename(resource.fsPath) : void 0, - description: !hidePath ? getPathLabel(paths.dirname(resource.fsPath), this.contextService, this.environmentService) : void 0 + description: !hidePath ? getPathLabel(paths.dirname(resource.fsPath), rootProvider, this.environmentService) : void 0 }, options); } } diff --git a/src/vs/workbench/parts/search/browser/media/searchviewlet.css b/src/vs/workbench/parts/search/browser/media/searchviewlet.css index 946b5b2edfa8d2c530c4ab20ebe802e9a07f7260..e568bf2982ca8d87e5e8e2c319aa00a11a6200b1 100644 --- a/src/vs/workbench/parts/search/browser/media/searchviewlet.css +++ b/src/vs/workbench/parts/search/browser/media/searchviewlet.css @@ -170,6 +170,7 @@ overflow: hidden; } +.search-viewlet .foldermatch, .search-viewlet .filematch { display: flex; position: relative; @@ -177,10 +178,12 @@ padding: 0; } +.search-viewlet .foldermatch .monaco-icon-label, .search-viewlet .filematch .monaco-icon-label { flex: 1; } +.search-viewlet .foldermatch .directory, .search-viewlet .filematch .directory { opacity: 0.7; font-size: 0.9em; @@ -266,9 +269,11 @@ margin-right: 12px; } -.search-viewlet > .results > .monaco-tree .monaco-tree-row:hover .content .monaco-count-badge, -.search-viewlet > .results > .monaco-tree.focused .monaco-tree-row.focused .content .monaco-count-badge { - display: none; +.search-viewlet > .results > .monaco-tree .monaco-tree-row:hover .content .filematch .monaco-count-badge, +.search-viewlet > .results > .monaco-tree .monaco-tree-row:hover .content .linematch .monaco-count-badge, +.search-viewlet > .results > .monaco-tree.focused .monaco-tree-row.focused .content .filematch .monaco-count-badge, +.search-viewlet > .results > .monaco-tree.focused .monaco-tree-row.focused .content .linematch .monaco-count-badge { + display: none; } .search-viewlet .focused .monaco-tree-row.selected:not(.highlighted) > .content.actions .action-remove, @@ -382,6 +387,7 @@ opacity: .5; } +.vs-dark .search-viewlet .foldermatch, .vs-dark .search-viewlet .filematch { padding: 0; } @@ -398,6 +404,7 @@ /* High Contrast Theming */ +.hc-black .monaco-workbench .search-viewlet .foldermatch, .hc-black .monaco-workbench .search-viewlet .filematch, .hc-black .monaco-workbench .search-viewlet .linematch { line-height: 20px; diff --git a/src/vs/workbench/parts/search/browser/searchActions.ts b/src/vs/workbench/parts/search/browser/searchActions.ts index 33673e1f9e922d552a004c9c0d2d56d4408aabe5..1b600f823b56bf10b96eec00a3c8134c83f9c76c 100644 --- a/src/vs/workbench/parts/search/browser/searchActions.ts +++ b/src/vs/workbench/parts/search/browser/searchActions.ts @@ -15,7 +15,7 @@ import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; import { ITree } from 'vs/base/parts/tree/browser/tree'; import { INavigator } from 'vs/base/common/iterator'; import { SearchViewlet } from 'vs/workbench/parts/search/browser/searchViewlet'; -import { SearchResult, Match, FileMatch, FileMatchOrMatch } from 'vs/workbench/parts/search/common/searchModel'; +import { Match, FileMatch, FileMatchOrMatch, FolderMatch } from 'vs/workbench/parts/search/common/searchModel'; import { IReplaceService } from 'vs/workbench/parts/search/common/replace'; import * as Constants from 'vs/workbench/parts/search/common/constants'; import { CollapseAllAction as TreeCollapseAction } from 'vs/base/parts/tree/browser/treeDefaults'; @@ -421,7 +421,7 @@ export class RemoveAction extends AbstractSearchAndReplaceAction { let elementToRefresh: any; if (this.element instanceof FileMatch) { - let parent: SearchResult = this.element.parent(); + let parent: FolderMatch = this.element.parent(); parent.remove(this.element); elementToRefresh = parent; } else { diff --git a/src/vs/workbench/parts/search/browser/searchResultsView.ts b/src/vs/workbench/parts/search/browser/searchResultsView.ts index 21c4a9da752fe37a7084d6b50c619025d3b3fc2f..6f1d9ce816be5f761e0e36d631c2f012304b40d2 100644 --- a/src/vs/workbench/parts/search/browser/searchResultsView.ts +++ b/src/vs/workbench/parts/search/browser/searchResultsView.ts @@ -13,7 +13,7 @@ import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { CountBadge } from 'vs/base/browser/ui/countBadge/countBadge'; import { FileLabel } from 'vs/workbench/browser/labels'; import { ITree, IDataSource, ISorter, IAccessibilityProvider, IFilter, IRenderer } from 'vs/base/parts/tree/browser/tree'; -import { Match, SearchResult, FileMatch, FileMatchOrMatch, SearchModel } from 'vs/workbench/parts/search/common/searchModel'; +import { Match, SearchResult, FileMatch, FileMatchOrMatch, SearchModel, FolderMatch } from 'vs/workbench/parts/search/common/searchModel'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { Range } from 'vs/editor/common/core/range'; import { SearchViewlet } from 'vs/workbench/parts/search/browser/searchViewlet'; @@ -22,12 +22,19 @@ import { IInstantiationService } from 'vs/platform/instantiation/common/instanti import { attachBadgeStyler } from 'vs/platform/theme/common/styler'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { getPathLabel } from 'vs/base/common/labels'; +import { FileKind } from "vs/platform/files/common/files"; export class SearchDataSource implements IDataSource { private static AUTOEXPAND_CHILD_LIMIT = 10; + constructor(private includeFolderMatch: boolean = true) { } + public getId(tree: ITree, element: any): string { + if (element instanceof FolderMatch) { + return element.id(); + } + if (element instanceof FileMatch) { return element.id(); } @@ -42,8 +49,14 @@ export class SearchDataSource implements IDataSource { private _getChildren(element: any): any[] { if (element instanceof FileMatch) { return element.matches(); - } else if (element instanceof SearchResult) { + } else if (element instanceof FolderMatch) { return element.matches(); + } else if (element instanceof SearchResult) { + if (this.includeFolderMatch) { + return element.folderMatches().filter(fm => !fm.isEmpty()); + } else { + return element.matches(); + } } return []; @@ -54,7 +67,7 @@ export class SearchDataSource implements IDataSource { } public hasChildren(tree: ITree, element: any): boolean { - return element instanceof FileMatch || element instanceof SearchResult; + return element instanceof FileMatch || element instanceof FolderMatch || element instanceof SearchResult; } public getParent(tree: ITree, element: any): TPromise { @@ -63,6 +76,8 @@ export class SearchDataSource implements IDataSource { if (element instanceof Match) { value = element.parent(); } else if (element instanceof FileMatch) { + value = this.includeFolderMatch ? element.parent() : element.parent().parent(); + } else if (element instanceof FolderMatch) { value = element.parent(); } @@ -71,13 +86,20 @@ export class SearchDataSource implements IDataSource { public shouldAutoexpand(tree: ITree, element: any): boolean { const numChildren = this._getChildren(element).length; - return numChildren > 0 && numChildren < SearchDataSource.AUTOEXPAND_CHILD_LIMIT; + if (numChildren <= 0) { + return false; + } + return numChildren < SearchDataSource.AUTOEXPAND_CHILD_LIMIT || element instanceof FolderMatch; } } export class SearchSorter implements ISorter { public compare(tree: ITree, elementA: FileMatchOrMatch, elementB: FileMatchOrMatch): number { + if (elementA instanceof FolderMatch && elementB instanceof FolderMatch) { + 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()); } @@ -90,6 +112,11 @@ export class SearchSorter implements ISorter { } } +interface IFolderMatchTemplate { + label: FileLabel; + badge: CountBadge; +} + interface IFileMatchTemplate { label: FileLabel; badge: CountBadge; @@ -107,6 +134,7 @@ interface IMatchTemplate { export class SearchRenderer extends Disposable implements IRenderer { + private static FOLDER_MATCH_TEMPLATE_ID = 'folderMatch'; private static FILE_MATCH_TEMPLATE_ID = 'fileMatch'; private static MATCH_TEMPLATE_ID = 'match'; @@ -125,7 +153,9 @@ export class SearchRenderer extends Disposable implements IRenderer { } public getTemplateId(tree: ITree, element: any): string { - if (element instanceof FileMatch) { + if (element instanceof FolderMatch) { + return SearchRenderer.FOLDER_MATCH_TEMPLATE_ID; + } else if (element instanceof FileMatch) { return SearchRenderer.FILE_MATCH_TEMPLATE_ID; } else if (element instanceof Match) { return SearchRenderer.MATCH_TEMPLATE_ID; @@ -134,6 +164,10 @@ export class SearchRenderer extends Disposable implements IRenderer { } public renderTemplate(tree: ITree, templateId: string, container: HTMLElement): any { + if (templateId === SearchRenderer.FOLDER_MATCH_TEMPLATE_ID) { + return this.renderFolderMatchTemplate(tree, templateId, container); + } + if (templateId === SearchRenderer.FILE_MATCH_TEMPLATE_ID) { return this.renderFileMatchTemplate(tree, templateId, container); } @@ -146,13 +180,23 @@ export class SearchRenderer extends Disposable implements IRenderer { } public renderElement(tree: ITree, element: any, templateId: string, templateData: any): void { - if (SearchRenderer.FILE_MATCH_TEMPLATE_ID === templateId) { + if (SearchRenderer.FOLDER_MATCH_TEMPLATE_ID === templateId) { + this.renderFolderMatch(tree, element, templateData); + } else if (SearchRenderer.FILE_MATCH_TEMPLATE_ID === templateId) { this.renderFileMatch(tree, element, templateData); } else if (SearchRenderer.MATCH_TEMPLATE_ID === templateId) { this.renderMatch(tree, element, templateData); } } + private renderFolderMatchTemplate(tree: ITree, templateId: string, container: HTMLElement): IFolderMatchTemplate { + let folderMatchElement = DOM.append(container, DOM.$('.foldermatch')); + const label = this.instantiationService.createInstance(FileLabel, folderMatchElement, void 0); + const badge = new CountBadge(DOM.append(folderMatchElement, DOM.$('.badge'))); + this._register(attachBadgeStyler(badge, this.themeService)); + return { label, badge }; + } + private renderFileMatchTemplate(tree: ITree, templateId: string, container: HTMLElement): IFileMatchTemplate { let fileMatchElement = DOM.append(container, DOM.$('.filematch')); const label = this.instantiationService.createInstance(FileLabel, fileMatchElement, void 0); @@ -182,8 +226,21 @@ export class SearchRenderer extends Disposable implements IRenderer { }; } + private renderFolderMatch(tree: ITree, folderMatch: FolderMatch, templateData: IFolderMatchTemplate): void { + if (folderMatch.hasRoot()) { + templateData.label.setFile(folderMatch.resource(), { fileKind: FileKind.ROOT_FOLDER }); + } else { + templateData.label.setValue(nls.localize('searchFolderMatch.other.label', "Other files")); + } + let count = folderMatch.fileCount(); + templateData.badge.setCount(count); + templateData.badge.setTitleFormat(count > 1 ? nls.localize('searchFileMatches', "{0} files found", count) : nls.localize('searchFileMatch', "{0} file found", count)); + } + private renderFileMatch(tree: ITree, fileMatch: FileMatch, templateData: IFileMatchTemplate): void { - templateData.label.setFile(fileMatch.resource()); + const folderMatch = fileMatch.parent(); + const root = folderMatch.hasRoot() ? folderMatch.resource() : undefined; + templateData.label.setFile(fileMatch.resource(), { root }); let count = fileMatch.count(); templateData.badge.setCount(count); templateData.badge.setTitleFormat(count > 1 ? nls.localize('searchMatches', "{0} matches found", count) : nls.localize('searchMatch', "{0} match found", count)); @@ -220,6 +277,9 @@ export class SearchRenderer extends Disposable implements IRenderer { } public disposeTemplate(tree: ITree, templateId: string, templateData: any): void { + if (SearchRenderer.FOLDER_MATCH_TEMPLATE_ID === templateId) { + (templateData).label.dispose(); + } if (SearchRenderer.FILE_MATCH_TEMPLATE_ID === templateId) { (templateData).label.dispose(); } @@ -232,6 +292,10 @@ export class SearchAccessibilityProvider implements IAccessibilityProvider { } public getAriaLabel(tree: ITree, element: FileMatchOrMatch): string { + if (element instanceof FolderMatch) { + return nls.localize('folderMatchAriaLabel', "{0} matches in folder root {1}, Search result", element.count(), element.name()); + } + if (element instanceof FileMatch) { const path = getPathLabel(element.resource(), this.contextService) || element.resource().fsPath; @@ -256,6 +320,6 @@ export class SearchAccessibilityProvider implements IAccessibilityProvider { export class SearchFilter implements IFilter { public isVisible(tree: ITree, element: any): boolean { - return !(element instanceof FileMatch) || element.matches().length > 0; + return !(element instanceof FileMatch || element instanceof FolderMatch) || element.matches().length > 0; } -} \ No newline at end of file +} diff --git a/src/vs/workbench/parts/search/browser/searchViewlet.ts b/src/vs/workbench/parts/search/browser/searchViewlet.ts index 66a4b808f66e41e52bba135e2e9c80b56fe68118..18df36545b5ab07c0d5c9852c71e4e5dfc6462dd 100644 --- a/src/vs/workbench/parts/search/browser/searchViewlet.ts +++ b/src/vs/workbench/parts/search/browser/searchViewlet.ts @@ -442,7 +442,7 @@ export class SearchViewlet extends Viewlet { this.results = div; this.results.addClass('show-file-icons'); - let dataSource = new SearchDataSource(); + let dataSource = new SearchDataSource(this.contextService.hasMultiFolderWorkspace()); let renderer = this.instantiationService.createInstance(SearchRenderer, this.getActionRunner(), this); this.tree = new Tree(div.getHTMLElement(), { @@ -1168,7 +1168,7 @@ export class SearchViewlet extends Viewlet { const msgWasHidden = this.messages.isHidden(); if (fileCount > 0) { const div = this.clearMessage(); - $(div).p({ text: this.buildResultCountMessage(this.viewModel.searchResult.count(), fileCount) }); + $(div).p({ text: this.buildResultCountMessage(this.viewModel.searchResult.count(), fileCount, this.viewModel.searchResult.folderCount()) }); if (msgWasHidden) { this.reLayout(); } @@ -1177,15 +1177,27 @@ export class SearchViewlet extends Viewlet { } } - private buildResultCountMessage(resultCount: number, fileCount: number): string { - if (resultCount === 1 && fileCount === 1) { - return nls.localize('search.file.result', "{0} result in {1} file", resultCount, fileCount); - } else if (resultCount === 1) { - return nls.localize('search.files.result', "{0} result in {1} files", resultCount, fileCount); - } else if (fileCount === 1) { - return nls.localize('search.file.results', "{0} results in {1} file", resultCount, fileCount); + private buildResultCountMessage(resultCount: number, fileCount: number, folderCount: number): string { + if (folderCount === 1) { + if (resultCount === 1 && fileCount === 1) { + return nls.localize('search.folder.file.result', "{0} result in {1} file in {2} folder", resultCount, fileCount, folderCount); + } else if (resultCount === 1) { + return nls.localize('search.folder.files.result', "{0} result in {1} files in {2} folder", resultCount, fileCount, folderCount); + } else if (fileCount === 1) { + return nls.localize('search.folder.file.results', "{0} results in {1} file in {2} folder", resultCount, fileCount, folderCount); + } else { + return nls.localize('search.folder.files.results', "{0} results in {1} files in {2} folder", resultCount, fileCount, folderCount); + } } else { - return nls.localize('search.files.results', "{0} results in {1} files", resultCount, fileCount); + if (resultCount === 1 && fileCount === 1) { + return nls.localize('search.folders.file.result', "{0} result in {1} file in {2} folders", resultCount, fileCount, folderCount); + } else if (resultCount === 1) { + return nls.localize('search.folders.files.result', "{0} result in {1} files in {2} folders", resultCount, fileCount, folderCount); + } else if (fileCount === 1) { + return nls.localize('search.folders.file.results', "{0} results in {1} file in {2} folders", resultCount, fileCount, folderCount); + } else { + return nls.localize('search.folders.files.results', "{0} results in {1} files in {2} folders", resultCount, fileCount, folderCount); + } } } diff --git a/src/vs/workbench/parts/search/common/searchModel.ts b/src/vs/workbench/parts/search/common/searchModel.ts index 223bb31870d2484db5a2ba5f220b206dee76b103..d523c6c26637a6b8a6859a6683d609825ccda5ab 100644 --- a/src/vs/workbench/parts/search/common/searchModel.ts +++ b/src/vs/workbench/parts/search/common/searchModel.ts @@ -11,7 +11,7 @@ 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 { values, ResourceMap } from 'vs/base/common/map'; +import { values, ResourceMap, TrieMap } from 'vs/base/common/map'; import Event, { Emitter, fromPromise, stopwatch, any } from 'vs/base/common/event'; import { ISearchService, ISearchProgressItem, ISearchComplete, ISearchQuery, IPatternInfo, IFileMatch } from 'vs/platform/search/common/search'; import { ReplacePattern } from 'vs/platform/search/common/replace'; @@ -132,7 +132,7 @@ export class FileMatch extends Disposable { private _updateScheduler: RunOnceScheduler; private _modelDecorations: string[] = []; - constructor(private _query: IPatternInfo, private _maxResults: number, private _parent: SearchResult, private rawMatch: IFileMatch, + constructor(private _query: IPatternInfo, private _maxResults: number, private _parent: FolderMatch, private rawMatch: IFileMatch, @IModelService private modelService: IModelService, @IReplaceService private replaceService: IReplaceService) { super(); this._resource = this.rawMatch.resource; @@ -252,7 +252,7 @@ export class FileMatch extends Disposable { return this.resource().toString(); } - public parent(): SearchResult { + public parent(): FolderMatch { return this._parent; } @@ -334,45 +334,66 @@ export interface IChangeEvent { removed?: boolean; } -export class SearchResult extends Disposable { +export class FolderMatch extends Disposable { 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 _fileMatches: ResourceMap; private _unDisposedFileMatches: ResourceMap; - private _query: IPatternInfo = null; - private _maxResults: number; - private _showHighlights: boolean; private _replacingAll: boolean = false; - private _rangeHighlightDecorations: RangeHighlightDecorations; - - constructor(private _searchModel: SearchModel, @IReplaceService private replaceService: IReplaceService, @ITelemetryService private telemetryService: ITelemetryService, + constructor(private _resource: URI, private _id: string, private _index: number, private _query: ISearchQuery, private _parent: SearchResult, private _searchModel: SearchModel, @IReplaceService private replaceService: IReplaceService, @ITelemetryService private telemetryService: ITelemetryService, @IInstantiationService private instantiationService: IInstantiationService) { super(); this._fileMatches = new ResourceMap(); this._unDisposedFileMatches = new ResourceMap(); - this._rangeHighlightDecorations = this.instantiationService.createInstance(RangeHighlightDecorations); } - public set query(query: IPatternInfo) { - this._query = query; + public get searchModel(): SearchModel { + return this._searchModel; } - public set maxResults(maxResults: number) { - this._maxResults = maxResults; + public get showHighlights(): boolean { + return this._parent.showHighlights; } - public get searchModel(): SearchModel { - return this._searchModel; + public set replacingAll(b: boolean) { + this._replacingAll = b; + } + + public id(): string { + return this._id; + } + + public resource(): URI { + return this._resource; } - public add(raw: IFileMatch[], silent: boolean = false): void { + public index(): number { + return this._index; + } + + public name(): string { + return paths.basename(this.resource().fsPath); + } + + public parent(): SearchResult { + return this._parent; + } + + public hasRoot(): boolean { + return this._resource.fsPath !== ''; + } + + public add(raw: IFileMatch[], silent: boolean): void { let changed: FileMatch[] = []; raw.forEach((rawFileMatch) => { if (!this._fileMatches.has(rawFileMatch.resource)) { - let fileMatch = this.instantiationService.createInstance(FileMatch, this._query, this._maxResults, this, rawFileMatch); + let fileMatch = this.instantiationService.createInstance(FileMatch, this._query.contentPattern, this._query.maxResults, this, rawFileMatch); this.doAdd(fileMatch); changed.push(fileMatch); let disposable = fileMatch.onChange(() => this.onFileChange(fileMatch)); @@ -400,31 +421,179 @@ export class SearchResult extends Disposable { }); } + 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); + } + + 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(); + } + + public dispose(): void { + this.disposeMatches(); + this._onDispose.fire(); + super.dispose(); + } +} + +export class SearchResult extends Disposable { + + private _onChange = this._register(new Emitter()); + public onChange: Event = this._onChange.event; + + private _folderMatches: FolderMatch[] = []; + private _folderMatchesMap: TrieMap = new TrieMap(TrieMap.PathSplitter); + private _query: ISearchQuery = null; + private _showHighlights: boolean; + + private _rangeHighlightDecorations: RangeHighlightDecorations; + + constructor(private _searchModel: SearchModel, @IReplaceService private replaceService: IReplaceService, @ITelemetryService private telemetryService: ITelemetryService, + @IInstantiationService private instantiationService: IInstantiationService) { + super(); + this._rangeHighlightDecorations = this.instantiationService.createInstance(RangeHighlightDecorations); + } + + public set query(query: ISearchQuery) { + // When updating the query we could change the roots, so ensure we clean up the old roots first. + this.clear(); + this._query = query; + const otherFiles = URI.parse(''); + this._folderMatches = (query.folderQueries || []).map((fq) => fq.folder).concat([otherFiles]).map((resource, index) => { + const id = resource.toString() || 'otherFiles'; + const folderMatch = this.instantiationService.createInstance(FolderMatch, resource, id, index, query, this, this._searchModel); + const disposable = folderMatch.onChange((event) => this._onChange.fire(event)); + folderMatch.onDispose(() => disposable.dispose()); + return folderMatch; + }); + // otherFiles is the fallback for missing values in the TrieMap. So we do not insert it. + this._folderMatches.slice(0, this.folderMatches.length - 1) + .forEach(fm => this._folderMatchesMap.insert(fm.resource().fsPath, fm)); + } + + public get searchModel(): SearchModel { + return this._searchModel; + } + + public add(allRaw: IFileMatch[], silent: boolean = false): void { + // Split up raw into a list per folder so we can do a batch add per folder. + let rawPerFolder = new ResourceMap(); + this._folderMatches.forEach((folderMatch) => rawPerFolder.set(folderMatch.resource(), [])); + allRaw.forEach(rawFileMatch => { + let folderMatch = this.getFolderMatch(rawFileMatch.resource); + rawPerFolder.get(folderMatch.resource()).push(rawFileMatch); + }); + rawPerFolder.forEach((raw) => { + if (!raw.length) { + return; + } + let folderMatch = this.getFolderMatch(raw[0].resource); + folderMatch.add(raw, silent); + }); + } + + public clear(): void { + this._folderMatches.forEach((folderMatch) => folderMatch.clear()); + this.disposeMatches(); + } + + public remove(match: FileMatch): void { + this.getFolderMatch(match.resource()).remove(match); + } + + public replace(match: FileMatch): TPromise { + return this.getFolderMatch(match.resource()).replace(match); + } + public replaceAll(progressRunner: IProgressRunner): TPromise { - this._replacingAll = true; + this.replacingAll = true; const promise = this.replaceService.replace(this.matches(), progressRunner); const onDone = stopwatch(fromPromise(promise)); onDone(duration => this.telemetryService.publicLog('replaceAll.started', { duration })); return promise.then(() => { - this._replacingAll = false; + this.replacingAll = false; this.clear(); }, () => { - this._replacingAll = false; + this.replacingAll = false; }); } + public folderMatches(): FolderMatch[] { + return this._folderMatches.concat(); + } + public matches(): FileMatch[] { - return this._fileMatches.values(); + let matches: FileMatch[][] = []; + this._folderMatches.forEach((folderMatch) => { + matches.push(folderMatch.matches()); + }); + return [].concat(...matches); } public isEmpty(): boolean { - return this.fileCount() === 0; + return this._folderMatches.every((folderMatch) => folderMatch.isEmpty()); } public fileCount(): number { - return this._fileMatches.size; + return this.folderMatches().reduce((prev, match) => prev + match.fileCount(), 0); + } + + public folderCount(): number { + return this.folderMatches().reduce((prev, match) => prev + (match.fileCount() > 0 ? 1 : 0), 0); } public count(): number { @@ -461,48 +630,25 @@ export class SearchResult extends Disposable { return this._rangeHighlightDecorations; } - 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 getFolderMatch(resource: URI): FolderMatch { + const folderMatch = this._folderMatchesMap.findSubstr(resource.fsPath); + return folderMatch ? folderMatch : this.otherFiles; } - private doAdd(fileMatch: FileMatch): void { - this._fileMatches.set(fileMatch.resource(), fileMatch); - if (this._unDisposedFileMatches.has(fileMatch.resource())) { - this._unDisposedFileMatches.delete(fileMatch.resource()); - } + private get otherFiles(): FolderMatch { + return this._folderMatches[this._folderMatches.length - 1]; } - 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 set replacingAll(running: boolean) { + this._folderMatches.forEach((folderMatch) => { + folderMatch.replacingAll = running; + }); } 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(); + this._folderMatches.forEach(folderMatch => folderMatch.dispose()); + this._folderMatches = []; + this._folderMatchesMap = new TrieMap(TrieMap.PathSplitter); this._rangeHighlightDecorations.removeHighlightRange(); } @@ -566,8 +712,7 @@ export class SearchModel extends Disposable { this.searchResult.clear(); - this._searchResult.query = this._searchQuery.contentPattern; - this._searchResult.maxResults = this._searchQuery.maxResults; + this._searchResult.query = this._searchQuery; this._replacePattern = new ReplacePattern(this._replaceString, this._searchQuery.contentPattern); const onDone = fromPromise(this.currentRequest); 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 8e7432b9b0bbe99508da10e183f16646efec92a6..3ccd1cc1896fe95c7b543a9308d17cc80d4a2901 100644 --- a/src/vs/workbench/parts/search/test/browser/searchViewlet.test.ts +++ b/src/vs/workbench/parts/search/test/browser/searchViewlet.test.ts @@ -25,7 +25,8 @@ suite('Search - Viewlet', () => { test('Data Source', function () { let ds = new SearchDataSource(); - let result = instantiation.createInstance(SearchResult, null); + let result: SearchResult = instantiation.createInstance(SearchResult, null); + result.query = { type: 1, folderQueries: [{ folder: uri.parse('file://c:/') }] }; result.add([{ resource: uri.parse('file:///c:/foo'), lineMatches: [{ lineNumber: 1, preview: 'bar', offsetAndLengths: [[0, 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 a14bfff2aa4916d09d92a5b25f31ec4957327bd3..c969aa5733519b42ece957463b82a62c3170e2b8 100644 --- a/src/vs/workbench/parts/search/test/common/searchModel.test.ts +++ b/src/vs/workbench/parts/search/test/common/searchModel.test.ts @@ -11,7 +11,7 @@ import { DeferredPPromise } from 'vs/base/test/common/utils'; import { PPromise } from 'vs/base/common/winjs.base'; import { SearchModel } from 'vs/workbench/parts/search/common/searchModel'; import URI from 'vs/base/common/uri'; -import { IFileMatch, ILineMatch, ISearchService, ISearchComplete, ISearchProgressItem, IUncachedSearchStats } from 'vs/platform/search/common/search'; +import { IFileMatch, IFolderQuery, ILineMatch, ISearchService, ISearchComplete, ISearchProgressItem, IUncachedSearchStats } from 'vs/platform/search/common/search'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; import { Range } from 'vs/editor/common/core/range'; @@ -57,6 +57,10 @@ suite('SearchModel', () => { filesWalked: 3 }; + const folderQueries: IFolderQuery[] = [ + { folder: URI.parse('file://c:/') } + ]; + setup(() => { restoreStubs = []; instantiationService = new TestInstantiationService(); @@ -76,8 +80,8 @@ suite('SearchModel', () => { let results = [aRawMatch('file://c:/1', aLineMatch('preview 1', 1, [[1, 3], [4, 7]])), aRawMatch('file://c:/2', aLineMatch('preview 2'))]; instantiationService.stub(ISearchService, 'search', PPromise.as({ results: results })); - let testObject = instantiationService.createInstance(SearchModel); - testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + let testObject: SearchModel = instantiationService.createInstance(SearchModel); + testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); let actual = testObject.searchResult.matches(); @@ -103,7 +107,7 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', promise); let testObject = instantiationService.createInstance(SearchModel); - let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); promise.progress(results[0]); promise.progress(results[1]); @@ -137,7 +141,7 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', PPromise.as({ results: results })); let testObject = instantiationService.createInstance(SearchModel); - testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); assert.ok(target.calledOnce); const data = target.args[0]; @@ -154,7 +158,7 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', PPromise.as({ results: [] })); let testObject = instantiationService.createInstance(SearchModel); - const result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + const result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); setTimeout(() => { result.done(() => { @@ -176,7 +180,7 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', promise); let testObject = instantiationService.createInstance(SearchModel); - let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); promise.progress(aRawMatch('file://c:/1', aLineMatch('some preview'))); promise.complete({ results: [], stats: testSearchStats }); @@ -202,7 +206,7 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', promise); let testObject = instantiationService.createInstance(SearchModel); - let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); promise.error('error'); @@ -227,7 +231,7 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', promise); let testObject = instantiationService.createInstance(SearchModel); - let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + let result = testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); promise.cancel(); @@ -245,12 +249,12 @@ suite('SearchModel', () => { let results = [aRawMatch('file://c:/1', aLineMatch('preview 1', 1, [[1, 3], [4, 7]])), aRawMatch('file://c:/2', aLineMatch('preview 2'))]; instantiationService.stub(ISearchService, 'search', PPromise.as({ results: results })); let testObject: SearchModel = instantiationService.createInstance(SearchModel); - testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); assert.ok(!testObject.searchResult.isEmpty()); instantiationService.stub(ISearchService, 'search', new DeferredPPromise()); - testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); assert.ok(testObject.searchResult.isEmpty()); }); @@ -259,9 +263,9 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', new DeferredPPromise((c, e, p) => { }, target)); let testObject: SearchModel = instantiationService.createInstance(SearchModel); - testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); instantiationService.stub(ISearchService, 'search', new DeferredPPromise()); - testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1 }); + testObject.search({ contentPattern: { pattern: 'somestring' }, type: 1, folderQueries }); assert.ok(target.calledOnce); }); @@ -271,24 +275,24 @@ suite('SearchModel', () => { instantiationService.stub(ISearchService, 'search', PPromise.as({ results: results })); let testObject: SearchModel = instantiationService.createInstance(SearchModel); - testObject.search({ contentPattern: { pattern: 're' }, type: 1 }); + testObject.search({ contentPattern: { pattern: 're' }, type: 1, folderQueries }); testObject.replaceString = 'hello'; let match = testObject.searchResult.matches()[0].matches()[0]; assert.equal('hello', match.replaceString); - testObject.search({ contentPattern: { pattern: 're', isRegExp: true }, type: 1 }); + testObject.search({ contentPattern: { pattern: 're', isRegExp: true }, type: 1, folderQueries }); match = testObject.searchResult.matches()[0].matches()[0]; assert.equal('hello', match.replaceString); - testObject.search({ contentPattern: { pattern: 're(?:vi)', isRegExp: true }, type: 1 }); + testObject.search({ contentPattern: { pattern: 're(?:vi)', isRegExp: true }, type: 1, folderQueries }); match = testObject.searchResult.matches()[0].matches()[0]; assert.equal('hello', match.replaceString); - testObject.search({ contentPattern: { pattern: 'r(e)(?:vi)', isRegExp: true }, type: 1 }); + testObject.search({ contentPattern: { pattern: 'r(e)(?:vi)', isRegExp: true }, type: 1, folderQueries }); match = testObject.searchResult.matches()[0].matches()[0]; assert.equal('hello', match.replaceString); - testObject.search({ contentPattern: { pattern: 'r(e)(?:vi)', isRegExp: true }, type: 1 }); + testObject.search({ contentPattern: { pattern: 'r(e)(?:vi)', isRegExp: true }, type: 1, folderQueries }); testObject.replaceString = 'hello$1'; match = testObject.searchResult.matches()[0].matches()[0]; assert.equal('helloe', match.replaceString); diff --git a/src/vs/workbench/parts/search/test/common/searchResult.test.ts b/src/vs/workbench/parts/search/test/common/searchResult.test.ts index d79a47921a3d054c87f8d7b05a75ba922023866a..e9f486bc1931d00f05b623f843a852027eac04ff 100644 --- a/src/vs/workbench/parts/search/test/common/searchResult.test.ts +++ b/src/vs/workbench/parts/search/test/common/searchResult.test.ts @@ -368,6 +368,7 @@ suite('SearchResult', () => { function aSearchResult(): SearchResult { let searchModel = instantiationService.createInstance(SearchModel); + searchModel.searchResult.query = { type: 1, folderQueries: [{ folder: URI.parse('file://c:/') }] }; return searchModel.searchResult; }