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
+65
View File
@@ -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<Set<string>>(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 */}