import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { getUserPreferences, parseCustomKeybindings, } from "@/api/open-tabs-api"; import { saveUserPreferences } from "@/main-axios"; import { listSnippets } from "@/lib/snippet-provider"; import { BUILT_IN_DEFAULTS } from "@/lib/default-keybindings"; import type { CustomKeybinding, KeyCombo, KeybindingActionType, } from "@/types/keybindings"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/dialog"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react"; interface SnippetOption { id: number; name: string; } function formatCombo(combo: KeyCombo): string { const parts: string[] = []; if (combo.ctrl) parts.push("Ctrl"); if (combo.alt) parts.push("Alt"); if (combo.shift) parts.push("Shift"); if (combo.meta) parts.push("Cmd"); parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key); return parts.join(" + "); } const ACTION_LABEL_KEYS: Record = { copy: "newUi.sidebar.keybindings.actionCopy", paste: "newUi.sidebar.keybindings.actionPaste", sendControlCode: "newUi.sidebar.keybindings.actionSendControlCode", sendText: "newUi.sidebar.keybindings.actionSendText", runSnippet: "newUi.sidebar.keybindings.actionRunSnippet", nextTab: "newUi.sidebar.keybindings.actionNextTab", previousTab: "newUi.sidebar.keybindings.actionPreviousTab", openCommandPalette: "newUi.sidebar.keybindings.actionOpenCommandPalette", reconnectSession: "newUi.sidebar.keybindings.actionReconnectSession", }; function generateId(): string { return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`; } export function KeybindingsDialog({ open, onOpenChange, }: { open: boolean; onOpenChange: (v: boolean) => void; }) { const { t } = useTranslation(); const [bindings, setBindings] = useState([]); const [loading, setLoading] = useState(false); const [snippets, setSnippets] = useState([]); const [formOpen, setFormOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [overridesDefaultId, setOverridesDefaultId] = useState< string | undefined >(undefined); const [recordedCombo, setRecordedCombo] = useState(null); const [recording, setRecording] = useState(false); const [actionType, setActionType] = useState("sendText"); const [text, setText] = useState(""); const [appendEnter, setAppendEnter] = useState(false); const [controlCode, setControlCode] = useState(""); const [snippetId, setSnippetId] = useState(""); useEffect(() => { if (!open) return; setLoading(true); Promise.all([ getUserPreferences().catch(() => null), listSnippets().catch(() => []), ]) .then(([prefs, snippetList]) => { setBindings( prefs ? parseCustomKeybindings(prefs.customKeybindings) : [], ); const list = Array.isArray(snippetList) ? snippetList : []; setSnippets( list.map((s: { id: number; name: string }) => ({ id: s.id, name: s.name, })), ); }) .finally(() => setLoading(false)); }, [open]); const conflictWarning = useMemo(() => { if (!recordedCombo) return null; const conflict = bindings.find( (kb) => kb.id !== editingId && kb.enabled && kb.combo.ctrl === recordedCombo.ctrl && kb.combo.alt === recordedCombo.alt && kb.combo.shift === recordedCombo.shift && kb.combo.meta === recordedCombo.meta && kb.combo.key === recordedCombo.key, ); if (conflict) { return t("newUi.sidebar.keybindings.conflictWarning", { combo: formatCombo(conflict.combo), }); } return null; }, [recordedCombo, bindings, editingId, t]); function resetForm() { setEditingId(null); setOverridesDefaultId(undefined); setRecordedCombo(null); setRecording(false); setActionType("sendText"); setText(""); setAppendEnter(false); setControlCode(""); setSnippetId(""); } function openAddForm() { resetForm(); setFormOpen(true); } function openEditForm(kb: CustomKeybinding) { setEditingId(kb.id); setOverridesDefaultId(kb.overridesDefaultId); setRecordedCombo(kb.combo); setRecording(false); setActionType(kb.action.type); setText(kb.action.text ?? ""); setAppendEnter(kb.action.appendEnter ?? false); setControlCode(kb.action.controlCode ?? ""); setSnippetId(kb.action.snippetId ?? ""); setFormOpen(true); } function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) { resetForm(); setOverridesDefaultId(defaultId); setRecordedCombo(combo); setFormOpen(true); } async function persist(updated: CustomKeybinding[]) { try { await saveUserPreferences({ customKeybindings: JSON.stringify(updated), }); setBindings(updated); window.dispatchEvent(new Event("customKeybindingsChanged")); } catch { toast.error(t("newUi.sidebar.keybindings.saveError")); } } function handleDelete(id: string) { persist(bindings.filter((kb) => kb.id !== id)); } function handleResetAll() { persist([]); } function startRecording(inputEl: HTMLInputElement) { setRecording(true); const handler = (e: KeyboardEvent) => { e.preventDefault(); e.stopPropagation(); if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) { return; } const isAlnum = /^[a-zA-Z0-9]$/.test(e.key); const combo: KeyCombo = { key: isAlnum ? e.key.toLowerCase() : e.code, isCode: !isAlnum, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey, }; setRecordedCombo(combo); setRecording(false); inputEl.removeEventListener("keydown", handler, true); inputEl.blur(); }; inputEl.addEventListener("keydown", handler, true); } function handleSave() { if (!recordedCombo) { toast.error(t("newUi.sidebar.keybindings.comboRequiredError")); return; } if (actionType === "sendText" && !text.trim()) { toast.error(t("newUi.sidebar.keybindings.textRequiredError")); return; } if (actionType === "sendControlCode" && !/^[a-zA-Z]$/.test(controlCode)) { toast.error(t("newUi.sidebar.keybindings.controlCodeRequiredError")); return; } if (actionType === "runSnippet" && !snippetId) { toast.error(t("newUi.sidebar.keybindings.snippetRequiredError")); return; } const now = new Date().toISOString(); const existing = editingId ? bindings.find((kb) => kb.id === editingId) : undefined; const newBinding: CustomKeybinding = { id: editingId ?? generateId(), combo: recordedCombo, action: { type: actionType, text: actionType === "sendText" ? text : undefined, controlCode: actionType === "sendControlCode" ? controlCode.toLowerCase() : undefined, snippetId: actionType === "runSnippet" ? snippetId : undefined, appendEnter: actionType === "runSnippet" ? true : actionType === "sendText" ? appendEnter : undefined, }, enabled: true, overridesDefaultId, createdAt: existing?.createdAt ?? now, updatedAt: now, }; const updated = editingId ? bindings.map((kb) => (kb.id === editingId ? newBinding : kb)) : [...bindings, newBinding]; persist(updated); setFormOpen(false); resetForm(); } const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId); return ( <> {t("newUi.sidebar.keybindings.title")} {t("newUi.sidebar.keybindings.description")}
{t("newUi.sidebar.keybindings.defaultsHeading")} {BUILT_IN_DEFAULTS.map((def) => { const override = bindings.find( (kb) => kb.overridesDefaultId === def.id, ); return (
{override ? formatCombo(override.combo) : formatCombo(def.combo)} {def.description}
{override ? ( <> {t("newUi.sidebar.keybindings.customizedBadge")} ) : ( <> {t("newUi.sidebar.keybindings.defaultBadge")} )}
); })}
{t("newUi.sidebar.keybindings.customHeading")}
{loading ? ( {t("newUi.sidebar.keybindings.loading")} ) : customOnlyBindings.length === 0 ? ( {t("newUi.sidebar.keybindings.noCustomBindings")} ) : ( customOnlyBindings.map((kb) => { const orphaned = kb.action.type === "runSnippet" && !snippets.some((s) => String(s.id) === kb.action.snippetId); return (
{formatCombo(kb.combo)} {t(ACTION_LABEL_KEYS[kb.action.type])} {orphaned && ( {" "} ( {t( "newUi.sidebar.keybindings.orphanedSnippetWarning", )} ) )}
); }) )}
{editingId || overridesDefaultId ? t("newUi.sidebar.keybindings.editBindingTitle") : t("newUi.sidebar.keybindings.addBindingTitle")}
startRecording(e.currentTarget)} className="font-mono cursor-pointer" /> {conflictWarning && ( {conflictWarning} )}
{actionType === "paste" && ( {t("newUi.sidebar.keybindings.clipboardPermissionNote")} )}
{actionType === "sendControlCode" && (
setControlCode(e.target.value)} placeholder="w" className="w-16 font-mono" />
)} {actionType === "sendText" && (