feat(file-icons): 目录保留扩展名注册 + 全局默认兜底 + 编辑器文件 tab 对应

- 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 用例(链序/保留值/目录/崩溃回退)
This commit is contained in:
Menghuan1918
2026-09-08 21:11:32 +08:00
parent 05773e0f2a
commit 8025b83a45
8 changed files with 427 additions and 172 deletions
+6 -2
View File
@@ -40,7 +40,7 @@
## ✨ 功能一览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红;文件树与文件 tab 按扩展名显示图标——markdown / 图片 / PDF / 代码 / 配置 / 压缩包等各有 glyph,插件可经 `registerFileIcon` 注册自定义图标与目录图标)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 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)。
+47 -11
View File
@@ -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) => <MyCsvIcon size={size} />, // 彩色也可以
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileIcon({
id: 'my-plugin:folders',
exts: ['folder'], // 闭合目录行;['folder-open'] 认领展开行——
icon: (path, size) => <MyFolderIcon size={size} />, // 开/合图标不同就注册两条
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileIcon({
id: 'my-plugin:default', // 全局默认:只兜内置 glyph 没认领的文件
exts: [],
icon: (path, size) => <MyGenericFileIcon size={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)下优雅降级:
+16 -19
View File
@@ -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<string | null>(null)
@@ -662,7 +659,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
>
{isOpen ? <VscFolderOpened size={14} /> : <VscFolder size={14} />}
{dirRowIcon(entry.path, isOpen)}
<span className={css.explorerName}>{entry.name}</span>
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
{rowActions(entry)}
@@ -740,7 +737,7 @@ export function FileTree(props: {
onDrop={(event) => { handleDirDrop(event, root) }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<VscFolderOpened size={14} />
{dirRowIcon(root, true)}
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
+9 -1
View File
@@ -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
+31 -17
View File
@@ -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 <VscFile size={size} />
}
/** The built-in directory glyphs (what a folder row shows unregistered). */
export function builtinFolderIcon(open: boolean, size: number): ReactNode {
return open ? <VscFolderOpened size={size} /> : <VscFolder size={size} />
}
/** 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) => <VscMarkdown size={size} />],
@@ -42,24 +51,29 @@ const GROUPS: readonly GlyphGroup[] = [
[['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar'], (size) => <VscFileZip size={size} />],
]
const LOOKUP: ReadonlyMap<string, (size: number) => ReactNode> = new Map(
const LOOKUP: ReadonlyMap<string, GlyphFactory> = 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)
}
+123 -21
View File
@@ -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,
-101
View File
@@ -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<string, unknown>
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)
})
})
+195
View File
@@ -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<string, unknown>
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 => <span data-marker />
const markerB = (): ReactNode => <span data-marker-b />
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)
})
})