0004cf7036
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.
229 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|