Files
sencho/frontend/src/components/sidebar/SidebarBulkBar.tsx
T
Anso a0bf5b5bf5 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.
2026-04-30 19:53:12 -04:00

55 lines
2.3 KiB
TypeScript

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