diff --git a/apps/storybook/stories/DiffViewer.stories.tsx b/apps/storybook/stories/DiffViewer.stories.tsx
index 9cfbd9539baeb..4d307352c958b 100644
--- a/apps/storybook/stories/DiffViewer.stories.tsx
+++ b/apps/storybook/stories/DiffViewer.stories.tsx
@@ -4,6 +4,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import {useState} from 'react';
import {
DiffViewer,
+ createDiffFile,
parseUnifiedDiff,
type DiffViewerLineRef,
} from '@astryxdesign/lab';
@@ -169,6 +170,32 @@ export const MultiFilePatch: Story = {
),
};
+/** Diff two versions of a text with createDiffFile. */
+export const FromTwoTexts: Story = {
+ args: {
+ file: createDiffFile({
+ path: 'config/flags.yaml',
+ oldValue: [
+ 'flags:',
+ ' checkout_v2: false',
+ ' dark_mode: true',
+ ' search_suggestions: false',
+ ' # owner: payments',
+ ' retries: 3',
+ ].join('\n'),
+ newValue: [
+ 'flags:',
+ ' checkout_v2: true',
+ ' dark_mode: true',
+ ' search_suggestions: false',
+ ' # owner: payments',
+ ' retries: 5',
+ ' timeout_ms: 2000',
+ ].join('\n'),
+ }),
+ },
+};
+
/** File header actions: a "Viewed" checkbox that also folds the file away. */
export const ViewedToggle: Story = {
render: args => {
diff --git a/packages/lab/src/DiffViewer/DiffViewer.doc.mjs b/packages/lab/src/DiffViewer/DiffViewer.doc.mjs
index 4d4062a158945..3a9096a910589 100644
--- a/packages/lab/src/DiffViewer/DiffViewer.doc.mjs
+++ b/packages/lab/src/DiffViewer/DiffViewer.doc.mjs
@@ -28,7 +28,7 @@ export const docs = {
usage: {
description:
- 'Read-only, syntax-highlighted diff of one file for code review surfaces. Renders a file header (path, Added/Deleted/Renamed token, +/− line counts, a headerEnd slot, and an optional disclosure) over the file\'s hunks in unified or split (side-by-side) view. Rows use the same syntax-theme diff slots as CodeBlock with isDiff, so one syntax theme styles both. renderLineAnnotation adds a full-width row under any line (for review comments) and onLineNumberClick turns line numbers into buttons. Build the file model with parseUnifiedDiff(patch), which returns one file per file in a git or diff -u patch. Each changed line carries a visually hidden "Added"/"Removed" label, so the change reads without colour. In right-to-left pages the hunk area stays left-to-right, like a code editor: annotation content reads in the page direction, and annotation rows scroll with the code. Rows are plain DOM with no virtualization: collapse or window very large files.',
+ 'Read-only, syntax-highlighted diff of one file for code review surfaces. Renders a file header (path, Added/Deleted/Renamed token, +/− line counts, a headerEnd slot, and an optional disclosure) over the file\'s hunks in unified or split (side-by-side) view. Rows use the same syntax-theme diff slots as CodeBlock with isDiff, so one syntax theme styles both. renderLineAnnotation adds a full-width row under any line (for review comments) and onLineNumberClick turns line numbers into buttons. Build the file model with parseUnifiedDiff(patch), which returns one file per file in a git or diff -u patch, or with createDiffFile({oldValue, newValue}) when you hold two versions of a text. Each changed line carries a visually hidden "Added"/"Removed" label, so the change reads without colour. In right-to-left pages the hunk area stays left-to-right, like a code editor: annotation content reads in the page direction, and annotation rows scroll with the code. Rows are plain DOM with no virtualization: collapse or window very large files.',
bestPractices: [
{
guidance: true,
@@ -64,6 +64,12 @@ export const docs = {
label: 'Split view',
code: '',
},
+ {
+ label: 'Diff two versions of a text',
+ code: `const file = createDiffFile({oldValue: before, newValue: after, path: 'src/app.ts'});
+
+`,
+ },
{
label: 'Review comments',
code: ` {
ref?: Ref;
/**
* The file to render. Build one with `parseUnifiedDiff(patch)` (one per
- * file in the patch).
+ * file in the patch) or `createDiffFile({oldValue, newValue})`.
*/
file: DiffViewerFile;
/**
diff --git a/packages/lab/src/DiffViewer/createDiffFile.test.ts b/packages/lab/src/DiffViewer/createDiffFile.test.ts
new file mode 100644
index 0000000000000..d715c924dc1bc
--- /dev/null
+++ b/packages/lab/src/DiffViewer/createDiffFile.test.ts
@@ -0,0 +1,180 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+import {describe, it, expect} from 'vitest';
+import {createDiffFile} from './createDiffFile';
+import type {DiffViewerFile} from './diffTypes';
+
+/** Rebuild both sides from the hunks of a full-context diff. */
+function sides(file: DiffViewerFile) {
+ const oldSide: string[] = [];
+ const newSide: string[] = [];
+ for (const hunk of file.hunks) {
+ for (const line of hunk.lines) {
+ if (line.kind !== 'added') {
+ oldSide.push(line.content);
+ }
+ if (line.kind !== 'removed') {
+ newSide.push(line.content);
+ }
+ }
+ }
+ return {oldSide, newSide};
+}
+
+describe('createDiffFile', () => {
+ it('returns no hunks for identical text', () => {
+ const file = createDiffFile({oldValue: 'a\nb\n', newValue: 'a\nb\n'});
+ expect(file).toMatchObject({status: 'modified', hunks: []});
+ });
+
+ it('finds a minimal change with numbered lines and a header', () => {
+ const file = createDiffFile({
+ oldValue: 'a\nb\nc\n',
+ newValue: 'a\nB\nc\n',
+ path: 'x.txt',
+ });
+ expect(file.oldPath).toBe('x.txt');
+ expect(file.newPath).toBe('x.txt');
+ expect(file.hunks).toHaveLength(1);
+ const [hunk] = file.hunks;
+ expect(hunk.header).toBe('@@ -1,3 +1,3 @@');
+ expect(
+ hunk.lines.map(l => [
+ l.kind,
+ l.content,
+ l.oldLineNumber,
+ l.newLineNumber,
+ ]),
+ ).toEqual([
+ ['context', 'a', 1, 1],
+ ['removed', 'b', 2, null],
+ ['added', 'B', null, 2],
+ ['context', 'c', 3, 3],
+ ]);
+ });
+
+ it('round-trips both texts with enough context', () => {
+ const oldValue = 'one\ntwo\nthree\nfour\nfive\nsix\n';
+ const newValue = 'zero\none\nthree\nfour\n4.5\nfive\nsix\nseven\n';
+ const file = createDiffFile({oldValue, newValue, contextLines: 100});
+ const {oldSide, newSide} = sides(file);
+ expect(oldSide).toEqual(oldValue.trimEnd().split('\n'));
+ expect(newSide).toEqual(newValue.trimEnd().split('\n'));
+ });
+
+ it('splits distant changes into separate hunks', () => {
+ const base = Array.from({length: 30}, (_, i) => `line ${i + 1}`);
+ const next = [...base];
+ next[1] = 'changed 2';
+ next[25] = 'changed 26';
+ const file = createDiffFile({
+ oldValue: base.join('\n'),
+ newValue: next.join('\n'),
+ contextLines: 2,
+ });
+ expect(file.hunks.map(h => h.header)).toEqual([
+ '@@ -1,4 +1,4 @@',
+ '@@ -24,5 +24,5 @@',
+ ]);
+ });
+
+ it('marks added and deleted files from a null path', () => {
+ const added = createDiffFile({
+ oldValue: '',
+ newValue: 'a\nb\n',
+ oldPath: null,
+ newPath: 'new.txt',
+ });
+ expect(added).toMatchObject({status: 'added', oldPath: null});
+ expect(added.hunks[0].header).toBe('@@ -0,0 +1,2 @@');
+
+ const deleted = createDiffFile({
+ oldValue: 'a\n',
+ newValue: '',
+ oldPath: 'old.txt',
+ newPath: null,
+ });
+ expect(deleted).toMatchObject({status: 'deleted', newPath: null});
+ expect(deleted.hunks[0].header).toBe('@@ -1 +0,0 @@');
+ });
+
+ it('keeps a minimal diff when changes sit between long shared runs', () => {
+ const base = Array.from({length: 3000}, (_, i) => `line ${i + 1}`);
+ const next = [...base];
+ next[999] = 'changed 1000';
+ next.splice(2000, 1);
+ const file = createDiffFile({
+ oldValue: base.join('\n'),
+ newValue: next.join('\n'),
+ contextLines: 0,
+ });
+ expect(file.hunks.map(h => h.header)).toEqual([
+ '@@ -1000 +1000 @@',
+ '@@ -2001 +2000,0 @@',
+ ]);
+ });
+
+ it('shows a rewrite too large to search as one replaced block', () => {
+ // Fully different 6000-line texts: past the search cap, the changed middle
+ // is every old line removed, then every new line added.
+ const lineCount = 6000;
+ const oldLines = Array.from({length: lineCount}, (_, i) => `old ${i}`);
+ const newLines = Array.from({length: lineCount}, (_, i) => `new ${i}`);
+ const file = createDiffFile({
+ oldValue: ['head', ...oldLines, 'tail'].join('\n'),
+ newValue: ['head', ...newLines, 'tail'].join('\n'),
+ contextLines: 1,
+ });
+ expect(file.hunks).toHaveLength(1);
+ const [hunk] = file.hunks;
+ expect(hunk.header).toBe(`@@ -1,${lineCount + 2} +1,${lineCount + 2} @@`);
+ const kinds = hunk.lines.map(line => line.kind);
+ expect(kinds.filter(kind => kind === 'removed')).toHaveLength(lineCount);
+ expect(kinds.filter(kind => kind === 'added')).toHaveLength(lineCount);
+ // Shared lines are still context, and every removal precedes the additions.
+ expect(kinds[0]).toBe('context');
+ expect(kinds[kinds.length - 1]).toBe('context');
+ expect(kinds.indexOf('added')).toBe(lineCount + 1);
+ const {oldSide, newSide} = sides(file);
+ expect(oldSide).toEqual(['head', ...oldLines, 'tail']);
+ expect(newSide).toEqual(['head', ...newLines, 'tail']);
+ });
+
+ it('handles a very long text changed only at both ends', () => {
+ // Every line in between is matched by the search, so the edit script
+ // holds one op per line: more than a call can take as spread arguments.
+ const lineCount = 150_000;
+ const base = Array.from({length: lineCount}, (_, i) => `line ${i}`);
+ const next = [...base];
+ next[0] = 'first changed';
+ next[lineCount - 1] = 'last changed';
+ const file = createDiffFile({
+ oldValue: base.join('\n'),
+ newValue: next.join('\n'),
+ contextLines: 1,
+ });
+ expect(file.hunks.map(h => h.header)).toEqual([
+ '@@ -1,2 +1,2 @@',
+ `@@ -${lineCount - 1},2 +${lineCount - 1},2 @@`,
+ ]);
+ });
+
+ it('uses the default context when contextLines is NaN', () => {
+ const file = createDiffFile({
+ oldValue: 'a\nb\nc',
+ newValue: 'a\nB\nc',
+ contextLines: NaN,
+ });
+ expect(file.hunks.map(h => h.header)).toEqual(['@@ -1,3 +1,3 @@']);
+ });
+
+ it('marks a rename when the paths differ', () => {
+ const file = createDiffFile({
+ oldValue: 'a',
+ newValue: 'a',
+ oldPath: 'a.ts',
+ newPath: 'b.ts',
+ });
+ expect(file.status).toBe('renamed');
+ });
+});
diff --git a/packages/lab/src/DiffViewer/createDiffFile.ts b/packages/lab/src/DiffViewer/createDiffFile.ts
new file mode 100644
index 0000000000000..d92d42a8bb52f
--- /dev/null
+++ b/packages/lab/src/DiffViewer/createDiffFile.ts
@@ -0,0 +1,316 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+/**
+ * @file createDiffFile.ts
+ * @input Two versions of a text (old and new) plus optional paths
+ * @output Exports createDiffFile(options) → DiffViewerFile and
+ * CreateDiffFileOptions
+ * @position Lab utility; computes a line diff (Myers) when the caller has two
+ * texts rather than a patch
+ *
+ * Shared leading and trailing lines are matched without a search. The minimal
+ * search over the rest stops after MAX_EDIT_DISTANCE changed lines, so memory
+ * stays bounded for any input; a middle that differs more than that is shown
+ * as one replaced block rather than a minimal diff.
+ *
+ * SYNC: When modified, update:
+ * - /packages/lab/src/DiffViewer/createDiffFile.test.ts
+ * - /packages/lab/src/DiffViewer/DiffViewer.doc.mjs (utilities section)
+ */
+
+import type {
+ DiffViewerFile,
+ DiffViewerFileStatus,
+ DiffViewerHunk,
+ DiffViewerLine,
+} from './diffTypes';
+
+export interface CreateDiffFileOptions {
+ /** The original text. */
+ oldValue: string;
+ /** The changed text. */
+ newValue: string;
+ /**
+ * Path of the file on both sides. `oldPath` / `newPath` take precedence
+ * when they are given.
+ */
+ path?: string;
+ /** Path before the change; pass `null` to mark the file as added. */
+ oldPath?: string | null;
+ /** Path after the change; pass `null` to mark the file as deleted. */
+ newPath?: string | null;
+ /**
+ * Unchanged lines kept around each change. Changes closer together than
+ * twice this share one hunk. `NaN` falls back to the default.
+ * @default 3
+ */
+ contextLines?: number;
+}
+
+type Op =
+ | {kind: 'context'; a: number; b: number}
+ | {kind: 'removed'; a: number}
+ | {kind: 'added'; b: number};
+
+function splitLines(value: string): string[] {
+ if (value === '') {
+ return [];
+ }
+ const lines = value.split('\n').map(line => line.replace(/\r$/, ''));
+ if (lines[lines.length - 1] === '') {
+ lines.pop();
+ }
+ return lines;
+}
+
+/**
+ * Most changed lines the minimal search explores before giving up. The trace
+ * it keeps grows with the square of this, so the cap bounds memory to a few
+ * megabytes; past it the changed middle is shown as one replaced block.
+ */
+const MAX_EDIT_DISTANCE = 2000;
+
+/**
+ * Line edit script from `a` to `b`. Shared leading and trailing lines are
+ * context without a search; the middle uses Myers' O(ND) shortest edit script,
+ * or, when it needs more than MAX_EDIT_DISTANCE edits, removes the old middle
+ * and adds the new one.
+ */
+function diffLines(a: string[], b: string[]): Op[] {
+ let head = 0;
+ while (head < a.length && head < b.length && a[head] === b[head]) {
+ head++;
+ }
+ let tailA = a.length;
+ let tailB = b.length;
+ while (tailA > head && tailB > head && a[tailA - 1] === b[tailB - 1]) {
+ tailA--;
+ tailB--;
+ }
+
+ const ops: Op[] = [];
+ for (let i = 0; i < head; i++) {
+ ops.push({kind: 'context', a: i, b: i});
+ }
+ const middle = myers(a, b, head, tailA, tailB);
+ if (middle != null) {
+ // A loop, not a spread: one call argument per op overflows the stack on
+ // long inputs.
+ for (const op of middle) {
+ ops.push(op);
+ }
+ } else {
+ for (let i = head; i < tailA; i++) {
+ ops.push({kind: 'removed', a: i});
+ }
+ for (let j = head; j < tailB; j++) {
+ ops.push({kind: 'added', b: j});
+ }
+ }
+ for (let i = 0; tailA + i < a.length; i++) {
+ ops.push({kind: 'context', a: tailA + i, b: tailB + i});
+ }
+ return ops;
+}
+
+/**
+ * Myers' shortest edit script over `a[start, endA)` and `b[start, endB)`,
+ * with absolute indexes in the ops; null when it needs more than
+ * MAX_EDIT_DISTANCE edits.
+ */
+function myers(
+ a: string[],
+ b: string[],
+ start: number,
+ endA: number,
+ endB: number,
+): Op[] | null {
+ const n = endA - start;
+ const m = endB - start;
+ const max = n + m;
+ const offset = max + 1;
+ const v = new Int32Array(2 * max + 3);
+ // trace[d] holds diagonals -d..d of v as it was before step d.
+ const trace: Int32Array[] = [];
+
+ outer: for (let d = 0; d <= max; d++) {
+ if (d > MAX_EDIT_DISTANCE) {
+ return null;
+ }
+ trace.push(v.slice(offset - d, offset + d + 1));
+ for (let k = -d; k <= d; k += 2) {
+ let x =
+ k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])
+ ? v[offset + k + 1]
+ : v[offset + k - 1] + 1;
+ let y = x - k;
+ while (x < n && y < m && a[start + x] === b[start + y]) {
+ x++;
+ y++;
+ }
+ v[offset + k] = x;
+ if (x >= n && y >= m) {
+ break outer;
+ }
+ }
+ }
+
+ const ops: Op[] = [];
+ let x = n;
+ let y = m;
+ for (let d = trace.length - 1; d >= 0; d--) {
+ const vd = trace[d];
+ // Outside the stored window (only k = 1 at d = 0) v was still zero.
+ const at = (k: number) => vd[k + d] ?? 0;
+ const k = x - y;
+ const prevK =
+ k === -d || (k !== d && at(k - 1) < at(k + 1)) ? k + 1 : k - 1;
+ const prevX = at(prevK);
+ const prevY = prevX - prevK;
+ while (x > prevX && y > prevY) {
+ x--;
+ y--;
+ ops.push({kind: 'context', a: start + x, b: start + y});
+ }
+ if (d > 0) {
+ if (x === prevX) {
+ ops.push({kind: 'added', b: start + prevY});
+ } else {
+ ops.push({kind: 'removed', a: start + prevX});
+ }
+ }
+ x = prevX;
+ y = prevY;
+ }
+ ops.reverse();
+ return ops;
+}
+
+function formatRange(start: number, count: number): string {
+ return count === 1 ? `${start}` : `${start},${count}`;
+}
+
+/**
+ * Compute a {@link DiffViewerFile} from two versions of a text, for callers
+ * that hold file contents rather than a patch.
+ *
+ * @example
+ * ```
+ * const file = createDiffFile({oldValue: before, newValue: after, path: 'src/app.ts'});
+ *
+ * ```
+ */
+export function createDiffFile({
+ oldValue,
+ newValue,
+ path,
+ oldPath: oldPathOption,
+ newPath: newPathOption,
+ contextLines = 3,
+}: CreateDiffFileOptions): DiffViewerFile {
+ const oldPath = oldPathOption === undefined ? (path ?? null) : oldPathOption;
+ const newPath = newPathOption === undefined ? (path ?? null) : newPathOption;
+ const context = Number.isNaN(contextLines)
+ ? 3
+ : Math.max(0, Math.floor(contextLines));
+
+ let status: DiffViewerFileStatus = 'modified';
+ if (oldPathOption === null) {
+ status = 'added';
+ } else if (newPathOption === null) {
+ status = 'deleted';
+ } else if (oldPath != null && newPath != null && oldPath !== newPath) {
+ status = 'renamed';
+ }
+
+ const a = splitLines(oldValue);
+ const b = splitLines(newValue);
+ const ops = diffLines(a, b);
+
+ const changed: number[] = [];
+ ops.forEach((op, i) => {
+ if (op.kind !== 'context') {
+ changed.push(i);
+ }
+ });
+
+ const hunks: DiffViewerHunk[] = [];
+ let g = 0;
+ while (g < changed.length) {
+ let last = changed[g];
+ let h = g + 1;
+ while (h < changed.length && changed[h] - last - 1 <= context * 2) {
+ last = changed[h];
+ h++;
+ }
+ const start = Math.max(0, changed[g] - context);
+ const end = Math.min(ops.length - 1, last + context);
+
+ const lines: DiffViewerLine[] = [];
+ let oldCount = 0;
+ let newCount = 0;
+ for (let i = start; i <= end; i++) {
+ const op = ops[i];
+ if (op.kind === 'context') {
+ lines.push({
+ kind: 'context',
+ content: a[op.a],
+ oldLineNumber: op.a + 1,
+ newLineNumber: op.b + 1,
+ });
+ oldCount++;
+ newCount++;
+ } else if (op.kind === 'removed') {
+ lines.push({
+ kind: 'removed',
+ content: a[op.a],
+ oldLineNumber: op.a + 1,
+ newLineNumber: null,
+ });
+ oldCount++;
+ } else {
+ lines.push({
+ kind: 'added',
+ content: b[op.b],
+ oldLineNumber: null,
+ newLineNumber: op.b + 1,
+ });
+ newCount++;
+ }
+ }
+
+ // Lines of each side that come before this hunk.
+ let oldBefore = 0;
+ let newBefore = 0;
+ for (let i = 0; i < start; i++) {
+ const op = ops[i];
+ if (op.kind !== 'added') {
+ oldBefore++;
+ }
+ if (op.kind !== 'removed') {
+ newBefore++;
+ }
+ }
+ // Unified convention: an empty side's start is the line before the hunk.
+ const oldStart = oldCount === 0 ? oldBefore : oldBefore + 1;
+ const newStart = newCount === 0 ? newBefore : newBefore + 1;
+
+ hunks.push({
+ header: `@@ -${formatRange(oldStart, oldCount)} +${formatRange(newStart, newCount)} @@`,
+ oldStart,
+ oldLines: oldCount,
+ newStart,
+ newLines: newCount,
+ lines,
+ });
+ g = h;
+ }
+
+ return {
+ oldPath: status === 'added' ? null : oldPath,
+ newPath: status === 'deleted' ? null : newPath,
+ status,
+ isBinary: false,
+ hunks,
+ };
+}
diff --git a/packages/lab/src/DiffViewer/diffTypes.ts b/packages/lab/src/DiffViewer/diffTypes.ts
index d376a607da318..60bf39156dd5c 100644
--- a/packages/lab/src/DiffViewer/diffTypes.ts
+++ b/packages/lab/src/DiffViewer/diffTypes.ts
@@ -6,7 +6,7 @@
* @output Exports the structured diff model DiffViewer renders: DiffViewerFile,
* DiffViewerHunk, DiffViewerLine, DiffViewerLineKind, DiffViewerFileStatus,
* DiffViewerSide, DiffViewerLineRef
- * @position Lab data model; produced by parseUnifiedDiff (or built by hand) and
+ * @position Lab data model; produced by parseUnifiedDiff / createDiffFile and
* consumed by DiffViewer
*
* SYNC: When modified, update:
diff --git a/packages/lab/src/DiffViewer/index.ts b/packages/lab/src/DiffViewer/index.ts
index 437aaeb7ac140..2e731d378777f 100644
--- a/packages/lab/src/DiffViewer/index.ts
+++ b/packages/lab/src/DiffViewer/index.ts
@@ -4,8 +4,8 @@
/**
* @file index.ts
- * @input Imports from DiffViewer.tsx, parseUnifiedDiff.ts, diffTypes.ts
- * @output Exports DiffViewer, its diff model types, and the patch parser
+ * @input Imports from DiffViewer.tsx, parseUnifiedDiff.ts, createDiffFile.ts, diffTypes.ts
+ * @output Exports DiffViewer, its diff model types, and the patch/text utilities
* @position Component entry point; re-exported by /packages/lab/src/index.ts
*
* SYNC: When modified, update /packages/lab/src/DiffViewer/DiffViewer.doc.mjs
@@ -14,6 +14,8 @@
export {DiffViewer} from './DiffViewer';
export type {DiffViewerProps, DiffViewerViewMode} from './DiffViewer';
export {parseUnifiedDiff} from './parseUnifiedDiff';
+export {createDiffFile} from './createDiffFile';
+export type {CreateDiffFileOptions} from './createDiffFile';
export type {
DiffViewerFile,
DiffViewerFileStatus,
diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts
index 9c34bef0618c6..973031730378a 100644
--- a/packages/lab/src/index.ts
+++ b/packages/lab/src/index.ts
@@ -262,8 +262,10 @@ export {
export {
DiffViewer,
parseUnifiedDiff,
+ createDiffFile,
type DiffViewerProps,
type DiffViewerViewMode,
+ type CreateDiffFileOptions,
type DiffViewerFile,
type DiffViewerFileStatus,
type DiffViewerHunk,