From a0bf5b5bf5a58025db33ac54171107cad5e4d2d6 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 30 Apr 2026 19:53:12 -0400 Subject: [PATCH] feat(sidebar): bulk stack operations (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): bulk stack operations (select, start/stop/restart/update) - Add ⊞ bulk mode toggle in SidebarActions (cyan active state, tooltip "Bulk mode (B)"); keyboard shortcut B toggles, Esc exits, Ctrl+A selects all visible (chip-filtered) stacks - Reserved checkbox column in StackRow becomes visible and interactive in bulk mode; clicking a row in bulk mode toggles selection instead of opening the stack; kebab and context-menu still work in either mode - SidebarBulkBar appears below filter chips when >=1 stack selected: shows count, Start / Stop / Restart / Update actions; Update is disabled with a Skipper TierBadge for Community licenses - useBulkStackActions hook fans out operations via Promise.allSettled and surfaces an aggregate toast ("3 of 4 restarted; 1 failed: plex") - Bulk update enforced Skipper-gated frontend-side (isPaid check in hook) and sends x-bulk-mode header for backend defense-in-depth - Extract isInputFocused / isPaletteOpen to lib/keyboard-guards.ts; both useStackKeyboardShortcuts and the new bulk keyboard effect now share the same guards instead of duplicating the logic - chipFilteredFiles captured via useRef in bulk keyboard effect so the listener is not torn down and re-added on every status-poll cycle * fix(sidebar): separate TooltipProviders for bulk and scan icon buttons Wrapping both icon buttons in a single TooltipProvider made them render as one flex child, collapsing the gap-2 between them. Splitting into two independent TooltipProviders restores the 8px gap and right padding of the scan button. --- frontend/src/components/EditorLayout.tsx | 65 ++++++++++++++++++ .../src/components/sidebar/SidebarActions.tsx | 25 ++++++- .../src/components/sidebar/SidebarBulkBar.tsx | 54 +++++++++++++++ frontend/src/components/sidebar/StackList.tsx | 15 ++++- frontend/src/components/sidebar/StackRow.tsx | 43 ++++++++++-- .../src/components/sidebar/StackSidebar.tsx | 28 +++++++- frontend/src/hooks/useBulkStackActions.ts | 66 +++++++++++++++++++ .../src/hooks/useStackKeyboardShortcuts.ts | 12 +--- frontend/src/lib/keyboard-guards.ts | 10 +++ 9 files changed, 296 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/sidebar/SidebarBulkBar.tsx create mode 100644 frontend/src/hooks/useBulkStackActions.ts create mode 100644 frontend/src/lib/keyboard-guards.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1425915c..ce2fa018 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -75,6 +75,8 @@ import { usePinnedStacks } from '@/hooks/usePinnedStacks'; import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; import type { StackRowStatus } from '@/components/sidebar/stack-status-utils'; import type { FilterChip, StackMenuCtx } from '@/components/sidebar/sidebar-types'; +import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions'; +import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards'; import { StackFileExplorer } from '@/components/files/StackFileExplorer'; interface ContainerInfo { @@ -1964,6 +1966,62 @@ export default function EditorLayout() { return filteredFiles; }, [filteredFiles, filterChip, stackStatuses, stackUpdates]); + const [bulkMode, setBulkMode] = useState(false); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + + const toggleBulkMode = useCallback(() => { + setBulkMode(prev => { + if (prev) setSelectedFiles(new Set()); + return !prev; + }); + }, []); + + const toggleSelect = useCallback((file: string) => { + setSelectedFiles(prev => { + const next = new Set(prev); + if (next.has(file)) next.delete(file); + else next.add(file); + return next; + }); + }, []); + + const clearSelection = useCallback(() => { + setSelectedFiles(new Set()); + }, []); + + const { runBulk } = useBulkStackActions(); + + const handleBulkAction = useCallback((action: BulkAction) => { + const files = Array.from(selectedFiles); + runBulk(action, files, { + onAfter: () => { refreshStacks(true); clearSelection(); }, + }); + }, [selectedFiles, runBulk, clearSelection]); + + const chipFilteredFilesRef = useRef(chipFilteredFiles); + useEffect(() => { chipFilteredFilesRef.current = chipFilteredFiles; }, [chipFilteredFiles]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (isInputFocused()) return; + if (isPaletteOpen()) return; + + if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + toggleBulkMode(); + } else if (e.key === 'Escape' && bulkMode) { + e.preventDefault(); + setBulkMode(false); + setSelectedFiles(new Set()); + } else if ((e.metaKey || e.ctrlKey) && e.key === 'a' && bulkMode) { + e.preventDefault(); + setSelectedFiles(new Set(chipFilteredFilesRef.current)); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [bulkMode, toggleBulkMode]); + const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id); const remoteResults = useMemo(() => { @@ -2333,6 +2391,13 @@ export default function EditorLayout() { notifications={notifications} tickerConnected={tickerConnected} onOpenActivity={() => setActiveView('global-observability')} + bulkMode={bulkMode} + selectedFiles={selectedFiles} + isPaid={isPaid} + onToggleBulkMode={toggleBulkMode} + onToggleSelect={toggleSelect} + onClearSelection={clearSelection} + onBulkAction={handleBulkAction} /> {/* Main Content Area */} diff --git a/frontend/src/components/sidebar/SidebarActions.tsx b/frontend/src/components/sidebar/SidebarActions.tsx index 753fe35c..fffb5217 100644 --- a/frontend/src/components/sidebar/SidebarActions.tsx +++ b/frontend/src/components/sidebar/SidebarActions.tsx @@ -1,18 +1,39 @@ import type { ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { FolderSearch, Loader2 } from 'lucide-react'; +import { FolderSearch, Loader2, LayoutList } from 'lucide-react'; +import { cn } from '@/lib/utils'; interface SidebarActionsProps { createStackSlot: ReactNode; onScan: () => void; isScanning: boolean; + bulkMode: boolean; + onToggleBulkMode: () => void; } -export function SidebarActions({ createStackSlot, onScan, isScanning }: SidebarActionsProps) { +export function SidebarActions({ createStackSlot, onScan, isScanning, bulkMode, onToggleBulkMode }: SidebarActionsProps) { return (
{createStackSlot}
+ + + + + + +

Bulk mode (B)

+
+
+
diff --git a/frontend/src/components/sidebar/SidebarBulkBar.tsx b/frontend/src/components/sidebar/SidebarBulkBar.tsx new file mode 100644 index 00000000..afed486c --- /dev/null +++ b/frontend/src/components/sidebar/SidebarBulkBar.tsx @@ -0,0 +1,54 @@ +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { TierBadge } from '@/components/TierBadge'; +import type { BulkAction } from '@/hooks/useBulkStackActions'; + +interface SidebarBulkBarProps { + selectedCount: number; + isPaid: boolean; + onAction: (action: BulkAction) => void; + onClear: () => void; +} + +export function SidebarBulkBar({ selectedCount, isPaid, onAction, onClear }: SidebarBulkBarProps) { + return ( +
+
+ {selectedCount} selected + +
+
+ + + + {isPaid ? ( + + ) : ( + + + + + + + + +

Bulk update requires a Skipper license

+
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx index 5b36118c..315a7d33 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -95,11 +95,19 @@ function buildGroups( return result; } -export function StackList(props: StackListProps) { +interface StackListBulkProps { + bulkMode: boolean; + selectedFiles: Set; + onToggleSelect: (file: string) => void; +} + +export function StackList(props: StackListProps & StackListBulkProps) { const { files, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses, stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse, - isBusy, getDisplayName, onSelectFile, buildMenuCtx, remoteResults, remoteLoading, onSelectRemoteFile, + isBusy, getDisplayName, onSelectFile, buildMenuCtx, + bulkMode, selectedFiles, onToggleSelect, + remoteResults, remoteLoading, onSelectRemoteFile, } = props; const groups = useMemo( @@ -152,6 +160,9 @@ export function StackList(props: StackListProps) { hasGitPending={!!gitSourcePendingMap[file]} onSelect={onSelectFile} kebabSlot={} + bulkMode={bulkMode} + isSelected={selectedFiles.has(file)} + onToggleSelect={onToggleSelect} /> diff --git a/frontend/src/components/sidebar/StackRow.tsx b/frontend/src/components/sidebar/StackRow.tsx index ace91f50..e6899e67 100644 --- a/frontend/src/components/sidebar/StackRow.tsx +++ b/frontend/src/components/sidebar/StackRow.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { GitBranch, Loader2 } from 'lucide-react'; import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/components/animate-ui/primitives/animate/cursor'; +import { Checkbox } from '@/components/ui/checkbox'; import { LabelDot } from '@/components/LabelPill'; import type { Label } from '@/components/label-types'; import { cn } from '@/lib/utils'; @@ -20,6 +21,9 @@ interface StackRowProps { hasGitPending: boolean; onSelect: (file: string) => void; kebabSlot: ReactNode; + bulkMode?: boolean; + isSelected?: boolean; + onToggleSelect?: (file: string) => void; } function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) { @@ -39,22 +43,51 @@ function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) { const MAX_VISIBLE_LABELS = 3; export function StackRow(props: StackRowProps) { - const { file, displayName, status, isBusy, isActive, isPaid, labels, hasUpdate, hasGitPending, onSelect, kebabSlot } = props; + const { + file, displayName, status, isBusy, isActive, isPaid, labels, + hasUpdate, hasGitPending, onSelect, kebabSlot, + bulkMode = false, isSelected = false, onToggleSelect, + } = props; const visibleLabels = isPaid ? labels.slice(0, MAX_VISIBLE_LABELS) : []; const overflowCount = isPaid ? Math.max(0, labels.length - MAX_VISIBLE_LABELS) : 0; + const handleClick = () => { + if (bulkMode) onToggleSelect?.(file); + else onSelect(file); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }; + return (
onSelect(file)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(file); } }} + onClick={handleClick} + onKeyDown={handleKeyDown} > - {/* Reserved checkbox slot — revealed in bulk mode (PR2) */} -