From 67579781e8ed56561909f7bb1d6f16f9148afa1e Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Wed, 25 Mar 2026 19:00:31 -0500 Subject: [PATCH 1/2] add benchmarking infra --- packages/trees/README.md | 51 ++ packages/trees/package.json | 1 + .../trees/scripts/benchmarkFileListToTree.ts | 779 ++++++++++++++++++ .../fileListToTree-monorepo-snapshot.txt | 648 +++++++++++++++ .../lib/fileListToTreeBenchmarkData.ts | 350 ++++++++ packages/trees/src/utils/fileListToTree.ts | 280 +++++-- .../test/fileListToTree.benchmark.test.ts | 240 ++++++ packages/trees/tsconfig.json | 1 + 8 files changed, 2279 insertions(+), 71 deletions(-) create mode 100644 packages/trees/scripts/benchmarkFileListToTree.ts create mode 100644 packages/trees/scripts/fixtures/fileListToTree-monorepo-snapshot.txt create mode 100644 packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts create mode 100644 packages/trees/test/fileListToTree.benchmark.test.ts diff --git a/packages/trees/README.md b/packages/trees/README.md index f3ccb8acb..59faf50ba 100644 --- a/packages/trees/README.md +++ b/packages/trees/README.md @@ -205,6 +205,7 @@ From `packages/trees`: ```bash bun test +bun run benchmark bun run test:e2e bun run tsc bun run build @@ -214,6 +215,56 @@ Testing policy and E2E guidance: - `test/TESTING.md` +## Benchmarking + +The `trees` package includes a dedicated `fileListToTree` benchmark runner: + +```bash +bun ws trees benchmark +``` + +Use `--case` to focus on a subset while iterating locally: + +```bash +bun ws trees benchmark -- --case=deep +bun ws trees benchmark -- --case=linux --runs=10 --warmup-runs=2 +``` + +The default suite mixes synthetic shapes with two real fixtures so changes are +measured against both controlled and realistic inputs: + +- `tiny-flat`, `small-mixed`, `medium-balanced`, `large-wide`, + `large-deep-chain`, `large-monorepo-shaped`, and `explicit-directories` +- `fixture-linux-kernel-files`, loaded from + `apps/docs/app/trees-dev/linux-files.json` +- `fixture-pierrejs-repo-snapshot`, a fixed snapshot of this repo at + `packages/trees/scripts/fixtures/fileListToTree-monorepo-snapshot.txt` + +The benchmark records: + +- end-to-end `fileListToTree` timing +- stage timings for `buildPathGraph`, `buildFlattenedNodes`, `buildFolderNodes`, + and `hashTreeKeys` +- a deterministic checksum per case so behavior changes are visible alongside + timing changes + +Use `--json` when you want machine-readable output or a saved baseline: + +```bash +bun ws trees benchmark -- --json > tmp/fileListToTree-baseline.json +``` + +Use `--compare` to run the current code against a saved JSON baseline: + +```bash +bun ws trees benchmark -- --compare tmp/fileListToTree-baseline.json +bun ws trees benchmark -- --case=linux --compare tmp/fileListToTree-baseline.json --json +``` + +`--compare` matches cases by name, reports median deltas, and flags checksum +mismatches. That makes it useful both for performance regressions and for +catching accidental behavior changes while refactoring. + # Credits and Acknolwedgements The core of this library's underlying tree implementation started as a hard fork diff --git a/packages/trees/package.json b/packages/trees/package.json index 3fe0a5400..c6ab4fa3e 100644 --- a/packages/trees/package.json +++ b/packages/trees/package.json @@ -34,6 +34,7 @@ }, "scripts": { "build": "tsdown --clean", + "benchmark": "bun run ./scripts/benchmarkFileListToTree.ts", "dev": "echo 'Watching for changes…' && tsdown --watch --log-level error", "test": "bun test", "coverage": "bun test --coverage", diff --git a/packages/trees/scripts/benchmarkFileListToTree.ts b/packages/trees/scripts/benchmarkFileListToTree.ts new file mode 100644 index 000000000..392c42c0d --- /dev/null +++ b/packages/trees/scripts/benchmarkFileListToTree.ts @@ -0,0 +1,779 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import type { FileTreeData } from '../src/types'; +import { + benchmarkFileListToTreeStages, + type FileListToTreeStageName, +} from '../src/utils/fileListToTree'; +import { + type FileListToTreeBenchmarkCase, + filterBenchmarkCases, + getFileListToTreeBenchmarkCases, +} from './lib/fileListToTreeBenchmarkData'; + +interface BenchmarkConfig { + runs: number; + warmupRuns: number; + outputJson: boolean; + caseFilters: string[]; + comparePath?: string; +} + +interface BenchmarkEnvironment { + bunVersion: string; + platform: string; + arch: string; +} + +interface TimingSummary { + runs: number; + meanMs: number; + medianMs: number; + p95Ms: number; + minMs: number; + maxMs: number; + stdDevMs: number; +} + +interface CaseSummary extends TimingSummary { + name: string; + source: FileListToTreeBenchmarkCase['source']; + fileCount: number; + uniqueFolderCount: number; + maxDepth: number; + checksum: number; +} + +interface StageSummary { + name: string; + stages: Record; +} + +interface CaseComparison { + name: string; + checksumMatches: boolean; + baselineChecksum: number; + currentChecksum: number; + baselineMedianMs: number; + currentMedianMs: number; + medianDeltaMs: number; + medianDeltaPct: number; + baselineMeanMs: number; + currentMeanMs: number; + meanDeltaMs: number; + meanDeltaPct: number; + baselineP95Ms: number; + currentP95Ms: number; + p95DeltaMs: number; + p95DeltaPct: number; +} + +interface StageComparisonSummary { + baselineMedianMs: number; + currentMedianMs: number; + medianDeltaMs: number; + medianDeltaPct: number; +} + +interface StageComparison { + name: string; + stages: Record; +} + +interface BenchmarkComparison { + baselinePath: string; + baselineEnvironment: BenchmarkEnvironment; + baselineConfig: BenchmarkConfig; + unmatchedCurrentCases: string[]; + unmatchedBaselineCases: string[]; + checksumMismatches: string[]; + cases: CaseComparison[]; + stages: StageComparison[]; +} + +interface BenchmarkOutput { + benchmark: 'fileListToTree'; + environment: BenchmarkEnvironment; + config: BenchmarkConfig; + checksum: number; + cases: CaseSummary[]; + stages: StageSummary[]; + comparison?: BenchmarkComparison; +} + +interface LoadedBenchmarkBaseline { + path: string; + output: BenchmarkOutput; +} + +const DEFAULT_CONFIG: BenchmarkConfig = { + runs: 25, + warmupRuns: 5, + outputJson: false, + caseFilters: [], +}; + +const STAGE_ORDER: FileListToTreeStageName[] = [ + 'buildPathGraph', + 'buildFlattenedNodes', + 'buildFolderNodes', + 'hashTreeKeys', +]; + +function parsePositiveInteger(value: string, flagName: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + `Invalid ${flagName} value '${value}'. Expected a positive integer.` + ); + } + return parsed; +} + +function parseNonNegativeInteger(value: string, flagName: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error( + `Invalid ${flagName} value '${value}'. Expected a non-negative integer.` + ); + } + return parsed; +} + +function printHelpAndExit(): never { + console.log('Usage: bun ws trees benchmark -- [options]'); + console.log(''); + console.log('Options:'); + console.log( + ' --runs Measured runs per benchmark case (default: 25)' + ); + console.log( + ' --warmup-runs Warmup runs per benchmark case before measurement (default: 5)' + ); + console.log( + ' --case Run only cases whose name contains the filter (repeatable)' + ); + console.log( + ' --compare Compare against a prior --json benchmark run' + ); + console.log(' --json Emit machine-readable JSON output'); + console.log(' -h, --help Show this help output'); + process.exit(0); +} + +function parseArgs(argv: string[]): BenchmarkConfig { + const config: BenchmarkConfig = { ...DEFAULT_CONFIG }; + + for (let index = 0; index < argv.length; index++) { + const rawArg = argv[index]; + if (rawArg === '--help' || rawArg === '-h') { + printHelpAndExit(); + } + + if (rawArg === '--json') { + config.outputJson = true; + continue; + } + + const [flag, inlineValue] = rawArg.split('=', 2); + if ( + flag === '--runs' || + flag === '--warmup-runs' || + flag === '--case' || + flag === '--compare' + ) { + const value = inlineValue ?? argv[index + 1]; + if (value == null) { + throw new Error(`Missing value for ${flag}`); + } + if (inlineValue == null) { + index += 1; + } + + if (flag === '--runs') { + config.runs = parsePositiveInteger(value, '--runs'); + } else if (flag === '--warmup-runs') { + config.warmupRuns = parseNonNegativeInteger(value, '--warmup-runs'); + } else if (flag === '--case') { + config.caseFilters.push(value); + } else { + config.comparePath = value; + } + continue; + } + + throw new Error(`Unknown argument: ${rawArg}`); + } + + return config; +} + +function percentile(sortedValues: number[], percentileRank: number): number { + if (sortedValues.length === 0) { + return 0; + } + + const rank = (sortedValues.length - 1) * percentileRank; + const lowerIndex = Math.floor(rank); + const upperIndex = Math.ceil(rank); + const lower = sortedValues[lowerIndex] ?? sortedValues[0] ?? 0; + const upper = + sortedValues[upperIndex] ?? sortedValues[sortedValues.length - 1] ?? lower; + if (lowerIndex === upperIndex) { + return lower; + } + + const interpolation = rank - lowerIndex; + return lower + (upper - lower) * interpolation; +} + +function summarizeSamples(samples: number[]): TimingSummary { + if (samples.length === 0) { + return { + runs: 0, + meanMs: 0, + medianMs: 0, + p95Ms: 0, + minMs: 0, + maxMs: 0, + stdDevMs: 0, + }; + } + + const sortedSamples = [...samples].sort((left, right) => left - right); + const total = samples.reduce((sum, value) => sum + value, 0); + const mean = total / samples.length; + const variance = + samples.reduce((sum, value) => sum + (value - mean) ** 2, 0) / + samples.length; + + return { + runs: samples.length, + meanMs: mean, + medianMs: percentile(sortedSamples, 0.5), + p95Ms: percentile(sortedSamples, 0.95), + minMs: sortedSamples[0] ?? 0, + maxMs: sortedSamples[sortedSamples.length - 1] ?? 0, + stdDevMs: Math.sqrt(variance), + }; +} + +function formatMs(value: number): string { + return value.toFixed(3); +} + +function formatSignedMs(value: number): string { + const prefix = value > 0 ? '+' : ''; + return `${prefix}${value.toFixed(3)}`; +} + +function formatSignedPercent(value: number): string { + if (!Number.isFinite(value)) { + return value > 0 ? '+inf%' : value < 0 ? '-inf%' : '0.0%'; + } + + const prefix = value > 0 ? '+' : ''; + return `${prefix}${value.toFixed(1)}%`; +} + +function checksumTree(tree: FileTreeData): number { + let checksum = 0; + + for (const [id, node] of Object.entries(tree)) { + checksum += id.length; + checksum += node.name.length; + checksum += node.path.length; + + if (node.children != null) { + checksum += node.children.direct.length; + for (const child of node.children.direct) { + checksum += child.length; + } + if (node.children.flattened != null) { + checksum += node.children.flattened.length; + for (const child of node.children.flattened) { + checksum += child.length; + } + } + } + + if (node.flattens != null) { + checksum += node.flattens.length; + for (const path of node.flattens) { + checksum += path.length; + } + } + } + + return checksum; +} + +function printTable(rows: Record[], headers: string[]): void { + const widths = headers.map((header) => { + const valueWidth = rows.reduce( + (max, row) => Math.max(max, row[header]?.length ?? 0), + header.length + ); + return valueWidth; + }); + + const formatRow = (row: Record) => + headers + .map((header, index) => (row[header] ?? '').padEnd(widths[index])) + .join(' ') + .trimEnd(); + + const headerRow = Object.fromEntries( + headers.map((header) => [header, header]) + ); + console.log(formatRow(headerRow)); + console.log( + widths + .map((width) => '-'.repeat(width)) + .join(' ') + .trimEnd() + ); + for (const row of rows) { + console.log(formatRow(row)); + } +} + +function createStageSampleStorage(): Record { + return { + buildPathGraph: [], + buildFlattenedNodes: [], + buildFolderNodes: [], + hashTreeKeys: [], + }; +} + +function getEnvironment(): BenchmarkEnvironment { + return { + bunVersion: Bun.version, + platform: process.platform, + arch: process.arch, + }; +} + +function calculateDeltaPercent(current: number, baseline: number): number { + if (baseline === 0) { + return current === 0 ? 0 : Number.POSITIVE_INFINITY; + } + + return ((current - baseline) / baseline) * 100; +} + +// Benchmarks only stay comparable when the output payload has the same shape. +// Load and validate the previous JSON run up front so comparison failures are +// immediate instead of producing misleading deltas later on. +function readBenchmarkBaseline(comparePath: string): LoadedBenchmarkBaseline { + const resolvedPath = resolve(process.cwd(), comparePath); + const parsed = JSON.parse( + readFileSync(resolvedPath, 'utf-8') + ) as Partial | null; + + if (parsed == null || parsed.benchmark !== 'fileListToTree') { + throw new Error( + `Invalid benchmark baseline at ${resolvedPath}. Expected fileListToTree JSON output.` + ); + } + + if (!Array.isArray(parsed.cases) || !Array.isArray(parsed.stages)) { + throw new Error( + `Invalid benchmark baseline at ${resolvedPath}. Expected cases and stages arrays.` + ); + } + + return { + path: resolvedPath, + output: parsed as BenchmarkOutput, + }; +} + +function buildComparison( + baseline: LoadedBenchmarkBaseline, + caseSummaries: CaseSummary[], + stageSummaries: StageSummary[] +): BenchmarkComparison { + const baselineCases = new Map( + baseline.output.cases.map((summary) => [summary.name, summary]) + ); + const baselineStages = new Map( + baseline.output.stages.map((summary) => [summary.name, summary]) + ); + const currentCaseNames = new Set( + caseSummaries.map((summary) => summary.name) + ); + + const matchedCases = caseSummaries.filter((summary) => + baselineCases.has(summary.name) + ); + if (matchedCases.length === 0) { + throw new Error( + `No benchmark cases matched baseline ${baseline.path}. Regenerate the baseline or adjust --case filters.` + ); + } + + const caseComparisons = matchedCases.map((currentSummary) => { + const baselineSummary = baselineCases.get(currentSummary.name); + if (baselineSummary == null) { + throw new Error(`Missing baseline case for ${currentSummary.name}`); + } + if (typeof baselineSummary.checksum !== 'number') { + throw new Error( + `Baseline case ${currentSummary.name} is missing a checksum. Regenerate the baseline with the current benchmark script.` + ); + } + + return { + name: currentSummary.name, + checksumMatches: baselineSummary.checksum === currentSummary.checksum, + baselineChecksum: baselineSummary.checksum, + currentChecksum: currentSummary.checksum, + baselineMedianMs: baselineSummary.medianMs, + currentMedianMs: currentSummary.medianMs, + medianDeltaMs: currentSummary.medianMs - baselineSummary.medianMs, + medianDeltaPct: calculateDeltaPercent( + currentSummary.medianMs, + baselineSummary.medianMs + ), + baselineMeanMs: baselineSummary.meanMs, + currentMeanMs: currentSummary.meanMs, + meanDeltaMs: currentSummary.meanMs - baselineSummary.meanMs, + meanDeltaPct: calculateDeltaPercent( + currentSummary.meanMs, + baselineSummary.meanMs + ), + baselineP95Ms: baselineSummary.p95Ms, + currentP95Ms: currentSummary.p95Ms, + p95DeltaMs: currentSummary.p95Ms - baselineSummary.p95Ms, + p95DeltaPct: calculateDeltaPercent( + currentSummary.p95Ms, + baselineSummary.p95Ms + ), + }; + }); + + const stageComparisons = matchedCases.map((currentSummary) => { + const currentStageSummary = stageSummaries.find( + (summary) => summary.name === currentSummary.name + ); + const baselineStageSummary = baselineStages.get(currentSummary.name); + if (currentStageSummary == null || baselineStageSummary == null) { + throw new Error( + `Missing stage summary for ${currentSummary.name}. Regenerate the baseline with the current benchmark script.` + ); + } + + return { + name: currentSummary.name, + stages: Object.fromEntries( + STAGE_ORDER.map((stage) => { + const currentStage = currentStageSummary.stages[stage]; + const baselineStage = baselineStageSummary.stages[stage]; + if (currentStage == null || baselineStage == null) { + throw new Error( + `Missing ${stage} stage summary for ${currentSummary.name}. Regenerate the baseline with the current benchmark script.` + ); + } + + return [ + stage, + { + baselineMedianMs: baselineStage.medianMs, + currentMedianMs: currentStage.medianMs, + medianDeltaMs: currentStage.medianMs - baselineStage.medianMs, + medianDeltaPct: calculateDeltaPercent( + currentStage.medianMs, + baselineStage.medianMs + ), + }, + ]; + }) + ) as Record, + }; + }); + + return { + baselinePath: baseline.path, + baselineEnvironment: baseline.output.environment, + baselineConfig: baseline.output.config, + unmatchedCurrentCases: caseSummaries + .filter((summary) => !baselineCases.has(summary.name)) + .map((summary) => summary.name), + unmatchedBaselineCases: baseline.output.cases + .filter((summary) => !currentCaseNames.has(summary.name)) + .map((summary) => summary.name), + checksumMismatches: caseComparisons + .filter((summary) => !summary.checksumMatches) + .map((summary) => summary.name), + cases: caseComparisons, + stages: stageComparisons, + }; +} + +function printComparison(comparison: BenchmarkComparison): void { + console.log(''); + console.log('Comparison vs baseline'); + console.log(`baseline=${comparison.baselinePath}`); + console.log( + `baselineBun=${comparison.baselineEnvironment.bunVersion} baselinePlatform=${comparison.baselineEnvironment.platform} baselineArch=${comparison.baselineEnvironment.arch}` + ); + console.log( + `baselineRunsPerCase=${comparison.baselineConfig.runs} baselineWarmupRunsPerCase=${comparison.baselineConfig.warmupRuns}` + ); + if (comparison.unmatchedCurrentCases.length > 0) { + console.log( + `unmatchedCurrentCases=${comparison.unmatchedCurrentCases.join(', ')}` + ); + } + if (comparison.unmatchedBaselineCases.length > 0) { + console.log( + `unmatchedBaselineCases=${comparison.unmatchedBaselineCases.join(', ')}` + ); + } + if (comparison.checksumMismatches.length > 0) { + console.log( + `checksumMismatches=${comparison.checksumMismatches.join(', ')}` + ); + } + console.log(''); + console.log('Case median deltas'); + printTable( + comparison.cases.map((summary) => ({ + case: summary.name, + baselineMedianMs: formatMs(summary.baselineMedianMs), + currentMedianMs: formatMs(summary.currentMedianMs), + deltaMs: formatSignedMs(summary.medianDeltaMs), + deltaPct: formatSignedPercent(summary.medianDeltaPct), + checksum: summary.checksumMatches ? 'match' : 'mismatch', + })), + [ + 'case', + 'baselineMedianMs', + 'currentMedianMs', + 'deltaMs', + 'deltaPct', + 'checksum', + ] + ); + console.log(''); + console.log('Stage median deltas'); + printTable( + comparison.stages.map((summary) => ({ + case: summary.name, + buildPathGraph: formatSignedMs( + summary.stages.buildPathGraph.medianDeltaMs + ), + buildFlattenedNodes: formatSignedMs( + summary.stages.buildFlattenedNodes.medianDeltaMs + ), + buildFolderNodes: formatSignedMs( + summary.stages.buildFolderNodes.medianDeltaMs + ), + hashTreeKeys: formatSignedMs(summary.stages.hashTreeKeys.medianDeltaMs), + })), + [ + 'case', + 'buildPathGraph', + 'buildFlattenedNodes', + 'buildFolderNodes', + 'hashTreeKeys', + ] + ); +} + +function main() { + const config = parseArgs(process.argv.slice(2)); + const selectedCases = filterBenchmarkCases( + getFileListToTreeBenchmarkCases(), + config.caseFilters + ); + + if (selectedCases.length === 0) { + throw new Error('No benchmark cases matched the provided --case filters.'); + } + + const totalSamples = selectedCases.map(() => [] as number[]); + const stageSamples = selectedCases.map(() => createStageSampleStorage()); + const caseChecksums = selectedCases.map( + () => undefined as number | undefined + ); + + const runSingleCase = ( + caseConfig: FileListToTreeBenchmarkCase, + caseIndex: number + ) => { + const startTime = performance.now(); + const result = benchmarkFileListToTreeStages(caseConfig.files); + const elapsedMs = performance.now() - startTime; + const resultChecksum = checksumTree(result.tree); + const existingChecksum = caseChecksums[caseIndex]; + + if (existingChecksum == null) { + caseChecksums[caseIndex] = resultChecksum; + } else if (existingChecksum !== resultChecksum) { + throw new Error( + `Non-deterministic checksum for benchmark case ${caseConfig.name}. Expected ${existingChecksum}, received ${resultChecksum}.` + ); + } + + return { + elapsedMs, + checksum: resultChecksum, + stageTimingsMs: result.stageTimingsMs, + }; + }; + + for (let runIndex = 0; runIndex < config.warmupRuns; runIndex++) { + for (let caseOffset = 0; caseOffset < selectedCases.length; caseOffset++) { + const caseIndex = (runIndex + caseOffset) % selectedCases.length; + const caseConfig = selectedCases[caseIndex]; + runSingleCase(caseConfig, caseIndex); + } + } + + for (let runIndex = 0; runIndex < config.runs; runIndex++) { + for (let caseOffset = 0; caseOffset < selectedCases.length; caseOffset++) { + const caseIndex = (runIndex + caseOffset) % selectedCases.length; + const caseConfig = selectedCases[caseIndex]; + const { elapsedMs, stageTimingsMs } = runSingleCase( + caseConfig, + caseIndex + ); + totalSamples[caseIndex].push(elapsedMs); + for (const stage of STAGE_ORDER) { + stageSamples[caseIndex][stage].push(stageTimingsMs[stage]); + } + } + } + + const caseSummaries: CaseSummary[] = selectedCases.map( + (caseConfig, index) => { + const checksum = caseChecksums[index]; + if (checksum == null) { + throw new Error( + `Missing checksum for benchmark case ${caseConfig.name}` + ); + } + + return { + name: caseConfig.name, + source: caseConfig.source, + fileCount: caseConfig.fileCount, + uniqueFolderCount: caseConfig.uniqueFolderCount, + maxDepth: caseConfig.maxDepth, + checksum, + ...summarizeSamples(totalSamples[index]), + }; + } + ); + + const stageSummaries: StageSummary[] = selectedCases.map( + (caseConfig, index) => ({ + name: caseConfig.name, + stages: Object.fromEntries( + STAGE_ORDER.map((stage) => [ + stage, + summarizeSamples(stageSamples[index][stage]), + ]) + ) as Record, + }) + ); + + const checksum = caseSummaries.reduce( + (sum, summary) => sum + summary.checksum, + 0 + ); + const environment = getEnvironment(); + const comparison = + config.comparePath != null + ? buildComparison( + readBenchmarkBaseline(config.comparePath), + caseSummaries, + stageSummaries + ) + : undefined; + + const output: BenchmarkOutput = { + benchmark: 'fileListToTree', + environment, + config, + checksum, + cases: caseSummaries, + stages: stageSummaries, + ...(comparison != null && { comparison }), + }; + + if (config.outputJson) { + console.log(JSON.stringify(output, null, 2)); + return; + } + + console.log('fileListToTree benchmark'); + console.log( + `bun=${environment.bunVersion} platform=${environment.platform} arch=${environment.arch}` + ); + console.log( + `cases=${selectedCases.length} runsPerCase=${config.runs} warmupRunsPerCase=${config.warmupRuns}` + ); + if (config.caseFilters.length > 0) { + console.log(`filters=${config.caseFilters.join(', ')}`); + } + console.log(`checksum=${checksum}`); + console.log(''); + + printTable( + caseSummaries.map((summary) => ({ + case: summary.name, + source: summary.source, + files: String(summary.fileCount), + folders: String(summary.uniqueFolderCount), + depth: String(summary.maxDepth), + runs: String(summary.runs), + meanMs: formatMs(summary.meanMs), + medianMs: formatMs(summary.medianMs), + p95Ms: formatMs(summary.p95Ms), + stdDevMs: formatMs(summary.stdDevMs), + })), + [ + 'case', + 'source', + 'files', + 'folders', + 'depth', + 'runs', + 'meanMs', + 'medianMs', + 'p95Ms', + 'stdDevMs', + ] + ); + console.log(''); + console.log('Stage medians (ms)'); + printTable( + stageSummaries.map((summary) => ({ + case: summary.name, + buildPathGraph: formatMs(summary.stages.buildPathGraph.medianMs), + buildFlattenedNodes: formatMs( + summary.stages.buildFlattenedNodes.medianMs + ), + buildFolderNodes: formatMs(summary.stages.buildFolderNodes.medianMs), + hashTreeKeys: formatMs(summary.stages.hashTreeKeys.medianMs), + })), + [ + 'case', + 'buildPathGraph', + 'buildFlattenedNodes', + 'buildFolderNodes', + 'hashTreeKeys', + ] + ); + + if (comparison != null) { + printComparison(comparison); + } +} + +main(); diff --git a/packages/trees/scripts/fixtures/fileListToTree-monorepo-snapshot.txt b/packages/trees/scripts/fixtures/fileListToTree-monorepo-snapshot.txt new file mode 100644 index 000000000..40513a9d6 --- /dev/null +++ b/packages/trees/scripts/fixtures/fileListToTree-monorepo-snapshot.txt @@ -0,0 +1,648 @@ +.browserslistrc +.github/CODE_OF_CONDUCT.md +.github/CONTRIBUTING.md +.github/ISSUE_TEMPLATE/bug_report.yml +.github/ISSUE_TEMPLATE/config.yml +.github/ISSUE_TEMPLATE/feature_request.yml +.github/PULL_REQUEST_TEMPLATE.md +.github/SECURITY.md +.github/workflows/ci.yml +.gitignore +.husky/pre-commit +.lvimrc.lua +.node-version +.oxfmtrc.json +.oxlintrc.json +.prettierrc.json +.prototools +.stylelintignore +.vscode/extensions.json +.vscode/settings.json +AGENTS.md +CLAUDE.md +README.md +apps/demo/index.html +apps/demo/package.json +apps/demo/public/fonts/BerkeleyMonoVariable.woff2 +apps/demo/src/components/App.tsx +apps/demo/src/components/FileStream.tsx +apps/demo/src/main.ts +apps/demo/src/mocks/diff.patch +apps/demo/src/mocks/diff2.patch +apps/demo/src/mocks/diff3.patch +apps/demo/src/mocks/diff4.patch +apps/demo/src/mocks/diff5.patch +apps/demo/src/mocks/example_md.txt +apps/demo/src/mocks/example_ts.txt +apps/demo/src/mocks/fileAnsi.txt +apps/demo/src/mocks/fileConflict.txt +apps/demo/src/mocks/fileConflictLarge.txt +apps/demo/src/mocks/fileNew.txt +apps/demo/src/mocks/fileOld.txt +apps/demo/src/mocks/index.ts +apps/demo/src/style.css +apps/demo/src/utils/createFakeContentStream.ts +apps/demo/src/utils/createHighlighterCleanup.ts +apps/demo/src/utils/createWorkerAPI.ts +apps/demo/src/utils/renderAnnotation.ts +apps/demo/src/vite-env.d.ts +apps/demo/tsconfig.json +apps/demo/vite.config.ts +apps/docs/.env.example +apps/docs/.gitignore +apps/docs/README.md +apps/docs/app/BerkeleyMonoVariable.woff2 +apps/docs/app/Hero.tsx +apps/docs/app/components/IconFootnote.tsx +apps/docs/app/components/PierreThemeFootnote.tsx +apps/docs/app/components/TreeExampleHeading.tsx +apps/docs/app/diff-examples/Annotations/Annotations.tsx +apps/docs/app/diff-examples/Annotations/constants.ts +apps/docs/app/diff-examples/ArbitraryFiles/ArbitraryFiles.tsx +apps/docs/app/diff-examples/ArbitraryFiles/constants.ts +apps/docs/app/diff-examples/CustomHeader/CustomHeader.tsx +apps/docs/app/diff-examples/CustomHeader/constants.ts +apps/docs/app/diff-examples/CustomHunkSeparators/CustomHunkSeparators.tsx +apps/docs/app/diff-examples/CustomHunkSeparators/constants.ts +apps/docs/app/diff-examples/DiffStyles/DiffStyles.tsx +apps/docs/app/diff-examples/DiffStyles/constants.ts +apps/docs/app/diff-examples/FeatureHeader.tsx +apps/docs/app/diff-examples/FontStyles/FontStyles.tsx +apps/docs/app/diff-examples/FontStyles/constants.ts +apps/docs/app/diff-examples/LineSelection/LineSelection.tsx +apps/docs/app/diff-examples/LineSelection/constants.ts +apps/docs/app/diff-examples/MergeConflict/MergeConflict.tsx +apps/docs/app/diff-examples/MergeConflict/constants.ts +apps/docs/app/diff-examples/ShikiThemes/ShikiThemes.tsx +apps/docs/app/diff-examples/ShikiThemes/constants.ts +apps/docs/app/diff-examples/SplitUnified/SplitUnified.tsx +apps/docs/app/diff-examples/SplitUnified/constants.ts +apps/docs/app/docs/CopyCodeButton.tsx +apps/docs/app/docs/CoreTypes/constants.ts +apps/docs/app/docs/CoreTypes/content.mdx +apps/docs/app/docs/CustomHunkSeparators/constants.ts +apps/docs/app/docs/CustomHunkSeparators/content.mdx +apps/docs/app/docs/DocsCodeExample.tsx +apps/docs/app/docs/DocsLayout.tsx +apps/docs/app/docs/DocsSidebar.tsx +apps/docs/app/docs/HeadingAnchors.tsx +apps/docs/app/docs/Installation/PackageManagerTabs.tsx +apps/docs/app/docs/Installation/constants.ts +apps/docs/app/docs/Installation/content.mdx +apps/docs/app/docs/Overview/CodeToggle.tsx +apps/docs/app/docs/Overview/constants.ts +apps/docs/app/docs/Overview/content.mdx +apps/docs/app/docs/ProseWrapper.tsx +apps/docs/app/docs/ReactAPI/ComponentTabs.tsx +apps/docs/app/docs/ReactAPI/constants.ts +apps/docs/app/docs/ReactAPI/content.mdx +apps/docs/app/docs/SSR/constants.ts +apps/docs/app/docs/SSR/content.mdx +apps/docs/app/docs/SidebarWrapper.tsx +apps/docs/app/docs/Styling/constants.ts +apps/docs/app/docs/Styling/content.mdx +apps/docs/app/docs/Theming/constants.ts +apps/docs/app/docs/Theming/content.mdx +apps/docs/app/docs/Theming/docs-content.mdx +apps/docs/app/docs/Utilities/AcceptRejectTabs.tsx +apps/docs/app/docs/Utilities/constants.ts +apps/docs/app/docs/Utilities/content.mdx +apps/docs/app/docs/VanillaAPI/ComponentTabs.tsx +apps/docs/app/docs/VanillaAPI/constants.ts +apps/docs/app/docs/VanillaAPI/content.mdx +apps/docs/app/docs/Virtualization/constants.ts +apps/docs/app/docs/Virtualization/content.mdx +apps/docs/app/docs/WorkerPool/constants.ts +apps/docs/app/docs/WorkerPool/content.mdx +apps/docs/app/docs/page.tsx +apps/docs/app/docs/types.ts +apps/docs/app/favicon.ico +apps/docs/app/globals.css +apps/docs/app/layout.tsx +apps/docs/app/opengraph-image.png +apps/docs/app/page.tsx +apps/docs/app/pierre-dark.png +apps/docs/app/pierre-light.png +apps/docs/app/playground/PlaygroundClient.tsx +apps/docs/app/playground/constants.ts +apps/docs/app/playground/opengraph-image.png +apps/docs/app/playground/page.tsx +apps/docs/app/preview/trees/docs/page.tsx +apps/docs/app/preview/trees/opengraph-image.png +apps/docs/app/preview/trees/page.tsx +apps/docs/app/product-config.ts +apps/docs/app/prose.css +apps/docs/app/ssr/SSRPage.tsx +apps/docs/app/ssr/page.tsx +apps/docs/app/ssr/ssr_types.ts +apps/docs/app/theme/ThemeDemo.tsx +apps/docs/app/theme/ThemeLayout.tsx +apps/docs/app/theme/ThemeScreenshots.tsx +apps/docs/app/theme/page.tsx +apps/docs/app/trees-dev/RenderingDemoClient.tsx +apps/docs/app/trees-dev/_components/DemoHeaderContent.tsx +apps/docs/app/trees-dev/_components/ExampleCard.tsx +apps/docs/app/trees-dev/_components/ItemStatePreview.tsx +apps/docs/app/trees-dev/_components/ReactClientRendered.tsx +apps/docs/app/trees-dev/_components/ReactServerRendered.tsx +apps/docs/app/trees-dev/_components/StateLog.tsx +apps/docs/app/trees-dev/_components/TreeDemoContextMenu.tsx +apps/docs/app/trees-dev/_components/TreesDevSettingsProvider.tsx +apps/docs/app/trees-dev/_components/TreesDevShell.tsx +apps/docs/app/trees-dev/_components/TreesDevSidebar.tsx +apps/docs/app/trees-dev/_components/VanillaClientRendered.tsx +apps/docs/app/trees-dev/_components/VanillaServerRendered.tsx +apps/docs/app/trees-dev/_components/cleanupFileTreeInstance.ts +apps/docs/app/trees-dev/_components/readSettingsCookies.ts +apps/docs/app/trees-dev/_components/useGitStatusControls.tsx +apps/docs/app/trees-dev/context-menu/ContextMenuDemoClient.tsx +apps/docs/app/trees-dev/context-menu/page.tsx +apps/docs/app/trees-dev/cookies.ts +apps/docs/app/trees-dev/custom-icons/CustomIconsDemoClient.tsx +apps/docs/app/trees-dev/custom-icons/page.tsx +apps/docs/app/trees-dev/demo-data.ts +apps/docs/app/trees-dev/drag-and-drop/DragAndDropDemoClient.tsx +apps/docs/app/trees-dev/drag-and-drop/page.tsx +apps/docs/app/trees-dev/dynamic-files/DynamicFilesDemoClient.tsx +apps/docs/app/trees-dev/dynamic-files/page.tsx +apps/docs/app/trees-dev/git-status/GitStatusDemoClient.tsx +apps/docs/app/trees-dev/git-status/page.tsx +apps/docs/app/trees-dev/header-slot/HeaderSlotDemoClient.tsx +apps/docs/app/trees-dev/header-slot/page.tsx +apps/docs/app/trees-dev/layout.tsx +apps/docs/app/trees-dev/linux-files.json +apps/docs/app/trees-dev/page.tsx +apps/docs/app/trees-dev/search/page.tsx +apps/docs/app/trees-dev/state/StateDemoClient.tsx +apps/docs/app/trees-dev/state/page.tsx +apps/docs/app/trees-dev/themes/Swatches.tsx +apps/docs/app/trees-dev/themes/ThemesGridClient.tsx +apps/docs/app/trees-dev/themes/constants.ts +apps/docs/app/trees-dev/themes/page.tsx +apps/docs/app/trees-dev/themes/useTreeStatePreview.ts +apps/docs/app/trees-dev/virtualization/page.tsx +apps/docs/app/trees/TreesHomePage.tsx +apps/docs/app/trees/demo-data.ts +apps/docs/app/trees/docs/CoreTypes/constants.ts +apps/docs/app/trees/docs/CoreTypes/content.mdx +apps/docs/app/trees/docs/GitStatus/content.mdx +apps/docs/app/trees/docs/Icons/content.mdx +apps/docs/app/trees/docs/Installation/constants.ts +apps/docs/app/trees/docs/Installation/content.mdx +apps/docs/app/trees/docs/Overview/TreesCodeToggle.tsx +apps/docs/app/trees/docs/Overview/constants.ts +apps/docs/app/trees/docs/Overview/content.mdx +apps/docs/app/trees/docs/ReactAPI/constants.ts +apps/docs/app/trees/docs/ReactAPI/content.mdx +apps/docs/app/trees/docs/SSR/constants.ts +apps/docs/app/trees/docs/SSR/content.mdx +apps/docs/app/trees/docs/Styling/constants.ts +apps/docs/app/trees/docs/Styling/content.mdx +apps/docs/app/trees/docs/Theming/constants.ts +apps/docs/app/trees/docs/Theming/content.mdx +apps/docs/app/trees/docs/Utilities/constants.ts +apps/docs/app/trees/docs/Utilities/content.mdx +apps/docs/app/trees/docs/VanillaAPI/constants.ts +apps/docs/app/trees/docs/VanillaAPI/content.mdx +apps/docs/app/trees/opengraph-image.png +apps/docs/app/trees/tree-examples/A11ySection.tsx +apps/docs/app/trees/tree-examples/CustomIconsSection.tsx +apps/docs/app/trees/tree-examples/DragDropSection.tsx +apps/docs/app/trees/tree-examples/DragDropSectionClient.tsx +apps/docs/app/trees/tree-examples/FlatteningSection.tsx +apps/docs/app/trees/tree-examples/GitStatusSection.tsx +apps/docs/app/trees/tree-examples/GitStatusSectionClient.tsx +apps/docs/app/trees/tree-examples/SearchSection.tsx +apps/docs/app/trees/tree-examples/StylingSection.tsx +apps/docs/app/trees/tree-examples/ThemingSection.tsx +apps/docs/app/trees/tree-examples/ThemingSectionClient.tsx +apps/docs/app/trees/tree-examples/TreeCssViewer.tsx +apps/docs/app/trees/tree-examples/TreeExampleSection.tsx +apps/docs/app/trees/tree-examples/VirtualizationSection.tsx +apps/docs/app/trees/tree-examples/demo-data.ts +apps/docs/app/trees/tree-examples/index.ts +apps/docs/app/trees/tree-examples/styleToCss.ts +apps/docs/app/truncate-dev/ResizableRightBorder.tsx +apps/docs/app/truncate-dev/page.tsx +apps/docs/app/twitter-image.png +apps/docs/components.json +apps/docs/components/Button.module.css +apps/docs/components/Button.tsx +apps/docs/components/CustomScrollbarCSS.ts +apps/docs/components/Footer.tsx +apps/docs/components/Header.tsx +apps/docs/components/MobileMenuButton.tsx +apps/docs/components/NavLink.tsx +apps/docs/components/PierreCompanySection.tsx +apps/docs/components/WorkerPoolContext.tsx +apps/docs/components/precision-diffs-logo.svg +apps/docs/components/ui/avatar.tsx +apps/docs/components/ui/button-group.tsx +apps/docs/components/ui/button.tsx +apps/docs/components/ui/command.tsx +apps/docs/components/ui/dialog.tsx +apps/docs/components/ui/dropdown-menu.tsx +apps/docs/components/ui/field.tsx +apps/docs/components/ui/input-group.tsx +apps/docs/components/ui/input.tsx +apps/docs/components/ui/label.tsx +apps/docs/components/ui/navigation-menu.tsx +apps/docs/components/ui/notice.tsx +apps/docs/components/ui/popover.tsx +apps/docs/components/ui/select.tsx +apps/docs/components/ui/separator.tsx +apps/docs/components/ui/sonner.tsx +apps/docs/components/ui/switch.tsx +apps/docs/components/ui/toggle-group.tsx +apps/docs/components/ui/toggle.tsx +apps/docs/components/ui/tooltip.tsx +apps/docs/lib/mdx.tsx +apps/docs/lib/rehype-hierarchical-slug.ts +apps/docs/lib/remark-toc-ignore.ts +apps/docs/lib/utils.ts +apps/docs/next.config.mjs +apps/docs/package.json +apps/docs/postcss.config.mjs +apps/docs/public/apple-touch-icon.png +apps/docs/public/avatars/avatar_amadeus.jpg +apps/docs/public/avatars/avatar_fat.jpg +apps/docs/public/avatars/avatar_mdo.jpg +apps/docs/public/favicon.png +apps/docs/public/favicon.svg +apps/docs/public/llms-full.txt +apps/docs/public/llms.txt +apps/docs/public/trees/llms-full.txt +apps/docs/public/trees/llms.txt +apps/docs/scripts/generate-llms-txt.ts +apps/docs/tsconfig.json +apps/docs/types/assets.d.ts +apps/docs/types/css-modules.d.ts +apps/docs/types/file-tree-container.d.ts +apps/docs/types/raw-loader.d.ts +bun.lock +package.json +packages/diffs/LICENSE.md +packages/diffs/README.md +packages/diffs/package.json +packages/diffs/scripts/benchmarkParseMergeConflictDiffFromFile.ts +packages/diffs/src/components/AdvancedVirtualizedFileDiff.ts +packages/diffs/src/components/AdvancedVirtualizer.ts +packages/diffs/src/components/File.ts +packages/diffs/src/components/FileDiff.ts +packages/diffs/src/components/FileStream.ts +packages/diffs/src/components/UnresolvedFile.ts +packages/diffs/src/components/VirtualizedFile.ts +packages/diffs/src/components/VirtualizedFileDiff.ts +packages/diffs/src/components/Virtualizer.ts +packages/diffs/src/components/VirtulizerDevelopment.d.ts +packages/diffs/src/components/web-components.ts +packages/diffs/src/constants.ts +packages/diffs/src/highlighter/languages/areLanguagesAttached.ts +packages/diffs/src/highlighter/languages/attachResolvedLanguages.ts +packages/diffs/src/highlighter/languages/cleanUpResolvedLanguages.ts +packages/diffs/src/highlighter/languages/constants.ts +packages/diffs/src/highlighter/languages/getResolvedLanguages.ts +packages/diffs/src/highlighter/languages/getResolvedOrResolveLanguage.ts +packages/diffs/src/highlighter/languages/hasResolvedLanguages.ts +packages/diffs/src/highlighter/languages/registerCustomLanguage.ts +packages/diffs/src/highlighter/languages/resolveLanguage.ts +packages/diffs/src/highlighter/languages/resolveLanguages.ts +packages/diffs/src/highlighter/shared_highlighter.ts +packages/diffs/src/highlighter/themes/areThemesAttached.ts +packages/diffs/src/highlighter/themes/attachResolvedThemes.ts +packages/diffs/src/highlighter/themes/cleanUpResolvedThemes.ts +packages/diffs/src/highlighter/themes/constants.ts +packages/diffs/src/highlighter/themes/getResolvedOrResolveTheme.ts +packages/diffs/src/highlighter/themes/getResolvedThemes.ts +packages/diffs/src/highlighter/themes/hasResolvedThemes.ts +packages/diffs/src/highlighter/themes/registerCustomCSSVariableTheme.ts +packages/diffs/src/highlighter/themes/registerCustomTheme.ts +packages/diffs/src/highlighter/themes/resolveTheme.ts +packages/diffs/src/highlighter/themes/resolveThemes.ts +packages/diffs/src/index.ts +packages/diffs/src/managers/InteractionManager.ts +packages/diffs/src/managers/ResizeManager.ts +packages/diffs/src/managers/ScrollSyncManager.ts +packages/diffs/src/managers/UniversalRenderingManager.ts +packages/diffs/src/react/File.tsx +packages/diffs/src/react/FileDiff.tsx +packages/diffs/src/react/MultiFileDiff.tsx +packages/diffs/src/react/PatchDiff.tsx +packages/diffs/src/react/UnresolvedFile.tsx +packages/diffs/src/react/Virtualizer.tsx +packages/diffs/src/react/WorkerPoolContext.tsx +packages/diffs/src/react/constants.ts +packages/diffs/src/react/index.ts +packages/diffs/src/react/jsx.d.ts +packages/diffs/src/react/types.ts +packages/diffs/src/react/utils/renderDiffChildren.tsx +packages/diffs/src/react/utils/renderFileChildren.tsx +packages/diffs/src/react/utils/templateRender.tsx +packages/diffs/src/react/utils/useFileDiffInstance.ts +packages/diffs/src/react/utils/useFileInstance.ts +packages/diffs/src/react/utils/useStableCallback.ts +packages/diffs/src/react/utils/useUnresolvedFileInstance.ts +packages/diffs/src/renderers/DiffHunksRenderer.ts +packages/diffs/src/renderers/FileRenderer.ts +packages/diffs/src/renderers/UnresolvedFileHunksRenderer.ts +packages/diffs/src/shiki-stream/index.ts +packages/diffs/src/shiki-stream/stream.ts +packages/diffs/src/shiki-stream/tokenizer.ts +packages/diffs/src/shiki-stream/types.ts +packages/diffs/src/sprite.ts +packages/diffs/src/ssr/FileDiffReact.tsx +packages/diffs/src/ssr/index.ts +packages/diffs/src/ssr/preloadDiffs.ts +packages/diffs/src/ssr/preloadFile.ts +packages/diffs/src/ssr/preloadPatchFile.ts +packages/diffs/src/ssr/renderHTML.ts +packages/diffs/src/string-import.d.ts +packages/diffs/src/style.css +packages/diffs/src/types.ts +packages/diffs/src/utils/areDiffLineAnnotationsEqual.ts +packages/diffs/src/utils/areFilesEqual.ts +packages/diffs/src/utils/areHunkDataEqual.ts +packages/diffs/src/utils/areLineAnnotationsEqual.ts +packages/diffs/src/utils/areMergeConflictActionsEqual.ts +packages/diffs/src/utils/areObjectsEqual.ts +packages/diffs/src/utils/areOptionsEqual.ts +packages/diffs/src/utils/arePrePropertiesEqual.ts +packages/diffs/src/utils/areRenderRangesEqual.ts +packages/diffs/src/utils/areSelectionPointsEqual.ts +packages/diffs/src/utils/areSelectionsEqual.ts +packages/diffs/src/utils/areThemesEqual.ts +packages/diffs/src/utils/areVirtualWindowSpecsEqual.ts +packages/diffs/src/utils/areWorkerStatsEqual.ts +packages/diffs/src/utils/cleanLastNewline.ts +packages/diffs/src/utils/createAnnotationElement.ts +packages/diffs/src/utils/createAnnotationWrapperNode.ts +packages/diffs/src/utils/createContentColumn.ts +packages/diffs/src/utils/createEmptyRowBuffer.ts +packages/diffs/src/utils/createFileHeaderElement.ts +packages/diffs/src/utils/createGutterUtilityContentNode.ts +packages/diffs/src/utils/createGutterUtilityElement.ts +packages/diffs/src/utils/createNoNewlineElement.ts +packages/diffs/src/utils/createPreElement.ts +packages/diffs/src/utils/createRowNodes.ts +packages/diffs/src/utils/createSeparator.ts +packages/diffs/src/utils/createSpanNodeFromToken.ts +packages/diffs/src/utils/createStyleElement.ts +packages/diffs/src/utils/createTransformerWithState.ts +packages/diffs/src/utils/createUnsafeCSSStyleNode.ts +packages/diffs/src/utils/createWindowFromScrollPosition.ts +packages/diffs/src/utils/cssWrappers.ts +packages/diffs/src/utils/diffAcceptRejectHunk.ts +packages/diffs/src/utils/formatCSSVariablePrefix.ts +packages/diffs/src/utils/getFiletypeFromFileName.ts +packages/diffs/src/utils/getHighlighterOptions.ts +packages/diffs/src/utils/getHighlighterThemeStyles.ts +packages/diffs/src/utils/getHunkSeparatorSlotName.ts +packages/diffs/src/utils/getIconForType.ts +packages/diffs/src/utils/getLineAnnotationName.ts +packages/diffs/src/utils/getLineEndingType.ts +packages/diffs/src/utils/getLineNodes.ts +packages/diffs/src/utils/getMergeConflictActionSlotName.ts +packages/diffs/src/utils/getMergeConflictLineTypes.ts +packages/diffs/src/utils/getOrCreateCodeNode.ts +packages/diffs/src/utils/getSingularPatch.ts +packages/diffs/src/utils/getThemes.ts +packages/diffs/src/utils/getTotalLineCountFromHunks.ts +packages/diffs/src/utils/hast_utils.ts +packages/diffs/src/utils/isDefaultRenderRange.ts +packages/diffs/src/utils/isWorkerContext.ts +packages/diffs/src/utils/iterateOverDiff.ts +packages/diffs/src/utils/iterateOverFile.ts +packages/diffs/src/utils/normalizeDiffResolution.ts +packages/diffs/src/utils/parseDiffDecorations.ts +packages/diffs/src/utils/parseDiffFromFile.ts +packages/diffs/src/utils/parseLineType.ts +packages/diffs/src/utils/parseMergeConflictDiffFromFile.ts +packages/diffs/src/utils/parsePatchFiles.ts +packages/diffs/src/utils/prerenderHTMLIfNecessary.ts +packages/diffs/src/utils/processLine.ts +packages/diffs/src/utils/renderDiffWithHighlighter.ts +packages/diffs/src/utils/renderFileWithHighlighter.ts +packages/diffs/src/utils/resolveConflict.ts +packages/diffs/src/utils/resolveRegion.ts +packages/diffs/src/utils/resolveVirtualFileMetrics.ts +packages/diffs/src/utils/setLanguageOverride.ts +packages/diffs/src/utils/setWrapperNodeProps.ts +packages/diffs/src/utils/splitFileContents.ts +packages/diffs/src/utils/trimPatchContext.ts +packages/diffs/src/worker/WorkerPoolManager.ts +packages/diffs/src/worker/getOrCreateWorkerPoolSingleton.ts +packages/diffs/src/worker/index.ts +packages/diffs/src/worker/types.ts +packages/diffs/src/worker/worker-portable.ts +packages/diffs/src/worker/worker.ts +packages/diffs/test/DiffHunksRender.test.ts +packages/diffs/test/DiffHunksRendererVirtualization.test.ts +packages/diffs/test/FileRenderer.ast.test.ts +packages/diffs/test/FileRenderer.test.ts +packages/diffs/test/InjectedRowHooks.test.ts +packages/diffs/test/__snapshots__/DiffHunksRender.test.ts.snap +packages/diffs/test/__snapshots__/DiffHunksRendererVirtualization.test.ts.snap +packages/diffs/test/__snapshots__/FileRenderer.test.ts.snap +packages/diffs/test/__snapshots__/annotations.test.ts.snap +packages/diffs/test/__snapshots__/parseDiffFromFile.test.ts.snap +packages/diffs/test/__snapshots__/parseMergeConflictDiffFromFile.test.ts.snap +packages/diffs/test/__snapshots__/parsePatchFiles.test.ts.snap +packages/diffs/test/__snapshots__/patchFileRender.test.ts.snap +packages/diffs/test/__snapshots__/trimPatchContext.test.ts.snap +packages/diffs/test/annotations.test.ts +packages/diffs/test/diffAcceptRejectHunk.test.ts +packages/diffs/test/file.patch +packages/diffs/test/getMergeConflictLineTypes.test.ts +packages/diffs/test/iterateOverDiff.test.ts +packages/diffs/test/iterateOverFile.test.ts +packages/diffs/test/mocks.ts +packages/diffs/test/parseDiffFromFile.test.ts +packages/diffs/test/parseMergeConflictDiffFromFile.test.ts +packages/diffs/test/parsePatchFiles.test.ts +packages/diffs/test/patchFileRender.test.ts +packages/diffs/test/sharedHighlighter.test.ts +packages/diffs/test/testUtils.ts +packages/diffs/test/trim.patch +packages/diffs/test/trimPatchContext.test.ts +packages/diffs/tsconfig.json +packages/diffs/tsdown.config.ts +packages/storage-elements-next/package.json +packages/storage-elements-next/src/auth/success-callback.ts +packages/storage-elements-next/src/github/installations.ts +packages/storage-elements-next/src/index.ts +packages/storage-elements-next/src/repo/route.ts +packages/storage-elements-next/tsconfig.json +packages/storage-elements/blocks/git-platform-sync/api/github/callback/route.ts +packages/storage-elements/blocks/git-platform-sync/api/github/installations/route.ts +packages/storage-elements/blocks/git-platform-sync/api/repo/route.ts +packages/storage-elements/blocks/git-platform-sync/components/git-platform-sync.tsx +packages/storage-elements/blocks/git-platform-sync/lib/github-app-connect.ts +packages/storage-elements/blocks/git-platform-sync/pages/success/page.tsx +packages/storage-elements/components.json +packages/storage-elements/components/ui/button.tsx +packages/storage-elements/components/ui/command.tsx +packages/storage-elements/components/ui/dialog.tsx +packages/storage-elements/components/ui/field.tsx +packages/storage-elements/components/ui/input.tsx +packages/storage-elements/components/ui/label.tsx +packages/storage-elements/components/ui/popover.tsx +packages/storage-elements/components/ui/select.tsx +packages/storage-elements/components/ui/separator.tsx +packages/storage-elements/components/ui/tooltip.tsx +packages/storage-elements/lib/utils.ts +packages/storage-elements/package.json +packages/storage-elements/tsconfig.json +packages/trees/LICENSE.md +packages/trees/NOTICE.md +packages/trees/README.md +packages/trees/package.json +packages/trees/src/FileTree.ts +packages/trees/src/components/Icon.tsx +packages/trees/src/components/OverflowText.tsx +packages/trees/src/components/Root.tsx +packages/trees/src/components/TreeItem.tsx +packages/trees/src/components/VirtualizedList.tsx +packages/trees/src/components/hooks/useContextMenuController.ts +packages/trees/src/components/hooks/useExpansionMigration.ts +packages/trees/src/components/hooks/useFlattenedDropTarget.ts +packages/trees/src/components/hooks/useStateChangeCallbacks.ts +packages/trees/src/components/hooks/useTree.ts +packages/trees/src/components/hooks/useTreeStateConfig.ts +packages/trees/src/components/web-components.ts +packages/trees/src/constants.ts +packages/trees/src/core/build-proxified-instance.ts +packages/trees/src/core/build-static-instance.ts +packages/trees/src/core/create-tree.ts +packages/trees/src/core/types/core.ts +packages/trees/src/core/utilities/create-on-drop-handler.ts +packages/trees/src/core/utilities/errors.ts +packages/trees/src/core/utilities/insert-items-at-target.ts +packages/trees/src/core/utilities/remove-items-from-parents.ts +packages/trees/src/core/utils.ts +packages/trees/src/features/async-data-loader/feature.ts +packages/trees/src/features/async-data-loader/types.ts +packages/trees/src/features/context-menu/feature.ts +packages/trees/src/features/context-menu/types.ts +packages/trees/src/features/drag-and-drop/feature.ts +packages/trees/src/features/drag-and-drop/types.ts +packages/trees/src/features/drag-and-drop/utils.ts +packages/trees/src/features/expand-all/feature.ts +packages/trees/src/features/expand-all/types.ts +packages/trees/src/features/git-status/feature.ts +packages/trees/src/features/git-status/types.ts +packages/trees/src/features/hotkeys-core/feature.ts +packages/trees/src/features/hotkeys-core/types.ts +packages/trees/src/features/keyboard-drag-and-drop/feature.ts +packages/trees/src/features/keyboard-drag-and-drop/types.ts +packages/trees/src/features/main/types.ts +packages/trees/src/features/prop-memoization/feature.ts +packages/trees/src/features/prop-memoization/types.ts +packages/trees/src/features/renaming/feature.ts +packages/trees/src/features/renaming/types.ts +packages/trees/src/features/search/feature.ts +packages/trees/src/features/search/types.ts +packages/trees/src/features/selection/feature.ts +packages/trees/src/features/selection/types.ts +packages/trees/src/features/sync-data-loader/feature.ts +packages/trees/src/features/sync-data-loader/types.ts +packages/trees/src/features/tree/feature.ts +packages/trees/src/features/tree/types.ts +packages/trees/src/index.ts +packages/trees/src/loader/lazy.ts +packages/trees/src/loader/sync.ts +packages/trees/src/loader/types.ts +packages/trees/src/react/FileTree.tsx +packages/trees/src/react/index.ts +packages/trees/src/react/jsx.ts +packages/trees/src/react/utils/useFileTreeInstance.ts +packages/trees/src/sprite.ts +packages/trees/src/ssr/index.ts +packages/trees/src/ssr/preloadFileTree.tsx +packages/trees/src/string-import.d.ts +packages/trees/src/style.css +packages/trees/src/types.ts +packages/trees/src/utils/computeNewFilesAfterDrop.ts +packages/trees/src/utils/controlledExpandedState.ts +packages/trees/src/utils/createIdMaps.ts +packages/trees/src/utils/createLoaderUtils.ts +packages/trees/src/utils/cssWrappers.ts +packages/trees/src/utils/expandImplicitParentDirectories.ts +packages/trees/src/utils/expandPaths.ts +packages/trees/src/utils/fileListToTree.ts +packages/trees/src/utils/getGitStatusSignature.ts +packages/trees/src/utils/getSelectionPath.ts +packages/trees/src/utils/guideLineAncestors.ts +packages/trees/src/utils/hashId.ts +packages/trees/src/utils/normalizeInputPath.ts +packages/trees/src/utils/preactRenderer.tsx +packages/trees/src/utils/renameFileTreePaths.ts +packages/trees/src/utils/sortChildren.ts +packages/trees/src/utils/themeToTreeStyles.ts +packages/trees/src/web-components.ts +packages/trees/test/PLAYWRIGHT_STYLE_ISOLATION_PLAN.md +packages/trees/test/TESTING.md +packages/trees/test/context-menu.test.ts +packages/trees/test/controlled-expanded-subtree.test.ts +packages/trees/test/core/core.test.ts +packages/trees/test/core/expand-all.test.ts +packages/trees/test/core/keyboard-drag-and-drop.test.ts +packages/trees/test/core/prop-memoization.test.ts +packages/trees/test/core/selection.test.ts +packages/trees/test/core/test-utils/test-tree-do.ts +packages/trees/test/core/test-utils/test-tree-expect.ts +packages/trees/test/core/test-utils/test-tree.ts +packages/trees/test/core/tree.test.ts +packages/trees/test/core/utils.test.ts +packages/trees/test/drag-and-drop.test.ts +packages/trees/test/e2e/check-playwright-binary.ts +packages/trees/test/e2e/context-menu-focus.pw.ts +packages/trees/test/e2e/fixtures/context-menu.html +packages/trees/test/e2e/fixtures/git-status.html +packages/trees/test/e2e/fixtures/style-isolation.html +packages/trees/test/e2e/fixtures/touch-dnd.html +packages/trees/test/e2e/git-status-attrs.pw.ts +packages/trees/test/e2e/playwright.config.ts +packages/trees/test/e2e/style-isolation.pw.ts +packages/trees/test/e2e/touch-dnd.pw.ts +packages/trees/test/e2e/vite.config.ts +packages/trees/test/expandImplicitParentDirectories.test.ts +packages/trees/test/expandPaths.test.ts +packages/trees/test/fileListToTree.test.ts +packages/trees/test/filetree-state.test.ts +packages/trees/test/git-status.test.ts +packages/trees/test/guide-line-ancestors.test.ts +packages/trees/test/lazy-loader.test.ts +packages/trees/test/loader-equivalence.test.ts +packages/trees/test/loader.shared.ts +packages/trees/test/react-controlled.test.tsx +packages/trees/test/rename-file-tree-paths.test.ts +packages/trees/test/root-memoization-regressions.test.ts +packages/trees/test/search-modes.test.ts +packages/trees/test/setFiles.test.ts +packages/trees/test/ssr-declarative-shadow-dom.test.ts +packages/trees/test/state-rendering.test.ts +packages/trees/test/sync-loader.test.ts +packages/trees/test/test-config.ts +packages/trees/test/virtualized-list.test.ts +packages/trees/tsconfig.json +packages/trees/tsdown.config.ts +packages/truncate/LICENSE.md +packages/truncate/README.md +packages/truncate/package.json +packages/truncate/src/index.ts +packages/truncate/src/lib/splits.ts +packages/truncate/src/lib/types.ts +packages/truncate/src/react/components/OverflowText.tsx +packages/truncate/src/react/index.ts +packages/truncate/src/style.css +packages/truncate/tsconfig.json +packages/truncate/tsdown.config.ts +scripts/build-sprite.js +scripts/precommit-tsc.ts +scripts/ws.ts +sprite.config.js +stylelint.config.js +svgo.config.js +tsconfig.json +tsconfig.options.json +tsconfig.oxlint.json diff --git a/packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts b/packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts new file mode 100644 index 000000000..99f00260e --- /dev/null +++ b/packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts @@ -0,0 +1,350 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { + forEachFolderInNormalizedPath, + normalizeInputPath, +} from '../../src/utils/normalizeInputPath'; + +export interface FileListShapeSummary { + fileCount: number; + uniqueFolderCount: number; + maxDepth: number; +} + +export interface FileListToTreeBenchmarkCase extends FileListShapeSummary { + name: string; + source: 'synthetic' | 'fixture'; + files: string[]; +} + +const BENCHMARK_FIXTURE_PATH = resolve( + import.meta.dir, + '../fixtures/fileListToTree-monorepo-snapshot.txt' +); +const LINUX_KERNEL_FIXTURE_PATH = resolve( + import.meta.dir, + '../../../../apps/docs/app/trees-dev/linux-files.json' +); + +interface LinuxKernelFixture { + files: string[]; + folders: string[]; +} + +function readFixtureLines(path: string): string[] { + return readFileSync(path, 'utf-8') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +// The docs app already ships this Linux kernel file list, so reusing it keeps +// the benchmark tied to a real workload we exercise elsewhere in the repo. +function readLinuxKernelFixture(path: string): LinuxKernelFixture { + return JSON.parse(readFileSync(path, 'utf-8')) as LinuxKernelFixture; +} + +function pushRepeatedFiles( + files: string[], + count: number, + buildPath: (index: number) => string +): void { + for (let index = 0; index < count; index++) { + files.push(buildPath(index)); + } +} + +function buildTinyFlatFiles(): string[] { + const files: string[] = []; + pushRepeatedFiles( + files, + 16, + (index) => `.config-${index.toString().padStart(2, '0')}.json` + ); + pushRepeatedFiles( + files, + 112, + (index) => `file-${index.toString().padStart(3, '0')}.ts` + ); + return files; +} + +function buildSmallMixedFiles(): string[] { + const files: string[] = [ + 'README.md', + 'package.json', + 'tsconfig.json', + '.gitignore', + '.github/workflows/ci.yml', + '.github/workflows/release.yml', + ]; + + for (let packageIndex = 0; packageIndex < 12; packageIndex++) { + const packageName = `pkg-${packageIndex.toString().padStart(2, '0')}`; + files.push(`packages/${packageName}/package.json`); + files.push(`packages/${packageName}/README.md`); + + for (let featureIndex = 0; featureIndex < 6; featureIndex++) { + const featureName = `feature-${featureIndex.toString().padStart(2, '0')}`; + for (let fileIndex = 0; fileIndex < 12; fileIndex++) { + files.push( + `packages/${packageName}/src/${featureName}/module-${fileIndex + .toString() + .padStart(2, '0')}.ts` + ); + } + } + } + + for (let appIndex = 0; appIndex < 4; appIndex++) { + const appName = `app-${appIndex.toString().padStart(2, '0')}`; + for (let routeIndex = 0; routeIndex < 16; routeIndex++) { + files.push( + `apps/${appName}/src/routes/route-${routeIndex + .toString() + .padStart(2, '0')}.tsx` + ); + } + } + + return files; +} + +function buildMediumBalancedFiles(): string[] { + const files: string[] = []; + + for (let workspaceIndex = 0; workspaceIndex < 20; workspaceIndex++) { + const workspaceName = `workspace-${workspaceIndex + .toString() + .padStart(2, '0')}`; + + for (let packageIndex = 0; packageIndex < 10; packageIndex++) { + const packageName = `package-${packageIndex.toString().padStart(2, '0')}`; + + for (let featureIndex = 0; featureIndex < 5; featureIndex++) { + const featureName = `feature-${featureIndex.toString().padStart(2, '0')}`; + + for (let fileIndex = 0; fileIndex < 4; fileIndex++) { + files.push( + `generated/${workspaceName}/${packageName}/${featureName}/file-${fileIndex + .toString() + .padStart(2, '0')}.ts` + ); + } + } + } + } + + return files; +} + +function buildLargeWideFiles(): string[] { + const files: string[] = []; + + for (let folderIndex = 0; folderIndex < 80; folderIndex++) { + const folderName = `bucket-${folderIndex.toString().padStart(2, '0')}`; + for (let fileIndex = 0; fileIndex < 100; fileIndex++) { + files.push( + `wide/${folderName}/item-${fileIndex.toString().padStart(3, '0')}.ts` + ); + } + } + + return files; +} + +function buildLargeDeepChainFiles(): string[] { + const files: string[] = []; + + for (let chainIndex = 0; chainIndex < 128; chainIndex++) { + const chainName = `chain-${chainIndex.toString().padStart(3, '0')}`; + const chainPrefix = Array.from( + { length: 10 }, + (_, depthIndex) => `level-${depthIndex.toString().padStart(2, '0')}` + ).join('/'); + + for (let fileIndex = 0; fileIndex < 16; fileIndex++) { + files.push( + `deep/${chainName}/${chainPrefix}/file-${fileIndex + .toString() + .padStart(2, '0')}.ts` + ); + } + } + + return files; +} + +function buildLargeMonorepoShapedFiles(): string[] { + const files: string[] = [ + '.github/workflows/ci.yml', + '.github/workflows/release.yml', + '.changeset/config.json', + 'README.md', + 'package.json', + 'tsconfig.json', + ]; + + for (let packageIndex = 0; packageIndex < 24; packageIndex++) { + const packageName = `pkg-${packageIndex.toString().padStart(2, '0')}`; + files.push(`packages/${packageName}/package.json`); + files.push(`packages/${packageName}/README.md`); + + for (let layerIndex = 0; layerIndex < 4; layerIndex++) { + const layerName = `layer-${layerIndex.toString().padStart(2, '0')}`; + for (let featureIndex = 0; featureIndex < 3; featureIndex++) { + const featureName = `feature-${featureIndex.toString().padStart(2, '0')}`; + for (let fileIndex = 0; fileIndex < 4; fileIndex++) { + files.push( + `packages/${packageName}/src/${layerName}/${featureName}/file-${fileIndex + .toString() + .padStart(2, '0')}.ts` + ); + } + } + } + } + + for (let appIndex = 0; appIndex < 12; appIndex++) { + const appName = `app-${appIndex.toString().padStart(2, '0')}`; + files.push(`apps/${appName}/package.json`); + + for (let routeIndex = 0; routeIndex < 12; routeIndex++) { + const routeName = `route-${routeIndex.toString().padStart(2, '0')}`; + for (let fileIndex = 0; fileIndex < 6; fileIndex++) { + files.push( + `apps/${appName}/src/routes/${routeName}/view-${fileIndex + .toString() + .padStart(2, '0')}.tsx` + ); + } + } + } + + for (let docsIndex = 0; docsIndex < 8; docsIndex++) { + for (let pageIndex = 0; pageIndex < 40; pageIndex++) { + files.push( + `apps/docs/content/section-${docsIndex + .toString() + .padStart(2, '0')}/page-${pageIndex.toString().padStart(3, '0')}.mdx` + ); + } + } + + return files; +} + +function buildExplicitDirectoriesFiles(): string[] { + const files: string[] = []; + + for (let packageIndex = 0; packageIndex < 32; packageIndex++) { + const packageName = `pkg-${packageIndex.toString().padStart(2, '0')}`; + files.push(`packages/${packageName}/src/components/`); + files.push(`packages/${packageName}/src/utils/`); + files.push(`packages/${packageName}/src/components/Button.tsx`); + files.push(`packages/${packageName}/src/utils/helpers.ts`); + } + + return files; +} + +export function describeFileListShape(files: string[]): FileListShapeSummary { + const uniqueFolders = new Set(); + let fileCount = 0; + let maxDepth = 0; + + for (const filePath of files) { + const normalizedPath = normalizeInputPath(filePath); + if (normalizedPath == null) { + continue; + } + + fileCount += 1; + const depth = normalizedPath.path.split('/').length; + if (depth > maxDepth) { + maxDepth = depth; + } + + forEachFolderInNormalizedPath( + normalizedPath.path, + normalizedPath.isDirectory, + (folderPath) => { + uniqueFolders.add(folderPath); + } + ); + } + + return { + fileCount, + uniqueFolderCount: uniqueFolders.size, + maxDepth, + }; +} + +function createCase( + name: string, + source: 'synthetic' | 'fixture', + files: string[] +): FileListToTreeBenchmarkCase { + return { + name, + source, + files, + ...describeFileListShape(files), + }; +} + +let cachedCases: FileListToTreeBenchmarkCase[] | null = null; + +export function getFileListToTreeBenchmarkCases(): FileListToTreeBenchmarkCase[] { + if (cachedCases != null) { + return cachedCases; + } + + cachedCases = [ + createCase('tiny-flat', 'synthetic', buildTinyFlatFiles()), + createCase('small-mixed', 'synthetic', buildSmallMixedFiles()), + createCase('medium-balanced', 'synthetic', buildMediumBalancedFiles()), + createCase('large-wide', 'synthetic', buildLargeWideFiles()), + createCase('large-deep-chain', 'synthetic', buildLargeDeepChainFiles()), + createCase( + 'large-monorepo-shaped', + 'synthetic', + buildLargeMonorepoShapedFiles() + ), + createCase( + 'explicit-directories', + 'synthetic', + buildExplicitDirectoriesFiles() + ), + createCase( + 'fixture-linux-kernel-files', + 'fixture', + readLinuxKernelFixture(LINUX_KERNEL_FIXTURE_PATH).files + ), + createCase( + 'fixture-pierrejs-repo-snapshot', + 'fixture', + readFixtureLines(BENCHMARK_FIXTURE_PATH) + ), + ]; + + return cachedCases; +} + +export function filterBenchmarkCases( + cases: FileListToTreeBenchmarkCase[], + filters: string[] +): FileListToTreeBenchmarkCase[] { + if (filters.length === 0) { + return cases; + } + + const normalizedFilters = filters.map((filter) => filter.toLowerCase()); + return cases.filter((caseConfig) => + normalizedFilters.some((filter) => + caseConfig.name.toLowerCase().includes(filter) + ) + ); +} diff --git a/packages/trees/src/utils/fileListToTree.ts b/packages/trees/src/utils/fileListToTree.ts index 90362fade..8d73adab6 100644 --- a/packages/trees/src/utils/fileListToTree.ts +++ b/packages/trees/src/utils/fileListToTree.ts @@ -1,7 +1,7 @@ import { FLATTENED_PREFIX } from '../constants'; import type { FileTreeNode } from '../types'; import { createIdMaps } from './createIdMaps'; -import { createLoaderUtils } from './createLoaderUtils'; +import { createLoaderUtils, type LoaderUtils } from './createLoaderUtils'; import { normalizeInputPath } from './normalizeInputPath'; import type { ChildrenSortOption } from './sortChildren'; import { defaultChildrenComparator, sortChildren } from './sortChildren'; @@ -12,37 +12,80 @@ export interface FileListToTreeOptions { sortComparator?: ChildrenSortOption; } +export type FileListToTreeStageName = + | 'buildPathGraph' + | 'buildFlattenedNodes' + | 'buildFolderNodes' + | 'hashTreeKeys'; + +export type FileListToTreeStageTimings = Record< + FileListToTreeStageName, + number +>; + +export interface FileListToTreeBenchmarkResult { + tree: Record; + stageTimingsMs: FileListToTreeStageTimings; +} + +interface FileListToTreeBuildState { + tree: Record; + folderChildren: Map>; +} + +interface FileListToTreeStageContext { + isFolder: (path: string) => boolean; + sortChildrenArray: (children: string[]) => string[]; + utils: LoaderUtils; +} + +type FileListToTreeStageRecorder = ( + stage: FileListToTreeStageName, + elapsedMs: number +) => void; + const ROOT_ID = 'root'; -/** - * Converts a list of file paths into a tree structure suitable for use with FileTree. - * Generates both direct children and flattened children (single-child folder chains). - * - * Time complexity: O(n * d) where n = number of files, d = average path depth - * Space complexity: O(n * d) for storing all nodes and folder relationships - * - * @param filePaths - Array of file path strings (e.g., ['src/index.ts', 'src/utils/helper.ts']) - * @param options - Optional configuration for root node - * @returns A record mapping node IDs (hashed) to FileTreeNode objects - * with the original path stored on each node's `path` field - */ -export function fileListToTree( - filePaths: string[], - options: FileListToTreeOptions = {} -): Record { - const { - rootId = ROOT_ID, - rootName = ROOT_ID, - sortComparator = defaultChildrenComparator, - } = options; +function createStageTimings(): FileListToTreeStageTimings { + return { + buildPathGraph: 0, + buildFlattenedNodes: 0, + buildFolderNodes: 0, + hashTreeKeys: 0, + }; +} - const tree: Record = {}; - const folderChildren: Map> = new Map(); +function timeStage( + stage: FileListToTreeStageName, + recorder: FileListToTreeStageRecorder | undefined, + run: () => T +): T { + if (recorder == null) { + return run(); + } - // Initialize root's children set + const startTime = performance.now(); + const result = run(); + recorder(stage, performance.now() - startTime); + return result; +} + +function createBuildState(rootId: string): FileListToTreeBuildState { + const folderChildren = new Map>(); folderChildren.set(rootId, new Set()); + return { + tree: {}, + folderChildren, + }; +} + +function buildPathGraph( + filePaths: string[], + rootId: string +): FileListToTreeBuildState { + const state = createBuildState(rootId); + const { tree, folderChildren } = state; - // Build the folder structure from file paths for (const filePath of filePaths) { const normalizedPath = normalizeInputPath(filePath); if (normalizedPath == null) continue; @@ -59,7 +102,6 @@ export function fileListToTree( const parentPath = currentPath ?? rootId; currentPath = currentPath != null ? `${currentPath}/${part}` : part; - // Ensure parent has a children set and add current path let parentChildren = folderChildren.get(parentPath); if (parentChildren == null) { parentChildren = new Set(); @@ -68,10 +110,8 @@ export function fileListToTree( parentChildren.add(currentPath); if (isFile) { - // Create file node (no children) tree[currentPath] ??= { name: part, path: currentPath }; } else if (!folderChildren.has(currentPath)) { - // Ensure folder has a children set for tracking folderChildren.set(currentPath, new Set()); } @@ -82,26 +122,36 @@ export function fileListToTree( } } - // Helper to check if a path is a folder - const isFolder = (path: string): boolean => folderChildren.has(path); + return state; +} - // Helper to sort children using the configured comparator +function createStageContext( + folderChildren: Map>, + sortComparator: ChildrenSortOption +): FileListToTreeStageContext { + const isFolder = (path: string): boolean => folderChildren.has(path); const sortChildrenArray = (children: string[]): string[] => sortChildren(children, isFolder, sortComparator); - - // Adapter to make folderChildren work with the shared helper const getChildrenArray = (path: string): string[] => { const children = folderChildren.get(path); return children != null ? [...children] : []; }; - // Create flattening utilities with memoization - const utils = createLoaderUtils(isFolder, getChildrenArray); + return { + isFolder, + sortChildrenArray, + utils: createLoaderUtils(isFolder, getChildrenArray), + }; +} - // Track intermediate folders (those that are part of a flattened chain) +function buildFlattenedNodes( + state: FileListToTreeBuildState, + context: FileListToTreeStageContext +): Set { const intermediateFolders = new Set(); + const { tree, folderChildren } = state; + const { isFolder, sortChildrenArray, utils } = context; - // First pass: identify all intermediate folders and create flattened nodes for (const children of folderChildren.values()) { for (const child of children) { if (!isFolder(child)) continue; @@ -109,16 +159,14 @@ export function fileListToTree( const flattenedEndpoint = utils.getFlattenedEndpoint(child); if (flattenedEndpoint == null) continue; - // Mark all folders in the chain as intermediate (except the endpoint) const flattenedFolders = utils.collectFlattenedFolders( child, flattenedEndpoint ); - for (let i = 0; i < flattenedFolders.length - 1; i++) { - intermediateFolders.add(flattenedFolders[i]); + for (let index = 0; index < flattenedFolders.length - 1; index++) { + intermediateFolders.add(flattenedFolders[index]); } - // Create the flattened node if it doesn't exist const flattenedKey = `${FLATTENED_PREFIX}${flattenedEndpoint}`; if (tree[flattenedKey] != null) continue; @@ -146,46 +194,53 @@ export function fileListToTree( } } - // Second pass: create regular folder nodes + return intermediateFolders; +} + +function buildFolderNodes( + state: FileListToTreeBuildState, + context: FileListToTreeStageContext, + rootId: string, + rootName: string, + intermediateFolders: Set +): void { + const { tree, folderChildren } = state; + const { sortChildrenArray, utils } = context; + for (const [path, children] of folderChildren) { const directChildren = sortChildrenArray([...children]); - const isIntermediate = intermediateFolders.has(path); - - // Only compute flattened children for non-intermediate folders - const flattenedChildren = isIntermediate + const flattenedChildren = intermediateFolders.has(path) ? undefined : utils.buildFlattenedChildren(directChildren); - if (path === rootId) { - tree[rootId] = { - name: rootName, - path: rootId, - children: { - direct: directChildren, - ...(flattenedChildren != null && { flattened: flattenedChildren }), - }, - }; - } else { - const lastSlashIndex = path.lastIndexOf('/'); - const name = lastSlashIndex >= 0 ? path.slice(lastSlashIndex + 1) : path; - tree[path] = { - name, - path, - children: { - direct: directChildren, - ...(flattenedChildren != null && { flattened: flattenedChildren }), - }, - }; - } + const name = + path === rootId + ? rootName + : (() => { + const lastSlashIndex = path.lastIndexOf('/'); + return lastSlashIndex >= 0 ? path.slice(lastSlashIndex + 1) : path; + })(); + + tree[path] = { + name, + path, + children: { + direct: directChildren, + ...(flattenedChildren != null && { flattened: flattenedChildren }), + }, + }; } +} +function hashTreeKeys( + tree: Record, + rootId: string +): Record { const { getIdForKey } = createIdMaps(rootId); const mapKey = (key: string) => getIdForKey(key); const hashedTree: Record = {}; - - // Use a deterministic key order so collision resolution in createIdMaps - // stays stable across different loaders and runtimes. const keys = Object.keys(tree).sort(); + for (const key of keys) { const node = tree[key]; const mappedKey = mapKey(key); @@ -207,3 +262,86 @@ export function fileListToTree( return hashedTree; } + +function fileListToTreeInternal( + filePaths: string[], + options: FileListToTreeOptions, + recorder?: FileListToTreeStageRecorder +): Record { + const { + rootId = ROOT_ID, + rootName = ROOT_ID, + sortComparator = defaultChildrenComparator, + } = options; + + const state = timeStage('buildPathGraph', recorder, () => + buildPathGraph(filePaths, rootId) + ); + let context: FileListToTreeStageContext | undefined; + const intermediateFolders = timeStage('buildFlattenedNodes', recorder, () => { + context = createStageContext(state.folderChildren, sortComparator); + return buildFlattenedNodes(state, context); + }); + + const resolvedContext = context; + if (resolvedContext == null) { + throw new Error('Expected fileListToTree stage context to be initialized'); + } + + timeStage('buildFolderNodes', recorder, () => { + buildFolderNodes( + state, + resolvedContext, + rootId, + rootName, + intermediateFolders + ); + }); + + return timeStage('hashTreeKeys', recorder, () => + hashTreeKeys(state.tree, rootId) + ); +} + +/** + * Converts a list of file paths into a tree structure suitable for use with FileTree. + * Generates both direct children and flattened children (single-child folder chains). + * + * Time complexity: O(n * d) where n = number of files, d = average path depth + * Space complexity: O(n * d) for storing all nodes and folder relationships + * + * @param filePaths - Array of file path strings (e.g., ['src/index.ts', 'src/utils/helper.ts']) + * @param options - Optional configuration for root node + * @returns A record mapping node IDs (hashed) to FileTreeNode objects + * with the original path stored on each node's `path` field + */ +export function fileListToTree( + filePaths: string[], + options: FileListToTreeOptions = {} +): Record { + return fileListToTreeInternal(filePaths, options); +} + +/** + * Runs fileListToTree and captures stage timings for the benchmark CLI. + * This is intentionally kept off the package public surface by remaining an + * internal module export rather than a root export. + */ +export function benchmarkFileListToTreeStages( + filePaths: string[], + options: FileListToTreeOptions = {} +): FileListToTreeBenchmarkResult { + const stageTimingsMs = createStageTimings(); + const tree = fileListToTreeInternal( + filePaths, + options, + (stage, elapsedMs) => { + stageTimingsMs[stage] = elapsedMs; + } + ); + + return { + tree, + stageTimingsMs, + }; +} diff --git a/packages/trees/test/fileListToTree.benchmark.test.ts b/packages/trees/test/fileListToTree.benchmark.test.ts new file mode 100644 index 000000000..f667a464a --- /dev/null +++ b/packages/trees/test/fileListToTree.benchmark.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + describeFileListShape, + filterBenchmarkCases, + getFileListToTreeBenchmarkCases, +} from '../scripts/lib/fileListToTreeBenchmarkData'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); + +describe('fileListToTree benchmark data', () => { + test('covers the intended mix of synthetic shapes and a fixture snapshot', () => { + const cases = getFileListToTreeBenchmarkCases(); + const caseNames = cases.map((caseConfig) => caseConfig.name); + + expect(caseNames).toEqual([ + 'tiny-flat', + 'small-mixed', + 'medium-balanced', + 'large-wide', + 'large-deep-chain', + 'large-monorepo-shaped', + 'explicit-directories', + 'fixture-linux-kernel-files', + 'fixture-pierrejs-repo-snapshot', + ]); + + const byName = new Map( + cases.map((caseConfig) => [caseConfig.name, caseConfig]) + ); + expect(byName.get('tiny-flat')?.fileCount).toBe(128); + expect(byName.get('large-wide')?.fileCount).toBe(8000); + expect(byName.get('large-deep-chain')?.fileCount).toBe(2048); + expect(byName.get('fixture-linux-kernel-files')?.source).toBe('fixture'); + expect(byName.get('fixture-linux-kernel-files')?.fileCount).toBe(92914); + expect(byName.get('fixture-pierrejs-repo-snapshot')?.source).toBe( + 'fixture' + ); + expect(byName.get('fixture-pierrejs-repo-snapshot')?.fileCount).toBe(648); + expect( + (byName.get('large-deep-chain')?.maxDepth ?? 0) > + (byName.get('large-wide')?.maxDepth ?? 0) + ).toBe(true); + }); + + test('describes file-list shape including explicit directories', () => { + expect( + describeFileListShape([ + 'README.md', + 'src/components/', + 'src/components/Button.tsx', + ]) + ).toEqual({ + fileCount: 3, + uniqueFolderCount: 2, + maxDepth: 3, + }); + }); + + test('filters cases by case-insensitive substring', () => { + const cases = getFileListToTreeBenchmarkCases(); + const filtered = filterBenchmarkCases(cases, ['DEEP', 'pierrejs']); + + expect(filtered.map((caseConfig) => caseConfig.name)).toEqual([ + 'large-deep-chain', + 'fixture-pierrejs-repo-snapshot', + ]); + }); +}); + +describe('fileListToTree benchmark CLI', () => { + test('emits JSON for a filtered smoke run', () => { + const result = Bun.spawnSync({ + cmd: [ + 'bun', + 'run', + './scripts/benchmarkFileListToTree.ts', + '--case=tiny-flat', + '--runs=1', + '--warmup-runs=0', + '--json', + ], + cwd: packageRoot, + env: { + ...process.env, + AGENT: '1', + }, + stdout: 'pipe', + stderr: 'pipe', + }); + + const stdout = new TextDecoder().decode(result.stdout).trim(); + const stderr = new TextDecoder().decode(result.stderr).trim(); + + expect(result.exitCode).toBe(0); + expect(stderr).toBe(''); + + const payload = JSON.parse(stdout) as { + benchmark: string; + checksum: number; + config: { + runs: number; + warmupRuns: number; + caseFilters: string[]; + comparePath?: string; + }; + cases: Array<{ + name: string; + fileCount: number; + runs: number; + checksum: number; + }>; + stages: Array<{ + name: string; + stages: Record; + }>; + }; + + expect(payload.benchmark).toBe('fileListToTree'); + expect(payload.config.runs).toBe(1); + expect(payload.config.warmupRuns).toBe(0); + expect(payload.config.caseFilters).toEqual(['tiny-flat']); + expect(payload.checksum).toBeGreaterThan(0); + expect(payload.cases).toHaveLength(1); + expect(payload.cases[0]).toMatchObject({ + name: 'tiny-flat', + fileCount: 128, + runs: 1, + }); + expect(payload.cases[0]?.checksum).toBeGreaterThan(0); + expect(payload.stages).toHaveLength(1); + expect(payload.stages[0]?.name).toBe('tiny-flat'); + expect(payload.stages[0]?.stages.buildPathGraph?.runs).toBe(1); + }); + + test('compares the current run against a saved baseline', () => { + const tempDir = mkdtempSync(join(packageRoot, 'tmp/benchmark-compare-')); + const baselinePath = join(tempDir, 'baseline.json'); + + try { + const baselineResult = Bun.spawnSync({ + cmd: [ + 'bun', + 'run', + './scripts/benchmarkFileListToTree.ts', + '--case=tiny-flat', + '--runs=1', + '--warmup-runs=0', + '--json', + ], + cwd: packageRoot, + env: { + ...process.env, + AGENT: '1', + }, + stdout: 'pipe', + stderr: 'pipe', + }); + + expect(baselineResult.exitCode).toBe(0); + expect(new TextDecoder().decode(baselineResult.stderr).trim()).toBe(''); + writeFileSync(baselinePath, baselineResult.stdout); + + const compareResult = Bun.spawnSync({ + cmd: [ + 'bun', + 'run', + './scripts/benchmarkFileListToTree.ts', + '--case=tiny-flat', + '--runs=1', + '--warmup-runs=0', + '--compare', + baselinePath, + '--json', + ], + cwd: packageRoot, + env: { + ...process.env, + AGENT: '1', + }, + stdout: 'pipe', + stderr: 'pipe', + }); + + const compareStdout = new TextDecoder() + .decode(compareResult.stdout) + .trim(); + const compareStderr = new TextDecoder() + .decode(compareResult.stderr) + .trim(); + + expect(compareResult.exitCode).toBe(0); + expect(compareStderr).toBe(''); + + const payload = JSON.parse(compareStdout) as { + config: { comparePath?: string }; + comparison: { + baselinePath: string; + unmatchedCurrentCases: string[]; + unmatchedBaselineCases: string[]; + checksumMismatches: string[]; + cases: Array<{ + name: string; + checksumMatches: boolean; + baselineChecksum: number; + currentChecksum: number; + }>; + stages: Array<{ + name: string; + stages: Record; + }>; + }; + }; + + expect(payload.config.comparePath).toBe(baselinePath); + expect(payload.comparison.baselinePath).toBe(baselinePath); + expect(payload.comparison.unmatchedCurrentCases).toEqual([]); + expect(payload.comparison.unmatchedBaselineCases).toEqual([]); + expect(payload.comparison.checksumMismatches).toEqual([]); + expect(payload.comparison.cases).toHaveLength(1); + expect(payload.comparison.cases[0]).toMatchObject({ + name: 'tiny-flat', + checksumMatches: true, + }); + expect(payload.comparison.cases[0]?.baselineChecksum).toBeGreaterThan(0); + expect(payload.comparison.cases[0]?.currentChecksum).toBeGreaterThan(0); + expect(payload.comparison.stages).toHaveLength(1); + expect(payload.comparison.stages[0]?.name).toBe('tiny-flat'); + expect( + typeof payload.comparison.stages[0]?.stages.buildPathGraph + ?.medianDeltaMs + ).toBe('number'); + } finally { + rmSync(tempDir, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/trees/tsconfig.json b/packages/trees/tsconfig.json index 417b2df3e..d6b954af1 100644 --- a/packages/trees/tsconfig.json +++ b/packages/trees/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.options.json", "include": [ + "scripts/**/*.ts", "src/**/*.ts", "src/**/*.tsx", "src/**/*.json", From ba5b07e539db1a220049996cf0eebe3502041bb2 Mon Sep 17 00:00:00 2001 From: Alex Sexton Date: Wed, 25 Mar 2026 19:09:19 -0500 Subject: [PATCH 2/2] fix up issues --- .gitattributes | 2 + .../lib/fileListToTreeBenchmarkData.ts | 2 + packages/trees/src/utils/fileListToTree.ts | 63 ++++++++++--------- .../test/fileListToTree.benchmark.test.ts | 7 ++- 4 files changed, 43 insertions(+), 31 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..ab44bd5d2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Mark large generated fixtures so they collapse in PR diffs +packages/trees/scripts/fixtures/fileListToTree-monorepo-snapshot.txt linguist-generated diff --git a/packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts b/packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts index 99f00260e..c8e1dbfbd 100644 --- a/packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts +++ b/packages/trees/scripts/lib/fileListToTreeBenchmarkData.ts @@ -22,6 +22,8 @@ const BENCHMARK_FIXTURE_PATH = resolve( import.meta.dir, '../fixtures/fileListToTree-monorepo-snapshot.txt' ); +// Cross-package dependency: this fixture lives in apps/docs because the dev +// page also renders it. If that file moves, this path must be updated. const LINUX_KERNEL_FIXTURE_PATH = resolve( import.meta.dir, '../../../../apps/docs/app/trees-dev/linux-files.json' diff --git a/packages/trees/src/utils/fileListToTree.ts b/packages/trees/src/utils/fileListToTree.ts index 8d73adab6..be8b6026f 100644 --- a/packages/trees/src/utils/fileListToTree.ts +++ b/packages/trees/src/utils/fileListToTree.ts @@ -18,12 +18,9 @@ export type FileListToTreeStageName = | 'buildFolderNodes' | 'hashTreeKeys'; -export type FileListToTreeStageTimings = Record< - FileListToTreeStageName, - number ->; +type FileListToTreeStageTimings = Record; -export interface FileListToTreeBenchmarkResult { +interface FileListToTreeBenchmarkResult { tree: Record; stageTimingsMs: FileListToTreeStageTimings; } @@ -79,6 +76,10 @@ function createBuildState(rootId: string): FileListToTreeBuildState { }; } +/** + * Walks every file path segment-by-segment, creating file nodes and tracking + * parent-to-child folder relationships in a Map of Sets. + */ function buildPathGraph( filePaths: string[], rootId: string @@ -144,6 +145,12 @@ function createStageContext( }; } +/** + * Identifies single-child folder chains and creates flattened nodes that + * collapse them into one entry (e.g. "src/utils" instead of "src" > "utils"). + * Returns the set of intermediate folders consumed by flattening so + * buildFolderNodes can skip them. + */ function buildFlattenedNodes( state: FileListToTreeBuildState, context: FileListToTreeStageContext @@ -197,6 +204,11 @@ function buildFlattenedNodes( return intermediateFolders; } +/** + * Creates a FileTreeNode for every folder (including root), attaching sorted + * direct children and optional flattened children. Intermediate folders that + * were absorbed into a flattened node get their flattened children omitted. + */ function buildFolderNodes( state: FileListToTreeBuildState, context: FileListToTreeStageContext, @@ -213,13 +225,13 @@ function buildFolderNodes( ? undefined : utils.buildFlattenedChildren(directChildren); - const name = - path === rootId - ? rootName - : (() => { - const lastSlashIndex = path.lastIndexOf('/'); - return lastSlashIndex >= 0 ? path.slice(lastSlashIndex + 1) : path; - })(); + let name: string; + if (path === rootId) { + name = rootName; + } else { + const lastSlashIndex = path.lastIndexOf('/'); + name = lastSlashIndex >= 0 ? path.slice(lastSlashIndex + 1) : path; + } tree[path] = { name, @@ -232,6 +244,11 @@ function buildFolderNodes( } } +/** + * Replaces human-readable path keys with deterministic hashed IDs and remaps + * all children/flattens references to use the same hashed IDs. Keys are sorted + * before hashing so collision resolution stays stable across runtimes. + */ function hashTreeKeys( tree: Record, rootId: string @@ -277,25 +294,13 @@ function fileListToTreeInternal( const state = timeStage('buildPathGraph', recorder, () => buildPathGraph(filePaths, rootId) ); - let context: FileListToTreeStageContext | undefined; - const intermediateFolders = timeStage('buildFlattenedNodes', recorder, () => { - context = createStageContext(state.folderChildren, sortComparator); - return buildFlattenedNodes(state, context); - }); - - const resolvedContext = context; - if (resolvedContext == null) { - throw new Error('Expected fileListToTree stage context to be initialized'); - } + const context = createStageContext(state.folderChildren, sortComparator); + const intermediateFolders = timeStage('buildFlattenedNodes', recorder, () => + buildFlattenedNodes(state, context) + ); timeStage('buildFolderNodes', recorder, () => { - buildFolderNodes( - state, - resolvedContext, - rootId, - rootName, - intermediateFolders - ); + buildFolderNodes(state, context, rootId, rootName, intermediateFolders); }); return timeStage('hashTreeKeys', recorder, () => diff --git a/packages/trees/test/fileListToTree.benchmark.test.ts b/packages/trees/test/fileListToTree.benchmark.test.ts index f667a464a..57426656d 100644 --- a/packages/trees/test/fileListToTree.benchmark.test.ts +++ b/packages/trees/test/fileListToTree.benchmark.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -71,7 +72,9 @@ describe('fileListToTree benchmark data', () => { }); }); -describe('fileListToTree benchmark CLI', () => { +// These tests spawn benchmark subprocesses and are meant for local iteration, +// not CI. Run them explicitly with: bun test --test-name-pattern "benchmark CLI" +describe.skip('fileListToTree benchmark CLI', () => { test('emits JSON for a filtered smoke run', () => { const result = Bun.spawnSync({ cmd: [ @@ -137,7 +140,7 @@ describe('fileListToTree benchmark CLI', () => { }); test('compares the current run against a saved baseline', () => { - const tempDir = mkdtempSync(join(packageRoot, 'tmp/benchmark-compare-')); + const tempDir = mkdtempSync(join(tmpdir(), 'benchmark-compare-')); const baselinePath = join(tempDir, 'baseline.json'); try {