Merge the file-icon theme shell from #594 (feat/file-icon-theme)

Brings in the theme plumbing around #531's API — the `fileIconTheme` pref
thread, the settings row, the 20 dictionaries' keys and the theme loader's
test — so the working tree is a complete, green starting point before the
colored implementation is replaced.

Conflicts (three; resolved by taking this line's side where it had moved on):

- src/client/service.ts: the import block keeps `baseName, extOf` (the
  `isNarrowWidth` import the theme branch needed belongs to its own
  fresh-session seeding, which main has since rewritten) and the feature-list
  doc comment keeps main's "v0.19.0 REMOVED 'floatWindows'" note while
  adopting the sharper `fileIcons` wording (matched by exts / names /
  folderNames).
- tests/plugin-shape.spec.ts and tests/prefs.spec.ts: main DELETED the store
  seeding tests these files used to hold and rewrote the schema assertions
  (the `SIDEBAR_PREFS_DEFAULTS` destructuring), so this line's side wins —
  the theme branch's re-added blocks describe behaviour main no longer has.

Next commit removes `fileIconTheme` again along with the 563-entry dataset and
its lazy chunk: DSH 0.1.5-rc.2 exports the official `FileTypeIcon` artwork, so
the plugin renders the host's icons instead of shipping its own color table.
This commit is contained in:
Menghuan1918
2026-09-11 15:14:47 +08:00
42 changed files with 1464 additions and 72 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'])
expect(toggles.map(t => t.key)).toEqual(['editorExplorer', 'workspaceFence', 'fileIconTheme'])
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'])
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid', 'locale', 'file-icons'])
})
})
+5 -2
View File
@@ -132,9 +132,11 @@ service.openFile({ sessionId: 's1', cwd: '/p' }, '/p/a.csv', 'Data')
const icon: FileIconDescriptor = {
id: 'my-plugin:icons',
exts: ['csv', FOLDER_EXT, FOLDER_OPEN_EXT],
names: ['package.json'],
folderNames: ['node_modules'],
priority: 5,
icon: (path: string, size: number) => {
void path; void size
icon: (path: string, size: number, open?: boolean) => {
void path; void size; void open
return null
},
}
@@ -142,6 +144,7 @@ service.registerFileIcon(icon)
service.getFileIcons()
service.matchFileIcon('/p/a.csv')
service.matchFolderIcon(true)
service.matchFolderIcon(true, 'node_modules')
service.fileIcon('/p/a.csv', 14)
service.folderIcon('/p', true, 14)
+176
View File
@@ -0,0 +1,176 @@
/**
* 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()
})
})
+51
View File
@@ -172,6 +172,57 @@ describe('fileIcon resolver chain (specific → builtin → catch-all → VscFil
})
})
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(VscFolder)
})
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 → builtin glyphs)', () => {
it('unregistered directories show the builtin glyphs', () => {
const service = createBetterSidebarService(createSidebarStore())
+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'].map(name => `lib/client-${name}.js`)
const CHUNK_FILES = ['terminal', 'editor', 'mermaid', 'file-icons'].map(name => `lib/client-${name}.js`)
/** The global registry slot a built chunk script assigns (its factory key). */
function chunkSlot(file: string): string {
+1
View File
@@ -1000,6 +1000,7 @@ describe('side card settings routes', () => {
terminalFontSize: 13,
editorExplorer: false,
workspaceFence: true,
fileIconTheme: 'builtin',
terminalShell: '',
terminalShellArgs: '',
titleBarCompat: false,
+38
View File
@@ -13,6 +13,8 @@
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { readFileSync, readdirSync } from 'node:fs'
import { resolve } from 'node:path'
import { colorAlpha, effectiveTokenValue, tokenValue } from '../src/client/theme.ts'
afterEach(() => {
@@ -80,3 +82,39 @@ describe('effectiveTokenValue', () => {
expect(effectiveTokenValue('--probe')).toBe('')
})
})
/**
* 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.
*/
// 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) {
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)
}
expect(offenders).toEqual([])
})
})