diff --git a/apps/docs/app/trees-dev/_components/TreesDevSidebar.tsx b/apps/docs/app/trees-dev/_components/TreesDevSidebar.tsx index 6773e1e9b..e766e2a41 100644 --- a/apps/docs/app/trees-dev/_components/TreesDevSidebar.tsx +++ b/apps/docs/app/trees-dev/_components/TreesDevSidebar.tsx @@ -16,6 +16,7 @@ const DEMO_PAGES = [ { slug: 'drag-and-drop', label: 'Drag and Drop' }, { slug: 'git-status', label: 'Git Status' }, { slug: 'custom-icons', label: 'Custom Icons' }, + { slug: 'icon-tiers', label: 'Icon Tiers' }, { slug: 'header-slot', label: 'Header Slot' }, { slug: 'context-menu', label: 'Context Menu' }, { slug: 'virtualization', label: 'Virtualization' }, diff --git a/apps/docs/app/trees-dev/icon-tiers/page.tsx b/apps/docs/app/trees-dev/icon-tiers/page.tsx new file mode 100644 index 000000000..950b6ffdf --- /dev/null +++ b/apps/docs/app/trees-dev/icon-tiers/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import type { FileTreeBuiltInIconSet } from '@pierre/trees'; +import { FileTree as FileTreeReact } from '@pierre/trees/react'; + +const TIER_FILES = [ + // Folder states + 'closed-folder/placeholder', + 'open-folder/placeholder', + + // Standard tier — languages & common file types + 'index.ts', + 'app.js', + 'App.tsx', + 'style.css', + 'page.html', + 'data.json', + 'README.md', + 'main.go', + 'main.py', + 'app.rb', + 'lib.rs', + 'app.swift', + 'script.sh', + 'logo.png', + 'font.woff2', + '.gitignore', + 'config.mcp', + 'notes.txt', + 'data.csv', + 'schema.sql', + 'archive.zip', + + // Complete tier — frameworks, brands, tooling + 'Layout.astro', + '.babelrc', + 'biome.json', + 'bootstrap.min.js', + '.browserslistrc', + 'bun.lock', + 'claude.md', + 'Dockerfile', + 'eslint.config.js', + 'schema.graphql', + 'next.config.ts', + 'package.json', + '.oxlintrc.json', + 'postcss.config.js', + '.prettierrc', + 'styles.scss', + '.stylelintrc', + 'icon.svg', + 'App.svelte', + 'svgo.config.js', + 'tailwind.config.ts', + 'main.tf', + 'vite.config.ts', + 'settings.code-workspace', + 'App.vue', + 'module.wasm', + 'webpack.config.js', + 'config.yml', + 'main.zig', + + // Falls through to `default` token + 'unknown.xyz', +]; + +const TIERS: { set: FileTreeBuiltInIconSet; label: string }[] = [ + { set: 'minimal', label: 'Minimal' }, + { set: 'standard', label: 'Standard' }, + { set: 'complete', label: 'Complete' }, +]; + +export default function IconTiersPage() { + return ( + <> +

Icon Tiers

+
+ {TIERS.map(({ set, label }) => ( +
+

{label}

+
+ +
+
+ ))} +
+ + ); +} diff --git a/apps/docs/app/trees/demo-data.ts b/apps/docs/app/trees/demo-data.ts index 9a49405e7..5447957bd 100644 --- a/apps/docs/app/trees/demo-data.ts +++ b/apps/docs/app/trees/demo-data.ts @@ -7,16 +7,25 @@ import type { export const sampleFileList: string[] = [ 'README.md', 'package.json', + 'bunfig.toml', + 'stylelint.config.js', + '.browserslistrc', + '.oxlintrc.json', + '.github/workflows/ci.yml', 'build/index.mjs', 'build/scripts.js', 'build/assets/images/social/logo.png', 'config/project/app.config.json', + 'public/404.html', + 'public/favicon.ico', + 'scripts/deploy.sh', 'src/components/Button.tsx', 'src/components/Card.tsx', 'src/components/Header.tsx', 'src/components/Sidebar.tsx', 'src/lib/mdx.tsx', 'src/lib/utils.ts', + 'src/styles/globals.css', 'src/utils/stream.ts', 'src/utils/worker.ts', 'src/utils/worker/index.ts', diff --git a/apps/docs/app/trees/docs/CoreTypes/constants.ts b/apps/docs/app/trees/docs/CoreTypes/constants.ts index 29c2502cd..0f775b3db 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?: 'minimal' | 'standard' | 'complete' | '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: 'standard', + 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..2f965adc3 100644 --- a/apps/docs/app/trees/docs/Icons/content.mdx +++ b/apps/docs/app/trees/docs/Icons/content.mdx @@ -1,16 +1,35 @@ -## 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: 'minimal' | 'standard' | 'complete'` — use one of the shipped icon + tiers. Each tier is cumulative: `standard` includes everything in `minimal` + plus language icons, and `complete` adds brands and tooling on top. +- `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 `standard` and + `complete` 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..5715886d0 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: 'standard', + colored: true, }, }); diff --git a/apps/docs/app/trees/tree-examples/CustomIconsSection.tsx b/apps/docs/app/trees/tree-examples/CustomIconsSection.tsx index 689be14ba..7da32c4fd 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,61 +12,41 @@ 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 minimalPrerenderedHTML = preloadFileTree( + { + ...baseTreeOptions, + id: 'built-in-icons-minimal', + lockedPaths: ['package.json'], + icons: 'minimal', + }, + { + initialExpandedItems: ['src', 'src/components'], + } +).shadowHtml; + const defaultPrerenderedHTML = preloadFileTree( { ...baseTreeOptions, - id: 'custom-icons-default', + id: 'built-in-icons-default', lockedPaths: ['package.json'], + icons: 'standard', }, { initialExpandedItems: ['src', 'src/components'], } ).shadowHtml; -const remappedPrerenderedHTML = preloadFileTree( +const completePrerenderedHTML = preloadFileTree( { ...baseTreeOptions, - id: 'custom-icons-remapped', + id: 'built-in-icons-complete', 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, - }, - }, - }, + icons: 'complete', }, { initialExpandedItems: ['src', 'src/components'], @@ -78,11 +58,14 @@ 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 minimal,{' '} + standard, and complete icon tiers. Each + tier is cumulative. 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 +73,24 @@ export function CustomIconsSection() { } /> -
+
} + icon={} description={ - <> - The default icons used when no icons option is set. - + <>Generic file, folder, and image icons with no file types. } > - Default + Minimal } - description={ - <> - Pass a spriteSheet and remap via the{' '} - icons option. - - } + description={<>Icons for common languages and file types.} + > + Standard + + +
+
+ } + description={<>Full, colored suite with brands and frameworks.} > - Remapped + Complete = { + // -- standard tier: languages, common file types ------------------------- + database: { icon: 'server-duo', tier: 'standard' }, + default: { icon: 'file-duo', tier: 'standard' }, + bash: { icon: 'bash-duo', tier: 'standard' }, + css: { icon: 'lang-css-duo', tier: 'standard' }, + font: { icon: 'font', tier: 'standard' }, + git: { icon: 'git', tier: 'standard' }, + go: { icon: 'lang-go', tier: 'standard' }, + html: { icon: 'lang-html-duo', tier: 'standard' }, + image: { icon: 'image-duo', tier: 'standard' }, + javascript: { icon: 'lang-javascript-duo', tier: 'standard' }, + json: { icon: 'braces', tier: 'standard' }, + markdown: { icon: 'lang-markdown', tier: 'standard' }, + mcp: { icon: 'mcp', tier: 'standard' }, + python: { icon: 'lang-python', tier: 'standard' }, + ruby: { icon: 'lang-ruby', tier: 'standard' }, + rust: { icon: 'lang-rust', tier: 'standard' }, + swift: { icon: 'lang-swift', tier: 'standard' }, + table: { icon: 'file-table-duo', tier: 'standard' }, + text: { icon: 'file-text-duo', tier: 'standard' }, + typescript: { icon: 'lang-typescript-duo', tier: 'standard' }, + zip: { icon: 'folder-zip-duo', tier: 'standard' }, + + // -- complete tier: frameworks, brands, tooling ------------------------- + astro: { icon: 'astro', tier: 'complete' }, + babel: { icon: 'babel', tier: 'complete' }, + biome: { icon: 'biome', tier: 'complete' }, + bootstrap: { icon: 'bootstrap-duo', tier: 'complete' }, + browserslist: { icon: 'browserslist-duo', tier: 'complete' }, + bun: { icon: 'bun-duo', tier: 'complete' }, + claude: { icon: 'claude', tier: 'complete' }, + docker: { icon: 'docker', tier: 'complete' }, + eslint: { icon: 'eslint', tier: 'complete' }, + graphql: { icon: 'graphql', tier: 'complete' }, + nextjs: { icon: 'nextjs', tier: 'complete' }, + npm: { icon: 'npm-duo', tier: 'complete' }, + oxc: { icon: 'oxc', tier: 'complete' }, + postcss: { icon: 'postcss', tier: 'complete' }, + prettier: { icon: 'prettier', tier: 'complete' }, + react: { icon: 'react', tier: 'complete' }, + sass: { icon: 'sass', tier: 'complete' }, + stylelint: { icon: 'stylelint', tier: 'complete' }, + svg: { icon: 'svg-2', tier: 'complete' }, + svelte: { icon: 'svelte', tier: 'complete' }, + svgo: { icon: 'svgo', tier: 'complete' }, + tailwind: { icon: 'tailwind', tier: 'complete' }, + terraform: { icon: 'terraform', tier: 'complete' }, + vite: { icon: 'vite', tier: 'complete' }, + vscode: { icon: 'vscode', tier: 'complete' }, + vue: { icon: 'vue', tier: 'complete' }, + wasm: { icon: 'wasm-duo', tier: 'complete' }, + webpack: { icon: 'webpack', tier: 'complete' }, + yml: { icon: 'yml', tier: 'complete' }, + zig: { icon: 'zig', tier: 'complete' }, +}; + +const SORTED_TOKENS = Object.keys(TOKEN_DEFS).sort(); + +// Reverse map: theme icon name → our token name +const ICON_TO_TOKEN: Record = {}; +for (const [token, def] of Object.entries(TOKEN_DEFS)) { + ICON_TO_TOKEN[def.icon] = token; +} +// Theme uses file-zip-duo for extensions, but we render with folder-zip-duo +ICON_TO_TOKEN['file-zip-duo'] = 'zip'; + +// Manual additions not covered by the theme data +const MANUAL_EXTENSION_TOKENS: Record = { + log: 'text', + mcp: 'mcp', + 'mdx.tsx': 'markdown', + txt: 'text', +}; + +const MANUAL_FILENAME_TOKENS: Record = { + 'readme.md': 'markdown', +}; + +// --------------------------------------------------------------------------- +// SVG → transform +// --------------------------------------------------------------------------- + +function readSvg(filename: string): string { + const path = join(svgsDir, filename); + if (!existsSync(path)) { + throw new Error(`SVG not found: ${path}`); + } + return readFileSync(path, '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[]; +} + +/** + * Builds the file-extension and file-name → token lookup tables by walking + * the theme tier data. Also produces an overrides map for extensions whose + * token changes between the standard and complete tiers (e.g. tsx → typescript + * at standard, tsx → react at complete). + */ +async function buildTokenMaps(): Promise<{ + extensionTokens: Record; + fileNameTokens: Record; + completeExtOverrides: Record; +}> { + const minimal = await loadThemeTier('minimal.mjs'); + const standards = await loadThemeTier('default.mjs'); + const complete = await loadThemeTier('complete.mjs'); + + const extensionTokens: Record = {}; + const fileNameTokens: Record = {}; + const completeExtOverrides: Record = {}; + + function processEntry(entry: ThemeEntry, target: 'base' | 'complete') { + const token = ICON_TO_TOKEN[entry.name]; + if (token == null) return; + + if (entry.fileExtensions != null) { + for (const ext of entry.fileExtensions) { + if (target === 'complete') { + const existing = extensionTokens[ext]; + if (existing != null && existing !== token) { + completeExtOverrides[ext] = token; + } else if (existing == null) { + extensionTokens[ext] = token; + } + } else { + extensionTokens[ext] = token; + } + } + } + if (entry.fileNames != null) { + for (const name of entry.fileNames) { + fileNameTokens[name.toLowerCase()] = token; + } + } + } + + for (const entry of [...minimal, ...standards]) { + processEntry(entry, 'base'); + } + for (const entry of complete) { + processEntry(entry, 'complete'); + } + + 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, completeExtOverrides }; +} + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +function generateSymbolConstants(): { + standardSymbols: string[]; + completeOnlySymbols: string[]; + declarations: string; +} { + const standardSymbols: string[] = []; + const completeOnlySymbols: string[] = []; + const lines: string[] = []; + + for (const token of SORTED_TOKENS) { + const def = TOKEN_DEFS[token]; + const symbolId = `file-tree-builtin-${token}`; + const varName = `sym_${token.replace(/-/g, '_')}`; + const svgFile = `${def.icon}.svg`; + + const symbol = svgToSymbol(svgFile, symbolId); + lines.push(`const ${varName} = \`${symbol}\`;`); + lines.push(''); + + if (def.tier === 'standard') { + standardSymbols.push(varName); + } else { + completeOnlySymbols.push(varName); + } + } + + return { + standardSymbols, + completeOnlySymbols, + 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, completeExtOverrides } = + await buildTokenMaps(); + const { standardSymbols, completeOnlySymbols, declarations } = + generateSymbolConstants(); + + const tokenType = SORTED_TOKENS.map((t) => ` | '${t}'`).join('\n'); + + const standardTokensList = SORTED_TOKENS.filter( + (t) => TOKEN_DEFS[t].tier === 'standard' + ); + + return `// @generated by scripts/generate-built-in-icons.ts — do not edit manually +import type { FileTreeBuiltInIconSet } from './iconConfig'; + +export type BuiltInFileIconToken = +${tokenType}; + +const MINIMAL_SVG_SPRITE_SHEET = \`\`; + +${declarations} +const standardTierSymbols = [ +${standardSymbols.map((v) => ` ${v},`).join('\n')} +]; + +const completeOnlySymbols = [ +${completeOnlySymbols.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 STANDARD_SVG_SPRITE_SHEET = appendSymbols( + MINIMAL_SVG_SPRITE_SHEET, + standardTierSymbols, +); + +const BUILT_IN_SVG_SPRITE_SHEETS: Record = { + minimal: MINIMAL_SVG_SPRITE_SHEET, + standard: STANDARD_SVG_SPRITE_SHEET, + complete: appendSymbols(STANDARD_SVG_SPRITE_SHEET, completeOnlySymbols), +}; + +const BUILT_IN_FILE_NAME_TOKENS: Partial> = + { +${formatRecord(fileNameTokens, ' ')} + }; + +const BUILT_IN_FILE_EXTENSION_TOKENS: Partial< + Record +> = { +${formatRecord(extensionTokens, ' ')} +}; +${ + Object.keys(completeExtOverrides).length > 0 + ? ` +const COMPLETE_EXTENSION_OVERRIDES: Partial< + Record +> = { +${formatRecord(completeExtOverrides, ' ')} +}; +` + : ` +const COMPLETE_EXTENSION_OVERRIDES: Partial< + Record +> = {}; +` +} +const STANDARD_TIER_TOKENS = new Set([ +${standardTokensList.map((t) => ` '${t}',`).join('\n')} +]); + +const COLORED_SETS = new Set(['complete']); + +export function getBuiltInSpriteSheet( + set: FileTreeBuiltInIconSet | 'none', +): string { + const builtInSet = set === 'none' ? 'minimal' : set; + return BUILT_IN_SVG_SPRITE_SHEETS[builtInSet]; +} + +export function getBuiltInFileIconName( + token: BuiltInFileIconToken, +): string { + return \`file-tree-builtin-\${token}\`; +} + +export function isColoredBuiltInIconSet( + set: FileTreeBuiltInIconSet | 'none', +): boolean { + return set !== 'none' && COLORED_SETS.has(set); +} + +export function resolveBuiltInFileIconToken( + set: FileTreeBuiltInIconSet | 'none', + fileName: string, + extensionCandidates: string[], +): BuiltInFileIconToken | undefined { + if (set === 'minimal' || set === 'none') { + return undefined; + } + + const isComplete = set === 'complete'; + + const lowerFileName = fileName.toLowerCase(); + const fileNameToken = BUILT_IN_FILE_NAME_TOKENS[lowerFileName]; + if (fileNameToken != null) { + if (isComplete || STANDARD_TIER_TOKENS.has(fileNameToken)) { + return fileNameToken; + } + } + + for (const extension of extensionCandidates) { + if (isComplete) { + const override = COMPLETE_EXTENSION_OVERRIDES[extension]; + if (override != null) { + return override; + } + } + const match = BUILT_IN_FILE_EXTENSION_TOKENS[extension]; + if (match != null) { + if (isComplete || STANDARD_TIER_TOKENS.has(match)) { + 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..a0b66efc3 --- /dev/null +++ b/packages/trees/src/builtInIcons.ts @@ -0,0 +1,707 @@ +// @generated by scripts/generate-built-in-icons.ts — do not edit manually +import type { FileTreeBuiltInIconSet } from './iconConfig'; + +export type BuiltInFileIconToken = + | 'astro' + | 'babel' + | 'bash' + | 'biome' + | 'bootstrap' + | 'browserslist' + | 'bun' + | 'claude' + | 'css' + | 'database' + | 'default' + | 'docker' + | 'eslint' + | 'font' + | 'git' + | 'go' + | 'graphql' + | 'html' + | 'image' + | 'javascript' + | 'json' + | 'markdown' + | 'mcp' + | 'nextjs' + | 'npm' + | 'oxc' + | 'postcss' + | 'prettier' + | 'python' + | 'react' + | 'ruby' + | 'rust' + | 'sass' + | 'stylelint' + | 'svelte' + | 'svg' + | 'svgo' + | 'swift' + | 'table' + | 'tailwind' + | 'terraform' + | 'text' + | 'typescript' + | 'vite' + | 'vscode' + | 'vue' + | 'wasm' + | 'webpack' + | 'yml' + | 'zig' + | 'zip'; + +const MINIMAL_SVG_SPRITE_SHEET = ``; + +const sym_astro = ` + + +`; + +const sym_babel = ` + +`; + +const sym_bash = ` + + +`; + +const sym_biome = ` + +`; + +const sym_bootstrap = ` + + +`; + +const sym_browserslist = ` + + + +`; + +const sym_bun = ` + + +`; + +const sym_claude = ` + +`; + +const sym_css = ` + + +`; + +const sym_database = ` + + + +`; + +const sym_default = ` + + +`; + +const sym_docker = ` + + +`; + +const sym_eslint = ` + + +`; + +const sym_font = ` + +`; + +const sym_git = ` + +`; + +const sym_go = ` + +`; + +const sym_graphql = ` + +`; + +const sym_html = ` + + +`; + +const sym_image = ` + + +`; + +const sym_javascript = ` + + +`; + +const sym_json = ` + +`; + +const sym_markdown = ` + +`; + +const sym_mcp = ` + + +`; + +const sym_nextjs = ` + + + + + + + + + +`; + +const sym_npm = ` + + +`; + +const sym_oxc = ` + +`; + +const sym_postcss = ` + +`; + +const sym_prettier = ` + + + + +`; + +const sym_python = ` + + +`; + +const sym_react = ` + + +`; + +const sym_ruby = ` + +`; + +const sym_rust = ` + +`; + +const sym_sass = ` + +`; + +const sym_stylelint = ` + + +`; + +const sym_svelte = ` + +`; + +const sym_svg = ` + + +`; + +const sym_svgo = ` + + +`; + +const sym_swift = ` + +`; + +const sym_table = ` + + +`; + +const sym_tailwind = ` + +`; + +const sym_terraform = ` + +`; + +const sym_text = ` + + +`; + +const sym_typescript = ` + + +`; + +const sym_vite = ` + +`; + +const sym_vscode = ` + + + +`; + +const sym_vue = ` + + +`; + +const sym_wasm = ` + + + +`; + +const sym_webpack = ` + + +`; + +const sym_yml = ` + +`; + +const sym_zig = ` + + +`; + +const sym_zip = ` + + +`; + +const standardTierSymbols = [ + sym_bash, + sym_css, + sym_database, + sym_default, + sym_font, + sym_git, + sym_go, + sym_html, + sym_image, + sym_javascript, + sym_json, + sym_markdown, + sym_mcp, + sym_python, + sym_ruby, + sym_rust, + sym_swift, + sym_table, + sym_text, + sym_typescript, + sym_zip, +]; + +const completeOnlySymbols = [ + sym_astro, + sym_babel, + sym_biome, + sym_bootstrap, + sym_browserslist, + sym_bun, + sym_claude, + sym_docker, + sym_eslint, + sym_graphql, + sym_nextjs, + sym_npm, + sym_oxc, + sym_postcss, + sym_prettier, + sym_react, + sym_sass, + sym_stylelint, + sym_svelte, + sym_svg, + sym_svgo, + sym_tailwind, + sym_terraform, + sym_vite, + sym_vscode, + sym_vue, + sym_wasm, + sym_webpack, + sym_yml, + sym_zig, +]; + +function appendSymbols(spriteSheet: string, symbols: string[]): string { + if (symbols.length === 0) return spriteSheet; + return spriteSheet.replace('', `\n ${symbols.join('\n ')}\n`); +} + +const STANDARD_SVG_SPRITE_SHEET = appendSymbols( + MINIMAL_SVG_SPRITE_SHEET, + standardTierSymbols +); + +const BUILT_IN_SVG_SPRITE_SHEETS: Record = { + minimal: MINIMAL_SVG_SPRITE_SHEET, + standard: STANDARD_SVG_SPRITE_SHEET, + complete: appendSymbols(STANDARD_SVG_SPRITE_SHEET, completeOnlySymbols), +}; + +const BUILT_IN_FILE_NAME_TOKENS: Partial> = + { + '.babelrc': 'babel', + '.babelrc.json': 'babel', + '.bash_profile': 'bash', + '.bashrc': 'bash', + '.browserslistrc': 'browserslist', + '.dockerignore': 'docker', + '.eslintignore': 'eslint', + '.eslintrc': 'eslint', + '.eslintrc.cjs': 'eslint', + '.eslintrc.js': 'eslint', + '.eslintrc.json': 'eslint', + '.eslintrc.yaml': 'eslint', + '.eslintrc.yml': 'eslint', + '.gitattributes': 'git', + '.gitignore': 'git', + '.gitkeep': 'git', + '.gitmodules': 'git', + '.oxlintrc.json': 'oxc', + '.postcssrc': 'postcss', + '.postcssrc.json': 'postcss', + '.postcssrc.yaml': 'postcss', + '.postcssrc.yml': 'postcss', + '.prettierignore': 'prettier', + '.prettierrc': 'prettier', + '.prettierrc.cjs': 'prettier', + '.prettierrc.js': 'prettier', + '.prettierrc.json': 'prettier', + '.prettierrc.mjs': 'prettier', + '.prettierrc.toml': 'prettier', + '.prettierrc.yaml': 'prettier', + '.prettierrc.yml': 'prettier', + '.stylelintignore': 'stylelint', + '.stylelintrc': 'stylelint', + '.stylelintrc.cjs': 'stylelint', + '.stylelintrc.js': 'stylelint', + '.stylelintrc.json': 'stylelint', + '.stylelintrc.mjs': 'stylelint', + '.stylelintrc.yaml': 'stylelint', + '.stylelintrc.yml': 'stylelint', + '.terraform.lock.hcl': 'terraform', + '.zprofile': 'bash', + '.zshenv': 'bash', + '.zshrc': 'bash', + 'babel.config.cjs': 'babel', + 'babel.config.js': 'babel', + 'babel.config.json': 'babel', + 'babel.config.mjs': 'babel', + 'biome.json': 'biome', + 'biome.jsonc': 'biome', + 'bootstrap.bundle.js': 'bootstrap', + 'bootstrap.bundle.min.js': 'bootstrap', + 'bootstrap.css': 'bootstrap', + 'bootstrap.js': 'bootstrap', + 'bootstrap.min.css': 'bootstrap', + 'bootstrap.min.js': 'bootstrap', + 'bun.lock': 'bun', + 'bun.lockb': 'bun', + 'bunfig.toml': 'bun', + 'claude.md': 'claude', + 'compose.yaml': 'docker', + 'compose.yml': 'docker', + 'docker-compose.override.yml': 'docker', + 'docker-compose.yaml': 'docker', + 'docker-compose.yml': 'docker', + dockerfile: 'docker', + 'eslint.config.cjs': 'eslint', + 'eslint.config.js': 'eslint', + 'eslint.config.mjs': 'eslint', + 'eslint.config.mts': 'eslint', + 'eslint.config.ts': 'eslint', + gemfile: 'ruby', + 'next.config.js': 'nextjs', + 'next.config.mjs': 'nextjs', + 'next.config.mts': 'nextjs', + 'next.config.ts': 'nextjs', + 'postcss.config.cjs': 'postcss', + 'postcss.config.js': 'postcss', + 'postcss.config.mjs': 'postcss', + 'postcss.config.ts': 'postcss', + 'prettier.config.cjs': 'prettier', + 'prettier.config.js': 'prettier', + 'prettier.config.mjs': 'prettier', + rakefile: 'ruby', + 'readme.md': 'markdown', + 'stylelint.config.cjs': 'stylelint', + 'stylelint.config.js': 'stylelint', + 'stylelint.config.mjs': 'stylelint', + 'svgo.config.cjs': 'svgo', + 'svgo.config.js': 'svgo', + 'svgo.config.mjs': 'svgo', + 'svgo.config.ts': 'svgo', + 'tailwind.config.cjs': 'tailwind', + 'tailwind.config.js': 'tailwind', + 'tailwind.config.mjs': 'tailwind', + 'tailwind.config.ts': 'tailwind', + 'vite.config.js': 'vite', + 'vite.config.mjs': 'vite', + 'vite.config.mts': 'vite', + 'vite.config.ts': 'vite', + 'webpack.config.babel.js': 'webpack', + 'webpack.config.cjs': 'webpack', + 'webpack.config.js': 'webpack', + 'webpack.config.mjs': 'webpack', + 'webpack.config.ts': 'webpack', + }; + +const BUILT_IN_FILE_EXTENSION_TOKENS: Partial< + Record +> = { + '7z': 'zip', + astro: 'astro', + AUTHORS: 'text', + avif: 'image', + bash: 'bash', + bmp: 'image', + bz2: 'zip', + cfg: 'text', + CHANGELOG: 'text', + cjs: 'javascript', + 'code-workspace': 'vscode', + conf: 'text', + CONTRIBUTORS: 'text', + csh: 'bash', + css: 'css', + csv: 'table', + cts: 'typescript', + db: 'database', + editorconfig: 'text', + env: 'text', + 'env.development': 'text', + 'env.local': 'text', + 'env.production': 'text', + eot: 'font', + erb: 'ruby', + fish: 'bash', + gemspec: 'ruby', + gif: 'image', + go: 'go', + gql: 'graphql', + graphql: 'graphql', + gz: 'zip', + htm: 'html', + html: 'html', + icns: 'image', + ico: 'image', + ini: 'text', + jar: 'zip', + jpeg: 'image', + jpg: 'image', + js: 'javascript', + json: 'json', + json5: 'json', + jsonc: 'json', + jsonl: 'json', + jsx: 'javascript', + ksh: 'bash', + less: 'css', + LICENSE: 'text', + log: 'text', + markdown: 'markdown', + mcp: 'mcp', + md: 'markdown', + mdx: 'markdown', + 'mdx.tsx': 'markdown', + mjs: 'javascript', + mts: 'typescript', + ods: 'table', + otf: 'font', + png: 'image', + postcss: 'css', + py: 'python', + pyi: 'python', + pyw: 'python', + pyx: 'python', + rake: 'ruby', + rar: 'zip', + rb: 'ruby', + rs: 'rust', + rst: 'text', + rtf: 'text', + sass: 'css', + scss: 'css', + sh: 'bash', + sql: 'database', + sqlite: 'database', + sqlite3: 'database', + styl: 'css', + svelte: 'svelte', + svg: 'svg', + swift: 'swift', + tar: 'zip', + tf: 'terraform', + tfstate: 'terraform', + tfvars: 'terraform', + tgz: 'zip', + tif: 'image', + tiff: 'image', + ts: 'typescript', + tsv: 'table', + tsx: 'typescript', + ttf: 'font', + txt: 'text', + vue: 'vue', + war: 'zip', + wasm: 'wasm', + wast: 'wasm', + wat: 'wasm', + webp: 'image', + woff: 'font', + woff2: 'font', + xhtml: 'html', + xls: 'table', + xlsx: 'table', + xz: 'zip', + yaml: 'yml', + yml: 'yml', + zig: 'zig', + zip: 'zip', + zsh: 'bash', +}; + +const COMPLETE_EXTENSION_OVERRIDES: Partial< + Record +> = { + jsx: 'react', + sass: 'sass', + scss: 'sass', + tsx: 'react', +}; + +const STANDARD_TIER_TOKENS = new Set([ + 'bash', + 'css', + 'database', + 'default', + 'font', + 'git', + 'go', + 'html', + 'image', + 'javascript', + 'json', + 'markdown', + 'mcp', + 'python', + 'ruby', + 'rust', + 'swift', + 'table', + 'text', + 'typescript', + 'zip', +]); + +const COLORED_SETS = new Set(['complete']); + +export function getBuiltInSpriteSheet( + set: FileTreeBuiltInIconSet | 'none' +): string { + const builtInSet = set === 'none' ? 'minimal' : set; + return BUILT_IN_SVG_SPRITE_SHEETS[builtInSet]; +} + +export function getBuiltInFileIconName(token: BuiltInFileIconToken): string { + return `file-tree-builtin-${token}`; +} + +export function isColoredBuiltInIconSet( + set: FileTreeBuiltInIconSet | 'none' +): boolean { + return set !== 'none' && COLORED_SETS.has(set); +} + +export function resolveBuiltInFileIconToken( + set: FileTreeBuiltInIconSet | 'none', + fileName: string, + extensionCandidates: string[] +): BuiltInFileIconToken | undefined { + if (set === 'minimal' || set === 'none') { + return undefined; + } + + const isComplete = set === 'complete'; + + const lowerFileName = fileName.toLowerCase(); + const fileNameToken = BUILT_IN_FILE_NAME_TOKENS[lowerFileName]; + if (fileNameToken != null) { + if (isComplete || STANDARD_TIER_TOKENS.has(fileNameToken)) { + return fileNameToken; + } + } + + for (const extension of extensionCandidates) { + if (isComplete) { + const override = COMPLETE_EXTENSION_OVERRIDES[extension]; + if (override != null) { + return override; + } + } + const match = BUILT_IN_FILE_EXTENSION_TOKENS[extension]; + if (match != null) { + if (isComplete || STANDARD_TIER_TOKENS.has(match)) { + return match; + } + } + } + + return 'default'; +} diff --git a/packages/trees/src/components/Icon.tsx b/packages/trees/src/components/Icon.tsx index b71456a9b..d6e3be56b 100644 --- a/packages/trees/src/components/Icon.tsx +++ b/packages/trees/src/components/Icon.tsx @@ -7,27 +7,12 @@ const DEFAULT_HEIGHT = 16; const ICON_SIZE_OVERRIDES: Record< string, { width: number; height: number; viewBox?: string } | undefined -> = { - 'file-tree-icon-chevron': { - width: 12, - height: 12, - viewBox: '0 0 16 16', - }, - 'file-tree-icon-file': { - width: 12, - height: 12, - viewBox: '0 0 16 16', - }, - 'file-tree-icon-lock': { - width: 12, - height: 12, - viewBox: '0 0 16 16', - }, -}; +> = {}; export function Icon({ name, remappedFrom, + token, width: propWidth, height: propHeight, viewBox: propViewBox, @@ -36,6 +21,7 @@ export function Icon({ }: { name: string; remappedFrom?: string; + token?: string; width?: number; height?: number; viewBox?: string; @@ -66,6 +52,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(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..bf65a7864 --- /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 = 'minimal' | 'standard' | 'complete'; + +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: 'complete', + colored: true, + }; + } + + if (typeof icons === 'string') { + return { + set: icons, + colored: true, + }; + } + + return { + ...icons, + set: icons.set ?? (hasCustomIconOverrides(icons) ? 'none' : 'complete'), + 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..336dfd805 100644 --- a/packages/trees/src/style.css +++ b/packages/trees/src/style.css @@ -43,6 +43,57 @@ --trees-git-modified-color-override --trees-git-deleted-color-override + // Built-in File Icon Color Overrides + --trees-file-icon-color + --trees-file-icon-color-astro + --trees-file-icon-color-babel + --trees-file-icon-color-bash + --trees-file-icon-color-biome + --trees-file-icon-color-bootstrap + --trees-file-icon-color-browserslist + --trees-file-icon-color-bun + --trees-file-icon-color-claude + --trees-file-icon-color-css + --trees-file-icon-color-database + --trees-file-icon-color-default + --trees-file-icon-color-docker + --trees-file-icon-color-eslint + --trees-file-icon-color-git + --trees-file-icon-color-go + --trees-file-icon-color-graphql + --trees-file-icon-color-html + --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-oxc + --trees-file-icon-color-postcss + --trees-file-icon-color-prettier + --trees-file-icon-color-python + --trees-file-icon-color-react + --trees-file-icon-color-ruby + --trees-file-icon-color-rust + --trees-file-icon-color-sass + --trees-file-icon-color-svg + --trees-file-icon-color-svelte + --trees-file-icon-color-svgo + --trees-file-icon-color-swift + --trees-file-icon-color-table + --trees-file-icon-color-text + --trees-file-icon-color-tailwind + --trees-file-icon-color-terraform + --trees-file-icon-color-typescript + --trees-file-icon-color-vite + --trees-file-icon-color-vscode + --trees-file-icon-color-vue + --trees-file-icon-color-wasm + --trees-file-icon-color-webpack + --trees-file-icon-color-yml + --trees-file-icon-color-zig + --trees-file-icon-color-zip + // Available CSS Layout Overrides --trees-gap-override --trees-border-radius-override @@ -97,7 +148,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 +241,212 @@ var(--trees-status-deleted) ); + --trees-icon-gray: light-dark(#84848a, #adadb1); + --trees-icon-red: light-dark(#d52c36, #ff6762); + --trees-icon-vermilion: light-dark(#ff8c5b, #d5512f); + --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-purple: light-dark(#a631be, #d568ea); + --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-astro: var( + --trees-file-icon-color, + var(--trees-icon-purple) + ); + --trees-file-icon-color-babel: var( + --trees-file-icon-color, + var(--trees-icon-yellow) + ); + --trees-file-icon-color-bash: var( + --trees-file-icon-color, + var(--trees-icon-green) + ); + --trees-file-icon-color-biome: var( + --trees-file-icon-color, + var(--trees-icon-blue) + ); + --trees-file-icon-color-bootstrap: var( + --trees-file-icon-color, + var(--trees-icon-indigo) + ); + --trees-file-icon-color-browserslist: var( + --trees-file-icon-color, + var(--trees-icon-yellow) + ); + --trees-file-icon-color-bun: var( + --trees-file-icon-color, + var(--trees-icon-pink) + ); + --trees-file-icon-color-claude: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --trees-file-icon-color-css: var( + --trees-file-icon-color, + var(--trees-icon-indigo) + ); + --trees-file-icon-color-database: var( + --trees-file-icon-color, + var(--trees-icon-purple) + ); + --trees-file-icon-color-docker: var( + --trees-file-icon-color, + var(--trees-icon-blue) + ); + --trees-file-icon-color-eslint: var( + --trees-file-icon-color, + var(--trees-icon-indigo) + ); + --trees-file-icon-color-git: var( + --trees-file-icon-vermilion, + var(--trees-icon-vermilion) + ); + --trees-file-icon-color-go: var( + --trees-file-icon-color, + var(--trees-icon-cyan) + ); + --trees-file-icon-color-graphql: var( + --trees-file-icon-color, + var(--trees-icon-pink) + ); + --trees-file-icon-color-html: 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-oxc: var( + --trees-file-icon-cyan, + var(--trees-icon-cyan) + ); + --trees-file-icon-color-postcss: var( + --trees-file-icon-color, + var(--trees-icon-red) + ); + --trees-file-icon-color-prettier: var( + --trees-file-icon-color, + var(--trees-icon-teal) + ); + --trees-file-icon-color-python: var( + --trees-file-icon-color, + var(--trees-icon-blue) + ); + --trees-file-icon-color-react: var( + --trees-file-icon-color, + var(--trees-icon-cyan) + ); + --trees-file-icon-color-ruby: var( + --trees-file-icon-color, + var(--trees-icon-red) + ); + --trees-file-icon-color-rust: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --trees-file-icon-color-sass: var( + --trees-file-icon-color, + var(--trees-icon-pink) + ); + --trees-file-icon-color-svg: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --trees-file-icon-color-svelte: var( + --trees-file-icon-color, + var(--trees-icon-red) + ); + --trees-file-icon-color-svgo: var( + --trees-file-icon-color, + var(--trees-icon-green) + ); + --trees-file-icon-color-swift: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --trees-file-icon-color-table: var( + --trees-file-icon-color, + var(--trees-icon-teal) + ); + --trees-file-icon-color-text: var( + --trees-file-icon-color, + var(--trees-icon-gray) + ); + --trees-file-icon-color-tailwind: var( + --trees-file-icon-color, + var(--trees-icon-cyan) + ); + --trees-file-icon-color-terraform: var( + --trees-file-icon-color, + var(--trees-icon-indigo) + ); + --trees-file-icon-color-typescript: var( + --trees-file-icon-color, + var(--trees-icon-blue) + ); + --trees-file-icon-color-vite: var( + --trees-file-icon-color, + var(--trees-icon-purple) + ); + --trees-file-icon-color-vscode: var( + --trees-file-icon-color, + var(--trees-icon-blue) + ); + --trees-file-icon-color-vue: var( + --trees-file-icon-color, + var(--trees-icon-green) + ); + --trees-file-icon-color-wasm: var( + --trees-file-icon-color, + var(--trees-icon-indigo) + ); + --trees-file-icon-color-webpack: var( + --trees-file-icon-color, + var(--trees-icon-blue) + ); + --trees-file-icon-color-yml: var( + --trees-file-icon-color, + var(--trees-icon-red) + ); + --trees-file-icon-color-zig: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --trees-file-icon-color-zip: var( + --trees-file-icon-color, + var(--trees-icon-orange) + ); + --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 +636,157 @@ 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='astro'] { + color: var(--trees-file-icon-color-astro); + } + [data-icon-token='babel'] { + color: var(--trees-file-icon-color-babel); + } + [data-icon-token='bash'] { + color: var(--trees-file-icon-color-bash); + } + [data-icon-token='biome'] { + color: var(--trees-file-icon-color-biome); + } + [data-icon-token='bootstrap'] { + color: var(--trees-file-icon-color-bootstrap); + } + [data-icon-token='browserslist'] { + color: var(--trees-file-icon-color-browserslist); + } + [data-icon-token='bun'] { + color: var(--trees-file-icon-color-bun); + } + [data-icon-token='claude'] { + color: var(--trees-file-icon-color-claude); + } + [data-icon-token='css'] { + color: var(--trees-file-icon-color-css); + } + [data-icon-token='database'] { + color: var(--trees-file-icon-color-database); + } + [data-icon-token='default'] { + color: var(--trees-file-icon-color-default); + } + [data-icon-token='docker'] { + color: var(--trees-file-icon-color-docker); + } + [data-icon-token='eslint'] { + color: var(--trees-file-icon-color-eslint); + } + [data-icon-token='git'] { + color: var(--trees-file-icon-color-git); + } + [data-icon-token='go'] { + color: var(--trees-file-icon-color-go); + } + [data-icon-token='graphql'] { + color: var(--trees-file-icon-color-graphql); + } + [data-icon-token='html'] { + color: var(--trees-file-icon-color-html); + } + [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='oxc'] { + color: var(--trees-file-icon-color-oxc); + } + [data-icon-token='postcss'] { + color: var(--trees-file-icon-color-postcss); + } + [data-icon-token='prettier'] { + color: var(--trees-file-icon-color-prettier); + } + [data-icon-token='python'] { + color: var(--trees-file-icon-color-python); + } + [data-icon-token='react'] { + color: var(--trees-file-icon-color-react); + } + [data-icon-token='ruby'] { + color: var(--trees-file-icon-color-ruby); + } + [data-icon-token='rust'] { + color: var(--trees-file-icon-color-rust); + } + [data-icon-token='sass'] { + color: var(--trees-file-icon-color-sass); + } + [data-icon-token='svg'] { + color: var(--trees-file-icon-color-svg); + } + [data-icon-token='svelte'] { + color: var(--trees-file-icon-color-svelte); + } + [data-icon-token='svgo'] { + color: var(--trees-file-icon-color-svgo); + } + [data-icon-token='swift'] { + color: var(--trees-file-icon-color-swift); + } + [data-icon-token='table'] { + color: var(--trees-file-icon-color-table); + } + [data-icon-token='text'] { + color: var(--trees-file-icon-color-text); + } + [data-icon-token='tailwind'] { + color: var(--trees-file-icon-color-tailwind); + } + [data-icon-token='terraform'] { + color: var(--trees-file-icon-color-terraform); + } + [data-icon-token='typescript'] { + color: var(--trees-file-icon-color-typescript); + } + [data-icon-token='vite'] { + color: var(--trees-file-icon-color-vite); + } + [data-icon-token='vscode'] { + color: var(--trees-file-icon-color-vscode); + } + [data-icon-token='vue'] { + color: var(--trees-file-icon-color-vue); + } + [data-icon-token='wasm'] { + color: var(--trees-file-icon-color-wasm); + } + [data-icon-token='webpack'] { + color: var(--trees-file-icon-color-webpack); + } + [data-icon-token='yml'] { + color: var(--trees-file-icon-color-yml); + } + [data-icon-token='zig'] { + color: var(--trees-file-icon-color-zig); + } + [data-icon-token='zip'] { + color: var(--trees-file-icon-color-zip); + } + } + /* Chevron rotation and visual alignment */ /* Chevron pointing down */ [data-icon-name='file-tree-icon-chevron'] { @@ -485,8 +893,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'] { @@ -514,8 +923,8 @@ [data-item-git-status='added'] { & - > [data-item-section='icon'] - > :not([data-icon-name='file-tree-icon-chevron']) { + > :where([data-item-section='icon']) + > :where(:not([data-icon-name='file-tree-icon-chevron'])) { color: var(--trees-git-added-color); } & > [data-item-section='content'] { @@ -528,8 +937,8 @@ [data-item-git-status='deleted'] { & - > [data-item-section='icon'] - > :not([data-icon-name='file-tree-icon-chevron']) { + > :where([data-item-section='icon']) + > :where(:not([data-icon-name='file-tree-icon-chevron'])) { color: var(--trees-git-deleted-color); } & > [data-item-section='content'] { @@ -542,8 +951,8 @@ [data-item-git-status='modified'] { & - > [data-item-section='icon'] - > :not([data-icon-name='file-tree-icon-chevron']) { + > :where([data-item-section='icon']) + > :where(:not([data-icon-name='file-tree-icon-chevron'])) { color: var(--trees-git-modified-color); } & > [data-item-section='content'] { diff --git a/packages/trees/test/ssr-declarative-shadow-dom.test.ts b/packages/trees/test/ssr-declarative-shadow-dom.test.ts index 7e6cb0e7d..dfef00bc7 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,241 @@ 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 complete 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-builtin-json'); + expect(getIconHref('index.ts')).toBe('#file-tree-builtin-typescript'); + expect(getIconHref('app.tsx')).toBe('#file-tree-builtin-react'); + expect(getIconHref('card.module.css')).toBe('#file-tree-builtin-css'); + expect(getIconHref('README.md')).toBe('#file-tree-builtin-markdown'); + expect(getIconHref('image.png')).toBe('#file-tree-builtin-image'); + expect(getIconHref('agent.mcp')).toBe('#file-tree-builtin-mcp'); + }); + + test('preloadFileTree uses the minimal icon set when requested', () => { + const payload = preloadFileTree({ + initialFiles: ['package.json', 'index.ts', 'app.tsx'], + icons: 'minimal', + }); + + 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 standard icon set when requested', () => { + const payload = preloadFileTree({ + initialFiles: [ + 'package.json', + 'index.ts', + 'app.tsx', + 'card.module.css', + 'README.md', + 'image.png', + 'agent.mcp', + ], + icons: 'standard', + }); + + 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-json'); + expect(getIconHref('index.ts')).toBe('#file-tree-builtin-typescript'); + expect(getIconHref('app.tsx')).toBe('#file-tree-builtin-typescript'); + expect(getIconHref('card.module.css')).toBe('#file-tree-builtin-css'); + expect(getIconHref('README.md')).toBe('#file-tree-builtin-markdown'); + expect(getIconHref('image.png')).toBe('#file-tree-builtin-image'); + expect(getIconHref('agent.mcp')).toBe('#file-tree-builtin-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: 'minimal', + }); + + 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-typescript') + ).toBeNull(); + expect(wrapper?.dataset.fileTreeColoredIcons).toBeUndefined(); + + ft.setOptions({ + icons: { + set: 'complete', + colored: true, + }, + }); + + expect( + shadowRoot?.querySelector('#file-tree-builtin-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(