From 05773e0f2abfc04c971173973d7bc098462fb29b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 16:03:41 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(file-icons):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=A0=91=E6=8C=89=E6=89=A9=E5=B1=95=E5=90=8D=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=20+=20=E5=AF=B9=E5=A4=96=20registerFileIcon?= =?UTF-8?q?=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增内置扩展名→glyph 映射(md/媒体/pdf/json/代码/配置/数据库/压缩包, 单色 currentColor 遵循皮肤契约,未知扩展回退 VscFile) - ctx.betterSidebar 新增 registerFileIcon/getFileIcons/matchFileIcon (priority 降序,同优先级 catch-all 垫底),features 增加 'fileIcons' - FileTree 文件行消费注册表:注册图标优先(可为彩色 ReactNode, 工厂抛错回退内置),注册/注销即时生效 - 同步 docs/external-plugin-guide.md §7 与 tests/file-icons.spec.ts --- docs/external-plugin-guide.md | 42 +++++++++++++- src/client/EditorHost.tsx | 2 + src/client/FileTree.tsx | 43 ++++++++++++++- src/client/TreePanel.tsx | 6 +- src/client/file-icons.tsx | 65 ++++++++++++++++++++++ src/client/service.ts | 69 +++++++++++++++++++++++ tests/file-icons.spec.ts | 101 ++++++++++++++++++++++++++++++++++ 7 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 src/client/file-icons.tsx create mode 100644 tests/file-icons.spec.ts diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index 0dac147..fe61fc7 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -549,10 +549,17 @@ interface BetterSidebarService { registerTab(descriptor: TabDescriptor): () => void /** 注册文件预览器;返回 disposer */ registerFileViewer(descriptor: FileViewerDescriptor): () => void + /** 注册自定义文件树图标(v0.19.0+,features 含 'fileIcons');返回 disposer */ + registerFileIcon(descriptor: FileIconDescriptor): () => void /** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */ getTabs(): readonly TabDescriptor[] /** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */ getFileViewers(): readonly FileViewerDescriptor[] + /** 当前已注册的文件图标描述符快照(v0.19.0+) */ + getFileIcons(): readonly FileIconDescriptor[] + /** 按 path 匹配已注册的文件图标(priority 降序;exts: [] 为 catch-all; + * 返回 undefined = 无人认领,调用方回退内置 glyph 再回退通用 VscFile) */ + matchFileIcon(path: string): FileIconDescriptor | undefined /** 按 id 查 tab 描述符 */ getTab(id: string): TabDescriptor | undefined /** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */ @@ -586,7 +593,8 @@ interface BetterSidebarService { readonly version: string /** 单调能力清单(只增不删):'badge' | 'tabLifecycle' | 'updateTab' | * 'openFile' | 'targetedOpen' | 'stateSubscription' | 'tabMeta' | - * 'pluginSettings' | 'urlTarget' | 'settingSelect' | 'floatWindows' + * 'pluginSettings' | 'urlTarget' | 'settingSelect' | 'floatWindows' | + * 'fileIcons' * ——用 `features.includes('xxx')` 按能力 gate。 */ readonly features: readonly string[] /** 当前快照:激活 sessionId + 其状态(面板几何/打开的 tabs/展开集)+ prefs。 @@ -618,8 +626,40 @@ interface OpenTabSeed { * undefined = 不改,null = 显式清除 */ meta?: unknown } + +/** 文件树图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */ +interface FileIconDescriptor { + /** 唯一 id(如 'my-plugin:icons') */ + id: string + /** 小写扩展名、不带前导点(如 ['csv','tsv']);[] = catch-all 匹配任意文件 */ + exts: readonly string[] + /** priority 高者胜,缺省 0;注册图标整体优先于内置 glyph 映射 */ + priority?: number + /** 尺寸感知的图标工厂(文件树当前以 size=14 渲染)。 + * 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是 + * 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。 */ + icon: (path: string, size: number) => ReactNode +} ``` +**文件树图标注册示例**(v0.19.0+;未注册的扩展名回退内置单色 glyph 映射—— +markdown/媒体/pdf/json/代码/配置/数据库/压缩包各有 glyph,再回退通用 `VscFile`): + +```ts +if (ctx.betterSidebar.features.includes('fileIcons')) { + ctx.effect(() => + ctx.betterSidebar.registerFileIcon({ + id: 'my-plugin:icons', + exts: ['csv', 'tsv'], + icon: (path, size) => , // 彩色也可以 + }) + ) +} +``` + +注册/注销即时生效(文件树订阅注册表变化自动重渲染);图标工厂抛错会被吞掉 +(console.error 后回退内置 glyph),不会空白文件树行。 + **版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级: ```ts diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index d09b281..f872f00 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -402,6 +402,7 @@ export function EditorHost(props: { onReferenceFile={onReferenceFile} onPathRenamed={onPathRenamed} onPathDeleted={onPathDeleted} + service={ctx.get('betterSidebar')} /> ) @@ -526,6 +527,7 @@ export function EditorHost(props: { onReferenceFile={onReferenceFile} onPathRenamed={onPathRenamed} onPathDeleted={onPathDeleted} + service={ctx.get('betterSidebar')} /> )} diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index d080d18..395f9f3 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -28,15 +28,17 @@ 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 { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' +import { VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts' import { FenceErrorNotice } from './FenceErrorNotice.tsx' +import { builtinFileIcon } from './file-icons.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' import { isImeComposition } from './ime-guard.ts' import { useSubmenuFlip } from './menu-flip.ts' import type { OpenWithTarget } from './open-with.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' +import type { BetterSidebarService } from './service.ts' import type { SidebarStore } from './state.ts' import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts' import css from './sidebar.module.css' @@ -145,9 +147,44 @@ export function FileTree(props: { onUploadRequest: (dir: string, items: UploadItem[]) => void /** True while an upload is in flight (drops are ignored). */ busy: boolean + /** + * The sidebar registry service: when present, externally registered file + * icons (`registerFileIcon`) outrank the built-in glyph map on file rows. + * Absent → the built-ins alone (the host always passes it today). + */ + service?: BetterSidebarService }) { - const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy } = props + const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, refreshTick, onUploadRequest, busy, service } = props const [data, setData] = useState>({}) + /** + * Registry revision for the file-icon feature: bumps on ANY registry + * change (register/dispose of tabs, viewers, or icons — one listener + * set) so rows re-resolve their icons. The value itself is unread; the + * state bump IS the re-render trigger. + */ + const [, setIconsVersion] = useState(0) + useEffect( + () => service?.subscribe(() => { setIconsVersion(version => version + 1) }), + [service], + ) + /** + * One file row's leading glyph: an externally registered icon + * (`registerFileIcon`, priority desc) wins; otherwise the built-in + * per-extension glyph map (monochrome VSCodicons), whose own fallback + * is the generic VscFile. A throwing registered factory is logged and + * skipped — a broken plugin icon must never blank the tree. + */ + const fileRowIcon = (path: string): ReactNode => { + const registered = service?.matchFileIcon(path) + if (registered !== undefined) { + try { + return registered.icon(path, 14) + } catch (error) { + console.error('[dsh-better-sidebar] file icon factory error:', error) + } + } + return builtinFileIcon(path, 14) + } const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) @@ -658,7 +695,7 @@ export function FileTree(props: { onDrop={(event) => { handleFileDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, false) }} > - + {fileRowIcon(entry.path)} {entry.name} {entry.isSymlink && } {rowActions(entry)} diff --git a/src/client/TreePanel.tsx b/src/client/TreePanel.tsx index e0b0309..8134ac1 100644 --- a/src/client/TreePanel.tsx +++ b/src/client/TreePanel.tsx @@ -19,6 +19,7 @@ import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react' import clsx from 'clsx' import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { api } from './api.ts' +import type { BetterSidebarService } from './service.ts' import type { SidebarStore } from './state.ts' import { FileTree } from './FileTree.tsx' import { IconUploadOutline16 } from './icons.tsx' @@ -70,8 +71,10 @@ export function TreePanel(props: { /** Full-window presentation: the panel fills its host instead of docking * at a fixed width. */ full?: boolean + /** The sidebar registry service (file-icon registrations; passed through to FileTree). */ + service?: BetterSidebarService }) { - const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full } = props + const { sessionId, cwd, store, expanded, revealed, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, openWithTargets, openWithPinned, openWithSsh, onOpenWith, onToggleOpenWithPin, onReferenceFile, onPathRenamed, onPathDeleted, full, service } = props const [query, setQuery] = useState('') const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null) const [error, setError] = useState(null) @@ -261,6 +264,7 @@ export function TreePanel(props: { refreshTick={refreshTick} onUploadRequest={startUpload} busy={busy} + service={service} /> ) : (
diff --git a/src/client/file-icons.tsx b/src/client/file-icons.tsx new file mode 100644 index 0000000..0574cd9 --- /dev/null +++ b/src/client/file-icons.tsx @@ -0,0 +1,65 @@ +/** + * Built-in per-extension file glyphs for the file tree (feature: file icons). + * + * All glyphs are VSCodicons consumed as `currentColor` monochrome — the same + * skin contract as every other icon this plugin draws (visual values ride the + * `--dsw-alias-*` tokens, never hardcoded colors). External plugins that want + * colorful icons register their own `FileIconDescriptor`s through + * `ctx.betterSidebar.registerFileIcon` (they own their colors); this map is + * only the fallback nobody claimed. + */ +import type { ReactNode } from 'react' +import { + VscDatabase, VscFile, VscFileCode, VscFileMedia, VscFilePdf, VscFileZip, + VscJson, VscLock, VscMarkdown, VscSettings, +} from 'react-icons/vsc' + +/** The generic file glyph — the fallback for every unclaimed extension. */ +export function fallbackFileIcon(size: number): ReactNode { + return +} + +/** One glyph shared by a group of extensions. */ +type GlyphGroup = [readonly string[], (size: number) => ReactNode] + +/** + * Extension → glyph groups, first match wins. Extensions are lowercase + * without the leading dot, matched case-insensitively by the caller's + * normalization. Groups are ordered specific-first; the trailing catch-all + * lives in `builtinFileIcon`, not here. + */ +const GROUPS: readonly GlyphGroup[] = [ + [['md', 'markdown', 'mdx'], (size) => ], + [['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tiff'], (size) => ], + [['pdf'], (size) => ], + [['json', 'jsonc', 'json5'], (size) => ], + [['html', 'htm', 'xhtml', 'xml', 'jsx', 'tsx'], (size) => ], + [['css', 'scss', 'sass', 'less', 'styl'], (size) => ], + [['js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'py', 'rs', 'go', 'java', 'kt', 'kts', 'swift', 'rb', 'php', 'c', 'h', 'cpp', 'hpp', 'cs', 'm', 'mm', 'scala', 'sh', 'bash', 'zsh', 'fish', 'lua', 'pl', 'r', 'jl', 'dart', 'ex', 'exs', 'erl', 'hs', 'clj', 'cljs', 'vb', 'fs'], (size) => ], + [['yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'editorconfig'], (size) => ], + [['sql', 'db', 'sqlite', 'sqlite3'], (size) => ], + [['lock'], (size) => ], + [['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar'], (size) => ], +] + +const LOOKUP: ReadonlyMap ReactNode> = new Map( + GROUPS.flatMap(([exts, glyph]) => exts.map((ext) => [ext, glyph] as const)), +) + +/** + * The built-in glyph for a path: its extension's group glyph, or the generic + * `VscFile` when nothing claims it. Pure and synchronous — the file tree row + * calls this on every render, so it stays a Map lookup. + */ +export function builtinFileIcon(path: string, size: number): ReactNode { + const glyph = LOOKUP.get(extOfPath(path)) + return glyph === undefined ? fallbackFileIcon(size) : glyph(size) +} + +/** The lowercase extension without leading dot ('' when none). */ +function extOfPath(path: string): string { + const at = path.lastIndexOf('.') + if (at === -1) return '' + const base = path.slice(at + 1).toLowerCase() + return base.includes('/') || base.includes('\\') ? '' : base +} diff --git a/src/client/service.ts b/src/client/service.ts index 8897e7d..b2407b7 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -324,6 +324,24 @@ export interface FileViewerDescriptor { component: (props: FileViewerProps) => ReactNode } +/** + * Describes one external file-icon registration (feature `fileIcons`). + * Registrations override the built-in per-extension glyph map for their + * extensions; unlike the built-ins (monochrome `currentColor` per the skin + * contract), a registration's icon may be ANY ReactNode — colored included — + * and the registering plugin owns how its colors behave across skins. + */ +export interface FileIconDescriptor { + /** Unique id (`'my-plugin:icons'`). */ + id: string + /** Lowercase extensions without leading dot (`['csv','tsv']`). `[]` = match any (catch-all). */ + exts: readonly string[] + /** Higher wins; default 0. Registered icons always outrank the built-in map. */ + priority?: number + /** Size-aware icon factory (the tree renders at 14 today). */ + icon: (path: string, size: number) => ReactNode +} + /** One `openTab` request. */ export interface OpenTabSeed { type: string @@ -347,8 +365,17 @@ export interface OpenTabSeed { export interface BetterSidebarService { registerTab(descriptor: TabDescriptor): () => void registerFileViewer(descriptor: FileViewerDescriptor): () => void + registerFileIcon(descriptor: FileIconDescriptor): () => void getTabs(): readonly TabDescriptor[] getFileViewers(): readonly FileViewerDescriptor[] + getFileIcons(): readonly FileIconDescriptor[] + /** + * Find a registered file icon for a path (priority desc, then registration + * order; `exts: []` is a catch-all). Undefined means "no registration + * claims it" — the caller falls back to the built-in glyph map, then the + * generic `VscFile`. + */ + matchFileIcon(path: string): FileIconDescriptor | undefined /** Find a tab descriptor by id (undefined if not registered). */ getTab(id: string): TabDescriptor | undefined /** @@ -483,6 +510,8 @@ export const SIDEBAR_SERVICE_VERSION = '0.18.1' * - 'floatWindows' (v0.16.0): tabs float as free windows — openTab's dedupe/ * 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. */ export const SIDEBAR_FEATURES = [ 'badge', @@ -496,6 +525,7 @@ export const SIDEBAR_FEATURES = [ 'urlTarget', 'settingSelect', 'floatWindows', + 'fileIcons', ] as const /** Run one plugin callback; a throw is logged and never breaks the caller. */ @@ -515,6 +545,7 @@ function safeCall(fn: () => void): void { export function createBetterSidebarService(store: SidebarStore): BetterSidebarService { const tabs = new Map() const viewers = new Map() + const fileIcons = new Map() const listeners = new Set<() => void>() const notify = (): void => { @@ -556,8 +587,43 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values()) const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values()) + const getFileIcons = (): readonly FileIconDescriptor[] => Array.from(fileIcons.values()) const getTab = (id: string): TabDescriptor | undefined => tabs.get(id) + const registerFileIcon = (descriptor: FileIconDescriptor): (() => void) => { + if (fileIcons.has(descriptor.id)) { + throw new Error(`[dsh-better-sidebar] file icons "${descriptor.id}" already registered`) + } + fileIcons.set(descriptor.id, descriptor) + notify() + return () => { + if (fileIcons.get(descriptor.id) === descriptor) { + fileIcons.delete(descriptor.id) + notify() + } + } + } + + // Priority desc, stable for equal priorities (insertion order) — the same + // ranking `matchFileViewer` uses, plus one tie-break: at EQUAL priority a + // catch-all (`exts: []`) sorts behind specific descriptors, so a + // same-priority `['md']` registration is never shadowed by an earlier + // registered catch-all. This never consults the built-in glyph map: an + // undefined result IS the "fall through to the built-ins" signal. + const matchFileIcon = (path: string): FileIconDescriptor | undefined => { + const ext = extOfPath(path) + for (const d of Array.from(fileIcons.values()).sort((a, b) => { + const byPriority = (b.priority ?? 0) - (a.priority ?? 0) + if (byPriority !== 0) return byPriority + const aCatch = a.exts.length === 0 ? 1 : 0 + const bCatch = b.exts.length === 0 ? 1 : 0 + return aCatch - bCatch + })) { + if (d.exts.length === 0 || d.exts.includes(ext)) return d + } + return undefined + } + // The enable switches come from the user's side card prefs (the shared // store the service is bound to): an absent key means enabled. const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false @@ -813,8 +879,11 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe return { registerTab, registerFileViewer, + registerFileIcon, getTabs, getFileViewers, + getFileIcons, + matchFileIcon, getTab, isTabEnabled, isViewerEnabled, diff --git a/tests/file-icons.spec.ts b/tests/file-icons.spec.ts new file mode 100644 index 0000000..b866ece --- /dev/null +++ b/tests/file-icons.spec.ts @@ -0,0 +1,101 @@ +/** + * Tests for the file-icon feature: the external registration API + * (registerFileIcon / matchFileIcon — registry lifecycle and ranking) and + * the built-in per-extension glyph map (extension normalization, group + * hits, and the generic VscFile fallback). + */ +import { describe, it, expect } from 'vitest' +import type { ReactElement } from 'react' +import { VscFile, VscFileCode, VscFileMedia, VscJson, VscMarkdown } 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 { createBetterSidebarService, SIDEBAR_FEATURES } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' +import { builtinFileIcon, fallbackFileIcon } from '../src/client/file-icons.tsx' + +describe('file icon registration API', () => { + it('registerFileIcon adds to the registry and dispose removes it', () => { + const service = createBetterSidebarService(createSidebarStore()) + expect(service.getFileIcons()).toHaveLength(0) + const dispose = service.registerFileIcon({ + id: 'test:icons', + exts: ['csv'], + icon: () => null, + }) + expect(service.getFileIcons()).toHaveLength(1) + expect(service.matchFileIcon('/w/a.csv')?.id).toBe('test:icons') + dispose() + expect(service.getFileIcons()).toHaveLength(0) + expect(service.matchFileIcon('/w/a.csv')).toBeUndefined() + }) + + it('registerFileIcon throws on duplicate id', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'dup', exts: [], icon: () => null }) + expect(() => service.registerFileIcon({ id: 'dup', exts: [], icon: () => null })).toThrow() + }) + + it('matches by extension (case-insensitive via path normalization)', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'csv', exts: ['csv'], icon: () => null }) + expect(service.matchFileIcon('/w/data.csv')?.id).toBe('csv') + expect(service.matchFileIcon('/w/DATA.CSV')?.id).toBe('csv') + expect(service.matchFileIcon('/w/a.tsv')).toBeUndefined() + }) + + it('higher priority wins on extension conflict; ties keep registration order', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'low', exts: ['csv'], icon: () => null }) + service.registerFileIcon({ id: 'high', exts: ['csv'], priority: 10, icon: () => null }) + service.registerFileIcon({ id: 'tie', exts: ['csv'], icon: () => null }) + expect(service.matchFileIcon('/w/a.csv')?.id).toBe('high') + const fresh = createBetterSidebarService(createSidebarStore()) + fresh.registerFileIcon({ id: 'first', exts: ['csv'], icon: () => null }) + fresh.registerFileIcon({ id: 'second', exts: ['csv'], icon: () => null }) + expect(fresh.matchFileIcon('/w/a.csv')?.id).toBe('first') + }) + + it('exts: [] is a catch-all at the bottom (specific exts still win)', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'all', exts: [], icon: () => null }) + service.registerFileIcon({ id: 'md', exts: ['md'], icon: () => null }) + expect(service.matchFileIcon('/w/README.md')?.id).toBe('md') + expect(service.matchFileIcon('/w/Makefile')?.id).toBe('all') + }) + + it('the feature is advertised in SIDEBAR_FEATURES', () => { + expect(SIDEBAR_FEATURES.includes('fileIcons')).toBe(true) + }) +}) + +describe('built-in glyph map (builtinFileIcon)', () => { + it('maps representative extensions to their group glyph', () => { + expect((builtinFileIcon('/w/README.md', 14) as ReactElement).type).toBe(VscMarkdown) + expect((builtinFileIcon('/w/logo.png', 14) as ReactElement).type).toBe(VscFileMedia) + expect((builtinFileIcon('/w/logo.SVG', 14) as ReactElement).type).toBe(VscFileMedia) + expect((builtinFileIcon('/w/pkg.json', 14) as ReactElement).type).toBe(VscJson) + expect((builtinFileIcon('/w/main.ts', 14) as ReactElement).type).toBe(VscFileCode) + expect((builtinFileIcon('/w/main.py', 14) as ReactElement).type).toBe(VscFileCode) + }) + + it('falls back to the generic VscFile for unknown extensions and paths without one', () => { + expect((builtinFileIcon('/w/data.xyzunknown', 14) as ReactElement).type).toBe(VscFile) + expect((builtinFileIcon('/w/Makefile', 14) as ReactElement).type).toBe(VscFile) + expect((fallbackFileIcon(14) as ReactElement).type).toBe(VscFile) + }) +}) From 8025b83a453eba2dc01bb12375724395f0dd1bfd Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Thu, 3 Sep 2026 17:29:01 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(file-icons):=20=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E6=89=A9=E5=B1=95=E5=90=8D=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=20+=20=E5=85=A8=E5=B1=80=E9=BB=98=E8=AE=A4=E5=85=9C=E5=BA=95?= =?UTF-8?q?=20+=20=E7=BC=96=E8=BE=91=E5=99=A8=E6=96=87=E4=BB=B6=20tab=20?= =?UTF-8?q?=E5=AF=B9=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerFileIcon 保留 'folder'/'folder-open' 特殊扩展名:目录行(含根行) 图标可注册,匹配保留值不认领真实 .folder 文件 - 新增权威解析器 fileIcon(path,size)(具体注册 → 内置 glyph → catch-all 全局默认 → VscFile,逐工厂崩溃隔离)与 folderIcon(path,open,size); matchFileIcon 收窄为仅具体扩展名匹配 - 编辑器文件 tab(非 meta.dir)与文件树行显示同一 fileIcon 图标 - 同步 external-plugin-guide §7(保留值/回退链/三例注册)与 README (功能一览、v0.19.0-alpha.0 发布注记、服务化扩展章节) - tests/file-icons.spec.tsx 重写为 17 用例(链序/保留值/目录/崩溃回退) --- README.md | 8 +- docs/external-plugin-guide.md | 58 ++++++++-- src/client/FileTree.tsx | 35 +++--- src/client/Sidebar.tsx | 10 +- src/client/file-icons.tsx | 48 ++++++--- src/client/service.ts | 144 +++++++++++++++++++++---- tests/file-icons.spec.ts | 101 ------------------ tests/file-icons.spec.tsx | 195 ++++++++++++++++++++++++++++++++++ 8 files changed, 427 insertions(+), 172 deletions(-) delete mode 100644 tests/file-icons.spec.ts create mode 100644 tests/file-icons.spec.tsx diff --git a/README.md b/README.md index 95f54e4..a39511d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `
` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF +- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红;文件树与文件 tab 按扩展名显示图标——markdown / 图片 / PDF / 代码 / 配置 / 压缩包等各有 glyph,插件可经 `registerFileIcon` 注册自定义图标与目录图标)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `
` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 @@ -284,6 +284,10 @@ 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)。 @@ -524,7 +528,7 @@ GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar ## 🔌 服务化扩展 -从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。 +从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 8 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。v0.19.0 起新增文件图标注册:`registerFileIcon` 按扩展名(或保留的 `'folder'` / `'folder-open'` 目录扩展名、`exts: []` 全局默认)替换文件树与文件 tab 的图标,彩色 ReactNode 亦可——内置消费、注册即生效,无需自己接线。 完整接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测、自由窗口与皮肤契约):**[`docs/external-plugin-guide.md`](./docs/external-plugin-guide.md)**;仓库开发规则(硬约束 / CI / 发版)见 [`AGENTS.md`](./AGENTS.md)。 diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index fe61fc7..a01fed1 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -549,7 +549,7 @@ interface BetterSidebarService { registerTab(descriptor: TabDescriptor): () => void /** 注册文件预览器;返回 disposer */ registerFileViewer(descriptor: FileViewerDescriptor): () => void - /** 注册自定义文件树图标(v0.19.0+,features 含 'fileIcons');返回 disposer */ + /** 注册自定义文件树/文件 tab 图标(v0.19.0+,features 含 'fileIcons');返回 disposer */ registerFileIcon(descriptor: FileIconDescriptor): () => void /** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */ getTabs(): readonly TabDescriptor[] @@ -557,9 +557,20 @@ interface BetterSidebarService { getFileViewers(): readonly FileViewerDescriptor[] /** 当前已注册的文件图标描述符快照(v0.19.0+) */ getFileIcons(): readonly FileIconDescriptor[] - /** 按 path 匹配已注册的文件图标(priority 降序;exts: [] 为 catch-all; - * 返回 undefined = 无人认领,调用方回退内置 glyph 再回退通用 VscFile) */ + /** 按 path 匹配**具体扩展名**注册(priority 降序、注册序;不查 catch-all + * 与 folder 保留值)。消费方一般直接用 fileIcon/folderIcon 全链解析器。 */ matchFileIcon(path: string): FileIconDescriptor | undefined + /** 匹配目录行注册('folder'/'folder-open' 保留扩展名;priority 降序、注册序; + * 返回 undefined = 回退内置 VscFolder/VscFolderOpened) */ + matchFolderIcon(open: boolean): FileIconDescriptor | undefined + /** 文件图标权威解析器(v0.19.0+),完整回退链: + * ① 具体扩展名注册 → ② 内置 glyph(md/媒体/pdf/json/代码/配置/数据库/lock/压缩包) + * → ③ 最优 catch-all 注册(exts: [],即全局默认)→ ④ 通用 VscFile。 + * 任一注册工厂抛错都会被吞(console.error 后跳下一级),永远返回有效 ReactNode。 */ + fileIcon(path: string, size: number): ReactNode + /** 目录图标解析器:注册的 'folder'(闭合)/'folder-open'(展开)图标 → + * 内置 VscFolder/VscFolderOpened;path 为目录自身路径(主题可按目录变化)。 */ + folderIcon(path: string, open: boolean, size: number): ReactNode /** 按 id 查 tab 描述符 */ getTab(id: string): TabDescriptor | undefined /** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */ @@ -627,23 +638,26 @@ interface OpenTabSeed { meta?: unknown } -/** 文件树图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */ +/** 文件图标注册描述符(v0.19.0+,features 含 'fileIcons')。 */ interface FileIconDescriptor { /** 唯一 id(如 'my-plugin:icons') */ id: string - /** 小写扩展名、不带前导点(如 ['csv','tsv']);[] = catch-all 匹配任意文件 */ + /** 小写扩展名、不带前导点(如 ['csv','tsv'])。两个**保留值**认领目录行 + * 而非文件扩展名:'folder'(闭合目录)、'folder-open'(展开目录)—— + * 它们不会匹配真实文件(名为 x.folder 的文件不受影响)。 + * [] = catch-all 全局默认:只兜内置 glyph 没认领的扩展名(注册的具体 + * 扩展名与内置 glyph 永远优先于它)。 */ exts: readonly string[] - /** priority 高者胜,缺省 0;注册图标整体优先于内置 glyph 映射 */ + /** priority 高者胜,缺省 0(同级按注册先后) */ priority?: number - /** 尺寸感知的图标工厂(文件树当前以 size=14 渲染)。 + /** 尺寸感知的图标工厂(文件树/文件 tab 当前以 size=14 渲染)。 * 与内置图标(currentColor 单色,遵循皮肤契约)不同,注册图标可以是 * 任意 ReactNode——包括彩色图标;颜色在皮肤间的表现由注册方自行负责。 */ icon: (path: string, size: number) => ReactNode } ``` -**文件树图标注册示例**(v0.19.0+;未注册的扩展名回退内置单色 glyph 映射—— -markdown/媒体/pdf/json/代码/配置/数据库/压缩包各有 glyph,再回退通用 `VscFile`): +**图标注册示例**(v0.19.0+;一次注册可同时覆盖具体扩展名、目录与全局默认): ```ts if (ctx.betterSidebar.features.includes('fileIcons')) { @@ -654,11 +668,33 @@ if (ctx.betterSidebar.features.includes('fileIcons')) { icon: (path, size) => , // 彩色也可以 }) ) + ctx.effect(() => + ctx.betterSidebar.registerFileIcon({ + id: 'my-plugin:folders', + exts: ['folder'], // 闭合目录行;['folder-open'] 认领展开行—— + icon: (path, size) => , // 开/合图标不同就注册两条 + }) + ) + ctx.effect(() => + ctx.betterSidebar.registerFileIcon({ + id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件 + exts: [], + icon: (path, size) => , + }) + ) } ``` -注册/注销即时生效(文件树订阅注册表变化自动重渲染);图标工厂抛错会被吞掉 -(console.error 后回退内置 glyph),不会空白文件树行。 +**消费表面与回退链**(由本插件内置消费,插件无需自己接线): + +- 文件树文件行 / 编辑器文件 tab(每个文件独立窗口):`fileIcon(path, size)` + ——具体扩展名注册 → 内置 glyph(markdown/媒体/pdf/json/代码/配置/数据库/lock/压缩包) + → catch-all 全局默认 → 通用 `VscFile`。 +- 文件树目录行(含根行):`folderIcon(path, open, size)`——`'folder'`/`'folder-open'` + 注册 → 内置 `VscFolder`/`VscFolderOpened`。 + +注册/注销即时生效(文件树与 tab 栏订阅注册表变化自动重渲染);图标工厂抛错会被吞掉 +(console.error 后跳到回退链下一级),不会空白行。 **版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级: diff --git a/src/client/FileTree.tsx b/src/client/FileTree.tsx index 395f9f3..375723c 100644 --- a/src/client/FileTree.tsx +++ b/src/client/FileTree.tsx @@ -31,7 +31,7 @@ import { SiCursor, SiZedindustries } from 'react-icons/si' import { VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned } from 'react-icons/vsc' import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts' import { FenceErrorNotice } from './FenceErrorNotice.tsx' -import { builtinFileIcon } from './file-icons.tsx' +import { builtinFileIcon, builtinFolderIcon } from './file-icons.tsx' import { IconUploadOutline16, IconVscode16 } from './icons.tsx' import { isImeComposition } from './ime-guard.ts' import { useSubmenuFlip } from './menu-flip.ts' @@ -168,23 +168,20 @@ export function FileTree(props: { [service], ) /** - * One file row's leading glyph: an externally registered icon - * (`registerFileIcon`, priority desc) wins; otherwise the built-in - * per-extension glyph map (monochrome VSCodicons), whose own fallback - * is the generic VscFile. A throwing registered factory is logged and - * skipped — a broken plugin icon must never blank the tree. + * One file row's leading glyph. The service resolver owns the whole + * chain (registered specific ext → built-in glyph map → registered + * global default → stock VscFile, with per-factory crash isolation); + * without a service the built-in map alone applies. */ - const fileRowIcon = (path: string): ReactNode => { - const registered = service?.matchFileIcon(path) - if (registered !== undefined) { - try { - return registered.icon(path, 14) - } catch (error) { - console.error('[dsh-better-sidebar] file icon factory error:', error) - } - } - return builtinFileIcon(path, 14) - } + const fileRowIcon = (path: string): ReactNode => + service !== undefined ? service.fileIcon(path, 14) : builtinFileIcon(path, 14) + + /** + * One directory row's leading glyph: the registered `'folder'` / + * `'folder-open'` icon when present, else the built-in VSCodicons glyphs. + */ + const dirRowIcon = (path: string, open: boolean): ReactNode => + service !== undefined ? service.folderIcon(path, open, 14) : builtinFolderIcon(open, 14) const dataRef = useRef(data) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) @@ -662,7 +659,7 @@ export function FileTree(props: { onDrop={(event) => { handleDirDrop(event, entry.path) }} onContextMenu={(event) => { openRowMenu(event, entry.path, true) }} > - {isOpen ? : } + {dirRowIcon(entry.path, isOpen)} {entry.name} {entry.isSymlink && } {rowActions(entry)} @@ -740,7 +737,7 @@ export function FileTree(props: { onDrop={(event) => { handleDirDrop(event, root) }} onContextMenu={(event) => { openRowMenu(event, root, true) }} > - + {dirRowIcon(root, true)} {baseName(root)} {copiedPath === root ? {t('copied')} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index f20ce7a..ce45389 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -813,8 +813,16 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * ctx and the conversation input service at click time; a missing service * or scope degrades to a logged no-op, never a crash. */ - /** The tab icon from the tab-type registry (shared by every workbench). */ + /** + * The tab icon: an editor tab WITH a file path (the per-path windows of + * split mode — `meta.dir` marks folder windows, which keep the folder + * glyph) shows the same file icon the tree row shows (`fileIcon`, feature + * `fileIcons`); every other tab uses its tab-type descriptor icon. + */ const tabIconOf = (tab: SidebarTab): ReactNode => { + if (tab.type === 'editor' && tab.path !== undefined && (tab.meta as { dir?: boolean } | undefined)?.dir !== true) { + return ctx.get('betterSidebar')?.fileIcon(tab.path, 14) ?? null + } const descriptor = ctx.get('betterSidebar')?.getTab(tab.type) if (descriptor === undefined) return null return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon diff --git a/src/client/file-icons.tsx b/src/client/file-icons.tsx index 0574cd9..7ea7d05 100644 --- a/src/client/file-icons.tsx +++ b/src/client/file-icons.tsx @@ -11,22 +11,31 @@ import type { ReactNode } from 'react' import { VscDatabase, VscFile, VscFileCode, VscFileMedia, VscFilePdf, VscFileZip, - VscJson, VscLock, VscMarkdown, VscSettings, + VscFolder, VscFolderOpened, VscJson, VscLock, VscMarkdown, VscSettings, } from 'react-icons/vsc' +import { extOf } from './paths.ts' + +/** One glyph shared by a group of extensions. */ +type GlyphFactory = (size: number) => ReactNode /** The generic file glyph — the fallback for every unclaimed extension. */ export function fallbackFileIcon(size: number): ReactNode { return } +/** The built-in directory glyphs (what a folder row shows unregistered). */ +export function builtinFolderIcon(open: boolean, size: number): ReactNode { + return open ? : +} + /** One glyph shared by a group of extensions. */ -type GlyphGroup = [readonly string[], (size: number) => ReactNode] +type GlyphGroup = [readonly string[], GlyphFactory] /** * Extension → glyph groups, first match wins. Extensions are lowercase * without the leading dot, matched case-insensitively by the caller's - * normalization. Groups are ordered specific-first; the trailing catch-all - * lives in `builtinFileIcon`, not here. + * normalization. The reserved folder values (`'folder'`/`'folder-open'`) + * never appear here — directories resolve through `builtinFolderIcon`. */ const GROUPS: readonly GlyphGroup[] = [ [['md', 'markdown', 'mdx'], (size) => ], @@ -42,24 +51,29 @@ const GROUPS: readonly GlyphGroup[] = [ [['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar'], (size) => ], ] -const LOOKUP: ReadonlyMap ReactNode> = new Map( +const LOOKUP: ReadonlyMap = new Map( GROUPS.flatMap(([exts, glyph]) => exts.map((ext) => [ext, glyph] as const)), ) /** - * The built-in glyph for a path: its extension's group glyph, or the generic - * `VscFile` when nothing claims it. Pure and synchronous — the file tree row - * calls this on every render, so it stays a Map lookup. + * The built-in glyph FACTORY claiming a path's extension, or undefined when + * the extension is unclaimed (the caller decides the fallback — the generic + * `VscFile` or a registered catch-all). Exposed as a separate function so + * the service's resolver can distinguish "builtin claims it" from "builtin + * is just its own fallback" without comparing ReactNodes by reference. */ -export function builtinFileIcon(path: string, size: number): ReactNode { - const glyph = LOOKUP.get(extOfPath(path)) - return glyph === undefined ? fallbackFileIcon(size) : glyph(size) +export function builtinFileIconOf(path: string): GlyphFactory | undefined { + return LOOKUP.get(extOf(path)) } -/** The lowercase extension without leading dot ('' when none). */ -function extOfPath(path: string): string { - const at = path.lastIndexOf('.') - if (at === -1) return '' - const base = path.slice(at + 1).toLowerCase() - return base.includes('/') || base.includes('\\') ? '' : base +/** + * The built-in glyph for a path: its extension's group glyph, or the generic + * `VscFile` when nothing claims it. Pure and synchronous — callers run this + * on every render, so it stays a Map lookup. The no-service fallback path + * for surfaces without the registry (and the last-but-one link of the + * service's `fileIcon` chain). + */ +export function builtinFileIcon(path: string, size: number): ReactNode { + const glyph = LOOKUP.get(extOf(path)) + return glyph === undefined ? fallbackFileIcon(size) : glyph(size) } diff --git a/src/client/service.ts b/src/client/service.ts index b2407b7..87bd6c2 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -28,6 +28,7 @@ import { } from './state.ts' import { isNarrowWidth } from './breakpoints.ts' import { 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' @@ -334,14 +335,31 @@ export interface FileViewerDescriptor { export interface FileIconDescriptor { /** Unique id (`'my-plugin:icons'`). */ id: string - /** Lowercase extensions without leading dot (`['csv','tsv']`). `[]` = match any (catch-all). */ + /** + * 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`. + */ exts: readonly string[] /** Higher wins; default 0. Registered icons always outrank the built-in map. */ priority?: number - /** Size-aware icon factory (the tree renders at 14 today). */ + /** Size-aware icon factory (the tree and file tabs render at 14 today). */ icon: (path: string, size: number) => ReactNode } +/** + * Reserved `exts` values that claim DIRECTORY rows instead of file + * extensions: `'folder'` matches a closed directory, `'folder-open'` an + * expanded one (`folderIcon(path, open)` resolves them). They are filtered out of + * real-extension matching, so a file literally named `x.folder` is NOT + * claimed by a folder registration. + */ +export const FOLDER_EXT = 'folder' as const +export const FOLDER_OPEN_EXT = 'folder-open' as const + /** One `openTab` request. */ export interface OpenTabSeed { type: string @@ -370,12 +388,38 @@ export interface BetterSidebarService { getFileViewers(): readonly FileViewerDescriptor[] getFileIcons(): readonly FileIconDescriptor[] /** - * Find a registered file icon for a path (priority desc, then registration - * order; `exts: []` is a catch-all). Undefined means "no registration - * claims it" — the caller falls back to the built-in glyph map, then the - * generic `VscFile`. + * 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 + * `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. + */ + matchFolderIcon(open: boolean): 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), + * 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`. + * A throwing factory is logged (console.error) and skipped — the caller + * always gets a valid ReactNode. + */ + 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`. + */ + folderIcon(path: string, open: boolean, size: number): ReactNode /** Find a tab descriptor by id (undefined if not registered). */ getTab(id: string): TabDescriptor | undefined /** @@ -604,26 +648,81 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe } } - // Priority desc, stable for equal priorities (insertion order) — the same - // ranking `matchFileViewer` uses, plus one tie-break: at EQUAL priority a - // catch-all (`exts: []`) sorts behind specific descriptors, so a - // same-priority `['md']` registration is never shadowed by an earlier - // registered catch-all. This never consults the built-in glyph map: an - // undefined result IS the "fall through to the built-ins" signal. + // Registrations in ranking order: priority desc, stable for equal + // priorities (insertion order) — the same ranking `matchFileViewer` uses. + const rankedFileIcons = (): FileIconDescriptor[] => + Array.from(fileIcons.values()).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + + // 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. const matchFileIcon = (path: string): FileIconDescriptor | undefined => { - const ext = extOfPath(path) - for (const d of Array.from(fileIcons.values()).sort((a, b) => { - const byPriority = (b.priority ?? 0) - (a.priority ?? 0) - if (byPriority !== 0) return byPriority - const aCatch = a.exts.length === 0 ? 1 : 0 - const bCatch = b.exts.length === 0 ? 1 : 0 - return aCatch - bCatch - })) { - if (d.exts.length === 0 || d.exts.includes(ext)) return d + 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 } 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 => { + const want = open ? FOLDER_OPEN_EXT : FOLDER_EXT + for (const d of rankedFileIcons()) { + if (d.exts.includes(want)) return d + } + return undefined + } + + /** Run one registered factory; a throw is logged and returns undefined. */ + const safeIcon = (d: FileIconDescriptor, path: string, size: number): ReactNode => { + try { + return d.icon(path, size) + } 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". + const fileIcon = (path: string, size: number): ReactNode => { + const specific = matchFileIcon(path) + if (specific !== undefined) { + const icon = safeIcon(specific, path, size) + if (icon !== undefined) return icon + } + const builtin = builtinFileIconOf(path) + if (builtin !== undefined) return builtin(size) + for (const d of rankedFileIcons()) { + if (d.exts.length === 0) { + const icon = safeIcon(d, path, size) + if (icon !== undefined) return icon + } + } + 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). + const folderIcon = (path: string, open: boolean, size: number): ReactNode => { + const registered = matchFolderIcon(open) + if (registered !== undefined) { + const icon = safeIcon(registered, path, size) + if (icon !== undefined) return icon + } + return builtinFolderIcon(open, size) + } + // The enable switches come from the user's side card prefs (the shared // store the service is bound to): an absent key means enabled. const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false @@ -884,6 +983,9 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe getFileViewers, getFileIcons, matchFileIcon, + matchFolderIcon, + fileIcon, + folderIcon, getTab, isTabEnabled, isViewerEnabled, diff --git a/tests/file-icons.spec.ts b/tests/file-icons.spec.ts deleted file mode 100644 index b866ece..0000000 --- a/tests/file-icons.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Tests for the file-icon feature: the external registration API - * (registerFileIcon / matchFileIcon — registry lifecycle and ranking) and - * the built-in per-extension glyph map (extension normalization, group - * hits, and the generic VscFile fallback). - */ -import { describe, it, expect } from 'vitest' -import type { ReactElement } from 'react' -import { VscFile, VscFileCode, VscFileMedia, VscJson, VscMarkdown } 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 { createBetterSidebarService, SIDEBAR_FEATURES } from '../src/client/service.ts' -import { createSidebarStore } from '../src/client/state.ts' -import { builtinFileIcon, fallbackFileIcon } from '../src/client/file-icons.tsx' - -describe('file icon registration API', () => { - it('registerFileIcon adds to the registry and dispose removes it', () => { - const service = createBetterSidebarService(createSidebarStore()) - expect(service.getFileIcons()).toHaveLength(0) - const dispose = service.registerFileIcon({ - id: 'test:icons', - exts: ['csv'], - icon: () => null, - }) - expect(service.getFileIcons()).toHaveLength(1) - expect(service.matchFileIcon('/w/a.csv')?.id).toBe('test:icons') - dispose() - expect(service.getFileIcons()).toHaveLength(0) - expect(service.matchFileIcon('/w/a.csv')).toBeUndefined() - }) - - it('registerFileIcon throws on duplicate id', () => { - const service = createBetterSidebarService(createSidebarStore()) - service.registerFileIcon({ id: 'dup', exts: [], icon: () => null }) - expect(() => service.registerFileIcon({ id: 'dup', exts: [], icon: () => null })).toThrow() - }) - - it('matches by extension (case-insensitive via path normalization)', () => { - const service = createBetterSidebarService(createSidebarStore()) - service.registerFileIcon({ id: 'csv', exts: ['csv'], icon: () => null }) - expect(service.matchFileIcon('/w/data.csv')?.id).toBe('csv') - expect(service.matchFileIcon('/w/DATA.CSV')?.id).toBe('csv') - expect(service.matchFileIcon('/w/a.tsv')).toBeUndefined() - }) - - it('higher priority wins on extension conflict; ties keep registration order', () => { - const service = createBetterSidebarService(createSidebarStore()) - service.registerFileIcon({ id: 'low', exts: ['csv'], icon: () => null }) - service.registerFileIcon({ id: 'high', exts: ['csv'], priority: 10, icon: () => null }) - service.registerFileIcon({ id: 'tie', exts: ['csv'], icon: () => null }) - expect(service.matchFileIcon('/w/a.csv')?.id).toBe('high') - const fresh = createBetterSidebarService(createSidebarStore()) - fresh.registerFileIcon({ id: 'first', exts: ['csv'], icon: () => null }) - fresh.registerFileIcon({ id: 'second', exts: ['csv'], icon: () => null }) - expect(fresh.matchFileIcon('/w/a.csv')?.id).toBe('first') - }) - - it('exts: [] is a catch-all at the bottom (specific exts still win)', () => { - const service = createBetterSidebarService(createSidebarStore()) - service.registerFileIcon({ id: 'all', exts: [], icon: () => null }) - service.registerFileIcon({ id: 'md', exts: ['md'], icon: () => null }) - expect(service.matchFileIcon('/w/README.md')?.id).toBe('md') - expect(service.matchFileIcon('/w/Makefile')?.id).toBe('all') - }) - - it('the feature is advertised in SIDEBAR_FEATURES', () => { - expect(SIDEBAR_FEATURES.includes('fileIcons')).toBe(true) - }) -}) - -describe('built-in glyph map (builtinFileIcon)', () => { - it('maps representative extensions to their group glyph', () => { - expect((builtinFileIcon('/w/README.md', 14) as ReactElement).type).toBe(VscMarkdown) - expect((builtinFileIcon('/w/logo.png', 14) as ReactElement).type).toBe(VscFileMedia) - expect((builtinFileIcon('/w/logo.SVG', 14) as ReactElement).type).toBe(VscFileMedia) - expect((builtinFileIcon('/w/pkg.json', 14) as ReactElement).type).toBe(VscJson) - expect((builtinFileIcon('/w/main.ts', 14) as ReactElement).type).toBe(VscFileCode) - expect((builtinFileIcon('/w/main.py', 14) as ReactElement).type).toBe(VscFileCode) - }) - - it('falls back to the generic VscFile for unknown extensions and paths without one', () => { - expect((builtinFileIcon('/w/data.xyzunknown', 14) as ReactElement).type).toBe(VscFile) - expect((builtinFileIcon('/w/Makefile', 14) as ReactElement).type).toBe(VscFile) - expect((fallbackFileIcon(14) as ReactElement).type).toBe(VscFile) - }) -}) diff --git a/tests/file-icons.spec.tsx b/tests/file-icons.spec.tsx new file mode 100644 index 0000000..400ed37 --- /dev/null +++ b/tests/file-icons.spec.tsx @@ -0,0 +1,195 @@ +/** + * Tests for the file-icon feature: the external registration API + * (registerFileIcon — registry lifecycle, reserved folder exts, and the + * authoritative fileIcon/folderIcon resolver chains) and the built-in + * per-extension glyph map (extension normalization, group hits, and the + * generic VscFile fallback). + */ +import { describe, it, expect, vi } from 'vitest' +import type { ReactElement, ReactNode } from 'react' +import { VscFile, VscFileCode, VscFileMedia, VscFolder, VscFolderOpened, VscJson, VscMarkdown } 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 { createBetterSidebarService, SIDEBAR_FEATURES } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' +import { builtinFileIcon, builtinFolderIcon, fallbackFileIcon } from '../src/client/file-icons.tsx' + +/** The component type of a rendered icon element (identity of the glyph). */ +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 => + +describe('file icon registration API', () => { + it('registerFileIcon adds to the registry and dispose removes it', () => { + const service = createBetterSidebarService(createSidebarStore()) + expect(service.getFileIcons()).toHaveLength(0) + const dispose = service.registerFileIcon({ id: 'test:icons', exts: ['csv'], icon: marker }) + expect(service.getFileIcons()).toHaveLength(1) + expect(service.matchFileIcon('/w/a.csv')?.id).toBe('test:icons') + dispose() + expect(service.getFileIcons()).toHaveLength(0) + expect(service.matchFileIcon('/w/a.csv')).toBeUndefined() + }) + + it('registerFileIcon throws on duplicate id', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'dup', exts: [], icon: marker }) + expect(() => service.registerFileIcon({ id: 'dup', exts: [], icon: marker })).toThrow() + }) + + it('matchFileIcon matches by extension (case-insensitive), specifics only', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'csv', exts: ['csv'], icon: marker }) + service.registerFileIcon({ id: 'all', exts: [], icon: marker }) + expect(service.matchFileIcon('/w/data.csv')?.id).toBe('csv') + expect(service.matchFileIcon('/w/DATA.CSV')?.id).toBe('csv') + // The catch-all never answers matchFileIcon (it lives in fileIcon's chain). + expect(service.matchFileIcon('/w/a.tsv')).toBeUndefined() + }) + + it('higher priority wins on extension conflict; ties keep registration order', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'low', exts: ['csv'], icon: marker }) + service.registerFileIcon({ id: 'high', exts: ['csv'], priority: 10, icon: marker }) + service.registerFileIcon({ id: 'tie', exts: ['csv'], icon: marker }) + expect(service.matchFileIcon('/w/a.csv')?.id).toBe('high') + const fresh = createBetterSidebarService(createSidebarStore()) + fresh.registerFileIcon({ id: 'first', exts: ['csv'], icon: marker }) + fresh.registerFileIcon({ id: 'second', exts: ['csv'], icon: marker }) + expect(fresh.matchFileIcon('/w/a.csv')?.id).toBe('first') + }) + + it('the feature is advertised in SIDEBAR_FEATURES', () => { + expect(SIDEBAR_FEATURES.includes('fileIcons')).toBe(true) + }) +}) + +describe('fileIcon resolver chain (specific → builtin → catch-all → VscFile)', () => { + it('a specific registration beats the builtin glyph', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'md', exts: ['md'], icon: marker }) + expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe('span') + }) + + it('the builtin glyph beats a registered catch-all (the default only claims unclaimed extensions)', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'all', exts: [], icon: marker }) + expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown) + expect(glyphOf(service.fileIcon('/w/logo.png', 14))).toBe(VscFileMedia) + // An extension the builtin map does not cover reaches the catch-all. + expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span') + }) + + it('the best (priority desc) catch-all wins among several', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'all-a', exts: [], icon: marker }) + service.registerFileIcon({ id: 'all-b', exts: [], priority: 5, icon: markerB }) + expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span') + }) + + it('with no registration at all the chain is the built-in one', () => { + const service = createBetterSidebarService(createSidebarStore()) + expect(glyphOf(service.fileIcon('/w/pkg.json', 14))).toBe(VscJson) + expect(glyphOf(service.fileIcon('/w/main.ts', 14))).toBe(VscFileCode) + expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile) + }) + + it('a throwing factory is skipped at every level (console.error, next link wins)', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'boom-md', exts: ['md'], icon: () => { throw new Error('boom') } }) + // Specific throws → falls to the builtin glyph. + expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(VscMarkdown) + service.registerFileIcon({ id: 'boom-all', exts: [], priority: 10, icon: () => { throw new Error('boom') } }) + // Catch-all throws → falls to the stock VscFile. + expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile) + expect(errorSpy).toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) + + it('reserved folder exts never claim a real file named x.folder', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'folders', exts: ['folder', 'folder-open'], icon: marker }) + expect(service.matchFileIcon('/w/x.folder')).toBeUndefined() + // The real file falls through to the stock default, not the folder icon. + expect(glyphOf(service.fileIcon('/w/x.folder', 14))).toBe(VscFile) + }) +}) + +describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)', () => { + it('unregistered directories show the builtin glyphs', () => { + const service = createBetterSidebarService(createSidebarStore()) + expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder) + expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe(VscFolderOpened) + expect(glyphOf(builtinFolderIcon(false, 14))).toBe(VscFolder) + expect(glyphOf(builtinFolderIcon(true, 14))).toBe(VscFolderOpened) + }) + + it('folder / folder-open registrations replace the dir glyphs (priority desc)', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'closed', exts: ['folder'], icon: marker }) + expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('span') + // 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') + service.registerFileIcon({ id: 'closed-hi', exts: ['folder'], priority: 3, icon: markerB }) + expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('span') + }) + + it('a catch-all registration never claims a directory', () => { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'all', exts: [], icon: marker }) + expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder) + }) + + it('a throwing folder factory falls back to the builtin glyph', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const service = createBetterSidebarService(createSidebarStore()) + service.registerFileIcon({ id: 'boom', exts: ['folder'], icon: () => { throw new Error('boom') } }) + expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(VscFolder) + expect(errorSpy).toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) +}) + +describe('built-in glyph map (builtinFileIcon)', () => { + it('maps representative extensions to their group glyph', () => { + expect(glyphOf(builtinFileIcon('/w/README.md', 14))).toBe(VscMarkdown) + expect(glyphOf(builtinFileIcon('/w/logo.png', 14))).toBe(VscFileMedia) + expect(glyphOf(builtinFileIcon('/w/logo.SVG', 14))).toBe(VscFileMedia) + expect(glyphOf(builtinFileIcon('/w/pkg.json', 14))).toBe(VscJson) + expect(glyphOf(builtinFileIcon('/w/main.ts', 14))).toBe(VscFileCode) + expect(glyphOf(builtinFileIcon('/w/main.py', 14))).toBe(VscFileCode) + }) + + it('falls back to the generic VscFile for unknown extensions and paths without one', () => { + expect(glyphOf(builtinFileIcon('/w/data.xyzunknown', 14))).toBe(VscFile) + expect(glyphOf(builtinFileIcon('/w/Makefile', 14))).toBe(VscFile) + expect(glyphOf(fallbackFileIcon(14))).toBe(VscFile) + }) +}) From 8722bfb29c724319f5c4eebcb6474cedc8def910 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Tue, 8 Sep 2026 21:14:15 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(file-icons):=20=E9=80=82=E9=85=8D=20v0.?= =?UTF-8?q?18.1=20=E5=9F=BA=E7=BA=BF=E2=80=94=E2=80=94=E9=87=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D=E8=A1=8C=E6=8E=A5=E5=85=A5=E8=A7=A3=E6=9E=90=E5=99=A8?= =?UTF-8?q?=20+=20=E6=B5=8B=E8=AF=95=E4=B8=8E=E9=97=A8=E7=A6=81=E8=A1=A5?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rebase 到 main(v0.18.1)后的适配层: - 冲突(EditorHost ×2 / TreePanel / FileTree)按并集解:main 侧 onPathRenamed/onPathDeleted 与 PR 侧 service prop 并存。 - main 新增的行内重命名行仍用 /,而本次改动已删除 VscFile import(合并后 TS2304)——改走 dirRowIcon/fileRowIcon,与树行 同一条回退链;VscFolder 随之无引用,从 import 移除。 - tests/file-icons.spec.tsx:markerB 换成独立元素类型(),使 「priority 降序 / 同级按注册序」断言真正可区分(此前两个 marker 都是 ,断言恒真);新增「工厂返回 undefined 则拒绝该级、链继续」与 「注册/注销触发订阅通知」两例(17 → 19)。 - tests/consumer-types.ts:补齐 FileIconDescriptor / registerFileIcon / getFileIcons / matchFileIcon / matchFolderIcon / fileIcon / folderIcon / FOLDER_EXT / FOLDER_OPEN_EXT 的编译期门禁。 - README:v0.19.0(未发布)段移到「最近更新」最前(此前被自动合并夹在 v0.18.1 与 v0.18.0 之间)。 - file-icons.tsx:注释修正——展平后的 Map 是后写覆盖,不是「first match wins」;当前无重复扩展名键,仅潜在语义。 --- README.md | 8 ++++---- src/client/FileTree.tsx | 6 +++--- src/client/file-icons.tsx | 10 ++++++---- tests/consumer-types.ts | 20 +++++++++++++++++++ tests/file-icons.spec.tsx | 42 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 72 insertions(+), 14 deletions(-) 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', () => {