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

@@ -73,7 +73,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut
export type ProxySettingAuth = { user: string, password: string, }; export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, 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<ClientCertificate>, 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<string, string[]>, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, }; export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL;

View File

@@ -11,6 +11,7 @@ use sea_query::{IntoColumnRef, IntoIden, IntoTableRef, Order, SimpleExpr, enum_d
use serde::{Deserialize, Deserializer, Serialize}; use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value; use serde_json::Value;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt::{Debug, Display}; use std::fmt::{Debug, Display};
use std::str::FromStr; use std::str::FromStr;
use ts_rs::TS; use ts_rs::TS;
@@ -147,6 +148,7 @@ pub struct Settings {
pub autoupdate: bool, pub autoupdate: bool,
pub auto_download_updates: bool, pub auto_download_updates: bool,
pub check_notifications: bool, pub check_notifications: bool,
pub hotkeys: HashMap<String, Vec<String>>,
} }
impl UpsertModelInfo for Settings { impl UpsertModelInfo for Settings {
@@ -180,6 +182,7 @@ impl UpsertModelInfo for Settings {
Some(p) => Some(serde_json::to_string(&p)?), Some(p) => Some(serde_json::to_string(&p)?),
}; };
let client_certificates = serde_json::to_string(&self.client_certificates)?; let client_certificates = serde_json::to_string(&self.client_certificates)?;
let hotkeys = serde_json::to_string(&self.hotkeys)?;
Ok(vec![ Ok(vec![
(CreatedAt, upsert_date(source, self.created_at)), (CreatedAt, upsert_date(source, self.created_at)),
(UpdatedAt, upsert_date(source, self.updated_at)), (UpdatedAt, upsert_date(source, self.updated_at)),
@@ -204,6 +207,7 @@ impl UpsertModelInfo for Settings {
(ColoredMethods, self.colored_methods.into()), (ColoredMethods, self.colored_methods.into()),
(CheckNotifications, self.check_notifications.into()), (CheckNotifications, self.check_notifications.into()),
(Proxy, proxy.into()), (Proxy, proxy.into()),
(Hotkeys, hotkeys.into()),
]) ])
} }
@@ -231,6 +235,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::AutoDownloadUpdates, SettingsIden::AutoDownloadUpdates,
SettingsIden::ColoredMethods, SettingsIden::ColoredMethods,
SettingsIden::CheckNotifications, SettingsIden::CheckNotifications,
SettingsIden::Hotkeys,
] ]
} }
@@ -241,6 +246,7 @@ impl UpsertModelInfo for Settings {
let proxy: Option<String> = row.get("proxy")?; let proxy: Option<String> = row.get("proxy")?;
let client_certificates: String = row.get("client_certificates")?; let client_certificates: String = row.get("client_certificates")?;
let editor_keymap: String = row.get("editor_keymap")?; let editor_keymap: String = row.get("editor_keymap")?;
let hotkeys: String = row.get("hotkeys")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -267,6 +273,7 @@ impl UpsertModelInfo for Settings {
hide_license_badge: row.get("hide_license_badge")?, hide_license_badge: row.get("hide_license_badge")?,
colored_methods: row.get("colored_methods")?, colored_methods: row.get("colored_methods")?,
check_notifications: row.get("check_notifications")?, check_notifications: row.get("check_notifications")?,
hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(),
}) })
} }
} }

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::db_context::DbContext; use crate::db_context::DbContext;
use crate::error::Result; use crate::error::Result;
use crate::models::{EditorKeymap, Settings, SettingsIden}; use crate::models::{EditorKeymap, Settings, SettingsIden};
@@ -38,6 +40,7 @@ impl<'a> DbContext<'a> {
hide_license_badge: false, hide_license_badge: false,
auto_download_updates: true, auto_download_updates: true,
check_notifications: true, check_notifications: true,
hotkeys: HashMap::new(),
}; };
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings") self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
} }

View File

@@ -46,7 +46,7 @@ import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { CookieDialog } from './CookieDialog'; import { CookieDialog } from './CookieDialog';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Heading } from './core/Heading'; import { Heading } from './core/Heading';
import { HotKey } from './core/HotKey'; import { Hotkey } from './core/Hotkey';
import { HttpMethodTag } from './core/HttpMethodTag'; import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput'; import { PlainInput } from './core/PlainInput';
@@ -139,7 +139,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{ {
key: 'environment.edit', key: 'environment.edit',
label: 'Edit Environment', label: 'Edit Environment',
action: 'environmentEditor.toggle', action: 'environment_editor.toggle',
onSelect: () => editEnvironment(activeEnvironment), onSelect: () => editEnvironment(activeEnvironment),
}, },
{ {
@@ -493,5 +493,5 @@ function CommandPaletteItem({
} }
function CommandPaletteAction({ action }: { action: HotkeyAction }) { function CommandPaletteAction({ action }: { action: HotkeyAction }) {
return <HotKey className="ml-auto" action={action} />; return <Hotkey className="ml-auto" action={action} />;
} }

View File

@@ -45,7 +45,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
: []) as DropdownItem[]), : []) as DropdownItem[]),
{ {
label: 'Manage Environments', label: 'Manage Environments',
hotKeyAction: 'environmentEditor.toggle', hotKeyAction: 'environment_editor.toggle',
leftSlot: <Icon icon="box" />, leftSlot: <Icon icon="box" />,
onSelect: () => editEnvironment(activeEnvironment), onSelect: () => editEnvironment(activeEnvironment),
}, },

View File

@@ -9,7 +9,7 @@ import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection'; import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection';
import { workspaceLayoutAtom } from '../lib/atoms'; import { workspaceLayoutAtom } from '../lib/atoms';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { HotKeyList } from './core/HotKeyList'; import { HotkeyList } from './core/HotkeyList';
import { SplitLayout } from './core/SplitLayout'; import { SplitLayout } from './core/SplitLayout';
import { GrpcRequestPane } from './GrpcRequestPane'; import { GrpcRequestPane } from './GrpcRequestPane';
import { GrpcResponsePane } from './GrpcResponsePane'; import { GrpcResponsePane } from './GrpcResponsePane';
@@ -117,7 +117,7 @@ export function GrpcConnectionLayout({ style }: Props) {
) : grpcEvents.length >= 0 ? ( ) : grpcEvents.length >= 0 ? (
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} /> <GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
) : ( ) : (
<HotKeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} /> <HotkeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
)} )}
</div> </div>
) )

View File

@@ -16,7 +16,7 @@ import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { HotKeyList } from './core/HotKeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow'; import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
@@ -73,7 +73,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
minHeightPx={20} minHeightPx={20}
firstSlot={() => firstSlot={() =>
activeConnection == null ? ( activeConnection == null ? (
<HotKeyList <HotkeyList
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/> />
) : ( ) : (

View File

@@ -15,7 +15,7 @@ import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { CountBadge } from './core/CountBadge'; import { CountBadge } from './core/CountBadge';
import { HotKeyList } from './core/HotKeyList'; import { HotkeyList } from './core/HotkeyList';
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag'; import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
import { HttpStatusTag } from './core/HttpStatusTag'; import { HttpStatusTag } from './core/HttpStatusTag';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
@@ -139,7 +139,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)} )}
> >
{activeResponse == null ? ( {activeResponse == null ? (
<HotKeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} /> <HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
) : ( ) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1"> <div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack <HStack

View File

@@ -1,10 +1,10 @@
import { hotkeyActions } from '../hooks/useHotKey'; import { hotkeyActions } from '../hooks/useHotKey';
import { HotKeyList } from './core/HotKeyList'; import { HotkeyList } from './core/HotkeyList';
export function KeyboardShortcutsDialog() { export function KeyboardShortcutsDialog() {
return ( return (
<div className="grid h-full"> <div className="grid h-full">
<HotKeyList hotkeys={hotkeyActions} className="pb-6" /> <HotkeyList hotkeys={hotkeyActions} className="pb-6" />
</div> </div>
); );
} }

View File

@@ -10,11 +10,13 @@ import { useKeyPressEvent } from 'react-use';
import { appInfo } from '../../lib/appInfo'; import { appInfo } from '../../lib/appInfo';
import { capitalize } from '../../lib/capitalize'; import { capitalize } from '../../lib/capitalize';
import { CountBadge } from '../core/CountBadge'; import { CountBadge } from '../core/CountBadge';
import { Icon } from '../core/Icon';
import { HStack } from '../core/Stacks'; import { HStack } from '../core/Stacks';
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs'; import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
import { HeaderSize } from '../HeaderSize'; import { HeaderSize } from '../HeaderSize';
import { SettingsCertificates } from './SettingsCertificates'; import { SettingsCertificates } from './SettingsCertificates';
import { SettingsGeneral } from './SettingsGeneral'; import { SettingsGeneral } from './SettingsGeneral';
import { SettingsHotkeys } from './SettingsHotkeys';
import { SettingsInterface } from './SettingsInterface'; import { SettingsInterface } from './SettingsInterface';
import { SettingsLicense } from './SettingsLicense'; import { SettingsLicense } from './SettingsLicense';
import { SettingsPlugins } from './SettingsPlugins'; import { SettingsPlugins } from './SettingsPlugins';
@@ -28,6 +30,7 @@ interface Props {
const TAB_GENERAL = 'general'; const TAB_GENERAL = 'general';
const TAB_INTERFACE = 'interface'; const TAB_INTERFACE = 'interface';
const TAB_THEME = 'theme'; const TAB_THEME = 'theme';
const TAB_SHORTCUTS = 'shortcuts';
const TAB_PROXY = 'proxy'; const TAB_PROXY = 'proxy';
const TAB_CERTIFICATES = 'certificates'; const TAB_CERTIFICATES = 'certificates';
const TAB_PLUGINS = 'plugins'; const TAB_PLUGINS = 'plugins';
@@ -36,6 +39,7 @@ const tabs = [
TAB_GENERAL, TAB_GENERAL,
TAB_THEME, TAB_THEME,
TAB_INTERFACE, TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_CERTIFICATES, TAB_CERTIFICATES,
TAB_PROXY, TAB_PROXY,
TAB_PLUGINS, TAB_PLUGINS,
@@ -97,6 +101,24 @@ export default function Settings({ hide }: Props) {
value, value,
label: capitalize(value), label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE, hidden: !appInfo.featureLicense && value === TAB_LICENSE,
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
) : value === TAB_THEME ? (
<Icon icon="palette" className="text-secondary" />
) : value === TAB_INTERFACE ? (
<Icon icon="columns_2" className="text-secondary" />
) : value === TAB_SHORTCUTS ? (
<Icon icon="keyboard" className="text-secondary" />
) : value === TAB_CERTIFICATES ? (
<Icon icon="shield_check" className="text-secondary" />
) : value === TAB_PROXY ? (
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
rightSlot: rightSlot:
value === TAB_CERTIFICATES ? ( value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} /> <CountBadge count={settings.clientCertificates.length} />
@@ -119,6 +141,9 @@ export default function Settings({ hide }: Props) {
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4"> <TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
<SettingsTheme /> <SettingsTheme />
</TabContent> </TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4"> <TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1 px-6 !py-4">
<SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} /> <SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
</TabContent> </TabContent>

View File

@@ -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 (
<VStack space={3} className="mb-4">
<div className="mb-3">
<Heading>Keyboard Shortcuts</Heading>
<p className="text-text-subtle">
Click the menu button to add, remove, or reset keyboard shortcuts.
</p>
</div>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Scope</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
<TableHeaderCell>Shortcut</TableHeaderCell>
<TableHeaderCell></TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{hotkeyActions.map((action) => (
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action]}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
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 });
}}
/>
))}
</TableBody>
</Table>
</VStack>
);
}
interface HotkeyRowProps {
action: HotkeyAction;
currentKeys: string[];
defaultKeys: string[];
onSave: (keys: string[]) => Promise<void>;
onReset: () => Promise<void>;
}
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 }) => (
<RecordHotkeyDialog
label={label}
onSave={async (key) => {
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: <Icon icon="plus" />,
onSelect: handleStartRecording,
},
];
// Add remove options for each existing shortcut
if (!isDisabled) {
currentKeys.forEach((key) => {
dropdownItems.push({
label: (
<HStack space={1.5}>
<span>Remove</span>
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
</HStack>
),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleRemove(key),
});
});
if (currentKeys.length > 1) {
dropdownItems.push(
{
type: 'separator',
},
{
label: 'Remove All Shortcuts',
leftSlot: <Icon icon="trash" />,
onSelect: handleClearAll,
},
);
}
}
if (isCustomized) {
dropdownItems.push({
type: 'separator',
});
dropdownItems.push({
label: 'Reset to Default',
leftSlot: <Icon icon="refresh" />,
onSelect: onReset,
});
}
return (
<TableRow>
<TableCell>
<span className="text-sm text-text-subtlest">{scope}</span>
</TableCell>
<TableCell>
<span className="text-sm">{label}</span>
</TableCell>
<TableCell>
<HStack space={1.5} className="py-1">
{isDisabled ? (
<span className="text-text-subtlest">Disabled</span>
) : (
currentKeys.map((k) => (
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
))
)}
</HStack>
</TableCell>
<TableCell align="right">
<Dropdown items={dropdownItems}>
<IconButton
icon="ellipsis_vertical"
size="sm"
title="Hotkey actions"
className="ml-auto text-text-subtlest"
/>
</Dropdown>
</TableCell>
</TableRow>
);
}
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<string | null>(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 (
<VStack space={4}>
<div>
<p className="text-text-subtle mb-2">
Record a key combination for <span className="font-semibold">{label}</span>
</p>
<button
type="button"
data-disable-hotkey
aria-label="Keyboard shortcut input"
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onClick={(e) => {
e.preventDefault();
e.currentTarget.focus();
}}
className={classNames(
'flex items-center justify-center',
'px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full',
'border-border-subtle focus:border-border-focus',
)}
>
{recordedKey ? (
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
) : (
<span className="text-text-subtlest">Press keys...</span>
)}
</button>
</div>
<HStack space={2} justifyContent="end">
<Button color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
Save
</Button>
</HStack>
</VStack>
);
}

View File

@@ -18,7 +18,7 @@ import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor'; import { Editor } from './core/Editor/LazyEditor';
import { HotKeyList } from './core/HotKeyList'; import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon'; import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton'; import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon'; import { LoadingIcon } from './core/LoadingIcon';
@@ -71,7 +71,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
minHeightPx={20} minHeightPx={20}
firstSlot={() => firstSlot={() =>
activeConnection == null ? ( activeConnection == null ? (
<HotKeyList <HotkeyList
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
/> />
) : ( ) : (

View File

@@ -33,7 +33,7 @@ import { jotaiStore } from '../lib/jotai';
import { CreateDropdown } from './CreateDropdown'; import { CreateDropdown } from './CreateDropdown';
import { Banner } from './core/Banner'; import { Banner } from './core/Banner';
import { Button } from './core/Button'; import { Button } from './core/Button';
import { HotKeyList } from './core/HotKeyList'; import { HotkeyList } from './core/HotkeyList';
import { FeedbackLink } from './core/Link'; import { FeedbackLink } from './core/Link';
import { HStack } from './core/Stacks'; import { HStack } from './core/Stacks';
import { ErrorBoundary } from './ErrorBoundary'; import { ErrorBoundary } from './ErrorBoundary';
@@ -233,7 +233,7 @@ function WorkspaceBody() {
} }
return ( return (
<HotKeyList <HotkeyList
hotkeys={['model.create', 'sidebar.focus', 'settings.show']} hotkeys={['model.create', 'sidebar.focus', 'settings.show']}
bottomSlot={ bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3"> <HStack space={1} justifyContent="center" className="mt-3">

View File

@@ -34,7 +34,7 @@ import { jotaiStore } from '../../lib/jotai';
import { ErrorBoundary } from '../ErrorBoundary'; import { ErrorBoundary } from '../ErrorBoundary';
import { Overlay } from '../Overlay'; import { Overlay } from '../Overlay';
import { Button } from './Button'; import { Button } from './Button';
import { HotKey } from './HotKey'; import { Hotkey } from './Hotkey';
import { Icon } from './Icon'; import { Icon } from './Icon';
import { LoadingIcon } from './LoadingIcon'; import { LoadingIcon } from './LoadingIcon';
import { Separator } from './Separator'; import { Separator } from './Separator';
@@ -630,7 +630,7 @@ function MenuItem({ className, focused, onFocus, item, onSelect, ...props }: Men
[focused], [focused],
); );
const rightSlot = item.rightSlot ?? <HotKey action={item.hotKeyAction ?? null} />; const rightSlot = item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />;
return ( return (
<Button <Button

View File

@@ -9,23 +9,34 @@ interface Props {
variant?: 'text' | 'with-bg'; variant?: 'text' | 'with-bg';
} }
export function HotKey({ action, className, variant }: Props) { export function Hotkey({ action, className, variant }: Props) {
const labelParts = useFormattedHotkey(action); const labelParts = useFormattedHotkey(action);
if (labelParts === null) { if (labelParts === null) {
return null; return null;
} }
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
}
interface HotkeyRawProps {
labelParts: string[];
className?: string;
variant?: 'text' | 'with-bg';
}
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
return ( return (
<HStack <HStack
className={classNames( className={classNames(
className, className,
variant === 'with-bg' && 'rounded border', variant === 'with-bg' &&
'text-text-subtlest', 'rounded bg-surface-highlight px-1 border border-border text-text-subtle',
variant === 'text' && 'text-text-subtlest',
)} )}
> >
{labelParts.map((char, index) => ( {labelParts.map((char, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: none // biome-ignore lint/suspicious/noArrayIndexKey: none
<div key={index} className="min-w-[1.1em] text-center"> <div key={index} className="min-w-[1em] text-center">
{char} {char}
</div> </div>
))} ))}

View File

@@ -1,14 +1,14 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type { HotkeyAction } from '../../hooks/useHotKey'; import type { HotkeyAction } from '../../hooks/useHotKey';
import { useHotKeyLabel } from '../../hooks/useHotKey'; import { useHotkeyLabel } from '../../hooks/useHotKey';
interface Props { interface Props {
action: HotkeyAction; action: HotkeyAction;
className?: string; className?: string;
} }
export function HotKeyLabel({ action, className }: Props) { export function HotkeyLabel({ action, className }: Props) {
const label = useHotKeyLabel(action); const label = useHotkeyLabel(action);
return ( return (
<span className={classNames(className, 'text-text-subtle whitespace-nowrap')}>{label}</span> <span className={classNames(className, 'text-text-subtle whitespace-nowrap')}>{label}</span>
); );

View File

@@ -2,8 +2,8 @@ import classNames from 'classnames';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Fragment } from 'react'; import { Fragment } from 'react';
import type { HotkeyAction } from '../../hooks/useHotKey'; import type { HotkeyAction } from '../../hooks/useHotKey';
import { HotKey } from './HotKey'; import { Hotkey } from './Hotkey';
import { HotKeyLabel } from './HotKeyLabel'; import { HotkeyLabel } from './HotkeyLabel';
interface Props { interface Props {
hotkeys: HotkeyAction[]; hotkeys: HotkeyAction[];
@@ -11,14 +11,14 @@ interface Props {
className?: string; className?: string;
} }
export const HotKeyList = ({ hotkeys, bottomSlot, className }: Props) => { export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return ( return (
<div className={classNames(className, 'h-full flex items-center justify-center')}> <div className={classNames(className, 'h-full flex items-center justify-center')}>
<div className="grid gap-2 grid-cols-[auto_auto]"> <div className="grid gap-2 grid-cols-[auto_auto]">
{hotkeys.map((hotkey) => ( {hotkeys.map((hotkey) => (
<Fragment key={hotkey}> <Fragment key={hotkey}>
<HotKeyLabel className="truncate" action={hotkey} /> <HotkeyLabel className="truncate" action={hotkey} />
<HotKey className="ml-4" action={hotkey} /> <Hotkey className="ml-4" action={hotkey} />
</Fragment> </Fragment>
))} ))}
{bottomSlot} {bottomSlot}

View File

@@ -127,6 +127,7 @@ import {
UploadIcon, UploadIcon,
VariableIcon, VariableIcon,
Wand2Icon, Wand2Icon,
WifiIcon,
WrenchIcon, WrenchIcon,
XIcon, XIcon,
} from 'lucide-react'; } from 'lucide-react';
@@ -260,6 +261,7 @@ const icons = {
update: RefreshCcwIcon, update: RefreshCcwIcon,
upload: UploadIcon, upload: UploadIcon,
variable: VariableIcon, variable: VariableIcon,
wifi: WifiIcon,
wrench: WrenchIcon, wrench: WrenchIcon,
x: XIcon, x: XIcon,
_unknown: ShieldAlertIcon, _unknown: ShieldAlertIcon,

View File

@@ -51,12 +51,21 @@ export function TableRow({ children }: { children: ReactNode }) {
return <tr>{children}</tr>; return <tr>{children}</tr>;
} }
export function TableCell({ children, className }: { children: ReactNode; className?: string }) { export function TableCell({
children,
className,
align = 'left',
}: {
children: ReactNode;
className?: string;
align?: 'left' | 'center' | 'right';
}) {
return ( return (
<td <td
className={classNames( className={classNames(
className, className,
'py-2 [&:not(:first-child)]:pl-4 text-left whitespace-nowrap', 'py-2 [&:not(:first-child)]:pl-4 whitespace-nowrap',
align === 'left' ? 'text-left' : align === 'center' ? 'text-center' : 'text-right',
)} )}
> >
{children} {children}

View File

@@ -13,11 +13,13 @@ export type TabItem =
value: string; value: string;
label: string; label: string;
hidden?: boolean; hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
} }
| { | {
value: string; value: string;
options: Omit<RadioDropdownProps, 'children'>; options: Omit<RadioDropdownProps, 'children'>;
leftSlot?: ReactNode;
rightSlot?: ReactNode; rightSlot?: ReactNode;
}; };
@@ -95,7 +97,7 @@ export function Tabs({
> >
<div <div
className={classNames( className={classNames(
layout === 'horizontal' && 'flex flex-col gap-1 w-full pb-3 mb-auto', layout === 'horizontal' && 'flex flex-col w-full pb-3 mb-auto',
layout === 'vertical' && 'flex flex-row flex-shrink-0 gap-2 w-full', layout === 'vertical' && 'flex flex-row flex-shrink-0 gap-2 w-full',
)} )}
> >
@@ -107,7 +109,6 @@ export function Tabs({
const isActive = t.value === value; const isActive = t.value === value;
const btnProps: Partial<ButtonProps> = { const btnProps: Partial<ButtonProps> = {
size: 'sm',
color: 'custom', color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center', justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive ? undefined : () => onChangeValue(t.value), onClick: isActive ? undefined : () => onChangeValue(t.value),
@@ -142,6 +143,7 @@ export function Tabs({
onChange={t.options.onChange} onChange={t.options.onChange}
> >
<Button <Button
leftSlot={t.leftSlot}
rightSlot={ rightSlot={
<div className="flex items-center"> <div className="flex items-center">
{t.rightSlot} {t.rightSlot}
@@ -165,7 +167,7 @@ export function Tabs({
); );
} }
return ( return (
<Button key={t.value} rightSlot={t.rightSlot} {...btnProps}> <Button key={t.value} leftSlot={t.leftSlot} rightSlot={t.rightSlot} {...btnProps}>
{t.label} {t.label}
</Button> </Button>
); );

View File

@@ -1,6 +1,7 @@
import { type } from '@tauri-apps/plugin-os'; import { type } from '@tauri-apps/plugin-os';
import { debounce } from '@yaakapp-internal/lib'; 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 { useEffect } from 'react';
import { capitalize } from '../lib/capitalize'; import { capitalize } from '../lib/capitalize';
import { jotaiStore } from '../lib/jotai'; import { jotaiStore } from '../lib/jotai';
@@ -13,7 +14,7 @@ export type HotkeyAction =
| 'app.zoom_out' | 'app.zoom_out'
| 'app.zoom_reset' | 'app.zoom_reset'
| 'command_palette.toggle' | 'command_palette.toggle'
| 'environmentEditor.toggle' | 'environment_editor.toggle'
| 'hotkeys.showHelp' | 'hotkeys.showHelp'
| 'model.create' | 'model.create'
| 'model.duplicate' | 'model.duplicate'
@@ -34,39 +35,94 @@ export type HotkeyAction =
| 'url_bar.focus' | 'url_bar.focus'
| 'workspace_settings.show'; | 'workspace_settings.show';
const hotkeys: Record<HotkeyAction, string[]> = { /** Default hotkeys for macOS (uses Meta for Cmd) */
'app.zoom_in': ['CmdCtrl+Equal'], const defaultHotkeysMac: Record<HotkeyAction, string[]> = {
'app.zoom_out': ['CmdCtrl+Minus'], 'app.zoom_in': ['Meta+Equal'],
'app.zoom_reset': ['CmdCtrl+0'], 'app.zoom_out': ['Meta+Minus'],
'command_palette.toggle': ['CmdCtrl+k'], 'app.zoom_reset': ['Meta+0'],
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'], 'command_palette.toggle': ['Meta+k'],
'request.rename': type() === 'macos' ? ['Control+Shift+r'] : ['F2'], 'environment_editor.toggle': ['Meta+Shift+e'],
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'], 'request.rename': ['Control+Shift+r'],
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark 'request.send': ['Meta+Enter', 'Meta+r'],
'model.create': ['CmdCtrl+n'], 'hotkeys.showHelp': ['Meta+Shift+/'],
'model.duplicate': ['CmdCtrl+d'], 'model.create': ['Meta+n'],
'model.duplicate': ['Meta+d'],
'switcher.next': ['Control+Shift+Tab'], 'switcher.next': ['Control+Shift+Tab'],
'switcher.prev': ['Control+Tab'], 'switcher.prev': ['Control+Tab'],
'switcher.toggle': ['CmdCtrl+p'], 'switcher.toggle': ['Meta+p'],
'settings.show': ['CmdCtrl+,'], 'settings.show': ['Meta+,'],
'sidebar.filter': ['CmdCtrl+f'], 'sidebar.filter': ['Meta+f'],
'sidebar.expand_all': ['CmdCtrl+Shift+Equal'], 'sidebar.expand_all': ['Meta+Shift+Equal'],
'sidebar.collapse_all': ['CmdCtrl+Shift+Minus'], 'sidebar.collapse_all': ['Meta+Shift+Minus'],
'sidebar.selected.delete': ['Delete', 'CmdCtrl+Backspace'], 'sidebar.selected.delete': ['Delete', 'Meta+Backspace'],
'sidebar.selected.duplicate': ['CmdCtrl+d'], 'sidebar.selected.duplicate': ['Meta+d'],
'sidebar.selected.rename': ['Enter'], 'sidebar.selected.rename': ['Enter'],
'sidebar.focus': ['CmdCtrl+b'], 'sidebar.focus': ['Meta+b'],
'sidebar.context_menu': type() === 'macos' ? ['Control+Enter'] : ['Alt+Insert'], 'sidebar.context_menu': ['Control+Enter'],
'url_bar.focus': ['CmdCtrl+l'], 'url_bar.focus': ['Meta+l'],
'workspace_settings.show': ['CmdCtrl+;'], '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> = { const hotkeyLabels: Record<HotkeyAction, string> = {
'app.zoom_in': 'Zoom In', 'app.zoom_in': 'Zoom In',
'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',
'environmentEditor.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',
'model.duplicate': 'Duplicate Request', 'model.duplicate': 'Duplicate Request',
@@ -90,7 +146,16 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight', 'Backquote']; 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 = { export type HotKeyOptions = {
enable?: boolean | (() => boolean); enable?: boolean | (() => boolean);
@@ -200,6 +265,7 @@ function handleKeyDown(e: KeyboardEvent) {
} }
const executed: string[] = []; const executed: string[] = [];
const hotkeys = getHotkeys();
outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) { outer: for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) { for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
if (hkAction !== action) { if (hkAction !== action) {
@@ -212,8 +278,7 @@ function handleKeyDown(e: KeyboardEvent) {
for (const hkKey of hkKeys) { for (const hkKey of hkKeys) {
const keys = hkKey.split('+'); const keys = hkKey.split('+');
const adjustedKeys = keys.map(resolveHotkeyKey); if (compareKeys(keys, Array.from(currentKeysWithModifiers))) {
if (compareKeys(adjustedKeys, Array.from(currentKeysWithModifiers))) {
if (!options.allowDefault) { if (!options.allowDefault) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -233,34 +298,38 @@ function handleKeyDown(e: KeyboardEvent) {
clearCurrentKeysDebounced(); clearCurrentKeysDebounced();
} }
export function useHotKeyLabel(action: HotkeyAction): string { export function useHotkeyLabel(action: HotkeyAction): string {
return hotkeyLabels[action]; return hotkeyLabels[action];
} }
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null { export function getHotkeyScope(action: HotkeyAction): string {
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null; const scope = action.split('.')[0];
if (trigger == null) { return scope || '';
return null; }
}
export function formatHotkeyString(trigger: string): string[] {
const os = type(); const os = type();
const parts = trigger.split('+'); const parts = trigger.split('+');
const labelParts: string[] = []; const labelParts: string[] = [];
for (const p of parts) { for (const p of parts) {
if (os === 'macos') { if (os === 'macos') {
if (p === 'CmdCtrl') { if (p === 'Meta') {
labelParts.push('⌘'); labelParts.push('⌘');
} else if (p === 'Shift') { } else if (p === 'Shift') {
labelParts.push('⇧'); labelParts.push('⇧');
} else if (p === 'Control') { } else if (p === 'Control') {
labelParts.push('⌃'); labelParts.push('⌃');
} else if (p === 'Alt') {
labelParts.push('⌥');
} else if (p === 'Enter') { } else if (p === 'Enter') {
labelParts.push('↩'); labelParts.push('↩');
} else if (p === 'Tab') { } else if (p === 'Tab') {
labelParts.push('⇥'); labelParts.push('⇥');
} else if (p === 'Backspace') { } else if (p === 'Backspace') {
labelParts.push('⌫'); labelParts.push('⌫');
} else if (p === 'Delete') {
labelParts.push('⌦');
} else if (p === 'Minus') { } else if (p === 'Minus') {
labelParts.push('-'); labelParts.push('-');
} else if (p === 'Plus') { } else if (p === 'Plus') {
@@ -271,7 +340,7 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
labelParts.push(capitalize(p)); labelParts.push(capitalize(p));
} }
} else { } else {
if (p === 'CmdCtrl') { if (p === 'Control') {
labelParts.push('Ctrl'); labelParts.push('Ctrl');
} else { } else {
labelParts.push(capitalize(p)); labelParts.push(capitalize(p));
@@ -285,12 +354,15 @@ export function useFormattedHotkey(action: HotkeyAction | null): string[] | null
return [labelParts.join('+')]; return [labelParts.join('+')];
} }
const resolveHotkeyKey = (key: string) => { export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
const os = type(); const hotkeys = useAtomValue(hotkeysAtom);
if (key === 'CmdCtrl' && os === 'macos') return 'Meta'; const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
if (key === 'CmdCtrl') return 'Control'; if (trigger == null) {
return key; return null;
}; }
return formatHotkeyString(trigger);
}
function compareKeys(keysA: string[], keysB: string[]) { function compareKeys(keysA: string[], keysB: string[]) {
if (keysA.length !== keysB.length) return false; if (keysA.length !== keysB.length) return false;

View File

@@ -1,3 +1,6 @@
export function capitalize(str: string): string { 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(' ');
} }