Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ee53b79
feat(lab): parseUnifiedDiff reads a patch into the DiffViewer file model
latxh Oct 15, 2026
939219d
feat(lab): DiffViewer — unified/split file diff for code review
latxh Oct 15, 2026
06b36a6
feat(lab): createDiffFile builds a DiffViewer file from two versions …
latxh Oct 15, 2026
6d0aab5
feat(sandbox): add a DiffViewer performance page for large change sets
cixzhang Oct 15, 2026
ccc71ce
chore(sandbox): register the DiffViewer performance route in the stat…
cixzhang Oct 15, 2026
c12a36a
Merge main into feat/lab-diff-parse
cixzhang Oct 15, 2026
9186983
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
1de5c35
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
61a4982
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
6de4c2d
fix(lab): parseUnifiedDiff marks a file renamed only from git rename …
cixzhang Oct 15, 2026
844c4df
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
ece4b0e
fix(lab): DiffViewer draws no annotation row when renderLineAnnotatio…
cixzhang Oct 15, 2026
213b77c
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
b813199
fix(lab): createDiffFile appends its edit script in a loop, and NaN c…
cixzhang Oct 15, 2026
05f4c88
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
ad6518d
fix(sandbox): DiffViewer perf page measures after the paint, keeps ea…
cixzhang Oct 15, 2026
2b96e01
fix(lab): parseUnifiedDiff reads a headerless diff -r binary line as …
cixzhang Oct 15, 2026
8cae7e3
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
f959076
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
2ef2635
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
41053c1
fix(sandbox): DiffViewer perf page labels each scroll and interaction…
cixzhang Oct 15, 2026
c6e8350
fix(lab): parseUnifiedDiff checks a line's shape before the binary-li…
cixzhang Oct 15, 2026
2e675ec
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
2dac4e3
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
a3e9f5a
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
111b8fc
fix(lab): parseUnifiedDiff reads binary lines with plain string matching
cixzhang Oct 15, 2026
3f5135b
Merge feat/lab-diff-parse into feat/lab-diff-viewer
cixzhang Oct 15, 2026
bad8194
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
1ba5e90
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
0d9a9f5
fix(sandbox): DiffViewer perf page badge counts diff lines, not rows
cixzhang Oct 15, 2026
b5a4121
Merge main into feat/lab-diff-viewer
cixzhang Oct 15, 2026
5fb1e64
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
1c98176
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
b558d0a
fix(lab): DiffViewer's hunk area reads left-to-right on right-to-left…
cixzhang Oct 15, 2026
7e28a06
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
cixzhang Oct 15, 2026
1b3ab3f
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
d37b688
Merge main into feat/lab-diff-create-file
cixzhang Oct 15, 2026
df5d3cf
Merge feat/lab-diff-create-file into feat/lab-diff-viewer-perf
cixzhang Oct 15, 2026
08e4f4b
fix(sandbox): list the DiffViewer Perf page beside the other perf pages
cixzhang Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/diff-viewer-catalog-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] Adds the `@astryx.diffViewer.*` keys to the `en` catalog so the lab `DiffViewer` reads its file-status badges, line counts, line-number button labels, per-line "Added"/"Removed" labels, and empty-state messages from the translation runtime. No existing string changes; locales without a translation fall back to English.

@latxh
1 change: 1 addition & 0 deletions apps/sandbox/scripts/next-export-routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"/pages/color-studio/",
"/pages/component-scores/",
"/pages/dictation-lab/",
"/pages/diffviewer-perf/",
"/pages/doc-discover/",
"/pages/doc-docs/",
"/pages/doc-home/",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import {describe, expect, it} from 'vitest';
import {
countLines,
createMultiFileWorkload,
createSingleFileWorkload,
formatMegabytes,
MULTI_FILE_WORKLOADS,
} from './benchmark';

describe('DiffViewer performance workloads', () => {
it('builds the same multi-file workload on every call', () => {
const first = createMultiFileWorkload(50, 10_000);
const second = createMultiFileWorkload(50, 10_000);
expect(second).toEqual(first);
expect(first.files).toHaveLength(50);
expect(new Set(first.files.map(file => file.path)).size).toBe(50);
});

it.each(MULTI_FILE_WORKLOADS)(
'hits about $totalLines lines across $files files, with a realistic mix',
({files, totalLines}) => {
const workload = createMultiFileWorkload(files, totalLines);
expect(workload.files).toHaveLength(files);
expect(workload.totalLines).toBe(countLines(workload.files));
expect(
Math.abs(workload.totalLines - totalLines) / totalLines,
).toBeLessThan(0.05);
const sizes = workload.files
.map(
file =>
(file.newContents ?? file.oldContents ?? '').split('\n').length,
)
.sort((a, b) => a - b);
// Mostly small files, with a few far larger than the median.
expect(sizes[Math.floor(files / 2)]).toBeLessThan(400);
expect(sizes[files - 1]).toBeGreaterThan(
sizes[Math.floor(files / 2)] * 10,
);
expect(workload.files.some(file => file.oldContents == null)).toBe(true);
expect(workload.files.some(file => file.newContents == null)).toBe(true);
expect(
workload.files.filter(
file =>
file.oldContents != null &&
file.newContents != null &&
file.oldContents !== file.newContents,
).length,
).toBeGreaterThan(files * 0.8);
},
);

it('builds one file of the requested size, sparse or rewritten', () => {
const sparse = createSingleFileWorkload(1000, 'sparse');
const rewrite = createSingleFileWorkload(1000, 'rewrite');
expect(sparse.files).toHaveLength(1);
expect(sparse.files[0].oldContents?.split('\n')).toHaveLength(1000);
const oldLines = new Set(rewrite.files[0].oldContents?.split('\n'));
const sharedNonBlank = (rewrite.files[0].newContents ?? '')
.split('\n')
.filter(line => line.trim() !== '' && oldLines.has(line));
expect(sharedNonBlank).toEqual([]);
const sparseOld = new Set(sparse.files[0].oldContents?.split('\n'));
const kept = (sparse.files[0].newContents ?? '')
.split('\n')
.filter(line => sparseOld.has(line)).length;
expect(kept / 1000).toBeGreaterThan(0.9);
});

it('formats heap readings', () => {
expect(formatMegabytes(null)).toBe('—');
expect(formatMegabytes(3 * 1_048_576)).toBe('3.0 MB');
});
});
271 changes: 271 additions & 0 deletions apps/sandbox/src/app/(fullscreen)/pages/diffviewer-perf/benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file benchmark.ts
* @input A workload id: a multi-file change set or one file of a given size
* @output Deterministic old/new file contents, their totals, and small
* formatting helpers for the DiffViewer performance sandbox
* @position Pure support utilities for the DiffViewer performance sandbox;
* no React and no DOM, so any harness can render the same fixtures
*
* Multi-file workloads mirror a real large change: many small files, some
* medium ones, and a few very large generated or vendored files, with sparse
* edits, a handful of new and deleted files, and a few full rewrites. Every
* value comes from a seeded generator, so a workload is byte-identical on
* every run and in every harness.
*/

export interface DiffBenchmarkFile {
readonly path: string;
/** Contents before the change; `null` for an added file. */
readonly oldContents: string | null;
/** Contents after the change; `null` for a deleted file. */
readonly newContents: string | null;
}

export interface DiffBenchmarkWorkload {
readonly id: string;
readonly label: string;
readonly files: ReadonlyArray<DiffBenchmarkFile>;
/** Lines across every file's newer side (the older side for a deleted file). */
readonly totalLines: number;
}

/** Multi-file workloads: file count and the total lines they target. */
export const MULTI_FILE_WORKLOADS = [
{files: 50, totalLines: 10_000},
{files: 200, totalLines: 50_000},
{files: 500, totalLines: 100_000},
{files: 1000, totalLines: 150_000},
] as const;

/** Single-file workloads: line counts, each with sparse edits or a rewrite. */
export const SINGLE_FILE_LINES = [100, 1000, 5000, 20_000] as const;

export type SingleFileChange = 'sparse' | 'rewrite';

/** Mulberry32: a small, fast, seeded generator; same seed, same sequence. */
function createRandom(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

function between(random: () => number, min: number, max: number): number {
return min + Math.floor(random() * (max - min + 1));
}

/**
* One line of TypeScript-like source, varied by its file and position. A
* variant changes every non-blank line, so two variants share no code.
*/
function sourceLine(fileIndex: number, line: number, variant = ''): string {
const base = baseSourceLine(fileIndex, line, variant);
return variant === '' || base === '' ? base : `${base} // ${variant}`;
}

function baseSourceLine(
fileIndex: number,
line: number,
variant: string,
): string {
const id = `${fileIndex}_${line}${variant}`;
switch (line % 12) {
case 0:
return `export interface Record${id} {`;
case 1:
return ` id: string; // ${variant || 'stable'} field`;
case 2:
return ` value: number;`;
case 3:
return `}`;
case 4:
return '';
case 5:
return `export async function load${id}(url: string): Promise<number> {`;
case 6:
return ` const response = await fetch(url, {cache: 'no-store'});`;
case 7:
return ` if (!response.ok) throw new Error('HTTP ' + response.status);`;
case 8:
return ` return (await response.json()).count ?? ${line}; // ${id}`;
case 9:
return `}`;
case 10:
return '';
default:
return `// Section ${Math.floor(line / 12) + 1} of file ${fileIndex}`;
}
}

function fileLines(fileIndex: number, count: number, variant = ''): string[] {
return Array.from({length: count}, (_, line) =>
sourceLine(fileIndex, line, variant),
);
}

/**
* Edit roughly one site per `spacing` lines: each site replaces one to three
* lines and sometimes inserts one or two more.
*/
function applySparseEdits(
lines: ReadonlyArray<string>,
random: () => number,
spacing: number,
): string[] {
const next: string[] = [];
let line = 0;
let isFirst = true;
while (line < lines.length) {
const gap = between(random, Math.floor(spacing / 2), spacing * 2);
// A short file still gets one edit: its first site lands inside it.
const reach = isFirst ? Math.min(gap, Math.floor(lines.length / 2)) : gap;
isFirst = false;
next.push(...lines.slice(line, line + reach));
line += reach;
if (line >= lines.length) {
break;
}
const replaced = between(random, 1, 3);
for (let i = 0; i < replaced && line < lines.length; i++, line++) {
next.push(`${lines[line]} // updated`);
}
const inserted = between(random, 0, 2);
for (let i = 0; i < inserted; i++) {
next.push(` void track('edit-${line}-${i}');`);
}
}
return next;
}

const DIRECTORIES = [
'src/components',
'src/hooks',
'src/utils',
'src/server/routes',
'src/server/models',
'test/unit',
'test/integration',
'docs/guides',
'generated/schema',
'vendor/parser',
];

/** File sizes in three tiers, scaled so their sum meets the target. */
function fileSizes(
random: () => number,
files: number,
totalLines: number,
): number[] {
const raw = Array.from({length: files}, () => {
const tier = random();
if (tier < 0.85) {
return between(random, 30, 160);
}
if (tier < 0.97) {
return between(random, 300, 1500);
}
return between(random, 3000, 12_000);
});
const sum = raw.reduce((total, size) => total + size, 0);
const scale = totalLines / sum;
return raw.map(size => Math.max(12, Math.round(size * scale)));
}

/**
* A change set of `files` files whose sides total about `totalLines` lines.
* About 5% of the files are new, 2% deleted and 2% rewritten in full; the
* rest carry sparse edits.
*/
export function createMultiFileWorkload(
files: number,
totalLines: number,
): DiffBenchmarkWorkload {
const random = createRandom(files * 7919 + totalLines);
const sizes = fileSizes(random, files, totalLines);
const result: DiffBenchmarkFile[] = sizes.map((size, index) => {
const directory = DIRECTORIES[index % DIRECTORIES.length];
const path = `${directory}/module_${String(index).padStart(4, '0')}.ts`;
const original = fileLines(index, size);
const kind = random();
if (kind < 0.05) {
return {path, oldContents: null, newContents: original.join('\n')};
}
if (kind < 0.07) {
return {path, oldContents: original.join('\n'), newContents: null};
}
if (kind < 0.09) {
return {
path,
oldContents: original.join('\n'),
newContents: fileLines(index, size, 'v2').join('\n'),
};
}
return {
path,
oldContents: original.join('\n'),
newContents: applySparseEdits(original, random, 40).join('\n'),
};
});
return {
id: `files-${files}`,
label: `${files.toLocaleString()} files`,
files: result,
totalLines: countLines(result),
};
}

/** One file of `lines` lines with sparse edits or rewritten in full. */
export function createSingleFileWorkload(
lines: number,
change: SingleFileChange,
): DiffBenchmarkWorkload {
const random = createRandom(lines * 31 + (change === 'rewrite' ? 1 : 0));
const original = fileLines(0, lines);
const next =
change === 'rewrite'
? fileLines(0, lines, 'v2')
: applySparseEdits(original, random, 40);
const files = [
{
path: 'src/large_module.ts',
oldContents: original.join('\n'),
newContents: next.join('\n'),
},
];
return {
id: `single-${lines}-${change}`,
label: `${lines.toLocaleString()} lines, ${change === 'rewrite' ? 'full rewrite' : 'sparse edits'}`,
files,
totalLines: countLines(files),
};
}

function lineCount(contents: string | null): number {
return contents == null || contents === '' ? 0 : contents.split('\n').length;
}

export function countLines(files: ReadonlyArray<DiffBenchmarkFile>): number {
return files.reduce(
(total, file) =>
total + lineCount(file.newContents ?? file.oldContents ?? null),
0,
);
}

export function formatDuration(value: number | null): string {
return value == null ? '—' : `${value.toFixed(1)} ms`;
}

export function formatCount(value: number | null): string {
return value == null ? '—' : value.toLocaleString();
}

export function formatMegabytes(bytes: number | null): string {
return bytes == null ? '—' : `${(bytes / 1_048_576).toFixed(1)} MB`;
}
Loading