mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(debugger): add paused editor evaluation entry points (#676)
While a debug session is paused, Alt/Option+click on a variable or expression runs Quick Evaluate at the clicked position (the selection when the click falls inside it) and shows the value next to it; outside a paused session Alt+click keeps its editor behaviour. The editor context menu offers Evaluate Expression… seeded with the selection or the expression at the caret while paused. Mouse shortcuts now pass the clicked editor and position to the action, and the binding is only consumed when its action is enabled. Fixes #664
This commit is contained in:
@@ -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<typeof import("@/platform/ipc")>();
|
||||
return { ...real, commands: { ...real.commands, ...ipc } };
|
||||
});
|
||||
const editor = vi.hoisted(() => ({ current: null as unknown }));
|
||||
vi.mock("@/editor/api", async (original) => ({
|
||||
...(await original<typeof import("@/editor/api")>()),
|
||||
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([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")!],
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -284,7 +284,7 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"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
|
||||
|
||||
@@ -278,7 +278,7 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user