import { type } from '@tauri-apps/plugin-os'; import { debounce } from '@yaakapp-internal/lib'; import { settingsAtom } from '@yaakapp-internal/models'; import { atom, useAtomValue } from 'jotai'; import { useEffect } from 'react'; import { capitalize } from '../lib/capitalize'; import { jotaiStore } from '../lib/jotai'; const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta']; const SINGLE_WHITELIST = ['Delete', 'Enter', 'Backspace']; export type HotkeyAction = | 'app.zoom_in' | 'app.zoom_out' | 'app.zoom_reset' | 'command_palette.toggle' | 'editor.autocomplete' | 'environment_editor.toggle' | 'hotkeys.showHelp' | 'model.create' | 'model.duplicate' | 'request.send' | 'request.rename' | 'switcher.next' | 'switcher.prev' | 'switcher.toggle' | 'settings.show' | 'sidebar.filter' | 'sidebar.selected.delete' | 'sidebar.selected.duplicate' | 'sidebar.selected.rename' | 'sidebar.expand_all' | 'sidebar.collapse_all' | 'sidebar.focus' | 'sidebar.context_menu' | 'url_bar.focus' | 'workspace_settings.show'; /** Default hotkeys for macOS (uses Meta for Cmd) */ const defaultHotkeysMac: Record = { 'app.zoom_in': ['Meta+Equal'], 'app.zoom_out': ['Meta+Minus'], 'app.zoom_reset': ['Meta+0'], 'command_palette.toggle': ['Meta+k'], 'editor.autocomplete': ['Control+Space'], 'environment_editor.toggle': ['Meta+Shift+e'], 'request.rename': ['Control+Shift+r'], 'request.send': ['Meta+Enter', 'Meta+r'], 'hotkeys.showHelp': ['Meta+Shift+/'], 'model.create': ['Meta+n'], 'model.duplicate': ['Meta+d'], 'switcher.next': ['Control+Shift+Tab'], 'switcher.prev': ['Control+Tab'], 'switcher.toggle': ['Meta+p'], 'settings.show': ['Meta+,'], 'sidebar.filter': ['Meta+f'], 'sidebar.expand_all': ['Meta+Shift+Equal'], 'sidebar.collapse_all': ['Meta+Shift+Minus'], 'sidebar.selected.delete': ['Delete', 'Meta+Backspace'], 'sidebar.selected.duplicate': ['Meta+d'], 'sidebar.selected.rename': ['Enter'], 'sidebar.focus': ['Meta+b'], 'sidebar.context_menu': ['Control+Enter'], 'url_bar.focus': ['Meta+l'], 'workspace_settings.show': ['Meta+;'], }; /** Default hotkeys for Windows/Linux (uses Control for Ctrl) */ const defaultHotkeysOther: Record = { 'app.zoom_in': ['Control+Equal'], 'app.zoom_out': ['Control+Minus'], 'app.zoom_reset': ['Control+0'], 'command_palette.toggle': ['Control+k'], 'editor.autocomplete': ['Control+Space'], 'environment_editor.toggle': ['Control+Shift+e'], 'request.rename': ['F2'], 'request.send': ['Control+Enter', 'Control+r'], 'hotkeys.showHelp': ['Control+Shift+/'], 'model.create': ['Control+n'], 'model.duplicate': ['Control+d'], 'switcher.next': ['Control+Shift+Tab'], 'switcher.prev': ['Control+Tab'], 'switcher.toggle': ['Control+p'], 'settings.show': ['Control+,'], 'sidebar.filter': ['Control+f'], 'sidebar.expand_all': ['Control+Shift+Equal'], 'sidebar.collapse_all': ['Control+Shift+Minus'], 'sidebar.selected.delete': ['Delete', 'Control+Backspace'], 'sidebar.selected.duplicate': ['Control+d'], 'sidebar.selected.rename': ['Enter'], 'sidebar.focus': ['Control+b'], 'sidebar.context_menu': ['Alt+Insert'], 'url_bar.focus': ['Control+l'], 'workspace_settings.show': ['Control+;'], }; /** Get the default hotkeys for the current platform */ export const defaultHotkeys: Record = type() === 'macos' ? defaultHotkeysMac : defaultHotkeysOther; /** Atom that provides the effective hotkeys by merging defaults with user settings */ export const hotkeysAtom = atom((get) => { const settings = get(settingsAtom); const customHotkeys = settings?.hotkeys ?? {}; // Merge default hotkeys with custom hotkeys from settings // Custom hotkeys override defaults for the same action // An empty array means the hotkey is intentionally disabled const merged: Record = { ...defaultHotkeys }; for (const [action, keys] of Object.entries(customHotkeys)) { if (action in defaultHotkeys && Array.isArray(keys)) { merged[action as HotkeyAction] = keys; } } return merged; }); /** Helper function to get current hotkeys from the store */ function getHotkeys(): Record { return jotaiStore.get(hotkeysAtom); } const hotkeyLabels: Record = { 'app.zoom_in': 'Zoom In', 'app.zoom_out': 'Zoom Out', 'app.zoom_reset': 'Zoom to Actual Size', 'command_palette.toggle': 'Toggle Command Palette', 'editor.autocomplete': 'Trigger Autocomplete', 'environment_editor.toggle': 'Edit Environments', 'hotkeys.showHelp': 'Show Keyboard Shortcuts', 'model.create': 'New Request', 'model.duplicate': 'Duplicate Request', 'request.rename': 'Rename Active Request', 'request.send': 'Send Active Request', 'switcher.next': 'Go To Previous Request', 'switcher.prev': 'Go To Next Request', 'switcher.toggle': 'Toggle Request Switcher', 'settings.show': 'Open Settings', 'sidebar.filter': 'Filter Sidebar', 'sidebar.expand_all': 'Expand All Folders', 'sidebar.collapse_all': 'Collapse All Folders', 'sidebar.selected.delete': 'Delete Selected Sidebar Item', 'sidebar.selected.duplicate': 'Duplicate Selected Sidebar Item', 'sidebar.selected.rename': 'Rename Selected Sidebar Item', 'sidebar.focus': 'Focus or Toggle Sidebar', 'sidebar.context_menu': 'Show Context Menu', 'url_bar.focus': 'Focus URL', 'workspace_settings.show': 'Open Workspace Settings', }; const layoutInsensitiveKeys = [ 'Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote', 'Space', ]; export const hotkeyActions: HotkeyAction[] = ( Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[] ).sort((a, b) => { const scopeA = a.split('.')[0] || ''; const scopeB = b.split('.')[0] || ''; if (scopeA !== scopeB) { return scopeA.localeCompare(scopeB); } return hotkeyLabels[a].localeCompare(hotkeyLabels[b]); }); export type HotKeyOptions = { enable?: boolean | (() => boolean); priority?: number; allowDefault?: boolean; }; interface Callback { action: HotkeyAction; callback: (e: KeyboardEvent) => void; options: HotKeyOptions; } const callbacksAtom = atom([]); const currentKeysAtom = atom>(new Set([])); export const sortedCallbacksAtom = atom((get) => [...get(callbacksAtom)].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)), ); const clearCurrentKeysDebounced = debounce(() => { jotaiStore.set(currentKeysAtom, new Set([])); }, 5000); export function useHotKey( action: HotkeyAction | null, callback: (e: KeyboardEvent) => void, options: HotKeyOptions = {}, ) { useEffect(() => { if (action == null) return; jotaiStore.set(callbacksAtom, (prev) => { const without = prev.filter((cb) => { const isTheSame = cb.action === action && cb.options.priority === options.priority; return !isTheSame; }); const newCb: Callback = { action, callback, options }; return [...without, newCb]; }); return () => { jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback)); }; }, [action, callback, options]); } export function useSubscribeHotKeys() { useEffect(() => { document.addEventListener('keyup', handleKeyUp, { capture: true }); document.addEventListener('keydown', handleKeyDown, { capture: true }); return () => { document.removeEventListener('keydown', handleKeyDown, { capture: true }); document.removeEventListener('keyup', handleKeyUp, { capture: true }); }; }, []); } function handleKeyUp(e: KeyboardEvent) { const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key; const currentKeys = new Set(jotaiStore.get(currentKeysAtom)); currentKeys.delete(keyToRemove); // Clear all keys if no longer holding modifier // HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ; // As you see, the ":" is not removed because it turned into ";" when shift was released const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; if (!isHoldingModifier) { currentKeys.clear(); } jotaiStore.set(currentKeysAtom, currentKeys); } function handleKeyDown(e: KeyboardEvent) { // Don't add key if not holding modifier const isValidKeymapKey = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || SINGLE_WHITELIST.includes(e.key); if (!isValidKeymapKey) { return; } // Don't add hold keys if (HOLD_KEYS.includes(e.key)) { return; } const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key; const currentKeys = new Set(jotaiStore.get(currentKeysAtom)); currentKeys.add(keyToAdd); const currentKeysWithModifiers = new Set(currentKeys); if (e.altKey) currentKeysWithModifiers.add('Alt'); if (e.ctrlKey) currentKeysWithModifiers.add('Control'); if (e.metaKey) currentKeysWithModifiers.add('Meta'); if (e.shiftKey) currentKeysWithModifiers.add('Shift'); // Don't trigger if the user is focused within an element that explicitly disableds hotkeys if (document.activeElement?.closest('[data-disable-hotkey]')) { return; } // Don't support certain single-key combinations within inputs if ( (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) && currentKeysWithModifiers.size === 1 && (currentKeysWithModifiers.has('Backspace') || currentKeysWithModifiers.has('Delete')) ) { return; } const executed: string[] = []; for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) { const enable = typeof options.enable === 'function' ? options.enable() : options.enable; if (enable === false) { continue; } if (keysMatchAction(Array.from(currentKeysWithModifiers), action)) { if (!options.allowDefault) { e.preventDefault(); e.stopPropagation(); } callback(e); executed.push(`${action} ${options.priority ?? 0}`); break; } } if (executed.length > 0) { console.log('Executed hotkey', executed.join(', ')); jotaiStore.set(currentKeysAtom, new Set([])); } clearCurrentKeysDebounced(); } export function useHotkeyLabel(action: HotkeyAction): string { return hotkeyLabels[action]; } export function getHotkeyScope(action: HotkeyAction): string { const scope = action.split('.')[0]; return scope || ''; } export function formatHotkeyString(trigger: string): string[] { const os = type(); const parts = trigger.split('+'); const labelParts: string[] = []; for (const p of parts) { if (os === 'macos') { if (p === 'Meta') { labelParts.push('⌘'); } else if (p === 'Shift') { labelParts.push('⇧'); } else if (p === 'Control') { labelParts.push('⌃'); } else if (p === 'Alt') { labelParts.push('⌥'); } else if (p === 'Enter') { labelParts.push('↩'); } else if (p === 'Tab') { labelParts.push('⇥'); } else if (p === 'Backspace') { labelParts.push('⌫'); } else if (p === 'Delete') { labelParts.push('⌦'); } else if (p === 'Minus') { labelParts.push('-'); } else if (p === 'Plus') { labelParts.push('+'); } else if (p === 'Equal') { labelParts.push('='); } else if (p === 'Space') { labelParts.push('Space'); } else { labelParts.push(capitalize(p)); } } else { if (p === 'Control') { labelParts.push('Ctrl'); } else if (p === 'Space') { labelParts.push('Space'); } else { labelParts.push(capitalize(p)); } } } if (os === 'macos') { return labelParts; } return [labelParts.join('+')]; } export function useFormattedHotkey(action: HotkeyAction | null): string[] | null { const hotkeys = useAtomValue(hotkeysAtom); const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null; if (trigger == null) { return null; } return formatHotkeyString(trigger); } function compareKeys(keysA: string[], keysB: string[]) { if (keysA.length !== keysB.length) return false; const sortedA = keysA .map((k) => k.toLowerCase()) .sort() .join('::'); const sortedB = keysB .map((k) => k.toLowerCase()) .sort() .join('::'); return sortedA === sortedB; } /** Build the full key combination from a KeyboardEvent including modifiers */ function getKeysFromEvent(e: KeyboardEvent): string[] { const keys: string[] = []; if (e.altKey) keys.push('Alt'); if (e.ctrlKey) keys.push('Control'); if (e.metaKey) keys.push('Meta'); if (e.shiftKey) keys.push('Shift'); // Add the actual key (use code for layout-insensitive keys) const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key; keys.push(keyToAdd); return keys; } /** Check if a set of pressed keys matches any hotkey for the given action */ function keysMatchAction(keys: string[], action: HotkeyAction): boolean { const hotkeys = getHotkeys(); const hkKeys = hotkeys[action]; if (!hkKeys || hkKeys.length === 0) return false; for (const hkKey of hkKeys) { const hotkeyParts = hkKey.split('+'); if (compareKeys(hotkeyParts, keys)) { return true; } } return false; } /** Check if a KeyboardEvent matches a hotkey action */ export function eventMatchesHotkey(e: KeyboardEvent, action: HotkeyAction): boolean { const keys = getKeysFromEvent(e); return keysMatchAction(keys, action); }