mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
feat(ui): add document revision diff viewer
Add a "View diff" option to the document three-dot menu (visible when revision > 1) that opens a modal showing side-by-side changes between revisions using react-diff-viewer-continued. Defaults to comparing the current revision with its predecessor, with dropdowns to select any two revisions. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
54ac2c6fe9
commit
13ada98e78
3 changed files with 195 additions and 3 deletions
|
|
@ -30,7 +30,6 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@lexical/link": "0.35.0",
|
"@lexical/link": "0.35.0",
|
||||||
"lexical": "0.35.0",
|
|
||||||
"@mdxeditor/editor": "^3.52.4",
|
"@mdxeditor/editor": "^3.52.4",
|
||||||
"@paperclipai/adapter-claude-local": "workspace:*",
|
"@paperclipai/adapter-claude-local": "workspace:*",
|
||||||
"@paperclipai/adapter-codex-local": "workspace:*",
|
"@paperclipai/adapter-codex-local": "workspace:*",
|
||||||
|
|
@ -41,17 +40,19 @@
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
"hermes-paperclip-adapter": "^0.2.0",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"hermes-paperclip-adapter": "^0.2.0",
|
||||||
|
"lexical": "0.35.0",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
"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",
|
||||||
|
|
|
||||||
169
ui/src/components/DocumentDiffModal.tsx
Normal file
169
ui/src/components/DocumentDiffModal.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { DocumentRevision } from "@paperclipai/shared";
|
||||||
|
import ReactDiffViewer, { DiffMethod } from "react-diff-viewer-continued";
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Diff — <span className="font-mono text-sm">{documentKey}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">Left:</span>
|
||||||
|
<Select
|
||||||
|
value={effectiveLeftId ?? ""}
|
||||||
|
onValueChange={(value) => setLeftRevisionId(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-56 text-xs">
|
||||||
|
<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 min-w-0">
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">Right:</span>
|
||||||
|
<Select
|
||||||
|
value={effectiveRightId ?? ""}
|
||||||
|
onValueChange={(value) => setRightRevisionId(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-56 text-xs">
|
||||||
|
<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 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>
|
||||||
|
) : (
|
||||||
|
<ReactDiffViewer
|
||||||
|
oldValue={leftBody}
|
||||||
|
newValue={rightBody}
|
||||||
|
splitView={false}
|
||||||
|
compareMethod={DiffMethod.WORDS}
|
||||||
|
useDarkTheme
|
||||||
|
leftTitle={`rev ${leftRevision.revisionNumber}`}
|
||||||
|
rightTitle={`rev ${rightRevision.revisionNumber}`}
|
||||||
|
styles={{
|
||||||
|
variables: {
|
||||||
|
dark: {
|
||||||
|
diffViewerBackground: "transparent",
|
||||||
|
gutterBackground: "hsl(var(--muted) / 0.3)",
|
||||||
|
addedBackground: "hsl(142 70% 25% / 0.3)",
|
||||||
|
addedGutterBackground: "hsl(142 70% 25% / 0.4)",
|
||||||
|
removedBackground: "hsl(0 70% 30% / 0.3)",
|
||||||
|
removedGutterBackground: "hsl(0 70% 30% / 0.4)",
|
||||||
|
wordAddedBackground: "hsl(142 70% 35% / 0.5)",
|
||||||
|
wordRemovedBackground: "hsl(0 70% 40% / 0.5)",
|
||||||
|
addedGutterColor: "hsl(var(--foreground))",
|
||||||
|
removedGutterColor: "hsl(var(--foreground))",
|
||||||
|
gutterColor: "hsl(var(--muted-foreground))",
|
||||||
|
codeFoldGutterBackground: "hsl(var(--muted) / 0.2)",
|
||||||
|
codeFoldBackground: "hsl(var(--muted) / 0.1)",
|
||||||
|
emptyLineBackground: "transparent",
|
||||||
|
codeFoldContentColor: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contentText: {
|
||||||
|
fontFamily: "inherit",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,7 +29,8 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
import { Check, ChevronDown, ChevronRight, Copy, Diff, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { DocumentDiffModal } from "./DocumentDiffModal";
|
||||||
|
|
||||||
type DraftState = {
|
type DraftState = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -162,6 +163,7 @@ export function IssueDocumentsSection({
|
||||||
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
const [highlightDocumentKey, setHighlightDocumentKey] = useState<string | null>(null);
|
||||||
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
const [revisionMenuOpenKey, setRevisionMenuOpenKey] = useState<string | null>(null);
|
||||||
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
const [selectedRevisionIds, setSelectedRevisionIds] = useState<Record<string, string | null>>({});
|
||||||
|
const [diffViewKey, setDiffViewKey] = useState<string | null>(null);
|
||||||
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const copiedDocumentTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const hasScrolledToHashRef = useRef(false);
|
const hasScrolledToHashRef = useRef(false);
|
||||||
|
|
@ -929,6 +931,12 @@ export function IssueDocumentsSection({
|
||||||
<Download className="h-3.5 w-3.5" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
Download document
|
Download document
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{doc.latestRevisionNumber > 1 ? (
|
||||||
|
<DropdownMenuItem onClick={() => setDiffViewKey(doc.key)}>
|
||||||
|
<Diff className="h-3.5 w-3.5" />
|
||||||
|
View diff
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
{canDeleteDocuments ? <DropdownMenuSeparator /> : null}
|
||||||
{canDeleteDocuments ? (
|
{canDeleteDocuments ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
|
|
@ -1174,6 +1182,20 @@ export function IssueDocumentsSection({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{diffViewKey && (() => {
|
||||||
|
const diffDoc = sortedDocuments.find((d) => d.key === diffViewKey);
|
||||||
|
if (!diffDoc) return null;
|
||||||
|
return (
|
||||||
|
<DocumentDiffModal
|
||||||
|
issueId={issue.id}
|
||||||
|
documentKey={diffDoc.key}
|
||||||
|
latestRevisionNumber={diffDoc.latestRevisionNumber}
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => { if (!open) setDiffViewKey(null); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue