From 6c86c8c7cf9709bcd06b3d1ad03828f569ccb1d1 Mon Sep 17 00:00:00 2001 From: tranphucbol Date: Sat, 3 Oct 2026 23:26:37 +0700 Subject: [PATCH 1/5] Follow the active VS Code file icon theme in webviews - Read the active workbench.iconTheme contribution on the extension side and send a resolved theme (icon definitions, fonts, file, folder, and language associations) to webviews over the new getFileIconTheme RPC. - Render file and folder icons in the commit view tree, git log commit details, and git log branch group folders with the user's icon theme, including SVG and font-glyph themes such as Material Icon Theme and Seti. - Refresh icons live when the icon theme, color theme kind, or installed extensions change, and extend webview resource roots to icon theme folders. - Fall back to the bundled Seti icons and codicon folders when the theme cannot be loaded; show no icons when the icon theme is set to None. - Allow webview images from the webview resource origin in the CSP. --- apps/extension/src/extension.ts | 13 +- .../src/providers/BaseWebviewProvider.ts | 40 ++- apps/extension/src/rpc/ExtensionRpcHandler.ts | 9 + .../src/services/FileIconThemeService.ts | 182 +++++++++++ .../extension/src/utils/fileIconTheme.test.ts | 149 +++++++++ apps/extension/src/utils/fileIconTheme.ts | 302 ++++++++++++++++++ apps/extension/src/utils/webviewHtml.ts | 2 +- .../src/components/commit/ChangelistTree.tsx | 20 +- .../src/components/common/BasicTreeView.tsx | 29 +- .../src/components/common/FileIcon.module.css | 22 ++ .../src/components/common/FileIcon.tsx | 108 +++++++ .../src/components/file-tree/BaseFileTree.tsx | 20 +- .../components/git-log/BranchListPanel.tsx | 12 +- apps/webview-ui/src/lib/fileIconTheme.ts | 78 +++++ .../src/lib/fileIconThemeResolver.test.ts | 85 +++++ .../src/lib/fileIconThemeResolver.ts | 65 ++++ apps/webview-ui/src/lib/rpc_client.ts | 2 + packages/shared/messages.ts | 37 +++ 18 files changed, 1134 insertions(+), 41 deletions(-) create mode 100644 apps/extension/src/services/FileIconThemeService.ts create mode 100644 apps/extension/src/utils/fileIconTheme.test.ts create mode 100644 apps/extension/src/utils/fileIconTheme.ts create mode 100644 apps/webview-ui/src/components/common/FileIcon.module.css create mode 100644 apps/webview-ui/src/components/common/FileIcon.tsx create mode 100644 apps/webview-ui/src/lib/fileIconTheme.ts create mode 100644 apps/webview-ui/src/lib/fileIconThemeResolver.test.ts create mode 100644 apps/webview-ui/src/lib/fileIconThemeResolver.ts diff --git a/apps/extension/src/extension.ts b/apps/extension/src/extension.ts index 9110ca1..b3e890d 100644 --- a/apps/extension/src/extension.ts +++ b/apps/extension/src/extension.ts @@ -11,6 +11,7 @@ import { import { RepositoryManager, type RepositoryScope } from './services/RepositoryManager'; import { createGitWatcher } from './services/GitRepositoryWatcher'; import { BackgroundFetchService } from './services/BackgroundFetchService'; +import { FileIconThemeService } from './services/FileIconThemeService'; import { ParentRepositoryScmIntegrationService } from './services/ParentRepositoryScmIntegrationService'; import { BranchStatusBar, GitLogStatusBar } from './ui'; import { @@ -260,10 +261,13 @@ export async function activate(context: vscode.ExtensionContext) { } // Initialize providers + const fileIconThemeService = new FileIconThemeService(); + context.subscriptions.push(fileIconThemeService); const providerOptions = { extensionUri: context.extensionUri, context, repositoryManager, + fileIconThemeService, }; const provider = new CommitViewProvider(providerOptions); const gitLogProvider = new GitLogViewProvider(providerOptions); @@ -523,14 +527,7 @@ export async function activate(context: vscode.ExtensionContext) { return; } - void openConflictFile( - { - extensionUri: context.extensionUri, - context, - repositoryManager, - }, - { path: file.path, repoPath: file.repoPath } - ); + void openConflictFile(providerOptions, { path: file.path, repoPath: file.repoPath }); } ) ); diff --git a/apps/extension/src/providers/BaseWebviewProvider.ts b/apps/extension/src/providers/BaseWebviewProvider.ts index a46b5ec..8d18912 100644 --- a/apps/extension/src/providers/BaseWebviewProvider.ts +++ b/apps/extension/src/providers/BaseWebviewProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import type { ExtensionMethods, WebviewMethods } from '@shared/messages'; import { RpcPeer } from '@shared/rpc'; import { RepositoryManager } from '../services/RepositoryManager'; +import type { FileIconThemeService } from '../services/FileIconThemeService'; import { getWebviewHtml } from '../utils/webviewHtml'; import { createRpc, ExtensionRpcHandler, type ExtensionRpcHandlerOptions } from '../rpc'; @@ -9,6 +10,7 @@ export interface WebviewProviderOptions { extensionUri: vscode.Uri; context: vscode.ExtensionContext; repositoryManager: RepositoryManager; + fileIconThemeService?: FileIconThemeService; } /** @@ -24,10 +26,8 @@ export abstract class BaseWebviewProvider { * Sets up the webview with RPC and message handling. */ protected setupWebview(webview: vscode.Webview, onDisposed: () => boolean): void { - webview.options = { - enableScripts: true, - localResourceRoots: [this.options.extensionUri], - }; + const fileIconThemeService = this.options.fileIconThemeService; + let resourceRootsKey = this.applyWebviewOptions(webview); this._rpc = createRpc({ webview, onDisposed }); @@ -35,6 +35,7 @@ export abstract class BaseWebviewProvider { context: this.options.context, repositoryManager: this.options.repositoryManager, onDispose: this.getOnDispose(), + getFileIconTheme: fileIconThemeService ? () => fileIconThemeService.getTheme(webview) : undefined, ...this.getRpcHandlerOptions(), }); handler.registerAll(this._rpc); @@ -44,6 +45,37 @@ export abstract class BaseWebviewProvider { this._rpc?.handleMessage(msg); }) ); + + if (fileIconThemeService) { + this._disposables.push( + fileIconThemeService.onDidChange(() => { + if (onDisposed()) { + return; + } + // A newly installed icon theme extension needs its folder added as a resource root. + const nextKey = this.getLocalResourceRoots() + .map((root) => root.toString()) + .join('|'); + if (nextKey !== resourceRootsKey) { + resourceRootsKey = this.applyWebviewOptions(webview); + } + void this._rpc?.proxy.fileIconThemeChange(); + }) + ); + } + } + + private getLocalResourceRoots(): vscode.Uri[] { + return [this.options.extensionUri, ...(this.options.fileIconThemeService?.getLocalResourceRoots() ?? [])]; + } + + private applyWebviewOptions(webview: vscode.Webview): string { + const localResourceRoots = this.getLocalResourceRoots(); + webview.options = { + enableScripts: true, + localResourceRoots, + }; + return localResourceRoots.map((root) => root.toString()).join('|'); } protected getHtml(webview: vscode.Webview): string { diff --git a/apps/extension/src/rpc/ExtensionRpcHandler.ts b/apps/extension/src/rpc/ExtensionRpcHandler.ts index 41000e7..752fea4 100644 --- a/apps/extension/src/rpc/ExtensionRpcHandler.ts +++ b/apps/extension/src/rpc/ExtensionRpcHandler.ts @@ -22,6 +22,7 @@ import type { CommitMessageGenerationRequest, CommitMessageGenerationResult, ConflictResolverOpenRequest, + FileIconTheme, PushFailedResult, PushFailureCode, PushRequest, @@ -228,6 +229,7 @@ export interface ExtensionRpcHandlerOptions { consumePendingGitLogReveal?: () => GitLogRevealRequest | undefined; openConflictResolver?: (file: ConflictResolverOpenRequest) => void; updateConflictResolverTitle?: (file: RepositoryFileReference) => void; + getFileIconTheme?: () => Promise; } /** @@ -242,6 +244,7 @@ export class ExtensionRpcHandler { private onChangelistFocusChange?: (focused: boolean) => void; private openConflictResolverPanel?: (file: ConflictResolverOpenRequest) => void; private updateConflictResolverPanelTitle?: (file: RepositoryFileReference) => void; + private fileIconThemeProvider?: () => Promise; private gitReadRpcHandler: GitReadRpcHandler; private changelistRpcHandler: ChangelistRpcHandler; private _lastRebaseStatus?: string; @@ -254,6 +257,7 @@ export class ExtensionRpcHandler { this.onChangelistFocusChange = options.onChangelistFocusChange; this.openConflictResolverPanel = options.openConflictResolver; this.updateConflictResolverPanelTitle = options.updateConflictResolverTitle; + this.fileIconThemeProvider = options.getFileIconTheme; this.gitReadRpcHandler = new GitReadRpcHandler(this.repositoryManager, options.consumePendingGitLogReveal); this.changelistRpcHandler = new ChangelistRpcHandler( (repoPath) => this.getChangelistOperationsForRepo(repoPath), @@ -718,6 +722,7 @@ export class ExtensionRpcHandler { getCurrentUser: this.gitReadRpcHandler.getCurrentUser, getUnpushedCommits: this.gitReadRpcHandler.getUnpushedCommits, getWorkspaceRoot: this.gitReadRpcHandler.getWorkspaceRoot, + getFileIconTheme: this.getFileIconTheme, getLastCommitInfo: this.gitReadRpcHandler.getLastCommitInfo, showErrorMessage: this.showErrorMessage, markHunkInactive: this.changelistRpcHandler.markHunkInactive, @@ -1864,6 +1869,10 @@ export class ExtensionRpcHandler { await vscode.commands.executeCommand('intelli-git.ai.openCommitPromptSettings'); }; + getFileIconTheme = async (): Promise => { + return this.fileIconThemeProvider ? this.fileIconThemeProvider() : null; + }; + showErrorMessage = async (message: string): Promise => { vscode.window.showErrorMessage(message); }; diff --git a/apps/extension/src/services/FileIconThemeService.ts b/apps/extension/src/services/FileIconThemeService.ts new file mode 100644 index 0000000..a57296d --- /dev/null +++ b/apps/extension/src/services/FileIconThemeService.ts @@ -0,0 +1,182 @@ +import * as vscode from 'vscode'; +import type { FileIconTheme } from '@shared/messages'; +import { logger } from '../utils/logger'; +import { + buildFileIconTheme, + mapFileIconThemeResources, + parseJsonWithComments, + type IconThemeColorKind, + type LanguageContribution, +} from '../utils/fileIconTheme'; + +const DEFAULT_ICON_THEME_ID = 'vs-seti'; + +interface IconThemeContribution { + id: string; + path: string; + extensionUri: vscode.Uri; +} + +const EMPTY_THEME: FileIconTheme = { + id: '', + iconDefinitions: {}, + fonts: [], + fileNames: {}, + fileExtensions: {}, + languageIds: {}, + languageFileNames: {}, + languageExtensions: {}, + folderNames: {}, + folderNamesExpanded: {}, +}; + +function getColorKind(kind: vscode.ColorThemeKind): IconThemeColorKind { + switch (kind) { + case vscode.ColorThemeKind.Light: + return 'light'; + case vscode.ColorThemeKind.HighContrast: + case vscode.ColorThemeKind.HighContrastLight: + return 'highContrast'; + default: + return 'dark'; + } +} + +function getContributes(extension: vscode.Extension): Record { + const contributes = (extension.packageJSON as { contributes?: unknown } | undefined)?.contributes; + return typeof contributes === 'object' && contributes !== null ? (contributes as Record) : {}; +} + +/** + * Resolves the user's active VS Code file icon theme so webviews can render the same file icons + * as the Explorer. Webviews cannot access the workbench icon theme directly. + */ +export class FileIconThemeService implements vscode.Disposable { + private readonly changeEmitter = new vscode.EventEmitter(); + public readonly onDidChange = this.changeEmitter.event; + private readonly disposables: vscode.Disposable[] = []; + private colorKind = getColorKind(vscode.window.activeColorTheme.kind); + private themePromise?: Promise; + + constructor() { + this.disposables.push( + this.changeEmitter, + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration('workbench.iconTheme')) { + this.invalidate(); + } + }), + vscode.window.onDidChangeActiveColorTheme((colorTheme) => { + const nextKind = getColorKind(colorTheme.kind); + if (nextKind !== this.colorKind) { + this.colorKind = nextKind; + this.invalidate(); + } + }), + vscode.extensions.onDidChange(() => this.invalidate()) + ); + } + + /** + * Folders that must be readable by webviews to load icon theme images and fonts. + */ + public getLocalResourceRoots(): vscode.Uri[] { + const roots = new Map(); + for (const contribution of this.getIconThemeContributions()) { + roots.set(contribution.extensionUri.toString(), contribution.extensionUri); + } + return Array.from(roots.values()); + } + + /** + * Returns the active theme with resource URIs mapped for the given webview. + * Returns an empty theme when file icons are disabled and null when the theme cannot be resolved. + */ + public async getTheme(webview: vscode.Webview): Promise { + this.themePromise ??= this.loadTheme(); + const theme = await this.themePromise; + if (!theme) { + return null; + } + return mapFileIconThemeResources(theme, (uri) => webview.asWebviewUri(vscode.Uri.parse(uri)).toString()); + } + + private invalidate(): void { + this.themePromise = undefined; + this.changeEmitter.fire(); + } + + private getIconThemeContributions(): IconThemeContribution[] { + return vscode.extensions.all.flatMap((extension) => { + const iconThemes = getContributes(extension).iconThemes; + if (!Array.isArray(iconThemes)) { + return []; + } + return iconThemes.flatMap((iconTheme: unknown) => { + const { id, path } = (iconTheme ?? {}) as { id?: unknown; path?: unknown }; + return typeof id === 'string' && typeof path === 'string' + ? [{ id, path, extensionUri: extension.extensionUri }] + : []; + }); + }); + } + + private getLanguageContributions(): LanguageContribution[] { + return vscode.extensions.all.flatMap((extension) => { + const languages = getContributes(extension).languages; + if (!Array.isArray(languages)) { + return []; + } + return languages.flatMap((language: unknown) => { + const { id, extensions, filenames } = (language ?? {}) as Record; + if (typeof id !== 'string') { + return []; + } + return [ + { + id, + extensions: Array.isArray(extensions) ? extensions : undefined, + filenames: Array.isArray(filenames) ? filenames : undefined, + }, + ]; + }); + }); + } + + private async loadTheme(): Promise { + const configuredId = vscode.workspace.getConfiguration('workbench').get('iconTheme'); + if (configuredId === null) { + return EMPTY_THEME; + } + + const contributions = this.getIconThemeContributions(); + const contribution = + contributions.find((candidate) => candidate.id === configuredId) ?? + contributions.find((candidate) => candidate.id === DEFAULT_ICON_THEME_ID); + if (!contribution) { + logger.warn(`File icon theme "${configuredId}" was not found; using bundled file icons.`); + return null; + } + + try { + const documentUri = vscode.Uri.joinPath(contribution.extensionUri, contribution.path); + const documentDir = vscode.Uri.joinPath(documentUri, '..'); + const content = await vscode.workspace.fs.readFile(documentUri); + return buildFileIconTheme({ + id: contribution.id, + document: parseJsonWithComments(new TextDecoder().decode(content)), + colorKind: this.colorKind, + languages: this.getLanguageContributions(), + resolveResource: (relativePath) => vscode.Uri.joinPath(documentDir, relativePath).toString(), + }); + } catch (error) { + logger.warn(`Failed to load file icon theme "${contribution.id}"; using bundled file icons.`, error); + return null; + } + } + + public dispose(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables.length = 0; + } +} diff --git a/apps/extension/src/utils/fileIconTheme.test.ts b/apps/extension/src/utils/fileIconTheme.test.ts new file mode 100644 index 0000000..66734da --- /dev/null +++ b/apps/extension/src/utils/fileIconTheme.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; +import { + buildFileIconTheme, + decodeFontCharacter, + mapFileIconThemeResources, + parseJsonWithComments, +} from './fileIconTheme'; + +const resolveResource = (relativePath: string) => `file:///themes/icons/${relativePath.replace(/^\.\//, '')}`; + +describe('parseJsonWithComments', () => { + it('accepts comments and trailing commas without touching string contents', () => { + const parsed = parseJsonWithComments(`{ + // line comment + "a": "http://example.com/*not-a-comment*/", + /* block */ + "b": [1, 2,], + }`); + + expect(parsed).toEqual({ a: 'http://example.com/*not-a-comment*/', b: [1, 2] }); + }); +}); + +describe('decodeFontCharacter', () => { + it('decodes CSS escapes used by font icon themes', () => { + expect(decodeFontCharacter('\\E001')).toBe('\uE001'); + expect(decodeFontCharacter('A')).toBe('A'); + }); +}); + +describe('buildFileIconTheme', () => { + const document = { + iconDefinitions: { + _file: { iconPath: './file.svg' }, + _ts: { iconPath: './ts.svg' }, + _ts_light: { iconPath: './ts-light.svg' }, + _dts: { iconPath: './dts.svg' }, + _pkg: { iconPath: './pkg.svg' }, + _markdown: { fontCharacter: '\\E002', fontColor: '#519aba' }, + _missing_font: { fontCharacter: '\\E003', fontId: 'other', fontSize: '120%' }, + }, + fonts: [{ id: 'seti', src: [{ path: './seti.woff', format: 'woff' }], size: '150%' }], + file: '_file', + fileNames: { 'Package.JSON': '_pkg', 'broken.txt': '_does_not_exist' }, + fileExtensions: { TS: '_ts', 'd.ts': '_dts' }, + languageIds: { markdown: '_markdown', unused: '_missing_font' }, + light: { fileExtensions: { ts: '_ts_light' } }, + }; + const languages = [ + { id: 'markdown', extensions: ['.md', '.MARKDOWN'], filenames: ['README'] }, + { id: 'typescript', extensions: ['.ts'] }, + ]; + + it('flattens associations, lower-cases keys and drops unresolvable entries', () => { + const theme = buildFileIconTheme({ id: 'vs-seti', document, colorKind: 'dark', languages, resolveResource }); + + expect(theme.file).toBe('_file'); + expect(theme.fileNames).toEqual({ 'package.json': '_pkg' }); + expect(theme.fileExtensions).toEqual({ ts: '_ts', 'd.ts': '_dts' }); + expect(theme.iconDefinitions._ts).toEqual({ iconUri: 'file:///themes/icons/ts.svg' }); + expect(theme.languageExtensions).toEqual({ md: 'markdown', markdown: 'markdown' }); + expect(theme.languageFileNames).toEqual({ readme: 'markdown' }); + expect(theme.languageIds.typescript).toBeUndefined(); + }); + + it('resolves font glyphs against the default font', () => { + const theme = buildFileIconTheme({ id: 'vs-seti', document, colorKind: 'dark', languages, resolveResource }); + + expect(theme.fonts).toEqual([ + { + family: 'intelli-git-icon-vs-seti-seti', + sources: [{ uri: 'file:///themes/icons/seti.woff', format: 'woff' }], + weight: undefined, + style: undefined, + }, + ]); + expect(theme.iconDefinitions._markdown).toEqual({ + fontCharacter: '\uE002', + fontColor: '#519aba', + fontSize: '150%', + fontFamily: 'intelli-git-icon-vs-seti-seti', + }); + expect(theme.iconDefinitions._missing_font).toMatchObject({ fontSize: '120%' }); + }); + + it('applies light overrides only for light color themes', () => { + const dark = buildFileIconTheme({ id: 't', document, colorKind: 'dark', languages, resolveResource }); + const light = buildFileIconTheme({ id: 't', document, colorKind: 'light', languages, resolveResource }); + const highContrast = buildFileIconTheme({ + id: 't', + document, + colorKind: 'highContrast', + languages, + resolveResource, + }); + + expect(dark.fileExtensions.ts).toBe('_ts'); + expect(light.fileExtensions.ts).toBe('_ts_light'); + expect(highContrast.fileExtensions.ts).toBe('_ts'); + }); + + it('flattens folder associations with light overrides', () => { + const folderDocument = { + iconDefinitions: { + _folder: { iconPath: './folder.svg' }, + _folder_open: { iconPath: './folder-open.svg' }, + _folder_src: { iconPath: './folder-src.svg' }, + _folder_src_open: { iconPath: './folder-src-open.svg' }, + _folder_src_light: { iconPath: './folder-src-light.svg' }, + }, + folder: '_folder', + folderExpanded: '_folder_open', + folderNames: { SRC: '_folder_src', broken: '_does_not_exist' }, + folderNamesExpanded: { src: '_folder_src_open' }, + light: { folderNames: { src: '_folder_src_light' } }, + }; + + const dark = buildFileIconTheme({ + id: 't', + document: folderDocument, + colorKind: 'dark', + languages, + resolveResource, + }); + const light = buildFileIconTheme({ + id: 't', + document: folderDocument, + colorKind: 'light', + languages, + resolveResource, + }); + + expect(dark.folder).toBe('_folder'); + expect(dark.folderExpanded).toBe('_folder_open'); + expect(dark.folderNames).toEqual({ src: '_folder_src' }); + expect(dark.folderNamesExpanded).toEqual({ src: '_folder_src_open' }); + expect(dark.iconDefinitions._folder_open).toEqual({ iconUri: 'file:///themes/icons/folder-open.svg' }); + expect(light.folderNames).toEqual({ src: '_folder_src_light' }); + }); + + it('rewrites icon and font URIs for the webview', () => { + const theme = buildFileIconTheme({ id: 't', document, colorKind: 'dark', languages, resolveResource }); + const mapped = mapFileIconThemeResources(theme, (uri) => uri.replace('file:///', 'https://webview/')); + + expect(mapped.iconDefinitions._ts.iconUri).toBe('https://webview/themes/icons/ts.svg'); + expect(mapped.fonts[0].sources[0].uri).toBe('https://webview/themes/icons/seti.woff'); + expect(theme.iconDefinitions._ts.iconUri).toBe('file:///themes/icons/ts.svg'); + }); +}); diff --git a/apps/extension/src/utils/fileIconTheme.ts b/apps/extension/src/utils/fileIconTheme.ts new file mode 100644 index 0000000..03a479f --- /dev/null +++ b/apps/extension/src/utils/fileIconTheme.ts @@ -0,0 +1,302 @@ +import type { FileIconDefinition, FileIconFont, FileIconTheme } from '@shared/messages'; + +export type IconThemeColorKind = 'light' | 'dark' | 'highContrast'; + +export interface LanguageContribution { + id: string; + extensions?: string[]; + filenames?: string[]; +} + +interface RawIconDefinition { + iconPath?: unknown; + fontCharacter?: unknown; + fontColor?: unknown; + fontSize?: unknown; + fontId?: unknown; +} + +interface RawFont { + id?: unknown; + src?: unknown; + weight?: unknown; + style?: unknown; + size?: unknown; +} + +interface RawAssociations { + file?: unknown; + fileNames?: unknown; + fileExtensions?: unknown; + languageIds?: unknown; + folder?: unknown; + folderExpanded?: unknown; + folderNames?: unknown; + folderNamesExpanded?: unknown; +} + +interface RawIconTheme extends RawAssociations { + iconDefinitions?: unknown; + fonts?: unknown; + light?: unknown; + highContrast?: unknown; +} + +export interface BuildFileIconThemeOptions { + id: string; + document: unknown; + colorKind: IconThemeColorKind; + languages: LanguageContribution[]; + /** Resolves a path from the theme document (relative to the document) to a URI string. */ + resolveResource: (relativePath: string) => string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function normalizeExtensionKey(key: string): string { + return key.replace(/^\.+/, '').toLowerCase(); +} + +function mergeAssociationMap( + target: Record, + source: unknown, + normalizeKey: (key: string) => string +): void { + if (!isRecord(source)) { + return; + } + for (const [key, value] of Object.entries(source)) { + const iconId = asString(value); + const normalizedKey = normalizeKey(key); + if (iconId && normalizedKey) { + target[normalizedKey] = iconId; + } + } +} + +function toCssFamilyToken(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +/** + * Decodes CSS-style escapes such as "\\E001" used by font based icon themes. + */ +export function decodeFontCharacter(value: string): string { + return value.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_match, hex: string) => { + const codePoint = Number.parseInt(hex, 16); + return Number.isFinite(codePoint) && codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : ''; + }); +} + +/** + * Parses JSON that may contain comments and trailing commas, which VS Code accepts in icon theme files. + */ +export function parseJsonWithComments(text: string): unknown { + let result = ''; + let inString = false; + + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (inString) { + result += ch; + if (ch === '\\') { + result += text[i + 1] ?? ''; + i++; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + result += ch; + } else if (ch === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') { + i++; + } + result += '\n'; + } else if (ch === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + i = end === -1 ? text.length : end + 1; + } else if (ch === '}' || ch === ']') { + result = result.replace(/,\s*$/, '') + ch; + } else { + result += ch; + } + } + + return JSON.parse(result.replace(/^\uFEFF/, '')); +} + +/** + * Flattens a VS Code file icon theme document into the lookup tables used by the webview. + * Mirrors the precedence VS Code applies: file name, file extension, language id, default file. + */ +export function buildFileIconTheme(options: BuildFileIconThemeOptions): FileIconTheme { + const { id, document, colorKind, languages, resolveResource } = options; + if (!isRecord(document)) { + throw new Error(`Invalid file icon theme document: ${id}`); + } + const raw = document as RawIconTheme; + + const layers: unknown[] = [raw]; + if (colorKind === 'light') { + layers.push(raw.light); + } else if (colorKind === 'highContrast') { + layers.push(raw.highContrast); + } + + let file: string | undefined; + const fileNames: Record = {}; + const fileExtensions: Record = {}; + const languageIds: Record = {}; + let folder: string | undefined; + let folderExpanded: string | undefined; + const folderNames: Record = {}; + const folderNamesExpanded: Record = {}; + for (const layer of layers) { + if (!isRecord(layer)) { + continue; + } + const associations = layer as RawAssociations; + file = asString(associations.file) ?? file; + folder = asString(associations.folder) ?? folder; + folderExpanded = asString(associations.folderExpanded) ?? folderExpanded; + mergeAssociationMap(folderNames, associations.folderNames, (key) => key.toLowerCase()); + mergeAssociationMap(folderNamesExpanded, associations.folderNamesExpanded, (key) => key.toLowerCase()); + mergeAssociationMap(fileNames, associations.fileNames, (key) => key.toLowerCase()); + mergeAssociationMap(fileExtensions, associations.fileExtensions, normalizeExtensionKey); + mergeAssociationMap(languageIds, associations.languageIds, (key) => key); + } + + const fontsById = new Map(); + const fonts: FileIconFont[] = []; + if (Array.isArray(raw.fonts)) { + for (const rawFont of raw.fonts as RawFont[]) { + const fontId = isRecord(rawFont) ? asString(rawFont.id) : undefined; + if (!fontId || !Array.isArray(rawFont.src)) { + continue; + } + const sources = (rawFont.src as unknown[]).flatMap((source) => { + const sourcePath = isRecord(source) ? asString(source.path) : undefined; + if (!sourcePath) { + return []; + } + const format = isRecord(source) ? asString(source.format) : undefined; + return [{ uri: resolveResource(sourcePath), ...(format ? { format } : {}) }]; + }); + if (sources.length === 0) { + continue; + } + const font: FileIconFont = { + family: `intelli-git-icon-${toCssFamilyToken(id)}-${toCssFamilyToken(fontId)}`, + sources, + weight: asString(rawFont.weight), + style: asString(rawFont.style), + }; + fonts.push(font); + fontsById.set(fontId, { font, size: asString(rawFont.size) }); + } + } + const defaultFont = fontsById.values().next().value; + + const rawDefinitions = isRecord(raw.iconDefinitions) ? raw.iconDefinitions : {}; + const iconDefinitions: Record = {}; + const resolveDefinition = (iconId: string | undefined): boolean => { + if (!iconId) { + return false; + } + if (iconDefinitions[iconId]) { + return true; + } + const rawDefinition = rawDefinitions[iconId]; + if (!isRecord(rawDefinition)) { + return false; + } + const definition = rawDefinition as RawIconDefinition; + const iconPath = asString(definition.iconPath); + if (iconPath) { + iconDefinitions[iconId] = { iconUri: resolveResource(iconPath) }; + return true; + } + const fontCharacter = asString(definition.fontCharacter); + if (fontCharacter) { + const fontId = asString(definition.fontId); + const font = (fontId ? fontsById.get(fontId) : undefined) ?? defaultFont; + iconDefinitions[iconId] = { + fontCharacter: decodeFontCharacter(fontCharacter), + fontColor: asString(definition.fontColor), + fontSize: asString(definition.fontSize) ?? font?.size, + fontFamily: font?.font.family, + }; + return true; + } + return false; + }; + + const keepResolvable = (associations: Record): Record => + Object.fromEntries(Object.entries(associations).filter(([, iconId]) => resolveDefinition(iconId))); + + const resolvedLanguageIds = keepResolvable(languageIds); + const languageFileNames: Record = {}; + const languageExtensions: Record = {}; + for (const language of languages) { + if (!resolvedLanguageIds[language.id]) { + continue; + } + for (const fileName of language.filenames ?? []) { + const key = typeof fileName === 'string' ? fileName.toLowerCase() : ''; + if (key && !languageFileNames[key]) { + languageFileNames[key] = language.id; + } + } + for (const extension of language.extensions ?? []) { + const key = typeof extension === 'string' ? normalizeExtensionKey(extension) : ''; + if (key && !languageExtensions[key]) { + languageExtensions[key] = language.id; + } + } + } + + return { + id, + iconDefinitions, + fonts, + file: resolveDefinition(file) ? file : undefined, + fileNames: keepResolvable(fileNames), + fileExtensions: keepResolvable(fileExtensions), + languageIds: resolvedLanguageIds, + languageFileNames, + languageExtensions, + folder: resolveDefinition(folder) ? folder : undefined, + folderExpanded: resolveDefinition(folderExpanded) ? folderExpanded : undefined, + folderNames: keepResolvable(folderNames), + folderNamesExpanded: keepResolvable(folderNamesExpanded), + }; +} + +/** + * Returns a copy of the theme with every resource URI rewritten (for example through `webview.asWebviewUri`). + */ +export function mapFileIconThemeResources(theme: FileIconTheme, mapUri: (uri: string) => string): FileIconTheme { + return { + ...theme, + iconDefinitions: Object.fromEntries( + Object.entries(theme.iconDefinitions).map(([iconId, definition]) => [ + iconId, + definition.iconUri ? { ...definition, iconUri: mapUri(definition.iconUri) } : definition, + ]) + ), + fonts: theme.fonts.map((font) => ({ + ...font, + sources: font.sources.map((source) => ({ ...source, uri: mapUri(source.uri) })), + })), + }; +} diff --git a/apps/extension/src/utils/webviewHtml.ts b/apps/extension/src/utils/webviewHtml.ts index 4f1aa52..9bb3d3f 100644 --- a/apps/extension/src/utils/webviewHtml.ts +++ b/apps/extension/src/utils/webviewHtml.ts @@ -42,7 +42,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { - + diff --git a/apps/webview-ui/src/components/commit/ChangelistTree.tsx b/apps/webview-ui/src/components/commit/ChangelistTree.tsx index 1454592..cdcfce9 100644 --- a/apps/webview-ui/src/components/commit/ChangelistTree.tsx +++ b/apps/webview-ui/src/components/commit/ChangelistTree.tsx @@ -16,8 +16,8 @@ import type { } from '@shared/webviewContext'; import { useTranslation } from 'react-i18next'; import { BasicTreeView } from '../common/BasicTreeView'; -import type { TreeNode, BasicTreeViewRef } from '../common/BasicTreeView'; -import { getFileIcon } from '../../lib/fileIcons'; +import type { TreeNode, BasicTreeViewRef, TreeNodeRenderState } from '../common/BasicTreeView'; +import { FileIcon, FolderIcon } from '../common/FileIcon'; import { emitRefresh, rpc } from '@/lib/rpc_client'; import { logger } from '@/utils/logger'; import styles from '../file-tree/BaseFileTree.module.css'; @@ -517,7 +517,7 @@ export const ChangelistTree = React.forwardRef) => { + (node: TreeNode, { expanded }: TreeNodeRenderState) => { const status = node.data?.status; const statusColor = getStatusColor(status); const isDeleted = status === 'D'; @@ -593,7 +593,6 @@ export const ChangelistTree = React.forwardRef - + - + {node.label} ); diff --git a/apps/webview-ui/src/components/common/BasicTreeView.tsx b/apps/webview-ui/src/components/common/BasicTreeView.tsx index 2cb52f1..033736e 100644 --- a/apps/webview-ui/src/components/common/BasicTreeView.tsx +++ b/apps/webview-ui/src/components/common/BasicTreeView.tsx @@ -19,6 +19,10 @@ export interface TreeNode { data?: T; } +export interface TreeNodeRenderState { + expanded: boolean; +} + export interface BasicTreeViewProps { nodes: TreeNode[]; expandedIds?: Set; @@ -33,7 +37,8 @@ export interface BasicTreeViewProps { onContextMenu?: (e: React.MouseEvent, node: TreeNode) => void; onFocusNodeChange?: (node: TreeNode) => void; onFocusChange?: (focused: boolean) => void; - renderLabel?: (node: TreeNode) => React.ReactNode; + renderIcon?: (node: TreeNode, state: TreeNodeRenderState) => React.ReactNode; + renderLabel?: (node: TreeNode, state: TreeNodeRenderState) => React.ReactNode; renderTrailing?: (node: TreeNode) => React.ReactNode; getNodeClassName?: (node: TreeNode) => string | undefined; getContextData?: (node: TreeNode) => Record | undefined; @@ -86,7 +91,8 @@ interface TreeNodeItemProps { onContextMenu?: (e: React.MouseEvent, node: TreeNode) => void; onFocusNodeChange?: (node: TreeNode) => void; onFocusChange?: (focused: boolean) => void; - renderLabel?: (node: TreeNode) => React.ReactNode; + renderIcon?: (node: TreeNode, state: TreeNodeRenderState) => React.ReactNode; + renderLabel?: (node: TreeNode, state: TreeNodeRenderState) => React.ReactNode; renderTrailing?: (node: TreeNode) => React.ReactNode; getNodeClassName?: (node: TreeNode) => string | undefined; getContextData?: (node: TreeNode) => Record | undefined; @@ -320,6 +326,7 @@ const TreeNodeItem = (props: TreeNodeItemProps) => { onDoubleClick, onContextMenu, onFocusNodeChange, + renderIcon, renderLabel, renderTrailing, getNodeClassName, @@ -356,6 +363,7 @@ const TreeNodeItem = (props: TreeNodeItemProps) => { const contextData = getContextData?.(node); const nodeClassName = getNodeClassName?.(node); const leadingContent = renderLeading?.(node); + const customIcon = renderIcon?.(node, { expanded: isExpanded }); const canDrag = isDraggable?.(node) ?? false; const canDrop = isDropTarget?.(node) ?? false; const isDragOver = dragOverId === node.id; @@ -524,14 +532,18 @@ const TreeNodeItem = (props: TreeNodeItemProps) => { {leadingContent &&
{leadingContent}
} - {node.icon && ( -
- -
+ {customIcon != null ? ( +
{customIcon}
+ ) : ( + node.icon && ( +
+ +
+ ) )}
- {renderLabel ? renderLabel(node) : node.label} + {renderLabel ? renderLabel(node, { expanded: isExpanded }) : node.label}
{renderTrailing &&
{renderTrailing(node)}
} @@ -554,6 +566,7 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde onContextMenu, onFocusNodeChange, onFocusChange, + renderIcon, renderLabel, renderTrailing, getNodeClassName, @@ -1063,6 +1076,7 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} onFocusNodeChange={onFocusNodeChange} + renderIcon={renderIcon} renderLabel={renderLabel} renderTrailing={renderTrailing} getNodeClassName={getNodeClassName} @@ -1106,6 +1120,7 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} onFocusNodeChange={onFocusNodeChange} + renderIcon={renderIcon} renderLabel={renderLabel} renderTrailing={renderTrailing} getNodeClassName={getNodeClassName} diff --git a/apps/webview-ui/src/components/common/FileIcon.module.css b/apps/webview-ui/src/components/common/FileIcon.module.css new file mode 100644 index 0000000..14852f8 --- /dev/null +++ b/apps/webview-ui/src/components/common/FileIcon.module.css @@ -0,0 +1,22 @@ +.container { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.image { + width: 16px; + height: 16px; + object-fit: contain; + pointer-events: none; +} + +.glyph { + line-height: 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + pointer-events: none; +} diff --git a/apps/webview-ui/src/components/common/FileIcon.tsx b/apps/webview-ui/src/components/common/FileIcon.tsx new file mode 100644 index 0000000..56768ba --- /dev/null +++ b/apps/webview-ui/src/components/common/FileIcon.tsx @@ -0,0 +1,108 @@ +import type { FileIconDefinition } from '@shared/messages'; +import { getFileIcon } from '../../lib/fileIcons'; +import { useFileIconTheme } from '../../lib/fileIconTheme'; +import { resolveFileIcon, resolveFolderIcon } from '../../lib/fileIconThemeResolver'; +import styles from './FileIcon.module.css'; + +interface FileIconProps { + /** File base name used to resolve the icon. */ + name: string; + /** Class applied to the fixed-size icon container. */ + className?: string; + /** Color override applied only to the bundled fallback icons. */ + fallbackColor?: string; +} + +interface FolderIconProps { + /** Folder label; compacted labels such as "src/lib" resolve by their last segment. */ + name: string; + expanded: boolean; + /** Class applied to the fixed-size icon container. */ + className?: string; + /** Class applied to the codicon used when no file icon theme is available. */ + fallbackClassName?: string; +} + +function joinClassNames(...classNames: Array): string { + return classNames.filter(Boolean).join(' '); +} + +function ThemeIcon({ icon, className }: { icon: FileIconDefinition | undefined; className?: string }) { + const containerClassName = joinClassNames(styles.container, className); + + if (icon?.iconUri) { + return ( + + ); + } + + if (icon?.fontCharacter) { + return ( + + ); + } + + // Loading, or the theme has no icon for this item (for example file icons are disabled). + return