Files
apes-sidebar/tests/file-icons.spec.tsx
T
Menghuan1918 22e772ce45 feat(icons): file icons and tab glyphs in color, from the host's own artwork
Issue #611 asked for file-type icons like the official explorer's, and #429
built a colored theme for them. DSH 0.1.5-rc.2 makes most of that work
unnecessary: `@deepseek-ai/dsh-client-ui-primitives` now exports
`FileTypeIcon` / `CodeFileIcon` with the host's own 48-type full-color code
artwork plus category-colored sheet glyphs (and its classifier behind them).
So the plugin renders the host's icons instead of shipping a color table, and
color becomes the only behaviour rather than an opt-in theme.

File icons (src/client/file-icons.tsx)
- `builtinFileIcon(path, size)` → `<FileTypeIcon path size />`; folders use the
  host's folder category. The plugin's own per-extension VSCodicon map is gone.
- The public `registerFileIcon` API from #531 is unchanged and still outranks
  the built-ins, so an external plugin can take over any row. One semantic
  consequence, now documented and tested: because the host classifier covers
  every path, a registered catch-all (`exts: []`) claims rows it used to lose
  to the built-in map. An unregistered catch-all (the old icon theme's shape)
  is unaffected.
- Nothing is lazy any more: the artwork lives in a platform module the client
  already holds, so `lib/client-file-icons.js` and its 563-entry dataset (255
  kB) are deleted outright — the core bundle grew by 10 kB (0.85 → 0.86 MB).
- `src/client/file-icon-theme.ts`, the `fileIconTheme` pref (prefs-shared /
  config / client prefs), the settings-page select row and the six
  `settingsFileIconTheme*` keys in all 20 dictionaries are removed with it.

Tab glyphs (src/client/builtins/tab-icons.tsx)
- Every built-in tab type now declares a colored glyph: files (the host's
  folder artwork), changes/diff (commit), tasks (checklist), side chat
  (discussion), terminal (shell), browser (globe).
- The color comes from a `--dsw-alias-*` token through a wrapper class, never
  from a literal — the skin contract (§12) holds and `tests/theme.spec.ts`
  enforces both halves (no literals in the module, every color declaration in
  the stylesheet resolves to a token).

Native right Sidebar chips (src/client/native/tab-adapter.tsx)
- The host's tab definition has no icon field, but the
  `sidebar.right.pane.tab.title` slot IS the chip's content, so the plugin
  draws the glyph itself: an editor tab with a path shows that file's icon,
  every other tab shows its descriptor's icon — the same `descriptor.icon` the
  workbench strip and the guide capsules already render, so the three cannot
  drift. The glyph is `aria-hidden`, which keeps the chip's accessible name
  exactly the title the mount lane matches on.

Gates: typecheck / lint / check:consumer-types green; pnpm test 127 files /
1342 passed / 9 skipped (3 new chip cases, 1 rewritten built-in case).
2026-09-11 15:28:49 +08:00

293 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 glyphs,
* which are DSH's own `FileTypeIcon` artwork rather than a plugin-owned
* extension table (see src/client/file-icons.tsx).
*/
import { describe, it, expect, vi } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { FileTypeIcon } from '@deepseek-ai/dsh-client-ui-primitives'
// 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 />
/** A second marker with a DISTINCT element type, so `glyphOf` can tell the
* two registrations apart in priority/ordering assertions. */
const markerB = (): ReactNode => <b 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)
})
it('register and dispose notify subscribers (mounted rows re-resolve, no reload)', () => {
const service = createBetterSidebarService(createSidebarStore())
let notified = 0
const unsubscribe = service.subscribe(() => { notified += 1 })
const dispose = service.registerFileIcon({ id: 'test:icons', exts: ['csv'], icon: marker })
expect(notified).toBe(1)
dispose()
expect(notified).toBe(2)
// A second dispose is a no-op — no spurious notification.
dispose()
expect(notified).toBe(2)
unsubscribe()
service.registerFileIcon({ id: 'test:other', exts: ['tsv'], icon: marker })
expect(notified).toBe(2)
})
})
describe('fileIcon resolver chain (specific → catch-all → the host artwork)', () => {
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('a registered catch-all claims every row the plugin itself would draw', () => {
// The plugin ships no extension table any more: the host's classifier
// covers every path, so "the built-in set already claims this one" is no
// longer a reason to keep a catch-all out. A catch-all therefore wins the
// fallback position outright, and an unregistered tree keeps the host's
// artwork (the next case).
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe('span')
expect(glyphOf(service.fileIcon('/w/logo.png', 14))).toBe('span')
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 })
// markerB ('b') is the higher-priority catch-all — not merely "some span".
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('b')
const tie = createBetterSidebarService(createSidebarStore())
tie.registerFileIcon({ id: 'first', exts: [], icon: marker })
tie.registerFileIcon({ id: 'second', exts: [], icon: markerB })
// Equal priority keeps registration order (the first-registered wins).
expect(glyphOf(tie.fileIcon('/w/Makefile', 14))).toBe('span')
})
it('a factory returning undefined declines that link and the chain continues', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'md-decline', exts: ['md'], icon: () => undefined })
// The specific registration declined → the built-in glyph claims it.
expect(glyphOf(service.fileIcon('/w/README.md', 14))).toBe(FileTypeIcon)
service.registerFileIcon({ id: 'all-decline', exts: [], priority: 10, icon: () => undefined })
service.registerFileIcon({ id: 'all-take', exts: [], icon: marker })
// The declining catch-all is skipped, the next one takes the row.
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span')
})
it('with no registration at all the chain is the host’s artwork', () => {
const service = createBetterSidebarService(createSidebarStore())
expect(glyphOf(service.fileIcon('/w/pkg.json', 14))).toBe(FileTypeIcon)
expect(glyphOf(service.fileIcon('/w/main.ts', 14))).toBe(FileTypeIcon)
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(FileTypeIcon)
})
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(FileTypeIcon)
service.registerFileIcon({ id: 'boom-all', exts: [], priority: 10, icon: () => { throw new Error('boom') } })
// Catch-all throws → falls to the host's file-type artwork.
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(FileTypeIcon)
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 host's artwork, not the folder glyph.
expect(glyphOf(service.fileIcon('/w/x.folder', 14))).toBe(FileTypeIcon)
})
})
describe('name matching (names / folderNames — the icon-theme half)', () => {
it('a names rule claims the exact basename, case-insensitively', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'npm', names: ['package.json'], icon: marker })
expect(service.matchFileIcon('/w/package.json')?.id).toBe('npm')
expect(service.matchFileIcon('/w/Package.JSON')?.id).toBe('npm')
expect(service.matchFileIcon('/w/pkg.json')).toBeUndefined()
})
it('a name rule outranks an extension rule and the built-in glyph', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'by-ext', exts: ['json'], icon: markerB })
service.registerFileIcon({ id: 'by-name', names: ['package.json'], icon: marker })
expect(service.matchFileIcon('/w/package.json')?.id).toBe('by-name')
expect(glyphOf(service.fileIcon('/w/package.json', 14))).toBe('span')
// Other json files still take the extension rule.
expect(glyphOf(service.fileIcon('/w/tsconfig.json', 14))).toBe('b')
})
it('a folderNames rule claims only the directories it names, and outranks the reserved exts', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'all-dirs', exts: ['folder', 'folder-open'], icon: markerB })
service.registerFileIcon({ id: 'named', folderNames: ['node_modules'], icon: marker })
expect(service.matchFolderIcon(false, 'node_modules')?.id).toBe('named')
expect(service.matchFolderIcon(false, 'NODE_MODULES')?.id).toBe('named')
// An unnamed directory still takes the reserved-ext registration.
expect(service.matchFolderIcon(false, 'src')?.id).toBe('all-dirs')
// A descriptor with folderNames only never claims unnamed directories.
const solo = createBetterSidebarService(createSidebarStore())
solo.registerFileIcon({ id: 'named', folderNames: ['src'], icon: marker })
expect(solo.matchFolderIcon(false, 'lib')).toBeUndefined()
expect(glyphOf(solo.folderIcon('/w/lib', false, 14))).toBe(FileTypeIcon)
})
it('the folder factory receives the open flag so one descriptor renders both states', () => {
const service = createBetterSidebarService(createSidebarStore())
const seen: (boolean | undefined)[] = []
service.registerFileIcon({
id: 'stateful',
folderNames: ['src'],
icon: (_path, _size, open) => {
seen.push(open)
return open === true ? marker() : markerB()
},
})
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('b')
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe('span')
expect(seen).toEqual([false, true])
})
})
describe('folderIcon resolver (registered folder/folder-open → the host folder glyph)', () => {
it('unregistered directories show the builtin glyphs', () => {
const service = createBetterSidebarService(createSidebarStore())
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(FileTypeIcon)
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe(FileTypeIcon)
expect(glyphOf(builtinFolderIcon(false, 14))).toBe(FileTypeIcon)
expect(glyphOf(builtinFolderIcon(true, 14))).toBe(FileTypeIcon)
})
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(FileTypeIcon)
service.registerFileIcon({ id: 'open', exts: ['folder-open'], icon: markerB })
expect(glyphOf(service.folderIcon('/w/src', true, 14))).toBe('b')
service.registerFileIcon({ id: 'closed-hi', exts: ['folder'], priority: 3, icon: markerB })
// The higher-priority registration ('b') takes the closed row.
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe('b')
})
it('a catch-all registration never claims a directory', () => {
const service = createBetterSidebarService(createSidebarStore())
service.registerFileIcon({ id: 'all', exts: [], icon: marker })
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(FileTypeIcon)
})
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(FileTypeIcon)
expect(errorSpy).toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
})
describe('built-in glyphs are the host’s own artwork', () => {
it('every path goes through the host FileTypeIcon (it classifies, the host draws)', () => {
for (const path of ['/w/README.md', '/w/logo.png', '/w/main.ts', '/w/pkg.json', '/w/data.xyzunknown', '/w/Makefile']) {
expect(glyphOf(builtinFileIcon(path, 14)), path).toBe(FileTypeIcon)
}
expect(glyphOf(fallbackFileIcon(14))).toBe(FileTypeIcon)
})
it('hands the row’s path and size to the host classifier', () => {
const props = (builtinFileIcon('/w/main.ts', 14) as ReactElement).props as { path: string; size: number }
expect(props.path).toBe('/w/main.ts')
expect(props.size).toBe(14)
})
it('renders the host folder category for directories', () => {
const props = (builtinFolderIcon(false, 14) as ReactElement).props as { kind: string; size: number }
expect(props.kind).toBe('folder')
expect(props.size).toBe(14)
// Both expansion states resolve to the host's folder drawing.
expect(glyphOf(builtinFolderIcon(true, 14))).toBe(FileTypeIcon)
})
})