molecule-core/canvas/src/components/BatchActionBar.tsx
Hongming Wang b1a1c8e4a9 canvas/BatchActionBar: wire Esc to clear selection (matches button title)
Two small fixes for the batch-action toolbar:

1. The deselect button's title says "Clear selection (Escape)" — but
   pressing Escape did NOTHING. The title has been lying since the bar
   shipped. Now wired: window keydown handler calls clearSelection
   when Esc fires. Skipped while the confirm dialog is open
   (`pending !== null`) so the dialog's own Esc-cancels takes
   precedence, and skipped during a busy in-flight action so the
   user can't strand a partial-failure mid-flight.

2. focus-visible:ring-zinc-500/70 → focus-visible:ring-accent/50
   on the deselect button. The hardcoded zinc broke the semantic-
   token pattern used by the other action buttons.

Tests: two new vitest cases — Esc clears with selection, Esc no-op
when empty (the bar isn't mounted at count===0 so the listener never
registers). Full suite: 1222/1222.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:31:23 -07:00

173 lines
7.2 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useCanvasStore } from "@/store/canvas";
import { ConfirmDialog } from "./ConfirmDialog";
import { showToast } from "./Toaster";
type BatchAction = "restart" | "pause" | "delete" | null;
export function BatchActionBar() {
const selectedNodeIds = useCanvasStore((s) => s.selectedNodeIds);
const clearSelection = useCanvasStore((s) => s.clearSelection);
const batchRestart = useCanvasStore((s) => s.batchRestart);
const batchPause = useCanvasStore((s) => s.batchPause);
const batchDelete = useCanvasStore((s) => s.batchDelete);
const [pending, setPending] = useState<BatchAction>(null);
const [busy, setBusy] = useState(false);
// Retry survivorship (QA pr-949 follow-up): when a batch action partial-fails
// and leaves a single survivor id, the default `count < 2` gate unmounts the
// bar and forces per-node context-menu retry. Track "active failure" so the
// bar stays mounted with a single item and the user can click the same action
// button to retry without re-selecting. Resets on success or Escape/clear.
const [hasFailedBatch, setHasFailedBatch] = useState(false);
const count = selectedNodeIds.size;
// Reset failure flag when the user clears selection (Escape / ✕ button).
useEffect(() => {
if (count === 0 && hasFailedBatch) setHasFailedBatch(false);
}, [count, hasFailedBatch]);
// Esc clears selection — the deselect button title has been promising
// "(Escape)" since the bar shipped, but no handler was wired. Skip when
// the confirm dialog is open (`pending !== null`) so the dialog's own
// Esc-cancels takes precedence and we don't double-handle the keystroke.
// Also skip during a busy in-flight action so the user can't accidentally
// strand a partial-failure mid-flight.
useEffect(() => {
if (count === 0 || pending !== null || busy) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
clearSelection();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [count, pending, busy, clearSelection]);
// Hide when nothing is selected. Hide for single-node selection UNLESS a
// partial-failure left a survivor awaiting retry.
if (count === 0) return null;
if (count < 2 && !hasFailedBatch) return null;
// Message copy must handle both multi (count >= 2) and single-survivor retry
// (count === 1 && hasFailedBatch). Use a helper so we render singular form
// only when there is exactly one survivor to act on.
const plural = (n: number) => (n === 1 ? "workspace" : "workspaces");
const confirmMessages: Record<NonNullable<BatchAction>, string> = {
restart: `Restart ${count} ${plural(count)}? Each will briefly go offline while it restarts.`,
pause: `Pause ${count} ${plural(count)}? Their containers will be stopped.`,
delete: `Permanently delete ${count} ${plural(count)}? This cannot be undone.`,
};
const confirmLabels: Record<NonNullable<BatchAction>, string> = {
restart: "Restart All",
pause: "Pause All",
delete: "Delete All",
};
async function execute() {
if (!pending) return;
setBusy(true);
try {
if (pending === "restart") await batchRestart();
if (pending === "pause") await batchPause();
if (pending === "delete") await batchDelete();
// Reaching here means every store call fulfilled (the store throws on
// any partial failure), so `count` is the actual success count.
showToast(`${pending.charAt(0).toUpperCase() + pending.slice(1)} applied to ${count} ${plural(count)}`, "success");
setHasFailedBatch(false);
clearSelection();
} catch (e) {
const msg = e instanceof Error && e.message ? e.message : `Batch ${pending} failed`;
showToast(msg, "error");
// Leave the failed IDs selected (the store preserved them) so the user
// can retry without re-selecting, and set hasFailedBatch so the bar
// stays mounted even if a single survivor remains.
setHasFailedBatch(true);
} finally {
setBusy(false);
setPending(null);
}
}
const bar = (
<div
role="toolbar"
aria-label="Batch workspace actions"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-sunken/95 border border-line/70 shadow-2xl shadow-black/50 backdrop-blur-md"
>
{/* Selection count badge */}
<span className="text-[12px] font-semibold text-white bg-accent-strong/80 px-2.5 py-0.5 rounded-full tabular-nums">
{count} selected
</span>
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
{/* Action buttons */}
<button
type="button"
disabled={busy}
onClick={() => setPending("restart")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
>
<span aria-hidden="true"></span>
Restart All
</button>
<button
type="button"
disabled={busy}
onClick={() => setPending("pause")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-warm bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
>
<span aria-hidden="true"></span>
Pause All
</button>
<button
type="button"
disabled={busy}
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-bad bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
>
<span aria-hidden="true"></span>
Delete All
</button>
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
{/* Deselect */}
<button
type="button"
disabled={busy}
onClick={clearSelection}
aria-label="Clear selection"
title="Clear selection (Escape)"
className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
</button>
</div>
);
return (
<>
{typeof window !== "undefined" ? createPortal(bar, document.body) : null}
<ConfirmDialog
open={!!pending}
title={pending ? confirmLabels[pending] : ""}
message={pending ? confirmMessages[pending] : ""}
confirmLabel={pending ? confirmLabels[pending] : "Confirm"}
confirmVariant={pending === "delete" ? "danger" : pending === "pause" ? "warning" : "primary"}
onConfirm={execute}
onCancel={() => setPending(null)}
/>
</>
);
}