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 版本**:支持的 DSH 版本(v0.18.1 正式版):0.1.2-rc.1+ · 完整发布历史见 [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', () => {