feat(sidebar): bulk stack operations (#854)

* 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.
This commit is contained in:
Anso
2026-04-30 19:53:12 -04:00
committed by GitHub
parent 4c0efcb9a8
commit a0bf5b5bf5
9 changed files with 296 additions and 22 deletions
@@ -5,10 +5,12 @@ import type { NotificationItem } from '@/components/dashboard/types';
import { SidebarActions } from './SidebarActions';
import { SidebarActivityTicker } from './SidebarActivityTicker';
import { SidebarBrand } from './SidebarBrand';
import { SidebarBulkBar } from './SidebarBulkBar';
import { SidebarFilterChips, type FilterCounts } from './SidebarFilterChips';
import { SidebarSearch } from './SidebarSearch';
import { StackList, type StackListProps } from './StackList';
import type { FilterChip } from './sidebar-types';
import type { BulkAction } from '@/hooks/useBulkStackActions';
export interface StackSidebarProps {
isDarkMode: boolean;
@@ -26,6 +28,13 @@ export interface StackSidebarProps {
notifications: NotificationItem[];
tickerConnected: boolean;
onOpenActivity: () => void;
bulkMode: boolean;
selectedFiles: Set<string>;
isPaid: boolean;
onToggleBulkMode: () => void;
onToggleSelect: (file: string) => void;
onClearSelection: () => void;
onBulkAction: (action: BulkAction) => void;
}
export function StackSidebar(props: StackSidebarProps) {
@@ -33,6 +42,7 @@ export function StackSidebar(props: StackSidebarProps) {
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
searchQuery, onSearchChange, filterChip, filterCounts, onFilterChipChange,
list, notifications, tickerConnected, onOpenActivity,
bulkMode, selectedFiles, isPaid, onToggleBulkMode, onToggleSelect, onClearSelection, onBulkAction,
} = props;
return (
@@ -40,7 +50,13 @@ export function StackSidebar(props: StackSidebarProps) {
<SidebarBrand isDarkMode={isDarkMode} />
<div className="px-4 pt-2 pb-0">{nodeSwitcherSlot}</div>
{canCreate && createStackSlot !== null && (
<SidebarActions createStackSlot={createStackSlot} onScan={onScan} isScanning={isScanning} />
<SidebarActions
createStackSlot={createStackSlot}
onScan={onScan}
isScanning={isScanning}
bulkMode={bulkMode}
onToggleBulkMode={onToggleBulkMode}
/>
)}
<Command shouldFilter={false} className="bg-transparent flex-1 flex flex-col overflow-hidden">
<SidebarSearch value={searchQuery} onValueChange={onSearchChange} />
@@ -49,9 +65,17 @@ export function StackSidebar(props: StackSidebarProps) {
counts={filterCounts}
onChange={onFilterChipChange}
/>
{selectedFiles.size > 0 && (
<SidebarBulkBar
selectedCount={selectedFiles.size}
isPaid={isPaid}
onAction={onBulkAction}
onClear={onClearSelection}
/>
)}
<ScrollArea className="flex-1 px-2 pb-2">
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
<StackList {...list} />
<StackList {...list} bulkMode={bulkMode} selectedFiles={selectedFiles} onToggleSelect={onToggleSelect} />
</div>
</ScrollArea>
</Command>