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
+25
View File
@@ -45,6 +45,31 @@ Right-click any stack (or open the kebab that appears on hover) for its context
<img src="/images/sidebar/sidebar-context-menu.png" alt="Grouped stack context menu" />
</Frame>
## Keyboard shortcuts
Every action in the context menu has a keyboard shortcut. Shortcuts fire on the currently selected stack and are blocked when a text input is focused or the command palette is open.
### Lifecycle
| Shortcut | Action | Condition |
|----------|--------|-----------|
| <kbd>Ctrl</kbd>+<kbd>Enter</kbd> | Deploy | Stack is stopped |
| <kbd>Ctrl</kbd>+<kbd>.</kbd> | Stop | Stack is running |
| <kbd>Ctrl</kbd>+<kbd>R</kbd> | Restart | Stack is running |
| <kbd>Ctrl</kbd>+<kbd>↑</kbd> | Update images | Stack is running |
| <kbd>Ctrl</kbd>+<kbd>Backspace</kbd> | Delete | Stack exists |
On macOS, use <kbd>Cmd</kbd> in place of <kbd>Ctrl</kbd>.
### Inspect and organize
| Key | Action |
|-----|--------|
| <kbd>A</kbd> | Open alerts sheet |
| <kbd>H</kbd> | Open auto-heal sheet (Skipper and above) |
| <kbd>U</kbd> | Check for image updates |
| <kbd>P</kbd> | Pin or unpin the stack |
## Activity footer
The footer shows the most recent stack lifecycle event within the last hour. Click it to jump to the global activity log. When nothing has happened recently, the footer reads **IDLE**.
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { ArrowUpRight, Loader2 } from 'lucide-react';
import { useStackKeyboardShortcuts } from '@/hooks/useStackKeyboardShortcuts';
import { CommandItem, CommandList } from '@/components/ui/command';
import { Skeleton } from '@/components/ui/skeleton';
import type { Label } from '@/components/label-types';
@@ -108,6 +109,8 @@ export function StackList(props: StackListProps) {
[files, pinnedFiles, stackLabelMap, labels],
);
useStackKeyboardShortcuts(selectedFile, buildMenuCtx);
if (isLoading) {
return (
<div className="space-y-2 px-2 mt-2">
@@ -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
}, []);
}