mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-24 01:38:26 +02:00
Add configurable hotkey for editor autocomplete trigger (#350)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user