Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand Down Expand Up @@ -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"
}
```

Expand Down
34 changes: 34 additions & 0 deletions src/core/tests/icons/fileIcons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
14 changes: 11 additions & 3 deletions src/extension/tools/changeDetection.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
83 changes: 83 additions & 0 deletions src/extension/tools/customIconPaths.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
});
91 changes: 91 additions & 0 deletions src/extension/tools/customIconPaths.ts
Original file line number Diff line number Diff line change
@@ -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
),
},
};
};