Add configurable hotkeys support (#343)

This commit is contained in:
Gregory Schier
2026-01-04 08:36:22 -08:00
committed by GitHub
parent 58bf55704a
commit 00bf5920e3
23 changed files with 540 additions and 79 deletions

View File

@@ -1,6 +1,7 @@
import { type } from '@tauri-apps/plugin-os';
import { debounce } from '@yaakapp-internal/lib';
import { atom } from 'jotai';
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';
@@ -13,7 +14,7 @@ export type HotkeyAction =
| 'app.zoom_out'
| 'app.zoom_reset'
| 'command_palette.toggle'
| 'environmentEditor.toggle'
| 'environment_editor.toggle'
| 'hotkeys.showHelp'
| 'model.create'
| 'model.duplicate'
@@ -34,39 +35,94 @@ export type HotkeyAction =
| 'url_bar.focus'
| 'workspace_settings.show';
const hotkeys: Record<HotkeyAction, string[]> = {
'app.zoom_in': ['CmdCtrl+Equal'],
'app.zoom_out': ['CmdCtrl+Minus'],
'app.zoom_reset': ['CmdCtrl+0'],
'command_palette.toggle': ['CmdCtrl+k'],
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
'request.rename': type() === 'macos' ? ['Control+Shift+r'] : ['F2'],
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
'model.create': ['CmdCtrl+n'],
'model.duplicate': ['CmdCtrl+d'],
/** Default hotkeys for macOS (uses Meta for Cmd) */
const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
'app.zoom_in': ['Meta+Equal'],
'app.zoom_out': ['Meta+Minus'],
'app.zoom_reset': ['Meta+0'],
'command_palette.toggle': ['Meta+k'],
'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': ['CmdCtrl+p'],
'settings.show': ['CmdCtrl+,'],
'sidebar.filter': ['CmdCtrl+f'],
'sidebar.expand_all': ['CmdCtrl+Shift+Equal'],
'sidebar.collapse_all': ['CmdCtrl+Shift+Minus'],
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'],
'sidebar.selected.duplicate': ['CmdCtrl+d'],
'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': ['CmdCtrl+b'],
'sidebar.context_menu': type() === 'macos' ? ['Control+Enter'] : ['Alt+Insert'],
'url_bar.focus': ['CmdCtrl+l'],
'workspace_settings.show': ['CmdCtrl+;'],
'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<HotkeyAction, string[]> = {
'app.zoom_in': ['Control+Equal'],
'app.zoom_out': ['Control+Minus'],
'app.zoom_reset': ['Control+0'],
'command_palette.toggle': ['Control+k'],
'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<HotkeyAction, string[]> =
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<HotkeyAction, string[]> = { ...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<HotkeyAction, string[]> {
return jotaiStore.get(hotkeysAtom);
}
const hotkeyLabels: Record<HotkeyAction, string> = {
'app.zoom_in': 'Zoom In',
'app.zoom_out': 'Zoom Out',
'app.zoom_reset': 'Zoom to Actual Size',
'command_palette.toggle': 'Toggle Command Palette',
'environmentEditor.toggle': 'Edit Environments',
'environment_editor.toggle': 'Edit Environments',
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
'model.create': 'New Request',
'model.duplicate': 'Duplicate Request',
@@ -90,7 +146,16 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote'];
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
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);
@@ -200,6 +265,7 @@ 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) {
@@ -212,8 +278,7 @@ function handleKeyDown(e: KeyboardEvent) {
for (const hkKey of hkKeys) {
const keys = hkKey.split('+');
const adjustedKeys = keys.map(resolveHotkeyKey);
if (compareKeys(adjustedKeys, Array.from(currentKeysWithModifiers))) {
if (compareKeys(keys, Array.from(currentKeysWithModifiers))) {
if (!options.allowDefault) {
e.preventDefault();
e.stopPropagation();
@@ -233,34 +298,38 @@ function handleKeyDown(e: KeyboardEvent) {
clearCurrentKeysDebounced();
}
export function useHotKeyLabel(action: HotkeyAction): string {
export function useHotkeyLabel(action: HotkeyAction): string {
return hotkeyLabels[action];
}
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (trigger == null) {
return null;
}
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 === 'CmdCtrl') {
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') {
@@ -271,7 +340,7 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
labelParts.push(capitalize(p));
}
} else {
if (p === 'CmdCtrl') {
if (p === 'Control') {
labelParts.push('Ctrl');
} else {
labelParts.push(capitalize(p));
@@ -285,12 +354,15 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
return [labelParts.join('+')];
}
const resolveHotkeyKey = (key: string) => {
const os = type();
if (key === 'CmdCtrl' && os === 'macos') return 'Meta';
if (key === 'CmdCtrl') return 'Control';
return key;
};
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;