diff --git a/src/features/run/RunView.test.tsx b/src/features/run/RunView.test.tsx new file mode 100644 index 000000000..e76307b9c --- /dev/null +++ b/src/features/run/RunView.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import { debugSession, resetRuns, useRun, type RunSession } from "./store"; +import { sessionIcon } from "./RunView"; + +const actions = vi.hoisted(() => ({ executeAction: vi.fn() })); +vi.mock("@/platform/actions", async (orig) => ({ ...(await orig()), executeAction: actions.executeAction })); + +const config = (extra: Partial = {}): RunConfig => ({ + id: "c", + name: "c", + kind: "spring-boot", + command: "mvn", + args: [], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: null, + ...extra, +}); + +const session = (extra: Partial = {}): RunSession => ({ + id: "s1", + config: config(), + runId: null, + status: "finished", + exitCode: 0, + startedAt: 0, + endedAt: 1, + stdinClosed: false, + ...extra, +}); + +describe("sessionIcon", () => { + it("shows the execution arrow while running, the check for tests", () => { + expect(sessionIcon(session({ status: "running" }))).toBe("run"); + expect(sessionIcon(session({ status: "starting" }))).toBe("run"); + expect(sessionIcon(session({ status: "starting", config: config({ isTest: true }) }))).toBe("check"); + }); + + it("shows the red error icon for a failed exit", () => { + expect(sessionIcon(session({ status: "failed" }))).toBe("error"); + expect(sessionIcon(session({ exitCode: 1 }))).toBe("error"); + }); + + it("shows the configuration's own icon once the process ended (IDEA's third state)", () => { + expect(sessionIcon(session())).toBe("spring-boot"); + expect(sessionIcon(session({ config: config({ kind: "program" }) }))).toBe("file-binary"); + expect(sessionIcon(session({ config: config({ kind: "npm" }) }))).toBe("file-npm"); + }); + + it("never shows the error icon for a session the user stopped", () => { + expect(sessionIcon(session({ exitCode: 1, stopped: true }))).toBe("spring-boot"); + }); +}); + +describe("debugSession", () => { + beforeEach(() => { + resetRuns(); + actions.executeAction.mockClear(); + }); + + it("launches the session's configuration through run.debugConfig", () => { + const c = config(); + useRun.setState({ sessions: [session({ config: c })] }); + debugSession("s1"); + expect(actions.executeAction).toHaveBeenCalledWith("run.debugConfig", c); + }); + + it("prefers the original configuration of a derived run", () => { + const base = config({ id: "base" }); + useRun.setState({ sessions: [session({ config: config({ id: "derived" }), baseConfig: base })] }); + debugSession("s1"); + expect(actions.executeAction).toHaveBeenCalledWith("run.debugConfig", base); + }); + + it("ignores unknown session ids", () => { + debugSession("nope"); + expect(actions.executeAction).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 18964f63d..0d6ddac4a 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -14,6 +14,7 @@ import { clearConsole, closeSessionTab, closeStdin, + debugSession, failedTests, rerunFailed, rerunSession, @@ -31,6 +32,7 @@ import { TestTreeView, formatDuration } from "./TestTreeView"; import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab } from "./stackTrace"; import { showApplicationUpdate, useApplicationUpdates } from "./applicationUpdate"; import { isUpdatable } from "./updatePolicies"; +import { configIcon } from "./configs"; import styles from "./Run.module.css"; import { tx } from "@/platform/i18n"; @@ -38,19 +40,28 @@ function failed(s: RunSession): boolean { return s.status === "failed" || (s.status === "finished" && s.exitCode !== 0 && !s.stopped); } -function sessionIcon(s: RunSession): string { +/** + * Tab icon, IDEA's three states: the green execution arrow while the process is alive, + * the red error badge for a failed exit, and once the process ended the run + * configuration's own icon (Services keeps the same model, see services.tsx). + */ +export function sessionIcon(s: RunSession): string { if (s.status === "running" || s.status === "starting") return s.config.isTest ? "check" : "run"; if (failed(s)) return "error"; - return s.config.isTest ? "check" : "run"; + // Ended (exit or stop): the configuration's own icon — the green arrow stays "still running". + return configIcon(s.config); } export function SessionPanel({ session, onRerun, + onDebug, onStop, }: { session: RunSession; onRerun?: (() => void) | null; + /** Override the Debug button (Services: rerun the attached debugger's launch); null disables it. */ + onDebug?: (() => void) | null; onStop?: () => void; }) { const updating = useApplicationUpdates((s) => s.ids.has(session.id)); @@ -136,6 +147,15 @@ export function SessionPanel({ disabled={onRerun === null} onClick={onRerun ?? (() => rerunSession(session.id))} /> + {/* Switch the launch mode: the same configuration started under the debugger. */} + void debugSession(session.id))} + /> {rt.tests && ( void stopServiceSessions([execution]) : undefined} /> ); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 431b09c2b..ef8616d3b 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -1063,6 +1063,16 @@ export function rerunSession(sessionId: string) { if (session) void startRun(session.config, sessionId, session.baseConfig); } +/** + * "Debug": relaunches the session's configuration in debug mode — the same + * `run.debugConfig` entry the Services view and the editor context menu use + * (`baseConfig` keeps the original configuration for derived launches). + */ +export function debugSession(sessionId: string) { + const session = getSession(sessionId); + if (session) void executeAction("run.debugConfig", session.baseConfig ?? session.config); +} + /** Paths of the failed tests of a session's last run. */ export function failedTests(sessionId: string): string[][] { const tests = runtimes.get(sessionId)?.tests;