mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
727249e143 |
No files matched your search
@@ -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<string, string> = {};
|
||||
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() };
|
||||
|
||||
+61
-5
@@ -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<KeymapState>(() => ({ 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<MouseEvent, "button" | "metaKey" | "ctrlKey" | "altKey" | "shiftKey">,
|
||||
): 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<string, string> = {
|
||||
* 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);
|
||||
};
|
||||
}
|
||||
@@ -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 = `<div class="cm-editor"><div class="cm-content" contenteditable="true">x</div></div><div id="tree"></div>`;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 (`<mouse-shortcut keystroke="button4"/>`).
|
||||
["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);
|
||||
|
||||
@@ -15,8 +15,8 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"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"],
|
||||
|
||||
@@ -16,8 +16,8 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"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"],
|
||||
|
||||
@@ -15,8 +15,8 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"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"],
|
||||
|
||||
@@ -20,8 +20,8 @@ const translated = Object.fromEntries(
|
||||
|
||||
export const vscodeWindows: Record<string, string[]> = {
|
||||
...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"],
|
||||
|
||||
Reference in new issue
Block a user