mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-02 23:19:33 +00:00
a0bf5b5bf5
* 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.
73 lines
2.4 KiB
TypeScript
73 lines
2.4 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
|
|
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
|
|
|
|
export function useStackKeyboardShortcuts(
|
|
selectedFile: string | null,
|
|
buildMenuCtx: (file: string) => StackMenuCtx,
|
|
) {
|
|
const selectedFileRef = useRef(selectedFile);
|
|
const buildMenuCtxRef = useRef(buildMenuCtx);
|
|
|
|
useEffect(() => { selectedFileRef.current = selectedFile; }, [selectedFile]);
|
|
useEffect(() => { buildMenuCtxRef.current = buildMenuCtx; }, [buildMenuCtx]);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
const file = selectedFileRef.current;
|
|
if (!file) return;
|
|
if (isInputFocused()) return;
|
|
if (isPaletteOpen()) return;
|
|
|
|
const cmdOrCtrl = e.metaKey || e.ctrlKey;
|
|
const key = e.key.toLowerCase();
|
|
|
|
const isCmdKey = cmdOrCtrl && ['enter', '.', 'r', 'arrowup', 'backspace'].includes(key);
|
|
const isSingleKey = !cmdOrCtrl && ['a', 'h', 'u', 'p'].includes(key);
|
|
if (!isCmdKey && !isSingleKey) return;
|
|
|
|
const ctx = buildMenuCtxRef.current(file);
|
|
const { showDeploy, showStop, showRestart, showUpdate } = ctx.menuVisibility;
|
|
|
|
if (cmdOrCtrl) {
|
|
if (key === 'enter' && showDeploy && !ctx.isBusy) {
|
|
e.preventDefault();
|
|
ctx.deploy();
|
|
} else if (key === '.' && showStop && !ctx.isBusy) {
|
|
e.preventDefault();
|
|
ctx.stop();
|
|
} else if (key === 'r' && showRestart && !ctx.isBusy) {
|
|
e.preventDefault();
|
|
ctx.restart();
|
|
} else if (key === 'arrowup' && showUpdate && !ctx.isBusy) {
|
|
e.preventDefault();
|
|
ctx.update();
|
|
} else if (key === 'backspace' && ctx.canDelete && !ctx.isBusy) {
|
|
e.preventDefault();
|
|
ctx.remove();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (key === 'a') {
|
|
e.preventDefault();
|
|
ctx.openAlertSheet();
|
|
} else if (key === 'h' && ctx.isPaid) {
|
|
e.preventDefault();
|
|
ctx.openAutoHeal();
|
|
} else if (key === 'u') {
|
|
e.preventDefault();
|
|
ctx.checkUpdates();
|
|
} else if (key === 'p') {
|
|
e.preventDefault();
|
|
if (ctx.isPinned) ctx.unpin();
|
|
else ctx.pin();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
}
|