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).
This commit is contained in:
Menghuan1918
2026-09-11 15:28:49 +08:00
parent a4e94c976e
commit 22e772ce45
46 changed files with 431 additions and 1381 deletions
+1 -1
View File
@@ -136,7 +136,7 @@ describe('built-in tab registrations', () => {
it('the editor tab declares its merged-mode (embedded file tree) setting', () => {
const { service } = setup()
const toggles = service.getTab('editor')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence', 'fileIconTheme'])
expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence'])
expect(toggles[0]?.title).toBeDefined()
expect(toggles[0]?.desc).toBeDefined()
// The merged mode is an iconed select (merged vs separate), not a switch.
+1 -1
View File
@@ -150,6 +150,6 @@ describe('/sidebar/bundle route', () => {
})
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale', 'file-icons'])
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale'])
})
})
-176
View File
@@ -1,176 +0,0 @@
/**
* The optional colored file-icon theme: the lazy-chunk loader that turns the
* `fileIconTheme` pref into a register/dispose of the colored dataset, and
* the dataset's own registration through the public `registerFileIcon` API.
*
* The loader tests drive a fake chunk (no fetch); the dataset tests use the
* real chunk module against a real service, asserting representative rules
* from each table (extension / exact file name / directory name).
*/
import { describe, it, expect, vi, afterEach } from 'vitest'
import type { ReactElement, ReactNode } from 'react'
import { SiNpm, SiReact } from 'react-icons/si'
import { VscFolder, VscFolderOpened } 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 { registerChunkForTests, resetChunks } from '../src/client/chunk-loader.ts'
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import {
createFileIconThemeLoader,
FILE_ICON_THEME_BUILTIN,
FILE_ICON_THEME_COLORED,
} from '../src/client/file-icon-theme.ts'
import { registerColoredFileIcons } from '../src/client/chunks/file-icons.tsx'
/** The component type of a rendered icon element (identity of the glyph). */
const glyphOf = (node: ReactNode): unknown => (node as ReactElement).type
/** The inline color of a rendered icon element (undefined when untinted). */
const colorOf = (node: ReactNode): unknown => (node as ReactElement).props.style?.color
/** Flush the queued promise chain (loadChunk → loader's `.then`). */
const settle = async (): Promise<void> => { await new Promise((resolve) => { setTimeout(resolve, 0) }) }
afterEach(() => { resetChunks() })
describe('file-icon theme loader (pref → lazy chunk → registerFileIcon)', () => {
it('registers the dataset when the colored theme is selected, and disposes on builtin', async () => {
const service = createBetterSidebarService(createSidebarStore())
let registrations = 0
let disposals = 0
registerChunkForTests('file-icons', async () => ({
registerColoredFileIcons: (target: typeof service) => {
expect(target).toBe(service)
registrations += 1
return () => { disposals += 1 }
},
}))
const loader = createFileIconThemeLoader(service)
expect(service.getFileIcons()).toHaveLength(0)
loader.set(FILE_ICON_THEME_COLORED)
await settle()
expect(registrations).toBe(1)
loader.set(FILE_ICON_THEME_BUILTIN)
expect(disposals).toBe(1)
loader.set(FILE_ICON_THEME_COLORED)
await settle()
expect(registrations).toBe(2)
loader.dispose()
expect(disposals).toBe(2)
})
it('treats an unknown pref value as the built-in theme', async () => {
const service = createBetterSidebarService(createSidebarStore())
let registrations = 0
registerChunkForTests('file-icons', async () => ({
registerColoredFileIcons: () => { registrations += 1; return () => {} },
}))
const loader = createFileIconThemeLoader(service)
loader.set('no-such-theme')
await settle()
expect(registrations).toBe(0)
})
it('drops a chunk load that lands after the pref moved back to builtin', async () => {
const service = createBetterSidebarService(createSidebarStore())
let registrations = 0
let release: (() => void) | undefined
registerChunkForTests('file-icons', () => new Promise((resolve) => {
release = () => resolve({ registerColoredFileIcons: () => { registrations += 1; return () => {} } })
}))
const loader = createFileIconThemeLoader(service)
loader.set(FILE_ICON_THEME_COLORED)
loader.set(FILE_ICON_THEME_BUILTIN)
release?.()
await settle()
expect(registrations).toBe(0)
})
it('does not re-register on a repeated set of the same value', async () => {
const service = createBetterSidebarService(createSidebarStore())
let registrations = 0
registerChunkForTests('file-icons', async () => ({
registerColoredFileIcons: () => { registrations += 1; return () => {} },
}))
const loader = createFileIconThemeLoader(service)
loader.set(FILE_ICON_THEME_COLORED)
loader.set(FILE_ICON_THEME_COLORED)
await settle()
expect(registrations).toBe(1)
})
it('logs and keeps the built-in glyphs when the chunk fails to load', async () => {
const service = createBetterSidebarService(createSidebarStore())
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
registerChunkForTests('file-icons', async () => { throw new Error('offline') })
const loader = createFileIconThemeLoader(service)
loader.set(FILE_ICON_THEME_COLORED)
await settle()
expect(errorSpy).toHaveBeenCalled()
expect(service.getFileIcons()).toHaveLength(0)
} finally {
errorSpy.mockRestore()
}
})
it('logs and keeps the built-in glyphs when the chunk exports no registrar', async () => {
const service = createBetterSidebarService(createSidebarStore())
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
registerChunkForTests('file-icons', async () => ({}))
const loader = createFileIconThemeLoader(service)
loader.set(FILE_ICON_THEME_COLORED)
await settle()
expect(errorSpy).toHaveBeenCalled()
expect(service.getFileIcons()).toHaveLength(0)
} finally {
errorSpy.mockRestore()
}
})
})
describe('colored dataset registration (563 rules through registerFileIcon)', () => {
it('registers the extension, file-name and directory rules and disposes them', () => {
const service = createBetterSidebarService(createSidebarStore())
const dispose = registerColoredFileIcons(service)
expect(service.getFileIcons().length).toBeGreaterThan(100)
// Extension rule: .tsx → React blue, and it outranks the built-in glyph.
const tsx = service.fileIcon('/w/App.tsx', 14)
expect(glyphOf(tsx)).toBe(SiReact)
expect(colorOf(tsx)).toBe('#61DAFB')
// Exact file name rule: package.json → npm red (name beats the .json ext).
const pkg = service.fileIcon('/w/package.json', 14)
expect(glyphOf(pkg)).toBe(SiNpm)
expect(colorOf(pkg)).toBe('#CB3837')
// Directory rule: node_modules is tinted, an unnamed dir keeps the glyph.
const nodeModules = service.folderIcon('/w/node_modules', false, 14)
expect(glyphOf(nodeModules)).toBe(VscFolder)
expect(colorOf(nodeModules)).toBe('#CB3837')
expect(glyphOf(service.folderIcon('/w/node_modules', true, 14))).toBe(VscFolderOpened)
expect(glyphOf(service.folderIcon('/w/zzz-unnamed-dir', false, 14))).toBe(VscFolder)
expect(colorOf(service.folderIcon('/w/zzz-unnamed-dir', false, 14))).toBeUndefined()
// An extension the dataset does not cover still falls back to the built-in.
expect(glyphOf(service.fileIcon('/w/data.unknownext', 14))).toBeDefined()
dispose()
expect(service.getFileIcons()).toHaveLength(0)
expect(colorOf(service.fileIcon('/w/App.tsx', 14))).toBeUndefined()
})
})
+50 -40
View File
@@ -1,13 +1,13 @@
/**
* 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).
* 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 { VscFile, VscFileCode, VscFileMedia, VscFolder, VscFolderOpened, VscJson, VscMarkdown } from 'react-icons/vsc'
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>
@@ -100,19 +100,23 @@ describe('file icon registration API', () => {
})
})
describe('fileIcon resolver chain (specific → builtin → catch-all → VscFile)', () => {
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('the builtin glyph beats a registered catch-all (the default only claims unclaimed extensions)', () => {
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(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/README.md', 14))).toBe('span')
expect(glyphOf(service.fileIcon('/w/logo.png', 14))).toBe('span')
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe('span')
})
@@ -133,18 +137,18 @@ describe('fileIcon resolver chain (specific → builtin → catch-all → VscFil
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(VscMarkdown)
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 built-in one', () => {
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(VscJson)
expect(glyphOf(service.fileIcon('/w/main.ts', 14))).toBe(VscFileCode)
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile)
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)', () => {
@@ -153,10 +157,10 @@ describe('fileIcon resolver chain (specific → builtin → catch-all → VscFil
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)
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 stock VscFile.
expect(glyphOf(service.fileIcon('/w/Makefile', 14))).toBe(VscFile)
// 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()
@@ -167,8 +171,8 @@ describe('fileIcon resolver chain (specific → builtin → catch-all → VscFil
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)
// The real file falls through to the host's artwork, not the folder glyph.
expect(glyphOf(service.fileIcon('/w/x.folder', 14))).toBe(FileTypeIcon)
})
})
@@ -203,7 +207,7 @@ describe('name matching (names / folderNames — the icon-theme half)', () => {
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(VscFolder)
expect(glyphOf(solo.folderIcon('/w/lib', false, 14))).toBe(FileTypeIcon)
})
it('the folder factory receives the open flag so one descriptor renders both states', () => {
@@ -223,13 +227,13 @@ describe('name matching (names / folderNames — the icon-theme half)', () => {
})
})
describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)', () => {
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(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)
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)', () => {
@@ -237,7 +241,7 @@ describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)
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)
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 })
@@ -248,7 +252,7 @@ describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)
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)
expect(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(FileTypeIcon)
})
it('a throwing folder factory falls back to the builtin glyph', () => {
@@ -256,7 +260,7 @@ describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)
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(glyphOf(service.folderIcon('/w/src', false, 14))).toBe(FileTypeIcon)
expect(errorSpy).toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
@@ -264,19 +268,25 @@ describe('folderIcon resolver (registered folder/folder-open → builtin glyphs)
})
})
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)
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('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)
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)
})
})
+1 -1
View File
@@ -71,7 +71,7 @@ function bundleId(file: string): string {
}
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
const CHUNK_FILES = ['terminal', 'editor', 'mermaid', 'file-icons'].map(name => `lib/client-${name}.js`)
const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`)
/** The global registry slot a built chunk script assigns (its factory key). */
function chunkSlot(file: string): string {
+109 -1
View File
@@ -8,7 +8,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { createNativeTabRecords, NativeTabBody } from '../src/client/native/tab-adapter.tsx'
import { createNativeTabRecords, NativeTabBody, NativeTabTitle } from '../src/client/native/tab-adapter.tsx'
import { registerNativeSurface } from '../src/client/native/index.ts'
import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
@@ -310,3 +310,111 @@ describe('NativeTabBody full-height host wrapper', () => {
host.remove()
})
})
/**
* The native tab CHIP: the host's tab definition carries no icon field, so
* the plugin draws the glyph itself inside the `sidebar.right.pane.tab.title`
* slot (which IS the chip's content). These cases pin the placement rule —
* an editor tab with a path shows the FILE's glyph, every other tab shows its
* descriptor's glyph — and the accessible-name boundary: the glyph is
* decorative, so the chip's name stays exactly the title the e2e lane matches
* with `getByRole('tab', { name })`.
*/
describe('NativeTabTitle (the chip glyph)', () => {
const renderTitle = (
records: ReturnType<typeof createNativeTabRecords>,
service: ReturnType<typeof createBetterSidebarService>,
info: unknown,
descriptorId: string,
): { host: HTMLDivElement; unmount: () => void } => {
const host = document.createElement('div')
document.body.appendChild(host)
let root: Root | undefined
act(() => {
root = createRoot(host)
root.render(createElement(NativeTabTitle, {
records,
service,
descriptorId,
useTabInfo: () => info as never,
}))
})
return {
host,
unmount: () => {
act(() => { root?.unmount() })
host.remove()
},
}
}
const nativeInfo = (id: string, kind: string, title: string) => ({
tab: {
id,
kind,
title,
contentId: `sidebar://${id}`,
visible: true,
navigation: { address: `sidebar://${id}`, params: undefined, revision: 0 },
signal: new AbortController().signal,
},
})
it('draws the descriptor glyph before the live title', () => {
const records = createNativeTabRecords()
const service = createBetterSidebarService(createSidebarStore())
service.registerTab({
id: 'stub-tab',
title: () => 'Stub',
icon: (size: number) => createElement('i', { 'data-stub-icon': size }),
component: () => createElement('div'),
})
records.ensure({ id: 'chip-1', kind: 'stub-tab', title: 'Stub', params: undefined, scope })
const { host, unmount } = renderTitle(records, service, nativeInfo('chip-1', 'stub-tab', 'Stub'), 'stub-tab')
const chip = host.querySelector('[aria-hidden="true"]')
expect(chip, 'the chip must carry a decorative glyph').not.toBeNull()
expect(chip!.querySelector('[data-stub-icon="14"]'), 'the descriptor icon renders at the chip scale').not.toBeNull()
expect(host.textContent, 'the title follows the glyph').toBe('Stub')
expect(host.querySelector('[role="tab"]'), 'the chip itself is the host’s element, not the plugin’s').toBeNull()
unmount()
})
it('an editor tab with a path shows the FILE glyph instead of the type glyph', () => {
const records = createNativeTabRecords()
const service = createBetterSidebarService(createSidebarStore())
service.registerTab({
id: 'editor',
title: () => 'Files',
icon: (size: number) => createElement('i', { 'data-type-icon': size }),
component: () => createElement('div'),
})
records.ensure({
id: 'chip-2',
kind: 'editor',
title: 'notes.md',
params: { path: '/work/notes.md' },
scope,
})
const { host, unmount } = renderTitle(records, service, nativeInfo('chip-2', 'editor', 'notes.md'), 'editor')
expect(host.querySelector('[data-type-icon]'), 'the type glyph must NOT be used for a file tab').toBeNull()
// The file glyph is the host's own FileTypeIcon (its component identity is
// asserted through the resolver tests); here the point is that a glyph is
// drawn and the title is intact.
expect(host.querySelector('[aria-hidden="true"]')).not.toBeNull()
expect(host.textContent).toBe('notes.md')
unmount()
})
it('falls back to the title alone when the type is gone (unregistered descriptor)', () => {
const records = createNativeTabRecords()
const service = createBetterSidebarService(createSidebarStore())
records.ensure({ id: 'chip-3', kind: 'ghost', title: 'Ghost', params: undefined, scope })
const { host, unmount } = renderTitle(records, service, nativeInfo('chip-3', 'ghost', 'Ghost'), 'ghost')
expect(host.querySelector('[aria-hidden="true"]')).toBeNull()
expect(host.textContent).toBe('Ghost')
unmount()
})
})
-1
View File
@@ -1000,7 +1000,6 @@ describe('side card settings routes', () => {
terminalFontSize: 13,
editorExplorer: false,
workspaceFence: true,
fileIconTheme: 'builtin',
terminalShell: '',
terminalShellArgs: '',
titleBarCompat: false,
+32 -20
View File
@@ -85,36 +85,48 @@ describe('effectiveTokenValue', () => {
/**
* Skin contract (guide §12): every visual value rides a `--dsw-alias-*` /
* `--dsw-font-*` / `--ds-*` token. The ONE documented exception is the
* optional colored icon theme: brand colors are identity, not chrome, so
* they are hardcoded — but only inside the lazy `file-icons` chunk, which
* the core bundle must never import. This guards that boundary.
* `--dsw-font-*` / `--ds-*` token, and the plugin draws no color of its own.
*
* File icons are no exception to check any more: the built-in set IS DSH's
* `FileTypeIcon` artwork from a platform module (the host owns those pixels
* and its own palette), and everything the plugin renders around them —
* including the colored tab glyphs — rides theme tokens. So the guard is
* simply that no plugin module carries a color literal, and that no icon
* dataset sneaked back in as a chunk.
*/
// jsdom has no file:// import.meta.url; vitest runs from the repo root.
const ROOT = process.cwd()
describe('skin contract boundary for hardcoded icon colors', () => {
const coreIconModules = ['src/client/file-icons.tsx', 'src/client/file-icon-theme.ts']
it('the core icon modules carry no color literals', () => {
for (const file of coreIconModules) {
describe('skin contract: the plugin owns no color of its own', () => {
it('the icon modules carry no color literals', () => {
for (const file of ['src/client/file-icons.tsx', 'src/client/icons.tsx']) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
expect(source, file).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(source, file).not.toMatch(/\brgba?\(/)
}
})
it('only the lazy chunk may carry the colored dataset, and the core never imports it statically', () => {
const chunk = readFileSync(resolve(ROOT, 'src/client/chunks/file-icons.tsx'), 'utf8')
expect(chunk).toMatch(/#[0-9a-fA-F]{6}/)
const clientDir = resolve(ROOT, 'src/client')
const offenders: string[] = []
for (const entry of readdirSync(clientDir, { recursive: true, withFileTypes: true })) {
if (!entry.isFile() || !/\.tsx?$/.test(entry.name)) continue
const path = resolve(entry.parentPath, entry.name)
if (path.includes('/chunks/')) continue
if (/from '[^']*chunks\/file-icons/.test(readFileSync(path, 'utf8'))) offenders.push(path)
it('the colored tab glyphs take their color from theme tokens, never from a literal', () => {
const module = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.tsx'), 'utf8')
expect(module).not.toMatch(/#[0-9a-fA-F]{3,8}\b/)
expect(module).not.toMatch(/\brgba?\(/)
// Each glyph is wrapped in a themed class, which is how the color arrives.
const styles = readFileSync(resolve(ROOT, 'src/client/builtins/tab-icons.module.css'), 'utf8')
const classes = [...module.matchAll(/css\.([a-zA-Z]+)/g)].map(m => m[1])
expect(classes.length).toBeGreaterThan(0)
for (const name of new Set(classes)) expect(styles, name).toContain(`.${name}`)
// Every declaration that paints a color resolves to a token.
for (const declaration of styles.matchAll(/color:\s*([^;]+);/g)) {
expect(declaration[1], declaration[0]).toContain('var(--dsw-')
}
})
it('no icon dataset is shipped as a lazy chunk', () => {
const chunkDir = resolve(ROOT, 'src/client/chunks')
const chunks = readdirSync(chunkDir).filter(name => /\.tsx?$/.test(name))
for (const name of chunks) {
const source = readFileSync(resolve(chunkDir, name), 'utf8')
expect(source, name).not.toMatch(/#[0-9a-fA-F]{6}\b/)
}
expect(offenders).toEqual([])
})
})