mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
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:
@@ -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))}>
|
||||
|
||||
Reference in New Issue
Block a user