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
+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))}>