diff --git a/AGENTS.md b/AGENTS.md index 0139dbf..dcef8fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ - **构建纯度门**:client bundle 禁止 value-import `@dsh-external/*` 或非白名单 `@deepseek-ai/*`(`tsdown.config.ts` 拦截);`import type {}` 被擦除不触发——类型可共享,运行时符号不行;跨插件交互走 `ctx.betterSidebar` 方法调用。 - **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)在独立 bundle(`lib/client-.js`),经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`),**核心 bundle 禁止静态 import `src/client/chunks/*`**。 - **i18n**:词典在 `betterSidebar` 命名空间,跟随 DSH `ctx.locale`;**新增 zh key 必须同步 `src/client/locales-ja.ts` 的 ja 翻译**(否则 ja 下回退 en)。渲染 `MarkdownText` 必须经 `markdownTextProps()`(§3 第 3 条)。 -- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色;契约全文与 titleBar 四方案模型见[指南 §12](docs/external-plugin-guide.md),改动必须同步该节与 `tests/theme.spec.ts`。 +- **皮肤契约**:视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌,无硬编码颜色(**唯一豁免**:可选彩色图标主题 `src/client/chunks/file-icons.tsx`,品牌色是内容非 chrome,且只在懒加载 chunk + 用户显式开启;核心图标模块零颜色字面量由 `tests/theme.spec.ts` 守护);契约全文与 titleBar 四方案模型见[指南 §12](docs/external-plugin-guide.md),改动必须同步该节与 `tests/theme.spec.ts`。 - **契约反向引用**:皮肤契约被 `src/client/shell-presets.ts`、`tests/e2e/mount.e2e.ts` 的注释以「指南 §12」引用——调整指南章节结构时同步检查这两处。 - **接入 API 即文档**:`src/client/service.ts` 与 `src/client/builtins/` 的任何行为变更,必须同步 [docs/external-plugin-guide.md](docs/external-plugin-guide.md)(唯一权威接入文档,不再双份维护)。 diff --git a/README.md b/README.md index 3975810..dd39da9 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,8 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ### v0.19.0(未发布) -- 🎨 **文件图标多样化 + 对外图标注册 API([file-icons.tsx](./src/client/file-icons.tsx))**:文件树与编辑器文件 tab 不再是单一 `VscFile`——markdown / 图片媒体 / PDF / JSON / 40+ 代码扩展 / 配置 / 数据库 / lock / 压缩包各有专属 glyph(VSCodicons 单色 `currentColor`,遵循皮肤契约),未知扩展回退通用图标。`ctx.betterSidebar` 新增 `registerFileIcon`(`features` 含 `'fileIcons'`):按扩展名注册自定义图标(彩色 ReactNode 亦可,颜色责任在注册方),`exts: []` 为全局默认(只兜内置 glyph 没认领的扩展,不吞掉内置多样性),保留扩展名 `'folder'` / `'folder-open'` 可换目录行图标;`fileIcon` / `folderIcon` 为权威解析器(完整回退链 + 逐工厂崩溃隔离),注册/注销即时生效。接入示例见[外部插件指南 §7](./docs/external-plugin-guide.md)。 +- 🎨 **可选彩色图标主题**:设置页「文件 → 文件图标」可在**内置单色**与**彩色品牌图标**之间切换——彩色主题含 563 条规则(218 扩展名 + 197 精确文件名 + 148 目录名,如 `.tsx → React`、`package.json → npm`、`node_modules` 着色),数据在**懒加载 chunk**(`lib/client-file-icons.js`)里,只有选中时才下载,默认关闭时启动零开销。数据源自 [#429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)。 +- 🎨 **文件图标多样化 + 对外图标注册 API([file-icons.tsx](./src/client/file-icons.tsx))**:文件树与编辑器文件 tab 不再是单一 `VscFile`——markdown / 图片媒体 / PDF / JSON / 40+ 代码扩展 / 配置 / 数据库 / lock / 压缩包各有专属 glyph(VSCodicons 单色 `currentColor`,遵循皮肤契约),未知扩展回退通用图标。`ctx.betterSidebar` 新增 `registerFileIcon`(`features` 含 `'fileIcons'`):按扩展名 / 精确文件名(`names`)/ 目录名(`folderNames`)注册自定义图标(彩色 ReactNode 亦可,颜色责任在注册方),`exts: []` 为全局默认(只兜内置 glyph 没认领的扩展,不吞掉内置多样性),保留扩展名 `'folder'` / `'folder-open'` 可换目录行图标;`fileIcon` / `folderIcon` 为权威解析器(完整回退链 + 逐工厂崩溃隔离),注册/注销即时生效。接入示例见[外部插件指南 §7](./docs/external-plugin-guide.md)。 ### v0.18.1 diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index f7edaa7..94a1b3b 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -591,19 +591,22 @@ interface BetterSidebarService { getFileViewers(): readonly FileViewerDescriptor[] /** 当前已注册的文件图标描述符快照(v0.19.0+) */ getFileIcons(): readonly FileIconDescriptor[] - /** 按 path 匹配**具体扩展名**注册(priority 降序、注册序;不查 catch-all - * 与 folder 保留值)。消费方一般直接用 fileIcon/folderIcon 全链解析器。 */ + /** 按 path 匹配**具体**注册(priority 降序、注册序):先 names 文件名,再 + * 具体扩展名;不查 catch-all 与 folder 保留值。消费方一般直接用 + * fileIcon/folderIcon 全链解析器。 */ matchFileIcon(path: string): FileIconDescriptor | undefined - /** 匹配目录行注册('folder'/'folder-open' 保留扩展名;priority 降序、注册序; - * 返回 undefined = 回退内置 VscFolder/VscFolderOpened) */ - matchFolderIcon(open: boolean): FileIconDescriptor | undefined + /** 匹配目录行注册:先按 folderNames 匹配目录名(name 传 basename,可省), + * 再按 'folder'/'folder-open' 保留扩展名;priority 降序、注册序; + * 返回 undefined = 回退内置 VscFolder/VscFolderOpened */ + matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined /** 文件图标权威解析器(v0.19.0+),完整回退链: - * ① 具体扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包) + * ① 具体 names/扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包) * → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。 * 任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */ fileIcon(path: string, size: number): ReactNode - /** 目录图标解析器:注册的 'folder'(闭合)/'folder-open'(展开)图标 → - * 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化)。 */ + /** 目录图标解析器:注册的 folderNames/'folder'(闭合)/'folder-open'(展开)图标 → + * 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化), + * open 会传给工厂,一条注册即可渲染开/合两态。 */ folderIcon(path: string, open: boolean, size: number): ReactNode /** 按 id 查 tab 描述符 */ getTab(id: string): TabDescriptor | undefined @@ -680,14 +683,23 @@ interface FileIconDescriptor { * 而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)—— * 它们不会匹配真实文件(名为 x.folder 的文件不受影响)。 * [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体 - * 扩展名与内置 glyph 永远优先于它)。 */ - exts: readonly string[] + * names/扩展名与内置 glyph 永远优先于它)。 + * **省略** = 完全没有扩展名规则(只有 names 的注册不是 catch-all)。 */ + exts?: readonly string[] + /** 精确**文件名**(basename,大小写不敏感,如 ['package.json','Dockerfile'])—— + * 图标主题的 fileNames 半边;命中优先于扩展名。省略/[] = 无文件名规则。 */ + names?: readonly string[] + /** 精确**目录名**(basename,大小写不敏感,如 ['node_modules','src'])—— + * 图标主题的 folderNames 半边;命中优先于保留扩展名,且**只认领列出的 + * 目录**(要接管所有目录请用 'folder'/'folder-open')。省略/[] = 无规则。 */ + folderNames?: readonly string[] /** priority 高者胜,缺省 0(同级按注册先后) */ priority?: number /** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。 * 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是 - * 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。 */ - icon: (path: string, size: number) => ReactNode + * 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。 + * open:目录行的展开态(文件行为 undefined),一条注册即可渲染开/合两态。 */ + icon: (path: string, size: number, open?: boolean) => ReactNode } ``` @@ -704,9 +716,23 @@ if (ctx.betterSidebar.features.includes('fileIcons')) { ) ctx.effect(() => ctx.betterSidebar.registerFileIcon({ - id: 'my-plugin:folders', - exts: ['folder'], // 闭合目录行;['folder-open'] 认领展开行—— - icon: (path, size) => , // 开/合图标不同就注册两条 + id: 'my-plugin:names', // 精确文件名:package.json 与别的 .json 区分开 + names: ['package.json', 'Dockerfile'], + icon: (path, size) => , + }) + ) + ctx.effect(() => + ctx.betterSidebar.registerFileIcon({ + id: 'my-plugin:folders', // 只认领列出的目录名 + folderNames: ['node_modules', 'src'], + icon: (path, size, open) => open === true ? : , + }) + ) + ctx.effect(() => + ctx.betterSidebar.registerFileIcon({ + id: 'my-plugin:all-folders', // 接管所有目录行(保留扩展名) + exts: ['folder', 'folder-open'], + icon: (path, size, open) => open === true ? : , }) ) ctx.effect(() => @@ -722,14 +748,24 @@ if (ctx.betterSidebar.features.includes('fileIcons')) { **消费表面与回退链**(由本插件内置消费,插件无需自己接线): - 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):`fileIcon(path, size)` - ——具体扩展名注册 → 内置 glyph(markdown/媒体/pdf/json/代码/配置/数据库/lock/压缩包) + ——具体 `names`/扩展名注册 → 内置 glyph(markdown/媒体/pdf/json/代码/配置/数据库/lock/压缩包) → catch-all 全局默认 → 通用 `VscFile`。 -- 文件树目录行(含根行):`folderIcon(path, open, size)`——`'folder'`/`'folder-open'` - 注册 → 内置 `VscFolder`/`VscFolderOpened`。 +- 文件树目录行(含根行):`folderIcon(path, open, size)`——`folderNames` 命中 + → `'folder'`/`'folder-open'` 保留扩展名 → 内置 `VscFolder`/`VscFolderOpened`。 注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉 (console.error 后跳到回退链下一级),不会空白行。 +**内置的可选彩色图标主题**(v0.19.0+,设置页「文件 → 文件图标」): + +- `'builtin'`(默认)=上面的单色 glyph 映射,零额外加载。 +- `'colored'`=563 条品牌/通用彩色规则(218 扩展名 + 197 文件名 + 148 目录名), + 数据在**懒加载 chunk** `lib/client-file-icons.js`(`/sidebar/bundle/file-icons.js`), + 只有用户选中时才拉取,选中后走 `registerFileIcon` 注册、切回即注销(`src/client/file-icon-theme.ts`)。 + 数据来自 [PR #429](https://github.com/omdsh-dev/DSH-better-sidebar/pull/429)(@fenter)。 +- 因此彩色图标是**皮肤契约 §12 的唯一豁免面**:品牌色是内容而非 chrome,不能走 + `--dsw-alias-*`;豁免只覆盖这个 chunk(核心图标模块零颜色字面量,由 `tests/theme.spec.ts` 守护)。 + **版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级: ```ts @@ -858,6 +894,13 @@ ctx.effect(() => ## 12. 皮肤兼容(令牌驱动) > better-sidebar 所有视觉值消费 DSH 的 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌(无硬编码颜色),**不做每皮肤适配**。已与 dsh-web-ui 皮肤中心兼容(10 款皮肤全覆盖 `--dsw-alias-*` 层;`tests/theme.spec.ts` 守护)。你的 tab/viewer 组件遵循同样的令牌规则即可自动兼容全部皮肤。 +> +> **唯一豁免面**:可选彩色文件图标主题(`fileIconTheme: 'colored'`)在 +> `src/client/chunks/file-icons.tsx` 内硬编码品牌色——品牌色是内容标识而非 chrome,无法 +> 映射到语义令牌;豁免的三个前提是「用户显式开启」「数据只在懒加载 chunk」「核心图标 +> 模块零颜色字面量」(`tests/theme.spec.ts` 的 boundary 用例守护)。插件自己注册的 +> 图标(`registerFileIcon`)颜色由注册方负责,同样不受本节令牌约束,但**不要**把彩色 +> 图标塞进核心 bundle 的常驻渲染路径。 ### 12.1 规则 diff --git a/src/bundle-route.ts b/src/bundle-route.ts index 4f098ec..24b9147 100644 --- a/src/bundle-route.ts +++ b/src/bundle-route.ts @@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url' import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts' /** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */ -export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale'] as const +export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid', 'locale', 'file-icons'] as const export type ChunkName = (typeof CHUNK_NAMES)[number] /** Directory of this host-half module (lib/ — the chunk scripts live next to it). */ diff --git a/src/client/builtins/tabs.tsx b/src/client/builtins/tabs.tsx index 0982808..eed3ec3 100644 --- a/src/client/builtins/tabs.tsx +++ b/src/client/builtins/tabs.tsx @@ -8,7 +8,7 @@ * `browser:` the same way (no quota). The editor IS the files window * (the old standalone explorer merged into it). */ -import { IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCodeOutline16, IconFolderOpen16, IconFolderOpenOutline16, IconNewChatOutline16, IconPanelLeftOutline16, IconSparkle16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../../context-types.ts' import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts' import { t } from '../locales.ts' @@ -122,6 +122,28 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read key: 'workspaceFence', title: () => t('settingsFenceTitle'), desc: () => t('settingsFenceDesc'), + }, { + // The file-icon theme: monochrome built-in glyphs (default) vs the + // optional colored brand dataset, which is fetched from the lazy + // `file-icons` chunk only when picked (see file-icon-theme.ts). + key: 'fileIconTheme', + type: 'select', + title: () => t('settingsFileIconThemeTitle'), + desc: () => t('settingsFileIconThemeDesc'), + options: [ + { + value: 'builtin', + icon: (size: number) => , + title: () => t('settingsFileIconThemeBuiltin'), + desc: () => t('settingsFileIconThemeBuiltinDesc'), + }, + { + value: 'colored', + icon: (size: number) => , + title: () => t('settingsFileIconThemeColored'), + desc: () => t('settingsFileIconThemeColoredDesc'), + }, + ], }], render: ({ pluginSettings, updatePluginSetting }) => ( diff --git a/src/client/chunk-loader.ts b/src/client/chunk-loader.ts index d89fdbf..e5181be 100644 --- a/src/client/chunk-loader.ts +++ b/src/client/chunk-loader.ts @@ -50,7 +50,7 @@ * client.js); an edit that does land while a core HMR happens is caught by * the ETag comparison on the next activation. */ -export type ChunkName = 'terminal' | 'editor' | 'mermaid' | 'locale' +export type ChunkName = 'terminal' | 'editor' | 'mermaid' | 'locale' | 'file-icons' /** The module exports a chunk factory provides (namespace-ish record). */ export type ChunkExports = Record diff --git a/src/client/chunks/file-icons.tsx b/src/client/chunks/file-icons.tsx new file mode 100644 index 0000000..c4b93f7 --- /dev/null +++ b/src/client/chunks/file-icons.tsx @@ -0,0 +1,773 @@ +/** + * Lazy chunk entry: the colored file-icon theme dataset — brand and generic + * glyphs paired with their display color (extensions / exact file names / + * directory names), consumed by `registerColoredFileIcons` in the core half + * when the user turns the theme on. + * + * Data provenance: the tables below come from PR #429 (@fenter, + * `feat/file-type-icons`, the 563-entry table); they are reused here as the + * plugin's built-in OPTIONAL icon theme. The case-variant folder keys + * (`Android`/`android`, `IOS`/`ios`) were folded into one rule because the + * resolver matches names case-insensitively. + * + * Why a chunk: ~160 react-icons glyphs (Simple Icons brands + VSCodicons) and + * ~700 lines of table would otherwise sit in the startup bundle for a theme + * most users never enable. The core half only fetches this on demand. + * + * Skin contract: this module is the ONE documented exception to the + * token-only rule (guide §12) — brand colors are identity, not chrome, and + * the theme is opt-in, so they cannot ride `--dsw-alias-*`. Nothing here is + * imported by the core bundle; keep it that way. + */ +import type { ReactNode } from 'react' +import type { BetterSidebarService, FileIconDescriptor } from '../service.ts' +import { + SiAngular, SiApple, SiAstro, SiBabel, SiBiome, SiBun, SiC, SiClojure, + SiCmake, SiCoffeescript, SiComposer, SiConventionalcommits, SiCplusplus, + SiCrystal, SiCss, SiCypress, SiDart, SiDjango, SiDocker, SiDocusaurus, + SiDotenv, SiDotnet, SiElixir, SiElm, SiErlang, SiEsbuild, SiEslint, + SiGatsby, SiGit, SiGithub, SiGitlab, SiGnubash, SiGo, SiGodotengine, + SiGooglecloud, SiGradle, SiGraphql, SiGrunt, SiGulp, SiHaskell, SiHelm, + SiHtml5, SiJavascript, SiJenkins, SiJest, SiJulia, SiKotlin, SiKubernetes, + SiLerna, SiLess, SiLua, SiMarkdown, SiMysql, SiNetlify, SiNextdotjs, SiNim, + SiNixos, SiNodedotjs, SiNpm, SiNuxt, SiNx, SiOcaml, SiPerl, SiPhp, SiPnpm, + SiPostcss, SiPrettier, SiPrisma, SiPython, SiRacket, SiReact, SiRemix, + SiRollupdotjs, SiRuby, SiRust, SiSass, SiScala, SiSolidity, SiSqlite, + SiStorybook, SiStylelint, SiSvelte, SiSwc, SiSwift, SiTailwindcss, SiTauri, + SiTerraform, SiToml, SiTurbo, SiTypescript, SiVercel, SiVim, SiVite, + SiVitepress, SiVitest, SiVuedotjs, SiWebpack, SiYaml, SiYarn, SiZig +} from 'react-icons/si' +import { + VscBook, VscFile, VscFileBinary, VscFileCode, VscFileMedia, VscFilePdf, + VscFileText, VscFileZip, VscFolder, VscFolderOpened, VscJson, VscKey, + VscMap, VscMusic, VscSettings, VscTerminalCmd, VscTerminalPowershell +} from 'react-icons/vsc' +import type { IconType } from 'react-icons' + +interface IconEntry { + Icon: IconType + color: string +} + +/** Render one colored icon at the tree's 14px size. */ +export function colored(entry: IconEntry, size = 14): ReactNode { + const { Icon, color } = entry + return +} + +// ── Brand file icons (extension → { icon, brand color }) ──────────────── + +export const FILE_ICON_BY_EXT: Partial> = { + // TypeScript / JavaScript + ts: { Icon: SiTypescript, color: '#3178C6' }, + tsx: { Icon: SiReact, color: '#61DAFB' }, + js: { Icon: SiJavascript, color: '#F7DF1E' }, + jsx: { Icon: SiReact, color: '#61DAFB' }, + mjs: { Icon: SiJavascript, color: '#F7DF1E' }, + cjs: { Icon: SiJavascript, color: '#F7DF1E' }, + mtjs: { Icon: SiJavascript, color: '#F7DF1E' }, + cts: { Icon: SiTypescript, color: '#3178C6' }, + // Web frameworks + vue: { Icon: SiVuedotjs, color: '#42B883' }, + svelte: { Icon: SiSvelte, color: '#FF3E00' }, + astro: { Icon: SiAstro, color: '#FF5E00' }, + html: { Icon: SiHtml5, color: '#E34F26' }, + htm: { Icon: SiHtml5, color: '#E34F26' }, + xhtml: { Icon: SiHtml5, color: '#E34F26' }, + css: { Icon: SiCss, color: '#1572B6' }, + scss: { Icon: SiSass, color: '#CC6699' }, + sass: { Icon: SiSass, color: '#CC6699' }, + less: { Icon: SiLess, color: '#1D365D' }, + styl: { Icon: VscFileCode, color: '#BF1B1B' }, + pug: { Icon: VscFileCode, color: '#A86454' }, + jade: { Icon: VscFileCode, color: '#A86454' }, + hbs: { Icon: VscFileCode, color: '#F0772B' }, + handlebars: { Icon: VscFileCode, color: '#F0772B' }, + ejs: { Icon: VscFileCode, color: '#E44D26' }, + eta: { Icon: VscFileCode, color: '#E44D26' }, + njk: { Icon: VscFileCode, color: '#2C7873' }, + nunjucks: { Icon: VscFileCode, color: '#2C7873' }, + twig: { Icon: VscFileCode, color: '#41C47B' }, + liquid: { Icon: VscFileCode, color: '#41C47B' }, + // Systems + py: { Icon: SiPython, color: '#3776AB' }, + pyi: { Icon: SiPython, color: '#3776AB' }, + pyw: { Icon: SiPython, color: '#3776AB' }, + rb: { Icon: SiRuby, color: '#CC342D' }, + go: { Icon: SiGo, color: '#00ADD8' }, + rs: { Icon: SiRust, color: '#DEA584' }, + php: { Icon: SiPhp, color: '#777BB4' }, + swift: { Icon: SiSwift, color: '#F05138' }, + kt: { Icon: SiKotlin, color: '#7F52FF' }, + kts: { Icon: SiKotlin, color: '#7F52FF' }, + dart: { Icon: SiDart, color: '#0175C2' }, + lua: { Icon: SiLua, color: '#2C2D72' }, + scala: { Icon: SiScala, color: '#DC322F' }, + clj: { Icon: SiClojure, color: '#5F5F5F' }, + cljs: { Icon: SiClojure, color: '#5F5F5F' }, + cljc: { Icon: SiClojure, color: '#5F5F5F' }, + edn: { Icon: SiClojure, color: '#5F5F5F' }, + pl: { Icon: SiPerl, color: '#393939' }, + pm: { Icon: SiPerl, color: '#393939' }, + hs: { Icon: SiHaskell, color: '#5E5086' }, + erl: { Icon: SiErlang, color: '#A90533' }, + jl: { Icon: SiJulia, color: '#9558B2' }, + ex: { Icon: SiElixir, color: '#4B275F' }, + exs: { Icon: SiElixir, color: '#4B275F' }, + elm: { Icon: SiElm, color: '#60B5CC' }, + nim: { Icon: SiNim, color: '#FFE95B' }, + zig: { Icon: SiZig, color: '#F7A41D' }, + ml: { Icon: SiOcaml, color: '#EC672F' }, + mli: { Icon: SiOcaml, color: '#EC672F' }, + sol: { Icon: SiSolidity, color: '#363636' }, + rkt: { Icon: SiRacket, color: '#9C1C1C' }, + vim: { Icon: SiVim, color: '#019733' }, + viml: { Icon: SiVim, color: '#019733' }, + ahk: { Icon: VscFileCode, color: '#334455' }, + feature: { Icon: VscFileCode, color: '#00C356' }, + groovy: { Icon: VscFileCode, color: '#4298B8' }, + gradle: { Icon: SiGradle, color: '#02303A' }, + proto: { Icon: VscFileCode, color: '#4285F4' }, + thrift: { Icon: VscFileCode, color: '#6B7280' }, + nix: { Icon: SiNixos, color: '#7EB1D6' }, + // C / C++ + c: { Icon: SiC, color: '#A8B9CC' }, + h: { Icon: SiC, color: '#A8B9CC' }, + cpp: { Icon: SiCplusplus, color: '#00599C' }, + cc: { Icon: SiCplusplus, color: '#00599C' }, + cxx: { Icon: SiCplusplus, color: '#00599C' }, + hpp: { Icon: SiCplusplus, color: '#00599C' }, + hxx: { Icon: SiCplusplus, color: '#00599C' }, + ino: { Icon: VscFileCode, color: '#00878F' }, + cs: { Icon: SiDotnet, color: '#512BD4' }, + fs: { Icon: SiDotnet, color: '#512BD4' }, + fsx: { Icon: SiDotnet, color: '#512BD4' }, + vb: { Icon: SiDotnet, color: '#512BD4' }, + // Shell + sh: { Icon: SiGnubash, color: '#4EAA25' }, + bash: { Icon: SiGnubash, color: '#4EAA25' }, + zsh: { Icon: SiGnubash, color: '#4EAA25' }, + fish: { Icon: SiGnubash, color: '#4EAA25' }, + ps1: { Icon: VscTerminalPowershell, color: '#012456' }, + psd1: { Icon: VscTerminalPowershell, color: '#012456' }, + psm1: { Icon: VscTerminalPowershell, color: '#012456' }, + bat: { Icon: VscTerminalCmd, color: '#C8C8C8' }, + cmd: { Icon: VscTerminalCmd, color: '#C8C8C8' }, + awk: { Icon: VscFileCode, color: '#4EAA25' }, + // Data / config + json: { Icon: VscJson, color: '#F7DF1E' }, + jsonc: { Icon: VscJson, color: '#F7DF1E' }, + json5: { Icon: VscJson, color: '#F7DF1E' }, + yaml: { Icon: SiYaml, color: '#CB171E' }, + yml: { Icon: SiYaml, color: '#CB171E' }, + toml: { Icon: SiToml, color: '#9C4121' }, + ini: { Icon: VscSettings, color: '#8B5CF6' }, + conf: { Icon: VscSettings, color: '#8B5CF6' }, + cfg: { Icon: VscSettings, color: '#8B5CF6' }, + env: { Icon: SiDotenv, color: '#ECD53F' }, + xml: { Icon: VscFileCode, color: '#E3792B' }, + plist: { Icon: VscSettings, color: '#6B7280' }, + properties: { Icon: VscSettings, color: '#8B5CF6' }, + csv: { Icon: VscFileText, color: '#2CB553' }, + tsv: { Icon: VscFileText, color: '#2CB553' }, + // Docs + md: { Icon: SiMarkdown, color: '#0844B8' }, + markdown: { Icon: SiMarkdown, color: '#0844B8' }, + mdx: { Icon: SiMarkdown, color: '#0844B8' }, + txt: { Icon: VscFileText, color: '#6B7280' }, + log: { Icon: VscFileText, color: '#6B7280' }, + pdf: { Icon: VscFilePdf, color: '#E53935' }, + rst: { Icon: VscFileText, color: '#7D3CBE' }, + adoc: { Icon: VscFileText, color: '#E40046' }, + asciidoc: { Icon: VscFileText, color: '#E40046' }, + tex: { Icon: VscFileText, color: '#3D6117' }, + latex: { Icon: VscFileText, color: '#3D6117' }, + org: { Icon: VscFileText, color: '#77D0B0' }, + rtf: { Icon: VscFileText, color: '#6B7280' }, + // Office + doc: { Icon: VscFileText, color: '#2B579A' }, + docx: { Icon: VscFileText, color: '#2B579A' }, + xls: { Icon: VscFileText, color: '#217346' }, + xlsx: { Icon: VscFileText, color: '#217346' }, + ppt: { Icon: VscFileText, color: '#D24726' }, + pptx: { Icon: VscFileText, color: '#D24726' }, + odt: { Icon: VscFileText, color: '#18A303' }, + ods: { Icon: VscFileText, color: '#18A303' }, + odp: { Icon: VscFileText, color: '#18A303' }, + // Images + png: { Icon: VscFileMedia, color: '#A855F7' }, + jpg: { Icon: VscFileMedia, color: '#A855F7' }, + jpeg: { Icon: VscFileMedia, color: '#A855F7' }, + gif: { Icon: VscFileMedia, color: '#A855F7' }, + webp: { Icon: VscFileMedia, color: '#A855F7' }, + svg: { Icon: VscFileMedia, color: '#FFB13B' }, + ico: { Icon: VscFileMedia, color: '#A855F7' }, + bmp: { Icon: VscFileMedia, color: '#A855F7' }, + avif: { Icon: VscFileMedia, color: '#A855F7' }, + tiff: { Icon: VscFileMedia, color: '#A855F7' }, + tif: { Icon: VscFileMedia, color: '#A855F7' }, + heic: { Icon: VscFileMedia, color: '#A855F7' }, + heif: { Icon: VscFileMedia, color: '#A855F7' }, + psd: { Icon: VscFileMedia, color: '#31A8FF' }, + ai: { Icon: VscFileMedia, color: '#FF9A00' }, + eps: { Icon: VscFileMedia, color: '#FFB13B' }, + raw: { Icon: VscFileMedia, color: '#6B7280' }, + xcf: { Icon: VscFileMedia, color: '#535555' }, + // Video + mp4: { Icon: VscFileMedia, color: '#FF6B6B' }, + webm: { Icon: VscFileMedia, color: '#FF6B6B' }, + mov: { Icon: VscFileMedia, color: '#FF6B6B' }, + mkv: { Icon: VscFileMedia, color: '#FF6B6B' }, + avi: { Icon: VscFileMedia, color: '#FF6B6B' }, + wmv: { Icon: VscFileMedia, color: '#FF6B6B' }, + flv: { Icon: VscFileMedia, color: '#FF6B6B' }, + m4v: { Icon: VscFileMedia, color: '#FF6B6B' }, + m3u8: { Icon: VscFileMedia, color: '#FF6B6B' }, + // Audio + mp3: { Icon: VscMusic, color: '#EC4899' }, + wav: { Icon: VscMusic, color: '#EC4899' }, + flac: { Icon: VscMusic, color: '#EC4899' }, + ogg: { Icon: VscMusic, color: '#EC4899' }, + m4a: { Icon: VscMusic, color: '#EC4899' }, + aac: { Icon: VscMusic, color: '#EC4899' }, + opus: { Icon: VscMusic, color: '#EC4899' }, + midi: { Icon: VscMusic, color: '#EC4899' }, + mid: { Icon: VscMusic, color: '#EC4899' }, + wma: { Icon: VscMusic, color: '#EC4899' }, + aiff: { Icon: VscMusic, color: '#EC4899' }, + mka: { Icon: VscMusic, color: '#EC4899' }, + // Archives + zip: { Icon: VscFileZip, color: '#F59E0B' }, + tar: { Icon: VscFileZip, color: '#F59E0B' }, + gz: { Icon: VscFileZip, color: '#F59E0B' }, + tgz: { Icon: VscFileZip, color: '#F59E0B' }, + rar: { Icon: VscFileZip, color: '#F59E0B' }, + '7z': { Icon: VscFileZip, color: '#F59E0B' }, + bz2: { Icon: VscFileZip, color: '#F59E0B' }, + xz: { Icon: VscFileZip, color: '#F59E0B' }, + iso: { Icon: VscFileZip, color: '#F59E0B' }, + dmg: { Icon: VscFileZip, color: '#F59E0B' }, + jar: { Icon: VscFileZip, color: '#F59E0B' }, + war: { Icon: VscFileZip, color: '#F59E0B' }, + ear: { Icon: VscFileZip, color: '#F59E0B' }, + apk: { Icon: VscFileZip, color: '#3DDC84' }, + deb: { Icon: VscFileZip, color: '#A81D33' }, + rpm: { Icon: VscFileZip, color: '#000000' }, + msi: { Icon: VscFileZip, color: '#6B7280' }, + lz: { Icon: VscFileZip, color: '#F59E0B' }, + zst: { Icon: VscFileZip, color: '#F59E0B' }, + cab: { Icon: VscFileZip, color: '#F59E0B' }, + // Database + db: { Icon: SiSqlite, color: '#003B57' }, + sqlite: { Icon: SiSqlite, color: '#003B57' }, + sqlite3: { Icon: SiSqlite, color: '#003B57' }, + sql: { Icon: SiMysql, color: '#4479A1' }, + // Source maps + map: { Icon: VscMap, color: '#6B7280' }, + // Keys / certs + pem: { Icon: VscKey, color: '#10B981' }, + key: { Icon: VscKey, color: '#10B981' }, + crt: { Icon: VscKey, color: '#10B981' }, + pub: { Icon: VscKey, color: '#10B981' }, + cer: { Icon: VscKey, color: '#10B981' }, + der: { Icon: VscKey, color: '#10B981' }, + p12: { Icon: VscKey, color: '#10B981' }, + pfx: { Icon: VscKey, color: '#10B981' }, + jks: { Icon: VscKey, color: '#10B981' }, + keystore: { Icon: VscKey, color: '#10B981' }, + csr: { Icon: VscKey, color: '#10B981' }, + ovpn: { Icon: VscKey, color: '#10B981' }, + // Fonts + ttf: { Icon: VscFileBinary, color: '#A855F7' }, + otf: { Icon: VscFileBinary, color: '#A855F7' }, + woff: { Icon: VscFileBinary, color: '#A855F7' }, + woff2: { Icon: VscFileBinary, color: '#A855F7' }, + eot: { Icon: VscFileBinary, color: '#A855F7' }, + // GraphQL + graphql: { Icon: SiGraphql, color: '#E10098' }, + gql: { Icon: SiGraphql, color: '#E10098' }, + // Terraform / IaC + tf: { Icon: SiTerraform, color: '#7B42BC' }, + tfvars: { Icon: SiTerraform, color: '#7B42BC' }, + tfstate: { Icon: SiTerraform, color: '#7B42BC' }, + hcl: { Icon: SiTerraform, color: '#7B42BC' }, + // Godot + gd: { Icon: SiGodotengine, color: '#478CBF' }, + // Crystal + cr: { Icon: SiCrystal, color: '#000000' }, + // CoffeeScript + coffee: { Icon: SiCoffeescript, color: '#2F2614' }, +} + +// ── Special filenames (exact match, case-sensitive) ────────────────────── + +export const FILE_ICON_BY_NAME: Partial> = { + // Package managers + 'package.json': { Icon: SiNpm, color: '#CB3837' }, + 'package-lock.json': { Icon: SiNpm, color: '#CB3837' }, + 'pnpm-lock.yaml': { Icon: SiPnpm, color: '#F69220' }, + 'yarn.lock': { Icon: SiYarn, color: '#2C8EBB' }, + 'Cargo.lock': { Icon: SiRust, color: '#DEA584' }, + 'Cargo.toml': { Icon: SiRust, color: '#DEA584' }, + 'go.sum': { Icon: SiGo, color: '#00ADD8' }, + 'go.mod': { Icon: SiGo, color: '#00ADD8' }, + 'go.work': { Icon: SiGo, color: '#00ADD8' }, + 'composer.json': { Icon: SiComposer, color: '#885630' }, + 'composer.lock': { Icon: SiComposer, color: '#885630' }, + 'poetry.lock': { Icon: SiPython, color: '#3776AB' }, + 'uv.lock': { Icon: SiPython, color: '#3776AB' }, + 'bun.lockb': { Icon: SiBun, color: '#FBF0DF' }, + 'bun.lock': { Icon: SiBun, color: '#FBF0DF' }, + 'mix.lock': { Icon: SiElixir, color: '#4B275F' }, + 'Podfile': { Icon: SiApple, color: '#A6A6A6' }, + 'Podfile.lock': { Icon: SiApple, color: '#A6A6A6' }, + 'Package.resolved': { Icon: SiSwift, color: '#F05138' }, + 'packages.lock.json': { Icon: SiNodedotjs, color: '#339933' }, + 'flake.lock': { Icon: SiNixos, color: '#7EB1D6' }, + 'flake.nix': { Icon: SiNixos, color: '#7EB1D6' }, + 'default.nix': { Icon: SiNixos, color: '#7EB1D6' }, + 'shell.nix': { Icon: SiNixos, color: '#7EB1D6' }, + // Git + '.gitignore': { Icon: SiGit, color: '#F05032' }, + '.gitattributes': { Icon: SiGit, color: '#F05032' }, + '.gitmodules': { Icon: SiGit, color: '#F05032' }, + '.gitconfig': { Icon: SiGit, color: '#F05032' }, + '.gitmessage': { Icon: SiGit, color: '#F05032' }, + '.git-blame-ignore-revs': { Icon: SiGit, color: '#F05032' }, + '.gitkeep': { Icon: VscFile, color: '#6B7280' }, + '.keep': { Icon: VscFile, color: '#6B7280' }, + // Docs + 'LICENSE': { Icon: VscBook, color: '#6B7280' }, + 'LICENSE.md': { Icon: VscBook, color: '#6B7280' }, + 'LICENSE.txt': { Icon: VscBook, color: '#6B7280' }, + 'README.md': { Icon: SiMarkdown, color: '#0844B8' }, + 'CHANGELOG.md': { Icon: SiMarkdown, color: '#0844B8' }, + 'CONTRIBUTING.md': { Icon: VscBook, color: '#6B7280' }, + 'CODE_OF_CONDUCT.md': { Icon: VscBook, color: '#6B7280' }, + 'SECURITY.md': { Icon: VscBook, color: '#6B7280' }, + 'AUTHORS': { Icon: VscBook, color: '#6B7280' }, + 'MAINTAINERS': { Icon: VscBook, color: '#6B7280' }, + 'CODEOWNERS': { Icon: SiGithub, color: '#181717' }, + // Docker + 'Dockerfile': { Icon: SiDocker, color: '#2496ED' }, + 'Dockerfile.dev': { Icon: SiDocker, color: '#2496ED' }, + 'Dockerfile.prod': { Icon: SiDocker, color: '#2496ED' }, + 'Dockerfile.test': { Icon: SiDocker, color: '#2496ED' }, + 'docker-compose.yml': { Icon: SiDocker, color: '#2496ED' }, + 'docker-compose.yaml': { Icon: SiDocker, color: '#2496ED' }, + 'docker-compose.override.yml': { Icon: SiDocker, color: '#2496ED' }, + 'Containerfile': { Icon: SiDocker, color: '#2496ED' }, + '.dockerignore': { Icon: SiDocker, color: '#2496ED' }, + '.containerignore': { Icon: SiDocker, color: '#2496ED' }, + // Build + 'Makefile': { Icon: SiCmake, color: '#064F8C' }, + 'CMakeLists.txt': { Icon: SiCmake, color: '#064F8C' }, + '.editorconfig': { Icon: VscSettings, color: '#E0EFEF' }, + // Env + '.env': { Icon: SiDotenv, color: '#ECD53F' }, + '.env.local': { Icon: SiDotenv, color: '#ECD53F' }, + '.env.development': { Icon: SiDotenv, color: '#ECD53F' }, + '.env.production': { Icon: SiDotenv, color: '#ECD53F' }, + '.env.example': { Icon: SiDotenv, color: '#ECD53F' }, + '.env.test': { Icon: SiDotenv, color: '#ECD53F' }, + '.env.staging': { Icon: SiDotenv, color: '#ECD53F' }, + '.env.docker': { Icon: SiDotenv, color: '#ECD53F' }, + // TypeScript / JS config + 'tsconfig.json': { Icon: SiTypescript, color: '#3178C6' }, + 'tsconfig.base.json': { Icon: SiTypescript, color: '#3178C6' }, + 'jsconfig.json': { Icon: SiJavascript, color: '#F7DF1E' }, + // Bundlers / build tools + 'vite.config.ts': { Icon: SiVite, color: '#646CFF' }, + 'vite.config.js': { Icon: SiVite, color: '#646CFF' }, + 'vite.config.mts': { Icon: SiVite, color: '#646CFF' }, + 'vite.config.mjs': { Icon: SiVite, color: '#646CFF' }, + 'webpack.config.js': { Icon: SiWebpack, color: '#1C78C0' }, + 'webpack.config.ts': { Icon: SiWebpack, color: '#1C78C0' }, + 'rollup.config.js': { Icon: SiRollupdotjs, color: '#C3413D' }, + 'rollup.config.mjs': { Icon: SiRollupdotjs, color: '#C3413D' }, + 'esbuild.config.js': { Icon: SiEsbuild, color: '#FFCF00' }, + 'gulpfile.js': { Icon: SiGulp, color: '#CF4647' }, + 'gulpfile.ts': { Icon: SiGulp, color: '#CF4647' }, + 'gulpfile.mjs': { Icon: SiGulp, color: '#CF4647' }, + 'gruntfile.js': { Icon: SiGrunt, color: '#FBA937' }, + 'tsup.config.ts': { Icon: VscSettings, color: '#646CFF' }, + 'tsdown.config.ts': { Icon: VscSettings, color: '#646CFF' }, + 'unbuild.config.ts': { Icon: VscSettings, color: '#646CFF' }, + 'farm.config.ts': { Icon: VscSettings, color: '#646CFF' }, + 'rspack.config.js': { Icon: VscSettings, color: '#646CFF' }, + 'swc.config.js': { Icon: SiSwc, color: '#E0BD6E' }, + '.swcrc': { Icon: SiSwc, color: '#E0BD6E' }, + 'turbo.json': { Icon: SiTurbo, color: '#6DD3C6' }, + 'turbo.config.json': { Icon: SiTurbo, color: '#6DD3C6' }, + 'nx.json': { Icon: SiNx, color: '#14305F' }, + 'lerna.json': { Icon: SiLerna, color: '#9333EA' }, + 'biome.json': { Icon: SiBiome, color: '#60A5FA' }, + // Frameworks + 'next.config.js': { Icon: SiNextdotjs, color: '#000000' }, + 'next.config.mjs': { Icon: SiNextdotjs, color: '#000000' }, + 'next.config.ts': { Icon: SiNextdotjs, color: '#000000' }, + 'nuxt.config.ts': { Icon: SiNuxt, color: '#00DC82' }, + 'nuxt.config.js': { Icon: SiNuxt, color: '#00DC82' }, + 'angular.json': { Icon: SiAngular, color: '#DD0031' }, + 'svelte.config.js': { Icon: SiSvelte, color: '#FF3E00' }, + 'astro.config.mjs': { Icon: SiAstro, color: '#FF5E00' }, + 'astro.config.ts': { Icon: SiAstro, color: '#FF5E00' }, + 'remix.config.js': { Icon: SiRemix, color: '#3992FF' }, + 'gatsby-config.js': { Icon: SiGatsby, color: '#663399' }, + 'gatsby-config.ts': { Icon: SiGatsby, color: '#663399' }, + 'tailwind.config.js': { Icon: SiTailwindcss, color: '#06B6D4' }, + 'tailwind.config.ts': { Icon: SiTailwindcss, color: '#06B6D4' }, + 'tauri.conf.json': { Icon: SiTauri, color: '#FFC131' }, + 'vercel.json': { Icon: SiVercel, color: '#000000' }, + 'netlify.toml': { Icon: SiNetlify, color: '#00C7B7' }, + 'docusaurus.config.js': { Icon: SiDocusaurus, color: '#3ECCF4' }, + 'docusaurus.config.ts': { Icon: SiDocusaurus, color: '#3ECCF4' }, + 'vitepress.config.ts': { Icon: SiVitepress, color: '#5672CD' }, + 'vitepress.config.js': { Icon: SiVitepress, color: '#5672CD' }, + // Testing + 'playwright.config.ts': { Icon: VscSettings, color: '#2EAD33' }, + 'playwright.config.js': { Icon: VscSettings, color: '#2EAD33' }, + 'vitest.config.ts': { Icon: SiVitest, color: '#FCC00B' }, + 'vitest.config.js': { Icon: SiVitest, color: '#FCC00B' }, + 'jest.config.js': { Icon: SiJest, color: '#C21325' }, + 'jest.config.ts': { Icon: SiJest, color: '#C21325' }, + 'cypress.config.ts': { Icon: SiCypress, color: '#17202C' }, + 'cypress.config.js': { Icon: SiCypress, color: '#17202C' }, + // Linting / formatting + '.eslintrc.js': { Icon: SiEslint, color: '#4B32C3' }, + '.eslintrc.json': { Icon: SiEslint, color: '#4B32C3' }, + '.eslintrc.cjs': { Icon: SiEslint, color: '#4B32C3' }, + '.eslintrc.mjs': { Icon: SiEslint, color: '#4B32C3' }, + '.eslintrc.yml': { Icon: SiEslint, color: '#4B32C3' }, + 'eslint.config.js': { Icon: SiEslint, color: '#4B32C3' }, + 'eslint.config.mjs': { Icon: SiEslint, color: '#4B32C3' }, + 'eslint.config.ts': { Icon: SiEslint, color: '#4B32C3' }, + '.eslintignore': { Icon: SiEslint, color: '#4B32C3' }, + '.prettierrc': { Icon: SiPrettier, color: '#F7B93E' }, + '.prettierrc.json': { Icon: SiPrettier, color: '#F7B93E' }, + '.prettierrc.js': { Icon: SiPrettier, color: '#F7B93E' }, + '.prettierrc.cjs': { Icon: SiPrettier, color: '#F7B93E' }, + '.prettierrc.mjs': { Icon: SiPrettier, color: '#F7B93E' }, + '.prettierrc.toml': { Icon: SiPrettier, color: '#F7B93E' }, + '.prettierrc.json5': { Icon: SiPrettier, color: '#F7B93E' }, + 'prettier.config.js': { Icon: SiPrettier, color: '#F7B93E' }, + 'prettier.config.mjs': { Icon: SiPrettier, color: '#F7B93E' }, + '.prettierignore': { Icon: SiPrettier, color: '#F7B93E' }, + '.stylelintrc': { Icon: SiStylelint, color: '#263A6F' }, + '.stylelintrc.json': { Icon: SiStylelint, color: '#263A6F' }, + '.stylelintrc.js': { Icon: SiStylelint, color: '#263A6F' }, + '.stylelintignore': { Icon: SiStylelint, color: '#263A6F' }, + 'babel.config.js': { Icon: SiBabel, color: '#F9DC3D' }, + 'babel.config.json': { Icon: SiBabel, color: '#F9DC3D' }, + '.babelrc': { Icon: SiBabel, color: '#F9DC3D' }, + '.babelrc.json': { Icon: SiBabel, color: '#F9DC3D' }, + '.babelrc.cjs': { Icon: SiBabel, color: '#F9DC3D' }, + 'postcss.config.js': { Icon: SiPostcss, color: '#DD3A8C' }, + 'postcss.config.ts': { Icon: SiPostcss, color: '#DD3A8C' }, + '.postcssrc': { Icon: SiPostcss, color: '#DD3A8C' }, + '.postcssrc.json': { Icon: SiPostcss, color: '#DD3A8C' }, + // Other config + '.npmrc': { Icon: SiNpm, color: '#CB3837' }, + '.yarnrc': { Icon: SiYarn, color: '#2C8EBB' }, + '.pnpmrc': { Icon: SiPnpm, color: '#F69220' }, + '.npmignore': { Icon: SiNpm, color: '#CB3837' }, + '.nvmrc': { Icon: SiNodedotjs, color: '#339933' }, + '.node-version': { Icon: SiNodedotjs, color: '#339933' }, + '.ruby-version': { Icon: SiRuby, color: '#CC342D' }, + '.python-version': { Icon: SiPython, color: '#3776AB' }, + '.lintstagedrc': { Icon: VscSettings, color: '#F7B500' }, + '.lintstagedrc.json': { Icon: VscSettings, color: '#F7B500' }, + '.commitlintrc': { Icon: SiConventionalcommits, color: '#FE5197' }, + '.commitlintrc.json': { Icon: SiConventionalcommits, color: '#FE5197' }, + '.releaserc': { Icon: VscSettings, color: '#F7B500' }, + '.releaserc.json': { Icon: VscSettings, color: '#F7B500' }, + '.czrc': { Icon: VscSettings, color: '#F7B500' }, + // Ruby / Python + 'Gemfile': { Icon: SiRuby, color: '#CC342D' }, + 'Gemfile.lock': { Icon: SiRuby, color: '#CC342D' }, + 'Rakefile': { Icon: SiRuby, color: '#CC342D' }, + 'requirements.txt': { Icon: SiPython, color: '#3776AB' }, + 'setup.py': { Icon: SiPython, color: '#3776AB' }, + 'pyproject.toml': { Icon: SiPython, color: '#3776AB' }, + 'manage.py': { Icon: SiDjango, color: '#092E20' }, + // CI/CD + 'Jenkinsfile': { Icon: SiJenkins, color: '#D24939' }, + '.gitlab-ci.yml': { Icon: SiGitlab, color: '#FC6D26' }, + '.travis.yml': { Icon: VscSettings, color: '#3EAAAF' }, + 'azure-pipelines.yml': { Icon: VscSettings, color: '#0078D4' }, + 'bitbucket-pipelines.yml': { Icon: VscSettings, color: '#0052CC' }, + '.drone.yml': { Icon: VscSettings, color: '#212121' }, + 'cloudbuild.yaml': { Icon: SiGooglecloud, color: '#4285F4' }, + // K8s / Helm + 'Chart.yaml': { Icon: SiHelm, color: '#0F1689' }, + 'values.yaml': { Icon: SiHelm, color: '#0F1689' }, + 'kustomization.yaml': { Icon: SiKubernetes, color: '#326CE5' }, + 'kustomization.yml': { Icon: SiKubernetes, color: '#326CE5' }, + 'skaffold.yaml': { Icon: SiKubernetes, color: '#326CE5' }, + '.helmignore': { Icon: SiHelm, color: '#0F1689' }, + // Serverless + 'serverless.yml': { Icon: VscSettings, color: '#6B7280' }, + 'sam.yaml': { Icon: VscSettings, color: '#6B7280' }, + 'appspec.yml': { Icon: VscSettings, color: '#6B7280' }, + // Prisma + 'schema.prisma': { Icon: SiPrisma, color: '#5D8AA8' }, + // Misc + 'Procfile': { Icon: SiNodedotjs, color: '#339933' }, + '.storybook/main.js': { Icon: SiStorybook, color: '#FF4785' }, + '.storybook/main.ts': { Icon: SiStorybook, color: '#FF4785' }, + '.storybook/preview.js': { Icon: SiStorybook, color: '#FF4785' }, + '.storybook/preview.ts': { Icon: SiStorybook, color: '#FF4785' }, +} + +// ── Special folder colors ─────────────────────────────────────────────── + +/** Folder name → tint color (applied to VscFolder / VscFolderOpened). */ +export const FOLDER_COLOR: Record = { + // Package / dependency + node_modules: '#CB3837', + packages: '#CB3837', + // Source + src: '#3178C6', + source: '#3178C6', + app: '#3178C6', + apps: '#3178C6', + core: '#A855F7', + common: '#6B7280', + shared: '#6B7280', + internal: '#6B7280', + modules: '#3178C6', + plugins: '#42B883', + // Git + '.git': '#F05032', + '.github': '#F05032', + '.husky': '#F05032', + // Output + public: '#42B883', + static: '#42B883', + dist: '#E34F26', + build: '#E34F26', + out: '#E34F26', + '.output': '#E34F26', + '.next': '#000000', + '.nuxt': '#00DC82', + '.svelte-kit': '#FF3E00', + '.astro': '#FF5E00', + '.turbo': '#6DD3C6', + '.parcel-cache': '#6B7280', + '.vercel': '#000000', + '.netlify': '#00C7B7', + '.serverless': '#6B7280', + '.terraform': '#7B42BC', + '.terragrunt-cache': '#7B42BC', + '.docusaurus': '#3ECCF4', + '.cache': '#6B7280', + // Testing + test: '#F7DF1E', + tests: '#F7DF1E', + __tests__: '#F7DF1E', + __mocks__: '#F7DF1E', + __snapshots__: '#F7DF1E', + __pacts__: '#F7DF1E', + spec: '#F7DF1E', + specs: '#F7DF1E', + mocks: '#F7DF1E', + fixtures: '#F7DF1E', + e2e: '#F7DF1E', + cypress: '#17202C', + '.nyc_output': '#F7DF1E', + coverage: '#42B883', + // IDE / editor + '.vscode': '#3178C6', + '.idea': '#3178C6', + '.devcontainer': '#3178C6', + '.codesandbox': '#6B7280', + '.stackblitz': '#6B7280', + '.gitpod': '#6B7280', + // Assets + assets: '#A855F7', + images: '#A855F7', + img: '#A855F7', + icons: '#A855F7', + fonts: '#A855F7', + media: '#A855F7', + resources: '#A855F7', + content: '#A855F7', + // Styles + styles: '#CC6699', + stylesheets: '#CC6699', + css: '#CC6699', + themes: '#CC6699', + templates: '#CC6699', + partials: '#CC6699', + layouts: '#CC6699', + // Components + components: '#42B883', + composables: '#42B883', + hooks: '#42B883', + // Library + lib: '#6B7280', + libs: '#6B7280', + vendor: '#6B7280', + // Docs + docs: '#0844B8', + documentation: '#0844B8', + examples: '#6B7280', + playground: '#42B883', + benchmarks: '#4EAA25', + // Scripts + scripts: '#4EAA25', + bin: '#4EAA25', + tools: '#4EAA25', + // Config + config: '#F7B500', + configs: '#F7B500', + '.config': '#F7B500', + // i18n + locales: '#F7B500', + i18n: '#F7B500', + translations: '#F7B500', + lang: '#F7B500', + language: '#F7B500', + // Routing + pages: '#3178C6', + views: '#3178C6', + routes: '#3178C6', + router: '#3178C6', + // State + store: '#A855F7', + stores: '#A855F7', + state: '#A855F7', + // Utils + utils: '#6B7280', + helpers: '#6B7280', + types: '#3178C6', + typings: '#3178C6', + // Backend + api: '#42B883', + server: '#42B883', + services: '#42B883', + client: '#3178C6', + clients: '#3178C6', + middleware: '#CC6699', + controllers: '#42B883', + models: '#A855F7', + entities: '#A855F7', + repositories: '#A855F7', + migrations: '#4479A1', + seeds: '#4479A1', + factories: '#4479A1', + // Jobs / workers + tasks: '#4EAA25', + jobs: '#4EAA25', + workers: '#4EAA25', + queues: '#4EAA25', + // Events + events: '#F7DF1E', + listeners: '#F7DF1E', + observers: '#F7DF1E', + notifications: '#F7DF1E', + mail: '#F7DF1E', + emails: '#F7DF1E', + channels: '#F7DF1E', + commands: '#4EAA25', + console: '#4EAA25', + cron: '#4EAA25', + // Temp / data + pub: '#42B883', + tmp: '#6B7280', + temp: '#6B7280', + cache: '#6B7280', + logs: '#6B7280', + data: '#A855F7', + backup: '#6B7280', + www: '#42B883', + webroot: '#42B883', + // CI/CD + '.gitlab': '#FC6D26', + '.circleci': '#6B7280', + '.travis': '#6B7280', + '.drone': '#6B7280', + '.azure': '#0078D4', + // Mobile + android: '#3DDC84', + ios: '#000000', + Platforms: '#3178C6', +} + +// ── Registration ──────────────────────────────────────────────────────── + +/** + * Register the dataset through the public `registerFileIcon` API: one + * descriptor per distinct glyph+color (the resolver walks descriptors per + * row, so 563 rules collapse into ~250 registrations), plus one per folder + * tint. Returns a disposer that removes every registration. + * + * Folder tints ride `folderNames` only — they claim the directories they + * name, never every folder, so unnamed directories keep the built-in glyphs. + */ +export function registerColoredFileIcons(service: BetterSidebarService): () => void { + const disposers: (() => void)[] = [] + const register = (descriptor: FileIconDescriptor): void => { + disposers.push(service.registerFileIcon(descriptor)) + } + let seq = 0 + + // Extensions: group the table by (glyph, color) → one descriptor per group. + for (const [Icon, byColor] of groupByGlyph(FILE_ICON_BY_EXT)) { + for (const [color, exts] of byColor) { + register({ + id: `colored:ext:${seq++}`, + exts, + icon: (_path, size) => colored({ Icon, color }, size), + }) + } + } + + // Exact file names (package.json, Dockerfile, …): same grouping, name rules. + for (const [Icon, byColor] of groupByGlyph(FILE_ICON_BY_NAME)) { + for (const [color, names] of byColor) { + register({ + id: `colored:name:${seq++}`, + names, + icon: (_path, size) => colored({ Icon, color }, size), + }) + } + } + + // Directory tints: VscFolder/VscFolderOpened in the rule's color, grouped + // by color (the folder table maps name → color, not name → glyph). + const foldersByColor = new Map() + for (const [name, color] of Object.entries(FOLDER_COLOR)) { + const names = foldersByColor.get(color) + if (names === undefined) foldersByColor.set(color, [name]) + else names.push(name) + } + for (const [color, folderNames] of foldersByColor) { + register({ + id: `colored:folder:${seq++}`, + folderNames, + icon: (_path, size, open) => open === true + ? + : , + }) + } + + return () => { for (const dispose of disposers) dispose() } +} + +/** Regroup one name→{Icon,color} table into Icon → color → keys. */ +function groupByGlyph(table: Partial>): Map> { + const grouped = new Map>() + for (const [key, entry] of Object.entries(table)) { + if (entry === undefined) continue + let byColor = grouped.get(entry.Icon) + if (byColor === undefined) { + byColor = new Map() + grouped.set(entry.Icon, byColor) + } + const keys = byColor.get(entry.color) + if (keys === undefined) byColor.set(entry.color, [key]) + else keys.push(key) + } + return grouped +} diff --git a/src/client/file-icon-theme.ts b/src/client/file-icon-theme.ts new file mode 100644 index 0000000..9753a5f --- /dev/null +++ b/src/client/file-icon-theme.ts @@ -0,0 +1,80 @@ +/** + * The optional colored file-icon theme (pref `fileIconTheme`). + * + * `'builtin'` (the default) is the monochrome glyph map the tree already + * uses — nothing to do. `'colored'` fetches the lazy `file-icons` chunk + * (~160 brand glyphs + 563 rules) and registers it through the public + * `registerFileIcon` API; switching back disposes those registrations. The + * chunk is only fetched when the user picks the theme, so the startup bundle + * never carries icon data it may not render. + * + * The loader is generation-guarded: a fast toggle (or an unload) while the + * chunk is still in flight must not register the stale dataset. + */ +import { loadChunk } from './chunk-loader.ts' +import type { BetterSidebarService } from './service.ts' + +/** The pref value selecting the built-in monochrome glyphs (default). */ +export const FILE_ICON_THEME_BUILTIN = 'builtin' +/** The pref value selecting the optional colored brand-icon dataset. */ +export const FILE_ICON_THEME_COLORED = 'colored' + +/** A registrar exported by the lazy `file-icons` chunk. */ +type ColoredRegistrar = (service: BetterSidebarService) => () => void + +export interface FileIconThemeLoader { + /** Apply a pref value (unknown values behave as the built-in theme). */ + set(theme: string): void + /** Drop any registration and stop reacting to in-flight chunk loads. */ + dispose(): void +} + +/** + * Bind `service` to the `fileIconTheme` pref. Call `set` on activation and on + * every prefs change; call `dispose` when the plugin unloads. + */ +export function createFileIconThemeLoader(service: BetterSidebarService): FileIconThemeLoader { + /** Disposer of the currently registered colored dataset, if any. */ + let unregister: (() => void) | undefined + /** Bumped on every set/dispose: a load that lands on a stale generation is dropped. */ + let generation = 0 + /** The theme the pref currently asks for (repeated set() calls are no-ops). */ + let current: string | undefined + + const dropRegistrations = (): void => { + const dispose = unregister + unregister = undefined + if (dispose !== undefined) dispose() + } + + const set = (theme: string): void => { + if (theme === current) return + current = theme + const generationAtSet = ++generation + if (theme !== FILE_ICON_THEME_COLORED) { + dropRegistrations() + return + } + loadChunk('file-icons').then((chunk) => { + if (generationAtSet !== generation) return + const register = chunk.registerColoredFileIcons as ColoredRegistrar | undefined + if (typeof register !== 'function') { + console.error('[dsh-better-sidebar] file-icons chunk exports no registerColoredFileIcons') + return + } + unregister = register(service) + }).catch((error: unknown) => { + if (generationAtSet !== generation) return + console.error('[dsh-better-sidebar] file-icons chunk failed to load:', error) + }) + } + + return { + set, + dispose: () => { + generation += 1 + current = undefined + dropRegistrations() + }, + } +} diff --git a/src/client/index.tsx b/src/client/index.tsx index d5c82a3..87e2f14 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -24,6 +24,7 @@ import { registerBottomToggle } from './sidebar/bottom-toggle.tsx' import { createNativeSurface } from './native/surface.ts' import { registerLinkInterception } from './link-intercept.ts' import { registerImeGuard } from './ime-guard.ts' +import { createFileIconThemeLoader } from './file-icon-theme.ts' import { registerSettingsNavIcon } from './settings-nav-icon.ts' import { loadBootDecision } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' @@ -188,6 +189,21 @@ export function apply(ctx: Context): void { () => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }), 'dsh-better-sidebar: register built-in tabs and viewers', ) + // Optional colored file-icon theme: the `fileIconTheme` pref decides whether + // the lazy `file-icons` chunk is fetched and its dataset registered through + // `registerFileIcon`. The loader reacts to prefs writes and is disposed with + // the fiber (HMR-safe); 'builtin' (the default) keeps the startup path free + // of icon data. + ctx.effect(() => { + const loader = createFileIconThemeLoader(service) + const sync = (): void => { loader.set(sidebarStore.getPrefs().fileIconTheme) } + sync() + const unsubscribe = service.subscribeState(sync) + return () => { + unsubscribe() + loader.dispose() + } + }, 'dsh-better-sidebar: colored file-icon theme') // A failure anywhere in the client lifecycle must never take the app down // silently: log with the plugin prefix and pin a visible diagnostic strip // to the page so a blank panel is never the only symptom. This strip is diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 45dc7bb..32082b8 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -260,6 +260,12 @@ export const ar: Record = { settingsToolsDesc: 'عند التفعيل، يمكن للنموذج إنشاء وتشغيل طرفيات الشريط الجانبي عبر أدوات terminal_* الثمانية (معطّل افتراضياً)', settingsFenceTitle: 'فحص مسارات مساحة العمل', settingsFenceDesc: 'عند التفعيل، تصل وظائف الملفات في الشريط الجانبي إلى مسارات داخل مساحة عمل الجلسة فقط (الافتراضي)؛ عند الإيقاف تصبح ملفات المضيف كلها متاحة — وتحصل سكربتات الصفحة على النفاذ نفسه طوال ذلك', + settingsFileIconThemeTitle: 'أيقونات الملفات', + settingsFileIconThemeDesc: 'نمط الأيقونات لشجرة الملفات وتبويبات الملفات (تُحمَّل السمة الملوّنة عند الطلب، لا عند بدء التشغيل)', + settingsFileIconThemeBuiltin: 'مدمج (أحادي اللون)', + settingsFileIconThemeBuiltinDesc: 'أيقونات أحادية اللون تتبع رموز السمة، تغطي أنواع التعليمات البرمجية والمستندات والوسائط الشائعة', + settingsFileIconThemeColored: 'أيقونات العلامات الملوّنة', + settingsFileIconThemeColoredDesc: 'ألوان العلامات حسب النوع (مثل .tsx → React و package.json → npm)؛ تُحمَّل حزمة الأيقونات عند أول تفعيل', fenceErrorReason: 'هذا المسار خارج مساحة عمل الجلسة وقد حجبه الفحص', fenceDisableAction: 'إيقاف فحص مساحة العمل', settingsBottomTerminalTitle: 'فتح طرفية تلقائياً عند أول توسيع للوحة السفلية', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index acc8364..593df05 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -245,6 +245,12 @@ export const de: Record = { settingsToolsDesc: 'Wenn aktiviert, kann das Modell über die 8 terminal_*-Werkzeuge Terminale in der Seitenleiste erstellen und steuern (standardmäßig deaktiviert)', settingsFenceTitle: 'Workspace-Pfadkontrolle', settingsFenceDesc: 'Wenn aktiv, erreichen die Dateifunktionen der Seitenleiste nur Pfade innerhalb des Session-Workspaces (Standard); ausgeschaltet sind Dateien überall auf dem Host erreichbar — Skripte der Seite erhalten solange dieselbe Reichweite', + settingsFileIconThemeTitle: 'Dateisymbole', + settingsFileIconThemeDesc: 'Symbolstil für Dateibaum und Datei-Tabs (das Farbthema lädt bei Bedarf, nie beim Start)', + settingsFileIconThemeBuiltin: 'Integriert (einfarbig)', + settingsFileIconThemeBuiltinDesc: 'Token-gesteuerte einfarbige Symbole für gängige Code-, Dokument- und Medientypen', + settingsFileIconThemeColored: 'Farbige Markensymbole', + settingsFileIconThemeColoredDesc: 'Markenfarben je Typ (z. B. .tsx → React, package.json → npm); das Icon-Paket lädt beim ersten Aktivieren', fenceErrorReason: 'Dieser Pfad liegt außerhalb des Session-Workspaces und wurde von der Workspace-Kontrolle blockiert', fenceDisableAction: 'Workspace-Kontrolle ausschalten', settingsBottomTerminalTitle: 'Beim ersten Ausklappen des unteren Panels automatisch ein Terminal öffnen', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 0425d45..af49406 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -252,6 +252,12 @@ export const fr: Record = { settingsToolsDesc: 'Une fois activé, le modèle peut créer et piloter des terminaux de la barre latérale via les 8 outils terminal_* (désactivé par défaut)', settingsFenceTitle: 'Contrôle des chemins de l\'espace de travail', settingsFenceDesc: 'Activé, les fonctions fichiers de la barre latérale n\'atteignent que les chemins dans l\'espace de travail de la session (par défaut) ; désactivé, les fichiers de tout l\'hôte deviennent accessibles — les scripts de la page obtiennent la même portée pendant ce temps', + settingsFileIconThemeTitle: 'Icônes de fichiers', + settingsFileIconThemeDesc: 'Style des icônes de l\'arborescence et des onglets de fichiers (le thème coloré se charge à la demande, jamais au démarrage)', + settingsFileIconThemeBuiltin: 'Intégré (monochrome)', + settingsFileIconThemeBuiltinDesc: 'Icônes monochromes pilotées par les jetons de thème, pour les types code / document / média courants', + settingsFileIconThemeColored: 'Icônes de marque colorées', + settingsFileIconThemeColoredDesc: 'Couleurs de marque par type (ex. .tsx → React, package.json → npm) ; le pack d\'icônes se charge à la première activation', fenceErrorReason: 'Ce chemin est hors de l\'espace de travail de la session et a été bloqué par le contrôle', fenceDisableAction: 'Désactiver le contrôle de l\'espace de travail', settingsBottomTerminalTitle: 'Ouvrir automatiquement un terminal au premier déploiement du panneau inférieur', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 63b095b..2f274b0 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -259,6 +259,12 @@ export const hi: Record = { settingsToolsDesc: 'सक्षम होने पर, मॉडल 8 terminal_* टूल्स से साइडबार टर्मिनल बना और चला सकता है (डिफ़ॉल्ट रूप से बंद)', settingsFenceTitle: 'वर्कस्पेस पाथ जाँच', settingsFenceDesc: 'चालू होने पर साइडबार की फ़ाइल सुविधाएँ केवल सेशन वर्कस्पेस के भीतर के पाथ तक पहुँचती हैं (डिफ़ॉल्ट); बंद करने पर होस्ट की कोई भी फ़ाइल खोली जा सकती है — उस दौरान पेज के स्क्रिप्ट को भी वही पहुँच मिल जाती है', + settingsFileIconThemeTitle: 'फ़ाइल आइकन', + settingsFileIconThemeDesc: 'फ़ाइल ट्री और फ़ाइल टैब के आइकन की शैली (रंगीन थीम माँग पर लोड होती है, शुरू होते समय नहीं)', + settingsFileIconThemeBuiltin: 'अंतर्निहित (एकरंगी)', + settingsFileIconThemeBuiltinDesc: 'स्किन टोकन के अनुसार एकरंगी आइकन, सामान्य कोड / दस्तावेज़ / मीडिया प्रकारों के लिए', + settingsFileIconThemeColored: 'रंगीन ब्रांड आइकन', + settingsFileIconThemeColoredDesc: 'प्रकार के अनुसार ब्रांड रंग (जैसे .tsx → React, package.json → npm); पहली बार चालू करने पर आइकन पैक लोड होता है', fenceErrorReason: 'यह पाथ सेशन वर्कस्पेस के बाहर है और जाँच द्वारा अवरुद्ध किया गया', fenceDisableAction: 'वर्कस्पेस जाँच बंद करें', settingsBottomTerminalTitle: 'निचला पैनल पहली बार खुलने पर स्वतः टर्मिनल खोलें', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index 7babc43..c5f6ef8 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -257,6 +257,12 @@ export const id: Record = { settingsToolsDesc: 'Saat diaktifkan, model dapat membuat dan mengendalikan terminal sidebar melalui 8 alat terminal_* (nonaktif secara default)', settingsFenceTitle: 'Pemeriksaan path workspace', settingsFenceDesc: 'Saat aktif, fitur berkas sidebar hanya menjangkau path di dalam workspace sesi (default); saat nonaktif, berkas di mana pun di host dapat diakses — skrip halaman memperoleh jangkauan yang sama selama itu', + settingsFileIconThemeTitle: 'Ikon berkas', + settingsFileIconThemeDesc: 'Gaya ikon untuk pohon berkas dan tab berkas (tema berwarna dimuat saat diperlukan, tidak saat mulai)', + settingsFileIconThemeBuiltin: 'Bawaan (monokrom)', + settingsFileIconThemeBuiltinDesc: 'Ikon monokrom mengikuti token tema, mencakup tipe kode / dokumen / media umum', + settingsFileIconThemeColored: 'Ikon merek berwarna', + settingsFileIconThemeColoredDesc: 'Warna merek per tipe (mis. .tsx → React, package.json → npm); paket ikon dimuat saat pertama diaktifkan', fenceErrorReason: 'Path ini berada di luar workspace sesi dan diblokir oleh pemeriksaan', fenceDisableAction: 'Matikan pemeriksaan workspace', settingsBottomTerminalTitle: 'Buka terminal otomatis saat panel bawah pertama kali dibentangkan', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index c1a92f6..48ba34d 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -250,6 +250,12 @@ export const it: Record = { settingsToolsDesc: 'Se attivato, il modello può creare e gestire terminali della barra laterale attraverso gli 8 strumenti terminal_* (disattivato per impostazione predefinita)', settingsFenceTitle: 'Controllo dei percorsi del workspace', settingsFenceDesc: 'Attivo, le funzioni file della barra laterale raggiungono solo i percorsi nel workspace di sessione (predefinito); disattivo, ogni file dell\'host è raggiungibile — gli script della pagina ottengono la stessa portata nel frattempo', + settingsFileIconThemeTitle: 'Icone dei file', + settingsFileIconThemeDesc: 'Stile delle icone per l\'albero dei file e le schede (il tema colorato si carica su richiesta, mai all\'avvio)', + settingsFileIconThemeBuiltin: 'Integrato (monocromatico)', + settingsFileIconThemeBuiltinDesc: 'Icone monocromatiche basate sui token del tema, per i tipi di codice / documento / media più comuni', + settingsFileIconThemeColored: 'Icone di marca colorate', + settingsFileIconThemeColoredDesc: 'Colori di marca per tipo (es. .tsx → React, package.json → npm); il pacchetto si carica alla prima attivazione', fenceErrorReason: 'Questo percorso è fuori dal workspace di sessione ed è stato bloccato dal controllo', fenceDisableAction: 'Disattiva il controllo del workspace', settingsBottomTerminalTitle: 'Apri automaticamente un terminale alla prima espansione del pannello inferiore', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 71483ff..3e78bf3 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -259,6 +259,12 @@ export const ja: Record = { settingsToolsDesc: 'オンにすると、モデルが terminal_create 等 8 個のツールでサイドバーターミナルを作成・操作可能(デフォルトオフ)', settingsFenceTitle: 'ワークスペースパスガード', settingsFenceDesc: 'オンの場合、サイドバーのファイル機能はセッションワークスペース内のパスのみアクセス可能(デフォルト)。オフにするとホスト上の任意のファイルにアクセスできるが、オフの間はページ内スクリプトも同じ権限を得る', + settingsFileIconThemeTitle: 'ファイルアイコン', + settingsFileIconThemeDesc: 'ファイルツリーとファイルタブのアイコンスタイル(カラーテーマは必要時に読み込み、起動時には読み込みません)', + settingsFileIconThemeBuiltin: '標準(モノクロ)', + settingsFileIconThemeBuiltinDesc: 'スキントークンに従うモノクロアイコン。一般的なコード / ドキュメント / メディア形式に対応', + settingsFileIconThemeColored: 'カラーブランドアイコン', + settingsFileIconThemeColoredDesc: '種類ごとにブランドカラーで表示(例:.tsx → React、package.json → npm)。初回有効時にアイコンパックを読み込みます', fenceErrorReason: 'このパスはセッションワークスペースの外にあり、ワークスペースガードによりブロックされました', fenceDisableAction: 'ワークスペースガードをオフにする', settingsBottomTerminalTitle: '下パネル初回展開時に自動でターミナルを開く', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 658c390..71b65d0 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -251,6 +251,12 @@ export const ko: Record = { settingsToolsDesc: '켜면 모델이 terminal_create 등 8개 도구로 사이드바 터미널을 만들고 조작할 수 있습니다(기본 꺼짐)', settingsFenceTitle: '워크스페이스 경로 검사', settingsFenceDesc: '켜면 사이드바의 파일 기능이 세션 워크스페이스 내 경로만 접근합니다(기본값). 끄면 호스트의 모든 파일에 접근할 수 있으며, 꺼져 있는 동안 페이지 내 스크립트도 동일한 권한을 얻습니다', + settingsFileIconThemeTitle: '파일 아이콘', + settingsFileIconThemeDesc: '파일 트리와 파일 탭의 아이콘 스타일 (컬러 테마는 필요할 때 로드되며 시작 시에는 로드되지 않습니다)', + settingsFileIconThemeBuiltin: '기본(단색)', + settingsFileIconThemeBuiltinDesc: '스킨 토큰을 따르는 단색 아이콘으로 주요 코드 / 문서 / 미디어 형식을 지원합니다', + settingsFileIconThemeColored: '컬러 브랜드 아이콘', + settingsFileIconThemeColoredDesc: '유형별 브랜드 색상 (예: .tsx → React, package.json → npm), 처음 켤 때 아이콘 팩을 로드합니다', fenceErrorReason: '이 경로는 세션 워크스페이스 밖에 있어 검사에 의해 차단되었습니다', fenceDisableAction: '워크스페이스 검사 끄기', settingsBottomTerminalTitle: '하단 패널을 처음 펼칠 때 터미널 자동 열기', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index 043a105..fcdb497 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -257,6 +257,12 @@ export const nl: Record = { settingsToolsDesc: 'Indien ingeschakeld kan het model zijbalk-terminals aanmaken en besturen via de 8 terminal_*-tools (standaard uit)', settingsFenceTitle: 'Workspace-padcontrole', settingsFenceDesc: 'Aan: de bestandsfuncties van de zijbalk bereiken alleen paden binnen de sessie-workspace (standaard); uit: elk bestand op de host is bereikbaar — paginascripts krijgen dat bereik zolang het uit staat', + settingsFileIconThemeTitle: 'Bestandspictogrammen', + settingsFileIconThemeDesc: 'Pictogramstijl voor de bestandsboom en bestandstabbladen (het kleurenthema wordt op verzoek geladen, nooit bij het starten)', + settingsFileIconThemeBuiltin: 'Ingebouwd (monochroom)', + settingsFileIconThemeBuiltinDesc: 'Monochrome pictogrammen volgens de token-regels, voor gangbare code-, document- en mediatypen', + settingsFileIconThemeColored: 'Gekleurde merkpictogrammen', + settingsFileIconThemeColoredDesc: 'Merkkleuren per type (bijv. .tsx → React, package.json → npm); het pictogrammenpakket wordt geladen bij de eerste activering', fenceErrorReason: 'Dit pad ligt buiten de sessie-workspace en is geblokkeerd door de controle', fenceDisableAction: 'Workspace-controle uitzetten', settingsBottomTerminalTitle: 'Automatisch een terminal openen bij de eerste uitklap van het onderpaneel', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index e09bab1..78d48c5 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -261,6 +261,12 @@ export const pl: Record = { settingsToolsDesc: 'Po włączeniu model może tworzyć i sterować terminalami panelu bocznego przez 8 narzędzi terminal_* (domyślnie wyłączone)', settingsFenceTitle: 'Kontrola ścieżek obszaru roboczego', settingsFenceDesc: 'Włączona: funkcje plików paska bocznego sięgają tylko ścieżek w obszarze roboczym sesji (domyślnie); wyłączona: dostępny jest dowolny plik hosta — skrypty strony zyskują ten sam zasięg przez ten czas', + settingsFileIconThemeTitle: 'Ikony plików', + settingsFileIconThemeDesc: 'Styl ikon drzewa plików i kart plików (kolorowy motyw wczytuje się na żądanie, nigdy przy starcie)', + settingsFileIconThemeBuiltin: 'Wbudowane (monochromatyczne)', + settingsFileIconThemeBuiltinDesc: 'Monochromatyczne ikony zgodne z tokenami motywu, dla typowych plików kodu / dokumentów / multimediów', + settingsFileIconThemeColored: 'Kolorowe ikony marek', + settingsFileIconThemeColoredDesc: 'Kolory marek według typu (np. .tsx → React, package.json → npm); paczka ikon wczytuje się przy pierwszym włączeniu', fenceErrorReason: 'Ta ścieżka jest poza obszarem roboczym sesji i została zablokowana przez kontrolę', fenceDisableAction: 'Wyłącz kontrolę obszaru roboczego', settingsBottomTerminalTitle: 'Automatycznie otwieraj terminal przy pierwszym rozwinięciu panelu dolnego', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index ebdc6e7..6168379 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -242,6 +242,12 @@ export const pt: Record = { settingsToolsDesc: 'Quando ativado, o modelo pode criar e operar terminais da barra lateral por meio das 8 ferramentas terminal_* (desativado por padrão)', settingsFenceTitle: 'Controle de caminhos do workspace', settingsFenceDesc: 'Ativado, os recursos de arquivos da barra lateral só alcançam caminhos dentro do workspace da sessão (padrão); desativado, arquivos de todo o host ficam acessíveis — scripts da página ganham o mesmo alcance enquanto isso', + settingsFileIconThemeTitle: 'Ícones de arquivo', + settingsFileIconThemeDesc: 'Estilo dos ícones da árvore de arquivos e das abas (o tema colorido é carregado sob demanda, nunca na inicialização)', + settingsFileIconThemeBuiltin: 'Integrado (monocromático)', + settingsFileIconThemeBuiltinDesc: 'Ícones monocromáticos guiados por tokens, para tipos comuns de código / documento / mídia', + settingsFileIconThemeColored: 'Ícones de marca coloridos', + settingsFileIconThemeColoredDesc: 'Cores de marca por tipo (ex.: .tsx → React, package.json → npm); o pacote é carregado ao ativar pela primeira vez', fenceErrorReason: 'Este caminho está fora do workspace da sessão e foi bloqueado pelo controle', fenceDisableAction: 'Desativar o controle do workspace', settingsBottomTerminalTitle: 'Abrir um terminal automaticamente na primeira expansão do painel inferior', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 3fd5149..7397c7a 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -256,6 +256,12 @@ export const ru: Record = { settingsToolsDesc: 'Если включено, модель может через 8 инструментов (terminal_create и др.) создавать терминалы боковой панели и управлять ими (по умолчанию выключено)', settingsFenceTitle: 'Контроль путей рабочей области', settingsFenceDesc: 'Включено — файловые функции боковой панели достигают только путей внутри рабочей области сессии (по умолчанию); выключено — доступны любые файлы хоста, а скрипты страницы получают тот же доступ всё это время', + settingsFileIconThemeTitle: 'Значки файлов', + settingsFileIconThemeDesc: 'Стиль значков дерева файлов и вкладок (цветная тема загружается по требованию, а не при запуске)', + settingsFileIconThemeBuiltin: 'Встроенные (монохромные)', + settingsFileIconThemeBuiltinDesc: 'Монохромные значки на основе токенов темы: код, документы и медиа', + settingsFileIconThemeColored: 'Цветные значки брендов', + settingsFileIconThemeColoredDesc: 'Цвета брендов по типу (например, .tsx → React, package.json → npm); набор значков загружается при первом включении', fenceErrorReason: 'Этот путь вне рабочей области сессии и заблокирован контролем', fenceDisableAction: 'Отключить контроль рабочей области', settingsBottomTerminalTitle: 'Автооткрытие терминала при первом разворачивании нижней панели', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index 66d5edd..d9a8313 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -242,6 +242,12 @@ export const sv: Record = { settingsToolsDesc: 'När aktiverat kan modellen skapa och styra sidopanelsterminaler via de 8 terminal_*-verktygen (av som standard)', settingsFenceTitle: 'Kontroll av workspace-sökvägar', settingsFenceDesc: 'På: sidopanelens filfunktioner når bara sökvägar i sessionens workspace (standard); av: vilken fil som helst på värden kan nås — sidans skript får samma räckvidd så länge', + settingsFileIconThemeTitle: 'Filikoner', + settingsFileIconThemeDesc: 'Ikonstil för filträdet och filflikarna (färgtemat laddas vid behov, aldrig vid start)', + settingsFileIconThemeBuiltin: 'Inbyggd (monokrom)', + settingsFileIconThemeBuiltinDesc: 'Monokroma ikoner som följer temats tokens, för vanliga kod-, dokument- och mediatyper', + settingsFileIconThemeColored: 'Färgade varumärkesikoner', + settingsFileIconThemeColoredDesc: 'Varumärkesfärger per typ (t.ex. .tsx → React, package.json → npm); ikonpaketet laddas första gången du aktiverar det', fenceErrorReason: 'Denna sökväg ligger utanför sessionens workspace och blockerades av kontrollen', fenceDisableAction: 'Stäng av workspace-kontrollen', settingsBottomTerminalTitle: 'Öppna automatiskt en terminal vid bottenpanelens första expandering', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 72b31fd..be8c173 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -259,6 +259,12 @@ export const th: Record = { settingsToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถสร้างและควบคุมเทอร์มินัลแถบด้านข้างผ่านเครื่องมือ terminal_* 8 ตัว (ปิดเป็นค่าเริ่มต้น)', settingsFenceTitle: 'การตรวจสอบเส้นทางเวิร์กสเปซ', settingsFenceDesc: 'เมื่อเปิด ฟีเจอร์ไฟล์ของแถบข้างเข้าถึงได้เฉพาะเส้นทางในเวิร์กสเปซของเซสชัน (ค่าเริ่มต้น) เมื่อปิด จะเข้าถึงไฟล์ใดก็ได้บนโฮสต์ — สคริปต์ในหน้าก็ได้ขอบเขตเดียวกันตลอดช่วงนั้น', + settingsFileIconThemeTitle: 'ไอคอนไฟล์', + settingsFileIconThemeDesc: 'สไตล์ไอคอนของแผนผังไฟล์และแท็บไฟล์ (ธีมสีจะโหลดเมื่อต้องการ ไม่โหลดตอนเริ่มต้น)', + settingsFileIconThemeBuiltin: 'ในตัว (สีเดียว)', + settingsFileIconThemeBuiltinDesc: 'ไอคอนสีเดียวตามโทเค็นธีม ครอบคลุมชนิดโค้ด / เอกสาร / สื่อทั่วไป', + settingsFileIconThemeColored: 'ไอคอนแบรนด์แบบมีสี', + settingsFileIconThemeColoredDesc: 'สีแบรนด์ตามชนิด (เช่น .tsx → React, package.json → npm) ชุดไอคอนจะโหลดเมื่อเปิดใช้ครั้งแรก', fenceErrorReason: 'เส้นทางนี้อยู่นอกเวิร์กสเปซของเซสชันและถูกบล็อกโดยการตรวจสอบ', fenceDisableAction: 'ปิดการตรวจสอบเวิร์กสเปซ', settingsBottomTerminalTitle: 'เปิดเทอร์มินัลอัตโนมัติเมื่อพาเนลล่างขยายครั้งแรก', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index e12ca75..1bfe080 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -259,6 +259,12 @@ export const tr: Record = { settingsToolsDesc: 'Etkinleştirildiğinde model, 8 terminal_* aracı üzerinden kenar çubuğu terminalleri oluşturup kullanabilir (varsayılan kapalı)', settingsFenceTitle: 'Çalışma alanı yol denetimi', settingsFenceDesc: 'Açıkken kenar çubuğunun dosya işlevleri yalnızca oturum çalışma alanındaki yollara erişir (varsayılan); kapatıldığında ana makinedeki her dosyaya erişilebilir — o süre boyunca sayfadaki betikler de aynı erişimi kazanır', + settingsFileIconThemeTitle: 'Dosya simgeleri', + settingsFileIconThemeDesc: 'Dosya ağacı ve dosya sekmeleri için simge stili (renkli tema isteğe bağlı yüklenir, başlangıçta değil)', + settingsFileIconThemeBuiltin: 'Yerleşik (tek renk)', + settingsFileIconThemeBuiltinDesc: 'Tema belirteçlerine uyan tek renk simgeler; yaygın kod / belge / medya türlerini kapsar', + settingsFileIconThemeColored: 'Renkli marka simgeleri', + settingsFileIconThemeColoredDesc: 'Türe göre marka renkleri (örn. .tsx → React, package.json → npm); simge paketi ilk açılışta yüklenir', fenceErrorReason: 'Bu yol oturum çalışma alanının dışında ve denetim tarafından engellendi', fenceDisableAction: 'Çalışma alanı denetimini kapat', settingsBottomTerminalTitle: 'Alt panelin ilk genişletilmesinde otomatik terminal aç', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 78e72ca..8d6dc42 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -259,6 +259,12 @@ export const vi: Record = { settingsToolsDesc: 'Khi bật, mô hình có thể tạo và điều khiển terminal thanh bên qua 8 công cụ terminal_* (mặc định tắt)', settingsFenceTitle: 'Kiểm tra đường dẫn workspace', settingsFenceDesc: 'Khi bật, các tính năng tệp của thanh bên chỉ truy cập đường dẫn trong workspace của phiên (mặc định); khi tắt, mọi tệp trên máy chủ đều có thể truy cập — script trong trang cũng có quyền tương ứng trong thời gian đó', + settingsFileIconThemeTitle: 'Biểu tượng tệp', + settingsFileIconThemeDesc: 'Kiểu biểu tượng cho cây tệp và tab tệp (chủ đề màu được tải khi cần, không tải lúc khởi động)', + settingsFileIconThemeBuiltin: 'Tích hợp (đơn sắc)', + settingsFileIconThemeBuiltinDesc: 'Biểu tượng đơn sắc theo token giao diện, bao phủ các loại mã / tài liệu / media phổ biến', + settingsFileIconThemeColored: 'Biểu tượng thương hiệu nhiều màu', + settingsFileIconThemeColoredDesc: 'Màu thương hiệu theo loại (ví dụ .tsx → React, package.json → npm); gói biểu tượng tải trong lần bật đầu tiên', fenceErrorReason: 'Đường dẫn này nằm ngoài workspace của phiên và đã bị chặn bởi kiểm tra', fenceDisableAction: 'Tắt kiểm tra workspace', settingsBottomTerminalTitle: 'Tự động mở terminal khi panel dưới mở rộng lần đầu', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 8c03489..47b1ee7 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -274,6 +274,12 @@ export const zhHK: Record = { settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', settingsFenceTitle: '工作區路徑檢測', settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', + settingsFileIconThemeTitle: '檔案圖示', + settingsFileIconThemeDesc: '檔案樹與檔案分頁的圖示風格(彩色主題按需載入,不影響啟動)', + settingsFileIconThemeBuiltin: '內建(單色)', + settingsFileIconThemeBuiltinDesc: '跟隨皮膚權杖的單色圖示,覆蓋常見程式碼 / 文件 / 媒體類型', + settingsFileIconThemeColored: '彩色品牌圖示', + settingsFileIconThemeColoredDesc: '按類型顯示品牌配色圖示(如 .tsx → React、package.json → npm);首次開啟時載入圖示包', fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index 4615010..6658557 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -274,6 +274,12 @@ export const zhMO: Record = { settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', settingsFenceTitle: '工作區路徑檢測', settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', + settingsFileIconThemeTitle: '檔案圖示', + settingsFileIconThemeDesc: '檔案樹與檔案分頁的圖示風格(彩色主題按需載入,不影響啟動)', + settingsFileIconThemeBuiltin: '內建(單色)', + settingsFileIconThemeBuiltinDesc: '跟隨皮膚權杖的單色圖示,覆蓋常見程式碼 / 文件 / 媒體類型', + settingsFileIconThemeColored: '彩色品牌圖示', + settingsFileIconThemeColoredDesc: '按類型顯示品牌配色圖示(如 .tsx → React、package.json → npm);首次開啟時載入圖示包', fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 23e7f6d..23679b5 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -274,6 +274,12 @@ export const zhTW: Record = { settingsToolsDesc: '開啟後,模型可透過 terminal_create 等 8 個工具建立並操作側邊欄終端(預設關閉)', settingsFenceTitle: '工作區路徑檢測', settingsFenceDesc: '開啟後,側欄的檔案功能僅能存取工作階段工作區內的路徑(預設);關閉後可存取主機上任意檔案——關閉期間頁面內指令碼也將獲得同等存取能力', + settingsFileIconThemeTitle: '檔案圖示', + settingsFileIconThemeDesc: '檔案樹與檔案分頁的圖示風格(彩色主題按需載入,不影響啟動)', + settingsFileIconThemeBuiltin: '內建(單色)', + settingsFileIconThemeBuiltinDesc: '跟隨面板權杖的單色圖示,涵蓋常見程式碼 / 文件 / 媒體類型', + settingsFileIconThemeColored: '彩色品牌圖示', + settingsFileIconThemeColoredDesc: '依類型顯示品牌配色圖示(如 .tsx → React、package.json → npm);首次開啟時載入圖示套件', fenceErrorReason: '此路徑在工作階段工作區之外,已被工作區檢測攔截', fenceDisableAction: '關閉工作區檢測', settingsBottomTerminalTitle: '底部面板首次展開自動開終端', diff --git a/src/client/locales.ts b/src/client/locales.ts index f418f86..7d5fb80 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -271,6 +271,12 @@ export const zh = { settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', settingsFenceTitle: '工作区路径检测', settingsFenceDesc: '开启后,侧栏的文件功能仅能访问会话工作区内的路径(默认);关闭后可访问主机上任意文件——关闭期间页面内脚本也将获得同等访问能力', + settingsFileIconThemeTitle: '文件图标', + settingsFileIconThemeDesc: '文件树与文件 tab 的图标风格(彩色主题按需加载,不影响启动)', + settingsFileIconThemeBuiltin: '内置(单色)', + settingsFileIconThemeBuiltinDesc: '跟随皮肤令牌的单色图标,覆盖常见代码 / 文档 / 媒体类型', + settingsFileIconThemeColored: '彩色品牌图标', + settingsFileIconThemeColoredDesc: '按类型显示品牌配色图标(如 .tsx → React、package.json → npm);首次开启时加载图标包', fenceErrorReason: '此路径在会话工作区之外,已被工作区检测拦截', fenceDisableAction: '关闭工作区检测', settingsBottomTerminalTitle: '底部面板首次展开自动开终端', @@ -713,6 +719,12 @@ export const en: Record = { settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)', settingsFenceTitle: 'Workspace path fence', settingsFenceDesc: 'On, the sidebar\'s file features only reach paths inside the session workspace (default); off, any file on the host is reachable — page scripts gain the same reach while it is off', + settingsFileIconThemeTitle: 'File icons', + settingsFileIconThemeDesc: 'Icon style for the file tree and file tabs (the colored theme loads on demand, never at startup)', + settingsFileIconThemeBuiltin: 'Built-in (monochrome)', + settingsFileIconThemeBuiltinDesc: 'Token-driven monochrome glyphs covering common code / document / media types', + settingsFileIconThemeColored: 'Colored brand icons', + settingsFileIconThemeColoredDesc: 'Per-type brand colors (e.g. .tsx → React, package.json → npm); the icon pack loads the first time you enable it', fenceErrorReason: 'This path is outside the session workspace and was blocked by the workspace fence', fenceDisableAction: 'Turn off the workspace fence', settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 4e6416f..e91f8fb 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -74,6 +74,9 @@ export function parsePrefs(value: unknown): SidebarPrefs { workspaceFence: typeof record.workspaceFence === 'boolean' ? record.workspaceFence : SIDEBAR_PREFS_DEFAULTS.workspaceFence, + fileIconTheme: typeof record.fileIconTheme === 'string' + ? record.fileIconTheme + : SIDEBAR_PREFS_DEFAULTS.fileIconTheme, // The title-bar scheme (auto | web | preset | custom). The schema // declares the field WITHOUT a default, so documents written by older // plugin versions resolve without it — migrate from the legacy fields: diff --git a/src/client/service.ts b/src/client/service.ts index bb72b36..9ee2f71 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -26,7 +26,7 @@ import { leafWithTab, openTabInBottomPane, patchTab, tabOpenIn, type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, type TabType, } from './state.ts' -import { extOf } from './paths.ts' +import { baseName, extOf } from './paths.ts' import { builtinFileIconOf, builtinFolderIcon, fallbackFileIcon } from './file-icons.tsx' import type { SessionScope } from './api.ts' import type { SidebarPrefs } from '../prefs-shared.ts' @@ -349,15 +349,36 @@ export interface FileIconDescriptor { * Lowercase extensions without leading dot (`['csv','tsv']`). `[]` = the * global default (catch-all): it only claims files the built-in glyph map * does not cover — registered specifics and built-in glyphs always outrank - * it. Two values are RESERVED for directory rows (never matched against - * real file extensions): `'folder'` (a closed directory) and - * `'folder-open'` (an expanded directory) — see `FOLDER_EXT`. + * it. OMITTED = no extension rule at all (a `names`-only registration is + * NOT a catch-all). Two values are RESERVED for directory rows (never + * matched against real file extensions): `'folder'` (a closed directory) + * and `'folder-open'` (an expanded directory) — see `FOLDER_EXT`. */ - exts: readonly string[] + exts?: readonly string[] + /** + * Exact FILE names (basename, case-insensitive — `['package.json', + * 'Dockerfile']`), the `fileNames` half of an icon theme. Name matches + * outrank extension matches, so a theme can color `package.json` apart + * from every other `.json`. Omitted/`[]` = no name rule. + */ + names?: readonly string[] + /** + * Exact DIRECTORY names (basename, case-insensitive — `['node_modules', + * 'src']`), the `folderNames` half of an icon theme. A name match outranks + * the reserved `'folder'`/`'folder-open'` exts, and a descriptor with + * `folderNames` only claims the directories it names (never every folder — + * that is what the reserved exts are for). Omitted/`[]` = no name rule. + */ + folderNames?: readonly string[] /** Higher wins; default 0. Registered icons always outrank the built-in map. */ priority?: number - /** Size-aware icon factory (the tree and file tabs render at 14 today). */ - icon: (path: string, size: number) => ReactNode + /** + * Size-aware icon factory (the tree and file tabs render at 14 today). + * `open` is the directory's expanded state for a DIRECTORY row and + * `undefined` for a file row — a folder icon uses it to pick between the + * closed and opened glyph. + */ + icon: (path: string, size: number, open?: boolean) => ReactNode } /** @@ -450,23 +471,26 @@ export interface BetterSidebarService { getFileIcons(): readonly FileIconDescriptor[] /** * Find a SPECIFIC registered file icon for a path (priority desc, then - * registration order). Catch-alls (`exts: []`) and folder registrations - * (`'folder'`/`'folder-open'`) are not consulted — this answers "did a - * registration claim this exact extension". Consumers should prefer + * registration order): a `names` match first, then an `exts` match. + * Catch-alls (`exts: []`) and folder registrations (`'folder'`/ + * `'folder-open'`) are not consulted — this answers "did a registration + * claim this exact name or extension". Consumers should prefer * `fileIcon`/`folderIcon`, which run the whole fallback chain. */ matchFileIcon(path: string): FileIconDescriptor | undefined /** * Find the registered icon for DIRECTORY rows (priority desc, then - * registration order): `open` picks between the `'folder'` and - * `'folder-open'` reserved exts. Undefined = fall back to the built-in - * VSCodicons folder glyphs. + * registration order): a `folderNames` match on `name` first (pass the + * directory's basename), then the `'folder'`/`'folder-open'` reserved + * exts by `open`. Undefined = fall back to the built-in VSCodicons folder + * glyphs. */ - matchFolderIcon(open: boolean): FileIconDescriptor | undefined + matchFolderIcon(open: boolean, name?: string): FileIconDescriptor | undefined /** * The authoritative FILE icon for a path (feature `fileIcons`), running * the whole chain with per-factory crash isolation: - * 1. a specific registered extension (priority desc, registration order), + * 1. a specific registered name or extension (priority desc, registration + * order), * 2. the built-in monochrome glyph map (md/media/pdf/json/code/...), * 3. the best registered global default (`exts: []`, priority desc), * 4. the generic `VscFile`. @@ -476,9 +500,11 @@ export interface BetterSidebarService { fileIcon(path: string, size: number): ReactNode /** * The authoritative DIRECTORY icon for a tree row: the registered - * `'folder'`/`'folder-open'` icon (priority desc), else the built-in - * `VscFolder`/`VscFolderOpened`. `path` is the directory's own path (a - * theme may vary icons per directory). Same crash isolation as `fileIcon`. + * `folderNames`/`'folder'`/`'folder-open'` icon (priority desc), else the + * built-in `VscFolder`/`VscFolderOpened`. `path` is the directory's own + * path (a theme may vary icons per directory); `open` reaches the factory + * so one descriptor can render both states. Same crash isolation as + * `fileIcon`. */ folderIcon(path: string, open: boolean, size: number): ReactNode /** Find a tab descriptor by id (undefined if not registered). */ @@ -616,7 +642,9 @@ export const SIDEBAR_SERVICE_VERSION = '0.19.1' * - 'urlTarget' (v0.13.0): TabDescriptor.urlTarget (external-link claims) * - 'settingSelect': SidebarSettingToggle type 'select' (options/multi) * - 'fileIcons' (v0.19.0): registerFileIcon/getFileIcons/matchFileIcon — - * external per-extension file-tree icons overriding the built-in glyphs. + * external file-tree icons overriding the built-in glyphs, matched by + * extension (`exts`), exact file name (`names`), or directory name + * (`folderNames`). * * v0.19.0 REMOVED 'floatWindows': the free-window feature is gone (DSH 0.1.5 * owns the right column, so the plugin keeps only its bottom workbench). @@ -721,45 +749,60 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe // Specific registrations only: catch-alls (`exts: []`) and folder // registrations (`'folder'`/`'folder-open'`) are skipped, and the reserved - // folder values never match a real file's extension. The built-in glyph - // map is not consulted here — an undefined result IS the "fall through" - // signal the `fileIcon` resolver acts on. + // folder values never match a real file's extension. Name rules (`names`) + // outrank extension rules. The built-in glyph map is not consulted here — + // an undefined result IS the "fall through" signal the `fileIcon` resolver + // acts on. const matchFileIcon = (path: string): FileIconDescriptor | undefined => { const ext = extOf(path) // Reserved folder values never claim a real file: `x.folder` falls // through to the built-in/catch-all chain like any unknown extension. - if (ext === FOLDER_EXT || ext === FOLDER_OPEN_EXT) return undefined - for (const d of rankedFileIcons()) { - if (d.exts.includes(ext)) return d + const reserved = ext === FOLDER_EXT || ext === FOLDER_OPEN_EXT + const name = baseName(path).toLowerCase() + const ranked = rankedFileIcons() + for (const d of ranked) { + if (d.names?.some(entry => entry.toLowerCase() === name) === true) return d + } + if (reserved) return undefined + for (const d of ranked) { + if (d.exts?.includes(ext) === true) return d } return undefined } - // Directory rows have no extension: the reserved `'folder'`/`'folder-open'` - // exts are the only claim surface (a catch-all never claims a directory). - const matchFolderIcon = (open: boolean): FileIconDescriptor | undefined => { + // Directory rows: a `folderNames` match on the directory's own basename + // first, then the reserved `'folder'`/`'folder-open'` exts (a catch-all + // never claims a directory). + const matchFolderIcon = (open: boolean, name?: string): FileIconDescriptor | undefined => { + const ranked = rankedFileIcons() + if (name !== undefined) { + const wanted = name.toLowerCase() + for (const d of ranked) { + if (d.folderNames?.some(entry => entry.toLowerCase() === wanted) === true) return d + } + } const want = open ? FOLDER_OPEN_EXT : FOLDER_EXT - for (const d of rankedFileIcons()) { - if (d.exts.includes(want)) return d + for (const d of ranked) { + if (d.exts?.includes(want) === true) return d } return undefined } /** Run one registered factory; a throw is logged and returns undefined. */ - const safeIcon = (d: FileIconDescriptor, path: string, size: number): ReactNode => { + const safeIcon = (d: FileIconDescriptor, path: string, size: number, open?: boolean): ReactNode => { try { - return d.icon(path, size) + return d.icon(path, size, open) } catch (error) { console.error(`[dsh-better-sidebar] file icon factory "${d.id}" error:`, error) return undefined } } - // The authoritative file-icon chain (see the interface doc): specific - // registration → built-in glyph → best catch-all → stock VscFile. The - // catch-all ranks by priority desc then registration order (first wins); - // an unclaimed extension reaching it is exactly "nothing specified falls - // back to the (registered or stock) default". + // The authoritative file-icon chain (see the interface doc): specific name + // or extension registration → built-in glyph → best catch-all → stock + // VscFile. The catch-all ranks by priority desc then registration order + // (first wins); an unclaimed extension reaching it is exactly "nothing + // specified falls back to the (registered or stock) default". const fileIcon = (path: string, size: number): ReactNode => { const specific = matchFileIcon(path) if (specific !== undefined) { @@ -769,7 +812,7 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const builtin = builtinFileIconOf(path) if (builtin !== undefined) return builtin(size) for (const d of rankedFileIcons()) { - if (d.exts.length === 0) { + if (d.exts !== undefined && d.exts.length === 0) { const icon = safeIcon(d, path, size) if (icon !== undefined) return icon } @@ -777,13 +820,14 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe return fallbackFileIcon(size) } - // Directory rows: registered folder/folder-open icon, else the built-in - // VSCodicons glyphs. The row's path feeds the factory (a theme may vary - // icons per directory). + // Directory rows: registered folderNames/folder/folder-open icon, else the + // built-in VSCodicons glyphs. The row's path feeds the factory (a theme may + // vary icons per directory) and `open` lets one descriptor render both + // states. const folderIcon = (path: string, open: boolean, size: number): ReactNode => { - const registered = matchFolderIcon(open) + const registered = matchFolderIcon(open, baseName(path)) if (registered !== undefined) { - const icon = safeIcon(registered, path, size) + const icon = safeIcon(registered, path, size, open) if (icon !== undefined) return icon } return builtinFolderIcon(open, size) diff --git a/src/config.ts b/src/config.ts index 07e5d4a..c7039b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -118,6 +118,7 @@ export const PrefsSchema: z = z.object({ terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT), editorExplorer: z.boolean().default(false), workspaceFence: z.boolean().default(true), + fileIconTheme: z.string().default('builtin'), terminalShell: z.string().default(''), terminalShellArgs: z.string().default(''), titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]), diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index d08a9aa..438e2dd 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -79,6 +79,14 @@ export interface SidebarPrefs { * one-click global off + retry. */ workspaceFence: boolean + /** + * The file-icon theme (v0.19.0+). `'builtin'` (default) is the monochrome + * VSCodicons glyph map every surface already uses; `'colored'` swaps in the + * optional brand-colored dataset, fetched from the lazy `file-icons` chunk + * only when selected (an unknown id behaves as `'builtin'`). The switch + * lives under the files tab's gear in the Side card settings. + */ + fileIconTheme: string /** * The shell the UI and agent terminals spawn (absolute path or bare * executable name). Empty (default) keeps the legacy resolution order: @@ -248,6 +256,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = { terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT, editorExplorer: false, workspaceFence: true, + fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', diff --git a/tests/builtins.spec.ts b/tests/builtins.spec.ts index 76c3a94..7746244 100644 --- a/tests/builtins.spec.ts +++ b/tests/builtins.spec.ts @@ -136,7 +136,7 @@ describe('built-in tab registrations', () => { it('the editor tab declares its merged-mode (embedded file tree) setting', () => { const { service } = setup() const toggles = service.getTab('editor')?.settings?.toggles ?? [] - expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence']) + expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence', 'fileIconTheme']) expect(toggles[0]?.title).toBeDefined() expect(toggles[0]?.desc).toBeDefined() // The merged mode is an iconed select (merged vs separate), not a switch. diff --git a/tests/bundle-route.spec.ts b/tests/bundle-route.spec.ts index 501585d..b27e9b2 100644 --- a/tests/bundle-route.spec.ts +++ b/tests/bundle-route.spec.ts @@ -150,6 +150,6 @@ describe('/sidebar/bundle route', () => { }) it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => { - expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale']) + expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale', 'file-icons']) }) }) diff --git a/tests/consumer-types.ts b/tests/consumer-types.ts index 33f0457..c256ba7 100644 --- a/tests/consumer-types.ts +++ b/tests/consumer-types.ts @@ -132,9 +132,11 @@ service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data') const icon: FileIconDescriptor = { id: 'my-plugin:icons', exts: ['csv', FOLDER_EXT, FOLDER_OPEN_EXT], + names: ['package.json'], + folderNames: ['node_modules'], priority: 5, - icon: (path: string, size: number) => { - void path; void size + icon: (path: string, size: number, open?: boolean) => { + void path; void size; void open return null }, } @@ -142,6 +144,7 @@ service.registerFileIcon(icon) service.getFileIcons() service.matchFileIcon('/p/a.csv') service.matchFolderIcon(true) +service.matchFolderIcon(true, 'node_modules') service.fileIcon('/p/a.csv', 14) service.folderIcon('/p', true, 14) diff --git a/tests/file-icon-theme.spec.tsx b/tests/file-icon-theme.spec.tsx new file mode 100644 index 0000000..bb743a4 --- /dev/null +++ b/tests/file-icon-theme.spec.tsx @@ -0,0 +1,176 @@ +/** + * The optional colored file-icon theme: the lazy-chunk loader that turns the + * `fileIconTheme` pref into a register/dispose of the colored dataset, and + * the dataset's own registration through the public `registerFileIcon` API. + * + * The loader tests drive a fake chunk (no fetch); the dataset tests use the + * real chunk module against a real service, asserting representative rules + * from each table (extension / exact file name / directory name). + */ +import { describe, it, expect, vi, afterEach } from 'vitest' +import type { ReactElement, ReactNode } from 'react' +import { SiNpm, SiReact } from 'react-icons/si' +import { VscFolder, VscFolderOpened } from 'react-icons/vsc' + +// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout) +const g = globalThis as Record +if (g.window === undefined) { + g.window = { + clearTimeout: () => {}, + setTimeout: (_fn: () => void) => 0, + innerWidth: 1024, + } +} +if (g.localStorage === undefined) { + g.localStorage = { getItem: () => null, setItem: () => {} } +} + +import { registerChunkForTests, resetChunks } from '../src/client/chunk-loader.ts' +import { createBetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' +import { + createFileIconThemeLoader, + FILE_ICON_THEME_BUILTIN, + FILE_ICON_THEME_COLORED, +} from '../src/client/file-icon-theme.ts' +import { registerColoredFileIcons } from '../src/client/chunks/file-icons.tsx' + +/** The component type of a rendered icon element (identity of the glyph). */ +const glyphOf = (node: ReactNode): unknown => (node as ReactElement).type +/** The inline color of a rendered icon element (undefined when untinted). */ +const colorOf = (node: ReactNode): unknown => (node as ReactElement).props.style?.color + +/** Flush the queued promise chain (loadChunk → loader's `.then`). */ +const settle = async (): Promise => { await new Promise((resolve) => { setTimeout(resolve, 0) }) } + +afterEach(() => { resetChunks() }) + +describe('file-icon theme loader (pref → lazy chunk → registerFileIcon)', () => { + it('registers the dataset when the colored theme is selected, and disposes on builtin', async () => { + const service = createBetterSidebarService(createSidebarStore()) + let registrations = 0 + let disposals = 0 + registerChunkForTests('file-icons', async () => ({ + registerColoredFileIcons: (target: typeof service) => { + expect(target).toBe(service) + registrations += 1 + return () => { disposals += 1 } + }, + })) + const loader = createFileIconThemeLoader(service) + expect(service.getFileIcons()).toHaveLength(0) + loader.set(FILE_ICON_THEME_COLORED) + await settle() + expect(registrations).toBe(1) + loader.set(FILE_ICON_THEME_BUILTIN) + expect(disposals).toBe(1) + loader.set(FILE_ICON_THEME_COLORED) + await settle() + expect(registrations).toBe(2) + loader.dispose() + expect(disposals).toBe(2) + }) + + it('treats an unknown pref value as the built-in theme', async () => { + const service = createBetterSidebarService(createSidebarStore()) + let registrations = 0 + registerChunkForTests('file-icons', async () => ({ + registerColoredFileIcons: () => { registrations += 1; return () => {} }, + })) + const loader = createFileIconThemeLoader(service) + loader.set('no-such-theme') + await settle() + expect(registrations).toBe(0) + }) + + it('drops a chunk load that lands after the pref moved back to builtin', async () => { + const service = createBetterSidebarService(createSidebarStore()) + let registrations = 0 + let release: (() => void) | undefined + registerChunkForTests('file-icons', () => new Promise((resolve) => { + release = () => resolve({ registerColoredFileIcons: () => { registrations += 1; return () => {} } }) + })) + const loader = createFileIconThemeLoader(service) + loader.set(FILE_ICON_THEME_COLORED) + loader.set(FILE_ICON_THEME_BUILTIN) + release?.() + await settle() + expect(registrations).toBe(0) + }) + + it('does not re-register on a repeated set of the same value', async () => { + const service = createBetterSidebarService(createSidebarStore()) + let registrations = 0 + registerChunkForTests('file-icons', async () => ({ + registerColoredFileIcons: () => { registrations += 1; return () => {} }, + })) + const loader = createFileIconThemeLoader(service) + loader.set(FILE_ICON_THEME_COLORED) + loader.set(FILE_ICON_THEME_COLORED) + await settle() + expect(registrations).toBe(1) + }) + + it('logs and keeps the built-in glyphs when the chunk fails to load', async () => { + const service = createBetterSidebarService(createSidebarStore()) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + registerChunkForTests('file-icons', async () => { throw new Error('offline') }) + const loader = createFileIconThemeLoader(service) + loader.set(FILE_ICON_THEME_COLORED) + await settle() + expect(errorSpy).toHaveBeenCalled() + expect(service.getFileIcons()).toHaveLength(0) + } finally { + errorSpy.mockRestore() + } + }) + + it('logs and keeps the built-in glyphs when the chunk exports no registrar', async () => { + const service = createBetterSidebarService(createSidebarStore()) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + registerChunkForTests('file-icons', async () => ({})) + const loader = createFileIconThemeLoader(service) + loader.set(FILE_ICON_THEME_COLORED) + await settle() + expect(errorSpy).toHaveBeenCalled() + expect(service.getFileIcons()).toHaveLength(0) + } finally { + errorSpy.mockRestore() + } + }) +}) + +describe('colored dataset registration (563 rules through registerFileIcon)', () => { + it('registers the extension, file-name and directory rules and disposes them', () => { + const service = createBetterSidebarService(createSidebarStore()) + const dispose = registerColoredFileIcons(service) + expect(service.getFileIcons().length).toBeGreaterThan(100) + + // Extension rule: .tsx → React blue, and it outranks the built-in glyph. + const tsx = service.fileIcon('/w/App.tsx', 14) + expect(glyphOf(tsx)).toBe(SiReact) + expect(colorOf(tsx)).toBe('#61DAFB') + + // Exact file name rule: package.json → npm red (name beats the .json ext). + const pkg = service.fileIcon('/w/package.json', 14) + expect(glyphOf(pkg)).toBe(SiNpm) + expect(colorOf(pkg)).toBe('#CB3837') + + // Directory rule: node_modules is tinted, an unnamed dir keeps the glyph. + const nodeModules = service.folderIcon('/w/node_modules', false, 14) + expect(glyphOf(nodeModules)).toBe(VscFolder) + expect(colorOf(nodeModules)).toBe('#CB3837') + expect(glyphOf(service.folderIcon('/w/node_modules', true, 14))).toBe(VscFolderOpened) + expect(glyphOf(service.folderIcon('/w/zzz-unnamed-dir', false, 14))).toBe(VscFolder) + expect(colorOf(service.folderIcon('/w/zzz-unnamed-dir', false, 14))).toBeUndefined() + + // An extension the dataset does not cover still falls back to the built-in. + expect(glyphOf(service.fileIcon('/w/data.unknownext', 14))).toBeDefined() + + dispose() + expect(service.getFileIcons()).toHaveLength(0) + expect(colorOf(service.fileIcon('/w/App.tsx', 14))).toBeUndefined() + }) +}) diff --git a/tests/file-icons.spec.tsx b/tests/file-icons.spec.tsx index 942cb32..ec1a60a 100644 --- a/tests/file-icons.spec.tsx +++ b/tests/file-icons.spec.tsx @@ -172,6 +172,57 @@ describe('fileIcon resolver chain (specific → builtin → catch-all → VscFil }) }) +describe('name matching (names / folderNames — the icon-theme half)', () => { + it('a names rule claims the exact basename, case-insensitively', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'npm', names: ['package.json'], icon: marker }) + expect(service.matchFileIcon('/w/package.json')?.id).toBe('npm') + expect(service.matchFileIcon('/w/Package.JSON')?.id).toBe('npm') + expect(service.matchFileIcon('/w/pkg.json')).toBeUndefined() + }) + + it('a name rule outranks an extension rule and the built-in glyph', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'by-ext', exts: ['json'], icon: markerB }) + service.registerFileIcon({ id: 'by-name', names: ['package.json'], icon: marker }) + expect(service.matchFileIcon('/w/package.json')?.id).toBe('by-name') + expect(glyphOf(service.fileIcon('/w/package.json', 14))).toBe('span') + // Other json files still take the extension rule. + expect(glyphOf(service.fileIcon('/w/tsconfig.json', 14))).toBe('b') + }) + + it('a folderNames rule claims only the directories it names, and outranks the reserved exts', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'all-dirs', exts: ['folder', 'folder-open'], icon: markerB }) + service.registerFileIcon({ id: 'named', folderNames: ['node_modules'], icon: marker }) + expect(service.matchFolderIcon(false, 'node_modules')?.id).toBe('named') + expect(service.matchFolderIcon(false, 'NODE_MODULES')?.id).toBe('named') + // An unnamed directory still takes the reserved-ext registration. + expect(service.matchFolderIcon(false, 'src')?.id).toBe('all-dirs') + // A descriptor with folderNames only never claims unnamed directories. + const solo = createBetterSidebarService(createSidebarStore()) + solo.registerFileIcon({ id: 'named', folderNames: ['src'], icon: marker }) + expect(solo.matchFolderIcon(false, 'lib')).toBeUndefined() + expect(glyphOf(solo.folderIcon('/w/lib', false, 14))).toBe(VscFolder) + }) + + it('the folder factory receives the open flag so one descriptor renders both states', () => { + const service = createBetterSidebarService(createSidebarStore()) + const seen: (boolean | undefined)[] = [] + service.registerFileIcon({ + id: 'stateful', + folderNames: ['src'], + icon: (_path, _size, open) => { + seen.push(open) + return open === true ? marker() : markerB() + }, + }) + expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('b') + expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe('span') + expect(seen).toEqual([false, true]) + }) +}) + describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)', () => { it('unregistered directories show the builtin glyphs', () => { const service = createBetterSidebarService(createSidebarStore()) diff --git a/tests/manifest-consistency.spec.ts b/tests/manifest-consistency.spec.ts index 04ba007..4d691b1 100644 --- a/tests/manifest-consistency.spec.ts +++ b/tests/manifest-consistency.spec.ts @@ -71,7 +71,7 @@ function bundleId(file: string): string { } /** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */ -const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`) +const CHUNK_FILES = ['terminal', 'editor', 'mermaid', 'file-icons'].map(name => `lib/client-${name}.js`) /** The global registry slot a built chunk script assigns (its factory key). */ function chunkSlot(file: string): string { diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 16caa48..d49141f 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -1000,6 +1000,7 @@ describe('side card settings routes', () => { terminalFontSize: 13, editorExplorer: false, workspaceFence: true, + fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarCompat: false, diff --git a/tests/theme.spec.ts b/tests/theme.spec.ts index 0552779..330db57 100644 --- a/tests/theme.spec.ts +++ b/tests/theme.spec.ts @@ -13,6 +13,8 @@ */ // @vitest-environment jsdom import { afterEach, describe, expect, it } from 'vitest' +import { readFileSync, readdirSync } from 'node:fs' +import { resolve } from 'node:path' import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts' afterEach(() => { @@ -80,3 +82,39 @@ describe('effectiveTokenValue', () => { expect(effectiveTokenValue('--probe')).toBe('') }) }) + +/** + * Skin contract (guide §12): every visual value rides a `--dsw-alias-*` / + * `--dsw-font-*` / `--ds-*` token. The ONE documented exception is the + * optional colored icon theme: brand colors are identity, not chrome, so + * they are hardcoded — but only inside the lazy `file-icons` chunk, which + * the core bundle must never import. This guards that boundary. + */ +// jsdom has no file:// import.meta.url; vitest runs from the repo root. +const ROOT = process.cwd() + +describe('skin contract boundary for hardcoded icon colors', () => { + const coreIconModules = ['src/client/file-icons.tsx', 'src/client/file-icon-theme.ts'] + + it('the core icon modules carry no color literals', () => { + for (const file of coreIconModules) { + const source = readFileSync(resolve(ROOT, file), 'utf8') + expect(source, file).not.toMatch(/#[0-9a-fA-F]{3,8}\b/) + expect(source, file).not.toMatch(/\brgba?\(/) + } + }) + + it('only the lazy chunk may carry the colored dataset, and the core never imports it statically', () => { + const chunk = readFileSync(resolve(ROOT, 'src/client/chunks/file-icons.tsx'), 'utf8') + expect(chunk).toMatch(/#[0-9a-fA-F]{6}/) + const clientDir = resolve(ROOT, 'src/client') + const offenders: string[] = [] + for (const entry of readdirSync(clientDir, { recursive: true, withFileTypes: true })) { + if (!entry.isFile() || !/\.tsx?$/.test(entry.name)) continue + const path = resolve(entry.parentPath, entry.name) + if (path.includes('/chunks/')) continue + if (/from '[^']*chunks\/file-icons/.test(readFileSync(path, 'utf8'))) offenders.push(path) + } + expect(offenders).toEqual([]) + }) +}) diff --git a/tsdown.config.ts b/tsdown.config.ts index 881a45d..9c80b5e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -211,6 +211,11 @@ function chunkBundle(name: string): UserConfig { inputOptions: { resolve: { conditionNames: ['browser', 'import', 'require', 'default'], + // The icon chunks import a handful of glyphs from the two react-icons + // packs; without this alias the pack's unshakeable CJS entry pulls in + // the whole set (6.4 MB for the file-icons chunk). Same reason the + // core bundle pins it. + ...(name === 'file-icons' ? { alias: REACT_ICONS_ESM_ALIAS } : {}), }, }, noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true), @@ -324,7 +329,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin { } /** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */ -const CHUNKS = ['terminal', 'editor', 'mermaid', 'locale'] +const CHUNKS = ['terminal', 'editor', 'mermaid', 'locale', 'file-icons'] export default [ {