import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { api } from "../api"; import { useConfirm } from "../hooks/useConfirm"; import { LoadingSpinner } from "./LoadingSpinner"; import "./StashRecoveryView.css"; type RecordItem = { sha: string; sourceTaskId: string | null; createdAt: string | null; classification: "subsumed" | "live" | "unknown"; changedPaths: string[]; }; type DiffResponse = { diff: string; truncated: boolean; }; export function StashRecoveryView() { const { t } = useTranslation("app"); const { confirm } = useConfirm(); const [records, setRecords] = useState([]); const [error, setError] = useState(null); const [applyState, setApplyState] = useState>({}); const [diffState, setDiffState] = useState<{ sha: string; diff: string; truncated: boolean; loading: boolean; error: string | null } | null>(null); const load = useCallback(async () => { try { setError(null); const data = await api<{ records: RecordItem[] }>("/stash-recovery/orphans"); setRecords(data.records ?? []); } catch (err) { setError(err instanceof Error ? err.message : t("stashRecovery.failedToLoadOrphans", "Failed to load orphans")); } }, [t]); useEffect(() => { void load(); }, [load]); const groups = useMemo(() => { const map = new Map(); for (const record of records) { const key = record.sourceTaskId ?? t("stashRecovery.unknownSource", "Unknown source"); const existing = map.get(key) ?? []; existing.push(record); map.set(key, existing); } return Array.from(map.entries()); }, [records, t]); const handleApply = useCallback(async (sha: string) => { const result = await api<{ ok: boolean; reason?: string; stderr?: string }>(`/stash-recovery/orphans/${sha}/apply`, { method: "POST" }); setApplyState((prev) => ({ ...prev, [sha]: result.ok ? t("stashRecovery.applied", "Applied") : result.stderr ?? result.reason ?? t("stashRecovery.applyFailed", "Apply failed") })); }, [t]); const handleDrop = useCallback(async (sha: string) => { const shouldDrop = await confirm({ title: t("stashRecovery.dropTitle", "Drop orphaned stash?"), message: t("stashRecovery.dropMessage", "This removes the stash entry permanently."), confirmLabel: t("stashRecovery.dropConfirm", "Drop"), danger: true, }); if (!shouldDrop) return; await api(`/stash-recovery/orphans/${sha}/drop`, { method: "POST", body: JSON.stringify({ confirm: true }) }); await load(); }, [confirm, load, t]); const handleInspectDiff = useCallback(async (sha: string) => { setDiffState({ sha, diff: "", truncated: false, loading: true, error: null }); try { const data = await api(`/stash-recovery/orphans/${sha}/diff`); setDiffState({ sha, diff: data.diff ?? "", truncated: Boolean(data.truncated), loading: false, error: null }); } catch (err) { setDiffState({ sha, diff: "", truncated: false, loading: false, error: err instanceof Error ? err.message : t("stashRecovery.failedToLoadDiff", "Failed to load diff") }); } }, [t]); if (records.length === 0 && !error) { return

{t("stashRecovery.noOrphans", "No orphaned merger autostashes found.")}

; } return (

{t("stashRecovery.title", "Stash Recovery")}

{t("stashRecovery.orphanCount", "{{count}} orphans", { count: records.length })}
{error &&
{error}
} {groups.map(([group, items]) => (

{group}

{items.map((item) => (
{t("stashRecovery.shaLabel", "SHA")} {item.sha.slice(0, 7)}
{t("stashRecovery.classificationLabel", "Classification")} {item.classification}
{t("stashRecovery.changedPathsLabel", "Changed paths")} {t("stashRecovery.fileCount", "{{count}} files", { count: item.changedPaths.length })}
{applyState[item.sha] &&
{applyState[item.sha]}
}
))}
))} {diffState && (
setDiffState(null)}>
event.stopPropagation()}>

{t("stashRecovery.diffHeader", "Diff for {{sha}}", { sha: diffState.sha.slice(0, 7) })}

{diffState.loading &&

} {diffState.error &&
{diffState.error}
} {!diffState.loading && !diffState.error && ( <>
{diffState.diff || t("stashRecovery.noDiffOutput", "No diff output available.")}
{diffState.truncated &&

{t("stashRecovery.diffTruncated", "Diff output truncated.")}

} )}
)}
); }