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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-05 10:58:54 -05:00
1 parent ec8c769e46
commit bc910e2b62
3 files changed
+118 -90

No files matched your search

+1 -90
View File
@@ -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<number>(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,
+26
View File
@@ -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);
});
});
+91
View File
@@ -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<number>(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;
}