From 6de705b2a124f292f03397741e1baa7d31a5405d Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sat, 18 Apr 2026 01:16:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20batch=20operations=20=E2=80=94?= =?UTF-8?q?=20multi-select=20+=20restart/pause/delete=20(Phase=2020.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shift+click to toggle node selection (multi-select mode) - BatchActionBar floating at bottom when >1 node selected - Batch Restart All, Pause All, Delete All with ConfirmDialog - Selected nodes get blue ring highlight - Escape clears selection - Pane click clears selection - Dark theme, accessible (ARIA labels, focus rings) Co-Authored-By: Claude Opus 4.6 (1M context) --- canvas/src/components/BatchActionBar.tsx | 124 +++++++++++++++++++++++ canvas/src/components/Canvas.tsx | 9 +- canvas/src/components/WorkspaceNode.tsx | 17 +++- canvas/src/store/canvas.ts | 39 +++++++ 4 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 canvas/src/components/BatchActionBar.tsx diff --git a/canvas/src/components/BatchActionBar.tsx b/canvas/src/components/BatchActionBar.tsx new file mode 100644 index 00000000..5421fbe2 --- /dev/null +++ b/canvas/src/components/BatchActionBar.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { 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(null); + const [busy, setBusy] = useState(false); + + const count = selectedNodeIds.size; + if (count < 2) return null; + + const confirmMessages: Record, string> = { + restart: `Restart ${count} workspace${count !== 1 ? "s" : ""}? Each will briefly go offline while it restarts.`, + pause: `Pause ${count} workspace${count !== 1 ? "s" : ""}? Their containers will be stopped.`, + delete: `Permanently delete ${count} workspace${count !== 1 ? "s" : ""}? This cannot be undone.`, + }; + + const confirmLabels: Record, 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(); + showToast(`${pending.charAt(0).toUpperCase() + pending.slice(1)} applied to ${count} workspace${count !== 1 ? "s" : ""}`, "success"); + clearSelection(); + } catch { + showToast(`Batch ${pending} failed`, "error"); + } finally { + setBusy(false); + setPending(null); + } + } + + const bar = ( +
+ {/* Selection count badge */} + + {count} selected + + +