First pass at file nesting

ref #6328
This commit is contained in:
Jackson Kearl 2022-01-16 19:43:59 -08:00
parent 23ac679d4f
commit 6612ae0f8b
No known key found for this signature in database
GPG key ID: DA09A59C409FC400
12 changed files with 777 additions and 30 deletions

View file

@ -100,5 +100,9 @@
"list",
"git",
"sash"
]
],
"explorer.experimental.fileNesting.patterns": {
"*.js": "$(capture).*.js",
"bootstrap.js": "bootstrap-*.js"
}
}

View file

@ -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<IFilesConfiguration>())));
this.disposables.add(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue<IFilesConfiguration>(), 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<void> {
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;

View file

@ -777,6 +777,7 @@ function onErrorWithRetry(notificationService: INotificationService, error: unkn
async function openExplorerAndCreate(accessor: ServicesAccessor, isFolder: boolean): Promise<void> {
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<void> => {

View file

@ -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',
}
}
}
});

View file

@ -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<boolean>('explorer.compactFolders');
const getFileNestingSettings = () => this.configurationService.getValue<IFilesConfiguration>().explorer.experimental.fileNesting;
this.tree = <WorkbenchCompressibleAsyncDataTree<ExplorerItem | ExplorerItem[], ExplorerItem, FuzzyScore>>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<IFilesConfiguration>()?.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 {

View file

@ -75,6 +75,7 @@ export class ExplorerDataSource implements IAsyncDataSource<ExplorerItem | Explo
constructor(
@IProgressService private readonly progressService: IProgressService,
@IConfigurationService private readonly configService: IConfigurationService,
@INotificationService private readonly notificationService: INotificationService,
@IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService,
@IFileService private readonly fileService: IFileService,
@ -83,7 +84,7 @@ export class ExplorerDataSource implements IAsyncDataSource<ExplorerItem | Explo
) { }
hasChildren(element: ExplorerItem | ExplorerItem[]): boolean {
return Array.isArray(element) || element.isDirectory;
return Array.isArray(element) || element.hasChildren;
}
getChildren(element: ExplorerItem | ExplorerItem[]): Promise<ExplorerItem[]> {
@ -106,7 +107,7 @@ export class ExplorerDataSource implements IAsyncDataSource<ExplorerItem | Explo
if (element instanceof ExplorerItem && element.isRoot) {
if (this.contextService.getWorkbenchState() === WorkbenchState.FOLDER) {
// Single folder create a dummy explorer item to show error
const placeholder = new ExplorerItem(element.resource, this.fileService, undefined, false);
const placeholder = new ExplorerItem(element.resource, this.fileService, this.configService, undefined, undefined, false);
placeholder.isError = true;
return [placeholder];
} else {

View file

@ -0,0 +1,195 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* A sort of double-ended trie, used to efficiently query for matches to "star" patterns, where
* a given key representas a parent and may contain a capturing group ("*"), which can then be
* referenced via the token "$(capture)" in associated child patterns.
*
* The generated tree will have at most two levels, as subtrees are flattened rather than nested.
*
* Example:
* The config: [
* [ *.ts , [ $(capture).*.ts ; $(capture).js ] ]
* [ *.js , [ $(capture).min.js ] ] ]
* Nests the files: [ a.ts ; a.d.ts ; a.js ; a.min.js ; b.ts ; b.min.js ]
* As:
* - a.ts => [ 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<string, Set<string>> {
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<string> = 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<string, Set<string>>();
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<string, PreTrie> = 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<string, SufTrie> = 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');
}
}

View file

@ -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<ExplorerItem[]> {
const nestingConfig = this.configService.getValue<IFilesConfiguration>().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;
}
}

View file

@ -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;
}

View file

@ -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<string, Set<string>>, expected: Record<string, string[]>) => {
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)
});
});

View file

@ -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);
(<any>merge2)._isDirectoryResolved = true;

View file

@ -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', () => {