diff --git a/README.md b/README.md
index a39511d..d5019ca 100644
--- a/README.md
+++ b/README.md
@@ -263,6 +263,10 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
**支持的 DSH 版本**:
· 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
+### 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)。
+
### v0.18.1
> 📌 **正式版**(npm `latest`):DSH 基线不变(**0.1.2-rc.1+**,peer 下限 `^0.1.2-rc.1`)——本版是 v0.18.0 之后的增量发布:变更面板预览能力增强、文件树可写,以及五项修复。
@@ -284,10 +288,6 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar
- ESLint flat config 接入 CI 与 Makefile(#536)、Makefile 命令面规范化(#526)、e2e 脚本加固(#527)、共享组件测试工具收敛样板(#524)
- 重构:Sidebar.tsx 按关注点拆分(#542)、四处轮询习语收敛到 `use-polling`(#541)、删除 rc.7 宿主的 `__DSH_MODULES__` 回退路径(#540)、One Dark/Light 语法色板单源化(#534)、重复实现收敛与死代码清理(#525)
-### 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)。
-
### v0.18.0
> 📌 **正式版**(npm `latest`):本版仅支持 **DSH 0.1.2-rc.1+**(peer 下限 `^0.1.2-rc.1`);不再支持 0.1.0-rc.8 ~ 0.1.1-rc.2——DSH stable 用户请固定安装 `dsh-better-sidebar@0.17.1`,停留在 0.1.2-alpha.x 的宿主继续用 `dsh-better-sidebar@alpha`(v0.18.0-alpha.0)。
diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx
index 375723c..ef09235 100644
--- a/src/client/FileTree.tsx
+++ b/src/client/FileTree.tsx
@@ -28,7 +28,7 @@ import {
IconEditOutline16, IconLinkOutline16, IconTrashOutline16, Menu, Modal, type MenuEntry, type MenuItem, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { SiCursor, SiZedindustries } from 'react-icons/si'
-import { VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
+import { VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc'
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx'
@@ -580,8 +580,8 @@ export function FileTree(props: {
style={{ paddingLeft: depth * 22 + 6 }}
>
{entry.isDir
- ? (expandedSet.has(entry.path) ? : )
- : }
+ ? dirRowIcon(entry.path, expandedSet.has(entry.path))
+ : fileRowIcon(entry.path)}
],
diff --git a/tests/consumer-types.ts b/tests/consumer-types.ts
index 19fe249..f35f92d 100644
--- a/tests/consumer-types.ts
+++ b/tests/consumer-types.ts
@@ -12,12 +12,15 @@
*/
import type {} from '../src/client/service.ts'
import {
+ FOLDER_EXT,
+ FOLDER_OPEN_EXT,
SIDEBAR_FEATURES,
SIDEBAR_SERVICE_VERSION,
} from '../src/client/service.ts'
import type {
BetterSidebarService,
FileFetchStrategy,
+ FileIconDescriptor,
FileViewerDescriptor,
FileViewerProps,
OpenTabSeed,
@@ -125,6 +128,23 @@ service.updateTab('tab:1', { title: 'T', path: '/p', meta: 1 })
service.activateTab('tab:1')
service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data')
+/** File-icon registration surface (feature `fileIcons`). */
+const icon: FileIconDescriptor = {
+ id: 'my-plugin:icons',
+ exts: ['csv', FOLDER_EXT, FOLDER_OPEN_EXT],
+ priority: 5,
+ icon: (path: string, size: number) => {
+ void path; void size
+ return null
+ },
+}
+service.registerFileIcon(icon)
+service.getFileIcons()
+service.matchFileIcon('/p/a.csv')
+service.matchFolderIcon(true)
+service.fileIcon('/p/a.csv', 14)
+service.folderIcon('/p', true, 14)
+
/** Named state vocabulary stays importable (the pre-0.12 gap). */
const diff: SidebarDiffRef = { kind: 'worktree', path: '/p/a.ts', staged: false }
const prefs: SidebarPrefs = { ...({} as SidebarPrefs) }
diff --git a/tests/file-icons.spec.tsx b/tests/file-icons.spec.tsx
index 400ed37..942cb32 100644
--- a/tests/file-icons.spec.tsx
+++ b/tests/file-icons.spec.tsx
@@ -35,7 +35,9 @@ const glyphOf = (node: ReactNode): unknown => (node as ReactElement).type
/** A marker component standing in for a plugin's registered icon. */
const marker = (): ReactNode =>
-const markerB = (): ReactNode =>
+/** A second marker with a DISTINCT element type, so `glyphOf` can tell the
+ * two registrations apart in priority/ordering assertions. */
+const markerB = (): ReactNode =>
describe('file icon registration API', () => {
it('registerFileIcon adds to the registry and dispose removes it', () => {
@@ -80,6 +82,22 @@ describe('file icon registration API', () => {
it('the feature is advertised in SIDEBAR_FEATURES', () => {
expect(SIDEBAR_FEATURES.includes('fileIcons')).toBe(true)
})
+
+ it('register and dispose notify subscribers (mounted rows re-resolve, no reload)', () => {
+ const service = createBetterSidebarService(createSidebarStore())
+ let notified = 0
+ const unsubscribe = service.subscribe(() => { notified += 1 })
+ const dispose = service.registerFileIcon({ id: 'test:icons', exts: ['csv'], icon: marker })
+ expect(notified).toBe(1)
+ dispose()
+ expect(notified).toBe(2)
+ // A second dispose is a no-op — no spurious notification.
+ dispose()
+ expect(notified).toBe(2)
+ unsubscribe()
+ service.registerFileIcon({ id: 'test:other', exts: ['tsv'], icon: marker })
+ expect(notified).toBe(2)
+ })
})
describe('fileIcon resolver chain (specific → builtin → catch-all → VscFile)', () => {
@@ -102,6 +120,23 @@ describe('fileIcon resolver chain (specific → builtin → catch-all → VscFil
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'all-a', exts: [], icon: marker })
service.registerFileIcon({ id: 'all-b', exts: [], priority: 5, icon: markerB })
+ // markerB ('b') is the higher-priority catch-all — not merely "some span".
+ expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('b')
+ const tie = createBetterSidebarService(createSidebarStore())
+ tie.registerFileIcon({ id: 'first', exts: [], icon: marker })
+ tie.registerFileIcon({ id: 'second', exts: [], icon: markerB })
+ // Equal priority keeps registration order (the first-registered wins).
+ expect(glyphOf(tie.fileIcon('/w/Makefile', 14))).toBe('span')
+ })
+
+ it('a factory returning undefined declines that link and the chain continues', () => {
+ const service = createBetterSidebarService(createSidebarStore())
+ service.registerFileIcon({ id: 'md-decline', exts: ['md'], icon: () => undefined })
+ // The specific registration declined → the built-in glyph claims it.
+ expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown)
+ service.registerFileIcon({ id: 'all-decline', exts: [], priority: 10, icon: () => undefined })
+ service.registerFileIcon({ id: 'all-take', exts: [], icon: marker })
+ // The declining catch-all is skipped, the next one takes the row.
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span')
})
@@ -153,9 +188,10 @@ describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)
// The OPEN row is a separate reserved ext — still the builtin here.
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe(VscFolderOpened)
service.registerFileIcon({ id: 'open', exts: ['folder-open'], icon: markerB })
- expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe('span')
+ expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe('b')
service.registerFileIcon({ id: 'closed-hi', exts: ['folder'], priority: 3, icon: markerB })
- expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('span')
+ // The higher-priority registration ('b') takes the closed row.
+ expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('b')
})
it('a catch-all registration never claims a directory', () => {