From aeec0e6759d45b33148dd1bf547e984399e9bb35 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:05:26 +0800
Subject: [PATCH] fix(keymap): match remaining letter shortcuts by typed
character (#292)
#291 named letter keys by the typed character (e.key) when Alt is not held, so AZERTY Ctrl+Z is no longer Ctrl+W. Hard-coded handlers still compared the physical KeyboardEvent.code. A shared letterOf(e) in platform/keymap.ts now backs strokeOf and those handlers:
- terminal Ctrl+Shift+C / V / T (copy, paste, new tab) and Ctrl+letter control codes
- Run/Debug Configurations Ctrl/Cmd+N
- browser shortcut guards (blocked reload/print etc.)
Alt combos keep the physical key (Option / AltGr type symbols).
---
src/features/run/EditConfigsDialog.tsx | 4 +++-
src/features/run/runConfigTypes.test.tsx | 11 +++++++++++
src/features/terminal/keys.ts | 12 ++++++------
src/features/terminal/terminal.test.ts | 14 ++++++++++++++
src/platform/keymap.test.ts | 4 ++++
src/platform/keymap.ts | 18 ++++++++++++++----
src/workbench/browserGuards.test.ts | 10 ++++++++++
src/workbench/browserGuards.ts | 9 ++-------
8 files changed, 64 insertions(+), 18 deletions(-)
diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx
index 06018beb..a6c54e70 100644
--- a/src/features/run/EditConfigsDialog.tsx
+++ b/src/features/run/EditConfigsDialog.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type DragEvent, type KeyboardEven
import clsx from "clsx";
import { executeAction } from "@/platform/actions";
import { call, commands, isTauri, type RunConfig } from "@/platform/ipc";
+import { letterOf } from "@/platform/keymap";
import { useWorkflowSettings } from "@/platform/workflowSettings";
import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui";
import {
@@ -312,7 +313,8 @@ function EditConfigsDialog() {
// IDEA: ⌘N / Alt+Insert adds a configuration, ⌥M opens "Modify options".
const onKeyDown = (e: KeyboardEvent) => {
const add =
- (e.code === "KeyN" && (isMac() ? e.metaKey || e.ctrlKey : e.ctrlKey)) || (e.altKey && e.key === "Insert");
+ (letterOf(e) === "N" && !e.altKey && (isMac() ? e.metaKey || e.ctrlKey : e.ctrlKey)) ||
+ (e.altKey && e.key === "Insert");
if (add && mode === "configs") {
e.preventDefault();
openAdd();
diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx
index c928520f..0531328d 100644
--- a/src/features/run/runConfigTypes.test.tsx
+++ b/src/features/run/runConfigTypes.test.tsx
@@ -318,6 +318,17 @@ describe("Run/Debug Configurations tree", () => {
fireEvent.click(await screen.findByText("Redirect input"));
expect(screen.getByLabelText("Redirect input from")).toBeTruthy();
});
+
+ it("opens Add New Configuration on Ctrl+N by typed letter", () => {
+ useRun.setState({ configs: [], loaded: true, editorOpen: true });
+ render();
+ const tree = screen.getByRole("tree", { name: "Run configurations" });
+ // Dvorak: physical KeyN types "b", physical KeyL types "n".
+ fireEvent.keyDown(tree, { key: "b", code: "KeyN", ctrlKey: true });
+ expect(screen.queryByLabelText("Search configuration types")).toBeNull();
+ fireEvent.keyDown(tree, { key: "n", code: "KeyL", ctrlKey: true });
+ expect(screen.getByLabelText("Search configuration types")).toBeTruthy();
+ });
});
describe("debug-only types (Remote JVM Debug)", () => {
diff --git a/src/features/terminal/keys.ts b/src/features/terminal/keys.ts
index 98ce5a91..39ec66dc 100644
--- a/src/features/terminal/keys.ts
+++ b/src/features/terminal/keys.ts
@@ -15,7 +15,7 @@
* there is no terminal, so there is nothing to handle.
*/
import { executeAction } from "@/platform/actions";
-import { effectiveBindings, strokeOf } from "@/platform/keymap";
+import { effectiveBindings, letterOf, strokeOf } from "@/platform/keymap";
import { workflowSettings } from "@/platform/workflowSettings";
import { terminalRuntime } from "./loader";
import { isImeKey } from "@/platform/ime";
@@ -53,16 +53,16 @@ export function terminalKeyAction(e: KeyLike, hasSelection: boolean): TerminalKe
if (e.key === "Backspace") return { kind: "send", data: "\x17" };
return { kind: "pass" };
}
+ const letter = letterOf(e);
if (ctrl && shift && !meta && !alt) {
- if (e.code === "KeyC") return hasSelection ? { kind: "copy" } : { kind: "pass" };
- if (e.code === "KeyV") return { kind: "paste" };
+ if (letter === "C") return hasSelection ? { kind: "copy" } : { kind: "pass" };
+ if (letter === "V") return { kind: "paste" };
// New Tab on Windows / Linux (IDEA keymap); plain Ctrl+T still reaches the shell.
- if (e.code === "KeyT") return { kind: "pass" };
+ if (letter === "T") return { kind: "pass" };
}
if (ctrl && !meta && !alt) {
// The letter the key types, so Shift doesn't change the control code and AZERTY's Ctrl+Z is ^Z, not ^W.
- const letter = /^[a-zA-Z]$/.test(e.key) ? e.key : /^Key([A-Z])$/.exec(e.code)?.[1];
- if (letter) return { kind: "send", data: String.fromCharCode(letter.toUpperCase().charCodeAt(0) - 64) };
+ if (letter) return { kind: "send", data: String.fromCharCode(letter.charCodeAt(0) - 64) };
if (e.code === "BracketLeft") return { kind: "send", data: "\x1b" };
if (e.code === "Backslash") return { kind: "send", data: "\x1c" };
if (e.code === "BracketRight") return { kind: "send", data: "\x1d" };
diff --git a/src/features/terminal/terminal.test.ts b/src/features/terminal/terminal.test.ts
index f5233225..580cf811 100644
--- a/src/features/terminal/terminal.test.ts
+++ b/src/features/terminal/terminal.test.ts
@@ -133,6 +133,20 @@ describe("terminal keys", () => {
kind: "send",
data: "\x1a",
});
+ // Non-QWERTY Ctrl+Shift+C / V / T match the typed letter, not the physical key.
+ expect(terminalKeyAction(key({ key: "C", code: "KeyI", ctrlKey: true, shiftKey: true }), true)).toEqual({
+ kind: "copy",
+ });
+ expect(terminalKeyAction(key({ key: "V", code: "KeyPeriod", ctrlKey: true, shiftKey: true }), false)).toEqual({
+ kind: "paste",
+ });
+ expect(terminalKeyAction(key({ key: "T", code: "KeyK", ctrlKey: true, shiftKey: true }), false)).toEqual({
+ kind: "pass",
+ });
+ expect(terminalKeyAction(key({ key: "J", code: "KeyC", ctrlKey: true, shiftKey: true }), true)).toEqual({
+ kind: "send",
+ data: "\x0a",
+ });
});
it("implements macOS conveniences", () => {
diff --git a/src/platform/keymap.test.ts b/src/platform/keymap.test.ts
index c5d3e694..62077e30 100644
--- a/src/platform/keymap.test.ts
+++ b/src/platform/keymap.test.ts
@@ -4,6 +4,7 @@ import {
installKeyboardDispatcher,
isAltGrTyping,
isValidBinding,
+ letterOf,
normalizeBinding,
parseDoubleModifier,
strokeOf,
@@ -31,6 +32,9 @@ describe("keymap", () => {
it("names letters by the character typed, so AZERTY's Ctrl+Z is not Ctrl+W", () => {
// AZERTY: the key typing "z" is physically KeyW.
+ expect(letterOf({ code: "KeyW", key: "z", altKey: false })).toBe("Z");
+ expect(letterOf({ code: "KeyF", key: "ƒ", altKey: true })).toBe("F");
+ expect(letterOf({ code: "Digit1", key: "1", altKey: false })).toBeNull();
expect(strokeOf({ code: "KeyW", key: "z", metaKey: false, ctrlKey: true, altKey: false, shiftKey: false })).toBe(
"Ctrl+Z",
);
diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts
index 43941d48..8bac753b 100644
--- a/src/platform/keymap.ts
+++ b/src/platform/keymap.ts
@@ -253,15 +253,25 @@ const CODE_NAMES: Record = {
NumpadDivide: "NumpadDivide",
};
+/**
+ * The uppercase ASCII letter of a key event: the character it types when Alt
+ * is not held (on AZERTY the Z key sits on the physical KeyW, so Ctrl+Z must
+ * not read as Ctrl+W), or the physical `KeyX` when Alt is held (⌥ on macOS and
+ * AltGr may type a symbol) or `e.key` is not an ASCII letter.
+ */
+export function letterOf(e: Pick & { altKey?: boolean }): string | null {
+ if (/^[a-zA-Z]$/.test(e.key) && !e.altKey) return e.key.toUpperCase();
+ if (/^Key[A-Z]$/.test(e.code)) return e.code.slice(3);
+ return null;
+}
+
/** Converts a keydown event into a canonical stroke, or null for bare modifiers. */
export function strokeOf(
e: Pick,
): string | null {
let key: string | undefined;
- // A letter is named by the character it types: on AZERTY the Z key sits on the physical KeyW,
- // so Ctrl+Z must not read as Ctrl+W. Alt combos may type a symbol (⌥ on macOS, AltGr), so they keep the physical key.
- if (/^[a-zA-Z]$/.test(e.key) && !e.altKey) key = e.key.toUpperCase();
- else if (/^Key[A-Z]$/.test(e.code)) key = e.code.slice(3);
+ const letter = letterOf(e);
+ if (letter) key = letter;
else if (/^Digit\d$/.test(e.code)) key = e.code.slice(5);
else if (/^Numpad\d$/.test(e.code)) key = e.code.slice(6);
else if (/^F\d{1,2}$/.test(e.code)) key = e.code;
diff --git a/src/workbench/browserGuards.test.ts b/src/workbench/browserGuards.test.ts
index d76c38d8..d44d6161 100644
--- a/src/workbench/browserGuards.test.ts
+++ b/src/workbench/browserGuards.test.ts
@@ -56,4 +56,14 @@ describe("browser guards: context menu", () => {
rightClick(p);
expect(useContextMenuStore.getState().menu).toBeNull();
});
+
+ it("blocks browser shortcuts by typed letter", () => {
+ const press = (init: KeyboardEventInit) => {
+ const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
+ window.dispatchEvent(e);
+ return e.defaultPrevented;
+ };
+ expect(press({ key: "p", code: "KeyR", ctrlKey: true })).toBe(true);
+ expect(press({ key: "l", code: "KeyP", ctrlKey: true })).toBe(false);
+ });
});
diff --git a/src/workbench/browserGuards.ts b/src/workbench/browserGuards.ts
index 167cb1c8..17a630b0 100644
--- a/src/workbench/browserGuards.ts
+++ b/src/workbench/browserGuards.ts
@@ -5,6 +5,7 @@
* shortcuts stay untouched. A right-click no component handled on selected or
* selectable read-only text gets IDEA's Copy menu instead of nothing.
*/
+import { letterOf } from "@/platform/keymap";
import { copyMenuFor, showContextMenu, useContextMenuStore } from "@/ui";
const BLOCKED = new Set([
@@ -23,13 +24,7 @@ const BLOCKED = new Set([
const RELOAD = new Set(["Meta+R", "Meta+Shift+R", "Ctrl+R", "Ctrl+Shift+R", "F5", "Shift+F5"]);
function keyId(e: KeyboardEvent) {
- const key = e.code.startsWith("Key")
- ? e.code.slice(3)
- : e.code === "BracketLeft"
- ? "["
- : e.code === "BracketRight"
- ? "]"
- : e.key;
+ const key = letterOf(e) ?? (e.code === "BracketLeft" ? "[" : e.code === "BracketRight" ? "]" : e.key);
return [e.ctrlKey && "Ctrl", e.altKey && "Alt", e.shiftKey && "Shift", e.metaKey && "Meta", key]
.filter(Boolean)
.join("+");