mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +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 */}
|
||||
|
||||
Reference in New Issue
Block a user