mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
Add configurable global hotkeys (#1305)
This commit is contained in:
@@ -4,6 +4,9 @@ const VALID_ACTION_TYPES = [
|
|||||||
"sendControlCode",
|
"sendControlCode",
|
||||||
"sendText",
|
"sendText",
|
||||||
"runSnippet",
|
"runSnippet",
|
||||||
|
"nextTab",
|
||||||
|
"previousTab",
|
||||||
|
"openCommandPalette",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function isValidKeyCombo(combo: unknown): boolean {
|
export function isValidKeyCombo(combo: unknown): boolean {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ describe("isValidKeybindingAction", () => {
|
|||||||
expect(isValidKeybindingAction({ type: "paste" })).toBe(true);
|
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", () => {
|
it("rejects an unknown action type", () => {
|
||||||
expect(isValidKeybindingAction({ type: "explode" })).toBe(false);
|
expect(isValidKeybindingAction({ type: "explode" })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ export interface KeyCombo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type KeybindingActionType =
|
export type KeybindingActionType =
|
||||||
"copy" | "paste" | "sendControlCode" | "sendText" | "runSnippet";
|
| "copy"
|
||||||
|
| "paste"
|
||||||
|
| "sendControlCode"
|
||||||
|
| "sendText"
|
||||||
|
| "runSnippet"
|
||||||
|
| "nextTab"
|
||||||
|
| "previousTab"
|
||||||
|
| "openCommandPalette";
|
||||||
|
|
||||||
export interface KeybindingAction {
|
export interface KeybindingAction {
|
||||||
type: KeybindingActionType;
|
type: KeybindingActionType;
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ import { defaultSizes, SplitView, type RowColSizes } from "@/shell/SplitView";
|
|||||||
import { renderTabContent } from "@/shell/tabUtils";
|
import { renderTabContent } from "@/shell/tabUtils";
|
||||||
import { TabBar } from "@/shell/TabBar";
|
import { TabBar } from "@/shell/TabBar";
|
||||||
import { dispatchCtrlW, isShiftKey } from "@/lib/app-keyboard-shortcuts";
|
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.
|
// Shell surfaces that are not needed for first paint.
|
||||||
const CommandPalette = lazy(() =>
|
const CommandPalette = lazy(() =>
|
||||||
@@ -454,6 +460,7 @@ export function AppShell({
|
|||||||
const tabsRef = useRef(tabs);
|
const tabsRef = useRef(tabs);
|
||||||
const activeTabIdRef = useRef(activeTabId);
|
const activeTabIdRef = useRef(activeTabId);
|
||||||
const closeActiveTabRef = useRef<() => void>(() => {});
|
const closeActiveTabRef = useRef<() => void>(() => {});
|
||||||
|
const globalKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||||
const splitModeRef = useRef(splitMode);
|
const splitModeRef = useRef(splitMode);
|
||||||
const focusedPaneIndexRef = useRef<number | null>(null);
|
const focusedPaneIndexRef = useRef<number | null>(null);
|
||||||
const paneContentElsRef = useRef<(HTMLDivElement | null)[]>(
|
const paneContentElsRef = useRef<(HTMLDivElement | null)[]>(
|
||||||
@@ -463,6 +470,76 @@ export function AppShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tabsRef.current = tabs;
|
tabsRef.current = tabs;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
activeTabIdRef.current = activeTabId;
|
activeTabIdRef.current = activeTabId;
|
||||||
}, [activeTabId]);
|
}, [activeTabId]);
|
||||||
|
|||||||
@@ -82,5 +82,15 @@ export function dispatchKeybindingAction(
|
|||||||
});
|
});
|
||||||
return;
|
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",
|
"actionSendControlCode": "Send Ctrl+letter signal",
|
||||||
"actionSendText": "Send text",
|
"actionSendText": "Send text",
|
||||||
"actionRunSnippet": "Run existing snippet",
|
"actionRunSnippet": "Run existing snippet",
|
||||||
|
"actionNextTab": "Switch to next tab",
|
||||||
|
"actionPreviousTab": "Switch to previous tab",
|
||||||
|
"actionOpenCommandPalette": "Open quick search",
|
||||||
"controlCodeLabel": "Letter",
|
"controlCodeLabel": "Letter",
|
||||||
"textLabel": "Text to send",
|
"textLabel": "Text to send",
|
||||||
"appendEnterLabel": "Press Enter after sending",
|
"appendEnterLabel": "Press Enter after sending",
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const ACTION_LABEL_KEYS: Record<KeybindingActionType, string> = {
|
|||||||
sendControlCode: "newUi.sidebar.keybindings.actionSendControlCode",
|
sendControlCode: "newUi.sidebar.keybindings.actionSendControlCode",
|
||||||
sendText: "newUi.sidebar.keybindings.actionSendText",
|
sendText: "newUi.sidebar.keybindings.actionSendText",
|
||||||
runSnippet: "newUi.sidebar.keybindings.actionRunSnippet",
|
runSnippet: "newUi.sidebar.keybindings.actionRunSnippet",
|
||||||
|
nextTab: "newUi.sidebar.keybindings.actionNextTab",
|
||||||
|
previousTab: "newUi.sidebar.keybindings.actionPreviousTab",
|
||||||
|
openCommandPalette: "newUi.sidebar.keybindings.actionOpenCommandPalette",
|
||||||
};
|
};
|
||||||
|
|
||||||
function generateId(): string {
|
function generateId(): string {
|
||||||
@@ -450,6 +453,7 @@ export function KeybindingsDialog({
|
|||||||
<span className="text-accent-brand">*</span>
|
<span className="text-accent-brand">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
|
data-keybinding-recorder
|
||||||
readOnly
|
readOnly
|
||||||
value={
|
value={
|
||||||
recording
|
recording
|
||||||
@@ -497,6 +501,15 @@ export function KeybindingsDialog({
|
|||||||
<option value="runSnippet">
|
<option value="runSnippet">
|
||||||
{t("newUi.sidebar.keybindings.actionRunSnippet")}
|
{t("newUi.sidebar.keybindings.actionRunSnippet")}
|
||||||
</option>
|
</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>
|
</select>
|
||||||
{actionType === "paste" && (
|
{actionType === "paste" && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|||||||
Reference in New Issue
Block a user