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..294cd08 --- /dev/null +++ b/apps/extension/src/services/FileIconThemeService.ts @@ -0,0 +1,184 @@ +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; +} + +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; + /** Last theme sent to webviews; `null` means no icon theme is set, `undefined` means none yet. */ + private appliedThemeId?: string | null; + + 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 null when no icon theme is set or the theme cannot be resolved, so webviews use + * the bundled icons. + */ + 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, filenamePatterns } = (language ?? {}) as Record; + if (typeof id !== 'string') { + return []; + } + return [ + { + id, + extensions: Array.isArray(extensions) ? extensions : undefined, + filenames: Array.isArray(filenames) ? filenames : undefined, + filenamePatterns: Array.isArray(filenamePatterns) ? filenamePatterns : undefined, + }, + ]; + }); + }); + } + + private async loadTheme(): Promise { + const configuredId = vscode.workspace.getConfiguration('workbench').get('iconTheme'); + if (configuredId === null) { + this.appliedThemeId = null; + return null; + } + + const contributions = this.getIconThemeContributions(); + const findContribution = (id: string) => contributions.find((candidate) => candidate.id === id); + let contribution = configuredId ? findContribution(configuredId) : undefined; + if (!contribution) { + // Like VS Code, keep the applied theme when the configured one is unknown; + // only the initial load falls back to the default theme. + if (this.appliedThemeId === null) { + return null; + } + contribution = + (this.appliedThemeId ? findContribution(this.appliedThemeId) : undefined) ?? + findContribution(DEFAULT_ICON_THEME_ID); + } + if (!contribution) { + logger.warn(`File icon theme "${configuredId}" was not found; using bundled file icons.`); + return null; + } + this.appliedThemeId = contribution.id; + + 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..7affe99 --- /dev/null +++ b/apps/extension/src/utils/fileIconTheme.test.ts @@ -0,0 +1,278 @@ +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', ts: 'typescript' }); + expect(theme.languageFileNames).toEqual({ readme: 'markdown' }); + expect(theme.languageIds.typescript).toBeUndefined(); + expect(theme.usesCurrentColor).toBeUndefined(); + }); + + it('keeps every language association so later registrations win, including file name patterns', () => { + const theme = buildFileIconTheme({ + id: 'vs-seti', + document, + colorKind: 'dark', + languages: [ + ...languages, + { + id: 'dockerfile', + extensions: ['.dockerfile'], + filenames: ['Dockerfile'], + filenamePatterns: ['Dockerfile.*', '**/docker/*.conf'], + }, + { id: 'mdx', extensions: ['.md'] }, + ], + resolveResource, + }); + + expect(theme.languageFileNames).toEqual({ readme: 'markdown', dockerfile: 'dockerfile' }); + expect(theme.languageExtensions).toMatchObject({ md: 'mdx', dockerfile: 'dockerfile' }); + expect(theme.languageFilenamePatterns).toEqual([ + { pattern: '**/docker/*.conf', languageId: 'dockerfile' }, + { pattern: 'dockerfile.*', languageId: 'dockerfile' }, + ]); + }); + + it('omits language tables when the theme has no language icons', () => { + const theme = buildFileIconTheme({ + id: 't', + document: { ...document, languageIds: {} }, + colorKind: 'dark', + languages, + resolveResource, + }); + + expect(theme.languageFileNames).toEqual({}); + expect(theme.languageExtensions).toEqual({}); + expect(theme.languageFilenamePatterns).toEqual([]); + }); + + it('reuses the JSON language icon for JSON with Comments', () => { + const theme = buildFileIconTheme({ + id: 't', + document: { + ...document, + languageIds: { json: '_markdown' }, + light: { languageIds: { json: '_ts' } }, + }, + colorKind: 'light', + languages, + resolveResource, + }); + + expect(theme.languageIds).toEqual({ json: '_ts', jsonc: '_ts' }); + + const explicit = buildFileIconTheme({ + id: 't', + document: { ...document, languageIds: { json: '_markdown', jsonc: '_ts' } }, + colorKind: 'dark', + languages, + resolveResource, + }); + expect(explicit.languageIds).toEqual({ json: '_markdown', jsonc: '_ts' }); + }); + + it('keeps parent folder qualifiers on association keys', () => { + const theme = buildFileIconTheme({ + id: 't', + document: { + ...document, + fileNames: { 'System/Win.ini': '_pkg' }, + fileExtensions: { '.github/YML': '_ts', '.TS': '_ts' }, + folderNames: { '.github/Workflows': '_file' }, + }, + colorKind: 'dark', + languages, + resolveResource, + }); + + expect(theme.fileNames).toEqual({ 'system/win.ini': '_pkg' }); + expect(theme.fileExtensions).toEqual({ '.github/yml': '_ts', ts: '_ts' }); + expect(theme.folderNames).toEqual({ '.github/workflows': '_file' }); + }); + + it('marks themes whose image icons use the current color', () => { + const theme = buildFileIconTheme({ + id: 't', + document: { ...document, usesCurrentColor: true }, + colorKind: 'dark', + languages, + resolveResource, + }); + + expect(theme.usesCurrentColor).toBe(true); + }); + + 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('sizes font glyphs like VS Code', () => { + const build = (fonts: unknown[]) => + buildFileIconTheme({ + id: 't', + document: { + iconDefinitions: { + _default: { fontCharacter: 'a' }, + _other: { fontCharacter: 'b', fontId: 'other' }, + _same: { fontCharacter: 'c', fontId: 'same' }, + _own: { fontCharacter: 'd', fontId: 'other', fontSize: '90%' }, + }, + fonts, + file: '_default', + fileExtensions: { b: '_other', c: '_same', d: '_own' }, + }, + colorKind: 'dark', + languages: [], + resolveResource, + }).iconDefinitions; + const font = (id: string, size?: string) => ({ id, src: [{ path: `./${id}.woff` }], size }); + + const unsized = build([font('main'), font('other', '26px'), font('same', '150%')]); + expect(unsized._default.fontSize).toBe('150%'); + expect(unsized._other.fontSize).toBe('200%'); + expect(unsized._same.fontSize).toBe('150%'); + expect(unsized._own.fontSize).toBe('90%'); + + const pixels = build([font('main', '13px'), font('other', '13px')]); + expect(pixels._default.fontSize).toBe('100%'); + expect(pixels._other.fontSize).toBe('100%'); + }); + + 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..eaeddaf --- /dev/null +++ b/apps/extension/src/utils/fileIconTheme.ts @@ -0,0 +1,354 @@ +import type { FileIconDefinition, FileIconFont, FileIconLanguagePattern, FileIconTheme } from '@shared/messages'; + +export type IconThemeColorKind = 'light' | 'dark' | 'highContrast'; + +export interface LanguageContribution { + id: string; + extensions?: string[]; + filenames?: string[]; + filenamePatterns?: string[]; +} + +const DEFAULT_FONT_SIZE = '150%'; + +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; + usesCurrentColor?: 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(); +} + +/** Normalizes an extension key that may be qualified by a parent folder, such as "system/ini". */ +function normalizeQualifiedExtensionKey(key: string): string { + const separator = key.lastIndexOf('/'); + if (separator === -1) { + return normalizeExtensionKey(key); + } + const extension = normalizeExtensionKey(key.slice(separator + 1)); + return extension ? `${key.slice(0, separator).toLowerCase()}/${extension}` : ''; +} + +/** Converts pixel font sizes to percentages of the 13px tree font, matching VS Code. */ +function normalizeFontSize(size: string | undefined): string | undefined { + if (size?.endsWith('px')) { + const pixels = Number.parseInt(size, 10); + return Number.isFinite(pixels) ? `${Math.round((pixels / 13) * 100)}%` : undefined; + } + return size; +} + +function withJsoncAlias(languageIds: unknown): unknown { + // VS Code reuses the JSON icon for JSON with Comments when a theme does not define one. + if (isRecord(languageIds) && languageIds.jsonc === undefined && languageIds.json !== undefined) { + return { ...languageIds, jsonc: languageIds.json }; + } + return languageIds; +} + +function buildLanguageTables( + languages: LanguageContribution[] +): Pick { + const languageFileNames: Record = {}; + const languageExtensions: Record = {}; + const languageFilenamePatterns: FileIconLanguagePattern[] = []; + // Later registrations override earlier ones, as in VS Code's language association lookup. + for (const language of languages) { + for (const fileName of language.filenames ?? []) { + const key = typeof fileName === 'string' ? fileName.toLowerCase() : ''; + if (key) { + languageFileNames[key] = language.id; + } + } + for (const extension of language.extensions ?? []) { + const key = typeof extension === 'string' ? normalizeExtensionKey(extension) : ''; + if (key) { + languageExtensions[key] = language.id; + } + } + for (const pattern of language.filenamePatterns ?? []) { + if (typeof pattern === 'string' && pattern) { + languageFilenamePatterns.unshift({ pattern: pattern.toLowerCase(), languageId: language.id }); + } + } + } + return { languageFileNames, languageExtensions, languageFilenamePatterns }; +} + +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, normalizeQualifiedExtensionKey); + mergeAssociationMap(languageIds, withJsoncAlias(associations.languageIds), (key) => key); + } + + const fontsById = new Map(); + const fonts: FileIconFont[] = []; + // VS Code applies the first font's size (default 150%) to every icon; other fonts only override it when different. + let defaultFontSize = DEFAULT_FONT_SIZE; + 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), + }; + const size = normalizeFontSize(asString(rawFont.size)); + if (fonts.length === 0) { + defaultFontSize = size ?? DEFAULT_FONT_SIZE; + } + fonts.push(font); + fontsById.set(fontId, { font, size: size !== defaultFontSize ? size : undefined }); + } + } + 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 ?? defaultFontSize, + 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); + // Detection must consider every language: a later language without an icon still claims its files. + const languageTables = + Object.keys(resolvedLanguageIds).length > 0 + ? buildLanguageTables(languages) + : { languageFileNames: {}, languageExtensions: {}, languageFilenamePatterns: [] }; + + return { + id, + iconDefinitions, + fonts, + ...(raw.usesCurrentColor === true ? { usesCurrentColor: true } : {}), + file: resolveDefinition(file) ? file : undefined, + fileNames: keepResolvable(fileNames), + fileExtensions: keepResolvable(fileExtensions), + languageIds: resolvedLanguageIds, + ...languageTables, + 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..1b1d0a7 100644 --- a/apps/webview-ui/src/components/commit/ChangelistTree.tsx +++ b/apps/webview-ui/src/components/commit/ChangelistTree.tsx @@ -16,8 +16,9 @@ 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 { useCompactFileTreeLayout } from '@/lib/fileIconTheme'; import { emitRefresh, rpc } from '@/lib/rpc_client'; import { logger } from '@/utils/logger'; import styles from '../file-tree/BaseFileTree.module.css'; @@ -55,6 +56,8 @@ interface FileNodeData { repository?: RepositoryInfo; workspaceRoot?: string; path: string; + /** Repository-relative folder path for folder nodes. */ + folderPath?: string; isFile: boolean; isRepositoryRoot?: boolean; hunkIds?: string[]; @@ -82,6 +85,10 @@ const getDirPath = (fullPath: string): string => { return lastSlash > 0 ? fullPath.substring(0, lastSlash) : ''; }; +// Include the repository folder so top-level items see it as their parent folder, as in the Explorer. +const getIconPath = (repoPath: string | undefined, relativePath: string): string => + repoPath ? `${repoPath.replace(/[\\/]+$/, '')}/${relativePath}` : relativePath; + const getStatusColor = (status?: string) => { if (status === 'C' || status === 'U') return 'var(--vscode-gitDecoration-conflictingResourceForeground)'; if (status === 'A') return 'var(--vscode-gitDecoration-addedResourceForeground)'; @@ -124,6 +131,7 @@ const buildTree = ( repository, workspaceRoot, path: file.path, + folderPath: isLast ? undefined : currentPath, isFile: isLast, status: isLast ? file.status : undefined, staged: isLast ? file.staged : undefined, @@ -516,8 +524,10 @@ export const ChangelistTree = React.forwardRef) => { + (node: TreeNode, { expanded }: TreeNodeRenderState) => { const status = node.data?.status; const statusColor = getStatusColor(status); const isDeleted = status === 'D'; @@ -593,7 +603,6 @@ export const ChangelistTree = React.forwardRef - - + {node.label} ); @@ -928,7 +943,8 @@ export const ChangelistTree = React.forwardRef (node.data?.showInDragMode ? 'dropOnlyGroup' : undefined)} - indent={16} + indent={compactLayout ? 8 : 16} + hideTwistie={compactLayout ? (node) => Boolean(node.data?.isFile) : undefined} baseIndent={8} isDraggable={isDraggable} isDropTarget={isDropTarget} diff --git a/apps/webview-ui/src/components/commit/CommitForm.module.css b/apps/webview-ui/src/components/commit/CommitForm.module.css index 00f2751..9bb5028 100644 --- a/apps/webview-ui/src/components/commit/CommitForm.module.css +++ b/apps/webview-ui/src/components/commit/CommitForm.module.css @@ -24,7 +24,7 @@ display: flex; align-items: center; gap: 6px; - font-size: 12px; + font-size: var(--ig-font-size-sm); cursor: pointer; user-select: none; color: var(--vscode-foreground); @@ -94,7 +94,7 @@ padding: 6px 8px; border: 1px solid var(--vscode-panel-border); border-radius: 4px; - font-size: 12px; + font-size: var(--ig-font-size-sm); line-height: 16px; word-break: break-word; } @@ -116,7 +116,7 @@ } .stats { - font-size: 12px; + font-size: var(--ig-font-size-sm); opacity: 0.9; display: flex; gap: 8px; @@ -145,7 +145,7 @@ resize: vertical; background: var(--vscode-input-background); color: var(--vscode-input-foreground); - font-size: 12px; + font-size: var(--ig-font-size-sm); box-sizing: border-box; } @@ -164,7 +164,7 @@ border: 1px solid var(--vscode-panel-border); border-radius: 4px; background: var(--vscode-sideBarSectionHeader-background); - font-size: 12px; + font-size: var(--ig-font-size-sm); } .commitPlanTitle { @@ -189,7 +189,7 @@ border: 1px solid var(--vscode-panel-border); border-radius: 4px; background: var(--vscode-sideBarSectionHeader-background); - font-size: 12px; + font-size: var(--ig-font-size-sm); } .pushTargetWarning { @@ -222,7 +222,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-family: var(--vscode-editor-font-family); + font-family: var(--ig-code-font-family); } .pushTargetButton { @@ -232,7 +232,7 @@ padding: 2px 6px; background: transparent; color: var(--vscode-textLink-foreground); - font-size: 12px; + font-size: var(--ig-font-size-sm); cursor: pointer; } @@ -250,7 +250,7 @@ background: var(--vscode-inputValidation-warningBackground); border: 1px solid var(--vscode-inputValidation-warningBorder); border-radius: 4px; - font-size: 12px; + font-size: var(--ig-font-size-sm); color: var(--vscode-inputValidation-warningForeground, var(--vscode-foreground)); } @@ -288,7 +288,7 @@ padding: 2px 6px; background: transparent; color: var(--vscode-textLink-foreground); - font-size: 12px; + font-size: var(--ig-font-size-sm); cursor: pointer; } @@ -312,7 +312,7 @@ .mainBtn { composes: splitMainButton from '../common/ControlStyles.module.css'; padding: 6px 12px; - font-size: 12px; + font-size: var(--ig-font-size-sm); } /* Options trigger button */ diff --git a/apps/webview-ui/src/components/commit/CommitToolbar.module.css b/apps/webview-ui/src/components/commit/CommitToolbar.module.css index a9ec8c7..b7e2017 100644 --- a/apps/webview-ui/src/components/commit/CommitToolbar.module.css +++ b/apps/webview-ui/src/components/commit/CommitToolbar.module.css @@ -51,7 +51,7 @@ background: var(--vscode-editorHoverWidget-background, var(--vscode-editorWidget-background)); box-shadow: 0 2px 8px var(--vscode-widget-shadow, rgb(0 0 0 / 30%)); color: var(--vscode-editorHoverWidget-foreground, var(--vscode-foreground)); - font-size: 11px; + font-size: var(--ig-font-size-xs); line-height: 16px; opacity: 0; pointer-events: none; diff --git a/apps/webview-ui/src/components/commit/CommitView.module.css b/apps/webview-ui/src/components/commit/CommitView.module.css index 2b75224..16cf5aa 100644 --- a/apps/webview-ui/src/components/commit/CommitView.module.css +++ b/apps/webview-ui/src/components/commit/CommitView.module.css @@ -31,7 +31,7 @@ .stateTitle { max-width: 360px; - font-size: 14px; + font-size: var(--ig-font-size-lg); font-weight: 600; line-height: 20px; } @@ -40,7 +40,7 @@ max-width: 360px; margin-top: 6px; color: var(--vscode-descriptionForeground); - font-size: 12px; + font-size: var(--ig-font-size-sm); line-height: 18px; } @@ -48,7 +48,7 @@ max-width: 360px; margin-top: 6px; color: var(--vscode-descriptionForeground); - font-size: 11px; + font-size: var(--ig-font-size-xs); line-height: 16px; } @@ -77,7 +77,7 @@ color: var(--vscode-button-secondaryForeground); cursor: pointer; font: inherit; - font-size: 12px; + font-size: var(--ig-font-size-sm); line-height: 18px; } diff --git a/apps/webview-ui/src/components/commit/RebaseForm.module.css b/apps/webview-ui/src/components/commit/RebaseForm.module.css index 3c31f2b..4ef1c97 100644 --- a/apps/webview-ui/src/components/commit/RebaseForm.module.css +++ b/apps/webview-ui/src/components/commit/RebaseForm.module.css @@ -19,20 +19,20 @@ display: flex; align-items: center; opacity: 0.7; - font-size: 12px; + font-size: var(--ig-font-size-sm); font-weight: bold; gap: 4px; } .rebaseHint { width: 100%; - font-size: 11px; + font-size: var(--ig-font-size-xs); color: var(--vscode-descriptionForeground); opacity: 0.9; } .stats { - font-size: 12px; + font-size: var(--ig-font-size-sm); opacity: 0.9; display: flex; gap: 8px; @@ -84,7 +84,7 @@ .btn { padding: 4px 12px; - font-size: 12px; + font-size: var(--ig-font-size-sm); border: none; border-radius: 3px; cursor: pointer; diff --git a/apps/webview-ui/src/components/common/BasicTreeView.module.css b/apps/webview-ui/src/components/common/BasicTreeView.module.css index 32a76b9..4ac0b18 100644 --- a/apps/webview-ui/src/components/common/BasicTreeView.module.css +++ b/apps/webview-ui/src/components/common/BasicTreeView.module.css @@ -24,7 +24,7 @@ align-items: center; height: 22px; padding-right: 8px; - font-size: 13px; + font-size: var(--ig-font-size); line-height: 22px; cursor: pointer; user-select: none; @@ -114,6 +114,15 @@ padding-left: 4px; } +/* Rows without a twistie (hideTwistie) start directly at the indentation. */ +.leading:first-child { + margin-left: 0; +} + +.label:first-child { + padding-left: 0; +} + .trailing { display: flex; align-items: center; @@ -139,7 +148,7 @@ display: flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: var(--ig-font-size-xs); opacity: 0.85; } diff --git a/apps/webview-ui/src/components/common/BasicTreeView.tsx b/apps/webview-ui/src/components/common/BasicTreeView.tsx index 2cb52f1..c0500d8 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,10 @@ 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; + /** Drops the empty twistie slot for matching leaf rows, like VS Code trees with hideTwistiesOfChildlessElements. */ + hideTwistie?: (node: TreeNode) => boolean; + renderLabel?: (node: TreeNode, state: TreeNodeRenderState) => React.ReactNode; renderTrailing?: (node: TreeNode) => React.ReactNode; getNodeClassName?: (node: TreeNode) => string | undefined; getContextData?: (node: TreeNode) => Record | undefined; @@ -86,7 +93,9 @@ 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; + hideTwistie?: (node: TreeNode) => boolean; + renderLabel?: (node: TreeNode, state: TreeNodeRenderState) => React.ReactNode; renderTrailing?: (node: TreeNode) => React.ReactNode; getNodeClassName?: (node: TreeNode) => string | undefined; getContextData?: (node: TreeNode) => Record | undefined; @@ -320,6 +329,8 @@ const TreeNodeItem = (props: TreeNodeItemProps) => { onDoubleClick, onContextMenu, onFocusNodeChange, + renderIcon, + hideTwistie, renderLabel, renderTrailing, getNodeClassName, @@ -356,6 +367,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; @@ -512,26 +524,32 @@ const TreeNodeItem = (props: TreeNodeItemProps) => { }} {...(contextData ? { 'data-vscode-context': JSON.stringify(contextData) } : {})} > -
{ - e.stopPropagation(); - if (hasChildren) toggleNode(node.id); - }} - > - {hasChildren && } -
+ {!(isLeaf && hideTwistie?.(node)) && ( +
{ + e.stopPropagation(); + if (hasChildren) toggleNode(node.id); + }} + > + {hasChildren && } +
+ )} {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 +572,8 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde onContextMenu, onFocusNodeChange, onFocusChange, + renderIcon, + hideTwistie, renderLabel, renderTrailing, getNodeClassName, @@ -1063,6 +1083,8 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} onFocusNodeChange={onFocusNodeChange} + renderIcon={renderIcon} + hideTwistie={hideTwistie} renderLabel={renderLabel} renderTrailing={renderTrailing} getNodeClassName={getNodeClassName} @@ -1106,6 +1128,8 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} onFocusNodeChange={onFocusNodeChange} + renderIcon={renderIcon} + hideTwistie={hideTwistie} renderLabel={renderLabel} renderTrailing={renderTrailing} getNodeClassName={getNodeClassName} @@ -1147,7 +1171,7 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde borderRadius: '5px', color: 'var(--vscode-foreground)', fontFamily: 'var(--vscode-font-family)', - fontSize: '13px', + fontSize: 'var(--ig-font-size)', pointerEvents: 'none', zIndex: 9999, whiteSpace: 'nowrap', @@ -1162,7 +1186,7 @@ function BasicTreeViewInner(props: BasicTreeViewProps, ref: React.Forwarde borderRadius: '10px', padding: '0 6px', marginRight: '6px', - fontSize: '11px', + fontSize: 'var(--ig-font-size-xs)', height: '16px', alignItems: 'center', justifyContent: 'center', diff --git a/apps/webview-ui/src/components/common/BranchStatus.module.css b/apps/webview-ui/src/components/common/BranchStatus.module.css index a6f1285..37ab48e 100644 --- a/apps/webview-ui/src/components/common/BranchStatus.module.css +++ b/apps/webview-ui/src/components/common/BranchStatus.module.css @@ -60,7 +60,7 @@ } .name { - font-size: 11px; + font-size: var(--ig-font-size-xs); max-width: 100px; overflow: hidden; text-overflow: ellipsis; @@ -87,7 +87,7 @@ } .count { - font-size: 10px; + font-size: var(--ig-font-size-2xs); } .synced { diff --git a/apps/webview-ui/src/components/common/CommitDetailsView.module.css b/apps/webview-ui/src/components/common/CommitDetailsView.module.css index 9073de1..cc7c77d 100644 --- a/apps/webview-ui/src/components/common/CommitDetailsView.module.css +++ b/apps/webview-ui/src/components/common/CommitDetailsView.module.css @@ -3,7 +3,7 @@ display: flex; flex-direction: column; overflow: hidden; - font-size: 13px; + font-size: var(--ig-font-size); min-width: 0; } @@ -59,7 +59,7 @@ } .filesCount { - font-size: 11px; + font-size: var(--ig-font-size-xs); color: var(--vscode-descriptionForeground); } @@ -81,19 +81,18 @@ .detailMessage { white-space: pre-wrap; - font-family: var(--vscode-editor-font-family); margin-bottom: 8px; color: var(--vscode-foreground); } .subject { - font-size: 13px; + font-size: var(--ig-font-size); font-weight: 600; line-height: 1.4; } .body { - font-size: 11px; + font-size: var(--ig-font-size-xs); margin-top: 4px; line-height: 1.4; opacity: 0.9; @@ -103,7 +102,7 @@ display: flex; flex-direction: column; gap: 4px; - font-size: 12px; + font-size: var(--ig-font-size-sm); color: var(--vscode-descriptionForeground); } @@ -135,9 +134,9 @@ } .metaHash { - font-family: var(--vscode-editor-font-family); + font-family: var(--ig-code-font-family); opacity: 0.8; - font-size: 11px; + font-size: var(--ig-code-font-size); } .metaLabel { diff --git a/apps/webview-ui/src/components/common/ContextMenu.module.css b/apps/webview-ui/src/components/common/ContextMenu.module.css index c12274b..c6b3618 100644 --- a/apps/webview-ui/src/components/common/ContextMenu.module.css +++ b/apps/webview-ui/src/components/common/ContextMenu.module.css @@ -12,7 +12,7 @@ .contextMenuItem { padding: 6px 12px; cursor: pointer; - font-size: 12px; + font-size: var(--ig-font-size-sm); display: flex; align-items: center; gap: 8px; diff --git a/apps/webview-ui/src/components/common/ControlStyles.module.css b/apps/webview-ui/src/components/common/ControlStyles.module.css index a31d1e0..876cb23 100644 --- a/apps/webview-ui/src/components/common/ControlStyles.module.css +++ b/apps/webview-ui/src/components/common/ControlStyles.module.css @@ -131,7 +131,7 @@ .dropdownHeader { padding: 6px 10px 4px; color: var(--vscode-descriptionForeground); - font-size: 10px; + font-size: var(--ig-font-size-2xs); font-weight: 600; letter-spacing: 0.5px; text-transform: uppercase; @@ -145,7 +145,7 @@ border: none; background: transparent; color: var(--vscode-foreground); - font-size: 12px; + font-size: var(--ig-font-size-sm); cursor: pointer; transition: background-color 0.1s ease; } @@ -206,7 +206,7 @@ .emptyDescription { color: var(--vscode-descriptionForeground); - font-size: 12px; + font-size: var(--ig-font-size-sm); } .emptyActions { @@ -225,7 +225,7 @@ border-radius: 3px; background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); - font-size: 12px; + font-size: var(--ig-font-size-sm); cursor: pointer; } 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..fa206fa --- /dev/null +++ b/apps/webview-ui/src/components/common/FileIcon.module.css @@ -0,0 +1,35 @@ +.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; +} + +.mask { + width: 16px; + height: 16px; + background-color: currentColor; + mask-repeat: no-repeat; + mask-position: left center; + mask-size: 16px; + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: left center; + -webkit-mask-size: 16px; + 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..7a4e178 --- /dev/null +++ b/apps/webview-ui/src/components/common/FileIcon.tsx @@ -0,0 +1,159 @@ +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 path used to resolve the icon; the parent folder matters for folder-qualified theme rules. */ + path: string; + /** Class applied to the fixed-size icon container. */ + className?: string; + /** Color override applied only to the bundled fallback icons. */ + fallbackColor?: string; + /** Label color used to fill icons of themes that set `usesCurrentColor`. */ + labelColor?: string; +} + +interface FolderIconProps { + /** Folder path used to resolve the icon; the parent folder matters for folder-qualified theme rules. */ + path: 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; + /** Label color used to fill icons of themes that set `usesCurrentColor`. */ + labelColor?: string; +} + +interface ThemeIconProps { + icon: FileIconDefinition | undefined; + usesCurrentColor: boolean; + labelColor?: string; + className?: string; +} + +function joinClassNames(...classNames: Array): string { + return classNames.filter(Boolean).join(' '); +} + +function getBaseName(path: string): string { + return path.split(/[\\/]/).pop() || path; +} + +function ThemeIcon({ icon, usesCurrentColor, labelColor, className }: ThemeIconProps) { + const containerClassName = joinClassNames(styles.container, className); + + if (icon?.iconUri && usesCurrentColor) { + // Matches VS Code: the image is a mask filled with the label's current color. + const mask = `url("${icon.iconUri}")`; + return ( +