diff --git a/ui/src/components/sidebar-layout.tsx b/ui/src/components/sidebar-layout.tsx index f3549d55..a5423923 100644 --- a/ui/src/components/sidebar-layout.tsx +++ b/ui/src/components/sidebar-layout.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { SidebarSimpleIcon } from "@phosphor-icons/react"; +import { useHotkey } from "@/lib/hotkeys"; import { cn } from "@/lib/utils"; const STORAGE_WIDTH = "open-swe.sidebar.width"; @@ -43,6 +44,8 @@ export function useSidebarLayout() { const toggle = useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]); + useHotkey("mod+b", toggle, { enableInFormFields: true, ignoreRepeat: true }); + const closeOnMobile = useCallback(() => { if (typeof window === "undefined") return; // State-only: don't persist, so the desktop collapsed preference is preserved. diff --git a/ui/src/lib/hotkeys.ts b/ui/src/lib/hotkeys.ts new file mode 100644 index 00000000..54c3172d --- /dev/null +++ b/ui/src/lib/hotkeys.ts @@ -0,0 +1,123 @@ +import { useEffect, useRef } from "react" + +export interface HotkeyOptions { + enabled?: boolean + preventDefault?: boolean + enableInFormFields?: boolean + ignoreRepeat?: boolean +} + +interface ParsedCombo { + key: string + mod: boolean + meta: boolean + ctrl: boolean + alt: boolean + shift: boolean +} + +function isMac(): boolean { + if (typeof navigator === "undefined") return false + return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent) +} + +/** Parse a combo string like "mod+b" or "shift+escape" into its parts. */ +function parseCombo(combo: string): ParsedCombo { + const parsed: ParsedCombo = { + key: "", + mod: false, + meta: false, + ctrl: false, + alt: false, + shift: false, + } + for (const part of combo + .toLowerCase() + .split("+") + .map((p) => p.trim())) { + switch (part) { + case "": + break + case "mod": + parsed.mod = true + break + case "meta": + case "cmd": + case "command": + parsed.meta = true + break + case "ctrl": + case "control": + parsed.ctrl = true + break + case "alt": + case "option": + parsed.alt = true + break + case "shift": + parsed.shift = true + break + default: + parsed.key = part + } + } + return parsed +} + +function eventMatchesCombo(event: KeyboardEvent, combo: ParsedCombo): boolean { + if (event.key.toLowerCase() !== combo.key) return false + const mac = isMac() + const expectMeta = combo.meta || (combo.mod && mac) + const expectCtrl = combo.ctrl || (combo.mod && !mac) + return ( + event.metaKey === expectMeta && + event.ctrlKey === expectCtrl && + event.altKey === combo.alt && + event.shiftKey === combo.shift + ) +} + +function isFormField(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable + ) +} + +/** + * Register a global keyboard shortcut. Use "mod" for the platform meta key + * (Cmd on macOS, Ctrl elsewhere). Accepts one combo or several aliases. + */ +export function useHotkey( + combo: string | string[], + handler: (event: KeyboardEvent) => void, + options: HotkeyOptions = {} +) { + const { + enabled = true, + preventDefault = true, + enableInFormFields = false, + ignoreRepeat = false, + } = options + const handlerRef = useRef(handler) + handlerRef.current = handler + + const comboKey = Array.isArray(combo) ? combo.join(",") : combo + + useEffect(() => { + if (!enabled || typeof window === "undefined") return + const combos = comboKey.split(",").map(parseCombo) + const onKeyDown = (event: KeyboardEvent) => { + if (ignoreRepeat && event.repeat) return + if (!enableInFormFields && isFormField(event.target)) return + if (!combos.some((c) => eventMatchesCombo(event, c))) return + if (preventDefault) event.preventDefault() + handlerRef.current(event) + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [enabled, preventDefault, enableInFormFields, ignoreRepeat, comboKey]) +}