mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
refactor(ui): inline document diff rendering
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d71ff903e4
commit
622a8e44bf
2 changed files with 130 additions and 46 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue