Add configurable hotkey for editor autocomplete trigger (#350)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-01-07 15:10:33 -08:00
committed by GitHub
parent 873abe69a1
commit ebcdee9be0
4 changed files with 120 additions and 28 deletions

View File

@@ -1,7 +1,8 @@
import { patchModel, settingsAtom } from '@yaakapp-internal/models'; import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames'; import classNames from 'classnames';
import { fuzzyMatch } from 'fuzzbunny';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
defaultHotkeys, defaultHotkeys,
formatHotkeyString, formatHotkeyString,
@@ -19,11 +20,19 @@ import { Heading } from '../core/Heading';
import { HotkeyRaw } from '../core/Hotkey'; import { HotkeyRaw } from '../core/Hotkey';
import { Icon } from '../core/Icon'; import { Icon } from '../core/Icon';
import { IconButton } from '../core/IconButton'; import { IconButton } from '../core/IconButton';
import { PlainInput } from '../core/PlainInput';
import { HStack, VStack } from '../core/Stacks'; import { HStack, VStack } from '../core/Stacks';
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table'; import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
const HOLD_KEYS = ['Shift', 'Control', 'Alt', 'Meta']; 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" */ /** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
function eventToHotkeyString(e: KeyboardEvent): string | null { function eventToHotkeyString(e: KeyboardEvent): string | null {
@@ -58,6 +67,19 @@ function eventToHotkeyString(e: KeyboardEvent): string | null {
export function SettingsHotkeys() { export function SettingsHotkeys() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const hotkeys = useAtomValue(hotkeysAtom); 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) { if (settings == null) {
return null; return null;
@@ -71,6 +93,14 @@ export function SettingsHotkeys() {
Click the menu button to add, remove, or reset keyboard shortcuts. Click the menu button to add, remove, or reset keyboard shortcuts.
</p> </p>
</div> </div>
<PlainInput
label="Filter"
placeholder="Filter shortcuts..."
defaultValue={filter}
onChange={setFilter}
hideLabel
containerClassName="max-w-xs"
/>
<Table> <Table>
<TableHead> <TableHead>
<TableRow> <TableRow>
@@ -80,8 +110,9 @@ export function SettingsHotkeys() {
<TableHeaderCell></TableHeaderCell> <TableHeaderCell></TableHeaderCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> {/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
{hotkeyActions.map((action) => ( <TableBody key={filter}>
{filteredActions.map((action) => (
<HotkeyRow <HotkeyRow
key={action} key={action}
action={action} action={action}

View File

@@ -1,3 +1,4 @@
import { startCompletion } from '@codemirror/autocomplete';
import { defaultKeymap, historyField, indentWithTab } from '@codemirror/commands'; import { defaultKeymap, historyField, indentWithTab } from '@codemirror/commands';
import { foldState, forceParsing } from '@codemirror/language'; import { foldState, forceParsing } from '@codemirror/language';
import type { EditorStateConfig, Extension } from '@codemirror/state'; import type { EditorStateConfig, Extension } from '@codemirror/state';
@@ -28,6 +29,7 @@ import {
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment'; import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables'; import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables'; import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
import { eventMatchesHotkey } from '../../../hooks/useHotKey';
import { useRequestEditor } from '../../../hooks/useRequestEditor'; import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions'; import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { editEnvironment } from '../../../lib/editEnvironment'; import { editEnvironment } from '../../../lib/editEnvironment';
@@ -580,7 +582,13 @@ function getExtensions({
blur: () => { blur: () => {
onBlur.current?.(); 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); onKeyDown.current?.(e);
}, },
paste: (e, v) => { paste: (e, v) => {

View File

@@ -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 = [ export const baseExtensions = [
highlightSpecialChars(), highlightSpecialChars(),
history(), history(),
@@ -192,6 +202,7 @@ export const baseExtensions = [
autocompletion({ autocompletion({
tooltipClass: () => 'x-theme-menu', tooltipClass: () => 'x-theme-menu',
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
defaultKeymap: false, // We handle the trigger via configurable hotkeys
compareCompletions: (a, b) => { compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost // Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0); return (a.boost ?? 0) - (b.boost ?? 0);
@@ -199,7 +210,7 @@ export const baseExtensions = [
}), }),
syntaxHighlighting(syntaxHighlightStyle), syntaxHighlighting(syntaxHighlightStyle),
syntaxTheme, syntaxTheme,
keymap.of([...historyKeymap, ...completionKeymap]), keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
]; ];
export const readonlyExtensions = [ export const readonlyExtensions = [

View File

@@ -14,6 +14,7 @@ export type HotkeyAction =
| 'app.zoom_out' | 'app.zoom_out'
| 'app.zoom_reset' | 'app.zoom_reset'
| 'command_palette.toggle' | 'command_palette.toggle'
| 'editor.autocomplete'
| 'environment_editor.toggle' | 'environment_editor.toggle'
| 'hotkeys.showHelp' | 'hotkeys.showHelp'
| 'model.create' | 'model.create'
@@ -41,6 +42,7 @@ const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
'app.zoom_out': ['Meta+Minus'], 'app.zoom_out': ['Meta+Minus'],
'app.zoom_reset': ['Meta+0'], 'app.zoom_reset': ['Meta+0'],
'command_palette.toggle': ['Meta+k'], 'command_palette.toggle': ['Meta+k'],
'editor.autocomplete': ['Control+Space'],
'environment_editor.toggle': ['Meta+Shift+e'], 'environment_editor.toggle': ['Meta+Shift+e'],
'request.rename': ['Control+Shift+r'], 'request.rename': ['Control+Shift+r'],
'request.send': ['Meta+Enter', 'Meta+r'], 'request.send': ['Meta+Enter', 'Meta+r'],
@@ -69,6 +71,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
'app.zoom_out': ['Control+Minus'], 'app.zoom_out': ['Control+Minus'],
'app.zoom_reset': ['Control+0'], 'app.zoom_reset': ['Control+0'],
'command_palette.toggle': ['Control+k'], 'command_palette.toggle': ['Control+k'],
'editor.autocomplete': ['Control+Space'],
'environment_editor.toggle': ['Control+Shift+e'], 'environment_editor.toggle': ['Control+Shift+e'],
'request.rename': ['F2'], 'request.rename': ['F2'],
'request.send': ['Control+Enter', 'Control+r'], 'request.send': ['Control+Enter', 'Control+r'],
@@ -122,6 +125,7 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'app.zoom_out': 'Zoom Out', 'app.zoom_out': 'Zoom Out',
'app.zoom_reset': 'Zoom to Actual Size', 'app.zoom_reset': 'Zoom to Actual Size',
'command_palette.toggle': 'Toggle Command Palette', 'command_palette.toggle': 'Toggle Command Palette',
'editor.autocomplete': 'Trigger Autocomplete',
'environment_editor.toggle': 'Edit Environments', 'environment_editor.toggle': 'Edit Environments',
'hotkeys.showHelp': 'Show Keyboard Shortcuts', 'hotkeys.showHelp': 'Show Keyboard Shortcuts',
'model.create': 'New Request', 'model.create': 'New Request',
@@ -144,7 +148,14 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
'workspace_settings.show': 'Open Workspace Settings', '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[] = ( export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[] Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
@@ -265,29 +276,20 @@ function handleKeyDown(e: KeyboardEvent) {
} }
const executed: string[] = []; const executed: string[] = [];
const hotkeys = getHotkeys(); for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) { const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) { if (enable === false) {
if (hkAction !== action) { continue;
continue; }
}
const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
if (enable === false) {
continue;
}
for (const hkKey of hkKeys) { if (keysMatchAction(Array.from(currentKeysWithModifiers), action)) {
const keys = hkKey.split('+'); if (!options.allowDefault) {
if (compareKeys(keys, Array.from(currentKeysWithModifiers))) { e.preventDefault();
if (!options.allowDefault) { e.stopPropagation();
e.preventDefault();
e.stopPropagation();
}
callback(e);
executed.push(`${action} ${options.priority ?? 0}`);
break outer;
}
} }
callback(e);
executed.push(`${action} ${options.priority ?? 0}`);
break;
} }
} }
@@ -336,12 +338,16 @@ export function formatHotkeyString(trigger: string): string[] {
labelParts.push('+'); labelParts.push('+');
} else if (p === 'Equal') { } else if (p === 'Equal') {
labelParts.push('='); labelParts.push('=');
} else if (p === 'Space') {
labelParts.push('Space');
} else { } else {
labelParts.push(capitalize(p)); labelParts.push(capitalize(p));
} }
} else { } else {
if (p === 'Control') { if (p === 'Control') {
labelParts.push('Ctrl'); labelParts.push('Ctrl');
} else if (p === 'Space') {
labelParts.push('Space');
} else { } else {
labelParts.push(capitalize(p)); labelParts.push(capitalize(p));
} }
@@ -376,3 +382,39 @@ function compareKeys(keysA: string[], keysB: string[]) {
.join('::'); .join('::');
return sortedA === sortedB; 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);
}