diff --git a/src/features/debugger/__tests__/quickEvaluate.test.ts b/src/features/debugger/__tests__/quickEvaluate.test.ts new file mode 100644 index 00000000..4de18562 --- /dev/null +++ b/src/features/debugger/__tests__/quickEvaluate.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { executeAction, registerAction } from "@/platform/actions"; +import * as hints from "@/editor/lsp/hint"; +import { setContext } from "@/platform/context"; +import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; +import { usePlatform } from "@/platform/platformInfo"; +import { menuGroupItems } from "@/workbench/contributions"; +import { navigationExtension } from "@/editor/lsp/navigation"; +import { activate } from "../index"; +import { adoptSession, publishContext, useDebug } from "../state"; +import { quickEvaluateMouse, quickEvaluateMouseDown } from "../editor/quickEvaluate"; +import { useOverlays } from "../ui/popoverStore"; + +const ipc = vi.hoisted(() => ({ + debugEvaluateIn: vi.fn(async () => ({ + status: "ok", + data: { + name: "result", + value: "42", + typeName: "int", + variablesReference: 0, + evaluateName: null, + }, + })), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, commands: { ...real.commands, ...ipc } }; +}); +const editor = vi.hoisted(() => ({ current: null as unknown })); +vi.mock("@/editor/api", async (original) => ({ + ...(await original()), + activeEditor: () => editor.current, +})); + +let view: EditorView; +let dispose: () => void; +let uninstall: () => void; +const doc = "total + order.items"; +function session(adapter = "debugpy", status: "paused" | "running" | "terminated" = "paused") { + adoptSession(7, { adapter }); + useDebug.setState((s) => ({ sessions: { 7: { ...s.sessions[7]!, status, selectedFrame: 10 } }, active: 7 })); + publishContext(); +} +function click() { + const e = new MouseEvent("mousedown", { button: 0, altKey: true, bubbles: true, cancelable: true }); + view.contentDOM.dispatchEvent(e); + return e; +} +beforeEach(() => { + vi.clearAllMocks(); + useDebug.setState({ sessions: {}, active: null }); + setContext("textEditorActive", true); + usePlatform.setState({ os: "macos" }); + useKeymap.setState({ preset: "intellij-macos", overrides: {} }); + dispose = activate(); + uninstall = installKeyboardDispatcher(window); + view = new EditorView({ + state: EditorState.create({ doc, extensions: [navigationExtension(), quickEvaluateMouse()] }), + parent: document.body, + }); + editor.current = { view, ctx: { path: "/p/app.py" } }; + vi.spyOn(view, "posAtCoords").mockReturnValue(16); +}); +afterEach(() => { + uninstall(); + dispose(); + view.destroy(); + editor.current = null; + document.body.replaceChildren(); + useDebug.setState({ sessions: {}, active: null }); + publishContext(); + useKeymap.setState({ overrides: {} }); + vi.restoreAllMocks(); +}); + +describe("paused editor evaluation", () => { + it.each(["java-debug", "js-debug", "debugpy"])("evaluates the clicked member through %s", async (adapter) => { + session(adapter); + view.dispatch({ selection: { anchor: 0, head: 5 } }); + expect(click().defaultPrevented).toBe(true); + await vi.waitFor(() => expect(document.querySelector(".cm-lsp-hint")?.textContent).toContain("order.items = 42")); + expect(ipc.debugEvaluateIn).toHaveBeenCalledExactlyOnceWith(7, 10, "order.items", "hover"); + expect(view.state.selection.main.head).toBe(16); + }); + + it.each(["intellij-macos", "intellij-windows"])("preserves a clicked selection with %s", async (preset) => { + const showHint = vi.spyOn(hints, "showHint"); + useKeymap.setState({ preset }); + session(); + view.dispatch({ selection: { anchor: 0, head: doc.length } }); + click(); + await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(7, 10, doc, "hover")); + expect(view.state.selection.main.from).toBe(0); + expect(view.state.selection.main.to).toBe(doc.length); + await vi.waitFor(() => + expect(showHint).toHaveBeenCalledWith(view, expect.any(Function), { pos: 16, timeout: 60_000 }), + ); + }); + + it("uses a selected expression spanning several lines", async () => { + session(); + const text = "(total +\n order.items)"; + view.dispatch({ changes: { from: 0, to: doc.length, insert: text }, selection: { anchor: 0, head: text.length } }); + click(); + await vi.waitFor(() => expect(ipc.debugEvaluateIn).toHaveBeenCalledWith(7, 10, text, "hover")); + }); + + it("lets a higher priority custom mouse action win the shared gesture", () => { + session(); + const run = vi.fn(); + const remove = registerAction({ id: "test.mouseOverride", title: "Custom", run }); + useKeymap.setState({ overrides: { "test.mouseOverride": ["Alt+Button1"] } }); + try { + click(); + expect(run).toHaveBeenCalledOnce(); + expect(ipc.debugEvaluateIn).not.toHaveBeenCalled(); + } finally { + remove(); + } + }); + + it.each(["none", "running", "terminated"] as const)("leaves Option+click to the editor when %s", async (status) => { + if (status !== "none") session("debugpy", status); + view.dispatch({ selection: { anchor: 0, head: 5 } }); + const dispatch = vi.spyOn(view, "dispatch"); + const e = new MouseEvent("mousedown", { button: 0, altKey: true, cancelable: true }); + expect(quickEvaluateMouseDown(e, view)).toBe(false); + expect(e.defaultPrevented).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + expect(ipc.debugEvaluateIn).not.toHaveBeenCalled(); + }); + + it("honours removal of the mouse binding", () => { + session(); + useKeymap.setState({ overrides: { "debug.quickEvaluate": ["Meta+Alt+F8"] } }); + click(); + expect(ipc.debugEvaluateIn).not.toHaveBeenCalled(); + }); + + it.each(["none", "running", "paused", "terminated"] as const)( + "shows the contributed menu only while paused: %s", + async (status) => { + if (status !== "none") session("debugpy", status); + const items = menuGroupItems("editor", { paths: ["/p/app.py"], text: doc }); + const evaluate = items.find((item) => item.kind === "action" && item.action === "debug.evaluate"); + expect(!!evaluate).toBe(status === "paused"); + if (evaluate) { + view.dispatch({ selection: { anchor: 16 } }); + await executeAction("debug.evaluate"); + expect(useOverlays.getState().evaluate?.expression).toBe("order.items"); + } + expect(menuGroupItems("editorTab", { paths: ["/p/app.py"] })).toEqual([]); + }, + ); +}); diff --git a/src/features/debugger/actions.ts b/src/features/debugger/actions.ts index 3112affc..235b86be 100644 --- a/src/features/debugger/actions.ts +++ b/src/features/debugger/actions.ts @@ -4,6 +4,7 @@ import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, ty import { downloadJdkActions } from "@/platform/jdk"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; +import type { EditorView } from "@codemirror/view"; import { maybeEditorService, notifications, showStatusInfo, toolWindowService } from "@/workbench/contributions"; import { classFileBreakpointReason } from "@/platform/classFiles"; import { canPlaceBreakpoint } from "./breakpoints/languages"; @@ -39,6 +40,7 @@ import { import { focusedDebugView } from "./ui/focusedView"; import { browseFocusedConsoleHistory } from "./ui/expressionHistory"; import { expressionAt, valueHint } from "./editor/valueTooltip"; +import { mouseEvaluateTarget } from "./editor/quickEvaluate"; import { showHint } from "@/editor/lsp/hint"; import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep"; import { @@ -157,28 +159,29 @@ function cursor(): { path: string; line: number; selection: string } | null { } /** Evaluate's initial text, as in IDEA: the selection, else the expression at the caret. */ -function evaluateSeed(): string { - const ed = activeEditor(); - if (!ed) return ""; - const { state } = ed.view; +function evaluateSeed(view: EditorView | undefined = activeEditor()?.view, pos?: number): string { + if (!view) return ""; + const { state } = view; const sel = state.selection.main; - if (!sel.empty) return state.sliceDoc(sel.from, sel.to); - return expressionAt(ed.view, sel.head)?.text ?? ""; + if (!sel.empty && (pos == null || (sel.from <= pos && pos <= sel.to))) return state.sliceDoc(sel.from, sel.to); + return expressionAt(view, pos ?? sel.head)?.text ?? ""; } -async function quickEvaluate() { - const view = activeEditor()?.view; +async function quickEvaluate(arg?: unknown) { + const target = mouseEvaluateTarget(arg); + const view = target?.view ?? activeEditor()?.view; if (!view) return; const sel = view.state.selection.main; - const expression = evaluateSeed().trim(); + const pos = target?.pos ?? sel.from; + const expression = evaluateSeed(view, target?.pos).trim(); if (!expression) return showHint(view, "Nothing to evaluate at the caret"); const session = activeSession(); try { const value = await evaluate(expression, "hover", session); if (!session) return; - showHint(view, () => valueHint(expression, value, session.id), { pos: sel.from, timeout: 60_000 }); + showHint(view, () => valueHint(expression, value, session.id), { pos, timeout: 60_000 }); } catch (e) { - showHint(view, e instanceof Error ? e.message : String(e), { pos: sel.from, tone: "error" }); + showHint(view, e instanceof Error ? e.message : String(e), { pos, tone: "error" }); } } @@ -677,8 +680,8 @@ export function registerDebugActions(): () => void { id: "debug.quickEvaluate", title: "Quick Evaluate Expression", category: "Debug", - when: "debugSessionActive && textEditorActive", - run: () => quickEvaluate(), + when: "debugSessionPaused && textEditorActive", + run: ({ arg }) => quickEvaluate(arg), }, { id: "debug.showToolWindow", diff --git a/src/features/debugger/editor/quickEvaluate.ts b/src/features/debugger/editor/quickEvaluate.ts new file mode 100644 index 00000000..76f94df1 --- /dev/null +++ b/src/features/debugger/editor/quickEvaluate.ts @@ -0,0 +1,34 @@ +import { EditorSelection, Prec, type Extension } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { dispatchMouseShortcut } from "@/platform/keymap"; + +export function mouseEvaluateTarget(arg: unknown): { view: EditorView; pos: number } | null { + if (!arg || typeof arg !== "object" || !("view" in arg) || !("pos" in arg)) return null; + return arg.view instanceof EditorView && typeof arg.pos === "number" ? { view: arg.view, pos: arg.pos } : null; +} + +/** Preserve a clicked selection and anchor the value hint at the mouse position. */ +export function quickEvaluateMouse(): Extension { + return Prec.highest(EditorView.domEventHandlers({ mousedown: quickEvaluateMouseDown })); +} + +export function quickEvaluateMouseDown(e: MouseEvent, view: EditorView): boolean { + if (e.button !== 0) return false; + const over = e.target instanceof Element ? e.target : null; + if (over?.closest(".cm-tooltip, .cm-lsp-inlay") || (over && !over.closest(".cm-content"))) return false; + const pos = view.posAtCoords({ x: e.clientX, y: e.clientY }, false); + if (pos == null) return false; + const handled = dispatchMouseShortcut( + e, + (id) => { + if (id !== "debug.quickEvaluate") return false; + const sel = view.state.selection.main; + if (sel.empty || pos < sel.from || pos > sel.to) view.dispatch({ selection: EditorSelection.cursor(pos) }); + view.focus(); + return true; + }, + { view, pos }, + ); + if (handled) e.preventDefault(); + return handled; +} diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index fa5db45f..5c606dfa 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -7,6 +7,7 @@ import { lazyComponent } from "@/ui"; import { onIdeEvent } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { selectionValueTooltip, valueTooltip } from "./editor/valueTooltip"; +import { quickEvaluateMouse } from "./editor/quickEvaluate"; import { registerEditorContribution } from "@/editor/api"; import { registerMenuGroup, @@ -24,7 +25,16 @@ import { executionPoint } from "./editor/execution"; import { runToCursorInlay } from "./editor/runToCursor"; import { installSmartStepPicking, smartStepTargets } from "./editor/smartStep"; import { onHotCodeReplace } from "./hotswap"; -import { handleDebugEvent, isLive, loadWatches, publishContext, resetSessions, stopSession, useDebug } from "./state"; +import { + activeSession, + handleDebugEvent, + isLive, + loadWatches, + publishContext, + resetSessions, + stopSession, + useDebug, +} from "./state"; import { DebuggerOverlays } from "./ui/Overlays"; import { REMOTE_TYPES } from "./remote"; @@ -53,6 +63,11 @@ export function activate(): () => void { headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)), }), registerOverlay({ id: "debugger", component: DebuggerOverlays }), + registerMenuGroup({ + id: "debugger.evaluate", + menus: ["editor"], + items: () => (activeSession()?.status === "paused" ? [{ kind: "action", action: "debug.evaluate" }] : []), + }), // Stop (⌘F2) offers the running debug sessions beside run processes. registerMenuGroup({ id: "debugger.stop", @@ -71,6 +86,7 @@ export function activate(): () => void { runToCursorInlay(ctx), valueTooltip(), selectionValueTooltip(), + quickEvaluateMouse(), smartStepTargets(ctx), ], }), diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts index d236c023..a99bd83c 100644 --- a/src/platform/keymap.ts +++ b/src/platform/keymap.ts @@ -407,15 +407,20 @@ function runIfEnabled(id: string): boolean { } /** Run a configured mouse binding; editors prepare the clicked caret and focus first. */ -export function dispatchMouseShortcut(e: MouseEvent, prepare?: () => boolean): boolean { +export function dispatchMouseShortcut(e: MouseEvent, prepare?: (id: string) => boolean, arg?: unknown): 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; + for (const id of ids.reverse()) { + const action = getAction(id); + if (!action || !isActionKeyEnabled(action)) continue; + if (prepare && !prepare(id)) return false; + void executeAction(id, arg); + return true; + } return false; } diff --git a/src/platform/keymapMouse.test.ts b/src/platform/keymapMouse.test.ts index 38d4e659..ddb4b080 100644 --- a/src/platform/keymapMouse.test.ts +++ b/src/platform/keymapMouse.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatBinding, + dispatchMouseShortcut, installKeyboardDispatcher, isMouseBinding, isValidBinding, @@ -13,6 +14,7 @@ import { useKeymap, } from "./keymap"; import { registerAction } from "./actions"; +import { setContext } from "./context"; const noMods = { metaKey: false, ctrlKey: false, altKey: false, shiftKey: false }; @@ -84,6 +86,23 @@ describe("mouse shortcuts", () => { return events; }; + it("does not prepare the editor for a disabled mouse action", () => { + const run = vi.fn(); + const prepare = vi.fn(() => true); + const remove = registerAction({ id: "test.pausedMouse", title: "Evaluate", when: "debugSessionPaused", run }); + useKeymap.setState({ overrides: { "test.pausedMouse": ["Alt+Button1"] } }); + setContext("debugSessionPaused", false); + const e = new MouseEvent("mousedown", { button: 0, altKey: true, cancelable: true }); + try { + expect(dispatchMouseShortcut(e, prepare)).toBe(false); + expect(prepare).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + expect(e.defaultPrevented).toBe(false); + } finally { + remove(); + } + }); + it.each([ ["the editor", () => editor], ["another component", () => document.getElementById("tree")!], diff --git a/src/platform/keymaps/ideaParity.test.ts b/src/platform/keymaps/ideaParity.test.ts index 9cff980b..31a77539 100644 --- a/src/platform/keymaps/ideaParity.test.ts +++ b/src/platform/keymaps/ideaParity.test.ts @@ -69,7 +69,7 @@ describe("IDEA keymap parity", () => { ["navigate.nextHighlightedUsage", ["Ctrl+Alt+Down"], ["Ctrl+Alt+Down"]], ["analyze.inspectFile", ["Alt+Shift+I"], ["Alt+Shift+I"]], ["project.copyElement", ["F5"], ["F5"]], - ["debug.quickEvaluate", ["Meta+Alt+F8"], ["Ctrl+Alt+F8"]], + ["debug.quickEvaluate", ["Meta+Alt+F8", "Alt+Button1"], ["Ctrl+Alt+F8", "Alt+Button1"]], ["run.chooseDebugConfig", ["Ctrl+Alt+D"], ["Alt+Shift+F9"]], ["editor.nextSplitter", ["Alt+Tab"], ["Alt+Tab"]], ["toolwindow.resizeLeft", ["Ctrl+Alt+Left"], ["Ctrl+Alt+Shift+Left"]], diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 8df2664a..2351a26b 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -284,7 +284,7 @@ export const intellijMacos: Record = { "debug.setValue": ["F2"], "debug.jumpToSource": ["F4", "Meta+Down"], "debug.evaluate": ["Alt+F8"], - "debug.quickEvaluate": ["Meta+Alt+F8"], + "debug.quickEvaluate": ["Meta+Alt+F8", "Alt+Button1"], "debug.browseConsoleHistory": ["Meta+Alt+E"], // VCS diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 64bb4ffb..951641d2 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -278,7 +278,7 @@ export const intellijWindows: Record = { "debug.setValue": ["F2"], "debug.jumpToSource": ["F4"], "debug.evaluate": ["Alt+F8"], - "debug.quickEvaluate": ["Ctrl+Alt+F8"], + "debug.quickEvaluate": ["Ctrl+Alt+F8", "Alt+Button1"], "debug.browseConsoleHistory": ["Ctrl+Alt+E"], // VCS