diff --git a/src-web/components/Settings/SettingsHotkeys.tsx b/src-web/components/Settings/SettingsHotkeys.tsx index c1ef58d7..f5fc1280 100644 --- a/src-web/components/Settings/SettingsHotkeys.tsx +++ b/src-web/components/Settings/SettingsHotkeys.tsx @@ -1,7 +1,8 @@ import { patchModel, settingsAtom } from '@yaakapp-internal/models'; import classNames from 'classnames'; +import { fuzzyMatch } from 'fuzzbunny'; import { useAtomValue } from 'jotai'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { defaultHotkeys, formatHotkeyString, @@ -19,11 +20,19 @@ 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']; +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 { @@ -58,6 +67,19 @@ function eventToHotkeyString(e: KeyboardEvent): string | null { 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; @@ -71,6 +93,14 @@ export function SettingsHotkeys() { Click the menu button to add, remove, or reset keyboard shortcuts.

+ @@ -80,8 +110,9 @@ export function SettingsHotkeys() { - - {hotkeyActions.map((action) => ( + {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */} + + {filteredActions.map((action) => ( { onBlur.current?.(); }, - keydown: (e) => { + keydown: (e, view) => { + // Check if the hotkey matches the editor.autocomplete action + if (eventMatchesHotkey(e, 'editor.autocomplete')) { + e.preventDefault(); + startCompletion(view); + return true; + } onKeyDown.current?.(e); }, paste: (e, v) => { diff --git a/src-web/components/core/Editor/extensions.ts b/src-web/components/core/Editor/extensions.ts index a145e27d..2bf9b8b5 100644 --- a/src-web/components/core/Editor/extensions.ts +++ b/src-web/components/core/Editor/extensions.ts @@ -184,6 +184,16 @@ export function getLanguageExtension({ }); } +// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys. +// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings. +const filteredCompletionKeymap = completionKeymap.filter((binding) => { + const key = binding.key?.toLowerCase() ?? ''; + const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? ''; + // Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i) + const isStartTrigger = key.includes('space') || mac.includes('alt-') || mac.includes('`'); + return !isStartTrigger; +}); + export const baseExtensions = [ highlightSpecialChars(), history(), @@ -192,6 +202,7 @@ export const baseExtensions = [ autocompletion({ tooltipClass: () => 'x-theme-menu', closeOnBlur: true, // Set to `false` for debugging in devtools without closing it + defaultKeymap: false, // We handle the trigger via configurable hotkeys compareCompletions: (a, b) => { // Don't sort completions at all, only on boost return (a.boost ?? 0) - (b.boost ?? 0); @@ -199,7 +210,7 @@ export const baseExtensions = [ }), syntaxHighlighting(syntaxHighlightStyle), syntaxTheme, - keymap.of([...historyKeymap, ...completionKeymap]), + keymap.of([...historyKeymap, ...filteredCompletionKeymap]), ]; export const readonlyExtensions = [ diff --git a/src-web/hooks/useHotKey.ts b/src-web/hooks/useHotKey.ts index 44f265e7..40e9985c 100644 --- a/src-web/hooks/useHotKey.ts +++ b/src-web/hooks/useHotKey.ts @@ -14,6 +14,7 @@ export type HotkeyAction = | 'app.zoom_out' | 'app.zoom_reset' | 'command_palette.toggle' + | 'editor.autocomplete' | 'environment_editor.toggle' | 'hotkeys.showHelp' | 'model.create' @@ -41,6 +42,7 @@ const defaultHotkeysMac: Record = { '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'], @@ -69,6 +71,7 @@ const defaultHotkeysOther: Record = { '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'], @@ -122,6 +125,7 @@ const hotkeyLabels: Record = { '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', @@ -144,7 +148,14 @@ const hotkeyLabels: Record = { 'workspace_settings.show': 'Open Workspace Settings', }; -const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote']; +const layoutInsensitiveKeys = [ + 'Equal', + 'Minus', + 'BracketLeft', + 'BracketRight', + 'Backquote', + 'Space', +]; export const hotkeyActions: HotkeyAction[] = ( Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[] @@ -265,29 +276,20 @@ function handleKeyDown(e: KeyboardEvent) { } const executed: string[] = []; - const hotkeys = getHotkeys(); - outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) { - for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) { - if (hkAction !== action) { - continue; - } - const enable = typeof options.enable === 'function' ? options.enable() : options.enable; - if (enable === false) { - continue; - } + for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) { + const enable = typeof options.enable === 'function' ? options.enable() : options.enable; + if (enable === false) { + continue; + } - for (const hkKey of hkKeys) { - const keys = hkKey.split('+'); - if (compareKeys(keys, Array.from(currentKeysWithModifiers))) { - if (!options.allowDefault) { - e.preventDefault(); - e.stopPropagation(); - } - callback(e); - executed.push(`${action} ${options.priority ?? 0}`); - break outer; - } + if (keysMatchAction(Array.from(currentKeysWithModifiers), action)) { + if (!options.allowDefault) { + e.preventDefault(); + e.stopPropagation(); } + callback(e); + executed.push(`${action} ${options.priority ?? 0}`); + break; } } @@ -336,12 +338,16 @@ export function formatHotkeyString(trigger: string): string[] { 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)); } @@ -376,3 +382,39 @@ function compareKeys(keysA: string[], keysB: string[]) { .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); +}