refactor(ui): inline document diff rendering

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-06 21:43:19 -05:00
parent d71ff903e4
commit 622a8e44bf
2 changed files with 130 additions and 46 deletions

View file

@ -52,7 +52,6 @@
"mermaid": "^11.12.0", "mermaid": "^11.12.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-diff-viewer-continued": "^4.2.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.1.5", "react-router-dom": "^7.1.5",

View file

@ -1,7 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { DocumentRevision } from "@paperclipai/shared"; import type { DocumentRevision } from "@paperclipai/shared";
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued";
import { issuesApi } from "../api/issues"; import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { relativeTime } from "../lib/utils"; import { relativeTime } from "../lib/utils";
@ -28,6 +27,96 @@ function getRevisionLabel(revision: DocumentRevision) {
return `rev ${revision.revisionNumber}${relativeTime(revision.createdAt)}${actor}`; 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({ export function DocumentDiffModal({
issueId, issueId,
documentKey, documentKey,
@ -69,6 +158,19 @@ export function DocumentDiffModal({
const leftBody = leftRevision?.body ?? ""; const leftBody = leftRevision?.body ?? "";
const rightBody = rightRevision?.body ?? ""; const rightBody = rightRevision?.body ?? "";
const diffRows = useMemo(() => buildLineDiff(leftBody, rightBody), [leftBody, rightBody]);
const lineClassesByKind: Record<DiffRow["kind"], string> = {
context: "bg-transparent",
removed: "bg-red-500/10 text-red-100",
added: "bg-green-500/10 text-green-100",
};
const markerByKind: Record<DiffRow["kind"], string> = {
context: " ",
removed: "-",
added: "+",
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -128,50 +230,33 @@ export function DocumentDiffModal({
) : leftRevision.id === rightRevision.id ? ( ) : leftRevision.id === rightRevision.id ? (
<div className="p-6 text-center text-muted-foreground text-sm">Both sides are the same revision.</div> <div className="p-6 text-center text-muted-foreground text-sm">Both sides are the same revision.</div>
) : ( ) : (
<ReactDiffViewer <div className="font-mono text-[12px] leading-6">
oldValue={leftBody} <div className="grid grid-cols-[56px_56px_24px_minmax(0,1fr)] border-b border-border/60 bg-muted/30 px-3 py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
newValue={rightBody} <span>Old</span>
splitView={false} <span>New</span>
compareMethod={DiffMethod.WORDS} <span />
useDarkTheme <span>Content</span>
leftTitle={`rev ${leftRevision.revisionNumber}`} </div>
rightTitle={`rev ${rightRevision.revisionNumber}`} {diffRows.map((row, index) => (
styles={{ <div
variables: { key={`${row.kind}-${index}-${row.oldLineNumber ?? "x"}-${row.newLineNumber ?? "x"}`}
dark: { className={`grid grid-cols-[56px_56px_24px_minmax(0,1fr)] gap-0 border-b border-border/30 px-3 ${lineClassesByKind[row.kind]}`}
diffViewerBackground: "transparent", >
gutterBackground: "hsl(var(--muted) / 0.3)", <span className="select-none border-r border-border/30 pr-3 text-right text-muted-foreground">
addedBackground: "hsl(142 70% 25% / 0.3)", {row.oldLineNumber ?? ""}
addedGutterBackground: "hsl(142 70% 25% / 0.4)", </span>
removedBackground: "hsl(0 70% 30% / 0.3)", <span className="select-none border-r border-border/30 px-3 text-right text-muted-foreground">
removedGutterBackground: "hsl(0 70% 30% / 0.4)", {row.newLineNumber ?? ""}
wordAddedBackground: "hsl(142 70% 35% / 0.5)", </span>
wordRemovedBackground: "hsl(0 70% 40% / 0.5)", <span className="select-none px-3 text-center text-muted-foreground">
addedGutterColor: "hsl(var(--foreground))", {markerByKind[row.kind]}
removedGutterColor: "hsl(var(--foreground))", </span>
gutterColor: "hsl(var(--muted-foreground))", <pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-0 text-inherit">
codeFoldGutterBackground: "hsl(var(--muted) / 0.2)", {row.text.length > 0 ? row.text : " "}
codeFoldBackground: "hsl(var(--muted) / 0.1)", </pre>
emptyLineBackground: "transparent", </div>
codeFoldContentColor: "hsl(var(--muted-foreground))", ))}
}, </div>
},
contentText: {
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
fontSize: "12px",
lineHeight: "1.5",
wordBreak: "break-word" as const,
whiteSpace: "pre-wrap" as const,
},
gutter: {
minWidth: "40px",
whiteSpace: "nowrap" as const,
},
line: {
wordBreak: "break-word" as const,
},
}}
/>
)} )}
</div> </div>
</DialogContent> </DialogContent>