import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import classNames from "classnames"; import { fuzzyMatch } from "fuzzbunny"; import { useAtomValue } from "jotai"; import { useCallback, useEffect, useMemo, useState } from "react"; import { defaultHotkeys, formatHotkeyString, getHotkeyScope, type HotkeyAction, hotkeyActions, hotkeysAtom, useHotkeyLabel, } from "../../hooks/useHotKey"; import { capitalize } from "../../lib/capitalize"; import { showDialog } from "../../lib/dialog"; import { Button } from "../core/Button"; import { Dropdown, type DropdownItem } from "../core/Dropdown"; import { Heading } from "../core/Heading"; import { HotkeyRaw } from "../core/Hotkey"; import { Icon } from "../core/Icon"; import { IconButton } from "../core/IconButton"; import { PlainInput } from "../core/PlainInput"; import { HStack, VStack } from "../core/Stacks"; import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "../core/Table"; const HOLD_KEYS = ["Shift", "Control", "Alt", "Meta"]; const LAYOUT_INSENSITIVE_KEYS = [ "Equal", "Minus", "BracketLeft", "BracketRight", "Backquote", "Space", ]; /** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */ function eventToHotkeyString(e: KeyboardEvent): string | null { // Don't capture modifier-only key presses if (HOLD_KEYS.includes(e.key)) { return null; } const parts: string[] = []; // Add modifiers in consistent order (Meta, Control, Alt, Shift) if (e.metaKey) { parts.push("Meta"); } if (e.ctrlKey) { parts.push("Control"); } if (e.altKey) { parts.push("Alt"); } if (e.shiftKey) { parts.push("Shift"); } // Get the main key - use the same logic as useHotKey.ts const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key; parts.push(key); return parts.join("+"); } export function SettingsHotkeys() { const settings = useAtomValue(settingsAtom); const hotkeys = useAtomValue(hotkeysAtom); const [filter, setFilter] = useState(""); const filteredActions = useMemo(() => { if (!filter.trim()) { return hotkeyActions; } return hotkeyActions.filter((action) => { const scope = getHotkeyScope(action).replace(/_/g, " "); const label = action.replace(/[_.]/g, " "); const searchText = `${scope} ${label}`; return fuzzyMatch(searchText, filter) != null; }); }, [filter]); if (settings == null) { return null; } return (
Keyboard Shortcuts

Click the menu button to add, remove, or reset keyboard shortcuts.

Scope Action Shortcut {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */} {filteredActions.map((action) => ( { const newHotkeys = { ...settings.hotkeys }; if (arraysEqual(keys, defaultHotkeys[action])) { // Remove from settings if it matches default (use default) delete newHotkeys[action]; } else { // Store the keys (including empty array to disable) newHotkeys[action] = keys; } await patchModel(settings, { hotkeys: newHotkeys }); }} onReset={async () => { const newHotkeys = { ...settings.hotkeys }; delete newHotkeys[action]; await patchModel(settings, { hotkeys: newHotkeys }); }} /> ))}
); } interface HotkeyRowProps { action: HotkeyAction; currentKeys: string[]; defaultKeys: string[]; onSave: (keys: string[]) => Promise; onReset: () => Promise; } function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) { const label = useHotkeyLabel(action); const scope = capitalize(getHotkeyScope(action).replace(/_/g, " ")); const isCustomized = !arraysEqual(currentKeys, defaultKeys); const isDisabled = currentKeys.length === 0; const handleStartRecording = useCallback(() => { showDialog({ id: `record-hotkey-${action}`, title: label, size: "sm", render: ({ hide }) => ( { await onSave([...currentKeys, key]); hide(); }} onCancel={hide} /> ), }); }, [action, label, currentKeys, onSave]); const handleRemove = useCallback( async (keyToRemove: string) => { const newKeys = currentKeys.filter((k) => k !== keyToRemove); await onSave(newKeys); }, [currentKeys, onSave], ); const handleClearAll = useCallback(async () => { await onSave([]); }, [onSave]); // Build dropdown items dynamically const dropdownItems: DropdownItem[] = [ { label: "Add Keyboard Shortcut", leftSlot: , onSelect: handleStartRecording, }, ]; // Add remove options for each existing shortcut if (!isDisabled) { currentKeys.forEach((key) => { dropdownItems.push({ label: ( Remove ), leftSlot: , onSelect: () => handleRemove(key), }); }); if (currentKeys.length > 1) { dropdownItems.push( { type: "separator", }, { label: "Remove All Shortcuts", leftSlot: , onSelect: handleClearAll, }, ); } } if (isCustomized) { dropdownItems.push({ type: "separator", }); dropdownItems.push({ label: "Reset to Default", leftSlot: , onSelect: onReset, }); } return ( {scope} {label} {isDisabled ? ( Disabled ) : ( currentKeys.map((k) => ( )) )} ); } function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; const sortedA = [...a].sort(); const sortedB = [...b].sort(); return sortedA.every((v, i) => v === sortedB[i]); } interface RecordHotkeyDialogProps { label: string; onSave: (key: string) => void; onCancel: () => void; } function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) { const [recordedKey, setRecordedKey] = useState(null); const [isFocused, setIsFocused] = useState(false); useEffect(() => { if (!isFocused) return; const handleKeyDown = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); if (e.key === "Escape") { onCancel(); return; } const hotkeyString = eventToHotkeyString(e); if (hotkeyString) { setRecordedKey(hotkeyString); } }; window.addEventListener("keydown", handleKeyDown, { capture: true }); return () => { window.removeEventListener("keydown", handleKeyDown, { capture: true }); }; }, [isFocused, onCancel]); const handleSave = useCallback(() => { if (recordedKey) { onSave(recordedKey); } }, [recordedKey, onSave]); return (

Record a key combination for {label}

); }