Skip to content

Commit ff7b624

Browse files
authored
[trees] Focus/Nav + Visible tree projection feature + performance optimizations (#490)
* Visible tree projection feature + performance optimizations Add createVisibleTreeProjection API with benchmark/profile scripts, public types, static-store integration, test coverage, and tree controller/view integration. Includes major performance optimizations: - Rewrite createVisibleTreeProjection: single-pass with Int32Array parent/child tracking, lazy visibleIndexByPath Map - Add DFS-based full-tree getVisibleSlice (avoids parent-walk for sibling finding) - Inline file-node materialization in DFS (~70% of rows skip directory checks) - Inline path cache + segment lookup in hot loop - Cache DirectoryChildIndex in DFS stack frame (avoid per-child Map.get) - Collapse benchmark uses preparePresortedInput to skip re-sorting - Eager name-id map building in presorted builder finish - Iterative recomputeCountsRecursive (explicit stack, -64% sub-timing) Experiments: #1#12, #15, #20, #21 toggle_wall_ms: 877ms → 143ms (-84%) collapse_wall_ms: 4757ms → 1510ms (-68%) * fix depth bug
1 parent 98200e4 commit ff7b624

19 files changed

Lines changed: 2468 additions & 97 deletions

apps/docs/app/trees-dev/_components/TreesDevSidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const DEMO_PAGES = [
2323
] as const;
2424

2525
const PATH_STORE_LANE_PAGES = [
26-
{ slug: 'path-store-powered', label: 'Expansion + Collapse' },
26+
{ slug: 'path-store-powered', label: 'Focus + Navigation' },
2727
] as const;
2828

2929
export function TreesDevSidebar({ onNavigate }: { onNavigate?: () => void }) {

apps/docs/app/trees-dev/path-store-powered/PathStorePoweredRenderDemoClient.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function PathStorePoweredRenderDemoClient({
7575
const options = useMemo<PathStoreFileTreeOptions>(
7676
() => ({
7777
...sharedOptions,
78-
id: 'pst-phase2',
78+
id: 'pst-phase3',
7979
preparedInput,
8080
}),
8181
[preparedInput, sharedOptions]
@@ -87,19 +87,19 @@ export function PathStorePoweredRenderDemoClient({
8787
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
8888
Path-store lane · provisional
8989
</p>
90-
<h1 className="text-2xl font-bold">Expansion + Collapse</h1>
90+
<h1 className="text-2xl font-bold">Focus + Navigation</h1>
9191
<p className="text-muted-foreground max-w-3xl text-sm leading-6">
92-
Phase 2 adds the first full pointer-driven interaction slice to the
93-
path-store-powered trees lane: directory expansion and collapse on top
94-
of the always-virtualized renderer.
92+
Phase 3 adds the first full keyboard interaction slice to the
93+
path-store-powered trees lane: single-item focus, baseline tree
94+
navigation, and virtualization-safe DOM focus recovery.
9595
</p>
9696
</header>
9797

9898
<HydratedPathStoreExample
9999
containerHtml={containerHtml}
100-
description="Click any directory row to toggle expansion. File rows stay inert, flattened rows toggle the terminal directory, and the renderer remains always virtualized."
100+
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."
101101
options={options}
102-
title="Expansion + Collapse"
102+
title="Focus + Navigation"
103103
/>
104104

105105
<section className="space-y-3 rounded-lg border p-4">

apps/docs/app/trees-dev/path-store-powered/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default function PathStorePoweredPage() {
2323

2424
const payload = preloadPathStoreFileTree({
2525
...sharedOptions,
26-
id: 'pst-phase2',
26+
id: 'pst-phase3',
2727
preparedInput: linuxKernelPreparedInput,
2828
});
2929

packages/path-store/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,11 @@
1818
},
1919
"scripts": {
2020
"benchmark": "bun run ./scripts/benchmark.ts",
21+
"benchmark:visible-tree-projection": "bun run ./scripts/benchmarkVisibleTreeProjection.ts",
2122
"build": "bun run vite build demo",
2223
"dev": "bun run vite demo --host 127.0.0.1 --port 4175",
2324
"profile:demo": "bun run ./scripts/profileDemo.ts",
25+
"profile:visible-tree-projection": "bun run ./scripts/profileVisibleTreeProjection.ts",
2426
"test": "bun test",
2527
"test:demo": "(bun ./test/e2e/check-playwright-binary.js || bun run test:demo:installbinary) && bunx playwright test -c test/e2e/playwright.config.js",
2628
"test:demo:installbinary": "bunx playwright@1.51.1 install chromium",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { performance } from 'node:perf_hooks';
2+
3+
import {
4+
createVisibleTreeProjectionScenarios,
5+
createVisibleTreeProjectionWorkload,
6+
summarizeDurations,
7+
} from './visibleTreeProjectionShared';
8+
9+
interface BenchmarkConfig {
10+
json: boolean;
11+
runs: number;
12+
warmupRuns: number;
13+
workloads: string[];
14+
}
15+
16+
interface ScenarioBenchmarkResult {
17+
description: string;
18+
metrics: ReturnType<typeof summarizeDurations>;
19+
name: string;
20+
rowCount: number;
21+
workload: {
22+
collapseTargetPath: string | null;
23+
expandedFolderCount: number;
24+
fileCount: number;
25+
name: string;
26+
visibleCount: number;
27+
};
28+
}
29+
30+
function parseArgs(argv: readonly string[]): BenchmarkConfig {
31+
const config: BenchmarkConfig = {
32+
json: false,
33+
runs: 20,
34+
warmupRuns: 5,
35+
workloads: ['linux-1x'],
36+
};
37+
38+
for (let index = 0; index < argv.length; index += 1) {
39+
const arg = argv[index];
40+
switch (arg) {
41+
case '--json':
42+
config.json = true;
43+
break;
44+
case '--runs':
45+
config.runs = Number(argv[index + 1] ?? config.runs);
46+
index += 1;
47+
break;
48+
case '--warmup-runs':
49+
config.warmupRuns = Number(argv[index + 1] ?? config.warmupRuns);
50+
index += 1;
51+
break;
52+
case '--workloads':
53+
config.workloads = (argv[index + 1] ?? '')
54+
.split(',')
55+
.map((workload) => workload.trim())
56+
.filter((workload) => workload.length > 0);
57+
index += 1;
58+
break;
59+
default:
60+
break;
61+
}
62+
}
63+
64+
return config;
65+
}
66+
67+
function main(): void {
68+
const config = parseArgs(process.argv.slice(2));
69+
const results: ScenarioBenchmarkResult[] = [];
70+
71+
for (const workloadName of config.workloads) {
72+
const workload = createVisibleTreeProjectionWorkload(workloadName);
73+
const scenarios = createVisibleTreeProjectionScenarios(workload);
74+
75+
for (const scenario of scenarios) {
76+
for (
77+
let warmupIndex = 0;
78+
warmupIndex < config.warmupRuns;
79+
warmupIndex += 1
80+
) {
81+
scenario.measure();
82+
}
83+
84+
const durationsMs: number[] = [];
85+
let rowCount = 0;
86+
for (let runIndex = 0; runIndex < config.runs; runIndex += 1) {
87+
const startedAt = performance.now();
88+
const result = scenario.measure();
89+
durationsMs.push(performance.now() - startedAt);
90+
rowCount = result.rowCount;
91+
}
92+
93+
results.push({
94+
description: scenario.description,
95+
metrics: summarizeDurations(durationsMs),
96+
name: scenario.name,
97+
rowCount,
98+
workload: {
99+
collapseTargetPath: workload.collapseTargetPath,
100+
expandedFolderCount: workload.expandedFolderCount,
101+
fileCount: workload.fileCount,
102+
name: workload.name,
103+
visibleCount: workload.visibleCount,
104+
},
105+
});
106+
}
107+
}
108+
109+
if (config.json) {
110+
console.log(
111+
JSON.stringify(
112+
{
113+
benchmark: 'visible-tree-projection',
114+
config,
115+
results,
116+
},
117+
null,
118+
2
119+
)
120+
);
121+
return;
122+
}
123+
124+
for (const result of results) {
125+
console.log(
126+
[
127+
`${result.workload.name}:${result.name}`,
128+
`rows=${String(result.rowCount)}`,
129+
`avg=${result.metrics.averageMs.toFixed(3)}ms`,
130+
`median=${result.metrics.medianMs.toFixed(3)}ms`,
131+
`p95=${result.metrics.p95Ms.toFixed(3)}ms`,
132+
`min=${result.metrics.minMs.toFixed(3)}ms`,
133+
`max=${result.metrics.maxMs.toFixed(3)}ms`,
134+
].join(' ')
135+
);
136+
}
137+
}
138+
139+
main();

0 commit comments

Comments
 (0)