diff --git a/apps/docs/app/trees/docs/CoreTypes/constants.ts b/apps/docs/app/trees/docs/CoreTypes/constants.ts index 29c2502cd..6f2fee5d2 100644 --- a/apps/docs/app/trees/docs/CoreTypes/constants.ts +++ b/apps/docs/app/trees/docs/CoreTypes/constants.ts @@ -13,7 +13,7 @@ export const FILE_TREE_OPTIONS_TYPE: PreloadFileOptions = { name: 'FileTreeOptions.ts', contents: `import type { FileTreeOptions, - FileTreeIconConfig, + FileTreeIcons, FileTreeStateConfig, FileTreeSearchMode, FileTreeCollision, @@ -48,8 +48,8 @@ interface FileTreeOptions { // Optional: Git status entries for file status indicators. gitStatus?: GitStatusEntry[]; - // Optional: custom SVG sprite sheet and icon remapping. - icons?: FileTreeIconConfig; + // Optional: built-in icon set selection, colors, and custom remapping. + icons?: FileTreeIcons; // Optional: paths that cannot be dragged when drag and drop is enabled. lockedPaths?: string[]; @@ -168,8 +168,15 @@ export const FILE_TREE_ICON_CONFIG_TYPE: PreloadFileOptions = { name: 'FileTreeIconConfig.ts', contents: `import type { FileTreeIconConfig } from '@pierre/trees'; -// FileTreeIconConfig lets you replace built-in icons with custom SVG symbols. +// FileTreeIconConfig lets you pick a built-in set, enable semantic colors, +// or inject your own SVG symbols. interface FileTreeIconConfig { + // Optional: use one of the built-in sets, or "none" for custom-only rules. + set?: 'simple' | 'file-type' | 'duo-tone' | 'none'; + + // Optional: enable built-in per-file-type colors. Default: true. + colored?: boolean; + // An SVG string with definitions injected into the shadow DOM. spriteSheet?: string; @@ -179,12 +186,35 @@ interface FileTreeIconConfig { | string | { name: string; width?: number; height?: number; viewBox?: string } >; + + // Remap file icons by exact basename (e.g. package.json, .gitignore). + byFileName?: Record< + string, + | string + | { name: string; width?: number; height?: number; viewBox?: string } + >; + + // Remap file icons by extension (e.g. ts, tsx, spec.ts). + byFileExtension?: Record< + string, + | string + | { name: string; width?: number; height?: number; viewBox?: string } + >; + + // Remap file icons when filename contains a substring (e.g. dockerfile). + byFileNameContains?: Record< + string, + | string + | { name: string; width?: number; height?: number; viewBox?: string } + >; } -// Example: replace the file and chevron icons with custom symbols. +// Example: use the built-in file-type set with colors enabled, then override one icon. const options = { initialFiles: ['src/index.ts', 'src/components/Button.tsx'], icons: { + set: 'file-type', + colored: true, spriteSheet: \` \`, + byFileExtension: { + ts: 'my-file', + }, remap: { - 'file-tree-icon-file': 'my-file', 'file-tree-icon-chevron': { name: 'my-folder', width: 16, height: 16 }, }, }, diff --git a/apps/docs/app/trees/docs/CoreTypes/content.mdx b/apps/docs/app/trees/docs/CoreTypes/content.mdx index 252690906..9861a24fd 100644 --- a/apps/docs/app/trees/docs/CoreTypes/content.mdx +++ b/apps/docs/app/trees/docs/CoreTypes/content.mdx @@ -46,7 +46,7 @@ folder containing `index.ts`, `utils/helpers.ts`, and a `components` folder with | `lockedPaths` | Optional list of file/folder paths that cannot be dragged when drag and drop is enabled. | | `onCollision` | Optional callback for drag collisions. Return `true` to overwrite destination. | | `gitStatus` | Optional `GitStatusEntry[]` used to show Git-style file status (`added`, `modified`, `deleted`). Folders with changed descendants also receive a change indicator. [Live demo](/preview/trees#path-colors). | -| `icons` | Optional `FileTreeIconConfig` to provide a custom SVG sprite sheet and remap built-in icon names to your own symbols. [Live demo](/preview/trees#custom-icons). | +| `icons` | Optional built-in icon set selection or `FileTreeIconConfig` for semantic colors, CSS-themable palettes, and custom sprite overrides. [Live demo](/preview/trees#custom-icons). | | `sort` | Sort children within each directory. `true` (default) uses the standard sort (folders first, dot-prefixed next, case-insensitive alphabetical). `false` preserves insertion order. `{ comparator: fn }` for custom sorting. | | `virtualize` | Enable virtualized rendering so only visible items are rendered. Pass `{ threshold: number }` to activate when item count exceeds the threshold, or `false` to disable. Default: `undefined` (off). | diff --git a/apps/docs/app/trees/docs/Icons/content.mdx b/apps/docs/app/trees/docs/Icons/content.mdx index 683e77b1e..de9170c3d 100644 --- a/apps/docs/app/trees/docs/Icons/content.mdx +++ b/apps/docs/app/trees/docs/Icons/content.mdx @@ -1,16 +1,34 @@ -## Custom Icons +## Icons -Use the `icons` option inside `FileTreeOptions` to swap built-in icons with your -own SVG symbols. Try the live demo at +Use the `icons` option inside `FileTreeOptions` to choose one of the built-in +icon sets or inject your own SVG sprite. Try the live demo at [/preview/trees#custom-icons](/preview/trees#custom-icons). +- `icons: 'simple' | 'file-type' | 'duo-tone'` — use one of the shipped icon + packs. +- `set` — use the object form to combine a built-in set with `colored`, + `spriteSheet`, or file-specific overrides. +- `colored` — semantic per-file-type colors for built-in `file-type` and + `duo-tone` icons. Defaults to `true`; set `colored: false` to disable it. + Override the palette with CSS variables like + `--trees-file-icon-color-javascript`. - `spriteSheet` — an SVG string containing `` definitions. It is - injected into the shadow DOM alongside the default sprite sheet. + injected into the shadow DOM alongside the selected built-in sprite sheet. - `remap` — a map from a built-in icon name to either a replacement symbol id (string) or an object with `name`, optional `width`, `height`, and `viewBox`. +- `byFileName` — remap the file icon for exact basenames (for example + `package.json` or `.gitignore`). +- `byFileNameContains` — remap the file icon when a basename contains a pattern + (for example `dockerfile` or `license`). +- `byFileExtension` — remap the file icon by extension (for example `ts`, `tsx`, + `spec.ts`, or `json`). -You can re-map any of the existing, default icons (listed below) by creating new -SVG symbols that use the same IDs. +You can remap any of the existing built-in icon slots (listed below) by creating +new SVG symbols that use the same IDs. + +For file rows, icon resolution order is: `byFileName` → `byFileNameContains` → +`byFileExtension` (most specific suffix first) → built-in set mapping → +`remap['file-tree-icon-file']` → fallback file icon. | Icon ID | Description | | ------------------------ | ------------------------------------------------------------------- | diff --git a/apps/docs/app/trees/docs/ReactAPI/constants.ts b/apps/docs/app/trees/docs/ReactAPI/constants.ts index bc7821653..edb95ed92 100644 --- a/apps/docs/app/trees/docs/ReactAPI/constants.ts +++ b/apps/docs/app/trees/docs/ReactAPI/constants.ts @@ -83,26 +83,14 @@ export const REACT_API_CUSTOM_ICONS_EXAMPLE: PreloadFileOptions = { name: 'custom_icons_file_tree.tsx', contents: `import { FileTree } from '@pierre/trees/react'; -const customSpriteSheet = \` - -\`; - -export function CustomIconsTree() { +export function IconSetTree() { return ( = { name: 'custom_icons_file_tree.ts', contents: `import { FileTree } from '@pierre/trees'; -const customSpriteSheet = \` - -\`; - const fileTree = new FileTree({ initialFiles: [ 'src/index.ts', @@ -147,10 +137,8 @@ const fileTree = new FileTree({ 'package.json', ], icons: { - spriteSheet: customSpriteSheet, - remap: { - 'file-tree-icon-file': 'my-file', - }, + set: 'file-type', + colored: true, }, }); diff --git a/apps/docs/app/trees/tree-examples/CustomIconsSection.tsx b/apps/docs/app/trees/tree-examples/CustomIconsSection.tsx index 689be14ba..b56f29a0e 100644 --- a/apps/docs/app/trees/tree-examples/CustomIconsSection.tsx +++ b/apps/docs/app/trees/tree-examples/CustomIconsSection.tsx @@ -1,4 +1,4 @@ -import { IconBrush, IconFire } from '@pierre/icons'; +import { IconBrush, IconFileTreeFill, IconFire } from '@pierre/icons'; import { FileTree } from '@pierre/trees/react'; import { preloadFileTree } from '@pierre/trees/ssr'; import type { CSSProperties } from 'react'; @@ -12,60 +12,46 @@ import { } from './demo-data'; import { TreeExampleSection } from './TreeExampleSection'; -const customSpriteSheet = ` - -`; - const panelStyle = { ...DEFAULT_FILE_TREE_PANEL_STYLE, '--trees-search-bg-override': 'light-dark(#fff, oklch(14.5% 0 0))', } as CSSProperties; -const defaultPrerenderedHTML = preloadFileTree( +const simplePrerenderedHTML = preloadFileTree( + { + ...baseTreeOptions, + id: 'built-in-icons-simple', + lockedPaths: ['package.json'], + icons: 'simple', + }, + { + initialExpandedItems: ['src', 'src/components'], + } +).shadowHtml; + +const fileTypePrerenderedHTML = preloadFileTree( { ...baseTreeOptions, - id: 'custom-icons-default', + id: 'built-in-icons-file-type', lockedPaths: ['package.json'], + icons: { + set: 'file-type', + colored: false, + }, }, { initialExpandedItems: ['src', 'src/components'], } ).shadowHtml; -const remappedPrerenderedHTML = preloadFileTree( +const duoTonePrerenderedHTML = preloadFileTree( { ...baseTreeOptions, - id: 'custom-icons-remapped', + id: 'built-in-icons-duo-tone', lockedPaths: ['package.json'], icons: { - spriteSheet: customSpriteSheet, - remap: { - 'file-tree-icon-file': { - name: 'custom-file-icon', - width: 12, - height: 12, - }, - 'file-tree-icon-chevron': { - name: 'custom-folder-icon', - width: 12, - height: 12, - }, - 'file-tree-icon-lock': { - name: 'custom-lock-icon', - width: 12, - height: 12, - }, - }, + set: 'duo-tone', + colored: true, }, }, { @@ -78,11 +64,15 @@ export function CustomIconsSection() { - Swap out our default icons by using a custom SVG sprite that remaps - the built-in icon names to your custom symbols. See the{' '} + Choose between the shipped simple,{' '} + file-type, and duo-tone icon sets. You can + also enable colored: true, override the built-in + palette with CSS variables like{' '} + --trees-file-icon-color-javascript, or fall back to a + fully custom sprite. See the{' '} FileTreeIconConfig docs {' '} @@ -90,25 +80,26 @@ export function CustomIconsSection() { } /> -
+
} + icon={} description={ <> - The default icons used when no icons option is set. + Generic built-ins with a single file glyph and no file-type map. } > - Default + Simple } + description={ + <>Semantic file-type icons without any extra configuration. + } + > + File-type + + +
+
+ } description={ <> - Pass a spriteSheet and remap via the{' '} - icons option. + With built-in semantic colors enabled via{' '} + colored: true. } > - Remapped + Duo-tone = { + default: { fileType: 'file.svg', duoTone: 'file-duo.svg' }, + typescript: { + fileType: 'lang-typescript.svg', + duoTone: 'lang-typescript-duo.svg', + }, + javascript: { + fileType: 'lang-javascript.svg', + duoTone: 'lang-javascript-duo.svg', + }, + css: { fileType: 'lang-css.svg', duoTone: 'lang-css-duo.svg' }, + react: { fileType: 'react.svg' }, + markdown: { fileType: 'lang-markdown.svg' }, + json: { fileType: 'braces.svg' }, + npm: { fileType: 'npm.svg' }, + git: { fileType: 'git.svg' }, + image: { fileType: 'image.svg', duoTone: 'image-duo.svg' }, + mcp: { fileType: 'mcp.svg' }, +}; + +const TOKENS = Object.keys(TOKEN_SVG_MAP).sort(); + +// Reverse map: theme icon name → our token. +// Excludes file-text-duo (its extensions fall through to 'default' automatically) +// and lang-html-duo (no html token in our system). +const THEME_ICON_TO_TOKEN: Record = { + 'image-duo': 'image', + 'lang-javascript-duo': 'javascript', + 'lang-typescript-duo': 'typescript', + 'lang-css-duo': 'css', + 'lang-markdown': 'markdown', + braces: 'json', + git: 'git', + react: 'react', + npm: 'npm', +}; + +// Manual additions not covered by the theme data +const MANUAL_EXTENSION_TOKENS: Record = { + mcp: 'mcp', + svg: 'image', + 'mdx.tsx': 'markdown', +}; + +const MANUAL_FILENAME_TOKENS: Record = { + 'readme.md': 'markdown', +}; + +// --------------------------------------------------------------------------- +// SVG → transform +// --------------------------------------------------------------------------- + +function readSvg(filename: string): string { + return readFileSync(join(svgsDir, filename), 'utf8'); +} + +function extractSvgInner(svg: string): string { + const openMatch = svg.match(/]*>/); + if (openMatch == null) throw new Error('No open tag found'); + const openEnd = (openMatch.index ?? 0) + openMatch[0].length; + const closeIdx = svg.lastIndexOf(''); + if (closeIdx < 0) throw new Error('No close tag found'); + return svg.slice(openEnd, closeIdx).trim(); +} + +function svgToSymbol( + filename: string, + symbolId: string, + viewBox = '0 0 16 16' +): string { + const inner = extractSvgInner(readSvg(filename)); + + const indented = inner + .split('\n') + .map((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 ? ` ${trimmed}` : ''; + }) + .filter((line) => line.length > 0) + .join('\n'); + + return `\n${indented}\n`; +} + +// --------------------------------------------------------------------------- +// Build extension / filename token maps from theme data +// --------------------------------------------------------------------------- + +interface ThemeEntry { + name: string; + fileExtensions?: string[]; + fileNames?: string[]; + color?: unknown; + opacity?: number; +} + +async function loadThemeTier(filename: string): Promise { + const mod = await import(join(themesDir, filename)); + return mod.default as ThemeEntry[]; +} + +async function buildTokenMaps(): Promise<{ + extensionTokens: Record; + fileNameTokens: Record; +}> { + const minimal = await loadThemeTier('minimal.mjs'); + const defaults = await loadThemeTier('default.mjs'); + const complete = await loadThemeTier('complete.mjs'); + const allEntries = [...minimal, ...defaults, ...complete]; + + const extensionTokens: Record = {}; + const fileNameTokens: Record = {}; + + for (const entry of allEntries) { + const token = THEME_ICON_TO_TOKEN[entry.name]; + if (token == null) continue; + + if (entry.fileExtensions != null) { + for (const ext of entry.fileExtensions) { + extensionTokens[ext] = token; + } + } + if (entry.fileNames != null) { + for (const name of entry.fileNames) { + fileNameTokens[name.toLowerCase()] = token; + } + } + } + + for (const [ext, token] of Object.entries(MANUAL_EXTENSION_TOKENS)) { + extensionTokens[ext] = token; + } + for (const [name, token] of Object.entries(MANUAL_FILENAME_TOKENS)) { + fileNameTokens[name] = token; + } + + return { extensionTokens, fileNameTokens }; +} + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +function generateSymbolConstants(): { + fileTypeSymbols: string[]; + duoToneSymbols: string[]; + declarations: string; +} { + const fileTypeSymbols: string[] = []; + const duoToneSymbols: string[] = []; + const lines: string[] = []; + + for (const token of TOKENS) { + const entry = TOKEN_SVG_MAP[token]; + const ftId = `file-tree-builtin-file-type-${token}`; + const dtId = `file-tree-builtin-duo-tone-${token}`; + const ftVarName = `ft_${token.replace(/-/g, '_')}`; + const dtVarName = `dt_${token.replace(/-/g, '_')}`; + + const ftSymbol = svgToSymbol(entry.fileType, ftId); + lines.push(`const ${ftVarName} = \`${ftSymbol}\`;`); + fileTypeSymbols.push(ftVarName); + + if (entry.duoTone != null) { + const dtSymbol = svgToSymbol(entry.duoTone, dtId); + lines.push(`const ${dtVarName} = \`${dtSymbol}\`;`); + duoToneSymbols.push(dtVarName); + } else { + lines.push( + `const ${dtVarName} = ${ftVarName}.replaceAll('${ftId}', '${dtId}');` + ); + duoToneSymbols.push(dtVarName); + } + + lines.push(''); + } + + return { + fileTypeSymbols, + duoToneSymbols, + declarations: lines.join('\n'), + }; +} + +function formatRecord(entries: Record, indent: string): string { + const sorted = Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)); + return sorted.map(([k, v]) => `${indent}'${k}': '${v}',`).join('\n'); +} + +async function generate(): Promise { + const { extensionTokens, fileNameTokens } = await buildTokenMaps(); + const { fileTypeSymbols, duoToneSymbols, declarations } = + generateSymbolConstants(); + + const tokenType = TOKENS.map((t) => ` | '${t}'`).join('\n'); + + return `// @generated by scripts/generate-built-in-icons.ts — do not edit manually +import type { FileTreeBuiltInIconSet } from './iconConfig'; + +export type BuiltInFileIconToken = +${tokenType}; + +const SIMPLE_SVG_SPRITE_SHEET = \`\`; + +${declarations} +const fileTypeSetSymbols = [ +${fileTypeSymbols.map((v) => ` ${v},`).join('\n')} +]; + +const duoToneSetSymbols = [ +${duoToneSymbols.map((v) => ` ${v},`).join('\n')} +]; + +function appendSymbols(spriteSheet: string, symbols: string[]): string { + if (symbols.length === 0) return spriteSheet; + return spriteSheet.replace('', \`\\n \${symbols.join('\\n ')}\\n\`); +} + +const BUILT_IN_SVG_SPRITE_SHEETS: Record = { + simple: SIMPLE_SVG_SPRITE_SHEET, + 'file-type': appendSymbols(SIMPLE_SVG_SPRITE_SHEET, fileTypeSetSymbols), + 'duo-tone': appendSymbols(SIMPLE_SVG_SPRITE_SHEET, duoToneSetSymbols), +}; + +const BUILT_IN_FILE_NAME_TOKENS: Partial> = + { +${formatRecord(fileNameTokens, ' ')} + }; + +const BUILT_IN_FILE_EXTENSION_TOKENS: Partial< + Record +> = { +${formatRecord(extensionTokens, ' ')} +}; + +const COLORED_ICON_SET_PALETTES = new Set([ + 'file-type', + 'duo-tone', +]); + +export function getBuiltInSpriteSheet( + set: FileTreeBuiltInIconSet | 'none' +): string { + const builtInSet = set === 'none' ? 'simple' : set; + return BUILT_IN_SVG_SPRITE_SHEETS[builtInSet]; +} + +export function getBuiltInFileIconName( + set: FileTreeBuiltInIconSet, + token: BuiltInFileIconToken +): string { + return \`file-tree-builtin-\${set}-\${token}\`; +} + +export function isColoredBuiltInIconSet( + set: FileTreeBuiltInIconSet | 'none' +): boolean { + return set !== 'none' && COLORED_ICON_SET_PALETTES.has(set); +} + +export function resolveBuiltInFileIconToken( + set: FileTreeBuiltInIconSet | 'none', + fileName: string, + extensionCandidates: string[] +): BuiltInFileIconToken | undefined { + if (set === 'simple' || set === 'none') { + return undefined; + } + + const lowerFileName = fileName.toLowerCase(); + const exactMatch = BUILT_IN_FILE_NAME_TOKENS[lowerFileName]; + if (exactMatch != null) { + return exactMatch; + } + + for (const extension of extensionCandidates) { + const match = BUILT_IN_FILE_EXTENSION_TOKENS[extension]; + if (match != null) { + return match; + } + } + + return 'default'; +} +`; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const outputPath = join( + dirname(fileURLToPath(import.meta.url)), + '..', + 'src', + 'builtInIcons.ts' +); + +const content = await generate(); +writeFileSync(outputPath, content); +console.log(`Wrote ${outputPath}`); diff --git a/packages/trees/scripts/lib/benchmarkVirtualizedRenderRuntime.tsx b/packages/trees/scripts/lib/benchmarkVirtualizedRenderRuntime.tsx index 3c23da3c2..4091d3b78 100644 --- a/packages/trees/scripts/lib/benchmarkVirtualizedRenderRuntime.tsx +++ b/packages/trees/scripts/lib/benchmarkVirtualizedRenderRuntime.tsx @@ -228,7 +228,10 @@ function createBenchmarkVirtualizedRoot( useLazyDataLoader, virtualize, } = fileTreeOptions; - const iconRemap = fileTreeOptions.icons?.remap; + const iconRemap = + typeof fileTreeOptions.icons === 'object' + ? fileTreeOptions.icons?.remap + : undefined; const remapIcon = useCallback( ( diff --git a/packages/trees/src/FileTree.ts b/packages/trees/src/FileTree.ts index a217a6f7f..489e696cd 100644 --- a/packages/trees/src/FileTree.ts +++ b/packages/trees/src/FileTree.ts @@ -1,3 +1,4 @@ +import { getBuiltInSpriteSheet, isColoredBuiltInIconSet } from './builtInIcons'; import type { FileTreeRootProps } from './components/Root'; import { FileTreeContainerLoaded } from './components/web-components'; import { @@ -6,12 +7,13 @@ import { FLATTENED_PREFIX, } from './constants'; import type { TreeInstance } from './core/types/core'; +import type { FileTreeIcons } from './iconConfig'; +import { normalizeFileTreeIcons } from './iconConfig'; import { getBenchmarkInstrumentation, inheritBenchmarkInstrumentation, withBenchmarkPhase, } from './internal/benchmarkInstrumentation'; -import { SVGSpriteSheet } from './sprite'; import type { ContextMenuItem, ContextMenuOpenContext, @@ -35,6 +37,12 @@ import { import type { ChildrenComparator } from './utils/sortChildren'; export type { GitStatusEntry } from './types'; +export type { + FileTreeBuiltInIconSet, + FileTreeIconConfig, + FileTreeIcons, + RemappedIcon, +} from './iconConfig'; let instanceId = -1; @@ -108,19 +116,6 @@ export interface FileTreeCallbacks { _onRenameFiles?: (newFiles: string[]) => void; } -type RemappedIcon = - | string - | { - name: string; - width?: number; - height?: number; - viewBox?: string; - }; -export interface FileTreeIconConfig { - spriteSheet?: string; - remap?: Record; -} - export interface FileTreeOptions { dragAndDrop?: boolean; fileTreeSearchMode?: FileTreeSearchMode; @@ -147,7 +142,7 @@ export interface FileTreeOptions { /** Enable virtualized rendering. Items are only rendered when visible. * `threshold` is the minimum item count to activate virtualization. */ virtualize?: { threshold: number } | false; - icons?: FileTreeIconConfig; + icons?: FileTreeIcons; } export interface FileTreeStateConfig { @@ -186,7 +181,6 @@ export class FileTree { __id: string; private fileTreeContainer: HTMLElement | undefined; private divWrapper: HTMLDivElement | undefined; - private defaultSpriteSheet: SVGElement | undefined; private unsafeCSSStyle: HTMLStyleElement | undefined; /** Populated by the Preact Root component with the tree instance + maps. */ @@ -603,6 +597,7 @@ export class FileTree { this.syncIconSpriteSheets(this.fileTreeContainer); this.syncUnsafeCSS(this.fileTreeContainer); } + this.syncIconModeAttrs(this.divWrapper); this.syncVirtualizedLayoutAttrs(this.fileTreeContainer, this.divWrapper); preactRenderRoot(this.divWrapper, this.buildRootProps()); } @@ -617,7 +612,7 @@ export class FileTree { return undefined; } - private isDefaultSpriteSheet(spriteSheet: SVGElement): boolean { + private isBuiltInSpriteSheet(spriteSheet: SVGElement): boolean { return ( spriteSheet.querySelector('#file-tree-icon-chevron') instanceof SVGElement && @@ -633,38 +628,42 @@ export class FileTree { ); } - private ensureDefaultSpriteSheet(shadowRoot: ShadowRoot): void { - let defaultSprite = - this.defaultSpriteSheet != null && - this.defaultSpriteSheet.parentNode === shadowRoot - ? this.defaultSpriteSheet - : undefined; - - defaultSprite ??= this.getTopLevelSpriteSheets(shadowRoot).find((sprite) => - this.isDefaultSpriteSheet(sprite) + private syncBuiltInSpriteSheet(shadowRoot: ShadowRoot): void { + const currentBuiltInSprite = this.getTopLevelSpriteSheets(shadowRoot).find( + (sprite) => this.isBuiltInSpriteSheet(sprite) + ); + const nextBuiltInSprite = this.parseSpriteSheet( + getBuiltInSpriteSheet(normalizeFileTreeIcons(this.options.icons).set) ); + if (nextBuiltInSprite == null) { + return; + } - if (defaultSprite == null) { - const builtInSprite = this.parseSpriteSheet(SVGSpriteSheet); - if (builtInSprite != null) { - shadowRoot.appendChild(builtInSprite); - defaultSprite = builtInSprite; - } + if ( + currentBuiltInSprite != null && + currentBuiltInSprite.outerHTML === nextBuiltInSprite.outerHTML + ) { + return; } - this.defaultSpriteSheet = defaultSprite; + if (currentBuiltInSprite != null) { + currentBuiltInSprite.replaceWith(nextBuiltInSprite); + } else { + shadowRoot.appendChild(nextBuiltInSprite); + } } private syncCustomSpriteSheet(shadowRoot: ShadowRoot): void { const topLevelSprites = this.getTopLevelSpriteSheets(shadowRoot); - const defaultSprite = topLevelSprites.find((sprite) => - this.isDefaultSpriteSheet(sprite) + const builtInSprite = topLevelSprites.find((sprite) => + this.isBuiltInSpriteSheet(sprite) ); const currentCustomSprites = topLevelSprites.filter( - (sprite) => sprite !== defaultSprite + (sprite) => sprite !== builtInSprite ); - const customSpriteSheet = this.options.icons?.spriteSheet?.trim() ?? ''; + const customSpriteSheet = + normalizeFileTreeIcons(this.options.icons).spriteSheet?.trim() ?? ''; if (customSpriteSheet.length === 0) { for (const customSprite of currentCustomSprites) { customSprite.remove(); @@ -699,10 +698,27 @@ export class FileTree { return; } - this.ensureDefaultSpriteSheet(shadowRoot); + this.syncBuiltInSpriteSheet(shadowRoot); this.syncCustomSpriteSheet(shadowRoot); } + private syncIconModeAttrs(divWrapper?: HTMLElement): void { + const wrapper = divWrapper ?? this.divWrapper; + if (wrapper == null) { + return; + } + + const normalizedIcons = normalizeFileTreeIcons(this.options.icons); + if ( + normalizedIcons.colored && + isColoredBuiltInIconSet(normalizedIcons.set) + ) { + wrapper.dataset.fileTreeColoredIcons = 'true'; + } else { + delete wrapper.dataset.fileTreeColoredIcons; + } + } + private syncUnsafeCSS(fileTreeContainer: HTMLElement): void { const shadowRoot = fileTreeContainer.shadowRoot; if (shadowRoot == null) { @@ -796,6 +812,7 @@ export class FileTree { if (this.divWrapper == null) { this.divWrapper = document.createElement('div'); this.divWrapper.dataset.fileTreeId = this.__id.toString(); + this.syncIconModeAttrs(this.divWrapper); container.shadowRoot?.appendChild(this.divWrapper); } } @@ -816,6 +833,7 @@ export class FileTree { containerWrapper ); const divWrapper = this.getOrCreateDivWrapperNode(fileTreeContainer); + this.syncIconModeAttrs(divWrapper); this.syncVirtualizedLayoutAttrs(fileTreeContainer, divWrapper); // Seed the first virtualized window with the outer mount height so client @@ -874,6 +892,7 @@ export class FileTree { this.fileTreeContainer = fileTreeContainer; this.syncIconSpriteSheets(fileTreeContainer); this.syncUnsafeCSS(fileTreeContainer); + this.syncIconModeAttrs(this.divWrapper); this.syncVirtualizedLayoutAttrs(fileTreeContainer, this.divWrapper); if (this.divWrapper == null) { @@ -909,6 +928,5 @@ export class FileTree { this.unsafeCSSStyle = undefined; this.fileTreeContainer = undefined; this.divWrapper = undefined; - this.defaultSpriteSheet = undefined; } } diff --git a/packages/trees/src/builtInIcons.ts b/packages/trees/src/builtInIcons.ts new file mode 100644 index 000000000..40a8b8422 --- /dev/null +++ b/packages/trees/src/builtInIcons.ts @@ -0,0 +1,267 @@ +// @generated by scripts/generate-built-in-icons.ts — do not edit manually +import type { FileTreeBuiltInIconSet } from './iconConfig'; + +export type BuiltInFileIconToken = + | 'css' + | 'default' + | 'git' + | 'image' + | 'javascript' + | 'json' + | 'markdown' + | 'mcp' + | 'npm' + | 'react' + | 'typescript'; + +const SIMPLE_SVG_SPRITE_SHEET = ``; + +const ft_css = ` + +`; +const dt_css = ` + + +`; + +const ft_default = ` + +`; +const dt_default = ` + + +`; + +const ft_git = ` + +`; +const dt_git = ft_git.replaceAll( + 'file-tree-builtin-file-type-git', + 'file-tree-builtin-duo-tone-git' +); + +const ft_image = ` + + +`; +const dt_image = ` + + +`; + +const ft_javascript = ` + +`; +const dt_javascript = ` + + +`; + +const ft_json = ` + +`; +const dt_json = ft_json.replaceAll( + 'file-tree-builtin-file-type-json', + 'file-tree-builtin-duo-tone-json' +); + +const ft_markdown = ` + +`; +const dt_markdown = ft_markdown.replaceAll( + 'file-tree-builtin-file-type-markdown', + 'file-tree-builtin-duo-tone-markdown' +); + +const ft_mcp = ` + + +`; +const dt_mcp = ft_mcp.replaceAll( + 'file-tree-builtin-file-type-mcp', + 'file-tree-builtin-duo-tone-mcp' +); + +const ft_npm = ` + +`; +const dt_npm = ft_npm.replaceAll( + 'file-tree-builtin-file-type-npm', + 'file-tree-builtin-duo-tone-npm' +); + +const ft_react = ` + + +`; +const dt_react = ft_react.replaceAll( + 'file-tree-builtin-file-type-react', + 'file-tree-builtin-duo-tone-react' +); + +const ft_typescript = ` + + +`; +const dt_typescript = ` + + +`; + +const fileTypeSetSymbols = [ + ft_css, + ft_default, + ft_git, + ft_image, + ft_javascript, + ft_json, + ft_markdown, + ft_mcp, + ft_npm, + ft_react, + ft_typescript, +]; + +const duoToneSetSymbols = [ + dt_css, + dt_default, + dt_git, + dt_image, + dt_javascript, + dt_json, + dt_markdown, + dt_mcp, + dt_npm, + dt_react, + dt_typescript, +]; + +function appendSymbols(spriteSheet: string, symbols: string[]): string { + if (symbols.length === 0) return spriteSheet; + return spriteSheet.replace('', `\n ${symbols.join('\n ')}\n`); +} + +const BUILT_IN_SVG_SPRITE_SHEETS: Record = { + simple: SIMPLE_SVG_SPRITE_SHEET, + 'file-type': appendSymbols(SIMPLE_SVG_SPRITE_SHEET, fileTypeSetSymbols), + 'duo-tone': appendSymbols(SIMPLE_SVG_SPRITE_SHEET, duoToneSetSymbols), +}; + +const BUILT_IN_FILE_NAME_TOKENS: Partial> = + { + '.gitattributes': 'git', + '.gitignore': 'git', + '.gitkeep': 'git', + '.gitmodules': 'git', + '.npmignore': 'npm', + '.npmrc': 'npm', + 'package-lock.json': 'npm', + 'package.json': 'npm', + 'readme.md': 'markdown', + }; + +const BUILT_IN_FILE_EXTENSION_TOKENS: Partial< + Record +> = { + avif: 'image', + bmp: 'image', + cjs: 'javascript', + css: 'css', + cts: 'typescript', + gif: 'image', + icns: 'image', + ico: 'image', + jpeg: 'image', + jpg: 'image', + js: 'javascript', + json: 'json', + json5: 'json', + jsonc: 'json', + jsonl: 'json', + jsx: 'react', + less: 'css', + markdown: 'markdown', + mcp: 'mcp', + md: 'markdown', + mdx: 'markdown', + 'mdx.tsx': 'markdown', + mjs: 'javascript', + mts: 'typescript', + png: 'image', + postcss: 'css', + sass: 'css', + scss: 'css', + styl: 'css', + svg: 'image', + tif: 'image', + tiff: 'image', + ts: 'typescript', + tsx: 'react', + webp: 'image', +}; + +const COLORED_ICON_SET_PALETTES = new Set([ + 'file-type', + 'duo-tone', +]); + +export function getBuiltInSpriteSheet( + set: FileTreeBuiltInIconSet | 'none' +): string { + const builtInSet = set === 'none' ? 'simple' : set; + return BUILT_IN_SVG_SPRITE_SHEETS[builtInSet]; +} + +export function getBuiltInFileIconName( + set: FileTreeBuiltInIconSet, + token: BuiltInFileIconToken +): string { + return `file-tree-builtin-${set}-${token}`; +} + +export function isColoredBuiltInIconSet( + set: FileTreeBuiltInIconSet | 'none' +): boolean { + return set !== 'none' && COLORED_ICON_SET_PALETTES.has(set); +} + +export function resolveBuiltInFileIconToken( + set: FileTreeBuiltInIconSet | 'none', + fileName: string, + extensionCandidates: string[] +): BuiltInFileIconToken | undefined { + if (set === 'simple' || set === 'none') { + return undefined; + } + + const lowerFileName = fileName.toLowerCase(); + const exactMatch = BUILT_IN_FILE_NAME_TOKENS[lowerFileName]; + if (exactMatch != null) { + return exactMatch; + } + + for (const extension of extensionCandidates) { + const match = BUILT_IN_FILE_EXTENSION_TOKENS[extension]; + if (match != null) { + return match; + } + } + + return 'default'; +} diff --git a/packages/trees/src/components/Icon.tsx b/packages/trees/src/components/Icon.tsx index b71456a9b..d756c57c3 100644 --- a/packages/trees/src/components/Icon.tsx +++ b/packages/trees/src/components/Icon.tsx @@ -28,6 +28,7 @@ const ICON_SIZE_OVERRIDES: Record< export function Icon({ name, remappedFrom, + token, width: propWidth, height: propHeight, viewBox: propViewBox, @@ -36,6 +37,7 @@ export function Icon({ }: { name: string; remappedFrom?: string; + token?: string; width?: number; height?: number; viewBox?: string; @@ -66,6 +68,7 @@ export function Icon({ return ( + value.trim().replace(/^\./, '').toLowerCase(); + +const getBaseFileName = (path: string): string => { + const slashIndex = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); + return slashIndex >= 0 ? path.slice(slashIndex + 1) : path; +}; + +const getExtensionCandidates = (fileName: string): string[] => { + const parts = fileName.toLowerCase().split('.'); + if (parts.length <= 1) return []; + const extensions: string[] = []; + for (let index = 1; index < parts.length; index += 1) { + const extension = parts.slice(index).join('.'); + if (extension.length > 0) { + extensions.push(extension); + } + } + return extensions; +}; + // Reuses the last rebuild's visible ID list so virtualized rendering can size // and slice the tree without forcing core to instantiate every visible item. function getVisibleItemIds(tree: TreeInstance): string[] { @@ -123,23 +159,95 @@ export function Root({ ? renaming : undefined; - const iconRemap = fileTreeOptions.icons?.remap; + const normalizedIcons = useMemo( + () => normalizeFileTreeIcons(fileTreeOptions.icons), + [fileTreeOptions.icons] + ); + const iconRemap = normalizedIcons.remap; + const iconByFileName = useMemo(() => { + const entries = normalizedIcons.byFileName; + const map = new Map(); + if (entries == null) return map; + for (const [fileName, icon] of Object.entries(entries)) { + map.set(fileName.toLowerCase(), icon); + } + return map; + }, [normalizedIcons.byFileName]); + const iconByFileExtension = useMemo(() => { + const entries = normalizedIcons.byFileExtension; + const map = new Map(); + if (entries == null) return map; + for (const [extension, icon] of Object.entries(entries)) { + map.set(normalizeIconRuleKey(extension), icon); + } + return map; + }, [normalizedIcons.byFileExtension]); + const iconByFileNameContains = useMemo(() => { + const entries = normalizedIcons.byFileNameContains; + if (entries == null) return [] as [string, RemappedIcon][]; + return Object.entries(entries).map( + ([needle, icon]): [string, RemappedIcon] => [needle.toLowerCase(), icon] + ); + }, [normalizedIcons.byFileNameContains]); + const remapEntryToIcon = useCallback( + (entry: RemappedIcon, remappedFrom: SVGSpriteNames): RemappedIconProps => { + if (typeof entry === 'string') { + return { name: entry, remappedFrom }; + } + return { ...entry, remappedFrom }; + }, + [] + ); const remapIcon = useCallback( - ( - name: SVGSpriteNames - ): { - name: string; - remappedFrom?: string; - width?: number; - height?: number; - viewBox?: string; - } => { + (name: SVGSpriteNames, filePath?: string): RemappedIconProps => { + if (name === 'file-tree-icon-file' && filePath != null) { + const fileName = getBaseFileName(filePath); + const lowerFileName = fileName.toLowerCase(); + const fileNameEntry = iconByFileName.get(lowerFileName); + if (fileNameEntry != null) { + return remapEntryToIcon(fileNameEntry, name); + } + + for (const [needle, matchEntry] of iconByFileNameContains) { + if (lowerFileName.includes(needle)) { + return remapEntryToIcon(matchEntry, name); + } + } + + const extensionCandidates = getExtensionCandidates(fileName); + for (const extension of extensionCandidates) { + const extensionEntry = iconByFileExtension.get(extension); + if (extensionEntry != null) { + return remapEntryToIcon(extensionEntry, name); + } + } + + const builtInToken = resolveBuiltInFileIconToken( + normalizedIcons.set, + fileName, + extensionCandidates + ); + if (builtInToken != null && normalizedIcons.set !== 'none') { + return { + name: getBuiltInFileIconName(normalizedIcons.set, builtInToken), + remappedFrom: name, + token: builtInToken, + }; + } + } + const entry = iconRemap?.[name]; if (entry == null) return { name }; - if (typeof entry === 'string') return { name: entry, remappedFrom: name }; - return { ...entry, remappedFrom: name }; + return remapEntryToIcon(entry, name); }, - [iconRemap] + [ + iconByFileExtension, + iconByFileName, + iconByFileNameContains, + iconRemap, + normalizedIcons.set, + remapEntryToIcon, + ] ); const treeDomId = useMemo(() => { diff --git a/packages/trees/src/components/TreeItem.tsx b/packages/trees/src/components/TreeItem.tsx index 346ac8cf9..8addb11d3 100644 --- a/packages/trees/src/components/TreeItem.tsx +++ b/packages/trees/src/components/TreeItem.tsx @@ -149,7 +149,10 @@ export interface TreeItemProps { idToPath: IdToPathLookup; ancestors: string[]; treeDomId: string; - remapIcon: (name: SVGSpriteNames) => { + remapIcon: ( + name: SVGSpriteNames, + filePath?: string + ) => { name: string; remappedFrom?: string; width?: number; @@ -183,7 +186,8 @@ function treeItemPropsAreEqual( prev.containsGitChange === next.containsGitChange && prev.flattens === next.flattens && prev.ancestors === next.ancestors && - prev.treeDomId === next.treeDomId + prev.treeDomId === next.treeDomId && + prev.remapIcon === next.remapIcon ); } @@ -308,7 +312,9 @@ function TreeItemInner({ alignCapitals={alignCapitals} /> ) : ( - + )}
diff --git a/packages/trees/src/iconConfig.ts b/packages/trees/src/iconConfig.ts new file mode 100644 index 000000000..aa2f64d17 --- /dev/null +++ b/packages/trees/src/iconConfig.ts @@ -0,0 +1,68 @@ +export type RemappedIcon = + | string + | { + name: string; + width?: number; + height?: number; + viewBox?: string; + }; + +export type FileTreeBuiltInIconSet = 'simple' | 'file-type' | 'duo-tone'; + +export interface FileTreeIconConfig { + /** Use one of the built-in icon sets, or `none` for custom-only icon rules. */ + set?: FileTreeBuiltInIconSet | 'none'; + /** Enable semantic per-file-type colors for built-in icon sets. */ + colored?: boolean; + /** An SVG string with definitions injected into the shadow DOM. */ + spriteSheet?: string; + /** Remap built-in tree icon slots (file, chevron, dot, lock). */ + remap?: Record; + /** Remap file icons by exact basename (e.g. "package.json", ".gitignore"). */ + byFileName?: Record; + /** Remap file icons by extension without a leading dot (e.g. "ts", "spec.ts"). */ + byFileExtension?: Record; + /** Remap file icons by basename substring (e.g. "dockerfile", "license"). */ + byFileNameContains?: Record; +} + +export type FileTreeIcons = FileTreeBuiltInIconSet | FileTreeIconConfig; + +export interface NormalizedFileTreeIconConfig extends FileTreeIconConfig { + set: FileTreeBuiltInIconSet | 'none'; + colored: boolean; +} + +function hasCustomIconOverrides(icons: FileTreeIconConfig): boolean { + return ( + icons.spriteSheet != null || + icons.remap != null || + icons.byFileName != null || + icons.byFileExtension != null || + icons.byFileNameContains != null + ); +} + +export function normalizeFileTreeIcons( + icons?: FileTreeIcons +): NormalizedFileTreeIconConfig { + if (icons == null) { + return { + set: 'simple', + colored: true, + }; + } + + if (typeof icons === 'string') { + return { + set: icons, + colored: true, + }; + } + + return { + ...icons, + set: icons.set ?? (hasCustomIconOverrides(icons) ? 'none' : 'simple'), + colored: icons.colored ?? true, + }; +} diff --git a/packages/trees/src/sprite.ts b/packages/trees/src/sprite.ts index 012488a59..fc2591638 100644 --- a/packages/trees/src/sprite.ts +++ b/packages/trees/src/sprite.ts @@ -4,21 +4,3 @@ export type SVGSpriteNames = | 'file-tree-icon-dot' | 'file-tree-icon-lock' | 'file-tree-icon-ellipsis'; - -export const SVGSpriteSheet = ``; diff --git a/packages/trees/src/ssr/preloadFileTree.tsx b/packages/trees/src/ssr/preloadFileTree.tsx index 3d5a1fd94..68c47599f 100644 --- a/packages/trees/src/ssr/preloadFileTree.tsx +++ b/packages/trees/src/ssr/preloadFileTree.tsx @@ -1,13 +1,17 @@ /** @jsxImportSource preact */ import { renderToString } from 'preact-render-to-string'; +import { + getBuiltInSpriteSheet, + isColoredBuiltInIconSet, +} from '../builtInIcons'; import { Root } from '../components/Root'; import { FILE_TREE_STYLE_ATTRIBUTE, FILE_TREE_UNSAFE_CSS_ATTRIBUTE, } from '../constants'; import type { FileTreeOptions, FileTreeStateConfig } from '../FileTree'; -import { SVGSpriteSheet } from '../sprite'; +import { normalizeFileTreeIcons } from '../iconConfig'; import fileTreeStyles from '../style.css'; import { wrapUnsafeCSS } from '../utils/cssWrappers'; @@ -27,14 +31,19 @@ export function preloadFileTree( stateConfig?: FileTreeStateConfig ): FileTreeSsrPayload { const id = fileTreeOptions.id ?? `ft_srv_${++ssrInstanceId}`; - const customSpriteSheet = fileTreeOptions.icons?.spriteSheet?.trim() ?? ''; + const normalizedIcons = normalizeFileTreeIcons(fileTreeOptions.icons); + const customSpriteSheet = normalizedIcons.spriteSheet?.trim() ?? ''; + const coloredIconsAttr = + normalizedIcons.colored && isColoredBuiltInIconSet(normalizedIcons.set) + ? ' data-file-tree-colored-icons="true"' + : ''; const unsafeCSS = fileTreeOptions.unsafeCSS?.trim(); const unsafeStyle = unsafeCSS != null && unsafeCSS.length > 0 ? `` : ''; - const shadowHtml = `${SVGSpriteSheet}${customSpriteSheet}${unsafeStyle} -
+ const shadowHtml = `${getBuiltInSpriteSheet(normalizedIcons.set)}${customSpriteSheet}${unsafeStyle} +
${renderToString()}
`; diff --git a/packages/trees/src/style.css b/packages/trees/src/style.css index 9714b071d..a0a10eb0e 100644 --- a/packages/trees/src/style.css +++ b/packages/trees/src/style.css @@ -43,6 +43,20 @@ --trees-git-modified-color-override --trees-git-deleted-color-override + // Built-in File Icon Color Overrides + --trees-file-icon-color + --trees-file-icon-color-css + --trees-file-icon-color-default + --trees-file-icon-color-git + --trees-file-icon-color-image + --trees-file-icon-color-javascript + --trees-file-icon-color-json + --trees-file-icon-color-markdown + --trees-file-icon-color-mcp + --trees-file-icon-color-npm + --trees-file-icon-color-react + --trees-file-icon-color-typescript + // Available CSS Layout Overrides --trees-gap-override --trees-border-radius-override @@ -97,7 +111,7 @@ --trees-border-color-override, var( --trees-theme-sidebar-border, - light-dark(oklch(0% 0 0 / 0.15), oklch(100% 0 0 / 0.15)) + light-dark(oklch(0% 0 0 / 0.2), oklch(100% 0 0 / 0.2)) ) ); --trees-border-radius: var(--trees-border-radius-override, 6px); @@ -190,6 +204,62 @@ var(--trees-status-deleted) ); + --trees-icon-gray: light-dark(#84848a, #adadb1); + --trees-icon-red: light-dark(#d52c36, #ff6762); + --trees-icon-orange: light-dark(#d47628, #ffa359); + --trees-icon-yellow: light-dark(#d5a910, #ffd452); + --trees-icon-green: light-dark(#199f43, #5ecc71); + --trees-icon-teal: light-dark(#17a5af, #64d1db); + --trees-icon-cyan: light-dark(#1ca1c7, #68cdf2); + --trees-icon-blue: light-dark(#1a85d4, #69b1ff); + --trees-icon-indigo: light-dark(#693acf, #9d6afb); + --trees-icon-pink: light-dark(#d32a61, #ff678d); + + --trees-file-icon-color-default: var( + --trees-file-icon-color, + var(--trees-icon-gray) + ); + --trees-file-icon-color-css: var( + --trees-file-icon-color, + var(--trees-icon-indigo) + ); + --trees-file-icon-color-git: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --trees-file-icon-color-image: var( + --trees-file-icon-color, + var(--trees-icon-pink) + ); + --trees-file-icon-color-javascript: var( + --trees-file-icon-color, + var(--trees-icon-yellow) + ); + --trees-file-icon-color-json: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --trees-file-icon-color-markdown: var( + --trees-file-icon-color, + var(--trees-icon-green) + ); + --trees-file-icon-color-mcp: var( + --trees-file-icon-color, + var(--trees-icon-teal) + ); + --trees-file-icon-color-npm: var( + --trees-file-icon-color, + var(--trees-icon-red) + ); + --trees-file-icon-color-react: var( + --trees-file-icon-color, + var(--trees-icon-cyan) + ); + --trees-file-icon-color-typescript: var( + --trees-file-icon-color, + var(--trees-icon-blue) + ); + --trees-level-gap: var(--trees-level-gap-override, 8px); --trees-item-padding-x: var(--trees-item-padding-x-override, 8px); --trees-item-margin-x: var(--trees-item-margin-x-override, 2px); @@ -379,6 +449,46 @@ width: var(--trees-icon-width); } + :where([data-item-section='icon'] > [data-icon-token]) { + color: var(--trees-fg-muted); + } + + [data-file-tree-colored-icons='true'] { + [data-icon-token='css'] { + color: var(--trees-file-icon-color-css); + } + [data-icon-token='default'] { + color: var(--trees-file-icon-color-default); + } + [data-icon-token='git'] { + color: var(--trees-file-icon-color-git); + } + [data-icon-token='image'] { + color: var(--trees-file-icon-color-image); + } + [data-icon-token='javascript'] { + color: var(--trees-file-icon-color-javascript); + } + [data-icon-token='json'] { + color: var(--trees-file-icon-color-json); + } + [data-icon-token='markdown'] { + color: var(--trees-file-icon-color-markdown); + } + [data-icon-token='mcp'] { + color: var(--trees-file-icon-color-mcp); + } + [data-icon-token='npm'] { + color: var(--trees-file-icon-color-npm); + } + [data-icon-token='react'] { + color: var(--trees-file-icon-color-react); + } + [data-icon-token='typescript'] { + color: var(--trees-file-icon-color-typescript); + } + } + /* Chevron rotation and visual alignment */ /* Chevron pointing down */ [data-icon-name='file-tree-icon-chevron'] { @@ -485,8 +595,9 @@ } :host(:hover) [data-item-section='spacing-item'] { - opacity: 0.55; + opacity: 0.75; } + [data-item-selected='true'], [data-type='item']:focus-visible { & [data-item-section='spacing-item'] { diff --git a/packages/trees/test/ssr-declarative-shadow-dom.test.ts b/packages/trees/test/ssr-declarative-shadow-dom.test.ts index 7e6cb0e7d..542267bc2 100644 --- a/packages/trees/test/ssr-declarative-shadow-dom.test.ts +++ b/packages/trees/test/ssr-declarative-shadow-dom.test.ts @@ -63,6 +63,26 @@ const CUSTOM_SPRITE_B = ` `; +const FILE_TYPE_CUSTOM_SPRITE = ` + + + + + + + + + + + + + + + + + +`; + describe('SSR + declarative shadow DOM', () => { test('preloadFileTree returns an id and shadow HTML containing the expected wrapper', () => { const payload = preloadFileTree({ @@ -469,6 +489,249 @@ describe('SSR + declarative shadow DOM', () => { ).not.toThrow(); }); + test('preloadFileTree remaps file icons by file name and extension', () => { + const payload = preloadFileTree({ + initialFiles: [ + 'package.json', + 'index.ts', + 'widget.spec.ts', + '.gitignore', + 'README.md', + ], + icons: { + spriteSheet: FILE_TYPE_CUSTOM_SPRITE, + remap: { + 'file-tree-icon-file': 'custom-default', + }, + byFileName: { + 'package.json': 'custom-package', + '.gitignore': 'custom-gitignore', + }, + byFileExtension: { + ts: 'custom-ts', + 'spec.ts': 'custom-spec-ts', + }, + }, + }); + + const container = document.createElement('file-tree-container'); + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = payload.shadowHtml; + + const wrapper = shadowRoot.querySelector( + `[data-file-tree-id="${payload.id}"]` + ); + expect(wrapper).not.toBeNull(); + if (wrapper == null) { + throw new Error('Expected file-tree wrapper in shadow DOM'); + } + + const getIconHref = (fileName: string): string => { + const button = Array.from( + wrapper.querySelectorAll('button[data-type="item"]') + ).find((item) => item.getAttribute('aria-label') === fileName); + expect(button).not.toBeUndefined(); + if (button == null) { + throw new Error(`Expected file row for ${fileName}`); + } + const useNode = button.querySelector('div[data-item-section="icon"] use'); + const href = useNode?.getAttribute('href'); + expect(href).not.toBeNull(); + if (href == null) { + throw new Error(`Expected icon for ${fileName}`); + } + return href; + }; + + expect(getIconHref('package.json')).toBe('#custom-package'); + expect(getIconHref('index.ts')).toBe('#custom-ts'); + expect(getIconHref('widget.spec.ts')).toBe('#custom-spec-ts'); + expect(getIconHref('.gitignore')).toBe('#custom-gitignore'); + expect(getIconHref('README.md')).toBe('#custom-default'); + }); + + test('preloadFileTree uses the simple icon set when icons are unset', () => { + const payload = preloadFileTree({ + initialFiles: [ + 'package.json', + 'index.ts', + 'app.tsx', + 'card.module.css', + 'README.md', + 'image.png', + 'agent.mcp', + ], + }); + + const container = document.createElement('file-tree-container'); + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = payload.shadowHtml; + + const wrapper = shadowRoot.querySelector( + `[data-file-tree-id="${payload.id}"]` + ); + expect(wrapper).not.toBeNull(); + if (wrapper == null) { + throw new Error('Expected file-tree wrapper in shadow DOM'); + } + + const getIconHref = (fileName: string): string => { + const button = Array.from( + wrapper.querySelectorAll('button[data-type="item"]') + ).find((item) => item.getAttribute('aria-label') === fileName); + expect(button).not.toBeUndefined(); + if (button == null) { + throw new Error(`Expected file row for ${fileName}`); + } + const useNode = button.querySelector('div[data-item-section="icon"] use'); + const href = useNode?.getAttribute('href'); + expect(href).not.toBeNull(); + if (href == null) { + throw new Error(`Expected icon for ${fileName}`); + } + return href; + }; + + expect(getIconHref('package.json')).toBe('#file-tree-icon-file'); + expect(getIconHref('index.ts')).toBe('#file-tree-icon-file'); + expect(getIconHref('app.tsx')).toBe('#file-tree-icon-file'); + expect(getIconHref('card.module.css')).toBe('#file-tree-icon-file'); + expect(getIconHref('README.md')).toBe('#file-tree-icon-file'); + expect(getIconHref('image.png')).toBe('#file-tree-icon-file'); + expect(getIconHref('agent.mcp')).toBe('#file-tree-icon-file'); + }); + + test('preloadFileTree uses the simple icon set when requested', () => { + const payload = preloadFileTree({ + initialFiles: ['package.json', 'index.ts', 'app.tsx'], + icons: 'simple', + }); + + const container = document.createElement('file-tree-container'); + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = payload.shadowHtml; + + const wrapper = shadowRoot.querySelector( + `[data-file-tree-id="${payload.id}"]` + ); + expect(wrapper).not.toBeNull(); + if (wrapper == null) { + throw new Error('Expected file-tree wrapper in shadow DOM'); + } + + const iconHrefs = Array.from( + wrapper.querySelectorAll('button[data-type="item"] use') + ).map((node) => node.getAttribute('href')); + + expect(iconHrefs).toEqual([ + '#file-tree-icon-file', + '#file-tree-icon-file', + '#file-tree-icon-file', + ]); + }); + + test('preloadFileTree uses the file-type icon set when requested', () => { + const payload = preloadFileTree({ + initialFiles: [ + 'package.json', + 'index.ts', + 'app.tsx', + 'card.module.css', + 'README.md', + 'image.png', + 'agent.mcp', + ], + icons: 'file-type', + }); + + const container = document.createElement('file-tree-container'); + const shadowRoot = + container.shadowRoot ?? container.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = payload.shadowHtml; + + const wrapper = shadowRoot.querySelector( + `[data-file-tree-id="${payload.id}"]` + ); + expect(wrapper).not.toBeNull(); + if (wrapper == null) { + throw new Error('Expected file-tree wrapper in shadow DOM'); + } + + const getIconHref = (fileName: string): string => { + const button = Array.from( + wrapper.querySelectorAll('button[data-type="item"]') + ).find((item) => item.getAttribute('aria-label') === fileName); + expect(button).not.toBeUndefined(); + if (button == null) { + throw new Error(`Expected file row for ${fileName}`); + } + const useNode = button.querySelector('div[data-item-section="icon"] use'); + const href = useNode?.getAttribute('href'); + expect(href).not.toBeNull(); + if (href == null) { + throw new Error(`Expected icon for ${fileName}`); + } + return href; + }; + + expect(getIconHref('package.json')).toBe( + '#file-tree-builtin-file-type-npm' + ); + expect(getIconHref('index.ts')).toBe( + '#file-tree-builtin-file-type-typescript' + ); + expect(getIconHref('app.tsx')).toBe('#file-tree-builtin-file-type-react'); + expect(getIconHref('card.module.css')).toBe( + '#file-tree-builtin-file-type-css' + ); + expect(getIconHref('README.md')).toBe( + '#file-tree-builtin-file-type-markdown' + ); + expect(getIconHref('image.png')).toBe('#file-tree-builtin-file-type-image'); + expect(getIconHref('agent.mcp')).toBe('#file-tree-builtin-file-type-mcp'); + }); + + test('setOptions swaps built-in icon sets and colored mode at runtime', () => { + const container = document.createElement('file-tree-container'); + const ft = new FileTree({ + initialFiles: ['index.ts'], + icons: 'simple', + }); + + const origRender = preactRenderer.renderRoot; + preactRenderer.renderRoot = () => {}; + try { + ft.render({ fileTreeContainer: container }); + + const shadowRoot = container.shadowRoot; + const wrapper = shadowRoot?.querySelector( + '[data-file-tree-id]' + ) as HTMLElement | null; + expect(wrapper).not.toBeNull(); + expect( + shadowRoot?.querySelector('#file-tree-builtin-file-type-typescript') + ).toBeNull(); + expect(wrapper?.dataset.fileTreeColoredIcons).toBeUndefined(); + + ft.setOptions({ + icons: { + set: 'duo-tone', + colored: true, + }, + }); + + expect( + shadowRoot?.querySelector('#file-tree-builtin-duo-tone-typescript') + ).not.toBeNull(); + expect(wrapper?.dataset.fileTreeColoredIcons).toBe('true'); + } finally { + preactRenderer.renderRoot = origRender; + } + }); + test('setFiles invokes onFilesChange callback', () => { const calls: string[][] = []; const ft = new FileTree(