mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Every settings tab in Yaak and Yaak Proxy now renders its heading, description, optional banner and content through SettingsPage, which puts the same 1.5rem gap between each. The banner slot hides itself when nothing inside it has content, so Yaak's General tab no longer gets an accidental gap from the empty commercial-use banner, and the proxy's General tab no longer sits tight under its description. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
10 KiB
TypeScript
365 lines
10 KiB
TypeScript
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|
import {
|
|
HStack,
|
|
Icon,
|
|
SettingsPage,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
VStack,
|
|
} from "@yaakapp-internal/ui";
|
|
import classNames from "classnames";
|
|
import { fuzzyMatch } from "fuzzbunny";
|
|
import { useAtomValue } from "jotai";
|
|
import { useCallback, useEffect, useMemo, 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 { HotkeyRaw } from "../core/Hotkey";
|
|
import { IconButton } from "../core/IconButton";
|
|
import { PlainInput } from "../core/PlainInput";
|
|
|
|
const HOLD_KEYS = ["Shift", "Control", "Alt", "Meta"];
|
|
const LAYOUT_INSENSITIVE_KEYS = [
|
|
"Equal",
|
|
"Minus",
|
|
"BracketLeft",
|
|
"BracketRight",
|
|
"Backquote",
|
|
"Space",
|
|
];
|
|
|
|
/** 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);
|
|
const [filter, setFilter] = useState("");
|
|
|
|
const filteredActions = useMemo(() => {
|
|
if (!filter.trim()) {
|
|
return hotkeyActions;
|
|
}
|
|
return hotkeyActions.filter((action) => {
|
|
const scope = getHotkeyScope(action).replace(/_/g, " ");
|
|
const label = action.replace(/[_.]/g, " ");
|
|
const searchText = `${scope} ${label}`;
|
|
return fuzzyMatch(searchText, filter) != null;
|
|
});
|
|
}, [filter]);
|
|
|
|
if (settings == null) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<SettingsPage
|
|
title="Keyboard Shortcuts"
|
|
description="Click the menu button to add, remove, or reset keyboard shortcuts."
|
|
>
|
|
<VStack space={3}>
|
|
<PlainInput
|
|
label="Filter"
|
|
placeholder="Filter shortcuts..."
|
|
defaultValue={filter}
|
|
onChange={setFilter}
|
|
hideLabel
|
|
containerClassName="max-w-xs"
|
|
/>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableHeaderCell>Scope</TableHeaderCell>
|
|
<TableHeaderCell>Action</TableHeaderCell>
|
|
<TableHeaderCell>Shortcut</TableHeaderCell>
|
|
<TableHeaderCell></TableHeaderCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
|
<TableBody key={filter}>
|
|
{filteredActions.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>
|
|
</SettingsPage>
|
|
);
|
|
}
|
|
|
|
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-hidden 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>
|
|
);
|
|
}
|