Files
AgentHub/client/src/components/lanes/DestructiveLaneModal.tsx
T
nntrivi2001 0004cf7036 fix(lanes): removing a lane deletes its record only, never the worktree
A lane's `remove` tore down what the lane pointed at: `git worktree remove
--force`, a branch delete, and a drop of the lane's own database. One
mis-click on a dashboard row cost a whole branch's unpushed code — and the
row is the only thing AgentHub actually owns. `remove` now stops the run and
the stack, clears AgentHub's own runtime bookkeeping under
`LANES_ROOT/.state/lane<slot>/`, and deletes the row. The working tree, its
branch, git's worktree records and the database stay exactly where they are,
for the user to delete by hand when they mean to.

That also fixes #28. Removal used to route through `assertDestroyable`, whose
third check demands the cwd appear in `git worktree list`; a managed lane
whose directory git no longer listed answered `ENOTWORKTREE` ("lane is not
listed as a worktree in …") and could never be dropped from the dashboard at
all. Removal no longer asks git anything, so no preflight blocker stops it,
and the unpushed-commits force-gate now applies to `reset` alone — nothing
`remove` does can discard a commit.

The UI follows: one label for both lane kinds ("Delete lane record"), copy
that says what survives, and no Force checkbox on removal.
`removeWorktree`/`removeLaneData` remain in place as guarded primitives; no
route calls them any more.
2026-09-18 09:44:17 +07:00

229 lines
8.3 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<string, string | number | null>; force?: true }) => void;
}
function expectFor(preflight: LanePreflight): Record<string, string | number | null> {
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<LanePreflight | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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` alone. `remove` forgets the lane's row and deletes nothing on
// disk — no worktree, no branch, no database — so there is no unpushed work it
// could discard. Never offered alongside a hard blocker, where confirming is
// impossible anyway.
const requiresForce =
!blocked &&
preflight !== null &&
preflight.action === "reset" &&
preflight.blocked.includes("unpushed-commits");
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 (
<ConfirmModal
open={open}
title={t(`destructive.${action}.title`)}
message={t(`destructive.${action}.message`)}
confirmLabel={t(`destructive.${action}.confirm`)}
cancelLabel={t("destructive.cancel")}
busy={loading}
disabled={disabled}
onCancel={onClose}
onConfirm={() => {
if (!preflight || disabled) return;
onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) });
}}
>
{loading && <p className="mt-3 text-xs text-fg-secondary">{t("destructive.loading")}</p>}
{error && (
<p className="mt-3 text-xs text-status-danger">
{t("preflightErrorWithMessage", { message: error })}
</p>
)}
{preflight && (
<table className="mt-3 w-full text-left text-xs text-fg-secondary">
<tbody>
{facts.map(([name, value]) => (
<tr key={name} className="border-t border-border">
<th scope="row" className="py-1.5 font-medium text-fg-secondary">
{t(`destructive.count.${name}`)}
</th>
<td className="py-1.5 text-right tabular-nums">{value ?? "—"}</td>
</tr>
))}
{/* Not part of `expect`: an estimate the server never verifies. */}
{purge && (
<tr className="border-t border-border">
<th scope="row" className="py-1.5 font-medium text-fg-secondary">
{t("destructive.count.bytesEstimate")}
</th>
<td className="py-1.5 text-right tabular-nums">
{formatBytes(purge.bytesEstimate)}
</td>
</tr>
)}
</tbody>
</table>
)}
{blocked && (
<p className="mt-3 rounded border border-status-warning/60 bg-status-warning/30 px-2 py-1.5 text-xs text-status-warning">
{t(`destructive.blocked.${blocked}`)}
</p>
)}
{purge?.activeSessionSkipped && (
<p className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary">
{t("destructive.notice.activeSessionSkipped")}
</p>
)}
{noticesFor(preflight).map((notice) => (
<p
key={notice}
className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary"
>
{t(`destructive.notice.${notice}`)}
</p>
))}
{warningsFor(preflight).map((warning) => (
<p
key={warning}
className="mt-3 rounded border border-border-light bg-surface-2/50 px-2 py-1.5 text-xs text-fg-secondary"
>
{t(`destructive.warning.${warning}`)}
</p>
))}
{requiresForce && (
<label className="mt-3 flex items-start gap-2 text-xs text-status-warning">
<input
type="checkbox"
checked={force}
onChange={(event) => setForce(event.target.checked)}
className="mt-0.5"
/>
{t("destructive.force")}
</label>
)}
{action === "reset" && (
<p className="mt-3 text-xs text-fg-secondary">{t("destructive.reset.survives")}</p>
)}
</ConfirmModal>
);
}