From 6612ae0f8b5d9c0d86518ebbd153c724a7cbcf73 Mon Sep 17 00:00:00 2001 From: Jackson Kearl Date: Sun, 16 Jan 2022 19:43:59 -0800 Subject: [PATCH] First pass at file nesting ref #6328 --- .vscode/settings.json | 6 +- .../contrib/files/browser/explorerService.ts | 14 +- .../contrib/files/browser/fileActions.ts | 3 +- .../files/browser/files.contribution.ts | 30 ++ .../files/browser/views/explorerView.ts | 43 +- .../files/browser/views/explorerViewer.ts | 5 +- .../files/common/explorerFileNestingTrie.ts | 195 +++++++++ .../contrib/files/common/explorerModel.ts | 79 +++- .../workbench/contrib/files/common/files.ts | 7 + .../browser/explorerFileNestingTrie.test.ts | 409 ++++++++++++++++++ .../files/test/browser/explorerModel.test.ts | 12 +- .../files/test/browser/explorerView.test.ts | 4 +- 12 files changed, 777 insertions(+), 30 deletions(-) create mode 100644 src/vs/workbench/contrib/files/common/explorerFileNestingTrie.ts create mode 100644 src/vs/workbench/contrib/files/test/browser/explorerFileNestingTrie.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index a896b7e5a7b..67bc218c44f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -100,5 +100,9 @@ "list", "git", "sash" - ] + ], + "explorer.experimental.fileNesting.patterns": { + "*.js": "$(capture).*.js", + "bootstrap.js": "bootstrap-*.js" + } } diff --git a/src/vs/workbench/contrib/files/browser/explorerService.ts b/src/vs/workbench/contrib/files/browser/explorerService.ts index cf2f81749c2..59bff887216 100644 --- a/src/vs/workbench/contrib/files/browser/explorerService.ts +++ b/src/vs/workbench/contrib/files/browser/explorerService.ts @@ -55,7 +55,7 @@ export class ExplorerService implements IExplorerService { this._sortOrder = this.configurationService.getValue('explorer.sortOrder'); this._lexicographicOptions = this.configurationService.getValue('explorer.sortOrderLexicographicOptions'); - this.model = new ExplorerModel(this.contextService, this.uriIdentityService, this.fileService); + this.model = new ExplorerModel(this.contextService, this.uriIdentityService, this.fileService, this.configurationService); this.disposables.add(this.model); this.disposables.add(this.fileService.onDidRunOperation(e => this.onDidRunOperation(e))); @@ -107,7 +107,7 @@ export class ExplorerService implements IExplorerService { this.onFileChangesScheduler.schedule(); } })); - this.disposables.add(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue()))); + this.disposables.add(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue(), e))); this.disposables.add(Event.any<{ scheme: string }>(this.fileService.onDidChangeFileSystemProviderRegistrations, this.fileService.onDidChangeFileSystemProviderCapabilities)(async e => { let affected = false; this.model.roots.forEach(r => { @@ -253,7 +253,7 @@ export class ExplorerService implements IExplorerService { const stat = await this.fileService.resolve(root.resource, options); // Convert to model - const modelStat = ExplorerItem.create(this.fileService, stat, undefined, options.resolveTo); + const modelStat = ExplorerItem.create(this.fileService, this.configurationService, stat, undefined, options.resolveTo); // Update Input with disk Stat ExplorerItem.mergeLocalWithDisk(modelStat, root); const item = root.find(resource); @@ -299,12 +299,12 @@ export class ExplorerService implements IExplorerService { if (!p.isDirectoryResolved) { const stat = await this.fileService.resolve(p.resource, { resolveMetadata }); if (stat) { - const modelStat = ExplorerItem.create(this.fileService, stat, p.parent); + const modelStat = ExplorerItem.create(this.fileService, this.configurationService, stat, p.parent); ExplorerItem.mergeLocalWithDisk(modelStat, p); } } - const childElement = ExplorerItem.create(this.fileService, addedElement, p.parent); + const childElement = ExplorerItem.create(this.fileService, this.configurationService, addedElement, p.parent); // Make sure to remove any previous version of the file if any p.removeChild(childElement); p.addChild(childElement); @@ -366,6 +366,10 @@ export class ExplorerService implements IExplorerService { private async onConfigurationUpdated(configuration: IFilesConfiguration, event?: IConfigurationChangeEvent): Promise { let shouldRefresh = false; + if (event?.affectedKeys.some(x => x.startsWith('explorer.experimental.fileNesting.'))) { + shouldRefresh = true; + } + const configSortOrder = configuration?.explorer?.sortOrder || SortOrder.Default; if (this._sortOrder !== configSortOrder) { shouldRefresh = this._sortOrder !== undefined; diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index 16cc54f9b69..4bee5f56d39 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -777,6 +777,7 @@ function onErrorWithRetry(notificationService: INotificationService, error: unkn async function openExplorerAndCreate(accessor: ServicesAccessor, isFolder: boolean): Promise { const explorerService = accessor.get(IExplorerService); const fileService = accessor.get(IFileService); + const configService = accessor.get(IConfigurationService); const editorService = accessor.get(IEditorService); const viewsService = accessor.get(IViewsService); const notificationService = accessor.get(INotificationService); @@ -813,7 +814,7 @@ async function openExplorerAndCreate(accessor: ServicesAccessor, isFolder: boole throw new Error('Parent folder is readonly.'); } - const newStat = new NewExplorerItem(fileService, folder, isFolder); + const newStat = new NewExplorerItem(fileService, configService, folder, isFolder); folder.addChild(newStat); const onSuccess = async (value: string): Promise => { diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index c3d59662481..98d9f43ff80 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -451,6 +451,36 @@ configurationRegistry.registerConfiguration({ ], 'description': nls.localize('copyRelativePathSeparator', "The path separation character used when copying relative file paths."), 'default': 'auto' + }, + 'explorer.experimental.fileNesting.enabled': { + 'type': 'boolean', + 'markdownDescription': nls.localize('fileNestingEnabled', "Experimental. Controls whether file nesting is enabled in the explorer. File nesting allows for related files in a directory to be visually grouped together under a single parent file."), + 'default': false, + }, + 'explorer.experimental.fileNesting.expand': { + 'type': 'boolean', + 'markdownDescription': nls.localize('fileNestingExpand', "Experimental. Controls whether file nests are automatically expended. `#explorer.fileNesting.enabled#` must be set for this to take effect."), + 'default': true, + }, + 'explorer.experimental.fileNesting.patterns': { + 'type': 'object', + 'markdownDescription': nls.localize('fileNestingPatterns', "Experimental. Controls nesting of files in the explorer. `#explorer.fileNesting.enabled#` must be set for this to take effect. Each key describes a parent file pattern and each value should be a comma separated list of children file patterns that will be nested under the parent.\n\nA single `*` in a parent pattern may be used to capture any substring, which can then be matched against using `$(capture)` in a child pattern. Child patterns may also contain one `*` to match any substring."), + patternProperties: { + '^[^*]*\\*?[^*]*$': { + description: nls.localize('fileNesting.description', "Key patterns may contain a single `*` capture group which matches any string. Each value pattern may contain one `$(capture)` token to be substituted with the parent capture group and one `*` token to match any string"), + type: 'string', + pattern: '^([^,*]*\\*?[^,*]*)(, ?[^,*]*\\*?[^,*]*)*$', + } + }, + additionalProperties: false, + 'default': { + '*.ts': '$(capture).js, $(capture).d.ts', + '*.js': '$(capture).js.map, $(capture).min.js, $(capture).d.ts', + '*.jsx': '$(capture).js', + '*.tsx': '$(capture).ts', + 'tsconfig.json': 'tsconfig.*.json', + 'package.json': 'package-lock.json, .npmrc, yarn.lock, .yarnrc', + } } } }); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index 55e1b2dfc76..88b895de6fb 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -56,6 +56,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IEditorResolverService } from 'vs/workbench/services/editor/common/editorResolverService'; import { IPaneCompositePartService } from 'vs/workbench/services/panecomposite/browser/panecomposite'; +import { INotificationService } from 'vs/platform/notification/common/notification'; interface IExplorerViewColors extends IColorMapping { listDropBackground?: ColorValue | undefined; @@ -185,6 +186,7 @@ export class ExplorerView extends ViewPane implements IExplorerView { @IMenuService private readonly menuService: IMenuService, @ITelemetryService telemetryService: ITelemetryService, @IExplorerService private readonly explorerService: IExplorerService, + @INotificationService private readonly notificationService: INotificationService, @IStorageService private readonly storageService: IStorageService, @IClipboardService private clipboardService: IClipboardService, @IFileService private readonly fileService: IFileService, @@ -380,6 +382,8 @@ export class ExplorerView extends ViewPane implements IExplorerView { const isCompressionEnabled = () => this.configurationService.getValue('explorer.compactFolders'); + const getFileNestingSettings = () => this.configurationService.getValue().explorer.experimental.fileNesting; + this.tree = >this.instantiationService.createInstance(WorkbenchCompressibleAsyncDataTree, 'FileExplorer', container, new ExplorerDelegate(), new ExplorerCompressionDelegate(), [this.renderer], this.instantiationService.createInstance(ExplorerDataSource), { compressionEnabled: isCompressionEnabled(), @@ -405,7 +409,26 @@ export class ExplorerView extends ViewPane implements IExplorerView { filter: this.filter, sorter: this.instantiationService.createInstance(FileSorter), dnd: this.instantiationService.createInstance(FileDragAndDrop), + collapseByDefault: (e) => { + if (e instanceof ExplorerItem) { + if (e.hasNests && getFileNestingSettings().expand) { + return false; + } + } + return true; + }, autoExpandSingleChildren: true, + expandOnlyOnTwistieClick: (e: unknown) => { + if (e instanceof ExplorerItem) { + if (e.hasNests) { + return true; + } + else if (this.configurationService.getValue<'singleClick' | 'doubleClick'>('workbench.tree.expandMode') === 'doubleClick') { + return true; + } + } + return false; + }, additionalScrollHeight: ExplorerDelegate.ITEM_HEIGHT, overrideStyles: { listBackground: SIDE_BAR_BACKGROUND @@ -600,9 +623,23 @@ export class ExplorerView extends ViewPane implements IExplorerView { } const toRefresh = item || this.tree.getInput(); - return this.tree.updateChildren(toRefresh, recursive, false, { - diffIdentityProvider: identityProvider - }); + if (this.configurationService.getValue()?.explorer?.experimental?.fileNesting?.enabled) { + return (async () => { + try { + await this.tree.updateChildren(toRefresh, recursive, false, { + diffIdentityProvider: identityProvider + }); + } catch (e) { + this.notificationService.error(e); + console.error('Unepxected error', e, 'in refreshing explorer. This may be due to experimental file nesting.'); + return; + } + })(); + } else { + return this.tree.updateChildren(toRefresh, recursive, false, { + diffIdentityProvider: identityProvider + }); + } } override getOptimalWidth(): number { diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index b45fb172b5e..6c9628dbbae 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -75,6 +75,7 @@ export class ExplorerDataSource implements IAsyncDataSource { @@ -106,7 +107,7 @@ export class ExplorerDataSource implements IAsyncDataSource [ a.d.ts ; a.js ; a.min.js ] + * - b.ts => [ ] + * - b.min.ts => [ ] + */ +export class ExplorerFileNestingTrie { + private root = new PreTrie(); + + constructor(config: [string, string[]][]) { + for (const [parentPattern, childPatterns] of config) { + for (const childPattern of childPatterns) { + this.root.add(parentPattern, childPattern); + } + } + } + + toString() { + return this.root.toString(); + } + + nest(files: string[]): Map> { + const parentFinder = new PreTrie(); + + for (const potentialParent of files) { + const children = this.root.get(potentialParent); + for (const child of children) { + parentFinder.add(child, potentialParent); + } + } + + const findAllRootAncestors = (file: string, seen: Set = new Set()): string[] => { + if (seen.has(file)) { return []; } + seen.add(file); + + const ancestors = parentFinder.get(file); + if (ancestors.length === 0) { + return [file]; + } + + if (ancestors.length === 1 && ancestors[0] === file) { + return [file]; + } + + return ancestors.flatMap(a => findAllRootAncestors(a, seen)); + }; + + const result = new Map>(); + for (const file of files) { + let ancestors = findAllRootAncestors(file); + if (ancestors.length === 0) { ancestors = [file]; } + for (const ancestor of ancestors) { + let existing = result.get(ancestor); + if (!existing) { result.set(ancestor, existing = new Set()); } + if (file !== ancestor) { + existing.add(file); + } + } + } + return result; + } +} + +/** Export for test only. */ +export class PreTrie { + private value: SufTrie = new SufTrie(); + + private map: Map = new Map(); + + constructor() { } + + add(key: string, value: string) { + if (key === '') { + this.value.add(key, value); + } else if (key[0] === '*') { + this.value.add(key, value); + } else { + const head = key[0]; + const rest = key.slice(1); + let existing = this.map.get(head); + if (!existing) { + this.map.set(head, existing = new PreTrie()); + } + existing.add(rest, value); + } + } + + get(key: string): string[] { + const results: string[] = []; + results.push(...this.value.get(key)); + + const head = key[0]; + const rest = key.slice(1); + const existing = this.map.get(head); + if (existing) { + results.push(...existing.get(rest)); + } + + return results; + } + + toString(indentation = ''): string { + const lines = []; + if (this.value.hasItems) { + lines.push('* => \n' + this.value.toString(indentation + ' ')); + } + [...this.map.entries()].map(([key, trie]) => + lines.push('^' + key + ' => \n' + trie.toString(indentation + ' '))); + return lines.map(l => indentation + l).join('\n'); + } +} + +/** Export for test only. */ +export class SufTrie { + private star: string[] = []; + private epsilon: string[] = []; + + private map: Map = new Map(); + hasItems: boolean = false; + + constructor() { } + + add(key: string, value: string) { + this.hasItems = true; + if (key === '*') { + this.star.push(value); + } else if (key === '') { + this.epsilon.push(value); + } else { + const tail = key[key.length - 1]; + const rest = key.slice(0, key.length - 1); + if (tail === '*') { + throw Error('Unexpected star in SufTrie key: ' + key); + } else { + let existing = this.map.get(tail); + if (!existing) { + this.map.set(tail, existing = new SufTrie()); + } + existing.add(rest, value); + } + } + } + + get(key: string): string[] { + const results: string[] = []; + if (key === '') { + results.push(...this.epsilon); + } + if (this.star.length) { + results.push(...this.star.map(x => x.replace('$(capture)', key))); + } + + const tail = key[key.length - 1]; + const rest = key.slice(0, key.length - 1); + const existing = this.map.get(tail); + if (existing) { + results.push(...existing.get(rest)); + } + + return results; + } + + toString(indentation = ''): string { + const lines = []; + if (this.star.length) { + lines.push('* => ' + this.star.join('; ')); + } + + if (this.epsilon.length) { + // allow-any-unicode-next-line + lines.push('ε => ' + this.epsilon.join('; ')); + } + + [...this.map.entries()].map(([key, trie]) => + lines.push(key + '$' + ' => \n' + trie.toString(indentation + ' '))); + + return lines.map(l => indentation + l).join('\n'); + } +} diff --git a/src/vs/workbench/contrib/files/common/explorerModel.ts b/src/vs/workbench/contrib/files/common/explorerModel.ts index 522676311b3..24aecbad112 100644 --- a/src/vs/workbench/contrib/files/common/explorerModel.ts +++ b/src/vs/workbench/contrib/files/common/explorerModel.ts @@ -15,8 +15,11 @@ import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { memoize } from 'vs/base/common/decorators'; import { Emitter, Event } from 'vs/base/common/event'; import { joinPath, isEqualOrParent, basenameOrAuthority } from 'vs/base/common/resources'; -import { SortOrder } from 'vs/workbench/contrib/files/common/files'; +import { IFilesConfiguration, SortOrder } from 'vs/workbench/contrib/files/common/files'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; +import { ExplorerFileNestingTrie } from 'vs/workbench/contrib/files/common/explorerFileNestingTrie'; +import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { assertIsDefined } from 'vs/base/common/types'; export class ExplorerModel implements IDisposable { @@ -27,10 +30,11 @@ export class ExplorerModel implements IDisposable { constructor( private readonly contextService: IWorkspaceContextService, private readonly uriIdentityService: IUriIdentityService, - fileService: IFileService + fileService: IFileService, + configService: IConfigurationService, ) { const setRoots = () => this._roots = this.contextService.getWorkspace().folders - .map(folder => new ExplorerItem(folder.uri, fileService, undefined, true, false, false, folder.name)); + .map(folder => new ExplorerItem(folder.uri, fileService, configService, undefined, true, false, false, folder.name)); setRoots(); this._listener = this.contextService.onDidChangeWorkspaceFolders(() => { @@ -83,9 +87,12 @@ export class ExplorerItem { public isError = false; private _isExcluded = false; + private nestedChildren: ExplorerItem[] | undefined; + constructor( public resource: URI, private readonly fileService: IFileService, + private readonly configService: IConfigurationService, private _parent: ExplorerItem | undefined, private _isDirectory?: boolean, private _isSymbolicLink?: boolean, @@ -112,6 +119,14 @@ export class ExplorerItem { this._isExcluded = value; } + get hasChildren() { + return this.isDirectory || this.hasNests; + } + + get hasNests() { + return !!(this.nestedChildren?.length); + } + get isDirectoryResolved(): boolean { return this._isDirectoryResolved; } @@ -179,8 +194,8 @@ export class ExplorerItem { return this === this.root; } - static create(fileService: IFileService, raw: IFileStat, parent: ExplorerItem | undefined, resolveTo?: readonly URI[]): ExplorerItem { - const stat = new ExplorerItem(raw.resource, fileService, parent, raw.isDirectory, raw.isSymbolicLink, raw.readonly, raw.name, raw.mtime, !raw.isFile && !raw.isDirectory); + static create(fileService: IFileService, configService: IConfigurationService, raw: IFileStat, parent: ExplorerItem | undefined, resolveTo?: readonly URI[]): ExplorerItem { + const stat = new ExplorerItem(raw.resource, fileService, configService, parent, raw.isDirectory, raw.isSymbolicLink, raw.readonly, raw.name, raw.mtime, !raw.isFile && !raw.isDirectory); // Recursively add children if present if (stat.isDirectory) { @@ -195,7 +210,7 @@ export class ExplorerItem { // Recurse into children if (raw.children) { for (let i = 0, len = raw.children.length; i < len; i++) { - const child = ExplorerItem.create(fileService, raw.children[i], stat, resolveTo); + const child = ExplorerItem.create(fileService, configService, raw.children[i], stat, resolveTo); stat.addChild(child); } } @@ -282,6 +297,9 @@ export class ExplorerItem { } async fetchChildren(sortOrder: SortOrder): Promise { + const nestingConfig = this.configService.getValue().explorer.experimental.fileNesting; + if (nestingConfig.enabled && this.nestedChildren) { return this.nestedChildren; } + if (!this._isDirectoryResolved) { // Resolve metadata only when the mtime is needed since this can be expensive // Mtime is only used when the sort order is 'modified' @@ -289,7 +307,7 @@ export class ExplorerItem { this.isError = false; try { const stat = await this.fileService.resolve(this.resource, { resolveSingleChildDescendants: true, resolveMetadata }); - const resolved = ExplorerItem.create(this.fileService, stat, this); + const resolved = ExplorerItem.create(this.fileService, this.configService, stat, this); ExplorerItem.mergeLocalWithDisk(resolved, this); } catch (e) { this.isError = true; @@ -299,9 +317,46 @@ export class ExplorerItem { } const items: ExplorerItem[] = []; - this.children.forEach(child => { - items.push(child); - }); + if (nestingConfig.enabled) { + const patterns = Object.entries(nestingConfig.patterns).map( + ([parentPattern, childrenPatterns]) => + [parentPattern.trim(), childrenPatterns.split(',').map(p => p.trim())] as [string, string[]]); + + const nester = new ExplorerFileNestingTrie(patterns); + + const fileChildren: [string, ExplorerItem][] = []; + const dirChildren: [string, ExplorerItem][] = []; + for (const child of this.children.entries()) { + if (child[1].isDirectory) { + dirChildren.push(child); + } else { + fileChildren.push(child); + } + } + + const nested = nester.nest(fileChildren.map(([name]) => name)); + + for (const [fileEntryName, fileEntryItem] of fileChildren) { + const nestedItems = nested.get(fileEntryName); + if (nestedItems !== undefined) { + fileEntryItem.nestedChildren = []; + for (const name of nestedItems.keys()) { + fileEntryItem.nestedChildren.push(assertIsDefined(this.children.get(name))); + } + items.push(fileEntryItem); + } else { + fileEntryItem.nestedChildren = undefined; + } + } + + for (const [_, dirEntryItem] of dirChildren.values()) { + items.push(dirEntryItem); + } + } else { + this.children.forEach(child => { + items.push(child); + }); + } return items; } @@ -410,8 +465,8 @@ export class ExplorerItem { } export class NewExplorerItem extends ExplorerItem { - constructor(fileService: IFileService, parent: ExplorerItem, isDirectory: boolean) { - super(URI.file(''), fileService, parent, isDirectory); + constructor(fileService: IFileService, configService: IConfigurationService, parent: ExplorerItem, isDirectory: boolean) { + super(URI.file(''), fileService, configService, parent, isDirectory); this._isDirectoryResolved = true; } } diff --git a/src/vs/workbench/contrib/files/common/files.ts b/src/vs/workbench/contrib/files/common/files.ts index e69ea33b4a1..29db74b405c 100644 --- a/src/vs/workbench/contrib/files/common/files.ts +++ b/src/vs/workbench/contrib/files/common/files.ts @@ -97,6 +97,13 @@ export interface IFilesConfiguration extends PlatformIFilesConfiguration, IWorkb badges: boolean; }; incrementalNaming: 'simple' | 'smart'; + experimental: { + fileNesting: { + enabled: boolean; + expand: boolean; + patterns: { [parent: string]: string } + } + } }; editor: IEditorOptions; } diff --git a/src/vs/workbench/contrib/files/test/browser/explorerFileNestingTrie.test.ts b/src/vs/workbench/contrib/files/test/browser/explorerFileNestingTrie.test.ts new file mode 100644 index 00000000000..a1bdc151838 --- /dev/null +++ b/src/vs/workbench/contrib/files/test/browser/explorerFileNestingTrie.test.ts @@ -0,0 +1,409 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { PreTrie, ExplorerFileNestingTrie, SufTrie } from 'vs/workbench/contrib/files/common/explorerFileNestingTrie'; +import * as assert from 'assert'; + +suite('SufTrie', () => { + test('exactMatches', () => { + const t = new SufTrie(); + t.add('.npmrc', 'MyKey'); + assert.deepStrictEqual(t.get('.npmrc'), ['MyKey']); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('a.npmrc'), []); + }); + + test('starMatches', () => { + const t = new SufTrie(); + t.add('*.npmrc', 'MyKey'); + assert.deepStrictEqual(t.get('.npmrc'), ['MyKey']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('a.npmrc'), ['MyKey']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['MyKey']); + }); + + test('starSubstitutes', () => { + const t = new SufTrie(); + t.add('*.npmrc', '$(capture).json'); + assert.deepStrictEqual(t.get('.npmrc'), ['.json']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('a.npmrc'), ['a.json']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['a.b.c.d.json']); + }); + + test('multiMatches', () => { + const t = new SufTrie(); + t.add('*.npmrc', 'Key1'); + t.add('*.json', 'Key2'); + t.add('*d.npmrc', 'Key3'); + assert.deepStrictEqual(t.get('.npmrc'), ['Key1']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('.json'), ['Key2']); + assert.deepStrictEqual(t.get('a.json'), ['Key2']); + assert.deepStrictEqual(t.get('a.npmrc'), ['Key1']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['Key1', 'Key3']); + }); + + test('multiSubstitutes', () => { + const t = new SufTrie(); + t.add('*.npmrc', 'Key1.$(capture).js'); + t.add('*.json', 'Key2.$(capture).js'); + t.add('*d.npmrc', 'Key3.$(capture).js'); + assert.deepStrictEqual(t.get('.npmrc'), ['Key1..js']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('.json'), ['Key2..js']); + assert.deepStrictEqual(t.get('a.json'), ['Key2.a.js']); + assert.deepStrictEqual(t.get('a.npmrc'), ['Key1.a.js']); + assert.deepStrictEqual(t.get('a.b.cd.npmrc'), ['Key1.a.b.cd.js', 'Key3.a.b.c.js']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['Key1.a.b.c.d.js', 'Key3.a.b.c..js']); + }); +}); + +suite('PreTrie', () => { + test('exactMatches', () => { + const t = new PreTrie(); + t.add('.npmrc', 'MyKey'); + assert.deepStrictEqual(t.get('.npmrc'), ['MyKey']); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('a.npmrc'), []); + }); + + test('starMatches', () => { + const t = new PreTrie(); + t.add('*.npmrc', 'MyKey'); + assert.deepStrictEqual(t.get('.npmrc'), ['MyKey']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('a.npmrc'), ['MyKey']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['MyKey']); + }); + + test('starSubstitutes', () => { + const t = new PreTrie(); + t.add('*.npmrc', '$(capture).json'); + assert.deepStrictEqual(t.get('.npmrc'), ['.json']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('a.npmrc'), ['a.json']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['a.b.c.d.json']); + }); + + test('multiMatches', () => { + const t = new PreTrie(); + t.add('*.npmrc', 'Key1'); + t.add('*.json', 'Key2'); + t.add('*d.npmrc', 'Key3'); + assert.deepStrictEqual(t.get('.npmrc'), ['Key1']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('.json'), ['Key2']); + assert.deepStrictEqual(t.get('a.json'), ['Key2']); + assert.deepStrictEqual(t.get('a.npmrc'), ['Key1']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['Key1', 'Key3']); + }); + + test('multiSubstitutes', () => { + const t = new PreTrie(); + t.add('*.npmrc', 'Key1.$(capture).js'); + t.add('*.json', 'Key2.$(capture).js'); + t.add('*d.npmrc', 'Key3.$(capture).js'); + assert.deepStrictEqual(t.get('.npmrc'), ['Key1..js']); + assert.deepStrictEqual(t.get('npmrc'), []); + assert.deepStrictEqual(t.get('.npmrcs'), []); + assert.deepStrictEqual(t.get('.json'), ['Key2..js']); + assert.deepStrictEqual(t.get('a.json'), ['Key2.a.js']); + assert.deepStrictEqual(t.get('a.npmrc'), ['Key1.a.js']); + assert.deepStrictEqual(t.get('a.b.cd.npmrc'), ['Key1.a.b.cd.js', 'Key3.a.b.c.js']); + assert.deepStrictEqual(t.get('a.b.c.d.npmrc'), ['Key1.a.b.c.d.js', 'Key3.a.b.c..js']); + }); + + + test('emptyMatches', () => { + const t = new PreTrie(); + t.add('package*json', 'package'); + assert.deepStrictEqual(t.get('package.json'), ['package']); + assert.deepStrictEqual(t.get('packagejson'), ['package']); + assert.deepStrictEqual(t.get('package-lock.json'), ['package']); + }); +}); + +suite('StarTrie', () => { + const assertMapEquals = (actual: Map>, expected: Record) => { + const actualStr = [...actual.entries()].map(e => `${e[0]} => [${[...e[1].keys()].join()}]`); + const expectedStr = Object.entries(expected).map(e => `${e[0]}: [${[e[1]].join()}]`); + const bigMsg = actualStr + '===' + expectedStr; + assert.strictEqual(actual.size, Object.keys(expected).length, bigMsg); + for (const parent of actual.keys()) { + const act = actual.get(parent)!; + const exp = expected[parent]; + const str = [...act.keys()].join() + '===' + exp.join(); + const msg = bigMsg + '\n' + str; + assert(act.size === exp.length, msg); + for (const child of exp) { + assert(act.has(child), msg); + } + } + }; + + test('does added extension nesting', () => { + const t = new ExplorerFileNestingTrie([ + ['*', ['$(capture).*']], + ]); + const nesting = t.nest([ + 'file', + 'file.json', + 'boop.test', + 'boop.test1', + 'boop.test.1', + 'beep', + 'beep.test1', + 'beep.boop.test1', + 'beep.boop.test2', + 'beep.boop.a', + ]); + assertMapEquals(nesting, { + 'file': ['file.json'], + 'boop.test': ['boop.test.1'], + 'boop.test1': [], + 'beep': ['beep.test1', 'beep.boop.test1', 'beep.boop.test2', 'beep.boop.a'] + }); + }); + + test('does ext specific nesting', () => { + const t = new ExplorerFileNestingTrie([ + ['*.ts', ['$(capture).js']], + ['*.js', ['$(capture).map']], + ]); + const nesting = t.nest([ + 'a.ts', + 'a.js', + 'a.jss', + 'ab.js', + 'b.js', + 'b.map', + 'c.ts', + 'c.js', + 'c.map', + 'd.ts', + 'd.map', + ]); + assertMapEquals(nesting, { + 'a.ts': ['a.js'], + 'ab.js': [], + 'a.jss': [], + 'b.js': ['b.map'], + 'c.ts': ['c.js', 'c.map'], + 'd.ts': [], + 'd.map': [], + }); + }); + + test('handles loops', () => { + const t = new ExplorerFileNestingTrie([ + ['*.a', ['$(capture).b', '$(capture).c']], + ['*.b', ['$(capture).a']], + ['*.c', ['$(capture).d']], + + ['*.aa', ['$(capture).bb']], + ['*.bb', ['$(capture).cc', '$(capture).dd']], + ['*.cc', ['$(capture).aa']], + ['*.dd', ['$(capture).ee']], + ]); + const nesting = t.nest([ + '.a', '.b', '.c', '.d', + 'a.a', 'a.b', 'a.d', + 'a.aa', 'a.bb', 'a.cc', + 'b.aa', 'b.bb', + 'c.bb', 'c.cc', + 'd.aa', 'd.cc', + 'e.aa', 'e.bb', 'e.dd', 'e.ee', + 'f.aa', 'f.bb', 'f.cc', 'f.dd', 'f.ee', + ]); + + assertMapEquals(nesting, { + '.a': [], '.b': [], '.c': [], '.d': [], + 'a.a': [], 'a.b': [], 'a.d': [], + 'a.aa': [], 'a.bb': [], 'a.cc': [], + 'b.aa': ['b.bb'], + 'c.bb': ['c.cc'], + 'd.cc': ['d.aa'], + 'e.aa': ['e.bb', 'e.dd', 'e.ee'], + 'f.aa': [], 'f.bb': [], 'f.cc': [], 'f.dd': [], 'f.ee': [] + }); + }); + + test('does general bidirectional suffix matching', () => { + const t = new ExplorerFileNestingTrie([ + ['*-vsdoc.js', ['$(capture).js']], + ['*.js', [`$(capture)-vscdoc.js`]], + ]); + + const nesting = t.nest([ + 'a-vsdoc.js', + 'a.js', + 'b.js', + 'b-vscdoc.js', + ]); + + assertMapEquals(nesting, { + 'a-vsdoc.js': ['a.js'], + 'b.js': ['b-vscdoc.js'], + }); + }); + + test('does general bidirectional prefix matching', () => { + const t = new ExplorerFileNestingTrie([ + ['vsdoc-*.js', ['$(capture).js']], + ['*.js', [`vscdoc-$(capture).js`]], + ]); + + const nesting = t.nest([ + 'vsdoc-a.js', + 'a.js', + 'b.js', + 'vscdoc-b.js', + ]); + + assertMapEquals(nesting, { + 'vsdoc-a.js': ['a.js'], + 'b.js': ['vscdoc-b.js'], + }); + }); + + test('does general bidirectional general matching', () => { + const t = new ExplorerFileNestingTrie([ + ['foo-*-bar.js', ['$(capture).js']], + ['*.js', [`bib-$(capture)-bap.js`]], + ]); + + const nesting = t.nest([ + 'foo-a-bar.js', + 'a.js', + 'b.js', + 'bib-b-bap.js', + ]); + + assertMapEquals(nesting, { + 'foo-a-bar.js': ['a.js'], + 'b.js': ['bib-b-bap.js'], + }); + }); + + test('does extension specific path segment matching', () => { + const t = new ExplorerFileNestingTrie([ + ['*.js', ['$(capture).*.js']], + ]); + + const nesting = t.nest([ + 'foo.js', + 'foo.test.js', + 'fooTest.js', + 'bar.js.js', + ]); + + assertMapEquals(nesting, { + 'foo.js': ['foo.test.js'], + 'fooTest.js': [], + 'bar.js.js': [], + }); + }); + + test('does exact match nesting', () => { + const t = new ExplorerFileNestingTrie([ + ['package.json', ['.npmrc', 'npm-shrinkwrap.json', 'yarn.lock', '.yarnclean', '.yarnignore', '.yarn-integrity', '.yarnrc']], + ['bower.json', ['.bowerrc']], + ]); + + const nesting = t.nest([ + 'package.json', + '.npmrc', 'npm-shrinkwrap.json', 'yarn.lock', + '.bowerrc', + ]); + + assertMapEquals(nesting, { + 'package.json': [ + '.npmrc', 'npm-shrinkwrap.json', 'yarn.lock'], + '.bowerrc': [], + }); + }); + + test('eslint test', () => { + const t = new ExplorerFileNestingTrie([ + ['.eslintrc*', ['.eslint*']], + ]); + + const nesting1 = t.nest([ + '.eslintrc.json', + '.eslintignore', + ]); + + assertMapEquals(nesting1, { + '.eslintrc.json': ['.eslintignore'], + }); + + const nesting2 = t.nest([ + '.eslintrc', + '.eslintignore', + ]); + + assertMapEquals(nesting2, { + '.eslintrc': ['.eslintignore'], + }); + }); + + test.skip('is fast', () => { + const bigNester = new ExplorerFileNestingTrie([ + ['*', ['$(capture).*']], + ['*.js', ['$(capture).*.js', '$(capture).map']], + ['*.jsx', ['$(capture).js']], + ['*.ts', ['$(capture).js', '$(capture).*.ts']], + ['*.tsx', ['$(capture).js']], + ['*.css', ['$(capture).*.css', '$(capture).map']], + ['*.html', ['$(capture).*.html']], + ['*.htm', ['$(capture).*.htm']], + ['*.less', ['$(capture).*.less', '$(capture).css']], + ['*.scss', ['$(capture).*.scss', '$(capture).css']], + ['*.sass', ['$(capture).css']], + ['*.styl', ['$(capture).css']], + ['*.coffee', ['$(capture).*.coffee', '$(capture).js']], + ['*.iced', ['$(capture).*.iced', '$(capture).js']], + ['*.config', ['$(capture).*.config']], + ['*.cs', ['$(capture).*.cs', '$(capture).cs.d.ts']], + ['*.vb', ['$(capture).*.vb']], + ['*.json', ['$(capture).*.json']], + ['*.md', ['$(capture).html']], + ['*.mdown', ['$(capture).html']], + ['*.markdown', ['$(capture).html']], + ['*.mdwn', ['$(capture).html']], + ['*.svg', ['$(capture).svgz']], + ['*.a', ['$(capture).b']], + ['*.b', ['$(capture).a']], + ['*.resx', ['$(capture).designer.cs']], + ['package.json', ['.npmrc', 'npm-shrinkwrap.json', 'yarn.lock', '.yarnclean', '.yarnignore', '.yarn-integrity', '.yarnrc']], + ['bower.json', ['.bowerrc']], + ['*-vsdoc.js', ['$(capture).js']], + ['*.tt', ['$(capture).*']] + ]); + + const bigFiles = Array.from({ length: 50000 / 6 }).map((_, i) => [ + 'file' + i + '.js', + 'file' + i + '.map', + 'file' + i + '.css', + 'file' + i + '.ts', + 'file' + i + '.d.ts', + 'file' + i + '.jsx', + ]).flat(); + + const start = performance.now(); + // const _bigResult = + bigNester.nest(bigFiles); + const end = performance.now(); + assert(end - start < 1000, 'too slow...' + (end - start)); + // console.log(bigResult) + }); +}); diff --git a/src/vs/workbench/contrib/files/test/browser/explorerModel.test.ts b/src/vs/workbench/contrib/files/test/browser/explorerModel.test.ts index 622264730e3..d3fe1e876fe 100644 --- a/src/vs/workbench/contrib/files/test/browser/explorerModel.test.ts +++ b/src/vs/workbench/contrib/files/test/browser/explorerModel.test.ts @@ -11,13 +11,15 @@ import { validateFileName } from 'vs/workbench/contrib/files/browser/fileActions import { ExplorerItem } from 'vs/workbench/contrib/files/common/explorerModel'; import { toResource } from 'vs/base/test/common/utils'; import { TestFileService, TestPathService } from 'vs/workbench/test/browser/workbenchTestServices'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; suite('Files - View Model', function () { const fileService = new TestFileService(); + const configService = new TestConfigurationService(); function createStat(this: any, path: string, name: string, isFolder: boolean, hasChildren: boolean, size: number, mtime: number): ExplorerItem { - return new ExplorerItem(toResource.call(this, path), fileService, undefined, isFolder, false, false, name, mtime); + return new ExplorerItem(toResource.call(this, path), fileService, configService, undefined, isFolder, false, false, name, mtime); } const pathService = new TestPathService(); @@ -248,19 +250,19 @@ suite('Files - View Model', function () { }); test('Merge Local with Disk', function () { - const merge1 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), fileService, undefined, true, false, false, 'to', Date.now()); - const merge2 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), fileService, undefined, true, false, false, 'to', Date.now()); + const merge1 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), fileService, configService, undefined, true, false, false, 'to', Date.now()); + const merge2 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), fileService, configService, undefined, true, false, false, 'to', Date.now()); // Merge Properties ExplorerItem.mergeLocalWithDisk(merge2, merge1); assert.strictEqual(merge1.mtime, merge2.mtime); // Merge Child when isDirectoryResolved=false is a no-op - merge2.addChild(new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), fileService, undefined, true, false, false, 'foo.html', Date.now())); + merge2.addChild(new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), fileService, configService, undefined, true, false, false, 'foo.html', Date.now())); ExplorerItem.mergeLocalWithDisk(merge2, merge1); // Merge Child with isDirectoryResolved=true - const child = new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), fileService, undefined, true, false, false, 'foo.html', Date.now()); + const child = new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), fileService, configService, undefined, true, false, false, 'foo.html', Date.now()); merge2.removeChild(child); merge2.addChild(child); (merge2)._isDirectoryResolved = true; diff --git a/src/vs/workbench/contrib/files/test/browser/explorerView.test.ts b/src/vs/workbench/contrib/files/test/browser/explorerView.test.ts index 723981a6238..31ea7ee80a6 100644 --- a/src/vs/workbench/contrib/files/test/browser/explorerView.test.ts +++ b/src/vs/workbench/contrib/files/test/browser/explorerView.test.ts @@ -14,12 +14,14 @@ import { CompressedNavigationController } from 'vs/workbench/contrib/files/brows import * as dom from 'vs/base/browser/dom'; import { Disposable } from 'vs/base/common/lifecycle'; import { provideDecorations } from 'vs/workbench/contrib/files/browser/views/explorerDecorationsProvider'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; const $ = dom.$; const fileService = new TestFileService(); +const configService = new TestConfigurationService(); function createStat(this: any, path: string, name: string, isFolder: boolean, hasChildren: boolean, size: number, mtime: number, isSymLink = false, isUnknown = false): ExplorerItem { - return new ExplorerItem(toResource.call(this, path), fileService, undefined, isFolder, isSymLink, false, name, mtime, isUnknown); + return new ExplorerItem(toResource.call(this, path), fileService, configService, undefined, isFolder, isSymLink, false, name, mtime, isUnknown); } suite('Files - ExplorerView', () => {