diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 4d0b5332b..7b5d4f84c 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -11,7 +11,7 @@ import { executeAction, getAction, isActionEnabled, useActions } from "@/platfor import { useContextKeys } from "@/platform/context"; import { keysWithPrefix, t, tx, useI18n } from "@/platform/i18n"; import { commands, isTauri, type DynamicItem, type MenuConfig, type MenuNode } from "@/platform/ipc"; -import { bindingsFor, effectiveBindings, formatBinding, useKeymap } from "@/platform/keymap"; +import { effectiveBindings, formatBinding, primaryKeyBinding, useKeymap } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { useContributions } from "@/workbench/contributions"; import { isDistractionFree, useDistractionFree } from "@/workbench/distractionFree"; @@ -48,7 +48,7 @@ export function buildMenuConfig(os: HostOs): MenuConfig { const shortcuts: Record = {}; for (const [, id] of effectiveBindings()) { if (id in shortcuts) continue; - const first = bindingsFor(id)[0]; + const first = primaryKeyBinding(id); if (first) shortcuts[id] = menuShortcut(first, os); } return { locale: useI18n.getState().locale, labels, shortcuts, dynamic: dynamicMenuItems() }; diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index 7a304ac9b..596c7033f 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -6,7 +6,9 @@ * (`Meta+K Meta+C`). A modifier pressed twice is `Shift Shift` (IDEA's double * Shift, `Ctrl Ctrl`…); pressed twice and held, then a key, `Alt Alt+Up`. * Keys are matched by physical position (`KeyboardEvent.code`), so ⌥-letter - * combos work on macOS where `event.key` would be a symbol. + * combos work on macOS where `event.key` would be a symbol. Mouse shortcuts + * name the button 1-based (`Button4` = back side button, `Button5` = forward), + * optionally with modifiers (`Alt+Button4`); they are single strokes. */ import { create } from "zustand"; import { getAction, isActionKeyEnabled, executeAction } from "./actions"; @@ -65,6 +67,27 @@ interface KeymapState { export const useKeymap = create(() => ({ preset: defaultPreset(), overrides: {} })); +const MOUSE_KEY_RE = /^Button(\d+)$/i; + +/** A mouse shortcut (`Button4`, `Shift+Button5`) rather than a keystroke. */ +export function isMouseBinding(binding: string): boolean { + const b = binding.trim(); + return !/\s/.test(b) && MOUSE_KEY_RE.test(b.split("+").pop()!); +} + +/** + * The mouse stroke of a press of a non-primary button: DOM `button` 3 / 4 + * (back / forward side buttons) → `Button4` / `Button5`. Left, middle and + * right buttons have their own meaning everywhere, so they are not shortcuts. + */ +export function mouseStrokeOf( + e: Pick, +): string | null { + if (!Number.isInteger(e.button) || e.button < 3) return null; + const mods = [e.ctrlKey && "Ctrl", e.altKey && "Alt", e.shiftKey && "Shift", e.metaKey && "Meta"].filter(Boolean); + return [...mods, `Button${e.button + 1}`].join("+"); +} + const STROKE_RE = /^(?:(?:Ctrl|Alt|Shift|Meta)\+)*[^\s+]+$|^(?:(?:Ctrl|Alt|Shift|Meta)\+)*\+$/i; const MODIFIERS = ["Ctrl", "Alt", "Shift", "Meta"] as const; @@ -183,7 +206,8 @@ export function normalizeBinding(binding: string): string { const parts = stroke.split("+"); const key = parts.pop()!; const mods = MOD_ORDER.filter((m) => parts.some((p) => p.toLowerCase() === m.toLowerCase())); - return [...mods, key.length === 1 ? key.toUpperCase() : key].join("+"); + const mouse = MOUSE_KEY_RE.exec(key); + return [...mods, mouse ? `Button${mouse[1]}` : key.length === 1 ? key.toUpperCase() : key].join("+"); }) .join(" "); } @@ -264,6 +288,11 @@ const SYMBOLS: Record = { * Windows/Linux: `Ctrl+Shift+N`, chords as `Ctrl+K, Ctrl+I`, `Shift, Shift`, `Ctrl, Ctrl+Up`. */ export function formatBinding(binding: string, mac = isMacHost()): string { + if (isMouseBinding(binding)) { + const parts = normalizeBinding(binding).split("+"); + const button = `${parts.pop()} Click`; + return mac ? parts.map((p) => SYMBOLS[p] ?? p).join("") + button : [...parts, button].join("+"); + } if (!mac) return normalizeBinding(binding).split(" ").join(", "); const double = parseDoubleModifier(binding); if (double) return SYMBOLS[double.modifier]!.repeat(2) + (double.key ? (SYMBOLS[double.key] ?? double.key) : ""); @@ -278,8 +307,13 @@ export function formatBinding(binding: string, mac = isMacHost()): string { .join(" "); } +/** The first keyboard binding of an action: what menus and tooltips show. */ +export function primaryKeyBinding(actionId: string): string | undefined { + return bindingsFor(actionId).find((b) => !isMouseBinding(b)); +} + export function shortcutLabel(actionId: string): string | undefined { - const b = bindingsFor(actionId)[0]; + const b = primaryKeyBinding(actionId); return b ? formatBinding(b) : undefined; } @@ -463,8 +497,26 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu for (const id of ids.reverse()) if (runIfEnabled(id)) return; }; - // Shift+click (extend a selection) is not half of a double Shift. - const onMouseDown = () => resetTap(); + const onMouseDown = (e: MouseEvent) => { + // Shift+click (extend a selection) is not half of a double Shift. + resetTap(); + if (suspended > 0) return; + const stroke = mouseStrokeOf(e); + if (!stroke) return; + // Side buttons never reach the target (no editor selection, no focus change). + e.preventDefault(); + e.stopPropagation(); + reset(); + const ids = effectiveBindings().filter(([b]) => b === stroke).map(([, id]) => id); + for (const id of ids.reverse()) if (runIfEnabled(id)) return; + }; + + /** The release of a side button: swallowed so the webview doesn't navigate its own history. */ + const onMouseRelease = (e: MouseEvent) => { + if (e.button < 3) return; + e.preventDefault(); + e.stopPropagation(); + }; const onBlur = () => { reset(); @@ -474,11 +526,15 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu target.addEventListener("keydown", onKeyDown, true); target.addEventListener("keyup", onKeyUp, true); target.addEventListener("mousedown", onMouseDown, true); + target.addEventListener("mouseup", onMouseRelease, true); + target.addEventListener("auxclick", onMouseRelease, true); target.addEventListener("blur", onBlur); return () => { target.removeEventListener("keydown", onKeyDown, true); target.removeEventListener("keyup", onKeyUp, true); target.removeEventListener("mousedown", onMouseDown, true); + target.removeEventListener("mouseup", onMouseRelease, true); + target.removeEventListener("auxclick", onMouseRelease, true); target.removeEventListener("blur", onBlur); }; } diff --git a/src/platform/keymapMouse.test.ts b/src/platform/keymapMouse.test.ts new file mode 100644 index 000000000..f81e845dd --- /dev/null +++ b/src/platform/keymapMouse.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatBinding, + installKeyboardDispatcher, + isMouseBinding, + isValidBinding, + mouseStrokeOf, + normalizeBinding, + PRESETS, + primaryKeyBinding, + shortcutLabel, + suspendKeyboardDispatch, + useKeymap, +} from "./keymap"; +import { registerAction } from "./actions"; + +const noMods = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; + +describe("mouse shortcuts", () => { + it.each(Object.keys(PRESETS))("%s binds the side buttons to Back / Forward", (preset) => { + expect(PRESETS[preset]!["navigate.back"]).toContain("Button4"); + expect(PRESETS[preset]!["navigate.forward"]).toContain("Button5"); + }); + + it("names DOM side buttons 1-based, with modifiers", () => { + expect(mouseStrokeOf({ ...noMods, button: 3 })).toBe("Button4"); + expect(mouseStrokeOf({ ...noMods, button: 4 })).toBe("Button5"); + expect(mouseStrokeOf({ ...noMods, button: 3, altKey: true, shiftKey: true })).toBe("Alt+Shift+Button4"); + for (const button of [0, 1, 2]) expect(mouseStrokeOf({ ...noMods, button })).toBeNull(); + }); + + it("parses, normalizes and formats mouse bindings", () => { + expect(isValidBinding("Button4")).toBe(true); + expect(isMouseBinding("shift+button5")).toBe(true); + expect(isMouseBinding("Meta+B")).toBe(false); + expect(normalizeBinding("shift+button5")).toBe("Shift+Button5"); + expect(formatBinding("Button4", true)).toBe("Button4 Click"); + expect(formatBinding("Shift+Button4", true)).toBe("⇧Button4 Click"); + expect(formatBinding("Ctrl+Button5", false)).toBe("Ctrl+Button5 Click"); + }); + + it("keeps mouse bindings out of menu / tooltip shortcuts", () => { + useKeymap.setState({ preset: "intellij-windows", overrides: { "t.mouseFirst": ["Button4", "Ctrl+Alt+Q"] } }); + expect(primaryKeyBinding("t.mouseFirst")).toBe("Ctrl+Alt+Q"); + useKeymap.setState({ overrides: { "t.mouseFirst": ["Button4"] } }); + expect(shortcutLabel("t.mouseFirst")).toBeUndefined(); + }); + + describe("dispatch", () => { + let uninstall = () => {}; + const back = vi.fn(); + const forward = vi.fn(); + let editor: HTMLElement; + + beforeEach(() => { + back.mockReset(); + forward.mockReset(); + registerAction({ id: "navigate.back", title: "Back", run: back }); + registerAction({ id: "navigate.forward", title: "Forward", run: forward }); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); + document.body.innerHTML = `
x
`; + editor = document.querySelector(".cm-content")!; + uninstall = installKeyboardDispatcher(window); + }); + afterEach(() => { + uninstall(); + document.body.innerHTML = ""; + }); + + const click = (target: Element, button: number, init: MouseEventInit = {}) => { + const events = (["mousedown", "mouseup", "auxclick"] as const).map( + (type) => new MouseEvent(type, { button, bubbles: true, cancelable: true, ...init }), + ); + for (const e of events) target.dispatchEvent(e); + return events; + }; + + it.each([ + ["the editor", () => editor], + ["another component", () => document.getElementById("tree")!], + ["the body", () => document.body], + ])("runs Back / Forward from %s", async (_, target) => { + click(target(), 3); + await Promise.resolve(); + expect(back).toHaveBeenCalledOnce(); + expect(forward).not.toHaveBeenCalled(); + click(target(), 4); + await Promise.resolve(); + expect(forward).toHaveBeenCalledOnce(); + }); + + it("keeps side-button events from the target and from webview history navigation", () => { + const reached = vi.fn(); + editor.addEventListener("mousedown", reached); + editor.addEventListener("mouseup", reached); + editor.addEventListener("auxclick", reached); + const events = click(editor, 3); + expect(reached).not.toHaveBeenCalled(); + expect(events.every((e) => e.defaultPrevented)).toBe(true); + }); + + it("leaves left, middle and right clicks alone", async () => { + const reached = vi.fn(); + editor.addEventListener("mousedown", reached); + for (const button of [0, 1, 2]) { + const [down] = click(editor, button); + expect(down!.defaultPrevented).toBe(false); + } + await Promise.resolve(); + expect(reached).toHaveBeenCalledTimes(3); + expect(back).not.toHaveBeenCalled(); + expect(forward).not.toHaveBeenCalled(); + }); + + it("follows keymap overrides and modifiers", async () => { + useKeymap.setState({ overrides: { "navigate.back": ["Meta+["], "navigate.forward": ["Shift+Button4"] } }); + click(editor, 3); + await Promise.resolve(); + expect(back).not.toHaveBeenCalled(); + click(editor, 3, { shiftKey: true }); + await Promise.resolve(); + expect(forward).toHaveBeenCalledOnce(); + }); + + it("does nothing while shortcuts are being recorded", async () => { + const resume = suspendKeyboardDispatch(); + click(editor, 3); + resume(); + await Promise.resolve(); + expect(back).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index 26a5161d4..8376db456 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -49,6 +49,9 @@ describe("IDEA keymap parity", () => { ["usages.rerun", ["Meta+R"], ["Ctrl+F5"]], // Vcs.Log.GoToRef: Find in the Git Log table. ["vcs.log.goToRef", ["Meta+F"], ["Ctrl+F"]], + // Back / Forward also carry the mouse side buttons (``). + ["navigate.back", ["Meta+[", "Meta+Alt+Left", "Button4"], ["Ctrl+Alt+Left", "Button4"]], + ["navigate.forward", ["Meta+]", "Meta+Alt+Right", "Button5"], ["Ctrl+Alt+Right", "Button5"]], ])("%s", (id, mac, win) => { expect(intellijMacos[id]).toEqual(mac); expect(intellijWindows[id]).toEqual(win); diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index e68236dcb..695f58dfd 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -15,8 +15,8 @@ export const intellijMacos: Record = { "navigate.switcherBack": ["Ctrl+Shift+Tab"], "navigate.recentFiles": ["Meta+E"], "navigate.recentLocations": ["Meta+Shift+E"], - "navigate.back": ["Meta+[", "Meta+Alt+Left"], - "navigate.forward": ["Meta+]", "Meta+Alt+Right"], + "navigate.back": ["Meta+[", "Meta+Alt+Left", "Button4"], + "navigate.forward": ["Meta+]", "Meta+Alt+Right", "Button5"], "navigate.gotoLine": ["Meta+L"], "navigate.declaration": ["Meta+B"], "navigate.implementation": ["Meta+Alt+B"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index d7a7c3e27..bafc2f083 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -16,8 +16,8 @@ export const intellijWindows: Record = { "navigate.switcherBack": ["Ctrl+Shift+Tab"], "navigate.recentFiles": ["Ctrl+E"], "navigate.recentLocations": ["Ctrl+Shift+E"], - "navigate.back": ["Ctrl+Alt+Left"], - "navigate.forward": ["Ctrl+Alt+Right"], + "navigate.back": ["Ctrl+Alt+Left", "Button4"], + "navigate.forward": ["Ctrl+Alt+Right", "Button5"], "navigate.gotoLine": ["Ctrl+G"], "navigate.declaration": ["Ctrl+B"], "navigate.implementation": ["Ctrl+Alt+B"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 0673701a5..b6d60fc6a 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -15,8 +15,8 @@ export const vscodeMacos: Record = { "navigate.switcherBack": ["Ctrl+Shift+Tab"], "navigate.recentFiles": [], "navigate.recentLocations": [], - "navigate.back": ["Ctrl+-"], - "navigate.forward": ["Ctrl+Shift+-"], + "navigate.back": ["Ctrl+-", "Button4"], + "navigate.forward": ["Ctrl+Shift+-", "Button5"], "navigate.gotoLine": ["Ctrl+G"], "navigate.declaration": ["F12"], "navigate.implementation": ["Meta+F12"], diff --git a/src/platform/keymaps/vscode-windows.ts b/src/platform/keymaps/vscode-windows.ts index 4ad9765c1..93ff7adc5 100644 --- a/src/platform/keymaps/vscode-windows.ts +++ b/src/platform/keymaps/vscode-windows.ts @@ -20,8 +20,8 @@ const translated = Object.fromEntries( export const vscodeWindows: Record = { ...translated, - "navigate.back": ["Alt+Left"], - "navigate.forward": ["Alt+Right"], + "navigate.back": ["Alt+Left", "Button4"], + "navigate.forward": ["Alt+Right", "Button5"], "find.replaceInFile": ["Ctrl+H"], "find.next": ["F3"], "find.previous": ["Shift+F3"],