mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
A direct debug launch kept its output only in Debug, while Services showed an idle entry or an earlier Run. - Show the debug execution's console and running, suspended or finished state in Services, reusing the debugger's bounded console through the Run/Debug bridge. Console output does not rebuild the Services tree. - Keep Maven and Gradle attached launches in their Run console, pairing Run and debug sessions by configuration and process. - Stop an owning Run and wait for its process to exit before detaching its debuggers, so no new JDWP listener is attached again. - Point configuration Stop and Rerun at the displayed execution, and group Stop at every live execution. Fixes #533 Co-authored-by: wangxga <[email protected]>
923 lines
32 KiB
TypeScript
923 lines
32 KiB
TypeScript
/**
|
||
* 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 { 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;
|
||
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<number, DebugSession>;
|
||
/** Session shown in the Debug tool window / title bar. */
|
||
active: DebugSessionId | null;
|
||
console: Record<number, ConsoleLine[]>;
|
||
watches: string[];
|
||
/** Incremented on every stop; views re-pull data when it changes. */
|
||
stopSeq: number;
|
||
}
|
||
|
||
export const useDebug = create<DebugState>(() => ({
|
||
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<string, string>;
|
||
}
|
||
|
||
export const useExecutionPoint = create<ExecutionPoint>(() => ({ 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<DebugSessionId, Map<string, AnsiStream>>();
|
||
|
||
/** 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<DebugSession>) {
|
||
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> = {}): 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<DebugSessionId>();
|
||
/** Every session id this window has owned (late events of dropped sessions are ignored). */
|
||
const owned = new Set<DebugSessionId>();
|
||
|
||
/** Registers a session as owned by this window (idempotent). */
|
||
export function adoptSession(id: DebugSessionId, p: Partial<DebugSession> = {}): 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. */
|
||
function settleStart(id: DebugSessionId | null) {
|
||
pendingStarts = Math.max(0, pendingStarts - 1);
|
||
if (id != null) provisional.delete(id);
|
||
if (pendingStarts > 0) return;
|
||
const leftovers = [...provisional];
|
||
provisional.clear();
|
||
for (const s of leftovers) if (isLive(useDebug.getState().sessions[s])) markTerminated(s);
|
||
}
|
||
|
||
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");
|
||
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<DebugSessionId, AbortController>();
|
||
|
||
/** 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<LaunchConfig>,
|
||
): Promise<DebugSessionId | null> {
|
||
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<DebugSessionId | null> {
|
||
if (!isTauri()) {
|
||
notifications().notify({ group: "run", level: "warning", title: "Debugging needs the desktop app" });
|
||
return null;
|
||
}
|
||
// Drop finished sessions; keep running ones (multiple concurrent sessions are fine).
|
||
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();
|
||
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 (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);
|
||
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);
|
||
}
|
||
|
||
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", 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 });
|
||
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 && 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) {
|
||
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) 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 values: Record<string, string> = {};
|
||
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 && !(v.name in values)) values[v.name] = v.value;
|
||
}
|
||
} catch {
|
||
// No inline values.
|
||
}
|
||
const s = useDebug.getState().sessions[id];
|
||
if (useDebug.getState().active !== id || s?.selectedFrame !== frameId) 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 });
|
||
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: {} });
|
||
}
|
||
|
||
// --- 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<Variable> {
|
||
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<Variable> {
|
||
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();
|
||
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;
|
||
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);
|
||
}
|