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

@@ -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>
);