Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
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
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
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
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
1ba5e90
Merge feat/lab-diff-viewer into feat/lab-diff-create-file
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
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
d37b688
Merge main into feat/lab-diff-create-file
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
27 changes: 27 additions & 0 deletions apps/storybook/stories/DiffViewer.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {Meta, StoryObj} from '@storybook/react';
import {useState} from 'react';
import {
DiffViewer,
createDiffFile,
parseUnifiedDiff,
type DiffViewerLineRef,
} from '@astryxdesign/lab';
Expand Down Expand Up @@ -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 => {
Expand Down
10 changes: 8 additions & 2 deletions packages/lab/src/DiffViewer/DiffViewer.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +64,12 @@ export const docs = {
label: 'Split view',
code: '<DiffViewer file={file} viewMode="split" />',
},
{
label: 'Diff two versions of a text',
code: `const file = createDiffFile({oldValue: before, newValue: after, path: 'src/app.ts'});

<DiffViewer file={file} />`,
},
{
label: 'Review comments',
code: `<DiffViewer
Expand All @@ -82,7 +88,7 @@ export const docs = {
name: 'file',
type: 'DiffViewerFile',
description:
'The file to render: paths, status, binary flag, and hunks of numbered lines. Build it with parseUnifiedDiff.',
'The file to render: paths, status, binary flag, and hunks of numbered lines. Build it with parseUnifiedDiff or createDiffFile.',
required: true,
},
{
Expand Down
2 changes: 1 addition & 1 deletion packages/lab/src/DiffViewer/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface DiffViewerProps extends BaseProps<HTMLDivElement> {
ref?: Ref<HTMLDivElement>;
/**
* 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;
/**
Expand Down
180 changes: 180 additions & 0 deletions packages/lab/src/DiffViewer/createDiffFile.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: this test also passes with MAX_EDIT_DISTANCE raised to 100,000, because Myers gives a full rewrite the same all-removed-then-all-added block as the fallback, only more slowly (about 1.3 s instead of 0.07 s here). A case whose minimal and capped results differ would pin the cap, for example:

const base = Array.from({length: 10_000}, (_, i) => `line ${i}`);
const edit = (count: number) => {
  const next = [...base];
  const step = Math.floor(base.length / (count + 1));
  for (let e = 1; e <= count; e++) next[e * step] = `changed ${e * step}`;
  return createDiffFile({oldValue: base.join('\n'), newValue: next.join('\n')});
};
expect(edit(1000).hunks).toHaveLength(1000); // 2,000 edits: minimal diff
expect(edit(1001).hunks).toHaveLength(1); // 2,002 edits: 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');
});
});
Loading
Loading