mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-01-11 22:40:26 +01:00
Add configurable hotkeys support (#343)
This commit is contained in:
2
src-tauri/yaak-models/bindings/gen_models.ts
generated
2
src-tauri/yaak-models/bindings/gen_models.ts
generated
@@ -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, };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE settings ADD COLUMN hotkeys TEXT DEFAULT '{}' NOT NULL;
|
||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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']}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
326
src-web/components/Settings/SettingsHotkeys.tsx
Normal file
326
src-web/components/Settings/SettingsHotkeys.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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']}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user