"use client"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { IconLoader2 } from "@tabler/icons-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { cn } from "@/lib/utils"; import type { TaskPlanRevision } from "@/lib/types/http"; import { lineDiff, diffSummary, type DiffLine, type DiffLineKind } from "./task-plan-diff"; type Props = { /** Revision pair in arbitrary user-pick order; the dialog re-orders them by * revision_number so the older one is always the "before" side. */ pair: [TaskPlanRevision | null, TaskPlanRevision | null]; loadContent: (revisionId: string) => Promise; onClose: () => void; /** Restore the older revision (the "before" side) — null if the pair isn't * fully populated. */ onRestoreOlder: (revisionId: string) => void; }; type DiffMode = "unified" | "split"; const KIND_CLASS: Record = { add: "bg-emerald-500/10 border-l-2 border-emerald-500/60", remove: "bg-rose-500/10 border-l-2 border-rose-500/60", context: "border-l-2 border-transparent", }; const KIND_PREFIX: Record = { add: "+", remove: "-", context: " ", }; export function PlanRevisionDiffDialog({ pair, loadContent, onClose, onRestoreOlder, }: Props): ReactNode { const [before, after] = useMemo(() => orderPair(pair), [pair]); const [mode, setMode] = useState("unified"); const open = before !== null && after !== null; const sameRevision = before !== null && after !== null && before.id === after.id; const title = before && after ? `Compare v${before.revision_number} → v${after.revision_number}` : "Compare"; return ( { if (!v) onClose(); }} > {title} Line-level diff between the two selected versions. {before && !sameRevision && ( )} ); } function DiffBody({ before, after, loadContent, mode, setMode, }: { before: TaskPlanRevision | null; after: TaskPlanRevision | null; loadContent: (revisionId: string) => Promise; mode: DiffMode; setMode: (m: DiffMode) => void; }) { const { beforeContent, afterContent, lines, error } = useDiffContent(before, after, loadContent); const summary = lines ? diffSummary(lines) : null; const sameRevision = before !== null && after !== null && before.id === after.id; return (
{summary ? `${summary.added} added · ${summary.removed} removed` : "Loading…"}
{ if (v === "unified" || v === "split") setMode(v); }} data-testid="plan-revision-diff-mode-toggle" > Unified Split
); } function DiffBodyInner({ mode, lines, beforeContent, afterContent, error, sameRevision, summary, }: { mode: DiffMode; lines: DiffLine[] | null; beforeContent: string | null; afterContent: string | null; error: string | null; sameRevision: boolean; summary: { added: number; removed: number } | null; }) { if (error) return
{error}
; if (lines === null) return ; if (sameRevision) return These are the same version; nothing to compare.; if (lines.length === 0) return (both versions are empty); if (summary && summary.added === 0 && summary.removed === 0) { return No textual changes between these versions.; } if (mode === "split") { return ; } return ; } function UnifiedDiff({ lines }: { lines: DiffLine[] }) { return ( // inline-block + min-w-full lets the row backgrounds extend past the // viewport so highlights stay solid when the user scrolls horizontally.
    {lines.map((line, i) => ( ))}
); } function DiffLineRow({ line }: { line: DiffLine }) { return (
  • {KIND_PREFIX[line.kind]} {line.text || " "}
  • ); } /** Side-by-side diff with two equal-width panes that scroll horizontally * together. The dialog stays at its fixed width; each pane shows up to half * of it and gets its own horizontal scrollbar for long lines. A scroll * listener keeps both panes' `scrollLeft` in lockstep so corresponding rows * stay aligned visually. */ function SplitDiff({ beforeContent, afterContent, }: { beforeContent: string; afterContent: string; }) { const rows = useMemo( () => alignSplit(beforeContent, afterContent), [beforeContent, afterContent], ); const beforeRef = useRef(null); const afterRef = useRef(null); // Re-entrancy guard: when we set the partner pane's scrollLeft, that fires // the partner's onScroll, which would bounce back here without this flag. const syncingRef = useRef(false); useEffect(() => { const before = beforeRef.current; const after = afterRef.current; if (!before || !after) return; const sync = (source: HTMLDivElement, target: HTMLDivElement) => () => { if (syncingRef.current) return; syncingRef.current = true; target.scrollLeft = source.scrollLeft; // Release on the next frame so the bounced onScroll has fired and // returned early, but we don't permanently disable syncing. requestAnimationFrame(() => { syncingRef.current = false; }); }; const onBefore = sync(before, after); const onAfter = sync(after, before); before.addEventListener("scroll", onBefore, { passive: true }); after.addEventListener("scroll", onAfter, { passive: true }); return () => { before.removeEventListener("scroll", onBefore); after.removeEventListener("scroll", onAfter); }; }, [rows]); return (
    ); } const SplitPane = ({ ref, rows, side, testIdSuffix, }: { ref: React.RefObject; rows: SplitRow[]; side: "before" | "after"; testIdSuffix: string; }) => (
    {/* w-max lets each pane's intrinsic width be the longest line on that * side; combined with the parent grid-cols-2, the visible portion is * still capped at 50% of the dialog while horizontal scroll reveals * the rest. */}
    {rows.map((row, i) => ( ))}
    ); function SplitCell({ side, line }: { side: "before" | "after"; line: DiffLine | null }) { // Empty cells (a removal on the after side, or an addition on the before // side) get a subtle striped background so the row alignment is visible. if (!line) { return (
    ); } return (
    {KIND_PREFIX[line.kind]} {line.text || " "}
    ); } function DiffLoading() { return (
    Loading…
    ); } function DiffMessage({ children }: { children: ReactNode }) { return
    {children}
    ; } /** Sort a 2-slot pair by revision_number ascending so the older revision is * always the "before" side, regardless of pick order. */ function orderPair( pair: [TaskPlanRevision | null, TaskPlanRevision | null], ): [TaskPlanRevision | null, TaskPlanRevision | null] { const [a, b] = pair; if (!a || !b) return pair; return a.revision_number <= b.revision_number ? [a, b] : [b, a]; } type SplitRow = { before: DiffLine | null; after: DiffLine | null }; /** Align the line-diff into side-by-side rows: each row contains at most one * before-line and one after-line. Removals render on the left only; adds on * the right only; context lines align across both sides. Adjacent * remove/add chunks are zipped together so a single-line edit shows up on the * same row. */ function alignSplit(beforeContent: string, afterContent: string): SplitRow[] { const lines = lineDiff(beforeContent, afterContent); const rows: SplitRow[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.kind === "context") { rows.push({ before: line, after: line }); i++; continue; } // Collect a contiguous remove block, then any adjacent add block. const removes: DiffLine[] = []; while (i < lines.length && lines[i].kind === "remove") { removes.push(lines[i]); i++; } const adds: DiffLine[] = []; while (i < lines.length && lines[i].kind === "add") { adds.push(lines[i]); i++; } const max = Math.max(removes.length, adds.length); for (let k = 0; k < max; k++) { rows.push({ before: removes[k] ?? null, after: adds[k] ?? null }); } } return rows; } function useDiffContent( before: TaskPlanRevision | null, after: TaskPlanRevision | null, loadContent: (revisionId: string) => Promise, ): { beforeContent: string | null; afterContent: string | null; lines: DiffLine[] | null; error: string | null; } { const [beforeContent, setBeforeContent] = useState(null); const [afterContent, setAfterContent] = useState(null); const [error, setError] = useState(null); useEffect(() => { if (!before || !after) return; let cancelled = false; Promise.all([loadContent(before.id), loadContent(after.id)]) .then(([b, a]) => { if (cancelled) return; setBeforeContent(b); setAfterContent(a); }) .catch((err) => { if (cancelled) return; setError(err instanceof Error ? err.message : "Failed to load revision content"); }); return () => { cancelled = true; }; }, [before, after, loadContent]); const lines = useMemo(() => { if (beforeContent === null || afterContent === null) return null; return lineDiff(beforeContent, afterContent); }, [beforeContent, afterContent]); return { beforeContent, afterContent, lines, error }; }