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 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, };

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_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<String, Vec<String>>,
}
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<String> = 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(),
})
}
}

View File

@@ -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")
}

View File

@@ -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 <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[]),
{
label: 'Manage Environments',
hotKeyAction: 'environmentEditor.toggle',
hotKeyAction: 'environment_editor.toggle',
leftSlot: <Icon icon="box" />,
onSelect: () => editEnvironment(activeEnvironment),
},

View File

@@ -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 ? (
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
) : (
<HotKeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
<HotkeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
)}
</div>
)

View File

@@ -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 ? (
<HotKeyList
<HotkeyList
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 { 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 ? (
<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">
<HStack

View File

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

View File

@@ -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 ? (
<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:
value === TAB_CERTIFICATES ? (
<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">
<SettingsTheme />
</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">
<SettingsPlugins defaultSubtab={tab === TAB_PLUGINS ? subtab : undefined} />
</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 { 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 ? (
<HotKeyList
<HotkeyList
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 { 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 (
<HotKeyList
<HotkeyList
hotkeys={['model.create', 'sidebar.focus', 'settings.show']}
bottomSlot={
<HStack space={1} justifyContent="center" className="mt-3">

View File

@@ -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 ?? <HotKey action={item.hotKeyAction ?? null} />;
const rightSlot = item.rightSlot ?? <Hotkey action={item.hotKeyAction ?? null} />;
return (
<Button

View File

@@ -9,23 +9,34 @@ interface Props {
variant?: 'text' | 'with-bg';
}
export function HotKey({ action, className, variant }: Props) {
export function Hotkey({ action, className, variant }: Props) {
const labelParts = useFormattedHotkey(action);
if (labelParts === 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 (
<HStack
className={classNames(
className,
variant === 'with-bg' && 'rounded border',
'text-text-subtlest',
variant === 'with-bg' &&
'rounded bg-surface-highlight px-1 border border-border text-text-subtle',
variant === 'text' && 'text-text-subtlest',
)}
>
{labelParts.map((char, index) => (
// 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}
</div>
))}

View File

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

View File

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

View File

@@ -51,12 +51,21 @@ export function TableRow({ children }: { children: ReactNode }) {
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 (
<td
className={classNames(
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}

View File

@@ -13,11 +13,13 @@ export type TabItem =
value: string;
label: string;
hidden?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
| {
value: string;
options: Omit<RadioDropdownProps, 'children'>;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
@@ -95,7 +97,7 @@ export function Tabs({
>
<div
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',
)}
>
@@ -107,7 +109,6 @@ export function Tabs({
const isActive = t.value === value;
const btnProps: Partial<ButtonProps> = {
size: 'sm',
color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive ? undefined : () => onChangeValue(t.value),
@@ -142,6 +143,7 @@ export function Tabs({
onChange={t.options.onChange}
>
<Button
leftSlot={t.leftSlot}
rightSlot={
<div className="flex items-center">
{t.rightSlot}
@@ -165,7 +167,7 @@ export function Tabs({
);
}
return (
<Button key={t.value} rightSlot={t.rightSlot} {...btnProps}>
<Button key={t.value} leftSlot={t.leftSlot} rightSlot={t.rightSlot} {...btnProps}>
{t.label}
</Button>
);

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;

View File

@@ -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(' ');
}