diff --git a/src/features/debugger/__tests__/runOrigin.test.ts b/src/features/debugger/__tests__/runOrigin.test.ts index 1f108bf1a..0472f27cb 100644 --- a/src/features/debugger/__tests__/runOrigin.test.ts +++ b/src/features/debugger/__tests__/runOrigin.test.ts @@ -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(); 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", diff --git a/src/features/debugger/__tests__/serviceConsole.test.tsx b/src/features/debugger/__tests__/serviceConsole.test.tsx new file mode 100644 index 000000000..d897c7460 --- /dev/null +++ b/src/features/debugger/__tests__/serviceConsole.test.tsx @@ -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(); + 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(); + 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( + <> + + + , + ); + 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(); + 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(); + expect(screen.getByText("The attached process may not provide standard output to the debugger.")).toBeTruthy(); +}); diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 18ade51d0..ed0bb12cc 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -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), diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 18d3f5202..d983c6289 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -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) { diff --git a/src/features/debugger/ui/DebugConsole.tsx b/src/features/debugger/ui/DebugConsole.tsx new file mode 100644 index 000000000..18a32b9ff --- /dev/null +++ b/src/features/debugger/ui/DebugConsole.tsx @@ -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 ? ( + + ) : ( + + ); +} + +export function DebugConsole({ session, interactive = true }: { session: DebugSession; interactive?: boolean }) { + const lines = useDebug((s) => s.console[session.id]); + const scroller = useRef(null); + const stick = useRef(true); + const programmatic = useRef(false); + const [input, setInput] = useState(""); + const history = useRef(readHistory("console")); + const historyPos = useRef(-1); + const inputRef = useRef(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) => { + 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 ( +
+
{ + 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" && ( + The attached process may not provide standard output to the debugger. + )} +
+ {virtualizer.getVirtualItems().map((item) => { + const l = lines?.[item.index]; + if (!l) return null; + return ( + + {l.spans.map((sp, i) => ( + + {sp.text} + + ))} + + ); + })} +
+
+
+ {interactive && ( + <> + + › + + 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" + /> + + )} + clearConsole(session.id)} + /> +
+
+ ); +} diff --git a/src/features/debugger/ui/DebugToolWindow.tsx b/src/features/debugger/ui/DebugToolWindow.tsx index d8bf9ea82..7a9af34ed 100644 --- a/src/features/debugger/ui/DebugToolWindow.tsx +++ b/src/features/debugger/ui/DebugToolWindow.tsx @@ -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() {
- {tab === "variables" ? : } + {tab === "variables" ? ( + + ) : ( + + )}
); @@ -555,151 +552,3 @@ function VariablesPane({ session }: { session: DebugSession }) { ); } - -// --- console ------------------------------------------------------------------- - -function ConsoleView({ session }: { session: DebugSession }) { - const lines = useDebug((s) => s.console[session.id]); - const scroller = useRef(null); - const stick = useRef(true); - const programmatic = useRef(false); - const [input, setInput] = useState(""); - const history = useRef(readHistory("console")); - const historyPos = useRef(-1); - const inputRef = useRef(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) => { - 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 ( -
-
{ - 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} - > -
- {virtualizer.getVirtualItems().map((item) => { - const l = lines?.[item.index]; - if (!l) return null; - return ( - - {l.spans.map((sp, i) => ( - - {sp.text} - - ))} - - ); - })} -
-
- -
- ); -} diff --git a/src/features/run/RunServices.module.css b/src/features/run/RunServices.module.css index b75407269..288bb3362 100644 --- a/src/features/run/RunServices.module.css +++ b/src/features/run/RunServices.module.css @@ -29,3 +29,9 @@ gap: 6px; padding: 0 12px 12px; } + +.debugConsole { + display: flex; + height: 100%; + min-height: 0; +} diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index ca43eb621..7c9b5a38f 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -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 && ( void stopSession(session.id)} + tone={running || onStop ? "danger" : "default"} + disabled={!running && !onStop} + onClick={onStop ?? (() => void stopSession(session.id))} /> {session.config.kind === "tomcat" && ( = {}): 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", () => { diff --git a/src/features/run/serviceSessions.test.ts b/src/features/run/serviceSessions.test.ts new file mode 100644 index 000000000..4bac8b315 --- /dev/null +++ b/src/features/run/serviceSessions.test.ts @@ -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 => ({ + id, + config: config(), + startedAt, + runId: 7, + status: "running", + endedAt: null, + exitCode: null, + stdinClosed: false, + ...extra, +}); +const debug = (id: number, startedAt: number, extra: Partial = {}): 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((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(); +}); diff --git a/src/features/run/serviceSessions.ts b/src/features/run/serviceSessions.ts new file mode 100644 index 000000000..49f217b7c --- /dev/null +++ b/src/features/run/serviceSessions.ts @@ -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 { + const out = new Map(); + 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 { + const roots = new Map(); + const runs = new Map(); + 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(); + 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); +} diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index ae2106045..8a5ceb93e 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -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"), diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index d11d69937..7f13a708f 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -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 { - const out = new Map(); - 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 }) {
); // The configuration ran: its console, as in the Run tool window. - if (r.session) return ; + if (r.session?.run) { + const execution = r.session; + const d = serviceDebugger(execution); + return ( + void stopServiceSessions([execution]) : undefined} + /> + ); + } + const d = r.session?.debug[0]; + if (d && Console) + return ( +
+ +
+ ); return (
@@ -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), diff --git a/src/features/services/ServicesTree.debug.test.tsx b/src/features/services/ServicesTree.debug.test.tsx new file mode 100644 index 000000000..1904d9f94 --- /dev/null +++ b/src/features/services/ServicesTree.debug.test.tsx @@ -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 => ({ + 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(); + 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(); + 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(); + 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(); + 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(); +}); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 28269b99b..e2c86d147 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "已暂停" } diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index e393b2e28..98e80ef09 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -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; + show: () => void; +} + +export const useDebugRunSessions = create<{ + sessions: DebugRunSession[]; + serviceSessions: DebugServiceSession[]; + Console: ComponentType<{ sessionId: number }> | null; +}>(() => ({ sessions: [], serviceSessions: [], Console: null }));