"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { diffLines, type ChangeObject } from "diff"; import { selectActiveTask, useStore } from "@/lib/store"; import { deleteRun, listRuns, MAX_VERSIONS_PER_TASK, type RunRecord, } from "@/lib/history/db"; import { isCurrentRun } from "@/lib/history/is-current"; import { previewHtml } from "@/lib/extract-html"; import { relativeTime, useMounted } from "@/lib/use-autosave"; import { useT } from "@/lib/i18n"; type Mode = "list" | "diff"; /** * Per-task version timeline. Reads from IndexedDB so the full HTML payloads * for past runs don't bloat localStorage. Lets the user inspect, restore, * or side-by-side compare any of the last `MAX_VERSIONS_PER_TASK` versions. */ export function HistoryPane() { const open = useStore((s) => s.historyPaneOpen); const setOpen = useStore((s) => s.setHistoryPaneOpen); const activeTaskId = useStore((s) => s.activeTaskId); const activeTask = useStore(selectActiveTask); const setHtmlFor = useStore((s) => s.setHtmlFor); const setContent = useStore((s) => s.setContent); const commitBase = useStore((s) => s.commitBaseFor); const setStatusFor = useStore((s) => s.setStatusFor); const t = useT(); const mounted = useMounted(); const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(false); const [mode, setMode] = useState("list"); // Versions selected for the diff view. Left = older, right = newer (by default). const [leftId, setLeftId] = useState(null); const [rightId, setRightId] = useState(null); const [showSourceDiff, setShowSourceDiff] = useState(false); // The persist middleware writes synchronously on every action, so we // refresh the list whenever the active task's html changes — that's our // signal that a new version was just committed. const activeHtml = activeTask?.html ?? ""; const refresh = useCallback(() => { if (!activeTaskId) { setRuns([]); return; } setLoading(true); listRuns(activeTaskId) .then((rows) => setRuns(rows)) .finally(() => setLoading(false)); }, [activeTaskId]); useEffect(() => { if (!open) return; refresh(); }, [open, refresh, activeHtml]); // Whenever the task changes, drop back to list mode so we don't render // diff against a stale version pair. useEffect(() => { setMode("list"); setLeftId(null); setRightId(null); setShowSourceDiff(false); }, [activeTaskId]); if (!open) return null; const onOpenDiff = (record: RunRecord) => { // Compare the picked version against the latest run if there is one, // otherwise against itself (degenerate but doesn't crash). const latest = runs[0]; const pickRight = latest && latest.id !== record.id ? latest.id : record.id; setLeftId(record.id); setRightId(pickRight); setMode("diff"); }; const onRestore = (record: RunRecord) => { if (!activeTaskId) return; if (!confirm(t("history.restoreConfirm", { v: record.version }))) return; setHtmlFor(activeTaskId, record.html); setContent(record.content); setStatusFor(activeTaskId, "done"); // Snapshot the restored state as a new version so the user can roll // forward again later; commitBaseFor also resets the diff-edit baseline. commitBase(activeTaskId); refresh(); }; const onDelete = async (record: RunRecord) => { if (!activeTaskId) return; if (!confirm(t("history.deleteConfirm", { v: record.version }))) return; await deleteRun(activeTaskId, record.version); refresh(); }; return ( ); } function HistoryList({ runs, loading, mounted, activeHtml, onCompare, onRestore, onDelete, }: { runs: RunRecord[]; loading: boolean; mounted: boolean; activeHtml: string; onCompare: (r: RunRecord) => void; onRestore: (r: RunRecord) => void; onDelete: (r: RunRecord) => void; }) { const t = useT(); if (loading && runs.length === 0) { return (
{t("history.loading")}
); } if (runs.length === 0) { return (
{t("history.empty.noVersions")}
); } return (
{runs.map((r) => ( onCompare(r)} onRestore={() => onRestore(r)} onDelete={() => onDelete(r)} /> ))}
); } function HistoryCard({ run, isCurrent, mounted, onCompare, onRestore, onDelete, }: { run: RunRecord; isCurrent: boolean; mounted: boolean; onCompare: () => void; onRestore: () => void; onDelete: () => void; }) { const t = useT(); const sizeKB = (run.html.length / 1024).toFixed(1); return (
v{run.version} {isCurrent && ( {t("history.current")} )}
{sizeKB} KB
{mounted ? relativeTime(run.ts) : ""}
); } function DiffView({ runs, leftId, rightId, onPickLeft, onPickRight, showSource, onToggleSource, }: { runs: RunRecord[]; leftId: string | null; rightId: string | null; onPickLeft: (id: string) => void; onPickRight: (id: string) => void; showSource: boolean; onToggleSource: () => void; }) { const t = useT(); const left = useMemo(() => runs.find((r) => r.id === leftId) ?? null, [runs, leftId]); const right = useMemo(() => runs.find((r) => r.id === rightId) ?? null, [runs, rightId]); const changes = useMemo[]>(() => { if (!showSource || !left || !right) return []; // Line-level diff over the raw HTML source. This is line-level not // visual; we explicitly punted on visual diff in the design. return diffLines(left.html, right.html); }, [showSource, left, right]); return (
{left && right && ( v{left.version} → v{right.version} )}
{!left || !right ? (
{t("history.diff.pickPair")}
) : showSource ? ( ) : ( )}
); } function VersionPicker({ runs, value, onChange, label, }: { runs: RunRecord[]; value: string | null; onChange: (id: string) => void; label: string; }) { return ( ); } function VisualDiff({ left, right }: { left: RunRecord; right: RunRecord }) { // Two iframes rendering each version's HTML stacked vertically (the pane // is narrow at 280px so horizontal split would shrink each render below // any useful resolution). srcdoc is intentional: it sandboxes scripts and // keeps history previews from contaminating the real preview pane. const t = useT(); return (
); } function DiffIframe({ html, label, caption }: { html: string; label: string; caption: string }) { const srcDoc = useMemo(() => previewHtml(html), [html]); return (
{label}