mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user