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) + }) +})