import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
ArrowDown01Icon,
Cancel01Icon,
FileEditIcon,
FilePlusIcon,
FolderAddIcon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useState } from "react";
import { usePlanStore, type QueuedEdit } from "../store/planStore";
function basename(p: string): string {
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
return i >= 0 ? p.slice(i + 1) : p;
}
function diffStats(
original: string,
proposed: string,
): { added: number; removed: number } {
const a = original.split("\n");
const b = proposed.split("\n");
const setA = new Set(a);
const setB = new Set(b);
let added = 0;
let removed = 0;
for (const line of b) if (!setA.has(line)) added++;
for (const line of a) if (!setB.has(line)) removed++;
return { added, removed };
}
export function PlanDiffReview() {
const queue = usePlanStore((s) => s.queue);
const removeOne = usePlanStore((s) => s.removeOne);
const clear = usePlanStore((s) => s.clear);
const applyAll = usePlanStore((s) => s.applyAll);
const [busy, setBusy] = useState(false);
if (queue.length === 0) return null;
const onApply = async () => {
setBusy(true);
try {
const results = await applyAll();
const failed = results.filter((r) => !r.ok);
if (failed.length) {
console.error("plan apply failures:", failed);
}
} finally {
setBusy(false);
}
};
return (
Plan review
{queue.length} pending change{queue.length === 1 ? "" : "s"}
clear()}
disabled={busy}
>
Discard all
Apply {queue.length}
{queue.map((q) => (
removeOne(q.id)} />
))}
);
}
function PlanRow({
item,
onReject,
}: {
item: QueuedEdit;
onReject: () => void;
}) {
const [open, setOpen] = useState(false);
const isDir = item.kind === "create_directory";
const isNew = item.isNewFile && !isDir;
const stats = isDir
? null
: diffStats(item.originalContent, item.proposedContent);
const Icon = isDir
? FolderAddIcon
: isNew
? FilePlusIcon
: FileEditIcon;
return (
!isDir && setOpen((v) => !v)}
disabled={isDir}
className={cn(
"mt-0.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
isDir && "invisible",
)}
aria-label="Toggle diff"
>
{basename(item.path)}
{isNew ? (
new
) : null}
{item.path}
{stats ? (
+{stats.added}
−{stats.removed}
{item.kind === "multi_edit" ? "multi-edit" : item.kind}
) : (
{item.description ?? "create directory"}
)}
{open && !isDir ? (
) : null}
);
}
function UnifiedDiffPreview({
original,
proposed,
}: {
original: string;
proposed: string;
}) {
// Coarse line-level diff (LCS-lite via set membership). For real diffs
// we'd reach for a library; this is good enough for at-a-glance review.
const a = original.split("\n");
const b = proposed.split("\n");
const setA = new Set(a);
const setB = new Set(b);
const lines: Array<{ kind: "add" | "del" | "ctx"; text: string }> = [];
// First pass: removed (in a, not in b).
for (const l of a) if (!setB.has(l)) lines.push({ kind: "del", text: l });
// Then: added (in b, not in a).
for (const l of b) if (!setA.has(l)) lines.push({ kind: "add", text: l });
if (lines.length === 0) {
return (
no line-level changes
);
}
const MAX = 80;
const shown = lines.slice(0, MAX);
const rest = lines.length - shown.length;
return (
{shown.map((l, i) => (
{l.kind === "add" ? "+" : "-"}
{l.text || " "}
))}
{rest > 0 ? (
… {rest} more changes
) : null}
);
}