Add configurable global hotkeys (#1305)

This commit is contained in:
ZacharyZcR
2026-08-24 01:10:19 +08:00
committed by GitHub
parent 9ee50d624a
commit cad7520c9f
7 changed files with 121 additions and 1 deletions
@@ -4,6 +4,9 @@ const VALID_ACTION_TYPES = [
"sendControlCode",
"sendText",
"runSnippet",
"nextTab",
"previousTab",
"openCommandPalette",
];
export function isValidKeyCombo(combo: unknown): boolean {
@@ -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);
});
+8 -1
View File
@@ -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;
+77
View File
@@ -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<CustomKeybinding[]>([]);
const splitModeRef = useRef(splitMode);
const focusedPaneIndexRef = useRef<number | null>(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]);
+10
View File
@@ -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;
}
}
}
+3
View File
@@ -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",
+13
View File
@@ -44,6 +44,9 @@ const ACTION_LABEL_KEYS: Record<KeybindingActionType, string> = {
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({
<span className="text-accent-brand">*</span>
</label>
<Input
data-keybinding-recorder
readOnly
value={
recording
@@ -497,6 +501,15 @@ export function KeybindingsDialog({
<option value="runSnippet">
{t("newUi.sidebar.keybindings.actionRunSnippet")}
</option>
<option value="nextTab">
{t("newUi.sidebar.keybindings.actionNextTab")}
</option>
<option value="previousTab">
{t("newUi.sidebar.keybindings.actionPreviousTab")}
</option>
<option value="openCommandPalette">
{t("newUi.sidebar.keybindings.actionOpenCommandPalette")}
</option>
</select>
{actionType === "paste" && (
<span className="text-xs text-muted-foreground">