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