e94cf6de83
Co-authored-by: Kandev Agent <agent@kandev.dev>
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
"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";
|
|
import { useTranslation } from "react-i18next";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
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<string>;
|
|
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<DiffLineKind, string> = {
|
|
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<DiffLineKind, string> = {
|
|
add: "+",
|
|
remove: "-",
|
|
context: " ",
|
|
};
|
|
|
|
export function PlanRevisionDiffDialog({
|
|
pair,
|
|
loadContent,
|
|
onClose,
|
|
onRestoreOlder,
|
|
}: Props): ReactNode {
|
|
const { t } = useTranslation();
|
|
const [before, after] = useMemo(() => orderPair(pair), [pair]);
|
|
const [mode, setMode] = useState<DiffMode>("unified");
|
|
const open = before !== null && after !== null;
|
|
const sameRevision = before !== null && after !== null && before.id === after.id;
|
|
const title =
|
|
before && after
|
|
? t("task:compareRevisions", { before: before.revision_number, after: after.revision_number })
|
|
: t("task:compare");
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(v) => {
|
|
if (!v) onClose();
|
|
}}
|
|
>
|
|
<DialogContent
|
|
className="!max-w-6xl w-[95vw] max-h-[90vh] flex flex-col"
|
|
data-testid="plan-revision-diff-dialog"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{t("task:lineLevelDiffBetweenTheTwo")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DiffBody
|
|
before={before}
|
|
after={after}
|
|
loadContent={loadContent}
|
|
mode={mode}
|
|
setMode={setMode}
|
|
/>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={onClose}
|
|
className="cursor-pointer"
|
|
data-testid="plan-revision-diff-close"
|
|
>
|
|
{t("task:close")}
|
|
</Button>
|
|
{before && !sameRevision && (
|
|
<Button
|
|
onClick={() => onRestoreOlder(before.id)}
|
|
className="cursor-pointer"
|
|
data-testid="plan-revision-diff-restore"
|
|
>
|
|
{t("task:restoreVersion", { version: before.revision_number })}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function DiffBody({
|
|
before,
|
|
after,
|
|
loadContent,
|
|
mode,
|
|
setMode,
|
|
}: {
|
|
before: TaskPlanRevision | null;
|
|
after: TaskPlanRevision | null;
|
|
loadContent: (revisionId: string) => Promise<string>;
|
|
mode: DiffMode;
|
|
setMode: (m: DiffMode) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
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 (
|
|
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-[11px] text-muted-foreground" data-testid="plan-revision-diff-summary">
|
|
{summary
|
|
? t("task:addedRemoved", { added: summary.added, removed: summary.removed })
|
|
: t("task:loading2")}
|
|
</div>
|
|
<ToggleGroup
|
|
type="single"
|
|
size="sm"
|
|
value={mode}
|
|
onValueChange={(v) => {
|
|
if (v === "unified" || v === "split") setMode(v);
|
|
}}
|
|
data-testid="plan-revision-diff-mode-toggle"
|
|
>
|
|
<ToggleGroupItem
|
|
value="unified"
|
|
className="text-xs px-2 cursor-pointer"
|
|
data-testid="plan-revision-diff-mode-unified"
|
|
>
|
|
{t("task:unified")}
|
|
</ToggleGroupItem>
|
|
<ToggleGroupItem
|
|
value="split"
|
|
className="text-xs px-2 cursor-pointer"
|
|
data-testid="plan-revision-diff-mode-split"
|
|
>
|
|
{t("task:split")}
|
|
</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"flex-1 min-h-0 rounded border border-border bg-muted/20 font-mono text-xs",
|
|
// Unified scrolls in both axes; split mode owns its own panes' scroll
|
|
// and only needs vertical here so its synced horizontal scrollbars
|
|
// sit inside the panes (one per side, scroll-linked).
|
|
mode === "split" ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
|
|
)}
|
|
data-testid="plan-revision-diff-body"
|
|
>
|
|
<DiffBodyInner
|
|
mode={mode}
|
|
lines={lines}
|
|
beforeContent={beforeContent}
|
|
afterContent={afterContent}
|
|
error={error}
|
|
sameRevision={sameRevision}
|
|
summary={summary}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (error) return <div className="p-3 text-destructive">{error}</div>;
|
|
if (lines === null) return <DiffLoading />;
|
|
if (sameRevision) return <DiffMessage>{t("task:theseAreTheSameVersionNothing")}</DiffMessage>;
|
|
if (lines.length === 0) return <DiffMessage>{t("task:bothVersionsAreEmpty")}</DiffMessage>;
|
|
if (summary && summary.added === 0 && summary.removed === 0) {
|
|
return <DiffMessage>{t("task:noTextualChangesBetweenTheseVersions")}</DiffMessage>;
|
|
}
|
|
if (mode === "split") {
|
|
return <SplitDiff beforeContent={beforeContent ?? ""} afterContent={afterContent ?? ""} />;
|
|
}
|
|
return <UnifiedDiff lines={lines} />;
|
|
}
|
|
|
|
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.
|
|
<ul className="inline-block min-w-full" data-testid="plan-revision-diff-unified">
|
|
{lines.map((line, i) => (
|
|
<DiffLineRow key={i} line={line} />
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
function DiffLineRow({ line }: { line: DiffLine }) {
|
|
return (
|
|
<li
|
|
className={cn(
|
|
"flex gap-2 px-3 py-0.5 leading-relaxed whitespace-pre min-w-full w-max",
|
|
KIND_CLASS[line.kind],
|
|
)}
|
|
data-testid="plan-revision-diff-line"
|
|
data-line-kind={line.kind}
|
|
>
|
|
<span className="select-none text-muted-foreground w-3 shrink-0">
|
|
{KIND_PREFIX[line.kind]}
|
|
</span>
|
|
<span className="flex-1 break-words">{line.text || " "}</span>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** 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<HTMLDivElement>(null);
|
|
const afterRef = useRef<HTMLDivElement>(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 (
|
|
<div
|
|
className="grid grid-cols-2 divide-x divide-border min-w-0"
|
|
data-testid="plan-revision-diff-split"
|
|
>
|
|
<SplitPane ref={beforeRef} rows={rows} side="before" testIdSuffix="before" />
|
|
<SplitPane ref={afterRef} rows={rows} side="after" testIdSuffix="after" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const SplitPane = ({
|
|
ref,
|
|
rows,
|
|
side,
|
|
testIdSuffix,
|
|
}: {
|
|
ref: React.RefObject<HTMLDivElement | null>;
|
|
rows: SplitRow[];
|
|
side: "before" | "after";
|
|
testIdSuffix: string;
|
|
}) => (
|
|
<div
|
|
ref={ref}
|
|
className="min-w-0 overflow-x-auto"
|
|
data-testid={`plan-revision-diff-split-pane-${testIdSuffix}`}
|
|
>
|
|
{/* 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. */}
|
|
<div className="w-max min-w-full">
|
|
{rows.map((row, i) => (
|
|
<SplitCell key={i} side={side} line={side === "before" ? row.before : row.after} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
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 (
|
|
<div
|
|
className="px-3 py-0.5 leading-relaxed bg-muted/40 border-l-2 border-transparent min-h-[1.25rem]"
|
|
data-testid="plan-revision-diff-split-cell"
|
|
data-line-kind="empty"
|
|
data-side={side}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<div
|
|
className={cn("flex gap-2 px-3 py-0.5 leading-relaxed whitespace-pre", KIND_CLASS[line.kind])}
|
|
data-testid="plan-revision-diff-split-cell"
|
|
data-line-kind={line.kind}
|
|
data-side={side}
|
|
>
|
|
<span className="select-none text-muted-foreground w-3 shrink-0">
|
|
{KIND_PREFIX[line.kind]}
|
|
</span>
|
|
<span className="flex-1">{line.text || " "}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiffLoading() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex items-center gap-2 p-3 text-muted-foreground">
|
|
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t("task:loading2")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiffMessage({ children }: { children: ReactNode }) {
|
|
return <div className="p-3 text-muted-foreground italic">{children}</div>;
|
|
}
|
|
|
|
/** 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<string>,
|
|
): {
|
|
beforeContent: string | null;
|
|
afterContent: string | null;
|
|
lines: DiffLine[] | null;
|
|
error: string | null;
|
|
} {
|
|
const [beforeContent, setBeforeContent] = useState<string | null>(null);
|
|
const [afterContent, setAfterContent] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(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 : t("task:failedToLoadRevisionContent"));
|
|
});
|
|
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 };
|
|
}
|