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,