622a8e44bf
Co-Authored-By: Paperclip <noreply@paperclip.ing>
266 lines
8.8 KiB
TypeScript
266 lines
8.8 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { DocumentRevision } from "@paperclipai/shared";
|
|
import { issuesApi } from "../api/issues";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { relativeTime } from "../lib/utils";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
|
|
function getRevisionLabel(revision: DocumentRevision) {
|
|
const actor = revision.createdByUserId
|
|
? "board"
|
|
: revision.createdByAgentId
|
|
? "agent"
|
|
: "system";
|
|
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,
|
|
latestRevisionNumber,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
issueId: string;
|
|
documentKey: string;
|
|
latestRevisionNumber: number;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const { data: revisions } = useQuery({
|
|
queryKey: queryKeys.issues.documentRevisions(issueId, documentKey),
|
|
queryFn: () => issuesApi.listDocumentRevisions(issueId, documentKey),
|
|
enabled: open,
|
|
});
|
|
|
|
const sortedRevisions = useMemo(() => {
|
|
if (!revisions) return [];
|
|
return [...revisions].sort((a, b) => b.revisionNumber - a.revisionNumber);
|
|
}, [revisions]);
|
|
|
|
// Default: compare previous (latestRevisionNumber - 1) with current (latestRevisionNumber)
|
|
const [leftRevisionId, setLeftRevisionId] = useState<string | null>(null);
|
|
const [rightRevisionId, setRightRevisionId] = useState<string | null>(null);
|
|
|
|
const effectiveLeftId = leftRevisionId ?? sortedRevisions.find(
|
|
(r) => r.revisionNumber === latestRevisionNumber - 1,
|
|
)?.id ?? null;
|
|
|
|
const effectiveRightId = rightRevisionId ?? sortedRevisions.find(
|
|
(r) => r.revisionNumber === latestRevisionNumber,
|
|
)?.id ?? null;
|
|
|
|
const leftRevision = sortedRevisions.find((r) => r.id === effectiveLeftId) ?? null;
|
|
const rightRevision = sortedRevisions.find((r) => r.id === effectiveRightId) ?? null;
|
|
|
|
const leftBody = leftRevision?.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 (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="!max-w-[90%] w-full max-h-[85vh] overflow-hidden flex flex-col">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<DialogHeader className="shrink-0">
|
|
<DialogTitle>
|
|
Diff — <span className="font-mono text-sm">{documentKey}</span>
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="flex items-center gap-4 shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-red-400">Old</span>
|
|
<Select
|
|
value={effectiveLeftId ?? ""}
|
|
onValueChange={(value) => setLeftRevisionId(value)}
|
|
>
|
|
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
|
|
<SelectValue placeholder="Select revision" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sortedRevisions.map((revision) => (
|
|
<SelectItem key={revision.id} value={revision.id} className="text-xs">
|
|
{getRevisionLabel(revision)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="rounded-full border border-green-500/30 bg-green-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-green-400">New</span>
|
|
<Select
|
|
value={effectiveRightId ?? ""}
|
|
onValueChange={(value) => setRightRevisionId(value)}
|
|
>
|
|
<SelectTrigger className="h-7 w-60 text-xs border-border/60">
|
|
<SelectValue placeholder="Select revision" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{sortedRevisions.map((revision) => (
|
|
<SelectItem key={revision.id} value={revision.id} className="text-xs">
|
|
{getRevisionLabel(revision)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-auto flex-1 rounded-md border border-border text-xs">
|
|
{!revisions ? (
|
|
<div className="p-6 text-center text-muted-foreground text-sm">Loading revisions...</div>
|
|
) : !leftRevision || !rightRevision ? (
|
|
<div className="p-6 text-center text-muted-foreground text-sm">Select two revisions to compare.</div>
|
|
) : leftRevision.id === rightRevision.id ? (
|
|
<div className="p-6 text-center text-muted-foreground text-sm">Both sides are the same revision.</div>
|
|
) : (
|
|
<div className="font-mono text-[12px] leading-6">
|
|
<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">
|
|
<span>Old</span>
|
|
<span>New</span>
|
|
<span />
|
|
<span>Content</span>
|
|
</div>
|
|
{diffRows.map((row, index) => (
|
|
<div
|
|
key={`${row.kind}-${index}-${row.oldLineNumber ?? "x"}-${row.newLineNumber ?? "x"}`}
|
|
className={`grid grid-cols-[56px_56px_24px_minmax(0,1fr)] gap-0 border-b border-border/30 px-3 ${lineClassesByKind[row.kind]}`}
|
|
>
|
|
<span className="select-none border-r border-border/30 pr-3 text-right text-muted-foreground">
|
|
{row.oldLineNumber ?? ""}
|
|
</span>
|
|
<span className="select-none border-r border-border/30 px-3 text-right text-muted-foreground">
|
|
{row.newLineNumber ?? ""}
|
|
</span>
|
|
<span className="select-none px-3 text-center text-muted-foreground">
|
|
{markerByKind[row.kind]}
|
|
</span>
|
|
<pre className="overflow-x-auto whitespace-pre-wrap break-words px-3 py-0 text-inherit">
|
|
{row.text.length > 0 ? row.text : " "}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|