diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index 217d5587..ee83c62e 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -45,6 +45,31 @@ Right-click any stack (or open the kebab that appears on hover) for its context Grouped stack context menu +## 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 | +|----------|--------|-----------| +| Ctrl+Enter | Deploy | Stack is stopped | +| Ctrl+. | Stop | Stack is running | +| Ctrl+R | Restart | Stack is running | +| Ctrl+ | Update images | Stack is running | +| Ctrl+Backspace | Delete | Stack exists | + +On macOS, use Cmd in place of Ctrl. + +### Inspect and organize + +| Key | Action | +|-----|--------| +| A | Open alerts sheet | +| H | Open auto-heal sheet (Skipper and above) | +| U | Check for image updates | +| P | 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**. diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx index 4ad5fdc0..1d7b907a 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -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 (
diff --git a/frontend/src/hooks/useStackKeyboardShortcuts.ts b/frontend/src/hooks/useStackKeyboardShortcuts.ts new file mode 100644 index 00000000..f100bb02 --- /dev/null +++ b/frontend/src/hooks/useStackKeyboardShortcuts.ts @@ -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 + }, []); +}