feat(sidebar): keyboard shortcuts for stack menu actions (#729)

* feat(sidebar): implement keyboard shortcuts for stack menu actions

Shortcut labels shown in the context menu and kebab menu were purely
decorative. This wires them up to the corresponding actions on the
currently selected stack.

Cmd/Ctrl shortcuts: Enter (deploy), . (stop), R (restart), Up (update),
Backspace (delete). Single-key shortcuts: a (alerts), h (auto-heal,
paid), u (check updates), p (pin/unpin).

Guards: shortcuts are blocked when an input element is focused, when the
global command palette dialog is open, when no stack is selected, or
when the stack is busy. All visibility and busy flags from the menu
context are respected.

* docs(sidebar): document keyboard shortcuts for stack actions
This commit is contained in:
Anso
2026-04-23 16:45:10 -04:00
committed by GitHub
parent d47f6b40e4
commit 1ef96582e1
3 changed files with 110 additions and 0 deletions
@@ -0,0 +1,82 @@
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]');
}
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
}, []);
}