From 4d858a68c94de594eb2646999ebd881cdcfa1452 Mon Sep 17 00:00:00 2001 From: Shoham Ben Shitrit <91195275+bsShoham@users.noreply.github.com> Date: Thu, 20 Jun 2024 18:38:44 +0300 Subject: [PATCH] allow usage of `extname(N)` template in custom labels (#213033) * allow usage of `filenamePart` template in custom labels * use extname(n) instead of filename part * change extname to return the full extensions and not just the last file extension * Fix regex to match extname Return match when N in `extname(N)` is larger than extensions amount * Add tests * Fix `.file` label assertion * Make sure `extname` doesn't include leading dots have filename return the file name from the start until the first non-leading `.` * :lipstick: --------- Co-authored-by: BeniBenj Co-authored-by: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> --- .../browser/workbench.contribution.ts | 5 +- .../editor/common/customEditorLabelService.ts | 80 ++++-- .../browser/customEditorLabelService.test.ts | 234 ++++++++++++++++++ 3 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 src/vs/workbench/services/editor/test/browser/customEditorLabelService.test.ts diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 8494c963f3f..a5a3f44d17c 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -102,9 +102,10 @@ const registry = Registry.as(ConfigurationExtensions.Con let customEditorLabelDescription = localize('workbench.editor.label.patterns', "Controls the rendering of the editor label. Each __Item__ is a pattern that matches a file path. Both relative and absolute file paths are supported. The relative path must include the WORKSPACE_FOLDER (e.g `WORKSPACE_FOLDER/src/**.tsx` or `*/src/**.tsx`). Absolute patterns must start with a `/`. In case multiple patterns match, the longest matching path will be picked. Each __Value__ is the template for the rendered editor when the __Item__ matches. Variables are substituted based on the context:"); customEditorLabelDescription += '\n- ' + [ localize('workbench.editor.label.dirname', "`${dirname}`: name of the folder in which the file is located (e.g. `WORKSPACE_FOLDER/folder/file.txt -> folder`)."), - localize('workbench.editor.label.nthdirname', "`${dirname(N)}`: name of the nth parent folder in which the file is located (e.g. `N=2: WORKSPACE_FOLDER/static/folder/file.txt -> WORKSPACE_FOLDER`). Folders can be picked from the start of the path by using negative numbers (e.g. `N=-1: WORKSPACE_FOLDER/folder/file.txt -> WORKSPACE_FOLDER`). If the __Item__ is an absolute pattern path, the first folder (`N=-1`) refers to the first folder in the absoulte path, otherwise it corresponds to the workspace folder."), + localize('workbench.editor.label.nthdirname', "`${dirname(N)}`: name of the nth parent folder in which the file is located (e.g. `N=2: WORKSPACE_FOLDER/static/folder/file.txt -> WORKSPACE_FOLDER`). Folders can be picked from the start of the path by using negative numbers (e.g. `N=-1: WORKSPACE_FOLDER/folder/file.txt -> WORKSPACE_FOLDER`). If the __Item__ is an absolute pattern path, the first folder (`N=-1`) refers to the first folder in the absolute path, otherwise it corresponds to the workspace folder."), localize('workbench.editor.label.filename', "`${filename}`: name of the file without the file extension (e.g. `WORKSPACE_FOLDER/folder/file.txt -> file`)."), localize('workbench.editor.label.extname', "`${extname}`: the file extension (e.g. `WORKSPACE_FOLDER/folder/file.txt -> txt`)."), + localize('workbench.editor.label.nthextname', "`${extname(N)}`: the nth extension of the file separated by '.' (e.g. `N=2: WORKSPACE_FOLDER/folder/file.ext1.ext2.ext3 -> ext1`). Extension can be picked from the start of the extension by using negative numbers (e.g. `N=-1: WORKSPACE_FOLDER/folder/file.ext1.ext2.ext3 -> ext2`)."), ].join('\n- '); // intentionally concatenated to not produce a string that is too long for translations customEditorLabelDescription += '\n\n' + localize('customEditorLabelDescriptionExample', "Example: `\"**/static/**/*.html\": \"${filename} - ${dirname} (${extname})\"` will render a file `WORKSPACE_FOLDER/static/folder/file.html` as `file - folder (html)`."); @@ -113,7 +114,7 @@ const registry = Registry.as(ConfigurationExtensions.Con additionalProperties: { type: 'string', - markdownDescription: localize('workbench.editor.label.template', "The template which should be rendered when the pattern mtches. May include the variables ${dirname}, ${filename} and ${extname}."), + markdownDescription: localize('workbench.editor.label.template', "The template which should be rendered when the pattern matches. May include the variables ${dirname}, ${filename} and ${extname}."), minLength: 1, pattern: '.*[a-zA-Z0-9].*' }, diff --git a/src/vs/workbench/services/editor/common/customEditorLabelService.ts b/src/vs/workbench/services/editor/common/customEditorLabelService.ts index 8264adcbd91..bd6633067a1 100644 --- a/src/vs/workbench/services/editor/common/customEditorLabelService.ts +++ b/src/vs/workbench/services/editor/common/customEditorLabelService.ts @@ -51,10 +51,10 @@ export class CustomEditorLabelService extends Disposable implements ICustomEdito this.storeEnablementState(); this.storeCustomPatterns(); - this.registerListernes(); + this.registerListeners(); } - private registerListernes(): void { + private registerListeners(): void { this._register(this.configurationService.onDidChangeConfiguration(e => { // Cache the enabled state if (e.affectsConfiguration(CustomEditorLabelService.SETTING_ID_ENABLED)) { @@ -148,29 +148,43 @@ export class CustomEditorLabelService extends Disposable implements ICustomEdito } if (pattern.parsedPattern(relevantPath)) { - return this.applyTempate(pattern.template, resource, relevantPath); + return this.applyTemplate(pattern.template, resource, relevantPath); } } return undefined; } - private readonly _parsedTemplateExpression = /\$\{(dirname|filename|extname|dirname\(([-+]?\d+)\))\}/g; - private applyTempate(template: string, resource: URI, relevantPath: string): string { + private readonly _parsedTemplateExpression = /\$\{(dirname|filename|extname|extname\((?[-+]?\d+)\)|dirname\((?[-+]?\d+)\))\}/g; + private readonly _filenameCaptureExpression = /(?^\.*[^.]*)/; + private applyTemplate(template: string, resource: URI, relevantPath: string): string { let parsedPath: undefined | ParsedPath; - return template.replace(this._parsedTemplateExpression, (match: string, variable: string, arg: string) => { + return template.replace(this._parsedTemplateExpression, (match: string, variable: string, ...args: any[]) => { parsedPath = parsedPath ?? parsePath(resource.path); - switch (variable) { - case 'filename': - return parsedPath.name; - case 'extname': - return parsedPath.ext.slice(1); - default: { // dirname and dirname(arg) - const n = variable === 'dirname' ? 0 : parseInt(arg); - const nthDir = this.getNthDirname(dirname(relevantPath), n); - if (nthDir) { - return nthDir; - } + // named group matches + const { dirnameN = '0', extnameN = '0' }: { dirnameN?: string; extnameN?: string } = args.pop(); + + if (variable === 'filename') { + const { filename } = this._filenameCaptureExpression.exec(parsedPath.base)?.groups ?? {}; + if (filename) { + return filename; + } + } else if (variable === 'extname') { + const extension = this.getExtnames(parsedPath.base); + if (extension) { + return extension; + } + } else if (variable.startsWith('extname')) { + const n = parseInt(extnameN); + const nthExtname = this.getNthExtname(parsedPath.base, n); + if (nthExtname) { + return nthExtname; + } + } else if (variable.startsWith('dirname')) { + const n = parseInt(dirnameN); + const nthDir = this.getNthDirname(dirname(relevantPath), n); + if (nthDir) { + return nthDir; } } @@ -178,12 +192,36 @@ export class CustomEditorLabelService extends Disposable implements ICustomEdito }); } + private removeLeadingDot(path: string): string { + let withoutLeadingDot = path; + while (withoutLeadingDot.startsWith('.')) { + withoutLeadingDot = withoutLeadingDot.slice(1); + } + return withoutLeadingDot; + } + private getNthDirname(path: string, n: number): string | undefined { // grand-parent/parent/filename.ext1.ext2 -> [grand-parent, parent] path = path.startsWith('/') ? path.slice(1) : path; const pathFragments = path.split('/'); - const length = pathFragments.length; + return this.getNthFragment(pathFragments, n); + } + + private getExtnames(fullFileName: string): string { + return this.removeLeadingDot(fullFileName).split('.').slice(1).join('.'); + } + + private getNthExtname(fullFileName: string, n: number): string | undefined { + // file.ext1.ext2.ext3 -> [file, ext1, ext2, ext3] + const extensionNameFragments = this.removeLeadingDot(fullFileName).split('.'); + extensionNameFragments.shift(); // remove the first element which is the file name + + return this.getNthFragment(extensionNameFragments, n); + } + + private getNthFragment(fragments: string[], n: number): string | undefined { + const length = fragments.length; let nth; if (n < 0) { @@ -192,11 +230,11 @@ export class CustomEditorLabelService extends Disposable implements ICustomEdito nth = length - n - 1; } - const nthDir = pathFragments[nth]; - if (nthDir === undefined || nthDir === '') { + const nthFragment = fragments[nth]; + if (nthFragment === undefined || nthFragment === '') { return undefined; } - return nthDir; + return nthFragment; } } diff --git a/src/vs/workbench/services/editor/test/browser/customEditorLabelService.test.ts b/src/vs/workbench/services/editor/test/browser/customEditorLabelService.test.ts new file mode 100644 index 00000000000..343c70585d7 --- /dev/null +++ b/src/vs/workbench/services/editor/test/browser/customEditorLabelService.test.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils'; +import { ConfigurationTarget, IConfigurationService } from 'vs/platform/configuration/common/configuration'; +import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; +import { CustomEditorLabelService } from 'vs/workbench/services/editor/common/customEditorLabelService'; +import { ITestInstantiationService, TestServiceAccessor, workbenchInstantiationService } from 'vs/workbench/test/browser/workbenchTestServices'; + +suite('Custom Editor Label Service', () => { + + const disposables = new DisposableStore(); + + setup(() => { }); + + teardown(async () => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + async function createCustomLabelService(instantiationService: ITestInstantiationService = workbenchInstantiationService(undefined, disposables)): Promise<[CustomEditorLabelService, TestConfigurationService, TestServiceAccessor]> { + const configService = new TestConfigurationService(); + await configService.setUserConfiguration(CustomEditorLabelService.SETTING_ID_ENABLED, true); + instantiationService.stub(IConfigurationService, configService); + + const customLabelService = disposables.add(instantiationService.createInstance(CustomEditorLabelService)); + return [customLabelService, configService, instantiationService.createInstance(TestServiceAccessor)]; + } + + async function updatePattern(configService: TestConfigurationService, value: any): Promise { + await configService.setUserConfiguration(CustomEditorLabelService.SETTING_ID_PATTERNS, value); + configService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration: (key: string) => key === CustomEditorLabelService.SETTING_ID_PATTERNS, + source: ConfigurationTarget.USER, + affectedKeys: new Set(CustomEditorLabelService.SETTING_ID_PATTERNS), + change: { + keys: [], + overrides: [] + } + }); + } + + test('Custom Labels: filename.extname', async () => { + const [customLabelService, configService] = await createCustomLabelService(); + + await updatePattern(configService, { + '**': '${filename}.${extname}' + }); + + const filenames = [ + 'file.txt', + 'file.txt1.tx2', + '.file.txt', + ]; + + for (const filename of filenames) { + const label = customLabelService.getName(URI.file(filename)); + assert.strictEqual(label, filename); + } + + let label = customLabelService.getName(URI.file('file')); + assert.strictEqual(label, 'file.${extname}'); + + label = customLabelService.getName(URI.file('.file')); + assert.strictEqual(label, '.file.${extname}'); + }); + + test('Custom Labels: filename', async () => { + const [customLabelService, configService] = await createCustomLabelService(); + + await updatePattern(configService, { + '**': '${filename}', + }); + + assert.strictEqual(customLabelService.getName(URI.file('file')), 'file'); + assert.strictEqual(customLabelService.getName(URI.file('file.txt')), 'file'); + assert.strictEqual(customLabelService.getName(URI.file('file.txt1.txt2')), 'file'); + assert.strictEqual(customLabelService.getName(URI.file('folder/file.txt1.txt2')), 'file'); + + assert.strictEqual(customLabelService.getName(URI.file('.file')), '.file'); + assert.strictEqual(customLabelService.getName(URI.file('.file.txt')), '.file'); + assert.strictEqual(customLabelService.getName(URI.file('.file.txt1.txt2')), '.file'); + assert.strictEqual(customLabelService.getName(URI.file('folder/.file.txt1.txt2')), '.file'); + }); + + test('Custom Labels: extname(N)', async () => { + const [customLabelService, configService] = await createCustomLabelService(); + + await updatePattern(configService, { + '**/ext/**': '${extname}', + '**/ext0/**': '${extname(0)}', + '**/ext1/**': '${extname(1)}', + '**/ext2/**': '${extname(2)}', + '**/extMinus1/**': '${extname(-1)}', + '**/extMinus2/**': '${extname(-2)}', + }); + + interface IExt { + extname?: string; + ext0?: string; + ext1?: string; + ext2?: string; + extMinus1?: string; + extMinus2?: string; + } + + function assertExtname(filename: string, ext: IExt): void { + assert.strictEqual(customLabelService.getName(URI.file(`test/ext/${filename}`)), ext.extname ?? '${extname}', filename); + assert.strictEqual(customLabelService.getName(URI.file(`test/ext0/${filename}`)), ext.ext0 ?? '${extname(0)}', filename); + assert.strictEqual(customLabelService.getName(URI.file(`test/ext1/${filename}`)), ext.ext1 ?? '${extname(1)}', filename); + assert.strictEqual(customLabelService.getName(URI.file(`test/ext2/${filename}`)), ext.ext2 ?? '${extname(2)}', filename); + assert.strictEqual(customLabelService.getName(URI.file(`test/extMinus1/${filename}`)), ext.extMinus1 ?? '${extname(-1)}', filename); + assert.strictEqual(customLabelService.getName(URI.file(`test/extMinus2/${filename}`)), ext.extMinus2 ?? '${extname(-2)}', filename); + } + + assertExtname('file.txt', { + extname: 'txt', + ext0: 'txt', + extMinus1: 'txt', + }); + + assertExtname('file.txt1.txt2', { + extname: 'txt1.txt2', + ext0: 'txt2', + ext1: 'txt1', + extMinus1: 'txt1', + extMinus2: 'txt2', + }); + + assertExtname('.file.txt1.txt2', { + extname: 'txt1.txt2', + ext0: 'txt2', + ext1: 'txt1', + extMinus1: 'txt1', + extMinus2: 'txt2', + }); + + assertExtname('.file.txt1.txt2.txt3.txt4', { + extname: 'txt1.txt2.txt3.txt4', + ext0: 'txt4', + ext1: 'txt3', + ext2: 'txt2', + extMinus1: 'txt1', + extMinus2: 'txt2', + }); + + assertExtname('file', {}); + assertExtname('.file', {}); + }); + + test('Custom Labels: dirname(N)', async () => { + const [customLabelService, configService] = await createCustomLabelService(); + + await updatePattern(configService, { + '**': '${dirname},${dirname(0)},${dirname(1)},${dirname(2)},${dirname(-1)},${dirname(-2)}', + }); + + interface IDir { + dirname?: string; + dir0?: string; + dir1?: string; + dir2?: string; + dirMinus1?: string; + dirMinus2?: string; + } + + function assertDirname(path: string, dir: IDir): void { + assert.strictEqual(customLabelService.getName(URI.file(path))?.split(',')[0], dir.dirname ?? '${dirname}', path); + assert.strictEqual(customLabelService.getName(URI.file(path))?.split(',')[1], dir.dir0 ?? '${dirname(0)}', path); + assert.strictEqual(customLabelService.getName(URI.file(path))?.split(',')[2], dir.dir1 ?? '${dirname(1)}', path); + assert.strictEqual(customLabelService.getName(URI.file(path))?.split(',')[3], dir.dir2 ?? '${dirname(2)}', path); + assert.strictEqual(customLabelService.getName(URI.file(path))?.split(',')[4], dir.dirMinus1 ?? '${dirname(-1)}', path); + assert.strictEqual(customLabelService.getName(URI.file(path))?.split(',')[5], dir.dirMinus2 ?? '${dirname(-2)}', path); + } + + assertDirname('folder/file.txt', { + dirname: 'folder', + dir0: 'folder', + dirMinus1: 'folder', + }); + + assertDirname('root/folder/file.txt', { + dirname: 'folder', + dir0: 'folder', + dir1: 'root', + dirMinus1: 'root', + dirMinus2: 'folder', + }); + + assertDirname('root/.folder/file.txt', { + dirname: '.folder', + dir0: '.folder', + dir1: 'root', + dirMinus1: 'root', + dirMinus2: '.folder', + }); + + assertDirname('root/parent/folder/file.txt', { + dirname: 'folder', + dir0: 'folder', + dir1: 'parent', + dir2: 'root', + dirMinus1: 'root', + dirMinus2: 'parent', + }); + + assertDirname('file.txt', {}); + }); + + test('Custom Labels: no pattern match', async () => { + const [customLabelService, configService] = await createCustomLabelService(); + + await updatePattern(configService, { + '**/folder/**': 'folder', + 'file': 'file', + }); + + assert.strictEqual(customLabelService.getName(URI.file('file')), undefined); + assert.strictEqual(customLabelService.getName(URI.file('file.txt')), undefined); + assert.strictEqual(customLabelService.getName(URI.file('file.txt1.txt2')), undefined); + assert.strictEqual(customLabelService.getName(URI.file('folder1/file.txt1.txt2')), undefined); + + assert.strictEqual(customLabelService.getName(URI.file('.file')), undefined); + assert.strictEqual(customLabelService.getName(URI.file('.file.txt')), undefined); + assert.strictEqual(customLabelService.getName(URI.file('.file.txt1.txt2')), undefined); + assert.strictEqual(customLabelService.getName(URI.file('folder1/file.txt1.txt2')), undefined); + }); +});