/** * Debug sessions as the UI sees them, fed by `IdeEvent::Debug` and by pulls * (threads, frames, locals) after each stop. */ import { tx } from "@/platform/i18n"; import { workflowSettings } from "@/platform/workflowSettings"; import { executeAction } from "@/platform/actions"; import { create } from "zustand"; import { AnsiStream, appendSpans, type Span } from "@/ui/ansi"; import { setContext } from "@/platform/context"; import { call, commands, isTauri, isCancelled, type DebugCapabilities, type DebugEvent, type DebugSessionId, type LaunchConfig, type RunId, type RunConfig, type StackFrame, type ThreadInfo, type Variable, } from "@/platform/ipc"; import { confirmTerminate } from "@/workbench/confirmTerminate"; import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions"; import { breakpointsOf, consumeTemporary, flushBreakpoints, refreshStates, resetStatuses } from "./breakpoints/store"; import { interpolateLog, logParts, suspends } from "./breakpoints/model"; import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special"; import { refreshExceptionFilters } from "./breakpoints/exceptions"; import { missingAdapter } from "./missingAdapter"; export type SessionStatus = "starting" | "running" | "paused" | "terminated"; export interface DebugSession { id: DebugSessionId; name: string; /** Originating configuration, retained for toolbar targeting and prepared debug reruns. */ runConfig?: RunConfig; startedAt?: number; /** When it terminated. */ endedAt?: number; adapter: string; parent: DebugSessionId | null; /** Launch configuration (root sessions), used by Rerun. */ launch: LaunchConfig | null; /** Process the session is attached to (a Run tab's build tool); stopped with the session. */ process: RunId | null; status: SessionStatus; stoppedThread: number | null; stopReason: string | null; stopDescription: string | null; threads: ThreadInfo[]; selectedThread: number | null; frames: StackFrame[]; selectedFrame: number | null; /** Breakpoint hits in this session. */ hits: number; exitCode: number | null; /** What the adapter supports (fetched after start). */ caps: DebugCapabilities | null; } export interface ConsoleLine { id: number; category: string; /** Plain text without escape sequences. */ text: string; /** `text` styled by its ANSI colours. */ spans: Span[]; } interface DebugState { sessions: Record; /** Session shown in the Debug tool window / title bar. */ active: DebugSessionId | null; console: Record; watches: string[]; /** Incremented on every stop; views re-pull data when it changes. */ stopSeq: number; } export const useDebug = create(() => ({ sessions: {}, active: null, console: {}, watches: [], stopSeq: 0, })); /** Execution point of the selected frame, plus the frame's locals for inline values. */ interface ExecutionPoint { path: string | null; line: number; /** The selected frame is the top frame (current position, not a caller). */ top: boolean; values: Record; } export const useExecutionPoint = create(() => ({ path: null, line: 0, top: true, values: {} })); /** Console buffer: at most this many physical lines per session. */ const MAX_CONSOLE_LINES = 50_000; let lineSeq = 0; /** ANSI state per session and output category (a colour can span several output events). */ const ansiStreams = new Map>(); /** Streamed output buffered until the next animation frame (one setState per frame). */ interface PendingOutput { session: DebugSessionId; category: string; raw: string; } const pendingOutput: PendingOutput[] = []; let outputFlushHandle: { raf: number; timeout: number } | null = null; function cancelOutputFlush() { if (!outputFlushHandle) return; cancelAnimationFrame(outputFlushHandle.raf); clearTimeout(outputFlushHandle.timeout); outputFlushHandle = null; } function ansiStream(session: DebugSessionId, category: string): AnsiStream { let streams = ansiStreams.get(session); if (!streams) ansiStreams.set(session, (streams = new Map())); let stream = streams.get(category); if (!stream) streams.set(category, (stream = new AnsiStream())); return stream; } // --- accessors --------------------------------------------------------------- export function activeSession(): DebugSession | null { const { active, sessions } = useDebug.getState(); return active != null ? (sessions[active] ?? null) : null; } export function isLive(s: DebugSession | null | undefined): boolean { return !!s && s.status !== "terminated"; } /** Root (user-started) session of `s`. */ export function rootOf(s: DebugSession): DebugSession { const { sessions } = useDebug.getState(); let cur = s; while (cur.parent != null && sessions[cur.parent]) cur = sessions[cur.parent]!; return cur; } function patch(id: DebugSessionId, p: Partial) { useDebug.setState((s) => { const cur = s.sessions[id]; if (!cur) return s; return { sessions: { ...s.sessions, [id]: { ...cur, ...p } } }; }); publishContext(); } function blank(id: DebugSessionId, p: Partial = {}): DebugSession { return { id, name: "Debug", startedAt: Date.now(), adapter: "", parent: null, launch: null, process: null, status: "starting", stoppedThread: null, stopReason: null, stopDescription: null, threads: [], selectedThread: null, frames: [], selectedFrame: null, hits: 0, exitCode: null, caps: null, ...p, }; } // Sessions this window owns. The backend sends a session's events only to the window // that started it; as a second line of defence, events of other sessions are dropped. /** `debugStart` calls in flight: their root session's id is not known yet. */ let pendingStarts = 0; /** Sessions adopted while a start was in flight, not yet confirmed by its result. */ const provisional = new Set(); /** Every session id this window has owned (late events of dropped sessions are ignored). */ const owned = new Set(); /** Registers a session as owned by this window (idempotent). */ export function adoptSession(id: DebugSessionId, p: Partial = {}): DebugSession { const existing = useDebug.getState().sessions[id]; if (existing) return existing; owned.add(id); const s = blank(id, { status: "running", ...p }); void loadCapabilities(id); useDebug.setState((st) => ({ sessions: { ...st.sessions, [id]: s }, active: st.active == null || !isLive(st.sessions[st.active]) ? id : st.active, })); publishContext(); return s; } /** * The session an event belongs to, adopting it when it is this window's: a child * (announced by `started` with a parent) of one of its sessions, or the root * session of a start still in flight here (its build output and `started` arrive * before `debugStart` returns the id). Anything else is not ours. */ function ownSession(id: DebugSessionId, event: DebugEvent): DebugSession | null { const st = useDebug.getState(); const existing = st.sessions[id]; if (existing) return existing; if (owned.has(id)) return null; if (event.kind === "started" && event.parent != null) { if (!st.sessions[event.parent]) return null; return adoptSession(id, { name: event.name, adapter: event.adapter, parent: event.parent }); } if (pendingStarts === 0) return null; provisional.add(id); return event.kind === "started" ? adoptSession(id, { name: event.name, adapter: event.adapter }) : adoptSession(id); } /** * A start finished (`id` on success): sessions no start claimed are failed starts' leftovers, * kept as terminated or, with `discard`, removed. */ function settleStart(id: DebugSessionId | null, discard = false) { pendingStarts = Math.max(0, pendingStarts - 1); if (id != null) provisional.delete(id); if (pendingStarts > 0) return; const leftovers = [...provisional]; provisional.clear(); if (discard) { useDebug.setState((st) => { const sessions = { ...st.sessions }; const console = { ...st.console }; for (const s of leftovers) { delete sessions[s]; delete console[s]; ansiStreams.delete(s); } return { sessions, console, active: st.active != null && sessions[st.active] ? st.active : null }; }); publishContext(); return; } for (const s of leftovers) if (isLive(useDebug.getState().sessions[s])) markTerminated(s); } /** Drops finished sessions; running ones stay (multiple concurrent sessions are fine). */ function dropFinished() { useDebug.setState((s) => { const sessions = Object.fromEntries(Object.entries(s.sessions).filter(([, v]) => isLive(v))); const consoleLines = Object.fromEntries(Object.entries(s.console).filter(([k]) => sessions[+k])); for (const id of ansiStreams.keys()) if (!sessions[id]) ansiStreams.delete(id); return { sessions, console: consoleLines, active: s.active != null && sessions[s.active] ? s.active : null }; }); publishContext(); } /** * A JDWP agent in server mode listens again, on a new port, when its debugger detaches; a * debugger that detaches as the JVM exits leaves an announcement of a JVM about to be gone. * An attach to `process` this soon after a session on it ended may be to such a JVM. */ const RELISTEN_MS = 3000; function justDetached(process: RunId | null | undefined): boolean { if (process == null) return false; const now = Date.now(); return Object.values(useDebug.getState().sessions).some( (s) => s.process === process && s.status === "terminated" && s.endedAt != null && now - s.endedAt < RELISTEN_MS, ); } export function publishContext() { const s = activeSession(); const retained = Object.values(useDebug.getState().sessions); useDebugRunSessions.setState({ serviceSessions: retained.flatMap((session) => { if (session.parent != null || !session.runConfig) return []; const paused = isLive(session) && retained.some((child) => child.status === "paused" && rootOf(child).id === session.id); return [ { id: session.id, config: session.runConfig, startedAt: session.startedAt ?? 0, status: paused ? ("paused" as const) : session.status, processId: session.process, rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null, stop: (options?: { stopProcess?: boolean }) => stopSession(session.id, options), show: () => { if (!useDebug.getState().sessions[session.id]) return; selectSession(session.id); toolWindowService().show("debug", true); }, }, ]; }), sessions: Object.values(useDebug.getState().sessions) .filter((session) => session.parent == null && isLive(session)) .map((session) => { const paused = Object.values(useDebug.getState().sessions).find( (child) => child.status === "paused" && rootOf(child).id === session.id, ); return { id: session.id, configId: session.runConfig?.id ?? null, name: session.runConfig?.name ?? session.name, startedAt: session.startedAt ?? 0, paused: !!paused, rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null, stop: () => void stopSession(session.id), resume: () => { selectSession(paused?.id ?? session.id); void executeAction("debug.resume"); }, }; }), }); const anyLive = Object.values(useDebug.getState().sessions).some(isLive); setContext("debugSessionActive", anyLive); setContext("debugSessionRunning", s?.status === "running"); setContext("debugSessionPaused", s?.status === "paused"); // Dart and Flutter sessions: Hot Reload / Hot Restart. setContext("debugSessionDart", !!s && rootOf(s).adapter === "dart"); setContext("debugCanRerun", !!s && !!(rootOf(s).runConfig || rootOf(s).launch)); setContext( "debugCanHotSwap", Object.values(useDebug.getState().sessions).some((x) => isLive(x) && !!x.caps?.hotCodeReplace), ); } // --- console ----------------------------------------------------------------- export function appendConsole(session: DebugSessionId, category: string, raw: string) { if (!raw) return; flushConsoleOutput(); const spans = ansiStream(session, category).parse(raw); const text = spans.map((sp) => sp.text).join(""); if (!text) return; useDebug.setState((s) => { const prev = s.console[session] ?? []; const next = [...prev]; appendConsoleLines(next, category, spans); trimConsole(next); return { console: { ...s.console, [session]: next } }; }); } /** * Queues streamed debuggee output: ANSI parsing and the console store update happen * once per animation frame (or timer while unfocused), with pending chunks merged per * (session, category), so a flood of `output` events can't saturate React. */ export function appendConsoleQueued(session: DebugSessionId, category: string, raw: string) { if (!raw) return; const last = pendingOutput[pendingOutput.length - 1]; if (last && last.session === session && last.category === category) { last.raw += raw; } else { pendingOutput.push({ session, category, raw }); } if (outputFlushHandle === null) { outputFlushHandle = { raf: requestAnimationFrame(flushConsoleOutput), timeout: window.setTimeout(flushConsoleOutput, 100), }; } } function flushConsoleOutput() { cancelOutputFlush(); if (pendingOutput.length === 0) return; const chunks = pendingOutput.splice(0); useDebug.setState((s) => { const consoleBySession = { ...s.console }; for (const { session, category, raw } of chunks) { if (!s.sessions[session]) continue; const spans = ansiStream(session, category).parse(raw); const text = spans.map((sp) => sp.text).join(""); if (!text) continue; const prev = consoleBySession[session] ?? []; const next = [...prev]; appendConsoleLines(next, category, spans); trimConsole(next); consoleBySession[session] = next; } return { console: consoleBySession }; }); } /** Split styled output at real line breaks, retaining partial lines across events. */ function appendConsoleLines(lines: ConsoleLine[], category: string, spans: Span[]) { const last = lines.at(-1); let line = last && last.category === category && !last.text.endsWith("\n") ? { ...last, spans: [...last.spans] } : undefined; if (line) lines[lines.length - 1] = line; for (const span of spans) { let start = 0; while (start < span.text.length) { const newline = span.text.indexOf("\n", start); const end = newline < 0 ? span.text.length : newline + 1; const text = span.text.slice(start, end); if (!line) { line = { id: ++lineSeq, category, text: "", spans: [] }; lines.push(line); } line.text += text; line.spans = appendSpans(line.spans, [{ ...span, text }]); if (newline >= 0) line = undefined; start = end; } } } /** Drops the oldest lines once past the line or configured character cap. */ function trimConsole(lines: ConsoleLine[]) { let drop = 0; if (lines.length > MAX_CONSOLE_LINES) { drop = lines.length - MAX_CONSOLE_LINES + Math.ceil(MAX_CONSOLE_LINES / 10); } const c = workflowSettings().console; if (c.overrideBuffer) { const limit = c.bufferKb * 1024; let chars = 0; for (let i = lines.length - 1; i >= 0; i--) { chars += lines[i]!.text.length + 1; if (chars > limit) { drop = Math.max(drop, i + 1); break; } } } if (drop > 0) lines.splice(0, drop); } export function clearConsole(session: DebugSessionId) { flushConsoleOutput(); ansiStreams.delete(session); useDebug.setState((s) => ({ console: { ...s.console, [session]: [] } })); } // --- lifecycle --------------------------------------------------------------- let preparationSeq = 0; const preparations = new Map(); /** A visible session owns preparation, including its console and Stop, before DAP has an id. */ export async function startPreparedSession( config: RunConfig, prepare: (signal: AbortSignal, report: (text: string, kind?: "system" | "stderr") => void) => Promise, ): Promise { const id = --preparationSeq as DebugSessionId; const controller = new AbortController(); preparations.set(id, controller); useDebug.setState((s) => ({ sessions: { ...s.sessions, [id]: blank(id, { name: config.name, adapter: config.debugAdapter ?? "", runConfig: config }), }, console: { ...s.console, [id]: [] }, active: id, })); publishContext(); try { if (workflowSettings().run.showOnStart) toolWindowService().show("debug"); } catch { /* no workbench (tests) */ } const report = (text: string, kind = "system") => { if (!controller.signal.aborted && useDebug.getState().sessions[id]) appendConsole(id, kind, text); }; try { report(tx`Starting debugger for ${config.name}…` + "\n"); const launch = await prepare(controller.signal, report); controller.signal.throwIfAborted(); return await startSession(launch, { runConfig: config, preparation: { id, signal: controller.signal } }); } catch (e) { if (controller.signal.aborted || isCancelled(e)) return null; report((e instanceof Error ? e.message : String(e)) + "\n", "stderr"); throw e; } finally { preparations.delete(id); if (useDebug.getState().sessions[id]) markTerminated(id); } } export async function startSession( launch: LaunchConfig, options: { runConfig?: RunConfig; preparation?: { id: DebugSessionId; signal: AbortSignal }; /** A process started for this session (e.g. Maven with a JDWP agent): Stop ends it too. */ process?: RunId | null; /** Rerun repeats the launch (false for attaching to a process that is gone afterwards). */ rerunnable?: boolean; } = {}, ): Promise { if (!isTauri()) { notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" }); return null; } // Attaching to a JVM that may be gone: the finished session stays until the attach succeeds. const afterDetach = justDetached(options.process); const previousActive = useDebug.getState().active; if (!afterDetach) dropFinished(); try { if (workflowSettings().run.showOnStart) toolWindowService().show("debug"); } catch { // Workbench not ready (tests). } const startedAt = Date.now(); pendingStarts++; try { await flushBreakpoints(); options.preparation?.signal.throwIfAborted(); const adapterStarted = performance.now(); const id = await call(commands.debugStart(launch)); console.info("Debug adapter started", { name: launch.name, ms: performance.now() - adapterStarted }); owned.add(id); if (afterDetach) dropFinished(); if (options.preparation?.signal.aborted) { await call(commands.debugStop(id)).catch(() => undefined); settleStart(id); if (useDebug.getState().sessions[id]) markTerminated(id); return null; } flushConsoleOutput(); const preparation = options.preparation && useDebug.getState().sessions[options.preparation.id]; useDebug.setState((s) => { const cur = s.sessions[id] ?? blank(id, { status: "running" }); const sessions = { ...s.sessions }; const console = { ...s.console }; if (preparation) { console[id] = [...(console[preparation.id] ?? []), ...(console[id] ?? [])]; delete console[preparation.id]; delete sessions[preparation.id]; ansiStreams.delete(preparation.id); } return { console, sessions: { ...sessions, [id]: { ...cur, name: launch.name, runConfig: options.runConfig, startedAt: preparation?.startedAt ?? startedAt, adapter: launch.adapter, launch: options.rerunnable === false ? null : launch, process: options.process ?? null, status: cur.status === "starting" ? "running" : cur.status, }, }, active: id, }; }); settleStart(id); publishContext(); void refreshExceptionFilters(); void loadCapabilities(id); // Method breakpoints / watchpoints went out during configuration: pull their states. void syncSpecial(); return id; } catch (e) { settleStart(null, afterDetach); if (afterDetach) { // The JVM that announced the port ended as its previous debugger detached. console.info("Debugger not attached: the JVM is gone", { name: launch.name, error: e }); const { sessions, active } = useDebug.getState(); if (active == null && previousActive != null && sessions[previousActive]) useDebug.setState({ active: previousActive }); publishContext(); return null; } if (options.preparation?.signal.aborted || isCancelled(e)) return null; if (options.preparation) appendConsole(options.preparation.id, "stderr", (e instanceof Error ? e.message : String(e)) + "\n"); const missing = missingAdapter(launch.adapter, e); if (missing) { notifications().notify({ group: "run", level: "warning", title: missing.title, body: missing.body, key: "debug.start", sticky: true, actions: [ { label: "Install and Debug", run: () => void executeAction("tools.install", missing.tool).then(() => startSession(launch, options)), }, ], }); return null; } notifications().notify({ group: "run", level: "error", title: `Cannot start debugger for ${launch.name}`, body: e instanceof Error ? e.message : String(e), key: "debug.start", }); return null; } } export async function stopSession(id: DebugSessionId, options: { stopProcess?: boolean } = {}) { const s = useDebug.getState().sessions[id]; if (!s) return; const root = rootOf(s); const preparation = preparations.get(root.id); if (preparation) { preparation.abort(); appendConsole(root.id, "system", tx("Launch cancelled.") + "\n"); markTerminated(root.id); return; } try { await call(commands.debugStop(root.id)); } catch (e) { // Already gone: mark it finished locally. console.warn("debugStop failed", e); markTerminated(root.id); } // Detaching leaves an attached JVM running: stop the process it belongs to, like IDEA. if (options.stopProcess !== false && root.process != null) await call(commands.runStop(root.process)).catch(() => undefined); } /** * Closes a session's tab: a live session is stopped first once the user agrees (the Run * tab's question), then the window forgets it — and its children. */ export async function closeSessionTab(id: DebugSessionId): Promise { const s = useDebug.getState().sessions[id]; if (!s) return false; if (isLive(s)) { if (workflowSettings().run.confirmTerminate && !(await confirmTerminate(s.name))) return false; await stopSession(s.id); } removeSession(s.id); return true; } /** Forgets a session and its children; a selected one passes the selection to the tab on its left. */ export function removeSession(id: DebugSessionId) { const st = useDebug.getState(); const gone = new Set([id]); for (const s of Object.values(st.sessions)) if (s.parent === id) gone.add(s.id); const order = Object.values(st.sessions).map((s) => s.id); const sessions: Record = {}; for (const s of Object.values(st.sessions)) if (!gone.has(s.id)) sessions[s.id] = s; const console = { ...st.console }; for (const goneId of gone) delete console[goneId]; const at = order.indexOf(id); const neighbour = order .slice(0, at) .reverse() .find((n) => sessions[n]) ?? order.slice(at + 1).find((n) => sessions[n]) ?? null; useDebug.setState({ sessions, active: st.active != null && sessions[st.active] ? st.active : neighbour, console, }); for (const goneId of gone) owned.delete(goneId); updateExecutionPoint(); publishContext(); } export async function rerunSession(id: DebugSessionId) { const s = useDebug.getState().sessions[id]; if (!s) return; const root = rootOf(s); if (root.runConfig) { if (isLive(root)) await stopSession(root.id); await executeAction("run.debugConfig", root.runConfig); return; } const launch = root.launch; if (!launch) return; if (isLive(root)) await stopSession(root.id); await startSession(launch); } async function loadCapabilities(id: DebugSessionId) { try { const caps = await call(commands.debugCapabilities(id)); if (useDebug.getState().sessions[id]) patch(id, { caps }); } catch { // Session already gone. } } /** Whether the active session's adapter supports `cap` (false while unknown). */ export function sessionSupports(cap: keyof DebugCapabilities, s: DebugSession | null = activeSession()): boolean { return !!s?.caps?.[cap]; } function markTerminated(id: DebugSessionId) { patch(id, { status: "terminated", endedAt: Date.now(), frames: [], selectedFrame: null, stoppedThread: null }); const st = useDebug.getState(); if (st.active === id) { // Prefer another live session (e.g. the parent of a finished child). const other = Object.values(st.sessions).find(isLive); if (other) useDebug.setState({ active: other.id }); } if (!Object.values(useDebug.getState().sessions).some(isLive)) { resetStatuses(); useSpecialBreakpoints.setState({ status: {} }); if (workflowSettings().debugger.hideOnTermination) { try { toolWindowService().hide("debug"); } catch { // Workbench not ready (tests). } } } updateExecutionPoint(); publishContext(); } // --- selection / pulls ------------------------------------------------------- export function selectSession(id: DebugSessionId) { useDebug.setState({ active: id }); publishContext(); updateExecutionPoint(); } export async function selectThread(id: DebugSessionId, threadId: number) { patch(id, { selectedThread: threadId, frames: [], selectedFrame: null }); updateExecutionPoint(); await loadFrames(id, threadId, true); } export function selectFrame(id: DebugSessionId, frameId: number, reveal = true) { patch(id, { selectedFrame: frameId }); updateExecutionPoint(); const s = useDebug.getState().sessions[id]; const frame = s?.frames.find((f) => f.id === frameId); if (reveal && useDebug.getState().active === id && frame?.path) { void maybeEditorService()?.openFile(frame.path, { selection: { line: frame.line, col: frame.column }, focus: false, }); } void loadLocals(id, frameId); } async function loadFrames(id: DebugSessionId, threadId: number, reveal: boolean) { const before = useDebug.getState().sessions[id]; let frames: StackFrame[] = []; try { frames = await call(commands.debugStackTrace(id, threadId)); } catch (e) { console.warn("stackTrace failed", e); } const s = useDebug.getState().sessions[id]; if (!s || s.status !== "paused" || s.selectedThread !== threadId || s.frames !== before?.frames) return; patch(id, { frames }); const top = frames.find((f) => f.path && f.presentationHint !== "label") ?? frames[0]; if (top?.path && (s.stopReason === "breakpoint" || s.stopReason === "function breakpoint")) { void logSuspendingHit(id, top); consumeTemporary(top.path, top.line); } if (top) selectFrame(id, top.id, reveal); else updateExecutionPoint(); } /** * A suspending breakpoint that also logs: DAP logpoints never suspend, so the * message is evaluated in the stopped frame and printed here instead. */ async function logSuspendingHit(id: DebugSessionId, frame: StackFrame) { const bp = breakpointsOf(frame.path!).find((b) => b.line === frame.line && b.enabled); if (!bp || !suspends(bp)) return; for (const part of logParts(bp, frame.path!)) { const text = await interpolateLog(part, async (expr) => { const v = await call(commands.debugEvaluateIn(id, frame.id, expr, "repl")); return v.value; }); appendConsole(id, "console", `${text}\n`); } } async function loadThreads(id: DebugSessionId) { try { const threads = await call(commands.debugThreads(id)); patch(id, { threads }); } catch (e) { console.warn("threads failed", e); } } /** Locals of `frameId` (first non-expensive scope) for inline values. */ async function loadLocals(id: DebugSessionId, frameId: number) { const frames = useDebug.getState().sessions[id]?.frames; const values: Record = Object.create(null); try { const scopes = await call(commands.debugScopes(id, frameId)); const scope = scopes.find((sc) => !sc.expensive); if (scope) { const vars = await call(commands.debugVariables(id, scope.variablesReference)); for (const v of vars) if (v.name && !Object.hasOwn(values, v.name)) values[v.name] = v.value; } } catch { // No inline values. } const s = useDebug.getState().sessions[id]; if (useDebug.getState().active !== id || s?.status !== "paused" || s.selectedFrame !== frameId || s.frames !== frames) return; useExecutionPoint.setState({ values }); } /** Show Execution Point (⌥F10): back to the suspended thread's top frame, revealed and focused. */ export async function showExecutionPoint(id: DebugSessionId) { const s = useDebug.getState().sessions[id]; if (!s || s.status !== "paused" || s.stoppedThread == null) return; if (s.selectedThread !== s.stoppedThread) { patch(id, { selectedThread: s.stoppedThread, frames: [], selectedFrame: null }); updateExecutionPoint(); await loadFrames(id, s.stoppedThread, false); } const cur = useDebug.getState().sessions[id]; const top = cur?.frames.find((f) => f.path && f.presentationHint !== "label"); if (!cur || !top) return; selectFrame(id, top.id, false); await revealFrame(top, true); } /** Opens a frame's source at its line (Jump to Source, F4). */ export async function revealFrame(frame: StackFrame, focus: boolean) { if (!frame.path) return; await maybeEditorService()?.openFile(frame.path, { selection: { line: frame.line, col: frame.column }, focus }); } export function updateExecutionPoint() { const s = activeSession(); const frame = s && s.status === "paused" ? s.frames.find((f) => f.id === s.selectedFrame) : undefined; if (!s || !frame || !frame.path) { if (useExecutionPoint.getState().path !== null) useExecutionPoint.setState({ path: null, values: {} }); return; } const top = s.frames.find((f) => f.path && f.presentationHint !== "label")?.id === frame.id; const cur = useExecutionPoint.getState(); if (cur.path === frame.path && cur.line === frame.line && cur.top === top) return; useExecutionPoint.setState({ path: frame.path, line: frame.line, top, values: {} }); } /** Invalidate suspended data before sending a command that resumes execution. */ export async function resumeExecution(id: DebugSessionId, request: () => Promise) { const before = useDebug.getState().sessions[id]; if (!before || before.status !== "paused") return; const frames: StackFrame[] = []; patch(id, { status: "running", frames, selectedFrame: null, stoppedThread: null }); updateExecutionPoint(); try { await request(); } catch (e) { const current = useDebug.getState().sessions[id]; // A stop/continue/termination received during the request owns the newer state. if (current?.status === "running" && current.frames === frames) { patch(id, { status: before.status, frames: before.frames, selectedFrame: before.selectedFrame, stoppedThread: before.stoppedThread, }); updateExecutionPoint(); if (before.selectedFrame != null) void loadLocals(id, before.selectedFrame); } throw e; } } // --- watches ------------------------------------------------------------------- export function addWatch(expression: string) { const e = expression.trim(); if (!e) return; useDebug.setState((s) => (s.watches.includes(e) ? s : { watches: [...s.watches, e] })); persistWatches(); } export function removeWatch(expression: string) { useDebug.setState((s) => ({ watches: s.watches.filter((w) => w !== expression) })); persistWatches(); } const WATCH_KEY = "openidea.debugger.watches"; function persistWatches() { try { localStorage.setItem(WATCH_KEY, JSON.stringify(useDebug.getState().watches)); } catch { // ignore } } export function loadWatches() { try { const raw = JSON.parse(localStorage.getItem(WATCH_KEY) ?? "[]") as unknown; if (Array.isArray(raw)) useDebug.setState({ watches: raw.filter((w): w is string => typeof w === "string") }); } catch { // ignore } } /** * "Set Value" (F2 in the variables tree): `setVariable` in `container`, or * `setExpression` on the evaluate name. Refreshes variables, watches and inline values. */ export async function setVariableValue( target: { container: number; name: string; evaluateName: string | null }, value: string, session: DebugSession | null = activeSession(), ): Promise { if (!session || session.status !== "paused") throw new Error("The debugger is not suspended"); const result = await call( commands.debugSetVariable( session.id, target.container, target.name, target.evaluateName, session.selectedFrame, value, ), ); useDebug.setState((st) => ({ stopSeq: st.stopSeq + 1 })); if (session.selectedFrame != null) void loadLocals(session.id, session.selectedFrame); return result; } export async function evaluate( expression: string, context: "repl" | "watch" | "hover" | "clipboard", session: DebugSession | null = activeSession(), ): Promise { if (!session || !isLive(session)) throw new Error("No active debug session"); return call(commands.debugEvaluateIn(session.id, session.selectedFrame, expression, context)); } /** Settings › Debugger › Focus application on breakpoint. */ function focusAppWindow() { if (!isTauri()) return; void import("@tauri-apps/api/window") .then(async ({ getCurrentWindow }) => { const w = getCurrentWindow(); if (await w.isMinimized()) await w.unminimize(); await w.setFocus(); }) .catch(() => undefined); } // --- events ---------------------------------------------------------------------- export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { const s = ownSession(id, event); if (!s) return; switch (event.kind) { case "started": if (!s.launch) patch(id, { name: event.name, adapter: event.adapter, parent: event.parent }); break; case "initialized": if (s.status === "starting") patch(id, { status: "running" }); break; case "stopped": { const isBp = event.reason === "breakpoint" || event.reason === "function breakpoint"; patch(id, { status: "paused", stoppedThread: event.threadId, selectedThread: event.threadId, stopReason: event.reason, stopDescription: event.description, frames: [], selectedFrame: null, hits: s.hits + (isBp ? 1 : 0), }); useDebug.setState((st) => ({ active: id, stopSeq: st.stopSeq + 1 })); publishContext(); updateExecutionPoint(); void loadThreads(id); void loadFrames(id, event.threadId, true); const dbg = workflowSettings().debugger; try { if (dbg.showOnBreakpoint) toolWindowService().show("debug"); } catch { // ignore } if (dbg.focusAppOnBreakpoint) focusAppWindow(); break; } case "continued": { const cur = useDebug.getState().sessions[id]!; if (cur.status === "terminated") break; if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break; patch(id, { status: "running", frames: [], selectedFrame: null, stoppedThread: null, }); updateExecutionPoint(); break; } case "output": appendConsoleQueued(id, event.category, event.output); break; case "thread": if (s.status === "paused") void loadThreads(id); break; case "breakpointsChanged": void refreshStates(event.path); break; case "exited": patch(id, { exitCode: event.code }); appendConsole(id, "system", `\nProcess finished with exit code ${event.code}\n`); break; case "terminated": markTerminated(id); break; } } /** The window switched projects: stops its sessions and forgets them (late events stay ignored via `owned`). */ export function resetSessions() { const { sessions } = useDebug.getState(); for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id); pendingOutput.length = 0; cancelOutputFlush(); ansiStreams.clear(); useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 }); useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); publishContext(); } /** Breakpoint ↔ session glue: unverified look only makes sense while debugging. */ export function sessionsRunning(): boolean { return Object.values(useDebug.getState().sessions).some(isLive); }