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:
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1000,6 +1000,7 @@ describe('side card settings routes', () => {
|
||||
terminalFontSize: 13,
|
||||
editorExplorer: false,
|
||||
workspaceFence: true,
|
||||
fileIconTheme: 'builtin',
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarCompat: false,
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user