/** * @file DestructiveLaneModal.tsx * @description Fetches and displays the exact lane lifecycle preflight facts * before delegating confirmation controls and accessibility to ConfirmModal. * * The server is the authority on what is permitted; this modal must never be * stricter than it. A blocker that does not actually stop the chosen action is * rendered as context (see HARD_BLOCKERS and noticesFor), not as a refusal — * treating every blocker as fatal is what left "Forget" dead for adopted lanes. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { ConfirmModal } from "../ConfirmModal"; import { api } from "../../lib/api"; import type { Lane, LanePreflight } from "../../lib/types"; type DestructiveAction = "reset" | "remove" | "purge"; export interface DestructiveLaneModalProps { lane: Lane; action: DestructiveAction; open: boolean; onClose: () => void; onConfirm: (body: { expect: Record; force?: true }) => void; } function expectFor(preflight: LanePreflight): Record { if (preflight.action === "purge") { return { sessions: preflight.sessions, events: preflight.events, tokenRows: preflight.tokenRows, }; } return { head: preflight.head, dirty: preflight.dirty, untracked: preflight.untracked, unpushed: preflight.unpushed, }; } /** * Which preflight blockers genuinely prevent each action in the UI. * * The server is the authority, and it permits EVERY shape of `remove`: an * adopted lane's record is forgotten with its directory untouched, a * hand-deleted worktree takes the prune path, an unreadable one is force * -removed. So `remove` is blocked here by nothing — treating `adopted` or * `missing` as blockers left the Forget button permanently dead with no * fallback. `reset` really is impossible in all three shapes. * * `unpushed-commits` is force-overridable and is gated by the Force checkbox * instead of by this list. */ const HARD_BLOCKERS: Record<"reset" | "remove", readonly string[]> = { reset: ["adopted", "missing", "unreadable"], remove: [], }; function blockingReason(preflight: LanePreflight | null): string | null { if (!preflight || preflight.action === "purge") return null; const hard = HARD_BLOCKERS[preflight.action]; return preflight.blocked.find((blocker) => hard.includes(blocker)) || null; } /** * Blockers that do not prevent THIS action but still change what it does, shown * as context rather than as an obstacle — e.g. forgetting an adopted lane leaves * its directory alone. `unpushed-commits` is excluded: the Force copy covers it. */ function noticesFor(preflight: LanePreflight | null): string[] { if (!preflight || preflight.action === "purge") return []; const hard = HARD_BLOCKERS[preflight.action]; return preflight.blocked.filter( (blocker) => blocker !== "unpushed-commits" && !hard.includes(blocker) ); } /** Rough byte estimate as a short human string; the server's own number is a guess. */ function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function warningsFor(preflight: LanePreflight | null): string[] { if (!preflight || preflight.action === "purge") return []; return preflight.warnings; } export function DestructiveLaneModal({ lane, action, open, onClose, onConfirm, }: DestructiveLaneModalProps) { const { t } = useTranslation(["lanes"]); const [preflight, setPreflight] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [force, setForce] = useState(false); useEffect(() => { if (!open) return; let current = true; setLoading(true); setError(null); setPreflight(null); setForce(false); void api.lanes .preflight(lane.id, action) .then((result) => current && setPreflight(result)) .catch((err) => current && setError(err instanceof Error ? err.message : t("preflightError"))) .finally(() => current && setLoading(false)); return () => { current = false; }; }, [action, lane.id, open, t]); const blocked = blockingReason(preflight); // Mirrors the server's gate exactly (server/routes/lanes.js): force is required // for `reset`, and for `remove` only when a real worktree is at risk. Forgetting // an adopted lane risks nothing on disk, so it needs no force even with unpushed // commits. Never offered alongside a hard blocker, where confirming is // impossible anyway. const requiresForce = !blocked && preflight !== null && preflight.action !== "purge" && preflight.blocked.includes("unpushed-commits") && (preflight.action === "reset" || preflight.kind === "managed"); const disabled = loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force); const facts = preflight ? Object.entries(expectFor(preflight)) : []; const purge = preflight?.action === "purge" ? preflight : null; return ( { if (!preflight || disabled) return; onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) }); }} > {loading &&

{t("destructive.loading")}

} {error && (

{t("preflightErrorWithMessage", { message: error })}

)} {preflight && ( {facts.map(([name, value]) => ( ))} {/* Not part of `expect`: an estimate the server never verifies. */} {purge && ( )}
{t(`destructive.count.${name}`)} {value ?? "—"}
{t("destructive.count.bytesEstimate")} {formatBytes(purge.bytesEstimate)}
)} {blocked && (

{t(`destructive.blocked.${blocked}`)}

)} {purge?.activeSessionSkipped && (

{t("destructive.notice.activeSessionSkipped")}

)} {noticesFor(preflight).map((notice) => (

{t(`destructive.notice.${notice}`)}

))} {warningsFor(preflight).map((warning) => (

{t(`destructive.warning.${warning}`)}

))} {requiresForce && ( )} {action === "reset" && (

{t("destructive.reset.survives")}

)}
); }