Compare commits

...
Author SHA1 Message Date
l0ng-ai ac6c6b2047 feat(debugger): add paused editor evaluation entry points 2026-11-01 17:14:10 +08:00
9 changed files with 255 additions and 20 deletions

No files matched your search

@@ -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([]);
},
);
});
+16 -13
View File
@@ -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;
}
+17 -1
View File
@@ -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),
],
}),
+8 -3
View File
@@ -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;
}
+19
View File
@@ -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")!],
+1 -1
View File
@@ -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"]],
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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