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 */}
@@ -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 (
<div className="p-4 flex gap-2">
<div className="flex-1">{createStackSlot}</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn('rounded-lg shrink-0 shadow-btn-glow', bulkMode && 'border-brand/40 text-brand bg-brand/10')}
onClick={onToggleBulkMode}
aria-pressed={bulkMode}
>
<LayoutList className="w-4 h-4" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Bulk mode (B)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -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 (
<div className="px-3 py-2 border-b border-glass-border bg-glass-highlight/20">
<div className="flex items-center justify-between mb-1.5">
<span className="font-mono text-[10px] text-brand tracking-[0.08em]">{selectedCount} selected</span>
<button
type="button"
onClick={onClear}
className="text-stat-icon hover:text-foreground transition-colors"
aria-label="Clear selection"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex gap-1 flex-wrap">
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('start')}>Start</Button>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('stop')}>Stop</Button>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('restart')}>Restart</Button>
{isPaid ? (
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono" onClick={() => onAction('update')}>Update</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] font-mono gap-1 pointer-events-none opacity-60" disabled>
Update
<TierBadge tier="paid" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Bulk update requires a Skipper license</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
);
}
+13 -2
View File
@@ -95,11 +95,19 @@ function buildGroups(
return result;
}
export function StackList(props: StackListProps) {
interface StackListBulkProps {
bulkMode: boolean;
selectedFiles: Set<string>;
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={<StackKebabMenu file={file} ctx={ctx} />}
bulkMode={bulkMode}
isSelected={selectedFiles.has(file)}
onToggleSelect={onToggleSelect}
/>
</CommandItem>
</StackContextMenu>
+38 -5
View File
@@ -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 (
<div
data-testid="stack-row"
data-bulk={bulkMode ? 'true' : undefined}
role="button"
tabIndex={0}
className={cn(sidebarRowBase, isActive && sidebarRowActive)}
onClick={() => 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) */}
<span className={sidebarRowCheckboxSlot} aria-hidden="true" />
<span
className={cn(sidebarRowCheckboxSlot, bulkMode && 'opacity-100 pointer-events-auto')}
onClick={e => { e.stopPropagation(); onToggleSelect?.(file); }}
aria-hidden={!bulkMode}
>
{bulkMode && (
<Checkbox
checked={isSelected}
className="w-3.5 h-3.5 border-muted-foreground/40 data-[state=checked]:border-brand data-[state=checked]:bg-brand"
tabIndex={-1}
aria-label={`Select ${displayName}`}
/>
)}
</span>
{/* Status pill */}
<span className={cn('font-mono text-[10px] shrink-0 w-[22px] flex items-center', statusColor(status, isBusy))}>
@@ -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>
+66
View File
@@ -0,0 +1,66 @@
import { useCallback } from 'react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { useLicense } from '@/context/LicenseContext';
export type BulkAction = 'start' | 'stop' | 'restart' | 'update';
const pastTense: Record<BulkAction, string> = {
start: 'started',
stop: 'stopped',
restart: 'restarted',
update: 'updated',
};
interface BulkCallbacks {
onBefore?: (files: string[]) => void;
onAfter?: (files: string[]) => void;
}
export function useBulkStackActions() {
const { isPaid } = useLicense();
const runBulk = useCallback(async (
action: BulkAction,
files: string[],
cbs?: BulkCallbacks,
) => {
if (files.length === 0) return;
if (action === 'update' && !isPaid) {
toast.error('Bulk update requires a Skipper license.');
return;
}
cbs?.onBefore?.(files);
const results = await Promise.allSettled(
files.map(file => {
const stackName = file.replace(/\.(yml|yaml)$/, '');
const headers: Record<string, string> = action === 'update' ? { 'x-bulk-mode': '1' } : {};
return apiFetch(`/stacks/${encodeURIComponent(stackName)}/${action}`, {
method: 'POST',
headers,
}).then(res => {
if (!res.ok) return Promise.reject(new Error(file));
return file;
});
})
);
cbs?.onAfter?.(files);
const failed = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map(r => (r.reason as Error).message);
const okCount = results.length - failed.length;
if (failed.length === 0) {
const noun = okCount === 1 ? 'stack' : 'stacks';
toast.success(`${okCount} ${noun} ${pastTense[action]}`);
} else {
toast.error(`${okCount} of ${files.length} ${pastTense[action]}; ${failed.length} failed: ${failed.join(', ')}`);
}
}, [isPaid]);
return { runBulk, isPaid };
}
@@ -1,16 +1,6 @@
import { useEffect, useRef } from 'react';
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
function isInputFocused(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
}
function isPaletteOpen(): boolean {
return !!document.querySelector('[role="dialog"] [cmdk-root]');
}
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
export function useStackKeyboardShortcuts(
selectedFile: string | null,
+10
View File
@@ -0,0 +1,10 @@
export function isInputFocused(): boolean {
const el = document.activeElement as HTMLElement | null;
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable;
}
export function isPaletteOpen(): boolean {
return !!document.querySelector('[role="dialog"] [cmdk-root]');
}