Файли
openidea/src/features/debugger/state.ts
T
xtr-hubтаxtr-hub b790a9ddb6 feat(run): keep service debugging in the Services tool window (#1844)
Closes #1684. Debug and rerun started from the Services tool window used startSession(), which opened the Debug tool window whenever run.showOnStart was on. Services-originated debug sessions and reruns now start with show: false and stay in the Services detail pane (in-place launches and reruns keep that origin; a breakpoint hit still shows the paused session). Debug from the toolbar, gutter, context menu and the Debug window keeps opening the Debug window. Also: palette themes inherit the macOS window button colours, and the title bar's fullscreen buttons skip Tauri calls outside Tauri (fixes the theme-token and title-bar test failures on main).

Co-authored-by: xtr-hub <[email protected]>
2026-11-08 00:46:39 +08:00

1144 рядки
41 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* 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, getAction } 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 { startedPort } from "@/platform/serverPort";
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 { flashTaskbarForBreakpoint, stopTaskbarFlash } from "./taskbarFlash";
import { missingAdapter } from "./missingAdapter";
import { startFailureMessage } from "./startFailure";
export type SessionStatus = "starting" | "running" | "paused" | "stopping" | "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;
/** The program runs in a terminal the IDE gave it: the console types into it while it runs. */
programInput?: boolean;
/** 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>;
/** Full variables behind `values`, for the click-to-show popup. */
variables?: Record<string, Variable>;
}
export const useExecutionPoint = create<ExecutionPoint>(() => ({
path: null,
line: 0,
top: true,
values: {},
variables: {},
}));
/** 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,
* 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,
httpPort: sessionHttpPort(session.id),
rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null,
rerunInPlace:
session.runConfig || session.launch ? () => void rerunSession(session.id, { show: false }) : 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,
status: session.status,
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 -----------------------------------------------------------------
/**
* The HTTP port the session's application announced on its console ("Tomcat started on
* port 8080"), the same embedded web server line the Run console is parsed for. Kept per
* session, so the Services node keeps the port after the line scrolls out of the buffer.
*/
const announcedPorts = new Map<DebugSessionId, number>();
export function sessionHttpPort(id: DebugSessionId): number | undefined {
let port: number | undefined;
for (const line of useDebug.getState().console[id] ?? []) {
const found = startedPort(line.text);
if (found !== null) port = found;
}
if (port !== undefined) announcedPorts.set(id, port);
else port = announcedPorts.get(id);
return port;
}
/** Republishes Services metadata only when a console announced a port it had not shown yet. */
function republishForPorts() {
const published = useDebugRunSessions.getState().serviceSessions;
if (published.some((d) => d.httpPort !== sessionHttpPort(d.id))) publishContext();
}
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 } };
});
republishForPorts();
}
/**
* 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 };
});
republishForPorts();
}
/** 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>,
options: { show?: boolean } = {},
): 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 (options.show !== false && 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 },
show: options.show,
});
} 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;
/** `false`: keep the current tool window (the Services panel starts debuggers in place). */
show?: boolean;
} = {},
): Promise<DebugSessionId | null> {
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 (options.show !== false && 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: startFailureMessage(e),
key: "debug.start",
});
return null;
}
}
/** Stops an associated Run process through its owner, which records the user's stop intent. */
export async function stopAssociatedProcess(process: RunId): Promise<void> {
if (getAction("run.stopProcess")) await executeAction("run.stopProcess", process);
else await call(commands.runStop(process)).catch(() => undefined);
}
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;
}
// Between the Stop press and the adapter's terminated event the session is shutting down:
// keep it live (Run stays disabled) and let the toolbar show the spinner.
for (const sid of new Set([root.id, s.id])) {
const target = useDebug.getState().sessions[sid];
if (target && isLive(target)) patch(sid, { status: "stopping" });
}
const stoppingProcess =
options.stopProcess !== false && root.process != null ? stopAssociatedProcess(root.process) : null;
try {
await call(commands.debugStop(root.id));
} catch (e) {
// Already gone: mark it finished locally.
console.warn("debugStop failed", e);
markTerminated(root.id);
}
await stoppingProcess;
}
/**
* 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<boolean> {
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<DebugSessionId>([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<number, DebugSession> = {};
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, options: { show?: boolean } = {}) {
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",
options.show === false ? { config: root.runConfig, show: false } : root.runConfig,
);
return;
}
const launch = root.launch;
if (!launch) return;
if (isLive(root)) await stopSession(root.id);
await startSession(launch, { show: options.show });
}
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) {
stopTaskbarFlash();
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<string, string> = Object.create(null);
const variables: Record<string, Variable> = 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;
variables[v.name] = v;
}
}
} 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, variables });
}
/** 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: {}, variables: {} });
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: {}, variables: {} });
}
/** Invalidate suspended data before sending a command that resumes execution. */
export async function resumeExecution(id: DebugSessionId, request: () => Promise<unknown>) {
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<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;
}
/** Whether the console types into the session's program (it runs, in a terminal the IDE gave it). */
export function readsProgramInput(s: DebugSession): boolean {
return !!s.programInput && s.status === "running";
}
/** Types `text` into the session's program; its terminal echoes it to the console. */
export async function sendProgramInput(session: DebugSession, text: string): Promise<void> {
await call(commands.debugSendInput(session.id, text));
}
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();
updateExecutionPoint();
void loadThreads(id);
void loadFrames(id, event.threadId, true);
const dbg = workflowSettings().debugger;
try {
if (dbg.showOnBreakpoint) toolWindowService().show("debug");
} catch {
// ignore
}
// Attention for a background window: focusing the app and flashing the taskbar are
// alternatives (IDEA), so a window configured to grab focus does not also flash.
if (dbg.focusAppOnBreakpoint) focusAppWindow();
else if (isBp) flashTaskbarForBreakpoint();
break;
}
case "continued": {
const cur = useDebug.getState().sessions[id]!;
if (cur.status === "terminated") break;
if (!event.allThreadsContinued && event.threadId !== (cur.selectedThread ?? cur.stoppedThread)) break;
stopTaskbarFlash();
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 "programInput":
patch(id, { programInput: true });
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();
announcedPorts.clear();
useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 });
useExecutionPoint.setState({ path: null, line: 0, top: true, values: {}, variables: {} });
publishContext();
}
/** Breakpoint ↔ session glue: unverified look only makes sense while debugging. */
export function sessionsRunning(): boolean {
return Object.values(useDebug.getState().sessions).some(isLive);
}