feat(file-icon-theme): 可选彩色图标主题接线(懒加载 chunk + 设置开关 + i18n)

- 新 chunk `file-icons`(`lib/client-file-icons.js`,255 kB / gzip 90 kB):把数据表
  注册成 `registerFileIcon` 描述符——按 glyph+color 归并,563 条规则 → 约 250 条注册;
  目录规则只走 `folderNames`(只认领列出的目录,未列出的仍用内置 glyph)。
- `src/client/file-icon-theme.ts`:pref → loadChunk → 注册/注销,generation 守卫
  丢弃「切换中途落地」的加载;chunk 拉取失败只 log,保持内置 glyph。
- pref `fileIconTheme`(默认 `'builtin'`,未知值按 builtin 处理)+ 设置页
  「文件 → 文件图标」下拉(内置单色 / 彩色品牌图标)。
- chunk 名同步四处(chunk-loader / bundle-route / tsdown / manifest-consistency)+
  `bundle-route.spec`;tsdown 为该 chunk 加 react-icons ESM alias——否则整包打进
  6.4 MB,alias 后 255 kB。
- i18n:6 个新 key 镜像到全部 19 份词典(zh/en 之外),locale 键集守护通过。
- 皮肤契约:指南 §12 增豁免条款(品牌色是内容非 chrome,仅限该懒加载 chunk),
  AGENTS §5 同步;`tests/theme.spec.ts` 新增边界用例(核心图标模块零颜色字面量、
  核心不静态 import 该 chunk)。
- 测试:`tests/file-icon-theme.spec.tsx` 7 例(loader 六态 + 真实数据三表注册);
  prefs 新增默认值/解析用例;builtins / prefs / plugin-shape / smoke 夹具补字段。
- 全量 `pnpm test` 失败集与 main 基线逐条一致(5 文件 / 26 例预存环境失败),
  新增用例全绿;typecheck / lint / build / check:consumer-types 全通过。
This commit is contained in:
Menghuan1918
2026-09-08 21:43:28 +08:00
parent 8909455df2
commit 162c16ec32
41 changed files with 603 additions and 16 deletions
+1 -1
View File
@@ -110,7 +110,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'])
})
})
+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()
})
})
+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 -1
View File
@@ -97,6 +97,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', changesDiffFloat: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, workspaceFence: true, fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', changesDiffFloat: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
})
})
+18 -5
View File
@@ -44,6 +44,7 @@ describe('side card preferences', () => {
interceptOpenPath: true,
editorExplorer: false, changesDiffFloat: true,
workspaceFence: true,
fileIconTheme: 'builtin',
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
@@ -78,6 +79,7 @@ describe('side card preferences', () => {
interceptOpenPath: true,
editorExplorer: false, changesDiffFloat: true,
workspaceFence: true,
fileIconTheme: 'builtin',
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
@@ -112,6 +114,7 @@ describe('side card preferences', () => {
interceptOpenPath: true,
editorExplorer: false, changesDiffFloat: true,
workspaceFence: true,
fileIconTheme: 'builtin',
terminalShell: '',
terminalShellArgs: '',
titleBarScheme: 'auto',
@@ -182,6 +185,16 @@ describe('side card preferences', () => {
expect((await loadPrefs(wire({ workspaceFence: false }))).workspaceFence).toBe(false)
})
it('defaults fileIconTheme to builtin; only a stored string survives', async () => {
// The colored theme is opt-in: absent or malformed keeps the monochrome
// built-in glyphs (and never fetches the icon chunk).
expect((await loadPrefs(wire({}))).fileIconTheme).toBe('builtin')
expect((await loadPrefs(wire({ fileIconTheme: 7 }))).fileIconTheme).toBe('builtin')
expect((await loadPrefs(wire({ fileIconTheme: 'colored' }))).fileIconTheme).toBe('colored')
// An unknown id is preserved here and treated as builtin by the loader.
expect((await loadPrefs(wire({ fileIconTheme: 'ghost' }))).fileIconTheme).toBe('ghost')
})
it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => {
// Absent or malformed → auto (plain web keeps the untouched layout).
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
@@ -282,9 +295,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -320,7 +333,7 @@ describe('side card preferences', () => {
it('skips the default seed tab when the editor (files window) type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
store.setSession('no-editor')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -330,7 +343,7 @@ describe('side card preferences', () => {
// editorExplorer modes.
for (const editorExplorer of [true, false]) {
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setSession(`with-editor-${editorExplorer}`)
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
@@ -340,7 +353,7 @@ describe('side card preferences', () => {
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
for (const editorExplorer of [true, false]) {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, fileIconTheme: 'builtin', terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession(`fresh-${editorExplorer}`)
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(tabs).toHaveLength(1)
+1
View File
@@ -950,6 +950,7 @@ describe('side card settings routes', () => {
interceptOpenPath: true,
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([])
})
})