diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 1425915c..ce2fa018 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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>(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 */} diff --git a/frontend/src/components/sidebar/SidebarActions.tsx b/frontend/src/components/sidebar/SidebarActions.tsx index 753fe35c..fffb5217 100644 --- a/frontend/src/components/sidebar/SidebarActions.tsx +++ b/frontend/src/components/sidebar/SidebarActions.tsx @@ -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 (
{createStackSlot}
+ + + + + + +

Bulk mode (B)

+
+
+
diff --git a/frontend/src/components/sidebar/SidebarBulkBar.tsx b/frontend/src/components/sidebar/SidebarBulkBar.tsx new file mode 100644 index 00000000..afed486c --- /dev/null +++ b/frontend/src/components/sidebar/SidebarBulkBar.tsx @@ -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 ( +
+
+ {selectedCount} selected + +
+
+ + + + {isPaid ? ( + + ) : ( + + + + + + + + +

Bulk update requires a Skipper license

+
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx index 5b36118c..315a7d33 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -95,11 +95,19 @@ function buildGroups( return result; } -export function StackList(props: StackListProps) { +interface StackListBulkProps { + bulkMode: boolean; + selectedFiles: Set; + 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={} + bulkMode={bulkMode} + isSelected={selectedFiles.has(file)} + onToggleSelect={onToggleSelect} /> diff --git a/frontend/src/components/sidebar/StackRow.tsx b/frontend/src/components/sidebar/StackRow.tsx index ace91f50..e6899e67 100644 --- a/frontend/src/components/sidebar/StackRow.tsx +++ b/frontend/src/components/sidebar/StackRow.tsx @@ -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 (
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) */} -