diff --git a/src/backend/database/routes/keybinding-validation.ts b/src/backend/database/routes/keybinding-validation.ts index 1890c328..11911049 100644 --- a/src/backend/database/routes/keybinding-validation.ts +++ b/src/backend/database/routes/keybinding-validation.ts @@ -4,6 +4,9 @@ const VALID_ACTION_TYPES = [ "sendControlCode", "sendText", "runSnippet", + "nextTab", + "previousTab", + "openCommandPalette", ]; export function isValidKeyCombo(combo: unknown): boolean { diff --git a/src/backend/tests/database/routes/keybinding-validation.test.ts b/src/backend/tests/database/routes/keybinding-validation.test.ts index 0802be37..b25ae37b 100644 --- a/src/backend/tests/database/routes/keybinding-validation.test.ts +++ b/src/backend/tests/database/routes/keybinding-validation.test.ts @@ -36,6 +36,13 @@ describe("isValidKeybindingAction", () => { expect(isValidKeybindingAction({ type: "paste" })).toBe(true); }); + it.each(["nextTab", "previousTab", "openCommandPalette"])( + "accepts the global %s action", + (type) => { + expect(isValidKeybindingAction({ type })).toBe(true); + }, + ); + it("rejects an unknown action type", () => { expect(isValidKeybindingAction({ type: "explode" })).toBe(false); }); diff --git a/src/types/keybindings.ts b/src/types/keybindings.ts index 5286560b..f34e09eb 100644 --- a/src/types/keybindings.ts +++ b/src/types/keybindings.ts @@ -8,7 +8,14 @@ export interface KeyCombo { } export type KeybindingActionType = - "copy" | "paste" | "sendControlCode" | "sendText" | "runSnippet"; + | "copy" + | "paste" + | "sendControlCode" + | "sendText" + | "runSnippet" + | "nextTab" + | "previousTab" + | "openCommandPalette"; export interface KeybindingAction { type: KeybindingActionType; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index c2f38371..bda72407 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -41,6 +41,12 @@ import { defaultSizes, SplitView, type RowColSizes } from "@/shell/SplitView"; import { renderTabContent } from "@/shell/tabUtils"; import { TabBar } from "@/shell/TabBar"; import { dispatchCtrlW, isShiftKey } from "@/lib/app-keyboard-shortcuts"; +import { parseCustomKeybindings } from "@/api/open-tabs-api"; +import { findMatchingKeybinding } from "@/lib/keybinding-match"; +import type { + CustomKeybinding, + KeybindingActionType, +} from "@/types/keybindings"; // Shell surfaces that are not needed for first paint. const CommandPalette = lazy(() => @@ -454,6 +460,7 @@ export function AppShell({ const tabsRef = useRef(tabs); const activeTabIdRef = useRef(activeTabId); const closeActiveTabRef = useRef<() => void>(() => {}); + const globalKeybindingsRef = useRef([]); const splitModeRef = useRef(splitMode); const focusedPaneIndexRef = useRef(null); const paneContentElsRef = useRef<(HTMLDivElement | null)[]>( @@ -463,6 +470,76 @@ export function AppShell({ useEffect(() => { tabsRef.current = tabs; }, [tabs]); + + useEffect(() => { + let cancelled = false; + const load = () => { + getUserPreferences() + .then((prefs) => { + if (cancelled) return; + globalKeybindingsRef.current = parseCustomKeybindings( + prefs.customKeybindings, + ).filter( + (binding) => + binding.enabled && + ["nextTab", "previousTab", "openCommandPalette"].includes( + binding.action.type, + ), + ); + }) + .catch(() => {}); + }; + load(); + window.addEventListener("customKeybindingsChanged", load); + return () => { + cancelled = true; + window.removeEventListener("customKeybindingsChanged", load); + }; + }, []); + + useEffect(() => { + const runAction = (type: KeybindingActionType) => { + if (type === "openCommandPalette") { + setCommandPaletteOpen(true); + return; + } + const currentTabs = tabsRef.current; + if (currentTabs.length < 2) return; + const index = currentTabs.findIndex( + (tab) => tab.id === activeTabIdRef.current, + ); + const offset = type === "nextTab" ? 1 : -1; + const next = (index + offset + currentTabs.length) % currentTabs.length; + setActiveTabId(currentTabs[next].id); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.target instanceof Element && + event.target.closest("[data-keybinding-recorder]") + ) + return; + const binding = findMatchingKeybinding( + event, + globalKeybindingsRef.current, + ); + if (!binding) return; + event.preventDefault(); + event.stopPropagation(); + runAction(binding.action.type); + }; + const handleAction = (event: Event) => + runAction( + (event as CustomEvent<{ type: KeybindingActionType }>).detail.type, + ); + + window.addEventListener("keydown", handleKeyDown, true); + window.addEventListener("termix:global-keybinding", handleAction); + return () => { + window.removeEventListener("keydown", handleKeyDown, true); + window.removeEventListener("termix:global-keybinding", handleAction); + }; + }, []); useEffect(() => { activeTabIdRef.current = activeTabId; }, [activeTabId]); diff --git a/src/ui/lib/keybinding-dispatch.ts b/src/ui/lib/keybinding-dispatch.ts index 8f7f76f1..3e718014 100644 --- a/src/ui/lib/keybinding-dispatch.ts +++ b/src/ui/lib/keybinding-dispatch.ts @@ -82,5 +82,15 @@ export function dispatchKeybindingAction( }); return; } + case "nextTab": + case "previousTab": + case "openCommandPalette": { + window.dispatchEvent( + new CustomEvent("termix:global-keybinding", { + detail: { type: action.type }, + }), + ); + return; + } } } diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index e160347b..0c6b628f 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -4057,6 +4057,9 @@ "actionSendControlCode": "Send Ctrl+letter signal", "actionSendText": "Send text", "actionRunSnippet": "Run existing snippet", + "actionNextTab": "Switch to next tab", + "actionPreviousTab": "Switch to previous tab", + "actionOpenCommandPalette": "Open quick search", "controlCodeLabel": "Letter", "textLabel": "Text to send", "appendEnterLabel": "Press Enter after sending", diff --git a/src/ui/sidebar/KeybindingsDialog.tsx b/src/ui/sidebar/KeybindingsDialog.tsx index d64487ba..ad90f8e5 100644 --- a/src/ui/sidebar/KeybindingsDialog.tsx +++ b/src/ui/sidebar/KeybindingsDialog.tsx @@ -44,6 +44,9 @@ const ACTION_LABEL_KEYS: Record = { 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", }; function generateId(): string { @@ -450,6 +453,7 @@ export function KeybindingsDialog({ * {t("newUi.sidebar.keybindings.actionRunSnippet")} + + + {actionType === "paste" && (