Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,11 +25,13 @@ interface PathStorePoweredRenderDemoClientProps {
function HydratedPathStoreExample({
containerHtml,
description,
footer,
options,
title,
}: {
containerHtml: string;
description: string;
footer?: ReactNode;
options: PathStoreFileTreeOptions;
title: string;
}) {
Expand All @@ -53,7 +57,7 @@ function HydratedPathStoreExample({
);

return (
<ExampleCard title={title} description={description}>
<ExampleCard title={title} description={description} footer={footer}>
<div
ref={ref}
style={{ height: `${String(options.viewportHeight ?? 420)}px` }}
Expand All @@ -68,17 +72,25 @@ export function PathStorePoweredRenderDemoClient({
containerHtml,
sharedOptions,
}: PathStorePoweredRenderDemoClientProps) {
const { addLog, log } = useStateLog();
const preparedInput = useMemo(
() => createPresortedPreparedInput(sharedOptions.paths),
[sharedOptions.paths]
);
const handleSelectionChange = useCallback(
(selectedPaths: readonly string[]) => {
addLog(`selected: [${selectedPaths.join(', ')}]`);
},
[addLog]
);
const options = useMemo<PathStoreFileTreeOptions>(
() => ({
...sharedOptions,
id: 'pst-phase3',
id: 'pst-phase4',
onSelectionChange: handleSelectionChange,
preparedInput,
}),
[preparedInput, sharedOptions]
[handleSelectionChange, preparedInput, sharedOptions]
);

return (
Expand All @@ -87,19 +99,21 @@ export function PathStorePoweredRenderDemoClient({
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
Path-store lane · provisional
</p>
<h1 className="text-2xl font-bold">Focus + Navigation</h1>
<h1 className="text-2xl font-bold">Focus + Selection</h1>
<p className="text-muted-foreground max-w-3xl text-sm leading-6">
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.
</p>
</header>

<HydratedPathStoreExample
containerHtml={containerHtml}
description="Click or focus any row, then use Arrow keys plus Home/End. Directory rows keep the Phase 2 toggle behavior, flattened rows target the terminal directory, and keyboard navigation survives virtualization."
description="Click a row to select it, use Ctrl/Cmd-click and Shift-click for multi-selection, and try Ctrl+Space, Shift+ArrowUp/Down, and Ctrl+A. Directory rows still keep the Phase 2 toggle behavior on plain click, and selection changes are logged below."
footer={<StateLog entries={log} />}
options={options}
title="Focus + Navigation"
title="Focus + Selection"
/>

<section className="space-y-3 rounded-lg border p-4">
Expand Down
2 changes: 1 addition & 1 deletion apps/docs/app/trees-dev/path-store-powered/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export default function PathStorePoweredPage() {

const payload = preloadPathStoreFileTree({
...sharedOptions,
id: 'pst-phase3',
id: 'pst-phase4',
preparedInput: linuxKernelPreparedInput,
});

Expand Down
22 changes: 22 additions & 0 deletions packages/path-store/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/path-store/scripts/benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions packages/path-store/test/path-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/trees/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"benchmark:path-store-get-item": "bun run ./scripts/benchmarkPathStoreGetItem.ts",
"benchmark:render": "bun run ./scripts/benchmarkVirtualizedFileTreeRender.ts",
"benchmark:render:client": "bun run ./scripts/benchmarkVirtualizedFileTreeClientMount.ts",
"profile:pathstore": "bun run ./scripts/profileTreesPathStore.ts",
"profile:virtualization": "bun run ./scripts/profileTreesDevVirtualization.ts",
"dev": "echo 'Watching for changes…' && tsdown --watch --log-level error",
"test": "bun test",
Expand Down
110 changes: 110 additions & 0 deletions packages/trees/scripts/lib/pathStoreProfileShared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { getVirtualizationWorkload } from '@pierre/tree-test-data';
import type { VirtualizationWorkload } from '@pierre/tree-test-data';

import type { PathStoreFileTreeOptions } from '../../src/path-store';

export const PATH_STORE_PROFILE_WORKLOAD_NAMES = [
'linux-5x',
'linux-10x',
'linux',
'demo-small',
] as const;

export type PathStoreProfileWorkloadName =
(typeof PATH_STORE_PROFILE_WORKLOAD_NAMES)[number];

export const DEFAULT_PATH_STORE_PROFILE_WORKLOAD_NAME = 'linux-5x';
export const PATH_STORE_PROFILE_VIEWPORT_HEIGHT = 500;

type PathStorePreparedInput = NonNullable<
PathStoreFileTreeOptions['preparedInput']
>;

export interface PathStoreProfileWorkloadSummary {
expandedFolderCount: number;
fileCount: number;
label: string;
name: PathStoreProfileWorkloadName;
}

export interface PathStoreProfilePhaseSummary {
count: number;
durationMs: number;
name: string;
selfDurationMs: number;
}

export interface PathStoreProfileHeapSummary {
jsHeapSizeLimitBytes: number;
totalJSHeapSizeAfterBytes: number;
usedJSHeapSizeAfterBytes: number;
usedJSHeapSizeBeforeBytes: number;
usedJSHeapSizeDeltaBytes: number;
}

export interface PathStoreProfileInstrumentationSummary {
counters: Record<string, number>;
heap: PathStoreProfileHeapSummary | null;
phases: PathStoreProfilePhaseSummary[];
}

export interface PathStoreProfilePageSummary {
instrumentation: PathStoreProfileInstrumentationSummary | null;
longTaskCount: number;
longTaskTotalMs: number;
longestLongTaskMs: number;
renderDurationMs: number;
renderedItemCount: number;
resultText: string | null;
visibleRowsReadyMs: number;
workload: PathStoreProfileWorkloadSummary;
}

export function isPathStoreProfileWorkloadName(
value: string
): value is PathStoreProfileWorkloadName {
return (PATH_STORE_PROFILE_WORKLOAD_NAMES as readonly string[]).includes(
value
);
}

export function getPathStoreProfileWorkload(
value: string | null | undefined
): VirtualizationWorkload {
const workloadName = isPathStoreProfileWorkloadName(value ?? '')
? value
: DEFAULT_PATH_STORE_PROFILE_WORKLOAD_NAME;
return getVirtualizationWorkload(workloadName);
}

export function createPathStorePresortedPreparedInput(
paths: readonly string[]
): PathStorePreparedInput {
return {
paths,
presortedPaths: paths,
} as PathStorePreparedInput;
}

export function createPathStoreProfileFixtureOptions(
workload: VirtualizationWorkload
): Omit<PathStoreFileTreeOptions, 'id' | 'onSelectionChange'> {
return {
flattenEmptyDirectories: true,
initialExpandedPaths: workload.expandedFolders,
paths: workload.files,
preparedInput: createPathStorePresortedPreparedInput(workload.files),
viewportHeight: PATH_STORE_PROFILE_VIEWPORT_HEIGHT,
};
}

export function createPathStoreProfileWorkloadSummary(
workload: VirtualizationWorkload
): PathStoreProfileWorkloadSummary {
return {
expandedFolderCount: workload.expandedFolders.length,
fileCount: workload.files.length,
label: workload.label,
name: workload.name as PathStoreProfileWorkloadName,
};
}
Loading
Loading