diff --git a/src/features/debugger/__tests__/processOutput.test.ts b/src/features/debugger/__tests__/processOutput.test.ts new file mode 100644 index 000000000..f1f24f344 --- /dev/null +++ b/src/features/debugger/__tests__/processOutput.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import type { LaunchConfig } from "@/platform/ipc"; +import { + provideNotificationService, + provideRunOutput, + provideToolWindowService, + type ToolWindowService, +} from "@/workbench/contributions"; +import { removeSession, startSession, useDebug } from "../state"; + +const ipc = vi.hoisted(() => ({ debugStart: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { ...real, isTauri: () => true, commands: { ...real.commands, ...ipc } }; +}); + +const attach: LaunchConfig = { + name: "demo [spring-boot:run]", + adapter: "java-debug", + request: "attach", + program: null, + args: [], + cwd: "/p", + env: {}, + extraJson: JSON.stringify({ hostName: "127.0.0.1", port: 5005 }), +}; + +type Listener = (kind: "stdout" | "stderr" | "system", text: string) => void; +const listeners = new Map(); +const text = (session: number) => (useDebug.getState().console[session] ?? []).map((l) => l.text).join(""); + +beforeEach(() => { + useDebug.setState({ sessions: {}, active: null, console: {} }); + useProject.setState({ project: { root: "/p" } as never }); + provideNotificationService({ notify: vi.fn() }); + provideToolWindowService({ show: vi.fn() } as unknown as ToolWindowService); + listeners.clear(); + provideRunOutput({ + subscribe: (process, listener) => { + // The output so far comes first. + listener("system", "mvn spring-boot:run\n"); + listener("stdout", "[INFO] Building demo\n"); + listeners.set(process, listener); + return () => void listeners.delete(process); + }, + }); + ipc.debugStart.mockResolvedValue({ status: "ok", data: 21 }); +}); + +it("shows the output of the Run process a debugger attached to in its Console", async () => { + expect(await startSession(attach, { process: 7, rerunnable: false })).toBe(21); + listeners.get(7)!("stdout", "Tomcat started on port 8080 (http)\n"); + listeners.get(7)!("stderr", "WARN something\n"); + await vi.waitFor(() => expect(text(21)).toContain("WARN something")); + expect(text(21)).toBe( + "mvn spring-boot:run\n[INFO] Building demo\nTomcat started on port 8080 (http)\nWARN something\n", + ); + const categories = (useDebug.getState().console[21] ?? []).map((l) => l.category); + expect(categories).toEqual(["system", "stdout", "stdout", "stderr"]); +}); + +it("stops following the process once the session is closed", async () => { + await startSession(attach, { process: 7, rerunnable: false }); + expect(listeners.has(7)).toBe(true); + removeSession(21); + expect(listeners.has(7)).toBe(false); +}); + +it("follows nothing for a session the debugger launched itself", async () => { + await startSession({ ...attach, request: "launch" }); + expect(listeners.size).toBe(0); +}); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 1e09716c5..1f0020168 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -24,7 +24,13 @@ import { type Variable, } from "@/platform/ipc"; import { confirmTerminate } from "@/workbench/confirmTerminate"; -import { maybeEditorService, notifications, toolWindowService, useDebugRunSessions } from "@/workbench/contributions"; +import { + maybeEditorService, + notifications, + runOutput, + toolWindowService, + useDebugRunSessions, +} from "@/workbench/contributions"; import { startedPort } from "@/platform/serverPort"; import { breakpointsOf, consumeTemporary, flushBreakpoints, refreshStates, resetStatuses } from "./breakpoints/store"; import { interpolateLog, logParts, suspends } from "./breakpoints/model"; @@ -382,6 +388,21 @@ function republishForPorts() { if (published.some((d) => d.httpPort !== sessionHttpPort(d.id))) publishContext(); } +/** Sessions attached to a process Run started: their Console follows its output. */ +const processOutput = new Map void>(); + +/** Shows the output of the Run process `process` in the Console of `session`, as a launched program's. */ +function followProcessOutput(session: DebugSessionId, process: RunId) { + processOutput.get(session)?.(); + const unsubscribe = runOutput()?.subscribe(process, (kind, text) => appendConsoleQueued(session, kind, text)); + if (unsubscribe) processOutput.set(session, unsubscribe); +} + +function unfollowProcessOutput(session: DebugSessionId) { + processOutput.get(session)?.(); + processOutput.delete(session); +} + export function appendConsole(session: DebugSessionId, category: string, raw: string) { if (!raw) return; flushConsoleOutput(); @@ -615,6 +636,7 @@ export async function startSession( }; }); settleStart(id); + if (options.process != null) followProcessOutput(id, options.process); publishContext(); void refreshExceptionFilters(); void loadCapabilities(id); @@ -739,7 +761,10 @@ export function removeSession(id: DebugSessionId) { active: st.active != null && sessions[st.active] ? st.active : neighbour, console, }); - for (const goneId of gone) owned.delete(goneId); + for (const goneId of gone) { + owned.delete(goneId); + unfollowProcessOutput(goneId); + } updateExecutionPoint(); publishContext(); } @@ -1130,6 +1155,7 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { export function resetSessions() { const { sessions } = useDebug.getState(); for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id); + for (const id of [...processOutput.keys()]) unfollowProcessOutput(id); pendingOutput.length = 0; cancelOutputFlush(); ansiStreams.clear(); diff --git a/src/features/run/console.ts b/src/features/run/console.ts index 782f38702..d901f2033 100644 --- a/src/features/run/console.ts +++ b/src/features/run/console.ts @@ -125,6 +125,11 @@ export class ConsoleModel { if (line) this.onLine?.(line.text, line.kind); } + /** The buffered output as plain-text chunks (the line still being written without a line break). */ + textChunks(): OutputChunk[] { + return this.lines.map((l) => ({ kind: l.kind, text: l === this.open ? l.text : `${l.text}\n` })); + } + /** Completes a trailing partial line (at process exit). */ flush() { if (this.open) this.closeLine(); diff --git a/src/features/run/index.ts b/src/features/run/index.ts index db0527a75..983f58bba 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -13,6 +13,7 @@ import { menuGroupItems, useDebugRunSessions, notifications, + provideRunOutput, registerMenuGroup, registerOverlay, registerRunAnythingProvider, @@ -80,6 +81,7 @@ import { startRun, startSampleOutput, stopSession, + subscribeOutput, trackRecentConfigurations, useRun, isLive, @@ -727,6 +729,8 @@ export function activate(): () => void { setBeforeLaunchRunner(runBeforeLaunch); disposers.push(() => setBeforeLaunchRunner(null)); + provideRunOutput({ subscribe: subscribeOutput }); + disposers.push(() => provideRunOutput(undefined)); if (hasProject()) void loadConfigs(); // Reload when run.toml or build files change on disk (cheap; detection is fast). diff --git a/src/features/run/javaDebug.ts b/src/features/run/javaDebug.ts index 688593b1b..7502995da 100644 --- a/src/features/run/javaDebug.ts +++ b/src/features/run/javaDebug.ts @@ -3,7 +3,8 @@ * build tool starts the JVM with a JDWP agent that waits for a debugger * (`spring-boot:run` → `-Dspring-boot.run.jvmArguments`, Surefire → * `-Dmaven.surefire.debug`, Gradle → `--debug-jvm`); when the agent reports - * its port, the Java debugger attaches. Output and the test tree stay in the Run tab. + * its port, the Java debugger attaches. The test tree stays in the Run tab; the + * Debug console shows the output as well. * Maven goals that run the application inside Maven's JVM (`tomcat7:run`, `jetty:run`, * `exec:java`) get the agent through `MAVEN_OPTS`; any other configuration set to the Java * debugger (a script such as `catalina.sh run`) through `JAVA_TOOL_OPTIONS`, which every JVM reads. diff --git a/src/features/run/runOutput.test.ts b/src/features/run/runOutput.test.ts new file mode 100644 index 000000000..ad94ca158 --- /dev/null +++ b/src/features/run/runOutput.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { startSampleOutput, subscribeOutput, useRun } from "./store"; + +describe("run output for other views", () => { + afterEach(() => vi.useRealTimers()); + + it("gives a subscriber the output so far, then each new chunk until it unsubscribes", async () => { + vi.useFakeTimers({ + toFake: ["setTimeout", "clearTimeout", "requestAnimationFrame", "cancelAnimationFrame", "performance"], + }); + const done = startSampleOutput(40, 10); + await vi.advanceTimersByTimeAsync(40); + const session = useRun.getState().sessions.at(-1)!; + // The sample has no process; give it one, as a started run gets. + useRun.setState((s) => ({ sessions: s.sessions.map((x) => (x.id === session.id ? { ...x, runId: 7 } : x)) })); + + const seen: string[] = []; + const unsubscribe = subscribeOutput(7, (kind, text) => seen.push(`${kind}:${text}`)); + const before = seen.length; + expect(before).toBeGreaterThan(0); + expect(seen.every((c) => c.endsWith("\n"))).toBe(true); + + await vi.advanceTimersByTimeAsync(100); + expect(seen.length).toBeGreaterThan(before); + unsubscribe(); + const after = seen.length; + await vi.advanceTimersByTimeAsync(2_000); + await done; + expect(seen.length).toBe(after); + // Every line arrived once: the snapshot and the stream don't overlap. + const lines = seen.join("").split("\n").filter(Boolean); + expect(new Set(lines).size).toBe(lines.length); + }); + + it("gives nothing for a process no session owns", () => { + const seen: string[] = []; + subscribeOutput(12345, (_, text) => seen.push(text))(); + expect(seen).toEqual([]); + }); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index b108bfa66..ad80b51e9 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -116,6 +116,8 @@ interface SessionRuntime { generation: number; /** Called with every complete output line (e.g. waiting for a JDWP agent). */ lineListeners: Array<(text: string) => void>; + /** Called with output as shown (the Debug console of a debugger attached to this process). */ + outputListeners: Set; /** Called once the process exited (run executors: collect a coverage report…). */ onExit?: (exitCode: number | null, stopped: boolean) => void; /** User input typed into the console, not yet sent (IDEA: Enter sends the line). */ @@ -542,7 +544,9 @@ function flush(sessionId: string) { const chunks = rt.queue.splice(0); for (const c of chunks) { const text = c.kind === "stdout" && rt.stdout ? rt.stdout.feed(c.text) : c.text; - if (text) rt.console.append({ ...c, text }); + if (!text) continue; + rt.console.append({ ...c, text }); + for (const l of rt.outputListeners) l(c.kind, text); } bump(sessionId); } @@ -552,6 +556,7 @@ function enqueue(sessionId: string, chunk: OutputChunk) { if (!rt) return; if (rt.terminal) { rt.terminal.append(chunk); + for (const l of rt.outputListeners) l(chunk.kind, chunk.text); return; } rt.queue.push(chunk); @@ -664,6 +669,7 @@ function newRuntime(config: RunConfig): SessionRuntime { flushedAt: -Infinity, generation: 0, lineListeners: [], + outputListeners: new Set(), input: "", terminal: null, terminalSize: null, @@ -1159,6 +1165,22 @@ function notifyExit(sessionId: string, code: number | null) { }); } +type OutputListener = (kind: OutputChunk["kind"], text: string) => void; + +/** + * Follows the output of the session running `process`: the output so far, then + * each chunk as it is shown, until the returned function is called. + */ +export function subscribeOutput(process: RunId, listener: OutputListener): () => void { + const session = useRun.getState().sessions.find((s) => s.runId === process); + const rt = session && runtimes.get(session.id); + if (!rt) return () => {}; + flush(session.id); + if (!rt.terminal) for (const c of rt.console.textChunks()) listener(c.kind, c.text); + rt.outputListeners.add(listener); + return () => void rt.outputListeners.delete(listener); +} + /** Handles the `runExited` backend event. */ export function onRunExited(runId: RunId, code: number | null) { const session = useRun.getState().sessions.find((s) => s.runId === runId); diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index bca8c506c..931704c40 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -17,7 +17,7 @@ import { type ReactNode, type RefObject, } from "react"; -import type { CommitSelection, FileMatches, SearchQuery, RunConfig } from "@/bindings"; +import type { CommitSelection, FileMatches, SearchQuery, RunConfig, RunId } from "@/bindings"; import type { MenuItem } from "@/ui/Menu"; import type { DragData } from "@/ui/pointerDrag"; import type { TreeReorder } from "@/ui/Tree"; @@ -677,11 +677,25 @@ export type EmbeddedTerminalFactory = ( options: EmbeddedTerminalOptions, ) => Promise; +/** + * The output of Run sessions, by process: lets the Debug console of a debugger + * attached to a process the Run feature started (Maven, Gradle, Spring Boot…) + * show that process's output. Provided by the run feature. + */ +export interface RunOutputService { + /** + * Calls `listener` with the output so far, then with each new chunk, until the + * returned function is called. A process no Run session owns gets nothing. + */ + subscribe(process: RunId, listener: (kind: "stdout" | "stderr" | "system", text: string) => void): () => void; +} + const services: { editor?: EditorService; toolWindows?: ToolWindowService; notifications?: NotificationService; embeddedTerminal?: EmbeddedTerminalFactory; + runOutput?: RunOutputService; } = {}; function need(s: T | undefined, name: string): T { @@ -693,6 +707,7 @@ export const provideEditorService = (s: EditorService) => void (services.editor export const provideToolWindowService = (s: ToolWindowService) => void (services.toolWindows = s); export const provideNotificationService = (s: NotificationService) => void (services.notifications = s); export const provideEmbeddedTerminal = (f: EmbeddedTerminalFactory | undefined) => void (services.embeddedTerminal = f); +export const provideRunOutput = (s: RunOutputService | undefined) => void (services.runOutput = s); export const editorService = (): EditorService => need(services.editor, "editor"); export const toolWindowService = (): ToolWindowService => need(services.toolWindows, "toolWindows"); @@ -702,6 +717,8 @@ export const notifications = (): NotificationService => export const maybeEditorService = (): EditorService | undefined => services.editor; /** Creates terminal widgets; provided by the terminal feature. */ export const embeddedTerminal = (): EmbeddedTerminalFactory | undefined => services.embeddedTerminal; +/** Run output by process; provided by the run feature. */ +export const runOutput = (): RunOutputService | undefined => services.runOutput; /** * The commit being prepared (the Commit tool window or dialog), for plugins: