mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-25 07:26:52 +00:00
Add configurable global hotkeys (#1305)
This commit is contained in:
@@ -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,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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user