From a9a635138a57dccffd66d93d308bd5035642687b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 8 Sep 2026 21:24:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(file-icons):=20FileIconDescriptor=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20names=20/=20folderNames=20=E4=B8=8E=20open?= =?UTF-8?q?=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 图标主题(VSCode 的 fileNames / folderNames 半边)需要按**文件名**和**目录名** 着色,扩展名规则表达不了 `package.json` / `Dockerfile` / `node_modules`。 - `names?: readonly string[]`:精确 basename(大小写不敏感),命中优先于扩展名。 - `folderNames?: readonly string[]`:精确目录 basename,命中优先于保留扩展名; 只认领列出的目录,未列出的目录仍走 `'folder'`/`'folder-open'` 或内置 glyph。 - `exts` 变为可选:省略 = 没有扩展名规则(`names`-only 注册不再是 catch-all); `exts: []` 仍是全局默认 catch-all,语义不变。 - `icon(path, size, open?)`:目录行的展开态传入工厂,一条注册即可渲染开/合两态。 - `matchFileIcon` 先 names 后 exts;`matchFolderIcon(open, name?)` 先 folderNames 后保留扩展名(新参数可选,旧调用不变)。 - 指南 §7 同步:字段、匹配顺序、四例注册(names / folderNames / 全目录 / catch-all)。 测试:`tests/file-icons.spec.tsx` 19 → 23(names 大小写、name 优先于 ext、 folderNames 只认领列出目录、open 传入工厂);`tests/consumer-types.ts` 补新字段。 --- docs/external-plugin-guide.md | 62 +++++++++++----- src/client/service.ts | 129 ++++++++++++++++++++++------------ tests/consumer-types.ts | 7 +- tests/file-icons.spec.tsx | 51 ++++++++++++++ 4 files changed, 186 insertions(+), 63 deletions(-) diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index a01fed1..058c21f 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -557,19 +557,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 @@ -646,14 +649,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 } ``` @@ -670,9 +682,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(() => @@ -688,10 +714,10 @@ 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 后跳到回退链下一级),不会空白行。 diff --git a/src/client/service.ts b/src/client/service.ts index 87bd6c2..b733cad 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -27,7 +27,7 @@ import { type SidebarSnapshot, type SidebarState, type SidebarStore, type SidebarTab, } from './state.ts' import { isNarrowWidth } from './breakpoints.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' @@ -339,15 +339,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 } /** @@ -389,23 +410,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`. @@ -415,9 +439,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). */ @@ -555,7 +581,8 @@ export const SIDEBAR_SERVICE_VERSION = '0.18.1' * id focus targets RAISE the floating window (never duplicate the tab or * expand panels), closeTab on a floating tab closes it with its window. * - '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, by extension + * (`exts`), exact file name (`names`), or directory name (`folderNames`). */ export const SIDEBAR_FEATURES = [ 'badge', @@ -655,45 +682,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) { @@ -703,7 +745,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 } @@ -711,13 +753,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/tests/consumer-types.ts b/tests/consumer-types.ts index f35f92d..4d0d06b 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-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())