From bc910e2b62dd6db0c0f5a8fe50e8d05d5536d763 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 4 May 2026 17:52:18 -0500 Subject: [PATCH] Extract buildLineDiff into a shared ui/src/lib/line-diff helper Lifts the LCS-based line diff out of DocumentDiffModal so that the routine revision history diff modal can reuse the same algorithm without duplication. Behavior is unchanged. Co-Authored-By: Paperclip --- ui/src/components/DocumentDiffModal.tsx | 91 +------------------------ ui/src/lib/line-diff.test.ts | 26 +++++++ ui/src/lib/line-diff.ts | 91 +++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 90 deletions(-) create mode 100644 ui/src/lib/line-diff.test.ts create mode 100644 ui/src/lib/line-diff.ts diff --git a/ui/src/components/DocumentDiffModal.tsx b/ui/src/components/DocumentDiffModal.tsx index aaee6514acf..715ca82eda8 100644 --- a/ui/src/components/DocumentDiffModal.tsx +++ b/ui/src/components/DocumentDiffModal.tsx @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import type { DocumentRevision } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; +import { buildLineDiff, type DiffRow } from "../lib/line-diff"; import { relativeTime } from "../lib/utils"; import { Dialog, @@ -27,96 +28,6 @@ function getRevisionLabel(revision: DocumentRevision) { return `rev ${revision.revisionNumber} — ${relativeTime(revision.createdAt)} • ${actor}`; } -type DiffRow = { - kind: "context" | "removed" | "added"; - oldLineNumber: number | null; - newLineNumber: number | null; - text: string; -}; - -function buildLineDiff(oldText: string, newText: string): DiffRow[] { - const oldLines = oldText.split("\n"); - const newLines = newText.split("\n"); - const oldCount = oldLines.length; - const newCount = newLines.length; - const dp = Array.from({ length: oldCount + 1 }, () => Array(newCount + 1).fill(0)); - - for (let i = oldCount - 1; i >= 0; i -= 1) { - for (let j = newCount - 1; j >= 0; j -= 1) { - dp[i][j] = oldLines[i] === newLines[j] - ? dp[i + 1][j + 1] + 1 - : Math.max(dp[i + 1][j], dp[i][j + 1]); - } - } - - const rows: DiffRow[] = []; - let i = 0; - let j = 0; - let oldLineNumber = 1; - let newLineNumber = 1; - - while (i < oldCount && j < newCount) { - if (oldLines[i] === newLines[j]) { - rows.push({ - kind: "context", - oldLineNumber, - newLineNumber, - text: oldLines[i], - }); - i += 1; - j += 1; - oldLineNumber += 1; - newLineNumber += 1; - continue; - } - - if (dp[i + 1][j] >= dp[i][j + 1]) { - rows.push({ - kind: "removed", - oldLineNumber, - newLineNumber: null, - text: oldLines[i], - }); - i += 1; - oldLineNumber += 1; - continue; - } - - rows.push({ - kind: "added", - oldLineNumber: null, - newLineNumber, - text: newLines[j], - }); - j += 1; - newLineNumber += 1; - } - - while (i < oldCount) { - rows.push({ - kind: "removed", - oldLineNumber, - newLineNumber: null, - text: oldLines[i], - }); - i += 1; - oldLineNumber += 1; - } - - while (j < newCount) { - rows.push({ - kind: "added", - oldLineNumber: null, - newLineNumber, - text: newLines[j], - }); - j += 1; - newLineNumber += 1; - } - - return rows; -} - export function DocumentDiffModal({ issueId, documentKey, diff --git a/ui/src/lib/line-diff.test.ts b/ui/src/lib/line-diff.test.ts new file mode 100644 index 00000000000..b839a6f701f --- /dev/null +++ b/ui/src/lib/line-diff.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildLineDiff } from "./line-diff"; + +describe("buildLineDiff", () => { + it("emits context rows when both sides are identical", () => { + const rows = buildLineDiff("a\nb\nc", "a\nb\nc"); + expect(rows).toHaveLength(3); + expect(rows.every((row) => row.kind === "context")).toBe(true); + }); + + it("marks added and removed lines", () => { + const rows = buildLineDiff("a\nb\nc", "a\nB\nc"); + const kinds = rows.map((row) => row.kind); + expect(kinds).toContain("removed"); + expect(kinds).toContain("added"); + const removed = rows.find((row) => row.kind === "removed"); + const added = rows.find((row) => row.kind === "added"); + expect(removed?.text).toBe("b"); + expect(added?.text).toBe("B"); + }); + + it("handles empty old text as full insertion", () => { + const rows = buildLineDiff("", "x\ny"); + expect(rows.filter((row) => row.kind === "added")).toHaveLength(2); + }); +}); diff --git a/ui/src/lib/line-diff.ts b/ui/src/lib/line-diff.ts new file mode 100644 index 00000000000..09a9e87fa8a --- /dev/null +++ b/ui/src/lib/line-diff.ts @@ -0,0 +1,91 @@ +export type DiffRowKind = "context" | "removed" | "added"; + +export type DiffRow = { + kind: DiffRowKind; + oldLineNumber: number | null; + newLineNumber: number | null; + text: string; +}; + +export function buildLineDiff(oldText: string, newText: string): DiffRow[] { + const oldLines = oldText.split("\n"); + const newLines = newText.split("\n"); + const oldCount = oldLines.length; + const newCount = newLines.length; + const dp = Array.from({ length: oldCount + 1 }, () => Array(newCount + 1).fill(0)); + + for (let i = oldCount - 1; i >= 0; i -= 1) { + for (let j = newCount - 1; j >= 0; j -= 1) { + dp[i][j] = oldLines[i] === newLines[j] + ? dp[i + 1][j + 1] + 1 + : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + const rows: DiffRow[] = []; + let i = 0; + let j = 0; + let oldLineNumber = 1; + let newLineNumber = 1; + + while (i < oldCount && j < newCount) { + if (oldLines[i] === newLines[j]) { + rows.push({ + kind: "context", + oldLineNumber, + newLineNumber, + text: oldLines[i], + }); + i += 1; + j += 1; + oldLineNumber += 1; + newLineNumber += 1; + continue; + } + + if (dp[i + 1][j] >= dp[i][j + 1]) { + rows.push({ + kind: "removed", + oldLineNumber, + newLineNumber: null, + text: oldLines[i], + }); + i += 1; + oldLineNumber += 1; + continue; + } + + rows.push({ + kind: "added", + oldLineNumber: null, + newLineNumber, + text: newLines[j], + }); + j += 1; + newLineNumber += 1; + } + + while (i < oldCount) { + rows.push({ + kind: "removed", + oldLineNumber, + newLineNumber: null, + text: oldLines[i], + }); + i += 1; + oldLineNumber += 1; + } + + while (j < newCount) { + rows.push({ + kind: "added", + oldLineNumber: null, + newLineNumber, + text: newLines[j], + }); + j += 1; + newLineNumber += 1; + } + + return rows; +}