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())