From 1802b01e732dc7b6f5dd8a96615e16289b82d70b Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Wed, 8 Apr 2026 16:42:21 -0500 Subject: [PATCH 1/7] wrong phase 4 but fine --- packages/path-store/IMPLEMENTATION.md | 22 ++++++++++++++++ packages/path-store/scripts/benchmark.ts | 6 +++++ packages/path-store/test/path-store.test.ts | 29 +++++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/packages/path-store/IMPLEMENTATION.md b/packages/path-store/IMPLEMENTATION.md index f3c0510ab..0625fad48 100644 --- a/packages/path-store/IMPLEMENTATION.md +++ b/packages/path-store/IMPLEMENTATION.md @@ -1125,6 +1125,28 @@ The main rejected primary directions are: - large-directory indexed aggregates - visible jump benchmarks +Phase 4 closure note (2026-04-08): + +- width thresholds are implemented in both mutable and static selection paths. +- medium-directory chunk summaries are implemented in both mutable and static + selection paths. +- large-directory indexed aggregates are **deferred intentionally**. The current + wide-directory benchmark evidence does not show Phase 4 as blocking enough to + justify a heavier second-tier structure yet. +- visible jump benchmarks are now recorded with: + - `bun ws path-store benchmark -- --filter '^visible-middle/wide-directory-5k/(30|200|500)$'` + - `visible-middle/wide-directory-5k/30` → p50 `1.04 µs`, p95 `1.50 µs` + - `visible-middle/wide-directory-5k/200` → p50 `6.17 µs`, p95 `7.83 µs` + - `visible-middle/wide-directory-5k/500` → p50 `15.92 µs`, p95 `18.50 µs` +- If large-directory aggregates are revisited later, the first candidate should + remain a Fenwick-style index or equivalent local aggregate tree rather than a + global visible-order structure. +- Reopen this question only when same-workload wide-directory visible jumps are + shown to be a real bottleneck, or when a prototype clears a meaningful gate: + at least 20% p50 improvement on the primary scenario, no more than 10% p95 + regression on same-workload guardrails, and no correctness or mutation-cost + regressions. + ### Phase 5: Flattening Projection - flatten chain detection diff --git a/packages/path-store/scripts/benchmark.ts b/packages/path-store/scripts/benchmark.ts index f411e8f21..77a41610e 100644 --- a/packages/path-store/scripts/benchmark.ts +++ b/packages/path-store/scripts/benchmark.ts @@ -5745,6 +5745,12 @@ function createScenarioFactories( factories.push( createVisibleScenarioFactory(wideDirectoryWorkload, 'middle', 200) ); + factories.push( + createVisibleScenarioFactory(wideDirectoryWorkload, 'middle', 30) + ); + factories.push( + createVisibleScenarioFactory(wideDirectoryWorkload, 'middle', 500) + ); const flattenChainWorkload = loadWorkload( PHASE_5_FLATTEN_CHAIN_WORKLOAD_NAME diff --git a/packages/path-store/test/path-store.test.ts b/packages/path-store/test/path-store.test.ts index 54bb4191b..cd1059815 100644 --- a/packages/path-store/test/path-store.test.ts +++ b/packages/path-store/test/path-store.test.ts @@ -2552,6 +2552,35 @@ describe('PathStore', () => { ]); }); + test('static store matches mutable wide-directory visible windows after collapse and re-expand', () => { + const paths = createWideDirectoryPaths(160); + const mutableStore = new PathStore({ + initialExpansion: 'open', + paths, + }); + const staticStore = new StaticPathStore({ + initialExpansion: 'open', + paths, + }); + + expect(staticStore.getVisibleCount()).toBe(mutableStore.getVisibleCount()); + expect(getVisibleRowsSansIds(staticStore, 95, 99)).toEqual( + getVisibleRowsSansIds(mutableStore, 95, 99) + ); + + mutableStore.collapse('wide/'); + staticStore.collapse('wide/'); + expect(getVisibleRowsSansIds(staticStore, 0, 1)).toEqual( + getVisibleRowsSansIds(mutableStore, 0, 1) + ); + + mutableStore.expand('wide/'); + staticStore.expand('wide/'); + expect(getVisibleRowsSansIds(staticStore, 95, 99)).toEqual( + getVisibleRowsSansIds(mutableStore, 95, 99) + ); + }); + test('matches a rebuild after wide-directory mutations cross chunk boundaries', () => { const store = new PathStore({ initialExpansion: 'open', From 76fe95b5758aaa639b765181248046ee8d005e37 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Wed, 8 Apr 2026 17:58:08 -0500 Subject: [PATCH 2/7] selection maybe --- .../PathStorePoweredRenderDemoClient.tsx | 32 +- .../app/trees-dev/path-store-powered/page.tsx | 2 +- packages/trees/src/path-store/controller.ts | 268 +++++++++++++++- packages/trees/src/path-store/file-tree.ts | 59 +++- packages/trees/src/path-store/index.ts | 1 + packages/trees/src/path-store/types.ts | 10 + packages/trees/src/path-store/view.tsx | 107 +++++-- .../test/path-store-render-scroll.test.ts | 300 +++++++++++++++++- ...irtualized-client-render-benchmark.test.ts | 14 +- 9 files changed, 732 insertions(+), 61 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 b723d137b..6b443599c 100644 --- a/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx @@ -4,9 +4,11 @@ import { PathStoreFileTree, type PathStoreFileTreeOptions, } from '@pierre/trees/path-store'; +import type { ReactNode } from 'react'; import { useCallback, useMemo } from 'react'; import { ExampleCard } from '../_components/ExampleCard'; +import { StateLog, useStateLog } from '../_components/StateLog'; import { pathStoreCapabilityMatrix } from './capabilityMatrix'; import { createPresortedPreparedInput } from './createPresortedPreparedInput'; @@ -23,11 +25,13 @@ interface PathStorePoweredRenderDemoClientProps { function HydratedPathStoreExample({ containerHtml, description, + footer, options, title, }: { containerHtml: string; description: string; + footer?: ReactNode; options: PathStoreFileTreeOptions; title: string; }) { @@ -53,7 +57,7 @@ function HydratedPathStoreExample({ ); return ( - +
createPresortedPreparedInput(sharedOptions.paths), [sharedOptions.paths] ); + const handleSelectionChange = useCallback( + (selectedPaths: readonly string[]) => { + addLog(`selected: [${selectedPaths.join(', ')}]`); + }, + [addLog] + ); const options = useMemo( () => ({ ...sharedOptions, - id: 'pst-phase3', + id: 'pst-phase4', + onSelectionChange: handleSelectionChange, preparedInput, }), - [preparedInput, sharedOptions] + [handleSelectionChange, preparedInput, sharedOptions] ); return ( @@ -87,19 +99,21 @@ export function PathStorePoweredRenderDemoClient({

Path-store lane · provisional

-

Focus + Navigation

+

Focus + Selection

- Phase 3 adds the first full keyboard interaction slice to the - path-store-powered trees lane: single-item focus, baseline tree - navigation, and virtualization-safe DOM focus recovery. + Phase 4 keeps the landed focus/navigation model and adds selection: + click and keyboard selection semantics, path-first imperative item + methods, and lightweight selection-change observation in the existing + path-store-powered demo.

} options={options} - title="Focus + Navigation" + title="Focus + Selection" />
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 f64b32fcf..7eab1bd74 100644 --- a/apps/docs/app/trees-dev/path-store-powered/page.tsx +++ b/apps/docs/app/trees-dev/path-store-powered/page.tsx @@ -23,7 +23,7 @@ export default function PathStorePoweredPage() { const payload = preloadPathStoreFileTree({ ...sharedOptions, - id: 'pst-phase3', + id: 'pst-phase4', preparedInput: linuxKernelPreparedInput, }); diff --git a/packages/trees/src/path-store/controller.ts b/packages/trees/src/path-store/controller.ts index 88f66c0d6..9373bd331 100644 --- a/packages/trees/src/path-store/controller.ts +++ b/packages/trees/src/path-store/controller.ts @@ -34,6 +34,32 @@ interface PathStoreTreesVisibleProjection { visibleRows: readonly PathStoreVisibleRow[]; } +function arePathSetsEqual( + currentPaths: ReadonlySet, + nextPaths: readonly string[] +): boolean { + if (currentPaths.size !== nextPaths.length) { + return false; + } + + for (const path of nextPaths) { + if (!currentPaths.has(path)) { + return false; + } + } + + return true; +} + +function getVisibleSelectionTargetPath( + row: Pick +): string { + return row.isFlattened + ? (row.flattenedSegments?.findLast((segment) => segment.isTerminal)?.path ?? + row.path) + : row.path; +} + function resolvePathStoreTreesItemPath( itemMetadata: ReadonlyMap, path: string @@ -239,6 +265,8 @@ export class PathStoreTreesController { #itemMetadata = new Map(); #parentPaths = new Map(); #projectionRows: readonly PathStoreVisibleTreeProjectionRow[] = []; + #selectionAnchorPath: string | null = null; + #selectedPaths = new Set(); #store: PathStore; #unsubscribe: (() => void) | null; #visibleIndexByPath = new Map(); @@ -336,6 +364,10 @@ export class PathStoreTreesController { return this.#focusedPath; } + public getSelectedPaths(): readonly string[] { + return [...this.#selectedPaths]; + } + public getVisibleCount(): number { return this.#visibleRows.length; } @@ -377,6 +409,9 @@ export class PathStoreTreesController { isExpanded: row.isExpanded, isFlattened: row.isFlattened, isFocused: projectionRow.path === this.#focusedPath, + isSelected: this.#selectedPaths.has( + getVisibleSelectionTargetPath(row) + ), kind: row.kind, level: row.depth, name: row.name, @@ -403,6 +438,169 @@ export class PathStoreTreesController { : (this.#itemHandles.get(resolvedPath) ?? null); } + public selectAllVisiblePaths(): void { + const nextSelectedPaths = this.#visibleRows.map((row) => + getVisibleSelectionTargetPath(row) + ); + this.#applySelection( + nextSelectedPaths, + this.#focusedPath ?? this.#selectionAnchorPath + ); + } + + public selectOnlyPath(path: string): void { + const resolvedPath = this.#resolveSelectionPath(path); + if (resolvedPath == null) { + return; + } + + this.#applySelection([resolvedPath], resolvedPath); + } + + public selectPath(path: string): void { + const resolvedPath = this.#resolveSelectionPath(path); + if (resolvedPath == null || this.#selectedPaths.has(resolvedPath)) { + return; + } + + this.#applySelection([...this.#selectedPaths, resolvedPath]); + } + + public deselectPath(path: string): void { + const resolvedPath = this.#resolveSelectionPath(path); + if (resolvedPath == null || !this.#selectedPaths.has(resolvedPath)) { + return; + } + + this.#applySelection( + [...this.#selectedPaths].filter( + (selectedPath) => selectedPath !== resolvedPath + ) + ); + } + + public toggleFocusedSelection(): void { + if (this.#focusedPath == null) { + return; + } + + this.togglePathSelection(this.#focusedPath); + } + + public togglePathSelection(path: string): void { + const resolvedPath = this.#resolveSelectionPath(path); + if (resolvedPath == null) { + return; + } + + if (this.#selectedPaths.has(resolvedPath)) { + this.deselectPath(resolvedPath); + return; + } + + this.selectPath(resolvedPath); + } + + public togglePathSelectionFromInput(path: string): void { + const resolvedPath = this.#resolveSelectionPath(path); + if (resolvedPath == null) { + return; + } + + if (this.#selectedPaths.has(resolvedPath)) { + this.#applySelection( + [...this.#selectedPaths].filter( + (selectedPath) => selectedPath !== resolvedPath + ), + resolvedPath + ); + return; + } + + this.#applySelection([...this.#selectedPaths, resolvedPath], resolvedPath); + } + + public selectPathRange(path: string, unionSelection: boolean): void { + const resolvedPath = this.#resolveSelectionPath(path); + if (resolvedPath == null) { + return; + } + + const anchorPath = this.#selectionAnchorPath; + if ( + anchorPath == null || + !this.#visibleIndexByPath.has(anchorPath) || + !this.#visibleIndexByPath.has(resolvedPath) + ) { + const nextSelectedPaths = unionSelection + ? [...this.#selectedPaths, resolvedPath] + : [resolvedPath]; + this.#applySelection(nextSelectedPaths, resolvedPath); + return; + } + + const anchorIndex = this.#visibleIndexByPath.get(anchorPath); + const targetIndex = this.#visibleIndexByPath.get(resolvedPath); + if (anchorIndex == null || targetIndex == null) { + return; + } + + const [startIndex, endIndex] = + anchorIndex <= targetIndex + ? [anchorIndex, targetIndex] + : [targetIndex, anchorIndex]; + const rangePaths = this.#visibleRows + .slice(startIndex, endIndex + 1) + .map((row) => getVisibleSelectionTargetPath(row)); + const nextSelectedPaths = unionSelection + ? [...this.#selectedPaths, ...rangePaths] + : rangePaths; + this.#applySelection(nextSelectedPaths, anchorPath); + } + + public extendSelectionFromFocused(offset: -1 | 1): void { + if (this.#focusedPath == null) { + return; + } + + const focusedIndex = this.getFocusedIndex(); + if (focusedIndex === -1) { + return; + } + + const nextIndex = Math.min( + this.#projectionRows.length - 1, + Math.max(0, focusedIndex + offset) + ); + if (nextIndex === focusedIndex) { + return; + } + + const currentRow = this.#visibleRows[focusedIndex]; + const nextRow = this.#visibleRows[nextIndex]; + const currentPath = + currentRow == null ? null : getVisibleSelectionTargetPath(currentRow); + const nextPath = + nextRow == null ? null : getVisibleSelectionTargetPath(nextRow); + if (currentPath == null || nextPath == null || nextRow == null) { + return; + } + + const nextSelectedPaths = new Set(this.#selectedPaths); + if (nextSelectedPaths.has(currentPath) && nextSelectedPaths.has(nextPath)) { + nextSelectedPaths.delete(currentPath); + } else { + nextSelectedPaths.add(nextPath); + } + + this.#applySelection( + [...nextSelectedPaths], + this.#selectionAnchorPath ?? currentPath, + false + ); + this.#setFocusedPath(nextRow.path); + } + public subscribe(listener: PathStoreTreesControllerListener): () => void { this.#listeners.add(listener); listener(); @@ -423,10 +621,28 @@ export class PathStoreTreesController { paths, }); const previousFocusedPath = this.#focusedPath; + const previousSelectedPaths = this.getSelectedPaths(); + const previousSelectionAnchorPath = this.#selectionAnchorPath; this.#unsubscribe?.(); this.#store = nextStore; this.#applyItemState(nextItemState); + this.#selectedPaths = new Set( + previousSelectedPaths.filter( + (selectedPath) => + resolvePathStoreTreesItemPath( + nextItemState.itemMetadata, + selectedPath + ) != null + ) + ); + this.#selectionAnchorPath = + previousSelectionAnchorPath == null + ? null + : (resolvePathStoreTreesItemPath( + nextItemState.itemMetadata, + previousSelectionAnchorPath + ) ?? null); this.#rebuildVisibleProjection(previousFocusedPath); this.#unsubscribe = this.#subscribe(); this.#emit(); @@ -458,11 +674,36 @@ export class PathStoreTreesController { this.#store.collapse(path); } + #applySelection( + nextSelectedPaths: readonly string[], + nextAnchorPath: string | null = this.#selectionAnchorPath, + emit: boolean = true + ): void { + const uniqueSelectedPaths = [...new Set(nextSelectedPaths)]; + const selectionChanged = !arePathSetsEqual( + this.#selectedPaths, + uniqueSelectedPaths + ); + const anchorChanged = this.#selectionAnchorPath !== nextAnchorPath; + if (!selectionChanged && !anchorChanged) { + return; + } + + this.#selectedPaths = new Set(uniqueSelectedPaths); + this.#selectionAnchorPath = nextAnchorPath; + if (emit) { + this.#emit(); + } + } + #createDirectoryHandle(path: string): PathStoreTreesDirectoryHandle { return { collapse: () => { this.#collapseDirectory(path); }, + deselect: () => { + this.deselectPath(path); + }, expand: () => { this.#expandDirectory(path); }, @@ -473,6 +714,13 @@ export class PathStoreTreesController { isDirectory: () => true, isExpanded: () => this.#expandedDirectories.has(path), isFocused: () => this.#focusedPath === path, + isSelected: () => this.#selectedPaths.has(path), + select: () => { + this.selectPath(path); + }, + toggleSelect: () => { + this.togglePathSelection(path); + }, toggle: () => { this.#toggleDirectory(path); }, @@ -481,12 +729,22 @@ export class PathStoreTreesController { #createFileHandle(path: string): PathStoreTreesFileHandle { return { + deselect: () => { + this.deselectPath(path); + }, focus: () => { this.focusPath(path); }, getPath: () => path, isDirectory: () => false, isFocused: () => this.#focusedPath === path, + isSelected: () => this.#selectedPaths.has(path), + select: () => { + this.selectPath(path); + }, + toggleSelect: () => { + this.togglePathSelection(path); + }, }; } @@ -566,7 +824,11 @@ export class PathStoreTreesController { this.#visibleRows = projection.visibleRows; } - #setFocusedPath(path: string): void { + #resolveSelectionPath(path: string): string | null { + return resolvePathStoreTreesItemPath(this.#itemMetadata, path); + } + + #setFocusedPath(path: string, emit: boolean = true): void { const currentFocusedPath = this.#focusedPath; if (currentFocusedPath === path) { return; @@ -576,7 +838,9 @@ export class PathStoreTreesController { return; } this.#focusedPath = path; - this.#emit(); + if (emit) { + this.#emit(); + } } #subscribe(): () => void { diff --git a/packages/trees/src/path-store/file-tree.ts b/packages/trees/src/path-store/file-tree.ts index df9d9936f..1c4aeedd6 100644 --- a/packages/trees/src/path-store/file-tree.ts +++ b/packages/trees/src/path-store/file-tree.ts @@ -21,6 +21,7 @@ import type { PathStoreTreeHydrationProps, PathStoreTreeRenderProps, PathStoreTreesItemHandle, + PathStoreTreesSelectionChangeListener, } from './types'; import { PathStoreTreesView } from './view'; import { PATH_STORE_TREES_DEFAULT_VIEWPORT_HEIGHT } from './virtualization'; @@ -73,23 +74,42 @@ export class PathStoreFileTree { readonly #controller: PathStoreTreesController; readonly #id: string; + readonly #onSelectionChange: + | PathStoreTreesSelectionChangeListener + | undefined; readonly #viewOptions: Pick< PathStoreFileTreeOptions, 'itemHeight' | 'overscan' | 'viewportHeight' >; #fileTreeContainer: HTMLElement | undefined; + #selectionSnapshot: string; + #selectionSubscription: (() => void) | null = null; #wrapper: HTMLDivElement | undefined; public constructor(options: PathStoreFileTreeOptions) { - const { id, itemHeight, overscan, viewportHeight, ...controllerOptions } = - options; + const { + id, + itemHeight, + onSelectionChange, + overscan, + viewportHeight, + ...controllerOptions + } = options; this.#id = createClientId(id); + this.#onSelectionChange = onSelectionChange; this.#viewOptions = { itemHeight, overscan, viewportHeight, }; this.#controller = new PathStoreTreesController(controllerOptions); + this.#selectionSnapshot = this.#createSelectionSnapshot(); + this.#selectionSubscription = + this.#onSelectionChange == null + ? null + : this.#controller.subscribe(() => { + this.#emitSelectionChange(); + }); } public cleanUp(): void { @@ -102,6 +122,8 @@ export class PathStoreFileTree { delete this.#fileTreeContainer.dataset.fileTreeVirtualized; this.#fileTreeContainer = undefined; } + this.#selectionSubscription?.(); + this.#selectionSubscription = null; this.#controller.destroy(); } @@ -113,6 +135,10 @@ export class PathStoreFileTree { return this.#controller.getItem(path); } + public getSelectedPaths(): readonly string[] { + return this.#controller.getSelectedPaths(); + } + public hydrate({ fileTreeContainer }: PathStoreTreeHydrationProps): void { const host = this.#prepareHost(fileTreeContainer); const wrapper = this.#getOrCreateWrapper(host); @@ -154,6 +180,25 @@ export class PathStoreFileTree { }; } + #createSelectionSnapshot(): string { + return this.#controller.getSelectedPaths().join('|'); + } + + #emitSelectionChange(): void { + const onSelectionChange = this.#onSelectionChange; + if (onSelectionChange == null) { + return; + } + + const nextSnapshot = this.#createSelectionSnapshot(); + if (nextSnapshot === this.#selectionSnapshot) { + return; + } + + this.#selectionSnapshot = nextSnapshot; + onSelectionChange(this.#controller.getSelectedPaths()); + } + #getOrCreateWrapper(host: HTMLElement): HTMLDivElement { if (this.#wrapper != null) { return this.#wrapper; @@ -206,8 +251,14 @@ export class PathStoreFileTree { export function preloadPathStoreFileTree( options: PathStoreFileTreeOptions ): PathStoreFileTreeSsrPayload { - const { id, itemHeight, overscan, viewportHeight, ...controllerOptions } = - options; + const { + id, + itemHeight, + onSelectionChange: _onSelectionChange, + overscan, + viewportHeight, + ...controllerOptions + } = options; const resolvedId = createServerId(id); const controller = new PathStoreTreesController(controllerOptions); const resolvedViewportHeight = diff --git a/packages/trees/src/path-store/index.ts b/packages/trees/src/path-store/index.ts index 73ccf3220..940596763 100644 --- a/packages/trees/src/path-store/index.ts +++ b/packages/trees/src/path-store/index.ts @@ -13,6 +13,7 @@ export type { PathStoreTreesPublicId, PathStoreTreesRenderOptions, PathStoreTreesRange, + PathStoreTreesSelectionChangeListener, PathStoreTreesStickyWindowLayout, PathStoreTreesVisibleRow, } from './types'; diff --git a/packages/trees/src/path-store/types.ts b/packages/trees/src/path-store/types.ts index f574b01e9..26ea184ba 100644 --- a/packages/trees/src/path-store/types.ts +++ b/packages/trees/src/path-store/types.ts @@ -23,6 +23,7 @@ export interface PathStoreTreesVisibleRow { hasChildren: boolean; index: number; isFocused: boolean; + isSelected: boolean; isExpanded: boolean; isFlattened: boolean; kind: 'directory' | 'file'; @@ -34,10 +35,14 @@ export interface PathStoreTreesVisibleRow { } export interface PathStoreTreesItemHandleBase { + deselect(): void; focus(): void; getPath(): PathStoreTreesPublicId; isFocused(): boolean; isDirectory(): boolean; + isSelected(): boolean; + select(): void; + toggleSelect(): void; } export interface PathStoreTreesDirectoryHandle extends PathStoreTreesItemHandleBase { @@ -65,6 +70,7 @@ export interface PathStoreTreesRenderOptions { export interface PathStoreFileTreeOptions extends PathStoreTreesControllerOptions, PathStoreTreesRenderOptions { id?: string; + onSelectionChange?: PathStoreTreesSelectionChangeListener; } export interface PathStoreTreesViewportMetrics { @@ -107,3 +113,7 @@ export interface PathStoreFileTreeSsrPayload { } export type PathStoreTreesControllerListener = () => void; + +export type PathStoreTreesSelectionChangeListener = ( + selectedPaths: readonly PathStoreTreesPublicId[] +) => void; diff --git a/packages/trees/src/path-store/view.tsx b/packages/trees/src/path-store/view.tsx index aaf535885..db6d339f5 100644 --- a/packages/trees/src/path-store/view.tsx +++ b/packages/trees/src/path-store/view.tsx @@ -101,6 +101,12 @@ function isPathStoreTreesDirectoryHandle( return item != null && 'toggle' in item; } +function isSpaceSelectionKey(event: KeyboardEvent): boolean { + return ( + event.code === 'Space' || event.key === ' ' || event.key === 'Spacebar' + ); +} + // Focus changes should keep the logical focused row visible without relying on // browser scrollIntoView heuristics inside the virtualized shadow root. // Keeps a newly focused row inside the viewport without relying on @@ -186,6 +192,7 @@ function renderStyledRow( row.isFocused && activeItemPath === targetPath ? { 'data-item-focused': true } : {}; + const selectedProps = row.isSelected ? { 'data-item-selected': true } : {}; return ( +
+
+ + + + + + diff --git a/packages/trees/test/path-store-profile-cli.test.ts b/packages/trees/test/path-store-profile-cli.test.ts new file mode 100644 index 000000000..345a706e8 --- /dev/null +++ b/packages/trees/test/path-store-profile-cli.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'bun:test'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); + +function createCommandEnv(): Record { + const env: Record = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] != null + ) + ); + env.AGENT = '1'; + delete env.FORCE_COLOR; + delete env.NO_COLOR; + return env; +} + +test('profile:pathstore CLI help advertises the expected workload/render workflow', () => { + const result = Bun.spawnSync({ + cmd: ['bun', 'run', './scripts/profileTreesPathStore.ts', '--help'], + cwd: packageRoot, + env: createCommandEnv(), + stdout: 'pipe', + stderr: 'pipe', + }); + + const stdout = new TextDecoder().decode(result.stdout); + const stderr = new TextDecoder().decode(result.stderr).trim(); + + expect(result.exitCode).toBe(0); + expect(stderr).toBe(''); + expect(stdout).toContain('bun ws trees profile:pathstore'); + expect(stdout).toContain('linux-5x'); + expect(stdout).toContain('path-store-profile.html'); +}); + +test('profile:pathstore CLI rejects unknown workloads before browser setup', () => { + const result = Bun.spawnSync({ + cmd: [ + 'bun', + 'run', + './scripts/profileTreesPathStore.ts', + '--workload', + 'not-a-real-workload', + ], + cwd: packageRoot, + env: createCommandEnv(), + stdout: 'pipe', + stderr: 'pipe', + }); + + const stdout = new TextDecoder().decode(result.stdout).trim(); + const stderr = new TextDecoder().decode(result.stderr); + + expect(result.exitCode).not.toBe(0); + expect(stdout).toBe(''); + expect(stderr).toContain("Invalid --workload value 'not-a-real-workload'"); +}); diff --git a/packages/trees/test/path-store-profile-fixture.test.ts b/packages/trees/test/path-store-profile-fixture.test.ts new file mode 100644 index 000000000..fd3d789c8 --- /dev/null +++ b/packages/trees/test/path-store-profile-fixture.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'bun:test'; +// @ts-expect-error -- no @types/jsdom; only used in tests +import { JSDOM } from 'jsdom'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { + createPathStoreProfileFixtureOptions, + DEFAULT_PATH_STORE_PROFILE_WORKLOAD_NAME, + getPathStoreProfileWorkload, + PATH_STORE_PROFILE_VIEWPORT_HEIGHT, + PATH_STORE_PROFILE_WORKLOAD_NAMES, +} from '../scripts/lib/pathStoreProfileShared'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); + +test('path-store profile fixture workload defaults mirror the intended path-store profile set', () => { + expect(PATH_STORE_PROFILE_WORKLOAD_NAMES).toEqual([ + 'linux-5x', + 'linux-10x', + 'linux', + 'demo-small', + ]); + expect(DEFAULT_PATH_STORE_PROFILE_WORKLOAD_NAME).toBe('linux-5x'); +}); + +test('path-store profile fixture options mirror the Phase 4 docs tree behavior', () => { + const workload = getPathStoreProfileWorkload('linux-5x'); + const options = createPathStoreProfileFixtureOptions(workload); + + expect(options.flattenEmptyDirectories).toBe(true); + expect(options.initialExpandedPaths).toEqual(workload.expandedFolders); + expect(options.paths).toEqual(workload.files); + expect(options.viewportHeight).toBe(PATH_STORE_PROFILE_VIEWPORT_HEIGHT); + const preparedInput = options.preparedInput as { + paths: readonly string[]; + presortedPaths: readonly string[]; + }; + expect(preparedInput.paths).toEqual(workload.files); + expect(preparedInput.presortedPaths).toEqual(workload.files); +}); + +test('path-store profile fixture HTML stays minimal and idle-on-load', () => { + const html = readFileSync( + `${packageRoot}/test/e2e/fixtures/path-store-profile.html`, + 'utf8' + ); + const dom = new JSDOM(html); + const { document } = dom.window; + + expect(document.querySelector('[data-profile-render-button]')).not.toBeNull(); + expect(document.querySelector('#workload')).not.toBeNull(); + expect(document.querySelector('[data-profile-mount]')).not.toBeNull(); + expect(document.querySelector('file-tree-container')).toBeNull(); + expect(document.querySelector('h1')).toBeNull(); + expect(html.includes('Capability / phase matrix')).toBe(false); +}); diff --git a/packages/trees/test/path-store-render-scroll.test.ts b/packages/trees/test/path-store-render-scroll.test.ts index a81522aa9..80a87272e 100644 --- a/packages/trees/test/path-store-render-scroll.test.ts +++ b/packages/trees/test/path-store-render-scroll.test.ts @@ -1559,6 +1559,326 @@ describe('path-store render + scroll', () => { } }); + test('Shift-click range selection works when anchor or target is a flattened row', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const containerWrapper = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(containerWrapper); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: true, + initialExpandedPaths: ['src/'], + paths: ['README.md', 'src/lib/util.ts', 'src/lib/helpers.ts'], + viewportHeight: 200, + }); + + fileTree.render({ containerWrapper }); + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + + // Click the flattened row (src / lib) to set it as anchor + clickItem(shadowRoot, dom, 'src/lib/'); + await flushDom(); + expect(fileTree.getSelectedPaths()).toEqual(['src/lib/']); + + // Shift-click a file below — should produce a range from the flattened + // row through the target, not fall back to single selection. + clickItem(shadowRoot, dom, 'src/lib/helpers.ts', { shiftKey: true }); + await flushDom(); + expect(fileTree.getSelectedPaths()).toContain('src/lib/'); + expect(fileTree.getSelectedPaths()).toContain('src/lib/helpers.ts'); + + // Now test the reverse: anchor on a regular file, Shift-click the + // flattened row. + clickItem(shadowRoot, dom, 'src/lib/helpers.ts'); + await flushDom(); + + clickItem(shadowRoot, dom, 'src/lib/', { shiftKey: true }); + await flushDom(); + expect(fileTree.getSelectedPaths()).toContain('src/lib/'); + expect(fileTree.getSelectedPaths()).toContain('src/lib/helpers.ts'); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('expansion between selected items does not corrupt index-based selection', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const containerWrapper = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(containerWrapper); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: false, + initialExpansion: 0, + paths: ['a.ts', 'src/index.ts', 'src/lib/util.ts', 'z.ts'], + viewportHeight: 200, + }); + + fileTree.render({ containerWrapper }); + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + + // Select a.ts, then Shift-click z.ts while the collapsed src/ row sits + // outside the anchored visible range because directories sort ahead of + // root files in this lane. + clickItem(shadowRoot, dom, 'a.ts'); + await flushDom(); + clickItem(shadowRoot, dom, 'z.ts', { shiftKey: true }); + await flushDom(); + expect(fileTree.getSelectedPaths()).toEqual(['a.ts', 'z.ts']); + expect(getSelectedItemPaths(shadowRoot, dom)).toEqual(['a.ts', 'z.ts']); + + // Expand src/ — new children appear but selection stays path-based + const srcDir = fileTree.getItem('src/'); + if (srcDir == null || !srcDir.isDirectory() || !('expand' in srcDir)) { + throw new Error('missing src directory'); + } + srcDir.expand(); + await flushDom(); + + // Only the original selected root-file range should remain selected, not + // the newly visible children. + expect(fileTree.getSelectedPaths()).toEqual(['a.ts', 'z.ts']); + expect( + getItemButton(shadowRoot, dom, 'src/index.ts').dataset.itemSelected + ).toBeUndefined(); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('onSelectionChange does not fire on collapse even when selected items become hidden', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const containerWrapper = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(containerWrapper); + const selectionEvents: string[][] = []; + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: false, + initialExpandedPaths: ['src/'], + onSelectionChange: (items) => { + selectionEvents.push([...items]); + }, + paths: ['src/index.ts', 'src/lib/util.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper }); + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + + clickItem(shadowRoot, dom, 'src/index.ts'); + await flushDom(); + expect(selectionEvents.length).toBe(1); + + // Collapse the parent — selected item disappears from DOM but stays + // in the selection set, so the callback should NOT fire again. + const srcDir = fileTree.getItem('src/'); + if (srcDir == null || !srcDir.isDirectory() || !('collapse' in srcDir)) { + throw new Error('missing src directory'); + } + srcDir.collapse(); + await flushDom(); + + expect(selectionEvents.length).toBe(1); + expect(fileTree.getSelectedPaths()).toEqual(['src/index.ts']); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('empty paths array creates a valid tree with no crashes', async () => { + const { PathStoreTreesController } = await import('../src/path-store'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + paths: [], + }); + + expect(controller.getVisibleCount()).toBe(0); + expect(controller.getFocusedPath()).toBeNull(); + expect(controller.getFocusedIndex()).toBe(-1); + expect(controller.getFocusedItem()).toBeNull(); + expect(controller.getSelectedPaths()).toEqual([]); + expect(controller.getItem('anything')).toBeNull(); + + // Navigation methods should be no-ops, not throw. + controller.focusNextItem(); + controller.focusPreviousItem(); + controller.focusFirstItem(); + controller.focusLastItem(); + controller.focusParentItem(); + controller.selectAllVisiblePaths(); + controller.extendSelectionFromFocused(1); + controller.extendSelectionFromFocused(-1); + controller.selectPathRange('missing.ts', false); + controller.selectOnlyPath('missing.ts'); + + expect(controller.getVisibleCount()).toBe(0); + + controller.destroy(); + }); + + test('single item tree handles all navigation and selection gracefully', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const containerWrapper = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(containerWrapper); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: false, + paths: ['only.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper }); + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + const button = getItemButton(shadowRoot, dom, 'only.ts'); + button.focus(); + await flushDom(); + + // ArrowDown/Up at the only item should stay put + pressKey(button, dom, 'ArrowDown'); + await flushDom(); + expect(fileTree.getItem('only.ts')?.isFocused()).toBe(true); + + pressKey(button, dom, 'ArrowUp'); + await flushDom(); + expect(fileTree.getItem('only.ts')?.isFocused()).toBe(true); + + // Ctrl+A selects the single item + pressKey(button, dom, 'a', { ctrlKey: true }); + await flushDom(); + expect(getSelectedItemPaths(shadowRoot, dom)).toEqual(['only.ts']); + + // Shift+ArrowDown at boundary is a no-op + pressKey(button, dom, 'ArrowDown', { shiftKey: true }); + await flushDom(); + expect(getSelectedItemPaths(shadowRoot, dom)).toEqual(['only.ts']); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('Ctrl-click deselects the anchor then Shift-click still ranges from it', async () => { + const { cleanup, dom } = installDom(); + try { + const { PathStoreFileTree } = await import('../src/path-store'); + const containerWrapper = dom.window.document.createElement('div'); + dom.window.document.body.appendChild(containerWrapper); + + const fileTree = new PathStoreFileTree({ + flattenEmptyDirectories: false, + paths: ['a.ts', 'b.ts', 'c.ts', 'd.ts'], + viewportHeight: 120, + }); + + fileTree.render({ containerWrapper }); + const shadowRoot = fileTree.getFileTreeContainer()?.shadowRoot; + + // Click b.ts to set it as anchor and select it + clickItem(shadowRoot, dom, 'b.ts'); + await flushDom(); + expect(getSelectedItemPaths(shadowRoot, dom)).toEqual(['b.ts']); + + // Ctrl-click b.ts to deselect it — anchor should remain b.ts + clickItem(shadowRoot, dom, 'b.ts', { ctrlKey: true }); + await flushDom(); + expect(getSelectedItemPaths(shadowRoot, dom)).toEqual([]); + + // Shift-click d.ts — should range from anchor b.ts to d.ts + clickItem(shadowRoot, dom, 'd.ts', { shiftKey: true }); + await flushDom(); + expect(getSelectedItemPaths(shadowRoot, dom)).toEqual([ + 'b.ts', + 'c.ts', + 'd.ts', + ]); + + fileTree.cleanUp(); + } finally { + cleanup(); + } + }); + + test('replacePaths preserves focus on surviving paths and resets focus when focused path is removed', async () => { + const { PathStoreTreesController } = await import('../src/path-store'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['a.ts', 'b.ts', 'c.ts'], + }); + + controller.focusPath('b.ts'); + expect(controller.getFocusedPath()).toBe('b.ts'); + + // Replace paths keeping b.ts — focus should survive + controller.replacePaths(['a.ts', 'b.ts', 'd.ts']); + expect(controller.getFocusedPath()).toBe('b.ts'); + + // Replace paths removing b.ts — focus should fall back + controller.replacePaths(['a.ts', 'd.ts']); + expect(controller.getFocusedPath()).not.toBe('b.ts'); + expect(controller.getFocusedPath()).not.toBeNull(); + + // Replace with empty — focus should be null + controller.replacePaths([]); + expect(controller.getFocusedPath()).toBeNull(); + + controller.destroy(); + }); + + test('controller subscribe fires when replacePaths prunes selected items', async () => { + const { PathStoreTreesController } = await import('../src/path-store'); + + const controller = new PathStoreTreesController({ + flattenEmptyDirectories: false, + initialExpansion: 'open', + paths: ['a.ts', 'b.ts', 'c.ts'], + }); + + controller.selectOnlyPath('a.ts'); + controller.selectPathRange('c.ts', false); + expect(controller.getSelectedPaths()).toEqual(['a.ts', 'b.ts', 'c.ts']); + const versionBeforeReplace = controller.getSelectionVersion(); + + // Remove b.ts — selection should prune it + controller.replacePaths(['a.ts', 'c.ts']); + expect(controller.getSelectedPaths()).toEqual(['a.ts', 'c.ts']); + expect(controller.getSelectionVersion()).toBeGreaterThan( + versionBeforeReplace + ); + + // Replace with all new paths — selection fully pruned + const versionBeforeFullPrune = controller.getSelectionVersion(); + controller.replacePaths(['x.ts', 'y.ts']); + expect(controller.getSelectedPaths()).toEqual([]); + expect(controller.getSelectionVersion()).toBeGreaterThan( + versionBeforeFullPrune + ); + + // Replace that doesn't affect selection — version stays the same + controller.selectOnlyPath('x.ts'); + const versionBeforeNoOp = controller.getSelectionVersion(); + controller.replacePaths(['x.ts', 'z.ts']); + expect(controller.getSelectedPaths()).toEqual(['x.ts']); + expect(controller.getSelectionVersion()).toBe(versionBeforeNoOp); + + controller.destroy(); + }); + test('collapse preserves a coherent virtualized window when affected rows move above and below the fold', async () => { const { cleanup, dom } = installDom(); try {