diff --git a/src/features/debugger/__tests__/runOrigin.test.ts b/src/features/debugger/__tests__/runOrigin.test.ts index 0472f27cb..ab4a1a44f 100644 --- a/src/features/debugger/__tests__/runOrigin.test.ts +++ b/src/features/debugger/__tests__/runOrigin.test.ts @@ -53,6 +53,19 @@ it("does not republish service metadata when console output changes", () => { expect(useDebugRunSessions.getState().serviceSessions).toBe(summaries); }); +it("announces the app's web server port once it appears on the console", () => { + adoptSession(1, { runConfig: config }); + const before = useDebugRunSessions.getState().serviceSessions; + expect(before).toHaveLength(1); + appendConsole(1, "stdout", "Tomcat started on port 8080 (http) with context path '/'\n"); + const withPort = useDebugRunSessions.getState().serviceSessions; + expect(withPort).not.toBe(before); + expect(withPort).toMatchObject([{ id: 1, httpPort: 8080 }]); + // Later output without a new port keeps the announced one without republishing. + appendConsole(1, "stdout", "application output\n"); + expect(useDebugRunSessions.getState().serviceSessions).toBe(withPort); +}); + it("stops shared-process debugger roots without stopping their process until requested", async () => { adoptSession(1, { runConfig: config, process: 7 }); await stopSession(1, { stopProcess: false }); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 4a5205573..2d619c5d4 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -25,6 +25,7 @@ import { } from "@/platform/ipc"; import { confirmTerminate } from "@/workbench/confirmTerminate"; import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions"; +import { startedPort } from "@/features/run/serverPort"; import { breakpointsOf, consumeTemporary, flushBreakpoints, refreshStates, resetStatuses } from "./breakpoints/store"; import { interpolateLog, logParts, suspends } from "./breakpoints/model"; import { syncSpecial, useSpecialBreakpoints } from "./breakpoints/special"; @@ -305,6 +306,7 @@ export function publishContext() { startedAt: session.startedAt ?? 0, status: paused ? ("paused" as const) : session.status, processId: session.process, + httpPort: sessionHttpPort(session.id), rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null, stop: (options?: { stopProcess?: boolean }) => stopSession(session.id, options), show: () => { @@ -351,6 +353,30 @@ export function publishContext() { // --- console ----------------------------------------------------------------- +/** + * The HTTP port the session's application announced on its console ("Tomcat started on + * port 8080"), the same embedded web server line the Run console is parsed for. Kept per + * session, so the Services node keeps the port after the line scrolls out of the buffer. + */ +const announcedPorts = new Map(); + +export function sessionHttpPort(id: DebugSessionId): number | undefined { + let port: number | undefined; + for (const line of useDebug.getState().console[id] ?? []) { + const found = startedPort(line.text); + if (found !== null) port = found; + } + if (port !== undefined) announcedPorts.set(id, port); + else port = announcedPorts.get(id); + return port; +} + +/** Republishes Services metadata only when a console announced a port it had not shown yet. */ +function republishForPorts() { + const published = useDebugRunSessions.getState().serviceSessions; + if (published.some((d) => d.httpPort !== sessionHttpPort(d.id))) publishContext(); +} + export function appendConsole(session: DebugSessionId, category: string, raw: string) { if (!raw) return; flushConsoleOutput(); @@ -364,6 +390,7 @@ export function appendConsole(session: DebugSessionId, category: string, raw: st trimConsole(next); return { console: { ...s.console, [session]: next } }; }); + republishForPorts(); } /** @@ -406,6 +433,7 @@ function flushConsoleOutput() { } return { console: consoleBySession }; }); + republishForPorts(); } /** Split styled output at real line breaks, retaining partial lines across events. */ @@ -1078,6 +1106,7 @@ export function resetSessions() { pendingOutput.length = 0; cancelOutputFlush(); ansiStreams.clear(); + announcedPorts.clear(); useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 }); useExecutionPoint.setState({ path: null, line: 0, top: true, values: {}, variables: {} }); publishContext(); diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 8d4c3f610..5465c3bc8 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -109,6 +109,30 @@ describe("run configurations in Services", () => { expect(attached[0]?.children?.[0]?.detail).toBe(":8080"); }); + it("shows the app port of a direct debug session like for Run", () => { + const c = config("app", "spring-boot"); + const d: DebugServiceSession = { + id: 1, + config: c, + status: "running", + processId: null, + startedAt: 2, + httpPort: 8080, + stop: async () => {}, + rerun: null, + show: () => {}, + }; + expect(runServiceItems([c], [], DEFAULT_TYPES, [], {}, [d])[0]?.children?.[0]).toMatchObject({ + detail: ":8080", + icon: "debug", + dim: false, + }); + // A paused debugger still wins over the announced port. + expect( + runServiceItems([c], [], DEFAULT_TYPES, [], {}, [{ ...d, status: "paused" }])[0]?.children?.[0]?.detail, + ).toBe("Suspended"); + }); + 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 7f16de10e..88f700554 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -227,12 +227,14 @@ 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"; + if (debug) { + // The app's HTTP port: a Run owns the console for an attached debugger, a direct + // debug launch announces it on the debug session's own console (#1718). + const run = s.run && busy(s.run) ? s.run : undefined; + const port = run?.httpPort ?? debug.httpPort; + if (port) return `:${port}`; + return debug.status === "starting" ? "Starting" : "Debugging"; + } return s.run ? sessionDetail(s.run) : "Finished"; } diff --git a/src/features/services/ServicesTree.debug.test.tsx b/src/features/services/ServicesTree.debug.test.tsx index 1b3cde063..e7e122956 100644 --- a/src/features/services/ServicesTree.debug.test.tsx +++ b/src/features/services/ServicesTree.debug.test.tsx @@ -101,6 +101,14 @@ it("shows debug output/state, pins actions, and does not rebuild the tree for st expect(d.stop).toHaveBeenCalledOnce(); }); +it("shows the service port announced by a debug session's application", () => { + adoptSession(1, { runConfig: config }); + appendConsole(1, "stdout", "Tomcat started on port 8080 (http) with context path '/'\n"); + useDebugRunSessions.setState({ serviceSessions: [summary(1, { httpPort: 8080 })] }); + render(); + expect(within(screen.getByRole("tree")).getByText(":8080")).toBeTruthy(); +}); + it("keeps the other parallel instance alive and then displays it", async () => { adoptSession(1); adoptSession(2); diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 72c159354..10ddbadd0 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1470,6 +1470,8 @@ export interface DebugServiceSession { startedAt: number; status: "starting" | "running" | "paused" | "terminated"; processId: number | null; + /** The debuggee's HTTP port announced on its console (not the JDWP attach port), if known. */ + httpPort?: number; rerun: (() => void) | null; stop: (options?: { stopProcess?: boolean }) => Promise; show: () => void;