mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 09:54:26 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]');
|
||||
}
|
||||
Reference in New Issue
Block a user