feat(file-icons): 文件树按扩展名区分图标 + 对外 registerFileIcon API
- 新增内置扩展名→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
This commit is contained in:
@@ -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) => <MyCsvIcon size={size} />, // 彩色也可以
|
||||
})
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
注册/注销即时生效(文件树订阅注册表变化自动重渲染);图标工厂抛错会被吞掉
|
||||
(console.error 后回退内置 glyph),不会空白文件树行。
|
||||
|
||||
**版本与能力探测**(v0.12.0+):消费插件先查能力再使用新 API,老版本(或旧 DSH)下优雅降级:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -402,6 +402,7 @@ export function EditorHost(props: {
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
service={ctx.get('betterSidebar')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -526,6 +527,7 @@ export function EditorHost(props: {
|
||||
onReferenceFile={onReferenceFile}
|
||||
onPathRenamed={onPathRenamed}
|
||||
onPathDeleted={onPathDeleted}
|
||||
service={ctx.get('betterSidebar')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+40
-3
@@ -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<Record<string, LevelData>>({})
|
||||
/**
|
||||
* 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<string | null>(null)
|
||||
@@ -658,7 +695,7 @@ export function FileTree(props: {
|
||||
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<VscFile size={14} />
|
||||
{fileRowIcon(entry.path)}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
@@ -261,6 +264,7 @@ export function TreePanel(props: {
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
service={service}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.explorerBody}>
|
||||
|
||||
@@ -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 <VscFile size={size} />
|
||||
}
|
||||
|
||||
/** 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) => <VscMarkdown size={size} />],
|
||||
[['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'tiff'], (size) => <VscFileMedia size={size} />],
|
||||
[['pdf'], (size) => <VscFilePdf size={size} />],
|
||||
[['json', 'jsonc', 'json5'], (size) => <VscJson size={size} />],
|
||||
[['html', 'htm', 'xhtml', 'xml', 'jsx', 'tsx'], (size) => <VscFileCode size={size} />],
|
||||
[['css', 'scss', 'sass', 'less', 'styl'], (size) => <VscFileCode size={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) => <VscFileCode size={size} />],
|
||||
[['yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'properties', 'editorconfig'], (size) => <VscSettings size={size} />],
|
||||
[['sql', 'db', 'sqlite', 'sqlite3'], (size) => <VscDatabase size={size} />],
|
||||
[['lock'], (size) => <VscLock size={size} />],
|
||||
[['zip', 'tar', 'gz', 'tgz', 'bz2', 'xz', '7z', 'rar'], (size) => <VscFileZip size={size} />],
|
||||
]
|
||||
|
||||
const LOOKUP: ReadonlyMap<string, (size: number) => 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
|
||||
}
|
||||
@@ -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<string, TabDescriptor>()
|
||||
const viewers = new Map<string, FileViewerDescriptor>()
|
||||
const fileIcons = new Map<string, FileIconDescriptor>()
|
||||
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,
|
||||
|
||||
@@ -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<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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user