9f13769fb4
Adopting the main repo as its own lane now gets stage detection (cwd matches, same as any other lane), and its card lists every managed-worktree lane provisioned from it with a jump-to link. Also add the two missing Skill-tool detect rules (implement, ship) so detection covers all four Superpowers workflow phases, not just plan/review.
688 lines
26 KiB
TypeScript
688 lines
26 KiB
TypeScript
/**
|
|
* @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
|
|
* facts, the "needs you" banner sourced from Claude Code's Notification hook,
|
|
* and the control row — which ends in the two deletions (lane, history) as
|
|
* plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
|
|
* session went silent while it should have been working) is called out
|
|
* loudly — that is the failure this view exists to catch. Stage, kind, and
|
|
* the "auto: <stage>" chip are NOT repeated here — this card only ever
|
|
* renders inside the Workspace lane-detail section, whose header above it
|
|
* already shows them.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
|
import { api } from "../../lib/api";
|
|
import { eventBus } from "../../lib/eventBus";
|
|
import type { Lane, LaneGitFacts, LaneRuntime, NamedLock } from "../../lib/types";
|
|
|
|
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
|
* each call is three git subprocesses server-side, and a branch name does not
|
|
* change on the timescale the lane list is polled at. */
|
|
const GIT_REFRESH_MS = 30_000;
|
|
|
|
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
|
* because a stack dying is exactly what the user needs to see, and slower than
|
|
* the lane poll because each call opens a socket per declared port. */
|
|
const RUNTIME_REFRESH_MS = 10_000;
|
|
|
|
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
|
* as the git facts (slow, since lock state changes infrequently). */
|
|
const LOCKS_REFRESH_MS = 30_000;
|
|
|
|
/**
|
|
* The lane's own working copy, fetched per card rather than folded into the
|
|
* polled lane list. Absent facts are not an error state: a lane may point at a
|
|
* plain directory, and it renders as a card without a git row.
|
|
*/
|
|
function useLaneGitFacts(laneId: number): LaneGitFacts | null {
|
|
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const read = () => {
|
|
api.lanes
|
|
.git(laneId)
|
|
.then((f) => {
|
|
if (alive) setFacts(f);
|
|
})
|
|
.catch(() => {
|
|
// Silent: a card that cannot read git shows the rest of itself. An
|
|
// error banner here would fire on every lane on every server blip.
|
|
if (alive) setFacts({ available: false });
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(read, GIT_REFRESH_MS);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneId]);
|
|
|
|
return facts;
|
|
}
|
|
|
|
/**
|
|
* The lane's own application stack. Same shape and same silence as the git
|
|
* facts above: a lane without a `.ccam/profile` simply has no runtime row, which
|
|
* is the common case and not an error worth a banner.
|
|
*
|
|
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
|
* poll interval.
|
|
*/
|
|
function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null {
|
|
const [runtime, setRuntime] = useState<LaneRuntime | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const read = () => {
|
|
api.lanes
|
|
.runtime(laneId)
|
|
.then((r) => {
|
|
if (alive) setRuntime(r);
|
|
})
|
|
.catch(() => {
|
|
if (alive) setRuntime({ available: false });
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneId, bump]);
|
|
|
|
return runtime;
|
|
}
|
|
|
|
/**
|
|
* Locks held by THIS lane, polled the same way runtime/git facts are.
|
|
*/
|
|
function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
|
const [locks, setLocks] = useState<NamedLock[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!laneSlot) return;
|
|
let alive = true;
|
|
const holder = `lane${laneSlot}`;
|
|
const read = () => {
|
|
api.locks
|
|
.list()
|
|
.then((data) => {
|
|
if (alive) setLocks(data.locks.filter((l) => l.holder === holder));
|
|
})
|
|
.catch(() => {
|
|
/* fails silently, same contract as the git/runtime pollers */
|
|
});
|
|
};
|
|
read();
|
|
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
|
return () => {
|
|
alive = false;
|
|
clearInterval(timer);
|
|
};
|
|
}, [laneSlot]);
|
|
|
|
return locks;
|
|
}
|
|
|
|
const INTEGRATION_NAMES = ["tracker", "dev_qc", "ci_wait"] as const;
|
|
|
|
function useLaneIntegrations(
|
|
laneId: number,
|
|
available: boolean
|
|
): Record<(typeof INTEGRATION_NAMES)[number], boolean> | null {
|
|
const [state, setState] = useState<Record<string, boolean> | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!available) {
|
|
setState(null);
|
|
return;
|
|
}
|
|
let alive = true;
|
|
Promise.all(INTEGRATION_NAMES.map((name) => api.lanes.integration(laneId, name)))
|
|
.then((results) => {
|
|
if (!alive) return;
|
|
const next: Record<string, boolean> = {};
|
|
INTEGRATION_NAMES.forEach((name, i) => {
|
|
next[name] = results[i]?.enabled ?? false;
|
|
});
|
|
setState(next);
|
|
})
|
|
.catch(() => {
|
|
if (alive) setState(null);
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [laneId, available]);
|
|
|
|
return state as Record<(typeof INTEGRATION_NAMES)[number], boolean> | null;
|
|
}
|
|
|
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
|
active: "bg-status-success",
|
|
idle: "bg-surface-4",
|
|
dead: "bg-status-danger",
|
|
};
|
|
|
|
function since(sec: number | null): string {
|
|
if (sec === null) return "—";
|
|
if (sec < 60) return `${sec}s`;
|
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
|
|
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
|
}
|
|
|
|
export default function LaneCard({
|
|
lane,
|
|
onAction,
|
|
childWorktrees,
|
|
onSelectLane,
|
|
}: {
|
|
lane: Lane;
|
|
onAction: (action: string, body?: Record<string, unknown>) => void;
|
|
/** Other lanes whose `source_repo` is this lane's `cwd` — populated only
|
|
* when this lane is itself a source repo (typically an adopted one) that
|
|
* other lanes were provisioned as worktrees from. */
|
|
childWorktrees?: Lane[];
|
|
/** Jumps the Workspace page's selection to another lane's card. */
|
|
onSelectLane?: (id: number) => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>(
|
|
null
|
|
);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const [runtimeBump, setRuntimeBump] = useState(0);
|
|
const [runtimeBusy, setRuntimeBusy] = useState<"up" | "down" | null>(null);
|
|
const [bootLine, setBootLine] = useState<string | null>(null);
|
|
const [laneActionBusy, setLaneActionBusy] = useState<"agents" | "mcp" | "sync-check" | null>(
|
|
null
|
|
);
|
|
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
|
const git = useLaneGitFacts(lane.id);
|
|
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
|
const locks = useLaneLocks(lane.slot);
|
|
const integrations = useLaneIntegrations(lane.id, runtime?.available === true);
|
|
|
|
/**
|
|
* Boot or stop the lane's stack. Deliberately NOT routed through `onAction`:
|
|
* that prop drives the lane's Claude run, and folding a second lifecycle into
|
|
* it would make "stop" ambiguous about which thing it stops.
|
|
*
|
|
* `up` answers 202 and keeps booting in the background, so the button stays
|
|
* busy until the server's `lane_runtime` message says the attempt finished —
|
|
* resolving the request is not the same as the stack being up.
|
|
*/
|
|
const runtimeAction = async (which: "up" | "down") => {
|
|
setRuntimeBusy(which);
|
|
setBootLine(null);
|
|
try {
|
|
if (which === "down") {
|
|
await api.lanes.down(lane.id);
|
|
setRuntimeBusy(null);
|
|
} else {
|
|
await api.lanes.up(lane.id);
|
|
}
|
|
} catch {
|
|
// The failure surfaces as the runtime row's lastError on the next read; a
|
|
// toast here would say the same thing twice.
|
|
setRuntimeBusy(null);
|
|
} finally {
|
|
setRuntimeBump((n) => n + 1);
|
|
}
|
|
};
|
|
|
|
const runLaneAction = async (
|
|
which: "agents" | "mcp" | "sync-check",
|
|
fn: () => Promise<string>
|
|
) => {
|
|
setLaneActionBusy(which);
|
|
setLaneActionResult(null);
|
|
try {
|
|
setLaneActionResult(await fn());
|
|
} catch (err) {
|
|
setLaneActionResult(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setLaneActionBusy(null);
|
|
}
|
|
};
|
|
|
|
const handleAgentsInstall = () =>
|
|
runLaneAction("agents", async () => {
|
|
const result = await api.lanes.agentsInstall(lane.id);
|
|
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
|
});
|
|
|
|
const handleMcpSync = () =>
|
|
runLaneAction("mcp", async () => {
|
|
const result = await api.lanes.mcpSync(lane.id);
|
|
return t("actions.mcpSyncResult", { servers: result.servers.join(", ") || "none" });
|
|
});
|
|
|
|
const handleSyncCheck = () =>
|
|
runLaneAction("sync-check", async () => {
|
|
const result = await api.lanes.syncBaseCheck(lane.id);
|
|
if (result.code === 5 && result.collisions && result.collisions.length > 0) {
|
|
const c = result.collisions[0]!;
|
|
return t("actions.syncCheckCollision", { file: c.file, suggestion: c.suggestion });
|
|
}
|
|
return t("actions.syncCheckClean", {
|
|
count: result.devDelta?.length ?? 0,
|
|
overlap: result.overlap?.length ? result.overlap.join(", ") : "none",
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Live boot feedback. A build can run for minutes, and a card showing only a
|
|
* disabled button through all of it reads as a hang. The hook's own output
|
|
* lines are the honest progress indicator.
|
|
*/
|
|
useEffect(
|
|
() =>
|
|
eventBus.subscribe((msg) => {
|
|
const data = msg.data as { laneId?: number; line?: string } | undefined;
|
|
if (!data || data.laneId !== lane.id) return;
|
|
if (msg.type === "lane_hook_output" && typeof data.line === "string") {
|
|
setBootLine(data.line);
|
|
}
|
|
if (msg.type === "lane_runtime") {
|
|
setRuntimeBusy(null);
|
|
setBootLine(null);
|
|
setRuntimeBump((n) => n + 1);
|
|
}
|
|
}),
|
|
[lane.id]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
data-testid={`lane-card-${lane.id}`}
|
|
className="flex flex-col rounded-xl border border-border bg-surface-4 p-4 transition-colors hover:border-border-light"
|
|
>
|
|
{/* Identity strip: which lane, and is it alive. Kept on one line and in
|
|
uppercase so a wall of cards can be scanned vertically. */}
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
|
|
{t("cardId", { id: lane.id })}
|
|
</span>
|
|
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
|
|
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
|
|
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
|
|
non-standard status rendered as the literal "status.foo".
|
|
defaultValue makes it degrade to the raw status instead. */}
|
|
{lane.liveness === "dead"
|
|
? t("statusDead")
|
|
: t(`status.${lane.status}`, { defaultValue: lane.status })}
|
|
</span>
|
|
</div>
|
|
|
|
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-fg-primary">
|
|
{lane.title || lane.cwd}
|
|
</h3>
|
|
|
|
{/* Progress line: how far through the pipeline and how long the
|
|
current stage has been sitting there. The stage's own name is
|
|
already in the Workspace header above; not repeated here. */}
|
|
<div className="mb-2 flex items-center gap-2 text-xs">
|
|
<div
|
|
className={`h-1 flex-1 overflow-hidden rounded-full ${
|
|
lane.progress > 0 ? "bg-surface-2" : "bg-transparent"
|
|
}`}
|
|
>
|
|
<div
|
|
data-testid="lane-progress-fill"
|
|
className="h-full rounded-full bg-blue-600 transition-[width]"
|
|
style={{ width: `${lane.progress}%` }}
|
|
/>
|
|
</div>
|
|
{lane.progress > 0 && (
|
|
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
|
|
)}
|
|
<span className="tabular-nums text-fg-muted">{since(lane.stage_seconds)}</span>
|
|
</div>
|
|
|
|
{lane.ci_status && (
|
|
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
|
|
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
|
CI {lane.ci_status}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{lane.needs_action && (
|
|
<div className="mb-3 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
|
⚠ {lane.needs_action}
|
|
</div>
|
|
)}
|
|
|
|
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
|
declare a profile. A port that drifted from `base + slot` is called
|
|
out — the number is otherwise predictable from the slot, and silently
|
|
serving on a different one is exactly the surprise worth flagging. */}
|
|
{runtime?.available && runtime.provisioned && (
|
|
<div
|
|
data-testid={`lane-runtime-${lane.id}`}
|
|
className="mb-3 rounded border border-border bg-surface-2/50 px-2 py-1.5 font-mono text-[11px]"
|
|
>
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<span className="text-fg-muted">{t("runtime.slot", { slot: runtime.slot })}</span>
|
|
<span
|
|
className={runtime.healthy ? "text-status-success" : "text-fg-muted"}
|
|
data-testid="lane-runtime-state"
|
|
>
|
|
{runtime.healthy
|
|
? t("runtime.healthy")
|
|
: runtime.up
|
|
? t("runtime.partial")
|
|
: t("runtime.down")}
|
|
</span>
|
|
</div>
|
|
{Object.entries(runtime.ports).map(([name, info]) => (
|
|
<div key={name} className="flex items-center gap-1.5 truncate">
|
|
<span
|
|
className={`h-1.5 w-1.5 shrink-0 rounded-full ${
|
|
info.listening ? "bg-status-success" : "bg-surface-4"
|
|
}`}
|
|
/>
|
|
<span className="text-fg-secondary">{name}</span>
|
|
<span className="text-fg-muted">:{info.port ?? "—"}</span>
|
|
{info.port !== null && info.port !== info.expected && (
|
|
<span
|
|
className="truncate text-status-warning/80"
|
|
title={t("runtime.steppedAsideTitle", { expected: info.expected })}
|
|
>
|
|
⚠ {info.expected}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
{/* While a boot is in flight the hook's own latest line IS the
|
|
progress bar — a build can take minutes, and a disabled button
|
|
with nothing moving reads as a hang. */}
|
|
{bootLine !== null && (
|
|
<div
|
|
data-testid="lane-runtime-bootline"
|
|
className="mt-1 truncate text-fg-muted"
|
|
title={bootLine}
|
|
>
|
|
{bootLine}
|
|
</div>
|
|
)}
|
|
{bootLine === null && runtime.lastError && (
|
|
<div
|
|
className="mt-1 truncate text-status-danger/90"
|
|
title={runtime.lastError.message}
|
|
>
|
|
{runtime.lastError.code || "error"}: {runtime.lastError.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{locks.length > 0 && (
|
|
<div
|
|
className="mb-3 flex items-center gap-1 text-xs text-status-warning"
|
|
data-testid={`lane-locks-${lane.id}`}
|
|
title={locks.map((l) => `${l.name} (${Math.floor(l.ageSec / 60)}m)`).join(", ")}
|
|
>
|
|
🔒 {t("locks.held", { count: locks.length })}
|
|
</div>
|
|
)}
|
|
|
|
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
|
|
{git?.available && (
|
|
<div data-testid="lane-git" className="space-y-1">
|
|
<div className="truncate">
|
|
⑂ {git.branch}
|
|
<span className="ml-2 text-fg-muted">{git.head}</span>
|
|
</div>
|
|
<div className="truncate text-fg-muted" title={git.subject}>
|
|
{git.subject}
|
|
</div>
|
|
{(git.dirty > 0 || git.untracked > 0) && (
|
|
<div className="text-status-warning/80">
|
|
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{/* The lane's own recorded branch, shown only when git could not be
|
|
read — otherwise it duplicates the live branch above. */}
|
|
{!git?.available && lane.branch && <div className="truncate">⑂ {lane.branch}</div>}
|
|
<div className="truncate text-fg-muted" title={lane.cwd}>
|
|
{lane.cwd}
|
|
</div>
|
|
</dl>
|
|
|
|
{childWorktrees && childWorktrees.length > 0 && (
|
|
<div
|
|
data-testid="lane-child-worktrees"
|
|
className="mb-3 space-y-1 text-[11px] text-fg-secondary"
|
|
>
|
|
<div className="text-fg-muted">
|
|
{t("worktrees.heading", { count: childWorktrees.length })}
|
|
</div>
|
|
<ul className="space-y-0.5">
|
|
{childWorktrees.map((w) => (
|
|
<li key={w.id}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectLane?.(w.id)}
|
|
className="truncate text-left text-blue-400 hover:underline"
|
|
title={w.cwd}
|
|
>
|
|
#{w.id} {w.title || w.cwd} · {w.status}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{integrations && (
|
|
<div className="mb-2 flex items-center gap-1.5 text-[10px]">
|
|
{(["tracker", "dev_qc", "ci_wait"] as const).map((name) => (
|
|
<span
|
|
key={name}
|
|
data-testid={`lane-integration-${name}`}
|
|
className={`rounded-full px-2 py-0.5 ${
|
|
integrations[name]
|
|
? "bg-status-success/10 text-status-success"
|
|
: "bg-surface-2 text-fg-muted"
|
|
}`}
|
|
>
|
|
{t(`integrations.${name}`)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* mt-auto pins the controls to the bottom so cards of differing height
|
|
in one grid row still line their buttons up. */}
|
|
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
|
|
{(["start", "stop", "clear"] as const).map((a) => (
|
|
<button
|
|
key={a}
|
|
type="button"
|
|
data-testid={`lane-action-${a}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAction(a);
|
|
}}
|
|
className={`rounded px-2 py-1 transition-colors ${
|
|
a === "start"
|
|
? "bg-blue-600/15 text-blue-400 hover:bg-blue-600/25"
|
|
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
|
|
}`}
|
|
title={a === "start" ? t("tooltipStart") : undefined}
|
|
>
|
|
{t(`action.${a}`)}
|
|
</button>
|
|
))}
|
|
{/* Stack controls, only for a lane whose repo declares a profile.
|
|
Separate from start/stop above: those drive the lane's Claude run,
|
|
these drive the application it is working on. */}
|
|
{runtime?.available && (
|
|
<button
|
|
type="button"
|
|
data-testid="lane-runtime-toggle"
|
|
disabled={runtimeBusy !== null}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void runtimeAction(runtime.provisioned && runtime.up ? "down" : "up");
|
|
}}
|
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
|
title={t("runtime.toggleTitle")}
|
|
>
|
|
{runtimeBusy
|
|
? t(`runtime.busy.${runtimeBusy}`)
|
|
: runtime.provisioned && runtime.up
|
|
? t("runtime.stop")
|
|
: t("runtime.boot")}
|
|
</button>
|
|
)}
|
|
{runtime?.available && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
data-testid="lane-agents-install"
|
|
disabled={laneActionBusy !== null}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void handleAgentsInstall();
|
|
}}
|
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
|
>
|
|
{laneActionBusy === "agents" ? t("actions.busy") : t("actions.agentsInstall")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="lane-mcp-sync"
|
|
disabled={laneActionBusy !== null}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void handleMcpSync();
|
|
}}
|
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
|
>
|
|
{laneActionBusy === "mcp" ? t("actions.busy") : t("actions.mcpSync")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="lane-sync-check"
|
|
disabled={laneActionBusy !== null}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void handleSyncCheck();
|
|
}}
|
|
className="rounded px-2 py-1 text-fg-secondary transition-colors hover:bg-surface-2 disabled:opacity-50"
|
|
>
|
|
{laneActionBusy === "sync-check" ? t("actions.busy") : t("actions.syncCheck")}
|
|
</button>
|
|
</>
|
|
)}
|
|
{/* Deleting the lane and deleting its history are each their own
|
|
button, by request: hiding "delete" behind a ⋯ made it unfindable,
|
|
and the one label that read as "delete" was `clear`. Neither fires
|
|
directly — both open DestructiveLaneModal, which shows the exact
|
|
counts and demands a confirmation. `reset` stays in the menu; it
|
|
is the rare one and only exists for managed lanes. */}
|
|
<div className="ml-auto flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
data-testid="lane-action-purge"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDestructiveAction("purge");
|
|
}}
|
|
className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
|
|
>
|
|
{t("action.purge")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-testid="lane-action-remove"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setDestructiveAction("remove");
|
|
}}
|
|
className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
|
|
>
|
|
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
|
|
</button>
|
|
{/* Adopted lanes have no worktree to reset, so the menu would be
|
|
empty — it is not rendered at all rather than opening onto
|
|
nothing. */}
|
|
{lane.kind === "managed" && (
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
data-testid="lane-more"
|
|
aria-haspopup="menu"
|
|
aria-expanded={menuOpen}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMenuOpen((v) => !v);
|
|
}}
|
|
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
|
|
title={t("moreActions")}
|
|
>
|
|
⋯
|
|
</button>
|
|
{menuOpen && (
|
|
<div
|
|
role="menu"
|
|
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="lane-action-reset"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setMenuOpen(false);
|
|
setDestructiveAction("reset");
|
|
}}
|
|
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
|
|
>
|
|
{t("action.reset")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{laneActionResult && (
|
|
<p
|
|
data-testid="lane-action-result"
|
|
className="mt-1 truncate text-[11px] text-fg-secondary"
|
|
>
|
|
{laneActionResult}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{destructiveAction && (
|
|
<DestructiveLaneModal
|
|
lane={lane}
|
|
action={destructiveAction}
|
|
open
|
|
onClose={() => setDestructiveAction(null)}
|
|
onConfirm={(body) => {
|
|
setDestructiveAction(null);
|
|
onAction(destructiveAction, body);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|