/** Debugger actions (ids match `keymaps/intellij-macos.ts`). */ import { registerActions, type Action } from "@/platform/actions"; import { call, commands, isTauri, type RunConfig, type RunId } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { activeEditor } from "@/editor/api"; import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions"; import { canPlaceBreakpoint } from "./breakpoints/languages"; import { removeAllBreakpoints, setMuted, toggleBreakpoint, toggleTemporaryBreakpoint, useBreakpoints, } from "./breakpoints/store"; import { removeAllSpecial, specialAt, toggleFieldAt, toggleMethodAt } from "./breakpoints/special"; import { fieldAt, methodAt } from "./breakpoints/symbols"; import { toggleBreakpointSmart } from "./breakpoints/toggle"; import { isJavaAttachTarget, isJavaDebuggable, javaAttachConfig, javaLaunchConfig } from "./java"; import { reloadChangedClasses } from "./hotswap"; import { debugUnsupportedReason, launchConfigFor } from "./launch"; import { activeSession, isLive, rerunSession, revealFrame, rootOf, sessionSupports, showExecutionPoint, startSession, stopSession, useDebug, type DebugSession, } from "./state"; import { focusedDebugView } from "./ui/focusedView"; import { expressionAt } from "./editor/valueTooltip"; import { pausedLineTargets, startInlineSmartStep } from "./editor/smartStep"; import { openBreakpointsDialog, openConfigChooser, openEvaluateDialog, openSmartStepChooser, useOverlays, } from "./ui/popoverStore"; function isRunConfig(arg: unknown): arg is RunConfig { return !!arg && typeof arg === "object" && "command" in arg && "args" in arg && "debugAdapter" in arg; } function errorText(e: unknown) { return e instanceof Error ? e.message : String(e); } async function report(title: string, p: Promise) { try { await p; } catch (e) { notifications().notify({ group: "run", level: "error", title, body: errorText(e), key: "debug.action" }); } } /** The paused session and the thread to act on. */ function pausedTarget(): { s: DebugSession; thread: number } | null { const s = activeSession(); if (!s || s.status !== "paused") return null; const thread = s.selectedThread ?? s.stoppedThread ?? s.threads[0]?.id; return thread == null ? null : { s, thread }; } function notSupported(what: string, s: DebugSession) { notifications().notify({ group: "run", level: "info", key: "debug.unsupported", title: `${what} is not supported by ${s.adapter || "this debugger"}`, }); } /** The selected frame of the paused session (top frame when none is selected). */ function selectedFrame(s: DebugSession) { return s.frames.find((f) => f.id === s.selectedFrame) ?? s.frames[0] ?? null; } /** * Smart Step Into: the calls on the current line, picked in the editor (the * dialog when they cannot be placed); one call (or none) steps straight in, like IDEA. */ async function smartStepInto() { const t = pausedTarget(); if (!t) return; const frame = selectedFrame(t.s); if (!frame || !sessionSupports("stepInTargets", t.s)) { await call(commands.debugStep(t.s.id, t.thread, "into")); return; } const targets = pausedLineTargets(await call(commands.debugStepInTargets(t.s.id, frame.id))); if (targets.length <= 1) { await call( targets[0] ? commands.debugStepInTarget(t.s.id, t.thread, targets[0].id) : commands.debugStep(t.s.id, t.thread, "into"), ); return; } if (!startInlineSmartStep(t.s.id, t.thread, targets)) openSmartStepChooser(t.s.id, t.thread, targets); } /** Opens the Attach to Process popup and loads the process list. */ export async function openAttachPopup() { if (!isTauri()) { notifications().notify({ group: "run", level: "warning", title: "Attaching needs the desktop app" }); return; } useOverlays.setState({ attach: { processes: null, error: null } }); try { const processes = await call(commands.debugAttachableProcesses()); if (useOverlays.getState().attach) useOverlays.setState({ attach: { processes, error: null } }); } catch (e) { if (useOverlays.getState().attach) useOverlays.setState({ attach: { processes: [], error: errorText(e) || "Cannot list processes" } }); } } function cursor(): { path: string; line: number; selection: string } | null { const ed = activeEditor(); if (!ed) return null; const { state } = ed.view; const main = state.selection.main; return { path: ed.ctx.path, line: state.doc.lineAt(main.head).number - 1, selection: state.sliceDoc(main.from, main.to), }; } /** 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; const sel = state.selection.main; if (!sel.empty) return state.sliceDoc(sel.from, sel.to); return expressionAt(ed.view, sel.head)?.text ?? ""; } async function startConfig(arg: unknown) { const root = useProject.getState().project?.root; if (!root) return; let config: RunConfig | null = isRunConfig(arg) ? arg : null; if (!config) { if (!isTauri()) { notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" }); return; } const configs = (await call(commands.runConfigs())).filter((c) => c.debugAdapter); if (!configs.length) { notifications().notify({ group: "run", level: "info", title: "Nothing to debug", body: "No run configuration in this project has a debug adapter.", }); return; } if (configs.length > 1) { openConfigChooser(configs); return; } config = configs[0]!; } if (isJavaDebuggable(config)) { await maybeEditorService() ?.saveAll() .catch(() => undefined); await startSession(await javaLaunchConfig(config, root)); return; } const launch = launchConfigFor(config, root); if (!launch) { notifications().notify({ group: "run", level: "warning", title: `“${config.name}” can't be debugged`, body: debugUnsupportedReason(config) ?? "It has no debug adapter.", }); return; } await maybeEditorService() ?.saveAll() .catch(() => undefined); await startSession(launch); } export function registerDebugActions(): () => void { const paused = "debugSessionPaused"; const step = (id: string, title: string, kind: "over" | "into" | "out", icon: string): Action => ({ id, title, category: "Debug", icon, when: paused, run: () => { const t = pausedTarget(); if (t) void report(title, call(commands.debugStep(t.s.id, t.thread, kind))); }, }); return registerActions([ { id: "debug.startConfig", title: "Debug…", category: "Debug", icon: "debug", description: "Start debugging a run configuration", run: ({ arg }) => report("Cannot start debugging", startConfig(arg)), }, { id: "debug.attach", title: "Attach Debugger", category: "Debug", hidden: true, description: "Attach the Java debugger to a JVM listening for JDWP (arg: { name, module, hostName, port, process })", run: ({ arg }) => report( "Cannot attach the debugger", (async () => { const root = useProject.getState().project?.root; if (!root || !isJavaAttachTarget(arg)) return; const launch = await javaAttachConfig(arg, root); await startSession(launch, { process: (arg.process ?? null) as RunId | null, rerunnable: false }); })(), ), }, { id: "debug.stop", title: "Stop", category: "Debug", icon: "stop", when: "debugSessionActive", run: () => { const s = activeSession() ?? Object.values(useDebug.getState().sessions).find(isLive); if (s) void stopSession(s.id); }, }, { id: "debug.reloadChangedClasses", title: "Reload Changed Classes", description: "HotSwap: compile and reload the changed classes into the running Java debug session", category: "Debug", icon: "refresh", keywords: ["hotswap", "hot swap", "hot code replace"], when: "debugCanHotSwap", run: () => reloadChangedClasses(), }, { id: "debug.rerun", title: "Rerun", category: "Debug", icon: "rerun", when: "debugCanRerun", // ⌘R / Ctrl+F5 rerun only in the Debug tool window (⌘R is Replace in the editor). keyWhen: "activeToolWindow == 'debug'", run: () => { const s = activeSession(); if (s) void rerunSession(rootOf(s).id); }, }, { id: "debug.resume", title: "Resume Program", category: "Debug", icon: "resume", when: paused, run: () => { const t = pausedTarget(); if (t) void report("Resume failed", call(commands.debugResume(t.s.id, t.thread))); }, }, { id: "debug.pause", title: "Pause Program", category: "Debug", icon: "pause", when: "debugSessionActive && !debugSessionPaused", run: async () => { const s = activeSession(); if (!s || !isLive(s)) return; let thread = s.selectedThread ?? s.threads[0]?.id; if (thread == null) { const threads = await call(commands.debugThreads(s.id)).catch(() => []); thread = threads[0]?.id ?? 0; } await report("Pause failed", call(commands.debugPause(s.id, thread))); }, }, step("debug.stepOver", "Step Over", "over", "step-over"), step("debug.stepInto", "Step Into", "into", "step-into"), step("debug.stepOut", "Step Out", "out", "step-out"), { id: "debug.forceStepOver", title: "Force Step Over", category: "Debug", icon: "force-step-over", description: "Step over, ignoring breakpoints in the called code", when: paused, run: () => { const t = pausedTarget(); if (t) void report("Force Step Over failed", call(commands.debugForceStepOver(t.s.id, t.thread))); }, }, { // DAP has no "ignore stepping filters" flag: adapters step into what their // launch options (justMyCode, skipFiles, stepFilters) allow. id: "debug.forceStepInto", title: "Force Step Into", category: "Debug", icon: "force-step-into", when: paused, run: () => { const t = pausedTarget(); if (t) void report("Force Step Into failed", call(commands.debugStep(t.s.id, t.thread, "into"))); }, }, { id: "debug.smartStepInto", title: "Smart Step Into", category: "Debug", icon: "smart-step-into", description: "Choose which call on the current line to step into", when: paused, run: () => void report("Smart Step Into failed", smartStepInto()), }, { id: "debug.showExecutionPoint", title: "Show Execution Point", category: "Debug", icon: "show-execution-point", when: paused, run: () => { const s = activeSession(); if (s) void showExecutionPoint(s.id); }, }, { id: "debug.resetFrame", title: "Reset Frame", category: "Debug", icon: "reset-frame", keywords: ["drop frame", "pop frame", "restart frame"], description: "Return to the start of the selected frame's method", when: paused, run: () => { const t = pausedTarget(); const frame = t && selectedFrame(t.s); if (!t || !frame) return; if (!sessionSupports("restartFrame", t.s)) { notSupported("Reset Frame", t.s); return; } void report("Reset Frame failed", call(commands.debugRestartFrame(t.s.id, frame.id))); }, }, { id: "debug.setValue", title: "Set Value…", category: "Debug", icon: "edit", keyWhen: "debugVariablesFocus", when: paused, run: () => focusedDebugView()?.setValue?.(), }, { id: "debug.jumpToSource", title: "Jump to Source", category: "Debug", keyWhen: "debugFramesFocus || debugVariablesFocus", when: paused, run: () => { const view = focusedDebugView(); if (view?.jumpToSource) { view.jumpToSource(); return; } const s = activeSession(); const frame = s && selectedFrame(s); if (frame) void revealFrame(frame, true); }, }, { id: "debug.attachToProcess", title: "Attach to Process…", category: "Debug", icon: "attach-to-process", description: "Attach the debugger to a running Java, Node.js, Python, Go or native process", run: () => void openAttachPopup(), }, { id: "debug.runToCursor", title: "Run to Cursor", category: "Debug", icon: "run-to-cursor", when: paused, run: () => { const t = pausedTarget(); const c = cursor(); if (t && c) void report("Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, false))); }, }, { id: "debug.forceRunToCursor", title: "Force Run to Cursor", category: "Debug", icon: "force-run-to-cursor", description: "Run to the caret, ignoring breakpoints on the way", when: paused, run: () => { const t = pausedTarget(); const c = cursor(); if (t && c) void report("Force Run to Cursor failed", call(commands.debugRunTo(t.s.id, t.thread, c.path, c.line, true))); }, }, { id: "debug.toggleBreakpoint", title: "Toggle Line Breakpoint", category: "Debug", icon: "breakpoint", run: () => { const c = cursor(); if (!c) return; const exists = useBreakpoints.getState().byFile[c.path]?.some((b) => b.line === c.line); const language = activeEditor()?.ctx.language ?? ""; if (!exists && !canPlaceBreakpoint(language)) { notifications().notify({ level: "info", key: "debug.breakpointNotAllowed", title: "Breakpoints can't be placed in this file", body: "Enable Settings › Build, Execution, Deployment › Debugger › Allow placing breakpoints in any file.", }); return; } const ed = activeEditor(); if (ed) toggleBreakpointSmart(c.path, c.line, language, ed.view.state.doc.toString()); else toggleBreakpoint(c.path, c.line); }, }, { id: "debug.toggleTemporaryBreakpoint", title: "Toggle Temporary Line Breakpoint", category: "Debug", icon: "breakpoint", description: "A line breakpoint that is removed after it is hit", run: () => { const c = cursor(); if (c && canPlaceBreakpoint(activeEditor()?.ctx.language ?? "")) toggleTemporaryBreakpoint(c.path, c.line); }, }, { id: "debug.toggleMethodBreakpoint", title: "Toggle Method Breakpoint", category: "Debug", icon: "breakpoint-method", run: () => { const ed = activeEditor(); const c = cursor(); if (!ed || !c) return; const existing = specialAt(c.path).find((b) => b.kind === "method" && b.line === c.line); const method = methodAt(ed.ctx.language, ed.view.state.doc.toString(), c.line); if (!existing && !method) { notifications().notify({ level: "info", key: "debug.noMethod", title: "No method declaration on this line", body: "Put the caret on a method declaration, or add a method breakpoint by name in View Breakpoints.", }); return; } toggleMethodAt(c.path, c.line, method?.name ?? ""); }, }, { id: "debug.toggleFieldWatchpoint", title: "Toggle Field Watchpoint", category: "Debug", icon: "breakpoint-field", run: () => { const ed = activeEditor(); const c = cursor(); if (!ed || !c) return; const existing = specialAt(c.path).find((b) => b.kind === "field" && b.line === c.line); const field = fieldAt(ed.ctx.language, ed.view.state.doc.toString(), c.line); if (!existing && !field) { notifications().notify({ level: "info", key: "debug.noField", title: "No field declaration on this line", body: "Field watchpoints can be set on Java field declarations, or from the Variables view while suspended.", }); return; } toggleFieldAt(c.path, c.line, field ?? { dataId: "", label: "" }); }, }, { id: "debug.viewBreakpoints", title: "View Breakpoints…", category: "Debug", icon: "breakpoint", run: () => { const c = cursor(); const bp = c ? useBreakpoints.getState().byFile[c.path]?.find((b) => b.line === c.line) : undefined; openBreakpointsDialog(c && bp ? { path: c.path, id: bp.id } : undefined); }, }, { id: "debug.muteBreakpoints", title: "Mute Breakpoints", category: "Debug", keywords: ["unmute", "disable all"], toggled: () => useBreakpoints.getState().muted, run: () => setMuted(!useBreakpoints.getState().muted), }, { id: "debug.removeAllBreakpoints", title: "Remove All Breakpoints", category: "Debug", run: () => { removeAllBreakpoints(); removeAllSpecial(); }, }, { id: "debug.evaluate", title: "Evaluate Expression…", category: "Debug", when: "debugSessionActive", run: () => openEvaluateDialog(evaluateSeed()), }, { id: "debug.showToolWindow", title: "Show Debug Tool Window", category: "Debug", hidden: true, run: () => toolWindowService().show("debug", true), }, ]); }