diff --git a/src-tauri/yaak-models/bindings/gen_models.ts b/src-tauri/yaak-models/bindings/gen_models.ts index 844c0deb..cb3eb035 100644 --- a/src-tauri/yaak-models/bindings/gen_models.ts +++ b/src-tauri/yaak-models/bindings/gen_models.ts @@ -73,7 +73,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut export type ProxySettingAuth = { user: string, password: string, }; -export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, }; +export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: Record, }; export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, }; diff --git a/src-tauri/yaak-models/migrations/20260104000000_hotkeys.sql b/src-tauri/yaak-models/migrations/20260104000000_hotkeys.sql new file mode 100644 index 00000000..fbc28b76 --- /dev/null +++ b/src-tauri/yaak-models/migrations/20260104000000_hotkeys.sql @@ -0,0 +1 @@ +ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL; diff --git a/src-tauri/yaak-models/src/models.rs b/src-tauri/yaak-models/src/models.rs index 63418896..5a8eda22 100644 --- a/src-tauri/yaak-models/src/models.rs +++ b/src-tauri/yaak-models/src/models.rs @@ -11,6 +11,7 @@ use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_d use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; use std::collections::BTreeMap; +use std::collections::HashMap; use std::fmt::{Debug, Display}; use std::str::FromStr; use ts_rs::TS; @@ -147,6 +148,7 @@ pub struct Settings { pub autoupdate: bool, pub auto_download_updates: bool, pub check_notifications: bool, + pub hotkeys: HashMap>, } impl UpsertModelInfo for Settings { @@ -180,6 +182,7 @@ impl UpsertModelInfo for Settings { Some(p) => Some(serde_json::to_string(&p)?), }; let client_certificates = serde_json::to_string(&self.client_certificates)?; + let hotkeys = serde_json::to_string(&self.hotkeys)?; Ok(vec![ (CreatedAt, upsert_date(source, self.created_at)), (UpdatedAt, upsert_date(source, self.updated_at)), @@ -204,6 +207,7 @@ impl UpsertModelInfo for Settings { (ColoredMethods, self.colored_methods.into()), (CheckNotifications, self.check_notifications.into()), (Proxy, proxy.into()), + (Hotkeys, hotkeys.into()), ]) } @@ -231,6 +235,7 @@ impl UpsertModelInfo for Settings { SettingsIden::AutoDownloadUpdates, SettingsIden::ColoredMethods, SettingsIden::CheckNotifications, + SettingsIden::Hotkeys, ] } @@ -241,6 +246,7 @@ impl UpsertModelInfo for Settings { let proxy: Option = row.get("proxy")?; let client_certificates: String = row.get("client_certificates")?; let editor_keymap: String = row.get("editor_keymap")?; + let hotkeys: String = row.get("hotkeys")?; Ok(Self { id: row.get("id")?, model: row.get("model")?, @@ -267,6 +273,7 @@ impl UpsertModelInfo for Settings { hide_license_badge: row.get("hide_license_badge")?, colored_methods: row.get("colored_methods")?, check_notifications: row.get("check_notifications")?, + hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), }) } } diff --git a/src-tauri/yaak-models/src/queries/settings.rs b/src-tauri/yaak-models/src/queries/settings.rs index 028568e0..b44aee3a 100644 --- a/src-tauri/yaak-models/src/queries/settings.rs +++ b/src-tauri/yaak-models/src/queries/settings.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use crate::db_context::DbContext; use crate::error::Result; use crate::models::{EditorKeymap, Settings, SettingsIden}; @@ -38,6 +40,7 @@ impl<'a> DbContext<'a> { hide_license_badge: false, auto_download_updates: true, check_notifications: true, + hotkeys: HashMap::new(), }; self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings") } diff --git a/src-web/components/CommandPaletteDialog.tsx b/src-web/components/CommandPaletteDialog.tsx index df113a7f..a2c04d36 100644 --- a/src-web/components/CommandPaletteDialog.tsx +++ b/src-web/components/CommandPaletteDialog.tsx @@ -46,7 +46,7 @@ import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams'; import { CookieDialog } from './CookieDialog'; import { Button } from './core/Button'; import { Heading } from './core/Heading'; -import { HotKey } from './core/HotKey'; +import { Hotkey } from './core/Hotkey'; import { HttpMethodTag } from './core/HttpMethodTag'; import { Icon } from './core/Icon'; import { PlainInput } from './core/PlainInput'; @@ -139,7 +139,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) { { key: 'environment.edit', label: 'Edit Environment', - action: 'environmentEditor.toggle', + action: 'environment_editor.toggle', onSelect: () => editEnvironment(activeEnvironment), }, { @@ -493,5 +493,5 @@ function CommandPaletteItem({ } function CommandPaletteAction({ action }: { action: HotkeyAction }) { - return ; + return ; } diff --git a/src-web/components/EnvironmentActionsDropdown.tsx b/src-web/components/EnvironmentActionsDropdown.tsx index 6beffff6..bb215d28 100644 --- a/src-web/components/EnvironmentActionsDropdown.tsx +++ b/src-web/components/EnvironmentActionsDropdown.tsx @@ -45,7 +45,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo : []) as DropdownItem[]), { label: 'Manage Environments', - hotKeyAction: 'environmentEditor.toggle', + hotKeyAction: 'environment_editor.toggle', leftSlot: , onSelect: () => editEnvironment(activeEnvironment), }, diff --git a/src-web/components/GrpcConnectionLayout.tsx b/src-web/components/GrpcConnectionLayout.tsx index fe3d5890..24260914 100644 --- a/src-web/components/GrpcConnectionLayout.tsx +++ b/src-web/components/GrpcConnectionLayout.tsx @@ -9,7 +9,7 @@ import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles'; import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection'; import { workspaceLayoutAtom } from '../lib/atoms'; import { Banner } from './core/Banner'; -import { HotKeyList } from './core/HotKeyList'; +import { HotkeyList } from './core/HotkeyList'; import { SplitLayout } from './core/SplitLayout'; import { GrpcRequestPane } from './GrpcRequestPane'; import { GrpcResponsePane } from './GrpcResponsePane'; @@ -117,7 +117,7 @@ export function GrpcConnectionLayout({ style }: Props) { ) : grpcEvents.length >= 0 ? ( ) : ( - + )} ) diff --git a/src-web/components/GrpcResponsePane.tsx b/src-web/components/GrpcResponsePane.tsx index 45ae9ef1..bdcc545c 100644 --- a/src-web/components/GrpcResponsePane.tsx +++ b/src-web/components/GrpcResponsePane.tsx @@ -16,7 +16,7 @@ import { AutoScroller } from './core/AutoScroller'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { Editor } from './core/Editor/LazyEditor'; -import { HotKeyList } from './core/HotKeyList'; +import { HotkeyList } from './core/HotkeyList'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; @@ -73,7 +73,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) { minHeightPx={20} firstSlot={() => activeConnection == null ? ( - ) : ( diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index 02c575c8..c59bcdc1 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -15,7 +15,7 @@ import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { CountBadge } from './core/CountBadge'; -import { HotKeyList } from './core/HotKeyList'; +import { HotkeyList } from './core/HotkeyList'; import { HttpResponseDurationTag } from './core/HttpResponseDurationTag'; import { HttpStatusTag } from './core/HttpStatusTag'; import { LoadingIcon } from './core/LoadingIcon'; @@ -139,7 +139,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { )} > {activeResponse == null ? ( - + ) : (
- +
); } diff --git a/src-web/components/Settings/Settings.tsx b/src-web/components/Settings/Settings.tsx index 39158190..cbfa9a98 100644 --- a/src-web/components/Settings/Settings.tsx +++ b/src-web/components/Settings/Settings.tsx @@ -10,11 +10,13 @@ import { useKeyPressEvent } from 'react-use'; import { appInfo } from '../../lib/appInfo'; import { capitalize } from '../../lib/capitalize'; import { CountBadge } from '../core/CountBadge'; +import { Icon } from '../core/Icon'; import { HStack } from '../core/Stacks'; import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs'; import { HeaderSize } from '../HeaderSize'; import { SettingsCertificates } from './SettingsCertificates'; import { SettingsGeneral } from './SettingsGeneral'; +import { SettingsHotkeys } from './SettingsHotkeys'; import { SettingsInterface } from './SettingsInterface'; import { SettingsLicense } from './SettingsLicense'; import { SettingsPlugins } from './SettingsPlugins'; @@ -28,6 +30,7 @@ interface Props { const TAB_GENERAL = 'general'; const TAB_INTERFACE = 'interface'; const TAB_THEME = 'theme'; +const TAB_SHORTCUTS = 'shortcuts'; const TAB_PROXY = 'proxy'; const TAB_CERTIFICATES = 'certificates'; const TAB_PLUGINS = 'plugins'; @@ -36,6 +39,7 @@ const tabs = [ TAB_GENERAL, TAB_THEME, TAB_INTERFACE, + TAB_SHORTCUTS, TAB_CERTIFICATES, TAB_PROXY, TAB_PLUGINS, @@ -97,6 +101,24 @@ export default function Settings({ hide }: Props) { value, label: capitalize(value), hidden: !appInfo.featureLicense && value === TAB_LICENSE, + leftSlot: + value === TAB_GENERAL ? ( + + ) : value === TAB_THEME ? ( + + ) : value === TAB_INTERFACE ? ( + + ) : value === TAB_SHORTCUTS ? ( + + ) : value === TAB_CERTIFICATES ? ( + + ) : value === TAB_PROXY ? ( + + ) : value === TAB_PLUGINS ? ( + + ) : value === TAB_LICENSE ? ( + + ) : null, rightSlot: value === TAB_CERTIFICATES ? ( @@ -119,6 +141,9 @@ export default function Settings({ hide }: Props) { + + + diff --git a/src-web/components/Settings/SettingsHotkeys.tsx b/src-web/components/Settings/SettingsHotkeys.tsx new file mode 100644 index 00000000..c1ef58d7 --- /dev/null +++ b/src-web/components/Settings/SettingsHotkeys.tsx @@ -0,0 +1,326 @@ +import { patchModel, settingsAtom } from '@yaakapp-internal/models'; +import classNames from 'classnames'; +import { useAtomValue } from 'jotai'; +import { useCallback, useEffect, useState } from 'react'; +import { + defaultHotkeys, + formatHotkeyString, + getHotkeyScope, + type HotkeyAction, + hotkeyActions, + hotkeysAtom, + useHotkeyLabel, +} from '../../hooks/useHotKey'; +import { capitalize } from '../../lib/capitalize'; +import { showDialog } from '../../lib/dialog'; +import { Button } from '../core/Button'; +import { Dropdown, type DropdownItem } from '../core/Dropdown'; +import { Heading } from '../core/Heading'; +import { HotkeyRaw } from '../core/Hotkey'; +import { Icon } from '../core/Icon'; +import { IconButton } from '../core/IconButton'; +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']; + +/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */ +function eventToHotkeyString(e: KeyboardEvent): string | null { + // Don't capture modifier-only key presses + if (HOLD_KEYS.includes(e.key)) { + return null; + } + + const parts: string[] = []; + + // Add modifiers in consistent order (Meta, Control, Alt, Shift) + if (e.metaKey) { + parts.push('Meta'); + } + if (e.ctrlKey) { + parts.push('Control'); + } + if (e.altKey) { + parts.push('Alt'); + } + if (e.shiftKey) { + parts.push('Shift'); + } + + // Get the main key - use the same logic as useHotKey.ts + const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key; + parts.push(key); + + return parts.join('+'); +} + +export function SettingsHotkeys() { + const settings = useAtomValue(settingsAtom); + const hotkeys = useAtomValue(hotkeysAtom); + + if (settings == null) { + return null; + } + + return ( + +
+ Keyboard Shortcuts +

+ Click the menu button to add, remove, or reset keyboard shortcuts. +

+
+ + + + Scope + Action + Shortcut + + + + + {hotkeyActions.map((action) => ( + { + const newHotkeys = { ...settings.hotkeys }; + if (arraysEqual(keys, defaultHotkeys[action])) { + // Remove from settings if it matches default (use default) + delete newHotkeys[action]; + } else { + // Store the keys (including empty array to disable) + newHotkeys[action] = keys; + } + await patchModel(settings, { hotkeys: newHotkeys }); + }} + onReset={async () => { + const newHotkeys = { ...settings.hotkeys }; + delete newHotkeys[action]; + await patchModel(settings, { hotkeys: newHotkeys }); + }} + /> + ))} + +
+
+ ); +} + +interface HotkeyRowProps { + action: HotkeyAction; + currentKeys: string[]; + defaultKeys: string[]; + onSave: (keys: string[]) => Promise; + onReset: () => Promise; +} + +function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) { + const label = useHotkeyLabel(action); + const scope = capitalize(getHotkeyScope(action).replace(/_/g, ' ')); + const isCustomized = !arraysEqual(currentKeys, defaultKeys); + const isDisabled = currentKeys.length === 0; + + const handleStartRecording = useCallback(() => { + showDialog({ + id: `record-hotkey-${action}`, + title: label, + size: 'sm', + render: ({ hide }) => ( + { + await onSave([...currentKeys, key]); + hide(); + }} + onCancel={hide} + /> + ), + }); + }, [action, label, currentKeys, onSave]); + + const handleRemove = useCallback( + async (keyToRemove: string) => { + const newKeys = currentKeys.filter((k) => k !== keyToRemove); + await onSave(newKeys); + }, + [currentKeys, onSave], + ); + + const handleClearAll = useCallback(async () => { + await onSave([]); + }, [onSave]); + + // Build dropdown items dynamically + const dropdownItems: DropdownItem[] = [ + { + label: 'Add Keyboard Shortcut', + leftSlot: , + onSelect: handleStartRecording, + }, + ]; + + // Add remove options for each existing shortcut + if (!isDisabled) { + currentKeys.forEach((key) => { + dropdownItems.push({ + label: ( + + Remove + + + ), + leftSlot: , + onSelect: () => handleRemove(key), + }); + }); + + if (currentKeys.length > 1) { + dropdownItems.push( + { + type: 'separator', + }, + { + label: 'Remove All Shortcuts', + leftSlot: , + onSelect: handleClearAll, + }, + ); + } + } + + if (isCustomized) { + dropdownItems.push({ + type: 'separator', + }); + dropdownItems.push({ + label: 'Reset to Default', + leftSlot: , + onSelect: onReset, + }); + } + + return ( + + + {scope} + + + {label} + + + + {isDisabled ? ( + Disabled + ) : ( + currentKeys.map((k) => ( + + )) + )} + + + + + + + + + ); +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((v, i) => v === sortedB[i]); +} + +interface RecordHotkeyDialogProps { + label: string; + onSave: (key: string) => void; + onCancel: () => void; +} + +function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) { + const [recordedKey, setRecordedKey] = useState(null); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (!isFocused) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.key === 'Escape') { + onCancel(); + return; + } + + const hotkeyString = eventToHotkeyString(e); + if (hotkeyString) { + setRecordedKey(hotkeyString); + } + }; + + window.addEventListener('keydown', handleKeyDown, { capture: true }); + return () => { + window.removeEventListener('keydown', handleKeyDown, { capture: true }); + }; + }, [isFocused, onCancel]); + + const handleSave = useCallback(() => { + if (recordedKey) { + onSave(recordedKey); + } + }, [recordedKey, onSave]); + + return ( + +
+

+ Record a key combination for {label} +

+ +
+ + + + +
+ ); +} diff --git a/src-web/components/WebsocketResponsePane.tsx b/src-web/components/WebsocketResponsePane.tsx index 77659eaa..78ca7627 100644 --- a/src-web/components/WebsocketResponsePane.tsx +++ b/src-web/components/WebsocketResponsePane.tsx @@ -18,7 +18,7 @@ import { AutoScroller } from './core/AutoScroller'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; import { Editor } from './core/Editor/LazyEditor'; -import { HotKeyList } from './core/HotKeyList'; +import { HotkeyList } from './core/HotkeyList'; import { Icon } from './core/Icon'; import { IconButton } from './core/IconButton'; import { LoadingIcon } from './core/LoadingIcon'; @@ -71,7 +71,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) { minHeightPx={20} firstSlot={() => activeConnection == null ? ( - ) : ( diff --git a/src-web/components/Workspace.tsx b/src-web/components/Workspace.tsx index c81032d7..d8ed7590 100644 --- a/src-web/components/Workspace.tsx +++ b/src-web/components/Workspace.tsx @@ -33,7 +33,7 @@ import { jotaiStore } from '../lib/jotai'; import { CreateDropdown } from './CreateDropdown'; import { Banner } from './core/Banner'; import { Button } from './core/Button'; -import { HotKeyList } from './core/HotKeyList'; +import { HotkeyList } from './core/HotkeyList'; import { FeedbackLink } from './core/Link'; import { HStack } from './core/Stacks'; import { ErrorBoundary } from './ErrorBoundary'; @@ -233,7 +233,7 @@ function WorkspaceBody() { } return ( - diff --git a/src-web/components/core/Dropdown.tsx b/src-web/components/core/Dropdown.tsx index 6c9f8c98..18260b80 100644 --- a/src-web/components/core/Dropdown.tsx +++ b/src-web/components/core/Dropdown.tsx @@ -34,7 +34,7 @@ import { jotaiStore } from '../../lib/jotai'; import { ErrorBoundary } from '../ErrorBoundary'; import { Overlay } from '../Overlay'; import { Button } from './Button'; -import { HotKey } from './HotKey'; +import { Hotkey } from './Hotkey'; import { Icon } from './Icon'; import { LoadingIcon } from './LoadingIcon'; import { Separator } from './Separator'; @@ -630,7 +630,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men [focused], ); - const rightSlot = item.rightSlot ?? ; + const rightSlot = item.rightSlot ?? ; return ( ); diff --git a/src-web/hooks/useHotKey.ts b/src-web/hooks/useHotKey.ts index 3d2a562e..44f265e7 100644 --- a/src-web/hooks/useHotKey.ts +++ b/src-web/hooks/useHotKey.ts @@ -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 = { - '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 = { + '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 = { + '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 = + 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', - '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 = { 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; diff --git a/src-web/lib/capitalize.ts b/src-web/lib/capitalize.ts index 9996d36f..cb513bf8 100644 --- a/src-web/lib/capitalize.ts +++ b/src-web/lib/capitalize.ts @@ -1,3 +1,6 @@ export function capitalize(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); + return str + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); }