diff --git a/src/editor/lsp/navigation.ts b/src/editor/lsp/navigation.ts index ca7b84ad7..9dc2d5cdf 100644 --- a/src/editor/lsp/navigation.ts +++ b/src/editor/lsp/navigation.ts @@ -55,7 +55,7 @@ import { requestHover, showPhpConstructDocumentation } from "./hover"; import { declarationSummary, renderCode } from "./markdown"; import { editorPrefs } from "@/platform/editorSettings"; import { isVirtualUri, virtualContainer } from "@/platform/virtualDocs"; -import { shortcutLabel } from "@/platform/keymap"; +import { dispatchMouseShortcut, shortcutLabel } from "@/platform/keymap"; import { isNavigationGesture, onNavigationModifier } from "@/platform/navigationModifier"; import { create } from "zustand"; import { tx } from "@/platform/i18n"; @@ -489,7 +489,20 @@ const linkPlugin = ViewPlugin.fromClass( this.show(false); }, mousedown(e, view) { - if (e.button !== 0 || !isNavigationGesture(e)) return false; + if (e.button !== 0) return false; + if ( + dispatchMouseShortcut(e, () => { + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); + if (pos == null) return false; + view.dispatch({ selection: EditorSelection.cursor(pos), effects: setLink.of(null) }); + view.focus(); + return true; + }) + ) { + e.preventDefault(); + return true; + } + if (!isNavigationGesture(e)) return false; const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); if (pos == null) return false; e.preventDefault(); diff --git a/src/editor/lsp/navigationMouse.test.ts b/src/editor/lsp/navigationMouse.test.ts new file mode 100644 index 000000000..25dc94218 --- /dev/null +++ b/src/editor/lsp/navigationMouse.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import type { commands } from "@/platform/ipc"; +import { usePlatform } from "@/platform/platformInfo"; +import { resetNavigationModifier } from "@/platform/navigationModifier"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { registerAction } from "@/platform/actions"; + +const lspGoto = vi.hoisted(() => vi.fn()); +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { ...real, isTauri: () => true, commands: { ...real.commands, lspGoto } }; +}); + +const { gotoTargets, navigationExtension } = await import("./navigation"); +const { lspDoc } = await import("./lsp"); +const { provideEditorService } = await import("@/workbench/contributions"); +const openFile = vi.fn(async () => {}); +provideEditorService({ openFile } as never); + +let view: EditorView; +let uninstall: () => void; +let removeAction: () => void; +beforeEach(() => { + usePlatform.setState({ os: "macos" }); + resetNavigationModifier(); + vi.clearAllMocks(); + useKeymap.setState({ + preset: "intellij-macos", + overrides: {}, + }); + removeAction = registerAction({ + id: "navigate.implementation", + title: "Go to Implementation(s)", + run: () => gotoTargets(view, "implementation"), + }); + uninstall = installKeyboardDispatcher(window); + lspGoto.mockResolvedValue({ + status: "ok", + data: [{ path: "/p/ServiceImpl.java", range: { start: { line: 4, col: 2 }, end: { line: 4, col: 5 } }, uri: null }], + }); + view = new EditorView({ + state: EditorState.create({ + doc: "service.run()", + extensions: [ + lspDoc.of({ doc: 1, path: "/p/Main.java", language: "java", uiMode: "Java", readOnly: false }), + navigationExtension(), + ], + }), + parent: document.body, + }); + vi.spyOn(view, "posAtCoords").mockReturnValue(9); +}); +afterEach(() => { + uninstall(); + removeAction(); + useKeymap.setState({ overrides: {} }); + view.destroy(); + document.body.replaceChildren(); + resetNavigationModifier(); + usePlatform.setState({ os: "macos" }); + vi.restoreAllMocks(); +}); + +function click(init: MouseEventInit) { + const event = new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, ...init }); + view.contentDOM.dispatchEvent(event); + return event; +} + +describe("mouse navigation", () => { + it("opens the implementation of the symbol clicked with Option+Command", async () => { + const event = click({ metaKey: true, altKey: true }); + await vi.waitFor(() => + expect(openFile).toHaveBeenCalledWith("/p/ServiceImpl.java", { + selection: { line: 4, col: 2 }, + focus: true, + }), + ); + expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "implementation"); + expect(view.state.selection.main.head).toBe(9); + expect(event.defaultPrevented).toBe(true); + }); + + it.each([ + { os: "macos", mods: { metaKey: true } }, + { os: "windows", mods: { ctrlKey: true } }, + { os: "linux", mods: { ctrlKey: true } }, + ] as const)("preserves declaration navigation on $os", async ({ os, mods }) => { + usePlatform.setState({ os }); + click(mods); + await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce()); + expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "definition"); + }); + + it.each([ + { button: 1, metaKey: true, altKey: true }, + { button: 2, metaKey: true, altKey: true }, + { metaKey: true, altKey: true, shiftKey: true }, + { metaKey: true, altKey: true, ctrlKey: true }, + ])("ignores other button/modifier combinations: %o", async (mods) => { + click(mods); + await Promise.resolve(); + expect(lspGoto).not.toHaveBeenCalled(); + }); + + it("reports missing implementations without navigating to the declaration", async () => { + lspGoto.mockResolvedValue({ status: "ok", data: [] }); + click({ metaKey: true, altKey: true }); + await vi.waitFor(() => + expect(document.querySelector(".cm-lsp-hint")?.textContent).toBe("No implementations found"), + ); + expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "implementation"); + expect(openFile).not.toHaveBeenCalled(); + }); + + it("does not navigate with Option+Command when the binding is removed", async () => { + useKeymap.setState({ overrides: { "navigate.implementation": ["Meta+Alt+B"] } }); + click({ metaKey: true, altKey: true }); + await Promise.resolve(); + expect(lspGoto).not.toHaveBeenCalled(); + }); + + it("follows a different mouse binding assigned to the implementation action", async () => { + useKeymap.setState({ overrides: { "navigate.implementation": ["Ctrl+Alt+Button1"] } }); + click({ metaKey: true, altKey: true }); + expect(lspGoto).not.toHaveBeenCalled(); + click({ ctrlKey: true, altKey: true }); + await vi.waitFor(() => expect(openFile).toHaveBeenCalledOnce()); + expect(lspGoto).toHaveBeenCalledExactlyOnceWith(1, { line: 0, col: 9 }, "implementation"); + }); + + it("does not run the binding if the click has no document position", async () => { + vi.mocked(view.posAtCoords).mockReturnValue(null); + click({ metaKey: true, altKey: true }); + await Promise.resolve(); + expect(lspGoto).not.toHaveBeenCalled(); + }); + + it("allows the same gesture to be reassigned to another action", async () => { + const run = vi.fn(); + const remove = registerAction({ id: "test.mouse", title: "Custom action", run }); + try { + useKeymap.setState({ + overrides: { "navigate.implementation": ["Meta+Alt+B"], "test.mouse": ["Alt+Meta+Button1"] }, + }); + click({ metaKey: true, altKey: true }); + await Promise.resolve(); + expect(run).toHaveBeenCalledOnce(); + expect(lspGoto).not.toHaveBeenCalled(); + } finally { + remove(); + } + }); +}); diff --git a/src/features/settings/__tests__/mouseShortcut.test.tsx b/src/features/settings/__tests__/mouseShortcut.test.tsx index 212e22e29..ac5a8e34f 100644 --- a/src/features/settings/__tests__/mouseShortcut.test.tsx +++ b/src/features/settings/__tests__/mouseShortcut.test.tsx @@ -7,6 +7,27 @@ afterEach(cleanup); const action = { id: "my.action", title: "My Action", category: "Test" }; describe("Mouse Shortcut dialog", () => { + it("records and submits Option+Command+left click", () => { + const onSubmit = vi.fn(); + render( + {}} + onSubmit={onSubmit} + />, + ); + fireEvent.mouseDown(screen.getByRole("textbox", { name: "Mouse shortcut" }), { + button: 0, + altKey: true, + metaKey: true, + }); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + expect(onSubmit).toHaveBeenCalledWith("Alt+Meta+Button1", [], false); + }); + it("records a side-button click with modifiers and submits it", () => { const onSubmit = vi.fn(); render( diff --git a/src/features/settings/keymap/RecordShortcutDialog.tsx b/src/features/settings/keymap/RecordShortcutDialog.tsx index b97eedb44..c3abc510d 100644 --- a/src/features/settings/keymap/RecordShortcutDialog.tsx +++ b/src/features/settings/keymap/RecordShortcutDialog.tsx @@ -22,7 +22,7 @@ export interface RecordShortcutDialogProps { onCancel: () => void; /** `removeConflicts`: also unbind the conflicting assignments. */ onSubmit: (binding: string, conflicts: Conflict[], removeConflicts: boolean) => void; - /** Record a mouse shortcut (side buttons with modifiers) instead of keys. */ + /** Record a mouse shortcut (modified left clicks or side buttons) instead of keys. */ mouse?: boolean; } @@ -178,7 +178,7 @@ function KeyStrokes({ first, second, useSecond, setFirst, setSecond, setUseSecon ); } -/** Pad that records a side-button click with its modifiers; shortcuts stay off while the dialog is open. */ +/** Record modified left clicks and side buttons; shortcuts stay off while the dialog is open. */ function MouseRecorder({ value, onChange }: { value: string; onChange: (binding: string) => void }) { useEffect(() => suspendKeyboardDispatch(), []); const record = (e: MouseEvent) => { @@ -202,7 +202,7 @@ function MouseRecorder({ value, onChange }: { value: string; onChange: (binding: {formatBinding(value)} ) : ( - Click here with a mouse side button, holding any modifiers + Click here with Ctrl, Alt or Command held, or use a mouse side button )} diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index 8bac753b9..d236c023f 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -7,7 +7,7 @@ * 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. Mouse shortcuts - * name the button 1-based (`Button4` = back side button, `Button5` = forward), + * name the button 1-based (`Button1` = left, `Button4` = back side button, `Button5` = forward), * optionally with modifiers (`Alt+Button4`); they are single strokes. */ import { create } from "zustand"; @@ -83,14 +83,14 @@ export function isMouseBinding(binding: string): boolean { } /** - * 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. + * Side buttons, or a left click with Ctrl/Alt/Meta. Plain and Shift-only + * left clicks keep selection semantics; middle/right clicks keep their own meaning. */ export function mouseStrokeOf( e: Pick, ): string | null { - if (!Number.isInteger(e.button) || e.button < 3) return null; + if (!Number.isInteger(e.button) || e.button < 0) return null; + if (e.button < 3 && !(e.button === 0 && (e.ctrlKey || e.altKey || e.metaKey))) 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("+"); } @@ -317,7 +317,8 @@ const KEY_LABELS: Record = { export function formatBinding(binding: string, mac = isMacHost()): string { if (isMouseBinding(binding)) { const parts = normalizeBinding(binding).split("+"); - const button = `${parts.pop()} Click`; + const key = parts.pop(); + const button = key === "Button1" ? "Left Click" : `${key} Click`; return mac ? parts.map((p) => SYMBOLS[p] ?? p).join("") + button : [...parts, button].join("+"); } if (!mac) @@ -405,6 +406,19 @@ function runIfEnabled(id: string): boolean { return true; } +/** Run a configured mouse binding; editors prepare the clicked caret and focus first. */ +export function dispatchMouseShortcut(e: MouseEvent, prepare?: () => boolean): boolean { + if (suspended > 0) return false; + const stroke = mouseStrokeOf(e); + if (!stroke) return false; + const ids = effectiveBindings() + .filter(([b]) => b === stroke) + .map(([, id]) => id); + if (!ids.length || (prepare && !prepare())) return false; + for (const id of ids.reverse()) if (runIfEnabled(id)) return true; + return false; +} + /** * Installs the global keydown dispatcher (capture phase, so IDE shortcuts win * over CodeMirror defaults). Returns an uninstall function. `now` is the clock @@ -551,6 +565,15 @@ export function installKeyboardDispatcher( if (suspended > 0) return; const stroke = mouseStrokeOf(e); if (!stroke) return; + if (e.button === 0) { + // The editor sets the caret under the pointer before dispatching the action. + if (e.target instanceof Element && e.target.closest(".cm-content")) return; + if (!dispatchMouseShortcut(e)) return; + e.preventDefault(); + e.stopPropagation(); + reset(); + return; + } // Side buttons never reach the target (no editor selection, no focus change). e.preventDefault(); e.stopPropagation(); diff --git a/src/platform/keymapMouse.test.ts b/src/platform/keymapMouse.test.ts index f81e845dd..38d4e6598 100644 --- a/src/platform/keymapMouse.test.ts +++ b/src/platform/keymapMouse.test.ts @@ -39,6 +39,15 @@ describe("mouse shortcuts", () => { expect(formatBinding("Ctrl+Button5", false)).toBe("Ctrl+Button5 Click"); }); + it("records modified left clicks without capturing selection and context-menu clicks", () => { + expect(mouseStrokeOf({ ...noMods, button: 0, altKey: true, metaKey: true })).toBe("Alt+Meta+Button1"); + expect(mouseStrokeOf({ ...noMods, button: 0, shiftKey: true })).toBeNull(); + for (const button of [1, 2]) expect(mouseStrokeOf({ ...noMods, button, metaKey: true })).toBeNull(); + expect(normalizeBinding("Meta+Alt+Button1")).toBe("Alt+Meta+Button1"); + expect(formatBinding("Alt+Meta+Button1", true)).toBe("⌥⌘Left Click"); + expect(formatBinding("Ctrl+Alt+Button1", false)).toBe("Ctrl+Alt+Left 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"); @@ -129,5 +138,26 @@ describe("mouse shortcuts", () => { await Promise.resolve(); expect(back).not.toHaveBeenCalled(); }); + + it("dispatches a bound modified left click outside the editor, but leaves an unbound one alone", async () => { + useKeymap.setState({ overrides: { "navigate.back": ["Alt+Meta+Button1"] } }); + const tree = document.getElementById("tree")!; + const [unbound] = click(tree, 0, { altKey: true }); + expect(unbound!.defaultPrevented).toBe(false); + const [bound] = click(tree, 0, { altKey: true, metaKey: true }); + expect(bound!.defaultPrevented).toBe(true); + await Promise.resolve(); + expect(back).toHaveBeenCalledOnce(); + }); + + it("defers modified left clicks inside the editor to its caret-aware handler", () => { + useKeymap.setState({ overrides: { "navigate.back": ["Alt+Meta+Button1"] } }); + const reached = vi.fn(); + editor.addEventListener("mousedown", reached); + const [down] = click(editor, 0, { altKey: true, metaKey: true }); + expect(down!.defaultPrevented).toBe(false); + expect(reached).toHaveBeenCalledOnce(); + expect(back).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index c9d77b14f..839c98007 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -36,6 +36,7 @@ describe("IDEA keymap parity", () => { ["window.previous", ["Meta+Alt+Shift+`"], ["Ctrl+Alt+["]], ["code.commentBlock", ["Meta+Alt+/", "Ctrl+Shift+/"], ["Ctrl+Shift+/"]], ["navigate.typeDeclaration", ["Meta+Shift+B", "Ctrl+Shift+B"], ["Ctrl+Shift+B"]], + ["navigate.implementation", ["Meta+Alt+B", "Meta+Alt+Button1"], ["Ctrl+Alt+B"]], ["find.next", ["Meta+G"], ["F3", "Ctrl+L"]], ["find.previous", ["Meta+Shift+G"], ["Shift+F3", "Ctrl+Shift+L"]], ["project.copy", ["Meta+C"], ["Ctrl+C", "Ctrl+Insert"]], diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index ae3ad64a1..89fa0b25c 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -19,7 +19,7 @@ export const intellijMacos: Record = { "navigate.forward": ["Meta+]", "Meta+Alt+Right", "Button5"], "navigate.gotoLine": ["Meta+L"], "navigate.declaration": ["Meta+B"], - "navigate.implementation": ["Meta+Alt+B"], + "navigate.implementation": ["Meta+Alt+B", "Meta+Alt+Button1"], "navigate.typeDeclaration": ["Meta+Shift+B", "Ctrl+Shift+B"], "navigate.fileStructure": ["Meta+F12"], "navigate.nextError": ["F2"], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 8ee9f18f5..03710d062 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4736,7 +4736,7 @@ "Sort by Type": "按类型排序", "Sort by Modification Time": "按修改时间排序", "Add Mouse Shortcut": "添加鼠标快捷键", - "Click here with a mouse side button, holding any modifiers": "按住任意修饰键,用鼠标侧键点击此处", + "Click here with Ctrl, Alt or Command held, or use a mouse side button": "按住 Ctrl、Alt 或 Command 在此点击,或使用鼠标侧键", "Mouse shortcut": "鼠标快捷键", "Mouse Shortcut: {action}": "鼠标快捷键:{action}", "Accept Left Side": "接受左侧",