diff --git a/README.md b/README.md index 42a770874f..d4238f7a92 100644 --- a/README.md +++ b/README.md @@ -133,22 +133,24 @@ If there's no leading `*` it will be automatically configured as filename and no #### Custom SVG icons -It's possible to add custom icons by adding a path to an SVG file which is located relative to the extension's dist folder. However, the restriction applies that the directory in which the custom icons are located must be within the `extensions` directory of the `.vscode` folder in the user directory. +It's possible to add custom icons by adding a path to an SVG file. You can either keep using a path that is relative to the extension's dist folder, or reference a file inside the current workspace with `${workspaceFolder}`. In multi-root workspaces you can target a specific folder with `${workspaceFolder:workspaceName}`. -For example a custom SVG file called `sample.svg` can be placed in an `icons` folder inside of VS Code's `extensions` folder: +For example a custom SVG file called `sample.svg` can be placed in an `icons` folder at the root of your workspace: ```text -.vscode - ┗ extensions - ┗ icons - ┗ sample.svg +your-workspace + ┣ icons + ┃ ┗ sample.svg + ┗ src ``` -In the settings.json (User Settings only!) the icon can be associated to a file name or file extension like this: +If you prefer to keep custom icons inside VS Code's `extensions` directory, relative paths such as `../../icons/sample` continue to work as before. + +In the settings.json the icon can be associated to a file name or file extension like this: ```json "material-icon-theme.files.associations": { - "fileName.ts": "../../icons/sample" + "fileName.ts": "${workspaceFolder}/icons/sample" } ``` @@ -212,7 +214,7 @@ In the settings.json (User Settings only!) the folder icons can be associated to ```json "material-icon-theme.folders.associations": { - "src": "../../../../icons/folder-sample" + "src": "../../../../icons/folder-sample" } ``` diff --git a/src/core/tests/icons/fileIcons.test.ts b/src/core/tests/icons/fileIcons.test.ts index 87d8f2c3c6..ad48a8dced 100644 --- a/src/core/tests/icons/fileIcons.test.ts +++ b/src/core/tests/icons/fileIcons.test.ts @@ -168,6 +168,40 @@ describe('file icons', () => { expect(iconDefinitions).toStrictEqual(expectedManifest); }); + it('should keep normalized custom SVG paths in the manifest', () => { + const fileIcons: FileIcons = { + defaultIcon: { name: 'file' }, + icons: [], + }; + + config.files.associations = { + 'sample.ts': '../../../../workspace/icons/sample', + }; + + const manifest = createEmptyManifest(); + const iconDefinitions = loadFileIconDefinitions( + fileIcons, + config, + manifest + ); + + expectedManifest.iconDefinitions = { + '../../../../workspace/icons/sample': { + iconPath: './../icons/../../../../workspace/icons/sample.svg', + }, + file: { + iconPath: './../icons/file.svg', + }, + }; + expectedManifest.file = 'file'; + expectedManifest.fileExtensions = {}; + expectedManifest.fileNames = { + 'sample.ts': '../../../../workspace/icons/sample', + }; + + expect(iconDefinitions).toStrictEqual(expectedManifest); + }); + it('should configure language icons for light and high contrast', () => { const fileIcons: FileIcons = { defaultIcon: { name: 'file', light: true, highContrast: true }, diff --git a/src/extension/tools/changeDetection.ts b/src/extension/tools/changeDetection.ts index 7d5a8b1c8c..12b8d5bbf8 100644 --- a/src/extension/tools/changeDetection.ts +++ b/src/extension/tools/changeDetection.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; import deepEqual from 'fast-deep-equal'; -import type { ConfigurationChangeEvent, ExtensionContext } from 'vscode'; +import { workspace, type ConfigurationChangeEvent, type ExtensionContext } from 'vscode'; import { applyConfigToIcons, type Config, @@ -18,6 +18,7 @@ import { writeToFile, } from '../../core'; import { getCurrentConfig } from '../shared/config'; +import { normalizeCustomIconConfigPaths } from './customIconPaths'; /** Compare the workspace and the user configurations with the current setup of the icons. */ export const detectConfigChanges = async ( @@ -28,7 +29,14 @@ export const detectConfigChanges = async ( if (event?.affectsConfiguration(extensionName) === false) return; const oldConfig = getConfigFromStorage(context); - const config = getCurrentConfig(); + const config = normalizeCustomIconConfigPaths(getCurrentConfig(), { + extensionPath: context.extension.extensionPath, + workspaceFolders: + workspace.workspaceFolders?.map((folder) => ({ + name: folder.name, + path: folder.uri.fsPath, + })) ?? [], + }); // if the configuration has not changed if (deepEqual(config, oldConfig)) return; @@ -72,7 +80,7 @@ const syncConfigWithStorage = (config: Config, context: ExtensionContext) => { }; const getConfigFromStorage = (context: ExtensionContext): Config => { - const config = context.globalState.get<{ version: string; config: Config }>( + const config = context.globalState.get<{ version: string; config: Config; }>( 'config' ); if (context.extension.packageJSON.version === config?.version) { diff --git a/src/extension/tools/customIconPaths.test.ts b/src/extension/tools/customIconPaths.test.ts new file mode 100644 index 0000000000..365e862ce7 --- /dev/null +++ b/src/extension/tools/customIconPaths.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'bun:test'; +import { getDefaultConfig } from '../../core'; +import { + normalizeCustomIconAssociations, + normalizeCustomIconConfigPaths, +} from './customIconPaths'; + +describe('custom icon paths', () => { + const extensionPath = + '/home/test/.vscode/extensions/pkief.material-icon-theme-1.0.0'; + + it('should normalize workspace file icon paths', () => { + const associations = normalizeCustomIconAssociations( + { + '*.sample': '${workspaceFolder}/icons/sample', + }, + { + extensionPath, + workspaceFolders: [{ name: 'app', path: '/home/test/workspace' }], + } + ); + + expect(associations['*.sample']).toBe('../../../../workspace/icons/sample'); + }); + + it('should normalize named workspace folder paths', () => { + const associations = normalizeCustomIconAssociations( + { + '*.sample': '${workspaceFolder:docs}/icons/sample', + }, + { + extensionPath, + workspaceFolders: [ + { name: 'app', path: '/home/test/workspace-app' }, + { name: 'docs', path: '/home/test/workspace-docs' }, + ], + } + ); + + expect(associations['*.sample']).toBe( + '../../../../workspace-docs/icons/sample' + ); + }); + + it('should leave ambiguous workspaceFolder paths unchanged', () => { + const associations = normalizeCustomIconAssociations( + { + '*.sample': '${workspaceFolder}/icons/sample', + }, + { + extensionPath, + workspaceFolders: [ + { name: 'app', path: '/home/test/workspace-app' }, + { name: 'docs', path: '/home/test/workspace-docs' }, + ], + } + ); + + expect(associations['*.sample']).toBe('${workspaceFolder}/icons/sample'); + }); + + it('should normalize file config associations', () => { + const config = getDefaultConfig(); + config.files.associations = { + '*.sample': '${workspaceFolder}/icons/sample', + }; + + const normalizedConfig = normalizeCustomIconConfigPaths(config, { + extensionPath, + workspaceFolders: [{ name: 'app', path: '/home/test/workspace' }], + }); + + expect(normalizedConfig.files.associations['*.sample']).toBe( + '../../../../workspace/icons/sample' + ); + expect(normalizedConfig.folders.associations).toStrictEqual( + config.folders.associations + ); + expect(normalizedConfig.rootFolders.associations).toStrictEqual( + config.rootFolders.associations + ); + }); +}); diff --git a/src/extension/tools/customIconPaths.ts b/src/extension/tools/customIconPaths.ts new file mode 100644 index 0000000000..dc15c89f14 --- /dev/null +++ b/src/extension/tools/customIconPaths.ts @@ -0,0 +1,91 @@ +import { dirname, join, relative } from 'node:path'; +import type { Config, IconAssociations } from '../../core'; + +type WorkspaceFolderLike = { + name: string; + path: string; +}; + +type NormalizeCustomIconPathOptions = { + extensionPath: string; + workspaceFolders: readonly WorkspaceFolderLike[]; +}; + +const workspaceFolderPattern = /^\$\{workspaceFolder(?::([^}]+))?\}(.*)$/; + +const toPosixPath = (path: string): string => { + return path.replace(/\\/g, '/'); +}; + +const resolveWorkspaceFolderPath = ( + pathValue: string, + workspaceFolders: readonly WorkspaceFolderLike[] +): string | undefined => { + const match = pathValue.match(workspaceFolderPattern); + if (!match) { + return undefined; + } + + const [, workspaceFolderName, suffix = ''] = match; + const workspaceFolder = workspaceFolderName + ? workspaceFolders.find((folder) => folder.name === workspaceFolderName) + : workspaceFolders.length === 1 + ? workspaceFolders[0] + : undefined; + + if (!workspaceFolder) { + return undefined; + } + + return join(workspaceFolder.path, suffix); +}; + +const toThemeCustomIconPath = ( + targetPath: string, + extensionPath: string +): string => { + const relativeFromExtensionsDir = toPosixPath( + relative(dirname(extensionPath), targetPath) + ); + + return relativeFromExtensionsDir + ? `../../${relativeFromExtensionsDir}` + : '../../'; +}; + +export const normalizeCustomIconAssociations = ( + associations: IconAssociations = {}, + options: NormalizeCustomIconPathOptions +): IconAssociations => { + return Object.fromEntries( + Object.entries(associations).map(([pattern, iconName]) => { + const workspaceTargetPath = resolveWorkspaceFolderPath( + iconName, + options.workspaceFolders + ); + + return [ + pattern, + workspaceTargetPath + ? toThemeCustomIconPath(workspaceTargetPath, options.extensionPath) + : iconName, + ]; + }) + ); +}; + +export const normalizeCustomIconConfigPaths = ( + config: Config, + options: NormalizeCustomIconPathOptions +): Config => { + return { + ...config, + files: { + ...config.files, + associations: normalizeCustomIconAssociations( + config.files.associations, + options + ), + }, + }; +};