From 1ef3083973e988d16657bac47b3ef0d17a7a78f4 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Apr 2026 17:03:07 -0500 Subject: [PATCH 1/3] Restore custom icon tiers in the path-store lane This pulls the icon-specific work out of the saved Phase 5 bundle so the path-store tree can render custom file icons and built-in icon tiers before the remaining composition surfaces land. The server and client now share one icon configuration path, the path-store view resolves file icons with the same rule priority as legacy trees, and focused regression tests cover SSR sprite output, custom remaps, and default built-in fallback behavior. Constraint: User requested an icons-only commit from the saved Phase 5 work Rejected: Land icons together with context menu and decorator work | wanted a smaller reviewable slice Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep later decorator/context-menu work layering on this icon resolver instead of reintroducing hardcoded file icon selection Tested: Targeted oxlint on changed files; bun ws trees build; bun ws trees tsc; bun test packages/trees/test/path-store-icon-config.test.ts and packages/trees/test/path-store-render-scroll.test.ts Not-tested: Full root lint remains noisy from unrelated existing docs/demo files --- .../PathStorePoweredRenderDemoClient.tsx | 20 +- .../app/trees-dev/path-store-powered/page.tsx | 4 +- .../path-store-powered/pathStoreDemoIcons.ts | 21 +++ packages/trees/src/path-store/file-tree.ts | 121 ++++++++++-- packages/trees/src/path-store/iconResolver.ts | 129 +++++++++++++ packages/trees/src/path-store/types.ts | 4 + packages/trees/src/path-store/view.tsx | 15 +- .../trees/test/path-store-icon-config.test.ts | 172 ++++++++++++++++++ 8 files changed, 463 insertions(+), 23 deletions(-) create mode 100644 apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts create mode 100644 packages/trees/src/path-store/iconResolver.ts create mode 100644 packages/trees/test/path-store-icon-config.test.ts diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index 415af3b25..bbe04a184 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -11,6 +11,7 @@ import { ExampleCard } from '../_components/ExampleCard'; import { StateLog, useStateLog } from '../_components/StateLog'; import { pathStoreCapabilityMatrix } from './capabilityMatrix'; import { createPresortedPreparedInput } from './createPresortedPreparedInput'; +import { PATH_STORE_DEMO_ICONS } from './pathStoreDemoIcons'; interface SharedDemoOptions extends Omit< PathStoreFileTreeOptions, @@ -113,7 +114,8 @@ export function PathStorePoweredRenderDemoClient({ }, }, }, - id: 'pst-phase4', + icons: PATH_STORE_DEMO_ICONS, + id: 'pst-phase5-icons', onSelectionChange: handleSelectionChange, preparedInput, }), @@ -126,22 +128,22 @@ export function PathStorePoweredRenderDemoClient({

Path-store lane · provisional

-

Focus + Selection + Header Slot

+

+ Focus + Selection + Header Slot + Icons +

- Phase 4 keeps the landed focus/navigation model and adds selection: - click and keyboard selection semantics, path-first imperative item - methods, lightweight selection-change observation, and now the first - simple composition surface via a slotted header in the existing - path-store-powered demo. + The path-store lane keeps the landed focus and selection model, + preserves the header slot, and now proves custom README and TypeScript + icon remaps without pulling in context-menu behavior yet.

} options={options} - title="Focus + Selection + Header Slot" + title="Focus + Selection + Header Slot + Icons" />
diff --git a/apps/docs/app/trees-dev/path-store-powered/page.tsx b/apps/docs/app/trees-dev/path-store-powered/page.tsx index 2114e30f7..c12deff54 100644 --- a/apps/docs/app/trees-dev/path-store-powered/page.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/page.tsx @@ -5,6 +5,7 @@ import { } from '@pierre/trees/path-store'; import { createPresortedPreparedInput } from './createPresortedPreparedInput'; +import { PATH_STORE_DEMO_ICONS } from './pathStoreDemoIcons'; import { PathStorePoweredRenderDemoClient } from './PathStorePoweredRenderDemoClient'; const linuxKernelWorkload = getVirtualizationWorkload('linux-1x'); @@ -23,6 +24,7 @@ export default function PathStorePoweredPage() { }, }, flattenEmptyDirectories: true, + icons: PATH_STORE_DEMO_ICONS, initialExpandedPaths: linuxKernelWorkload.expandedFolders, paths: linuxKernelWorkload.files, viewportHeight: 500, @@ -30,7 +32,7 @@ export default function PathStorePoweredPage() { const payload = preloadPathStoreFileTree({ ...sharedOptions, - id: 'pst-phase4', + id: 'pst-phase5-icons', preparedInput: linuxKernelPreparedInput, }); diff --git a/apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts b/apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts new file mode 100644 index 000000000..162b6c2a8 --- /dev/null +++ b/apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts @@ -0,0 +1,21 @@ +import type { FileTreeIcons } from '@pierre/trees'; + +export const PATH_STORE_DEMO_ICONS: FileTreeIcons = { + byFileExtension: { + ts: 'pst-phase5-icon-typescript', + }, + byFileName: { + 'readme.md': 'pst-phase5-icon-readme', + }, + spriteSheet: ``, +}; diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index 7fa646128..3750f70a5 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -1,7 +1,10 @@ import { h } from 'preact'; import { renderToString } from 'preact-render-to-string'; -import { getBuiltInSpriteSheet } from '../builtInIcons'; +import { + getBuiltInSpriteSheet, + isColoredBuiltInIconSet, +} from '../builtInIcons'; import { adoptDeclarativeShadowDom, ensureFileTreeStyles, @@ -12,6 +15,7 @@ import { FILE_TREE_TAG_NAME, HEADER_SLOT_NAME, } from '../constants'; +import { normalizeFileTreeIcons } from '../iconConfig'; import fileTreeStyles from '../style.css'; import { PathStoreTreesController } from './controller'; import { @@ -75,15 +79,20 @@ function getHeaderSlotHtml( return `
${headerHtml}
`; } -function ensureBuiltInSpriteSheet(shadowRoot: ShadowRoot): void { - if (shadowRoot.querySelector('svg[data-icon-sprite]') != null) { - return; - } +function isBuiltInSpriteSheet(spriteSheet: SVGElement): boolean { + return ( + spriteSheet.querySelector('#file-tree-icon-chevron') instanceof + SVGElement && + spriteSheet.querySelector('#file-tree-icon-file') instanceof SVGElement && + spriteSheet.querySelector('#file-tree-icon-dot') instanceof SVGElement && + spriteSheet.querySelector('#file-tree-icon-lock') instanceof SVGElement + ); +} - const spriteSheet = parseSpriteSheet(getBuiltInSpriteSheet('minimal')); - if (spriteSheet != null) { - shadowRoot.prepend(spriteSheet); - } +function getTopLevelSpriteSheets(shadowRoot: ShadowRoot): SVGElement[] { + return Array.from(shadowRoot.children).filter( + (element): element is SVGElement => element instanceof SVGElement + ); } export class PathStoreFileTree { @@ -92,6 +101,7 @@ export class PathStoreFileTree { readonly #composition: PathStoreTreesCompositionOptions | undefined; readonly #controller: PathStoreTreesController; readonly #id: string; + readonly #icons: PathStoreFileTreeOptions['icons']; readonly #onSelectionChange: | PathStoreTreesSelectionChangeListener | undefined; @@ -109,6 +119,7 @@ export class PathStoreFileTree { const { composition, id, + icons, itemHeight, onSelectionChange, overscan, @@ -117,6 +128,7 @@ export class PathStoreFileTree { } = options; this.#composition = composition; this.#id = createClientId(id); + this.#icons = icons; this.#onSelectionChange = onSelectionChange; this.#viewOptions = { itemHeight, @@ -168,6 +180,7 @@ export class PathStoreFileTree { this.#syncHeaderSlotContent(); hydratePathStoreTreesRoot(wrapper, { controller: this.#controller, + icons: this.#icons, ...this.#getResolvedViewOptions(host), }); } @@ -184,6 +197,7 @@ export class PathStoreFileTree { this.#syncHeaderSlotContent(); renderPathStoreTreesRoot(wrapper, { controller: this.#controller, + icons: this.#icons, ...this.#getResolvedViewOptions(host), }); } @@ -235,6 +249,81 @@ export class PathStoreFileTree { ); } + #syncBuiltInSpriteSheet(shadowRoot: ShadowRoot): void { + const currentBuiltInSprite = getTopLevelSpriteSheets(shadowRoot).find( + (sprite) => isBuiltInSpriteSheet(sprite) + ); + const nextBuiltInSprite = parseSpriteSheet( + getBuiltInSpriteSheet(normalizeFileTreeIcons(this.#icons).set) + ); + if (nextBuiltInSprite == null) { + return; + } + + if ( + currentBuiltInSprite != null && + currentBuiltInSprite.outerHTML === nextBuiltInSprite.outerHTML + ) { + return; + } + + if (currentBuiltInSprite != null) { + currentBuiltInSprite.replaceWith(nextBuiltInSprite); + } else { + shadowRoot.prepend(nextBuiltInSprite); + } + } + + #syncCustomSpriteSheet(shadowRoot: ShadowRoot): void { + const topLevelSprites = getTopLevelSpriteSheets(shadowRoot); + const builtInSprite = topLevelSprites.find((sprite) => + isBuiltInSpriteSheet(sprite) + ); + const currentCustomSprites = topLevelSprites.filter( + (sprite) => sprite !== builtInSprite + ); + const customSpriteSheet = + normalizeFileTreeIcons(this.#icons).spriteSheet?.trim() ?? ''; + if (customSpriteSheet.length === 0) { + for (const currentCustomSprite of currentCustomSprites) { + currentCustomSprite.remove(); + } + return; + } + + const customSprite = parseSpriteSheet(customSpriteSheet); + if (customSprite == null) { + for (const currentCustomSprite of currentCustomSprites) { + currentCustomSprite.remove(); + } + return; + } + + if ( + currentCustomSprites.length === 1 && + currentCustomSprites[0].outerHTML === customSprite.outerHTML + ) { + return; + } + + for (const currentCustomSprite of currentCustomSprites) { + currentCustomSprite.remove(); + } + shadowRoot.appendChild(customSprite); + } + + #syncIconModeAttrs(wrapper: HTMLElement): void { + const normalizedIcons = normalizeFileTreeIcons(this.#icons); + if ( + normalizedIcons.colored && + isColoredBuiltInIconSet(normalizedIcons.set) + ) { + wrapper.dataset.fileTreeColoredIcons = 'true'; + } else { + delete wrapper.dataset.fileTreeColoredIcons; + } + } + #getOrCreateWrapper(host: HTMLElement): HTMLDivElement { if (this.#wrapper != null) { return this.#wrapper; @@ -253,6 +342,7 @@ export class PathStoreFileTree { this.#wrapper = existingWrapper ?? document.createElement('div'); this.#wrapper.dataset.fileTreeId = this.#id; this.#wrapper.dataset.fileTreeVirtualizedWrapper = 'true'; + this.#syncIconModeAttrs(this.#wrapper); if (this.#wrapper.parentNode !== shadowRoot) { shadowRoot.appendChild(this.#wrapper); @@ -276,7 +366,8 @@ export class PathStoreFileTree { const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' }); adoptDeclarativeShadowDom(host, shadowRoot); ensureFileTreeStyles(shadowRoot); - ensureBuiltInSpriteSheet(shadowRoot); + this.#syncBuiltInSpriteSheet(shadowRoot); + this.#syncCustomSpriteSheet(shadowRoot); host.dataset.fileTreeVirtualized = 'true'; host.style.display = 'flex'; this.#slotHost.setHost(host); @@ -291,6 +382,7 @@ export function preloadPathStoreFileTree( const { composition, id, + icons, itemHeight, onSelectionChange: _onSelectionChange, overscan, @@ -301,10 +393,17 @@ export function preloadPathStoreFileTree( const controller = new PathStoreTreesController(controllerOptions); const resolvedViewportHeight = viewportHeight ?? PATH_STORE_TREES_DEFAULT_VIEWPORT_HEIGHT; + const normalizedIcons = normalizeFileTreeIcons(icons); + const customSpriteSheet = normalizedIcons.spriteSheet?.trim() ?? ''; + const coloredIconsAttr = + normalizedIcons.colored && isColoredBuiltInIconSet(normalizedIcons.set) + ? ' data-file-tree-colored-icons="true"' + : ''; const bodyHtml = renderToString( h(PathStoreTreesView, { controller, + icons, itemHeight, overscan, viewportHeight: resolvedViewportHeight, @@ -312,7 +411,7 @@ export function preloadPathStoreFileTree( ); controller.destroy(); - const shadowHtml = `${getBuiltInSpriteSheet('minimal')}
${bodyHtml}
`; + const shadowHtml = `${getBuiltInSpriteSheet(normalizedIcons.set)}${customSpriteSheet}
${bodyHtml}
`; const headerSlotHtml = getHeaderSlotHtml(composition); const html = `${headerSlotHtml}`; return { diff --git a/packages/trees/src/path-store/iconResolver.ts b/packages/trees/src/path-store/iconResolver.ts new file mode 100644 index 000000000..b54629bf4 --- /dev/null +++ b/packages/trees/src/path-store/iconResolver.ts @@ -0,0 +1,129 @@ +import { + getBuiltInFileIconName, + resolveBuiltInFileIconToken, +} from '../builtInIcons'; +import { + type FileTreeIcons, + normalizeFileTreeIcons, + type RemappedIcon, +} from '../iconConfig'; +import type { SVGSpriteNames } from '../sprite'; + +export interface PathStoreResolvedIcon { + height?: number; + name: string; + remappedFrom?: string; + token?: string; + viewBox?: string; + width?: number; +} + +const normalizeIconRuleKey = (value: string): string => + value.trim().toLowerCase(); + +const getBaseFileName = (path: string): string => { + const parts = path.split('/'); + return parts.at(-1) ?? path; +}; + +const getExtensionCandidates = (fileName: string): string[] => { + const segments = fileName.toLowerCase().split('.'); + const candidates: string[] = []; + for (let index = 1; index < segments.length; index += 1) { + candidates.push(segments.slice(index).join('.')); + } + return candidates; +}; + +function remapEntryToIcon( + entry: RemappedIcon, + remappedFrom: SVGSpriteNames +): PathStoreResolvedIcon { + if (typeof entry === 'string') { + return { name: entry, remappedFrom }; + } + + return { ...entry, remappedFrom }; +} + +// Mirrors the legacy file-icon priority order so the path-store lane can gain +// icon tiers without creating a second icon rule system. +export function createPathStoreIconResolver(icons?: FileTreeIcons): { + resolveIcon: ( + name: SVGSpriteNames, + filePath?: string + ) => PathStoreResolvedIcon; +} { + const normalizedIcons = normalizeFileTreeIcons(icons); + const iconRemap = normalizedIcons.remap; + const iconByFileName = new Map(); + for (const [fileName, icon] of Object.entries( + normalizedIcons.byFileName ?? {} + )) { + iconByFileName.set(fileName.toLowerCase(), icon); + } + + const iconByFileExtension = new Map(); + for (const [extension, icon] of Object.entries( + normalizedIcons.byFileExtension ?? {} + )) { + iconByFileExtension.set(normalizeIconRuleKey(extension), icon); + } + + const iconByFileNameContains = Object.entries( + normalizedIcons.byFileNameContains ?? {} + ).map(([needle, icon]): [string, RemappedIcon] => [ + needle.toLowerCase(), + icon, + ]); + + const resolveIcon = ( + name: SVGSpriteNames, + filePath?: string + ): PathStoreResolvedIcon => { + 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 remappedEntry = iconRemap?.[name]; + if (remappedEntry == null) { + return { name }; + } + + return remapEntryToIcon(remappedEntry, name); + }; + + return { resolveIcon }; +} diff --git a/packages/trees/src/path-store/types.ts b/packages/trees/src/path-store/types.ts index 70147b63b..8011ee33a 100644 --- a/packages/trees/src/path-store/types.ts +++ b/packages/trees/src/path-store/types.ts @@ -1,5 +1,7 @@ import type { PathStoreConstructorOptions } from '@pierre/path-store'; +import type { FileTreeIcons } from '../iconConfig'; + /** * The provisional public identity stays path-first so later phases can evolve * internal row bookkeeping without freezing path-store numeric IDs. @@ -71,6 +73,7 @@ export interface PathStoreFileTreeOptions extends PathStoreTreesControllerOptions, PathStoreTreesRenderOptions { composition?: PathStoreTreesCompositionOptions; id?: string; + icons?: FileTreeIcons; onSelectionChange?: PathStoreTreesSelectionChangeListener; } @@ -96,6 +99,7 @@ export interface PathStoreTreesStickyWindowLayout { export interface PathStoreTreesViewProps extends PathStoreTreesRenderOptions { controller: import('./controller').PathStoreTreesController; + icons?: FileTreeIcons; } export interface PathStoreTreeRenderProps { diff --git a/packages/trees/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index 55c02eece..149767f70 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -7,6 +7,7 @@ import { Icon } from '../components/Icon'; import { MiddleTruncate, Truncate } from '../components/OverflowText'; import { HEADER_SLOT_NAME } from '../constants'; import { PathStoreTreesController } from './controller'; +import { createPathStoreIconResolver } from './iconResolver'; import type { PathStoreTreesDirectoryHandle, PathStoreTreesItemHandle, @@ -179,6 +180,7 @@ function renderStyledRow( activeItemPath: string | null, itemHeight: number, registerButton: (path: string, element: HTMLButtonElement | null) => void, + resolveIcon: ReturnType['resolveIcon'], onKeyDown: (event: KeyboardEvent) => void, key: string | number, options: { @@ -261,9 +263,9 @@ function renderStyledRow( ) : null}
{row.hasChildren ? ( - + ) : ( - + )}
@@ -286,6 +288,7 @@ function renderRangeChildren( activeItemPath: string | null, itemHeight: number, registerButton: (path: string, element: HTMLButtonElement | null) => void, + resolveIcon: ReturnType['resolveIcon'], onKeyDown: (event: KeyboardEvent) => void ): JSX.Element[] { if (range.end < range.start) { @@ -305,6 +308,7 @@ function renderRangeChildren( activeItemPath, itemHeight, registerButton, + resolveIcon, onKeyDown, slotIndex ) @@ -317,6 +321,7 @@ function renderRangeChildren( */ export function PathStoreTreesView({ controller, + icons, itemHeight = PATH_STORE_TREES_DEFAULT_ITEM_HEIGHT, overscan = PATH_STORE_TREES_DEFAULT_OVERSCAN, viewportHeight = PATH_STORE_TREES_DEFAULT_VIEWPORT_HEIGHT, @@ -345,6 +350,10 @@ export function PathStoreTreesView({ viewportHeight, }) ); + const { resolveIcon } = useMemo( + () => createPathStoreIconResolver(icons), + [icons] + ); const focusedPath = controller.getFocusedPath(); const focusedIndex = controller.getFocusedIndex(); const focusedRowIsMounted = @@ -702,6 +711,7 @@ export function PathStoreTreesView({ rowButtonRefs.current.set(path, element); }, + resolveIcon, handleTreeKeyDown )} {parkedFocusedRow != null && parkedFocusedRowOffset != null @@ -718,6 +728,7 @@ export function PathStoreTreesView({ rowButtonRefs.current.set(path, element); }, + resolveIcon, handleTreeKeyDown, `parked:${parkedFocusedRow.path}`, { diff --git a/packages/trees/test/path-store-icon-config.test.ts b/packages/trees/test/path-store-icon-config.test.ts new file mode 100644 index 000000000..8a8ae0130 --- /dev/null +++ b/packages/trees/test/path-store-icon-config.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'bun:test'; +// @ts-expect-error -- no @types/jsdom; only used in tests +import { JSDOM } from 'jsdom'; + +function installDom() { + const dom = new JSDOM('', { + url: 'http://localhost', + }); + const originalValues = { + CSSStyleSheet: Reflect.get(globalThis, 'CSSStyleSheet'), + customElements: Reflect.get(globalThis, 'customElements'), + document: Reflect.get(globalThis, 'document'), + Event: Reflect.get(globalThis, 'Event'), + HTMLElement: Reflect.get(globalThis, 'HTMLElement'), + HTMLButtonElement: Reflect.get(globalThis, 'HTMLButtonElement'), + HTMLDivElement: Reflect.get(globalThis, 'HTMLDivElement'), + HTMLStyleElement: Reflect.get(globalThis, 'HTMLStyleElement'), + HTMLTemplateElement: Reflect.get(globalThis, 'HTMLTemplateElement'), + MutationObserver: Reflect.get(globalThis, 'MutationObserver'), + navigator: Reflect.get(globalThis, 'navigator'), + Node: Reflect.get(globalThis, 'Node'), + ResizeObserver: Reflect.get(globalThis, 'ResizeObserver'), + SVGElement: Reflect.get(globalThis, 'SVGElement'), + ShadowRoot: Reflect.get(globalThis, 'ShadowRoot'), + window: Reflect.get(globalThis, 'window'), + }; + + class MockStyleSheet { + replaceSync(_value: string): void {} + } + + class MockResizeObserver { + observe(_target: Element): void {} + disconnect(): void {} + } + + Object.assign(globalThis, { + CSSStyleSheet: MockStyleSheet, + customElements: dom.window.customElements, + document: dom.window.document, + Event: dom.window.Event, + HTMLElement: dom.window.HTMLElement, + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLDivElement: dom.window.HTMLDivElement, + HTMLStyleElement: dom.window.HTMLStyleElement, + HTMLTemplateElement: dom.window.HTMLTemplateElement, + MutationObserver: dom.window.MutationObserver, + navigator: dom.window.navigator, + Node: dom.window.Node, + ResizeObserver: MockResizeObserver, + SVGElement: dom.window.SVGElement, + ShadowRoot: dom.window.ShadowRoot, + window: dom.window, + }); + + return { + cleanup() { + for (const [key, value] of Object.entries(originalValues)) { + if (value === undefined) { + Reflect.deleteProperty(globalThis, key); + } else { + Object.assign(globalThis, { [key]: value }); + } + } + dom.window.close(); + }, + dom, + }; +} + +async function flushDom(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function getItemButton( + shadowRoot: ShadowRoot | null | undefined, + dom: JSDOM, + path: string +): HTMLButtonElement { + const button = shadowRoot?.querySelector(`[data-item-path="${path}"]`); + if (!(button instanceof dom.window.HTMLButtonElement)) { + throw new Error(`missing button for ${path}`); + } + + return button as HTMLButtonElement; +} + +describe('path-store icon config', () => { + test('preloadPathStoreFileTree includes custom sprite sheets and colored icon attrs', async () => { + const { preloadPathStoreFileTree } = await import('../src/path-store'); + + const payload = preloadPathStoreFileTree({ + flattenEmptyDirectories: true, + icons: { + set: 'complete', + colored: true, + spriteSheet: + '', + }, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + expect(payload.shadowHtml).toContain('pst-test-readme'); + expect(payload.shadowHtml).toContain('data-file-tree-colored-icons="true"'); + }); + + test('renders file icon remaps by file name', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: true, + icons: { + byFileName: { + 'readme.md': 'pst-test-readme', + }, + spriteSheet: + '', + }, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + const readmeButton = getItemButton(shadowRoot, dom, 'README.md'); + const href = readmeButton.querySelector('use')?.getAttribute('href'); + + expect(href).toBe('#pst-test-readme'); + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('falls back to built-in file icon tiers when overrides are absent', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: true, + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + const readmeButton = getItemButton(shadowRoot, dom, 'README.md'); + const href = + readmeButton.querySelector('use')?.getAttribute('href') ?? ''; + + expect(href.startsWith('#file-tree-builtin-')).toBe(true); + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); +}); From 97e83dcfd2ebd03fa7eaa40a05848f8f1dc64e40 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Apr 2026 19:41:47 -0500 Subject: [PATCH 2/3] get a better icon demo going --- .../PathStorePoweredRenderDemoClient.tsx | 126 +++++++++++++----- .../app/trees-dev/path-store-powered/page.tsx | 3 +- .../path-store-powered/pathStoreDemoIcons.ts | 2 +- packages/trees/src/path-store/file-tree.ts | 33 ++++- .../trees/test/path-store-icon-config.test.ts | 113 ++++++++++++++++ 5 files changed, 240 insertions(+), 37 deletions(-) diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index bbe04a184..93f282ec2 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -5,13 +5,13 @@ import { type PathStoreFileTreeOptions, } from '@pierre/trees/path-store'; import type { ReactNode } from 'react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ExampleCard } from '../_components/ExampleCard'; import { StateLog, useStateLog } from '../_components/StateLog'; import { pathStoreCapabilityMatrix } from './capabilityMatrix'; import { createPresortedPreparedInput } from './createPresortedPreparedInput'; -import { PATH_STORE_DEMO_ICONS } from './pathStoreDemoIcons'; +import { PATH_STORE_CUSTOM_ICONS } from './pathStoreDemoIcons'; interface SharedDemoOptions extends Omit< PathStoreFileTreeOptions, @@ -25,6 +25,7 @@ interface PathStorePoweredRenderDemoClientProps { function HydratedPathStoreExample({ containerHtml, + icons, description, footer, options, @@ -33,29 +34,42 @@ function HydratedPathStoreExample({ containerHtml: string; description: string; footer?: ReactNode; - options: PathStoreFileTreeOptions; + icons: PathStoreFileTreeOptions['icons']; + options: Omit; title: string; }) { - const ref = useCallback( - (node: HTMLDivElement | null) => { - if (node == null) { - return; - } - - const fileTree = new PathStoreFileTree(options); - const fileTreeContainer = node.querySelector('file-tree-container'); - if (fileTreeContainer instanceof HTMLElement) { - fileTree.hydrate({ fileTreeContainer }); - } else { - fileTree.render({ containerWrapper: node }); - } - - return () => { - fileTree.cleanUp(); - }; - }, - [options] - ); + const ref = useRef(null); + const fileTreeRef = useRef(null); + const initialIconsRef = useRef(icons); + + useEffect(() => { + const node = ref.current; + if (node == null) { + return; + } + + const fileTree = new PathStoreFileTree({ + ...options, + icons: initialIconsRef.current, + }); + fileTreeRef.current = fileTree; + const fileTreeContainer = node.querySelector('file-tree-container'); + if (fileTreeContainer instanceof HTMLElement) { + fileTree.hydrate({ fileTreeContainer }); + } else { + node.innerHTML = ''; + fileTree.render({ containerWrapper: node }); + } + + return () => { + fileTree.cleanUp(); + fileTreeRef.current = null; + }; + }, [containerHtml, options]); + + useEffect(() => { + fileTreeRef.current?.setIcons(icons); + }, [icons]); return ( @@ -74,6 +88,9 @@ export function PathStorePoweredRenderDemoClient({ sharedOptions, }: PathStorePoweredRenderDemoClientProps) { const { addLog, log } = useStateLog(); + const [iconMode, setIconMode] = useState< + 'complete' | 'custom' | 'minimal' | 'standard' + >('complete'); const preparedInput = useMemo( () => createPresortedPreparedInput(sharedOptions.paths), [sharedOptions.paths] @@ -84,7 +101,7 @@ export function PathStorePoweredRenderDemoClient({ }, [addLog] ); - const options = useMemo( + const options = useMemo>( () => ({ ...sharedOptions, composition: { @@ -114,13 +131,14 @@ export function PathStorePoweredRenderDemoClient({ }, }, }, - icons: PATH_STORE_DEMO_ICONS, id: 'pst-phase5-icons', onSelectionChange: handleSelectionChange, preparedInput, }), [addLog, handleSelectionChange, preparedInput, sharedOptions] ); + const activeIcons = + iconMode === 'custom' ? PATH_STORE_CUSTOM_ICONS : iconMode; return (
@@ -129,21 +147,69 @@ export function PathStorePoweredRenderDemoClient({ Path-store lane · provisional

- Focus + Selection + Header Slot + Icons + Focus + Selection + Header Slot + Icon Sets

The path-store lane keeps the landed focus and selection model, - preserves the header slot, and now proves custom README and TypeScript - icon remaps without pulling in context-menu behavior yet. + preserves the header slot, and now proves the built-in Minimal, + Standard, and Complete icon sets alongside a fully custom icon + configuration.

+
+ + + + +
} + icons={activeIcons} options={options} - title="Focus + Selection + Header Slot + Icons" + title="Focus + Selection + Header Slot + Icon Sets" />
diff --git a/apps/docs/app/trees-dev/path-store-powered/page.tsx b/apps/docs/app/trees-dev/path-store-powered/page.tsx index c12deff54..33ff791f9 100644 --- a/apps/docs/app/trees-dev/path-store-powered/page.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/page.tsx @@ -5,7 +5,6 @@ import { } from '@pierre/trees/path-store'; import { createPresortedPreparedInput } from './createPresortedPreparedInput'; -import { PATH_STORE_DEMO_ICONS } from './pathStoreDemoIcons'; import { PathStorePoweredRenderDemoClient } from './PathStorePoweredRenderDemoClient'; const linuxKernelWorkload = getVirtualizationWorkload('linux-1x'); @@ -24,7 +23,6 @@ export default function PathStorePoweredPage() { }, }, flattenEmptyDirectories: true, - icons: PATH_STORE_DEMO_ICONS, initialExpandedPaths: linuxKernelWorkload.expandedFolders, paths: linuxKernelWorkload.files, viewportHeight: 500, @@ -32,6 +30,7 @@ export default function PathStorePoweredPage() { const payload = preloadPathStoreFileTree({ ...sharedOptions, + icons: 'complete', id: 'pst-phase5-icons', preparedInput: linuxKernelPreparedInput, }); diff --git a/apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts b/apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts index 162b6c2a8..e9dca18a8 100644 --- a/apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts +++ b/apps/docs/app/trees-dev/path-store-powered/pathStoreDemoIcons.ts @@ -1,6 +1,6 @@ import type { FileTreeIcons } from '@pierre/trees'; -export const PATH_STORE_DEMO_ICONS: FileTreeIcons = { +export const PATH_STORE_CUSTOM_ICONS: FileTreeIcons = { byFileExtension: { ts: 'pst-phase5-icon-typescript', }, diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index 3750f70a5..90cc3be55 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -101,7 +101,6 @@ export class PathStoreFileTree { readonly #composition: PathStoreTreesCompositionOptions | undefined; readonly #controller: PathStoreTreesController; readonly #id: string; - readonly #icons: PathStoreFileTreeOptions['icons']; readonly #onSelectionChange: | PathStoreTreesSelectionChangeListener | undefined; @@ -111,6 +110,7 @@ export class PathStoreFileTree { 'itemHeight' | 'overscan' | 'viewportHeight' >; #fileTreeContainer: HTMLElement | undefined; + #icons: PathStoreFileTreeOptions['icons']; #selectionVersion: number; #selectionSubscription: (() => void) | null = null; #wrapper: HTMLDivElement | undefined; @@ -174,6 +174,23 @@ export class PathStoreFileTree { return this.#controller.getSelectedPaths(); } + public setIcons(icons?: PathStoreFileTreeOptions['icons']): void { + this.#icons = icons; + + const host = this.#fileTreeContainer; + const wrapper = this.#wrapper; + if (host == null || wrapper == null) { + return; + } + + this.#syncIconSurface(host, wrapper); + renderPathStoreTreesRoot(wrapper, { + controller: this.#controller, + icons: this.#icons, + ...this.#getResolvedViewOptions(host), + }); + } + public hydrate({ fileTreeContainer }: PathStoreTreeHydrationProps): void { const host = this.#prepareHost(fileTreeContainer); const wrapper = this.#getOrCreateWrapper(host); @@ -219,6 +236,16 @@ export class PathStoreFileTree { }; } + #syncIconSurface(host: HTMLElement, wrapper: HTMLElement): void { + const shadowRoot = host.shadowRoot; + if (shadowRoot != null) { + this.#syncBuiltInSpriteSheet(shadowRoot); + this.#syncCustomSpriteSheet(shadowRoot); + } + + this.#syncIconModeAttrs(wrapper); + } + #emitSelectionChange(): void { const onSelectionChange = this.#onSelectionChange; if (onSelectionChange == null) { @@ -342,7 +369,7 @@ export class PathStoreFileTree { this.#wrapper = existingWrapper ?? document.createElement('div'); this.#wrapper.dataset.fileTreeId = this.#id; this.#wrapper.dataset.fileTreeVirtualizedWrapper = 'true'; - this.#syncIconModeAttrs(this.#wrapper); + this.#syncIconSurface(host, this.#wrapper); if (this.#wrapper.parentNode !== shadowRoot) { shadowRoot.appendChild(this.#wrapper); @@ -366,8 +393,6 @@ export class PathStoreFileTree { const shadowRoot = host.shadowRoot ?? host.attachShadow({ mode: 'open' }); adoptDeclarativeShadowDom(host, shadowRoot); ensureFileTreeStyles(shadowRoot); - this.#syncBuiltInSpriteSheet(shadowRoot); - this.#syncCustomSpriteSheet(shadowRoot); host.dataset.fileTreeVirtualized = 'true'; host.style.display = 'flex'; this.#slotHost.setHost(host); diff --git a/packages/trees/test/path-store-icon-config.test.ts b/packages/trees/test/path-store-icon-config.test.ts index 8a8ae0130..c5632dd1e 100644 --- a/packages/trees/test/path-store-icon-config.test.ts +++ b/packages/trees/test/path-store-icon-config.test.ts @@ -169,4 +169,117 @@ describe('path-store icon config', () => { cleanup(); } }); + + test('setIcons swaps icon modes without resetting expanded state', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const mount = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(mount); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: false, + icons: 'complete', + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts', 'src/lib/utils.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper: mount }); + await flushDom(); + + const directoryItem = fileTree.getItem('src'); + if ( + directoryItem == null || + directoryItem.isDirectory() !== true || + !('collapse' in directoryItem) + ) { + throw new Error('expected src directory handle'); + } + + directoryItem.collapse(); + await flushDom(); + + let shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + expect( + shadowRoot?.querySelector('[data-item-path="src/index.ts"]') + ).toBeNull(); + + fileTree.setIcons({ + byFileName: { + 'readme.md': 'pst-test-readme', + }, + spriteSheet: + '', + }); + await flushDom(); + + shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + expect( + shadowRoot?.querySelector('[data-item-path="src/index.ts"]') + ).toBeNull(); + + const readmeButton = getItemButton(shadowRoot, dom, 'README.md'); + expect(readmeButton.querySelector('use')?.getAttribute('href')).toBe( + '#pst-test-readme' + ); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('hydrate reuses the existing SSR wrapper when the tree id matches', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree, preloadPathStoreFileTree } = + await import('../src/path-store'); + + const payload = preloadPathStoreFileTree({ + flattenEmptyDirectories: true, + icons: 'complete', + id: 'pst-hydrate-icons', + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts', 'src/lib/utils.ts'], + viewportHeight: 120, + }); + + const mount = dom.window.document.createElement('div'); + mount.innerHTML = payload.html; + dom.window.document.body.appendChild(mount); + + const host = mount.querySelector('file-tree-container'); + if (!(host instanceof dom.window.HTMLElement)) { + throw new Error('expected SSR host'); + } + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: true, + icons: 'complete', + id: 'pst-hydrate-icons', + initialExpansion: 'open', + paths: ['README.md', 'src/index.ts', 'src/lib/utils.ts'], + viewportHeight: 120, + }); + + fileTree.hydrate({ fileTreeContainer: host }); + await flushDom(); + + const shadowRoot = host.shadowRoot; + const wrapperCountAfter = shadowRoot?.querySelectorAll( + '[data-file-tree-virtualized-wrapper="true"]' + ).length; + const readmeButtons = shadowRoot?.querySelectorAll( + '[data-item-path="README.md"]' + ).length; + + expect(wrapperCountAfter).toBe(1); + expect(readmeButtons).toBe(1); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); }); From 9aea3e7674f7d7dcb2b09d6fba2d22b866f22182 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Mon, 13 Apr 2026 19:55:35 -0500 Subject: [PATCH 3/3] fix demo remounts bug --- .../path-store-powered/PathStorePoweredRenderDemoClient.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx index 93f282ec2..66afe877c 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -40,7 +40,8 @@ function HydratedPathStoreExample({ }) { const ref = useRef(null); const fileTreeRef = useRef(null); - const initialIconsRef = useRef(icons); + const latestIconsRef = useRef(icons); + latestIconsRef.current = icons; useEffect(() => { const node = ref.current; @@ -50,7 +51,7 @@ function HydratedPathStoreExample({ const fileTree = new PathStoreFileTree({ ...options, - icons: initialIconsRef.current, + icons: latestIconsRef.current, }); fileTreeRef.current = fileTree; const fileTreeContainer = node.querySelector('file-tree-container');