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>
);
}
+6
Переглянути файл
@@ -29,3 +29,9 @@
gap: 6px;
padding: 0 12px 12px;
}
.debugConsole {
display: flex;
height: 100%;
min-height: 0;
}
+14 -5
Переглянути файл
@@ -43,7 +43,15 @@ function sessionIcon(s: RunSession): string {
return s.config.isTest ? "check" : "run";
}
export function SessionPanel({ session }: { session: RunSession }) {
export function SessionPanel({
session,
onRerun,
onStop,
}: {
session: RunSession;
onRerun?: (() => void) | null;
onStop?: () => void;
}) {
const version = useRun((s) => s.versions[session.id] ?? 0);
const rt = runtime(session.id);
const [follow, setFollow] = useState(true);
@@ -123,7 +131,8 @@ export function SessionPanel({ session }: { session: RunSession }) {
iconSize={14}
title="Rerun"
shortcut={shortcutLabel("run.rerun")}
onClick={() => rerunSession(session.id)}
disabled={onRerun === null}
onClick={onRerun ?? (() => rerunSession(session.id))}
/>
{rt.tests && (
<IconButton
@@ -140,9 +149,9 @@ export function SessionPanel({ session }: { session: RunSession }) {
icon="stop"
iconSize={13}
title="Stop"
tone={running ? "danger" : "default"}
disabled={!running}
onClick={() => void stopSession(session.id)}
tone={running || onStop ? "danger" : "default"}
disabled={!running && !onStop}
onClick={onStop ?? (() => void stopSession(session.id))}
/>
{session.config.kind === "tomcat" && (
<IconButton
+2 -1
Переглянути файл
@@ -39,7 +39,7 @@ import { registerGoActions } from "./goActions";
import { invalidateGoModules } from "./goModules";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { runServiceProvider } from "./services";
import { recordRun, runServiceProvider } from "./services";
import { runBeforeLaunch } from "./beforeLaunch";
import { compoundChildren } from "./configs";
import { remoteDebugBlocked } from "./remote";
@@ -106,6 +106,7 @@ async function debugConfig(config: RunConfig, chain: readonly string[] = []) {
}
if (remoteDebugBlocked(config)) return;
if (!(await prepareLaunch(config, chain))) return;
recordRun(config.id);
// Remote JVM Debug, Go Remote…: the debugger attaches, nothing is launched here.
if (isDebugOnly(config) && getAction("debug.startConfig")) {
await executeAction("debug.startConfig", config);
+9
Переглянути файл
@@ -50,6 +50,7 @@ import { activate } from "./index";
import { remoteDebugBlocked, remoteLabel, startRemedy, startWithRemedies, withTarget, type RemedyDeps } from "./remote";
import { resetRuns, saveUserConfigs, startRun, useRun } from "./store";
import { newConfigOfType } from "./types";
import { readRan } from "./services";
const cfg = (id: string, extra: Partial<RunConfig> = {}): RunConfig => ({
id,
@@ -175,6 +176,14 @@ describe("remote debugging", () => {
await executeAction("run.debugConfig", local.id);
expect(startConfig).toHaveBeenCalledWith(local);
});
it("remembers a non-Spring-Boot configuration accepted for debugging", async () => {
useProject.setState({ project: { root: "/remember-debug" } as never });
const local = cfg("python:debug", { debugAdapter: "debugpy" });
useRun.setState({ configs: [local], loaded: true });
await executeAction("run.debugConfig", local.id);
expect(readRan("/remember-debug")).toContain(local.id);
});
});
describe("SSH remedies when starting", () => {
+174
Переглянути файл
@@ -0,0 +1,174 @@
import { expect, it, vi } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import type { DebugServiceSession } from "@/workbench/contributions";
import type { RunSession } from "./store";
import { currentServiceSession, serviceDebugger, serviceSessions, stopServiceSessions } from "./serviceSessions";
const { stopRun, waitForExit, getSession } = vi.hoisted(() => ({
stopRun: vi.fn(),
waitForExit: vi.fn(),
getSession: vi.fn(),
}));
vi.mock("./store", () => ({
stopSession: stopRun,
waitForExit,
useRun: { getState: () => ({ sessions: [getSession()].filter(Boolean) }) },
}));
const config = (id = "app"): RunConfig => ({
id,
name: "same name",
kind: "spring-boot",
command: "java",
args: [],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: "java-debug",
});
const run = (id: string, startedAt: number, extra: Partial<RunSession> = {}): RunSession => ({
id,
config: config(),
startedAt,
runId: 7,
status: "running",
endedAt: null,
exitCode: null,
stdinClosed: false,
...extra,
});
const debug = (id: number, startedAt: number, extra: Partial<DebugServiceSession> = {}): DebugServiceSession => ({
id,
config: config(),
startedAt,
status: "running",
processId: null,
stop: vi.fn(async () => {}),
rerun: vi.fn(),
show: vi.fn(),
...extra,
});
it("prefers a live debug launch to stale Run output and isolates equal names by config id", () => {
const d = debug(1, 2);
const sessions = serviceSessions(
[run("old", 1, { status: "finished" })],
[d, debug(2, 3, { config: config("other") })],
);
expect(currentServiceSession("app", sessions)?.debug).toEqual([d]);
expect(currentServiceSession("other", sessions)?.debug[0]?.id).toBe(2);
});
it("keeps an older live instance ahead of a newer finished one and uses stable tie breaking", () => {
const active = debug(1, 1);
const sessions = serviceSessions([run("done", 10, { status: "finished" })], [active]);
expect(currentServiceSession("app", sessions)?.debug).toEqual([active]);
const tied = serviceSessions([run("a", 1)], [active]);
expect(currentServiceSession("app", tied)?.debug).toEqual([active]);
expect(currentServiceSession("app", [...tied].reverse())?.debug).toEqual([active]);
});
it("pairs all debugger roots with the same Run process without guessing by config id alone", () => {
const r = run("maven", 1);
const d = [debug(1, 2, { processId: 7 }), debug(2, 3, { processId: 7 })];
const sessions = serviceSessions([r], [...d, debug(3, 4, { processId: 8 })]);
expect(sessions).toHaveLength(2);
expect(sessions[0]).toMatchObject({ run: r, debug: d, startedAt: 1 });
expect(serviceDebugger(sessions[0])).toBe(d[1]);
expect(serviceSessions([r], [debug(4, 5, { config: config("other"), processId: 7 })])).toHaveLength(2);
});
it("falls back to Run controls when its attached debugger has terminated", () => {
const sessions = serviceSessions([run("maven", 1)], [debug(1, 2, { processId: 7, status: "terminated" })]);
expect(serviceDebugger(sessions[0])).toBeUndefined();
expect(currentServiceSession("app", sessions)?.run?.id).toBe("maven");
});
it("stops the displayed execution without stopping another parallel instance", async () => {
const first = debug(1, 1);
const latest = debug(2, 2);
const selected = currentServiceSession("app", serviceSessions([], [first, latest]))!;
await stopServiceSessions([selected]);
expect(latest.stop).toHaveBeenCalledOnce();
expect(first.stop).not.toHaveBeenCalled();
});
it("awaits shared debugger stops without a retained Run and terminates their process only once", async () => {
stopRun.mockClear();
const calls: string[] = [];
const first = debug(1, 1, {
processId: 7,
stop: vi.fn(async (options) => {
await Promise.resolve();
calls.push(options?.stopProcess === false ? "detach first" : "stop process first");
}),
});
const last = debug(2, 2, {
processId: 7,
stop: vi.fn(async (options) => {
calls.push(options?.stopProcess === false ? "detach last" : "stop process last");
}),
});
const targets = serviceSessions([], [first, last]);
await stopServiceSessions([...targets, ...targets]);
expect(calls).toEqual(["detach first", "stop process last"]);
expect(stopRun).not.toHaveBeenCalled();
});
it("stops the owning Run before detaching shared roots so JDWP cannot trigger another attachment", async () => {
stopRun.mockClear();
let processAlive = true;
let reattachments = 0;
const calls: string[] = [];
stopRun.mockImplementationOnce(async () => {
calls.push("stop run");
});
let exit!: () => void;
waitForExit.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
exit = () => {
processAlive = false;
resolve();
};
}),
);
getSession.mockImplementationOnce(() => run("maven", 0, { status: "finished" }));
const roots = [1, 2].map((id) =>
debug(id, id, {
processId: 7,
stop: vi.fn(async () => {
await Promise.resolve();
if (processAlive) reattachments++;
calls.push(`detach ${id}`);
}),
}),
);
const targets = serviceSessions([run("maven", 0)], roots);
const stopping = stopServiceSessions([...targets, ...targets]);
await vi.waitFor(() => expect(stopRun).toHaveBeenCalledOnce());
for (const root of roots) expect(root.stop).not.toHaveBeenCalled();
exit();
await stopping;
expect(reattachments).toBe(0);
expect(calls).toEqual(["stop run", "detach 1", "detach 2"]);
expect(stopRun).toHaveBeenCalledExactlyOnceWith("maven");
for (const root of roots) expect(root.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false });
});
it("stops all group executions including unpaired Run and ignores finished roots", async () => {
stopRun.mockClear();
const done = debug(3, 1, { status: "terminated" });
const running = debug(4, 2);
await stopServiceSessions(serviceSessions([run("plain", 1)], [done, running]));
expect(stopRun).toHaveBeenCalledWith("plain");
expect(running.stop).toHaveBeenCalledOnce();
expect(done.stop).not.toHaveBeenCalled();
});
it("does not detach a JVM still running after the exit wait expires", async () => {
getSession.mockReturnValueOnce(run("maven", 0));
const root = debug(1, 1, { processId: 7 });
await stopServiceSessions(serviceSessions([run("maven", 0)], [root]));
expect(root.stop).not.toHaveBeenCalled();
});
+97
Переглянути файл
@@ -0,0 +1,97 @@
import type { RunConfig } from "@/platform/ipc";
import type { DebugServiceSession } from "@/workbench/contributions";
import { stopSession, useRun, waitForExit, type RunSession } from "./store";
/** One execution: a Run process and its attached debuggers, or a direct debug launch. */
export interface ServiceSession {
config: RunConfig;
startedAt: number;
run?: RunSession;
debug: DebugServiceSession[];
}
const running = (s: RunSession) => s.status === "running" || s.status === "starting";
const debugging = (s: DebugServiceSession) => s.status !== "terminated";
export function serviceSessions(runs: readonly RunSession[], debug: readonly DebugServiceSession[]): ServiceSession[] {
const out = runs.map((run): ServiceSession => ({ config: run.config, startedAt: run.startedAt, run, debug: [] }));
const processes = new Map(out.filter((s) => s.run!.runId !== null).map((s) => [s.run!.runId, s]));
for (const session of debug) {
const owner = session.processId === null ? undefined : processes.get(session.processId);
if (owner && owner.config.id === session.config.id) owner.debug.push(session);
else out.push({ config: session.config, startedAt: session.startedAt, debug: [session] });
}
return out;
}
export function liveServiceSession(session: ServiceSession): boolean {
return !!(session.run && running(session.run)) || session.debug.some(debugging);
}
/** Only a live debugger owns controls; retained output alone cannot claim a live Run. */
export function serviceDebugger(session: ServiceSession | undefined): DebugServiceSession | undefined {
return session?.debug.filter(debugging).sort((a, b) => b.startedAt - a.startedAt || b.id - a.id)[0];
}
function compareSessions(a: ServiceSession, b: ServiceSession): number {
const key = (s: ServiceSession) => s.run?.id ?? `debug:${s.debug[0]!.id}`;
return (
Number(liveServiceSession(b)) - Number(liveServiceSession(a)) ||
b.startedAt - a.startedAt ||
Number(b.debug.length > 0) - Number(a.debug.length > 0) ||
key(a).localeCompare(key(b))
);
}
export function currentServiceSessions(sessions: readonly ServiceSession[]): Map<string, ServiceSession> {
const out = new Map<string, ServiceSession>();
for (const session of sessions) {
const previous = out.get(session.config.id);
if (!previous || compareSessions(session, previous) < 0) out.set(session.config.id, session);
}
return out;
}
export function currentServiceSession(
configId: string,
sessions: readonly ServiceSession[],
): ServiceSession | undefined {
return currentServiceSessions(sessions).get(configId);
}
/** A build process may expose several JDWP ports, each with its own debugger root. */
export async function stopServiceSessions(sessions: readonly ServiceSession[]): Promise<void> {
const roots = new Map<number, DebugServiceSession>();
const runs = new Map<string, RunSession>();
for (const s of sessions) {
for (const d of s.debug) if (debugging(d)) roots.set(d.id, d);
if (s.run && running(s.run)) runs.set(s.run.id, s.run);
}
const groups = new Map<number, DebugServiceSession[]>();
for (const d of roots.values()) {
if (d.processId === null) await d.stop();
else {
const group = groups.get(d.processId) ?? [];
group.push(d);
groups.set(d.processId, group);
}
}
for (const [processId, group] of groups) {
const run = [...runs.values()].find((s) => s.runId === processId);
if (run) {
// Detaching a live JVM republishes its JDWP port and can trigger another attachment.
await stopSession(run.id);
await waitForExit([run.id]);
if (useRun.getState().sessions.some((s) => s.id === run.id && running(s))) {
runs.delete(run.id);
continue;
}
for (const d of group) await d.stop({ stopProcess: false });
} else {
for (const d of group.slice(0, -1)) await d.stop({ stopProcess: false });
await group.at(-1)!.stop();
}
for (const [id, run] of runs) if (run.runId === processId) runs.delete(id);
}
for (const run of runs.values()) await stopSession(run.id);
}
+51
Переглянути файл
@@ -3,6 +3,7 @@ import type { RunConfig } from "@/platform/ipc";
import { DEFAULT_TYPES, readRan, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services";
import { startedPort } from "./serverPort";
import type { RunSession } from "./store";
import type { DebugServiceSession } from "@/workbench/contributions";
const config = (id: string, kind: string): RunConfig => ({
id,
@@ -41,6 +42,56 @@ describe("run configurations in Services", () => {
const dev = config("dev", "npm");
const scratch = config("context:file", "python");
it.each(["spring-boot", "node", "python", "go", "cargo"])(
"shows configured %s debug roots without a Run session",
(kind) => {
const c = config("debug-app", kind);
const d: DebugServiceSession = {
id: 1,
config: c,
status: "running",
processId: null,
startedAt: 2,
stop: async () => {},
rerun: null,
show: () => {},
};
const build = (status: DebugServiceSession["status"]) => runServiceItems([c], [], [], [], {}, [{ ...d, status }]);
expect(build("running")[0]?.children?.[0]).toMatchObject({
key: "run:config:debug-app",
detail: "Debugging",
icon: "debug",
dim: false,
});
expect(build("paused")[0]?.children?.[0]?.detail).toBe("Suspended");
expect(build("terminated")[0]?.children?.[0]).toMatchObject({ detail: "Finished", dim: true });
},
);
it("does not reuse an old Run port for a new direct debug launch or double-count an attachment", () => {
const c = config("app", "spring-boot");
const r = session("run", c, "finished", 1, { runId: 7, httpPort: 8080 });
const d: DebugServiceSession = {
id: 1,
config: c,
status: "running",
processId: null,
startedAt: 2,
stop: async () => {},
rerun: null,
show: () => {},
};
const direct = runServiceItems([c], [r], DEFAULT_TYPES, [], {}, [d]);
expect(direct[0]?.detail).toBe("1 running");
expect(direct[0]?.children?.[0]?.detail).toBe("Debugging");
const attached = runServiceItems([c], [{ ...r, status: "running" }], DEFAULT_TYPES, [], {}, [
{ ...d, processId: 7 },
]);
expect(attached[0]?.detail).toBe("1 running");
expect(attached[0]?.children).toHaveLength(1);
expect(attached[0]?.children?.[0]?.detail).toBe(":8080");
});
it("applies display order to detected and saved configurations of every language", () => {
const configs = [
config("java-a", "spring-boot"),
+88 -36
Переглянути файл
@@ -19,13 +19,24 @@ import {
toolWindowService,
notifications,
useRunConfigTypes,
useDebugRunSessions,
type DebugServiceSession,
type ServiceItemAction,
type ServiceProviderContribution,
type ServiceTreeItem,
} from "@/workbench/contributions";
import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs";
import { SessionPanel } from "./RunView";
import { startRun, stopSession, useRun, type RunSession } from "./store";
import { startRun, useRun, type RunSession } from "./store";
import {
currentServiceSession,
currentServiceSessions,
liveServiceSession,
serviceDebugger,
serviceSessions,
stopServiceSessions,
type ServiceSession,
} from "./serviceSessions";
import { isDebugOnly } from "./types";
import {
currentServiceOrder,
@@ -129,16 +140,6 @@ export function recordRun(configId: string) {
const busy = (s: RunSession) => s.status === "running" || s.status === "starting";
/** The newest session of each configuration id. */
function latestSessions(sessions: readonly RunSession[]): Map<string, RunSession> {
const out = new Map<string, RunSession>();
for (const s of sessions) {
const prev = out.get(s.config.id);
if (!prev || s.startedAt >= prev.startedAt) out.set(s.config.id, s);
}
return out;
}
export function sessionStatus(s: RunSession | undefined): string | undefined {
if (!s) return undefined;
if (busy(s)) return s.status === "starting" ? "Starting" : "Running";
@@ -152,6 +153,19 @@ export function sessionDetail(s: RunSession | undefined): string | undefined {
return s && busy(s) && s.httpPort ? `:${s.httpPort}` : sessionStatus(s);
}
function executionDetail(s: ServiceSession | undefined): string | undefined {
if (!s) return undefined;
const debug = serviceDebugger(s);
if (debug?.status === "paused") return "Suspended";
if (debug)
return s.run && busy(s.run) && s.run.httpPort
? `:${s.run.httpPort}`
: debug.status === "starting"
? "Starting"
: "Debugging";
return s.run ? sessionDetail(s.run) : "Finished";
}
/**
* Groups by configuration type: added types list all their configurations, other
* types only those that ran. A default type (Spring Boot) without configurations is hidden.
@@ -162,10 +176,12 @@ export function runServiceItems(
added: readonly string[],
ran: readonly string[] = [],
order: ServiceOrder = {},
debug: readonly DebugServiceSession[] = [],
): ServiceTreeItem[] {
const latest = latestSessions(sessions);
const executions = serviceSessions(sessions, debug);
const latest = currentServiceSessions(executions);
const kinds = [...added];
for (const s of sessions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
for (const s of executions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind);
for (const id of ran) {
const kind = configs.find((c) => c.id === id)?.kind;
if (kind && !kinds.includes(kind)) kinds.push(kind);
@@ -182,14 +198,14 @@ export function runServiceItems(
return {
key: CONFIG_PREFIX + c.id,
label: c.name,
icon: s && busy(s) ? "run" : configIcon(c),
detail: sessionDetail(s),
dim: !s || !busy(s),
icon: serviceDebugger(s) ? "debug" : s && liveServiceSession(s) ? "run" : configIcon(c),
detail: executionDetail(s),
dim: !s || !liveServiceSession(s),
};
});
const running = entries.filter((c) => {
const s = latest.get(c.id);
return !!s && busy(s);
return !!s && liveServiceSession(s);
}).length;
return [
{
@@ -210,6 +226,7 @@ function useRoots(): ServiceTreeItem[] {
const order = useServiceOrder(projectLocation(root));
const configs = useRun((s) => s.configs);
const sessions = useRun((s) => s.sessions);
const debug = useDebugRunSessions((s) => s.serviceSessions);
const types = useAddedTypes((s) => s.types);
const typesRoot = useAddedTypes((s) => s.root);
const ran = useRanConfigs((s) => s.ids);
@@ -219,28 +236,29 @@ function useRoots(): ServiceTreeItem[] {
if (ranRoot !== root) useRanConfigs.setState({ root, ids: root ? readRan(root) : [] });
}, [root, typesRoot, ranRoot]);
return useMemo(
() => runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order),
[configs, sessions, types, typesRoot, ran, ranRoot, root, order],
() =>
runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order, debug),
[configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug],
);
}
interface Resolved {
kind: string | null;
config: RunConfig | null;
session: RunSession | undefined;
session: ServiceSession | undefined;
/** Sessions of a group's configurations. */
sessions: RunSession[];
sessions: ServiceSession[];
}
function resolve(item: ServiceTreeItem): Resolved {
const { configs, sessions } = useRun.getState();
const latest = latestSessions(sessions);
const executions = serviceSessions(sessions, useDebugRunSessions.getState().serviceSessions);
if (item.key.startsWith(TYPE_PREFIX)) {
const kind = item.key.slice(TYPE_PREFIX.length);
return { kind, config: null, session: undefined, sessions: sessions.filter((s) => s.config.kind === kind) };
return { kind, config: null, session: undefined, sessions: executions.filter((s) => s.config.kind === kind) };
}
const id = item.key.slice(CONFIG_PREFIX.length);
const session = latest.get(id);
const session = currentServiceSession(id, executions);
const config = configs.find((c) => c.id === id) ?? session?.config ?? null;
return { kind: config?.kind ?? null, config, session, sessions: session ? [session] : [] };
}
@@ -252,36 +270,42 @@ function showOutput(s: RunSession) {
/** Runs (or reruns, in its tab) a configuration; the output stays in Services instead of opening Run. */
function run(r: Resolved) {
if (r.session && r.session.config.id === r.config?.id)
void startRun(r.session.config, r.session.id, r.session.baseConfig, { show: false });
else if (r.config) void startRun(r.config, undefined, undefined, { show: false });
const debug = serviceDebugger(r.session);
if (debug) debug.rerun?.();
else if (r.session?.run) {
const s = r.session.run;
void startRun(s.config, s.id, s.baseConfig, { show: false });
} else if (r.config) void startRun(r.config, undefined, undefined, { show: false });
}
function debug(r: Resolved) {
if (r.config) void executeAction("run.debugConfig", r.config);
const session = serviceDebugger(r.session);
if (session) session.rerun?.();
else if (r.config) void executeAction("run.debugConfig", r.config);
}
function actions(item: ServiceTreeItem): ServiceItemAction[] {
const r = resolve(item);
const running = r.sessions.filter(busy);
const running = r.sessions.filter(liveServiceSession);
const stop = {
id: "stop",
title: "Stop",
icon: "stop",
disabled: !running.length,
run: () => running.forEach((s) => void stopSession(s.id)),
run: () => void stopServiceSessions(running),
};
if (!r.config) return [stop];
const live = !!r.session && busy(r.session);
const live = !!r.session && liveServiceSession(r.session);
const debugging = serviceDebugger(r.session);
return [
{
id: "run",
title: live ? "Rerun" : "Run",
icon: live ? "rerun" : "run",
disabled: isDebugOnly(r.config),
disabled: debugging ? !debugging.rerun : isDebugOnly(r.config),
run: () => run(r),
},
{ id: "debug", title: "Debug", icon: "debug", run: () => debug(r) },
{ id: "debug", title: "Debug", icon: "debug", disabled: !!debugging && !debugging.rerun, run: () => debug(r) },
stop,
];
}
@@ -301,7 +325,8 @@ function menu(item: ServiceTreeItem): MenuItem[] {
}
if (r.config) {
const config = r.config;
const session = r.session;
const session = r.session?.run;
const debuggerSession = serviceDebugger(r.session) ?? r.session?.debug.at(-1);
items.push(
{ kind: "separator" },
{
@@ -310,6 +335,12 @@ function menu(item: ServiceTreeItem): MenuItem[] {
disabled: !session,
run: () => session && showOutput(session),
},
{
label: "Show in Debug Tool Window",
icon: "toolwindow-debug",
disabled: !debuggerSession,
run: () => debuggerSession?.show(),
},
{ label: "Edit Configuration…", icon: "edit", run: () => void executeAction("run.editConfigs", config.id) },
);
}
@@ -319,6 +350,8 @@ function menu(item: ServiceTreeItem): MenuItem[] {
function RunDetail({ item }: { item: ServiceTreeItem }) {
// Re-render on session changes.
useRun((s) => s.sessions);
const debugViews = useDebugRunSessions();
const Console = debugViews.Console;
const t = useT();
const txTemplate = useTxTemplate();
const r = resolve(item);
@@ -333,7 +366,25 @@ function RunDetail({ item }: { item: ServiceTreeItem }) {
</EmptyState>
);
// The configuration ran: its console, as in the Run tool window.
if (r.session) return <SessionPanel session={r.session} />;
if (r.session?.run) {
const execution = r.session;
const d = serviceDebugger(execution);
return (
<SessionPanel
key={execution.run!.id}
session={execution.run!}
onRerun={d ? d.rerun : undefined}
onStop={d ? () => void stopServiceSessions([execution]) : undefined}
/>
);
}
const d = r.session?.debug[0];
if (d && Console)
return (
<div className={styles.debugConsole}>
<Console key={d.id} sessionId={d.id} />
</div>
);
return (
<div className={styles.detail}>
<dl className={styles.facts}>
@@ -400,7 +451,7 @@ export const runServiceProvider: ServiceProviderContribution = {
menu,
activate: (item) => {
const r = resolve(item);
if (r.config && !(r.session && busy(r.session)) && !isDebugOnly(r.config)) run(r);
if (r.config && !(r.session && liveServiceSession(r.session)) && !isDebugOnly(r.config)) run(r);
},
detail: RunDetail,
addMenu,
@@ -425,6 +476,7 @@ export const runServiceProvider: ServiceProviderContribution = {
currentTypes(),
ranIds(useProject.getState().project?.root ?? null),
order,
useDebugRunSessions.getState().serviceSessions,
).find((g) => g.key === TYPE_PREFIX + kind);
const ids = group?.children?.map((c) => c.key.slice(CONFIG_PREFIX.length)) ?? [];
const from = source.key.slice(CONFIG_PREFIX.length),
+158
Переглянути файл
@@ -0,0 +1,158 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { commands, type RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { validateSettings } from "@/features/settings/model/schema";
import { adoptSession, appendConsole, useDebug } from "@/features/debugger/state";
import { DebugServiceConsole } from "@/features/debugger/ui/DebugConsole";
import { runServiceProvider } from "@/features/run/services";
import { onRunExited, runtime, startRun, useRun } from "@/features/run/store";
import { registerServiceProvider, useDebugRunSessions, type DebugServiceSession } from "@/workbench/contributions";
import { ServicesTreeView } from "./ServicesTree";
import { useServicesTree } from "./tree";
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 18,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({ index, key: index, start: index * 18, size: 18 })),
measureElement: () => {},
measure: () => {},
scrollToIndex: () => {},
}),
}));
const config: RunConfig = {
id: "gateway",
name: "Gateway",
kind: "spring-boot",
command: "java",
args: [],
cwd: null,
env: {},
detected: true,
isTest: false,
debugAdapter: "java-debug",
};
const summary = (id: number, extra: Partial<DebugServiceSession> = {}): DebugServiceSession => ({
id,
config,
startedAt: id,
status: "running",
processId: null,
stop: vi.fn(async () => {}),
rerun: vi.fn(),
show: vi.fn(),
...extra,
});
let dispose: () => void;
beforeEach(() => {
localStorage.clear();
useProject.setState({
project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) },
});
useRun.setState({ configs: [config], sessions: [], loaded: true });
useDebug.setState({ sessions: {}, console: {}, active: null });
useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: DebugServiceConsole });
useServicesTree.setState({
roots: {},
selectedKey: null,
selectedKeys: new Set(),
expanded: new Set(["run:type:spring-boot"]),
});
dispose = registerServiceProvider(runServiceProvider);
});
afterEach(() => {
vi.restoreAllMocks();
dispose();
useDebugRunSessions.setState({ sessions: [], serviceSessions: [], Console: null });
});
const select = () =>
fireEvent.mouseDown(
within(screen.getByRole("tree", { name: "Services" }))
.getByText("Gateway")
.closest('[role="treeitem"]')!,
);
const toolbar = () => within(screen.getByRole("toolbar", { name: "Services actions" }));
it("shows debug output/state, pins actions, and does not rebuild the tree for stdout", async () => {
adoptSession(1, { runConfig: config });
adoptSession(2, { runConfig: { ...config, id: "other" } });
appendConsole(1, "stdout", "gateway log\n");
appendConsole(2, "stdout", "other log\n");
const d = summary(1);
useDebugRunSessions.setState({ serviceSessions: [d] });
useDebug.setState({ active: 2 });
render(<ServicesTreeView />);
select();
expect(screen.getByRole("log")).toHaveTextContent("gateway log");
expect(screen.getByRole("log")).not.toHaveTextContent("other log");
expect(toolbar().getByRole("button", { name: "Stop" })).toBeEnabled();
fireEvent.click(toolbar().getByRole("button", { name: "Rerun" }));
expect(d.rerun).toHaveBeenCalledOnce();
expect(useDebug.getState().active).toBe(2);
const roots = useServicesTree.getState().roots;
act(() => appendConsole(1, "stdout", "next log\n"));
expect(screen.getByRole("log")).toHaveTextContent("next log");
expect(useServicesTree.getState().roots).toBe(roots);
act(() => useDebugRunSessions.setState({ serviceSessions: [{ ...d, status: "paused" }] }));
expect(within(screen.getByRole("tree")).getByText("Suspended")).toBeTruthy();
fireEvent.click(toolbar().getByRole("button", { name: "Stop" }));
expect(d.stop).toHaveBeenCalledOnce();
});
it("keeps the other parallel instance alive and then displays it", async () => {
adoptSession(1);
adoptSession(2);
appendConsole(1, "stdout", "first instance\n");
appendConsole(2, "stdout", "second instance\n");
const first = summary(1),
second = summary(2);
useDebugRunSessions.setState({ serviceSessions: [first, second] });
render(<ServicesTreeView />);
select();
expect(screen.getByRole("log")).toHaveTextContent("second instance");
fireEvent.click(toolbar().getByRole("button", { name: "Stop" }));
expect(second.stop).toHaveBeenCalledOnce();
expect(first.stop).not.toHaveBeenCalled();
act(() => useDebugRunSessions.setState({ serviceSessions: [first, { ...second, status: "terminated" }] }));
expect(screen.getByRole("log")).toHaveTextContent("first instance");
});
it("shows the owning Run output for attached debugging and overrides both sets of controls", async () => {
const stopRun = vi.spyOn(commands, "runStop").mockImplementation(async (id) => {
queueMicrotask(() => onRunExited(id, 130));
return { status: "ok", data: null };
});
const c = { ...config, kind: "shell", command: "echo", debugAdapter: null };
const s = await startRun(c);
expect(s).not.toBeNull();
const active = { ...s!, config, status: "running" as const, runId: 7 };
runtime(s!.id)!.console.append({ kind: "stdout", text: "Maven application log\n" });
useRun.setState({ configs: [config], sessions: [active] });
const d = summary(1, { processId: 7 });
useDebugRunSessions.setState({ serviceSessions: [d] });
render(<ServicesTreeView />);
select();
expect(screen.getByRole("log")).toHaveTextContent("Maven application log");
const actions = within(screen.getByRole("toolbar", { name: "Run actions" }));
fireEvent.click(actions.getByRole("button", { name: "Rerun" }));
expect(d.rerun).toHaveBeenCalledOnce();
await act(async () => fireEvent.click(actions.getByRole("button", { name: "Stop" })));
expect(stopRun).toHaveBeenCalledExactlyOnceWith(7);
await vi.waitFor(() => expect(d.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false }));
act(() => useRun.setState({ sessions: [{ ...active, status: "finished" }] }));
expect(actions.getByRole("button", { name: "Stop" })).toBeEnabled();
});
it("group Stop stops all roots but terminates a shared Maven process only once", async () => {
const first = summary(1, { processId: 7 }),
second = summary(2, { processId: 7 });
useDebugRunSessions.setState({ serviceSessions: [first, second] });
render(<ServicesTreeView />);
fireEvent.mouseDown(within(screen.getByRole("tree")).getByText("Spring Boot").closest('[role="treeitem"]')!);
fireEvent.click(toolbar().getByRole("button", { name: "Stop" }));
await vi.waitFor(() => expect(second.stop).toHaveBeenCalledOnce());
expect(first.stop).toHaveBeenCalledExactlyOnceWith({ stopProcess: false });
expect(second.stop).toHaveBeenCalledExactlyOnceWith();
});
+5 -1
Переглянути файл
@@ -5318,5 +5318,9 @@
"No selected paths can be deployed to {server}": "所选路径均无法部署到 {server}",
"No deployment server covers the selected paths": "没有部署服务器覆盖所选路径",
"Not covered by this server's mappings": "此服务器的映射未覆盖该路径",
"Excluded from deployment": "已从部署中排除"
"Excluded from deployment": "已从部署中排除",
"No debug output available": "暂无可用的调试输出",
"The attached process may not provide standard output to the debugger.": "附加的进程可能不会向调试器提供标准输出。",
"Show in Debug Tool Window": "在调试工具窗口中显示",
"Suspended": "已暂停"
}
+17 -1
Переглянути файл
@@ -1294,4 +1294,20 @@ export interface DebugRunSession {
resume: () => void;
}
export const useDebugRunSessions = create<{ sessions: DebugRunSession[] }>(() => ({ sessions: [] }));
/** Configured debugger roots retained by the debugger, including finished output. */
export interface DebugServiceSession {
id: number;
config: RunConfig;
startedAt: number;
status: "starting" | "running" | "paused" | "terminated";
processId: number | null;
rerun: (() => void) | null;
stop: (options?: { stopProcess?: boolean }) => Promise<void>;
show: () => void;
}
export const useDebugRunSessions = create<{
sessions: DebugRunSession[];
serviceSessions: DebugServiceSession[];
Console: ComponentType<{ sessionId: number }> | null;
}>(() => ({ sessions: [], serviceSessions: [], Console: null }));