fix(services): show debug session output and state (#540)

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]>
Цей коміт міститься в:
wangxgaтаwangxga authored and GitHub committed 2026-10-31 15:11:52 +08:00
1 джерело 7610fc3f80
коміт 1e9cf1c447
17 files changed
+978 -205

No files matched your search

+73 -1
Переглянути файл
@@ -9,17 +9,89 @@ import {
} from "@/workbench/contributions";
import type { RunConfig } from "@/platform/ipc";
import { registerDebugActions } from "../actions";
import { adoptSession, publishContext, rerunSession, useDebug } from "../state";
import {
adoptSession,
appendConsole,
handleDebugEvent,
publishContext,
rerunSession,
resetSessions,
startSession,
stopSession,
useDebug,
} from "../state";
const ipc = vi.hoisted(() => ({
javaDebugServer: vi.fn(async () => ({ status: "ok", data: 5005 })),
debugStart: vi.fn(async () => ({ status: "ok", data: 42 })),
debugStop: vi.fn(async () => ({ status: "ok", data: null })),
runStop: vi.fn(async () => ({ status: "ok", data: null })),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } };
});
it("retains only configured debug roots for Services without changing live toolbar sessions", () => {
adoptSession(1, { runConfig: config, process: 7, startedAt: 10 });
adoptSession(2, { parent: 1, status: "paused" });
adoptSession(3);
publishContext();
expect(useDebugRunSessions.getState().serviceSessions).toMatchObject([
{ id: 1, config, processId: 7, startedAt: 10, status: "paused" },
]);
handleDebugEvent(1, { kind: "terminated" });
expect(useDebugRunSessions.getState().sessions.map((s) => s.id)).toEqual([3]);
expect(useDebugRunSessions.getState().serviceSessions).toMatchObject([{ id: 1, status: "terminated" }]);
});
it("does not republish service metadata when console output changes", () => {
adoptSession(1, { runConfig: config });
const summaries = useDebugRunSessions.getState().serviceSessions;
expect(summaries).toHaveLength(1);
appendConsole(1, "stdout", "application output\n");
expect(useDebugRunSessions.getState().serviceSessions).toBe(summaries);
});
it("stops shared-process debugger roots without stopping their process until requested", async () => {
adoptSession(1, { runConfig: config, process: 7 });
await stopSession(1, { stopProcess: false });
expect(ipc.debugStop).toHaveBeenCalledWith(1);
expect(ipc.runStop).not.toHaveBeenCalled();
await stopSession(1);
expect(ipc.runStop).toHaveBeenCalledWith(7);
});
it("pins Show in Debug to the configured session and clears summaries with the project", () => {
adoptSession(1, { runConfig: config });
adoptSession(2, { runConfig: { ...config, id: "other" } });
const summary = useDebugRunSessions.getState().serviceSessions[0]!;
summary.show();
expect(useDebug.getState().active).toBe(1);
resetSessions();
expect(useDebugRunSessions.getState().serviceSessions).toEqual([]);
summary.show();
expect(useDebug.getState().active).toBeNull();
});
it("removes terminated summaries when a subsequent debug start prunes their source", async () => {
adoptSession(1, { runConfig: config });
handleDebugEvent(1, { kind: "terminated" });
await startSession(
{
name: "next",
adapter: "js-debug",
request: "launch",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: null,
},
{ runConfig: { ...config, id: "next" } },
);
expect(useDebugRunSessions.getState().serviceSessions.map((s) => s.id)).toEqual([42]);
});
const config: RunConfig = {
id: "node:server",
name: "server",
+73
Переглянути файл
@@ -0,0 +1,73 @@
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { adoptSession, appendConsole, useDebug } from "../state";
import { DebugConsole, DebugServiceConsole } from "../ui/DebugConsole";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 18,
getVirtualItems: () => Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 18 })),
measureElement: () => {},
scrollToIndex: () => {},
}),
}));
beforeEach(() => useDebug.setState({ sessions: {}, console: {}, active: null }));
it("shows the requested session without changing or following the active debugger", () => {
adoptSession(1, { name: "one" });
adoptSession(2, { name: "two" });
appendConsole(1, "stdout", "service one\n");
appendConsole(2, "stdout", "service two\n");
useDebug.setState({ active: 2 });
const view = render(<DebugServiceConsole sessionId={1} />);
expect(screen.getByRole("log")).toHaveTextContent("service one");
expect(screen.getByRole("log")).not.toHaveTextContent("service two");
expect(screen.queryByRole("textbox")).toBeNull();
expect(useDebug.getState().active).toBe(2);
act(() => appendConsole(2, "stdout", "unrelated\n"));
expect(screen.getByRole("log")).not.toHaveTextContent("unrelated");
view.rerender(<DebugServiceConsole sessionId={2} />);
expect(screen.getByRole("log")).toHaveTextContent("service two");
expect(screen.getByRole("log")).not.toHaveTextContent("service one");
});
it("shares output and clearing with the interactive debugger console", () => {
const session = adoptSession(1);
appendConsole(1, "stdout", "shared log\n");
render(
<>
<DebugServiceConsole sessionId={1} />
<DebugConsole session={session} />
</>,
);
expect(screen.getAllByRole("log").every((log) => log.textContent?.includes("shared log"))).toBe(true);
expect(screen.getByRole("textbox", { name: "Debug console input" })).toBeEnabled();
const clear = screen.getAllByRole("button", { name: "Clear Console" })[0]!;
fireEvent.click(clear);
for (const log of screen.getAllByRole("log")) expect(within(log).queryByText("shared log")).toBeNull();
expect(useDebug.getState().console[1]).toEqual([]);
});
it("shows an empty state when its source has been cleared", () => {
render(<DebugServiceConsole sessionId={99} />);
expect(screen.getByText("No debug output available")).toBeTruthy();
expect(screen.queryByRole("log")).toBeNull();
});
it("explains why a remote attached process may have no application logs", () => {
adoptSession(1, {
launch: {
name: "remote",
adapter: "java-debug",
request: "attach",
program: null,
args: [],
cwd: "/p",
env: {},
extraJson: null,
},
});
render(<DebugServiceConsole sessionId={1} />);
expect(screen.getByText("The attached process may not provide standard output to the debugger.")).toBeTruthy();
});
+5
Переглянути файл
@@ -13,6 +13,7 @@ import {
registerOverlay,
registerRunConfigType,
registerToolWindow,
useDebugRunSessions,
} from "@/workbench/contributions";
import { registerDebugActions } from "./actions";
import { loadBreakpoints } from "./breakpoints/store";
@@ -28,12 +29,16 @@ import { DebuggerOverlays } from "./ui/Overlays";
import { REMOTE_TYPES } from "./remote";
export function activate(): () => void {
useDebugRunSessions.setState({
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
});
loadWatches();
publishContext();
loadBreakpoints(useProject.getState().project?.root ?? null);
loadExceptionBreakpoints(useProject.getState().project?.root ?? null);
loadSpecialBreakpoints(useProject.getState().project?.root ?? null);
const disposers = [
() => useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null }),
registerDebugActions(),
// Remote JVM Debug, Go Remote, …: Run/Debug Configurations › Add New Configuration.
...REMOTE_TYPES.map(registerRunConfigType),
+26 -2
Переглянути файл
@@ -235,7 +235,29 @@ function settleStart(id: DebugSessionId | null) {
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) => {
@@ -445,6 +467,7 @@ export async function startSession(
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 {
@@ -536,7 +559,7 @@ export async function startSession(
}
}
export async function stopSession(id: DebugSessionId) {
export async function stopSession(id: DebugSessionId, options: { stopProcess?: boolean } = {}) {
const s = useDebug.getState().sessions[id];
if (!s) return;
const root = rootOf(s);
@@ -555,7 +578,8 @@ export async function stopSession(id: DebugSessionId) {
markTerminated(root.id);
}
// Detaching leaves an attached JVM running: stop the process it belongs to, like IDEA.
if (root.process != null) await call(commands.runStop(root.process)).catch(() => undefined);
if (options.stopProcess !== false && root.process != null)
await call(commands.runStop(root.process)).catch(() => undefined);
}
export async function rerunSession(id: DebugSessionId) {
+173
Переглянути файл
@@ -0,0 +1,173 @@
import { useLayoutEffect, useRef, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { EmptyState, IconButton, showContextMenu, T, type MenuItem } from "@/ui";
import { tx } from "@/platform/i18n";
import { spanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
import { appendConsole, clearConsole, evaluate, isLive, useDebug, type DebugSession } from "../state";
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
import styles from "./DebugToolWindow.module.css";
export function DebugServiceConsole({ sessionId }: { sessionId: number }) {
const session = useDebug((s) => s.sessions[sessionId]);
return session ? (
<DebugConsole key={sessionId} session={session} interactive={false} />
) : (
<EmptyState title="No debug output available" />
);
}
export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) {
const lines = useDebug((s) => s.console[session.id]);
const scroller = useRef<HTMLDivElement>(null);
const stick = useRef(true);
const programmatic = useRef(false);
const [input, setInput] = useState("");
const history = useRef<string[]>(readHistory("console"));
const historyPos = useRef(-1);
const inputRef = useRef<HTMLInputElement>(null);
const virtualizer = useVirtualizer({
count: lines?.length ?? 0,
getScrollElement: () => scroller.current,
estimateSize: () => 18,
overscan: 30,
getItemKey: (i) => lines?.[i]?.id ?? i,
});
useLayoutEffect(() => {
const el = scroller.current;
if (!el || !stick.current || !lines?.length) return;
programmatic.current = true;
virtualizer.scrollToIndex(lines.length - 1, { align: "end" });
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => (programmatic.current = false));
}, [lines, virtualizer]);
const submit = async () => {
const e = input.trim();
if (!e) return;
pushHistory("console", e);
history.current = readHistory("console");
historyPos.current = -1;
setInput("");
appendConsole(session.id, "input", `> ${e}\n`);
try {
const v = await evaluate(e, "repl", session);
appendConsole(session.id, "result", `${v.value}\n`);
} catch (err) {
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
}
};
/** IDEA's Browse History popup: the expressions typed before, most recent first. */
const browseHistory = () => {
const el = inputRef.current;
const r = el?.getBoundingClientRect();
const items: MenuItem[] = history.current.length
? history.current.slice(0, 30).map((h, i) => ({
id: String(i),
label: h.length > 120 ? `${h.slice(0, 120)}…` : h,
run: () => {
setInput(h);
historyPos.current = i;
el?.focus();
},
}))
: [{ id: "none", label: "No history", disabled: true }];
showContextMenu({ x: r?.left ?? 120, y: (r?.top ?? 200) - 4 }, items);
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const h = history.current;
if (!h.length) return;
e.preventDefault();
const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1)));
historyPos.current = pos;
setInput(pos < 0 ? "" : h[pos]!);
}
};
return (
<div className={styles.console}>
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette)}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
}}
role="log"
onKeyDown={copySelectionOnKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
>
{!lines?.length && session.launch?.request === "attach" && (
<T>The attached process may not provide standard output to the debugger.</T>
)}
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
return (
<span
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{l.spans.map((sp, i) => (
<span key={i} style={spanStyle(sp)}>
{sp.text}
</span>
))}
</span>
);
})}
</div>
</div>
<div className={styles.repl}>
{interactive && (
<>
<span className={styles.prompt} aria-hidden>
›
</span>
<input
ref={inputRef}
className={styles.evalInput}
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
onBlur={() => setConsoleHistoryBrowser(null)}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
autoComplete="off"
/>
</>
)}
<IconButton
icon="trash"
title="Clear Console"
size="small"
iconSize={12}
onClick={() => clearConsole(session.id)}
/>
</div>
</div>
);
}
+7 -158
Переглянути файл
@@ -1,8 +1,6 @@
/** Debug tool window: toolbar, Threads & Variables (frames + variables), Console. */
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import { useVirtualizer } from "@tanstack/react-virtual";
import { executeAction, getAction } from "@/platform/actions";
import { evaluateWhen, useContextKeys } from "@/platform/context";
import { basename, useProject } from "@/platform/project";
@@ -28,24 +26,19 @@ import { frameLabel, groupFrames } from "../frames";
import { blurDebugView, focusDebugView } from "./focusedView";
import {
addWatch,
clearConsole,
evaluate,
isLive,
selectFrame,
selectSession,
selectThread,
appendConsole,
revealFrame,
sessionSupports,
useDebug,
type DebugSession,
} from "../state";
import { DebugConsole } from "./DebugConsole";
import { VariablesView, type VarRoot } from "./VariablesView";
import { pushHistory, readHistory, setConsoleHistoryBrowser } from "./expressionHistory";
import { sortVariables } from "../values";
import { workflowSettings } from "@/platform/workflowSettings";
import { spanStyle } from "@/ui/ansi";
import ansi from "@/ui/ansi.module.css";
import styles from "./DebugToolWindow.module.css";
import { tx } from "@/platform/i18n";
@@ -222,7 +215,11 @@ export function DebugToolWindow() {
<MoreActions />
</div>
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
{tab === "variables" ? <ThreadsAndVariables session={active} /> : <ConsoleView session={active} />}
{tab === "variables" ? (
<ThreadsAndVariables session={active} />
) : (
<DebugConsole key={active.id} session={active} />
)}
</div>
</div>
);
@@ -555,151 +552,3 @@ function VariablesPane({ session }: { session: DebugSession }) {
</div>
);
}
// --- console -------------------------------------------------------------------
function ConsoleView({ session }: { session: DebugSession }) {
const lines = useDebug((s) => s.console[session.id]);
const scroller = useRef<HTMLDivElement>(null);
const stick = useRef(true);
const programmatic = useRef(false);
const [input, setInput] = useState("");
const history = useRef<string[]>(readHistory("console"));
const historyPos = useRef(-1);
const inputRef = useRef<HTMLInputElement>(null);
const virtualizer = useVirtualizer({
count: lines?.length ?? 0,
getScrollElement: () => scroller.current,
estimateSize: () => 18,
overscan: 30,
getItemKey: (i) => lines?.[i]?.id ?? i,
});
useLayoutEffect(() => {
const el = scroller.current;
if (!el || !stick.current || !lines?.length) return;
programmatic.current = true;
virtualizer.scrollToIndex(lines.length - 1, { align: "end" });
el.scrollTop = el.scrollHeight;
requestAnimationFrame(() => (programmatic.current = false));
}, [lines, virtualizer]);
const submit = async () => {
const e = input.trim();
if (!e) return;
pushHistory("console", e);
history.current = readHistory("console");
historyPos.current = -1;
setInput("");
appendConsole(session.id, "input", `> ${e}\n`);
try {
const v = await evaluate(e, "repl", session);
appendConsole(session.id, "result", `${v.value}\n`);
} catch (err) {
appendConsole(session.id, "stderr", `${err instanceof Error ? err.message : String(err)}\n`);
}
};
/** IDEA's Browse History popup: the expressions typed before, most recent first. */
const browseHistory = () => {
const el = inputRef.current;
const r = el?.getBoundingClientRect();
const items: MenuItem[] = history.current.length
? history.current.slice(0, 30).map((h, i) => ({
id: String(i),
label: h.length > 120 ? `${h.slice(0, 120)}…` : h,
run: () => {
setInput(h);
historyPos.current = i;
el?.focus();
},
}))
: [{ id: "none", label: "No history", disabled: true }];
showContextMenu({ x: r?.left ?? 120, y: (r?.top ?? 200) - 4 }, items);
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const h = history.current;
if (!h.length) return;
e.preventDefault();
const pos = Math.max(-1, Math.min(h.length - 1, historyPos.current + (e.key === "ArrowUp" ? 1 : -1)));
historyPos.current = pos;
setInput(pos < 0 ? "" : h[pos]!);
}
};
return (
<div className={styles.console}>
<div
ref={scroller}
className={clsx(styles.consoleOutput, ansi.palette)}
onScroll={(e) => {
if (programmatic.current) return;
const el = e.currentTarget;
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
}}
onContextMenu={(e) => {
e.preventDefault();
showContextMenu(e, [{ label: "Clear All", icon: "trash", run: () => clearConsole(session.id) }]);
}}
role="log"
onKeyDown={copySelectionOnKeyDown}
aria-label={tx("Debug console")}
tabIndex={0}
>
<div className={styles.consoleInner} style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => {
const l = lines?.[item.index];
if (!l) return null;
return (
<span
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
className={clsx(styles.out, styles[`cat-${l.category}`])}
style={{ transform: `translateY(${item.start}px)` }}
>
{l.spans.map((sp, i) => (
<span key={i} style={spanStyle(sp)}>
{sp.text}
</span>
))}
</span>
);
})}
</div>
</div>
<label className={styles.repl}>
<span className={styles.prompt} aria-hidden>
›
</span>
<input
ref={inputRef}
className={styles.evalInput}
onFocus={() => setConsoleHistoryBrowser(browseHistory)}
onBlur={() => setConsoleHistoryBrowser(null)}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
placeholder={isLive(session) ? "Evaluate in the debug console" : "Session finished"}
disabled={!isLive(session)}
aria-label={tx("Debug console input")}
spellCheck={false}
autoComplete="off"
/>
<IconButton
icon="trash"
title="Clear Console"
size="small"
iconSize={12}
onClick={() => clearConsole(session.id)}
/>
</label>
</div>
);
}