From f665beb17e9cc1512cdbfd3e1db347a832b988bf Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:12:13 +0800 Subject: [PATCH] fix(run): rerun with the configuration as edited, and drop deleted ones from Services (#1579) Rerunning a run tab launched the configuration snapshot taken when the tab first ran, so edits from Edit Configurations were ignored until the tab was closed. Every rerun path (Run tool window, Services, the exit notification, Debug from a run tab, Rerun Failed Tests / selected tests, Update Application's restart) now uses the saved configuration with the same id, falling back to the snapshot when it was deleted or never saved. Services also drops a configuration deleted after it ran once its run has ended; ad-hoc runs (build tasks, external tools) still show. Fixes #1497 Co-Authored-By: Claude Opus 5.5 --- src/features/run/applicationUpdate.test.ts | 11 +++++++ src/features/run/applicationUpdate.ts | 9 +++--- src/features/run/configs.test.ts | 37 +++++++++++++++++++++- src/features/run/services.test.ts | 11 +++++++ src/features/run/services.tsx | 30 +++++++++++++++--- src/features/run/store.ts | 36 ++++++++++++++++++--- 6 files changed, 119 insertions(+), 15 deletions(-) diff --git a/src/features/run/applicationUpdate.test.ts b/src/features/run/applicationUpdate.test.ts index 58616bc9..35ded3f8 100644 --- a/src/features/run/applicationUpdate.test.ts +++ b/src/features/run/applicationUpdate.test.ts @@ -50,6 +50,7 @@ vi.mock("@/platform/javaLaunch", async (original) => ({ vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild })); vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build, syncTomcatOutputs: mocks.sync })); vi.mock("./store", () => ({ + rerunConfig: (s: Pick) => mocks.state.configs.find((c) => c.id === s.config.id) ?? s.config, startRun: mocks.start, stopSession: mocks.stop, waitForExit: mocks.wait, @@ -105,6 +106,16 @@ describe("Tomcat application updates", () => { expect(mocks.wait.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!); expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", session.config); }); + it("restarts with the configuration as edited since the server started", async () => { + const edited = { ...session.config, name: "Tomcat (edited)" }; + mocks.state.configs = [edited]; + try { + await updateTomcat(session, "restart"); + expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", edited); + } finally { + mocks.state.configs = []; + } + }); it("redeploys when the debugger cannot perform HotSwap", async () => { mocks.action.mockResolvedValue(false); await updateTomcat(session, "classes"); diff --git a/src/features/run/applicationUpdate.ts b/src/features/run/applicationUpdate.ts index 280b9720..02c52a01 100644 --- a/src/features/run/applicationUpdate.ts +++ b/src/features/run/applicationUpdate.ts @@ -13,7 +13,7 @@ import { import { showContextMenu } from "@/ui"; import { buildTomcatArtifact, syncTomcatOutputs } from "./tomcat"; import { runUpdateBuild } from "./buildUpdate"; -import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store"; +import { rerunConfig, startRun, stopSession, useRun, waitForExit, type RunSession } from "./store"; import { isUpdatable, updateAllowed, type UpdateMode } from "./updatePolicies"; export type UpdateSession = Pick & { debug?: DebugServiceSession }; @@ -72,14 +72,15 @@ export async function updateApplication(session: UpdateSession, mode: string): P const debugging = !!session.debug || useDebugRunSessions.getState().sessions.some((s) => s.configId === session.config.id); if (mode === "restart") { + const config = rerunConfig(session); if (session.debug) { await session.debug.stop(); - await executeAction("run.debugConfig", session.config); + await executeAction("run.debugConfig", config); } else if (debugging) { await stopSession(session.id); await waitForExit([session.id]); - await executeAction("run.debugConfig", session.config); - } else await startRun(session.config, session.id); + await executeAction("run.debugConfig", config); + } else await startRun(config, session.id); return; } await maybeEditorService()?.saveAll(); diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 1a02731b..1d515d92 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -15,7 +15,7 @@ import { } from "./configs"; import { contextConfig, fileRole } from "./context"; import { envFromRows } from "./EnvDialog"; -import { defaultSelection } from "./store"; +import { configRemoved, defaultSelection, rerunConfig, useRun, type RunSession } from "./store"; const cfg = (over: Partial & { id: string }): RunConfig => ({ name: over.id, @@ -344,3 +344,38 @@ describe("context configuration", () => { expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); }); }); + +describe("rerunning a session after its configuration changed (#1497)", () => { + const session = (config: RunConfig, baseConfig?: RunConfig): RunSession => ({ + id: "s", + config, + baseConfig, + runId: null, + status: "finished", + exitCode: 0, + startedAt: 1, + endedAt: 2, + stdinClosed: true, + }); + + it("launches the configuration as saved now, or the one it ran with once deleted", () => { + const ran = cfg({ id: "app", command: "python", args: ["init.py"] }); + const edited = { ...ran, args: ["main.py"] }; + useRun.setState({ configs: [edited] }); + expect(rerunConfig(session(ran))).toBe(edited); + expect(configRemoved("app")).toBe(false); + useRun.setState({ configs: [] }); + expect(rerunConfig(session(ran))).toBe(ran); + expect(configRemoved("app")).toBe(true); + // A run that was never a listed configuration isn't "removed". + expect(configRemoved("build:task")).toBe(false); + }); + + it("keeps the tests of a derived run (Rerun Failed Tests)", () => { + const base = cfg({ id: "tests", isTest: true }); + const derived = { ...base, args: ["-k", "failing"] }; + useRun.setState({ configs: [base] }); + expect(rerunConfig(session(derived, base))).toBe(derived); + useRun.setState({ configs: [] }); + }); +}); diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 2072ecf8..8d4c3f61 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -256,6 +256,17 @@ describe("configurations that ran", () => { expect(items).toHaveLength(1); expect(items[0]!.children!.map((c) => [c.label, c.detail])).toEqual([["dev", "Running"]]); }); + + it("drops a deleted configuration once its run has ended (#1497)", () => { + const removed = (id: string) => id === "dev"; + const ended = runServiceItems([], [session("s1", dev, "finished", 1)], [], ["dev"], {}, [], removed); + expect(ended).toEqual([]); + const live = runServiceItems([], [session("s1", dev, "running", 1)], [], ["dev"], {}, [], removed); + expect(live[0]!.children!.map((c) => [c.label, c.detail])).toEqual([["dev", "Running"]]); + // Never a saved configuration (a build task, an external tool): its finished run stays. + const adHoc = runServiceItems([], [session("s2", scratch, "finished", 1)], [], [], {}, [], removed); + expect(adHoc[0]!.children!.map((c) => c.key)).toEqual(["run:config:context:file"]); + }); }); describe("embedded web server port", () => { diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index 41af0d17..7f16de10 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -29,7 +29,16 @@ import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs"; import { SessionPanel } from "./RunView"; import { showApplicationUpdate } from "./applicationUpdate"; import { isUpdatable } from "./updatePolicies"; -import { loadConfigs, persistTemporary, startRun, stopSession, useRun, type RunSession } from "./store"; +import { + configRemoved, + loadConfigs, + persistTemporary, + rerunConfig, + startRun, + stopSession, + useRun, + type RunSession, +} from "./store"; import { currentServiceSession, currentServiceSessions, @@ -230,6 +239,7 @@ function executionDetail(s: ServiceSession | undefined): string | undefined { /** * 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. + * A configuration deleted since it ran (`removed`) stays only while its run is live. */ export function runServiceItems( configs: readonly RunConfig[], @@ -238,11 +248,13 @@ export function runServiceItems( ran: readonly string[] = [], order: ServiceOrder = {}, debug: readonly DebugServiceSession[] = [], + removed: (id: string) => boolean = () => false, ): ServiceTreeItem[] { const executions = serviceSessions(sessions, debug); const latest = currentServiceSessions(executions); const kinds = [...added]; - for (const s of executions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind); + const shown = (s: ServiceSession) => liveServiceSession(s) || !removed(s.config.id); + for (const s of executions) if (shown(s) && !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); @@ -252,7 +264,7 @@ export function runServiceItems( ? configs.filter((c) => c.kind === kind) : ran.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c && c.kind === kind); for (const s of latest.values()) - if (s.config.kind === kind && !entries.some((c) => c.id === s.config.id)) entries.push(s.config); + if (s.config.kind === kind && shown(s) && !entries.some((c) => c.id === s.config.id)) entries.push(s.config); if (!entries.length && DEFAULT_TYPES.includes(kind)) return []; const children = orderServices(entries, order[kind]).map((c): ServiceTreeItem => { const s = latest.get(c.id); @@ -298,7 +310,15 @@ function useRoots(): ServiceTreeItem[] { }, [root, typesRoot, ranRoot]); return useMemo( () => - runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order, debug), + runServiceItems( + configs, + sessions, + typesRoot === root ? types : [], + ranRoot === root ? ran : [], + order, + debug, + configRemoved, + ), [configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug], ); } @@ -335,7 +355,7 @@ function run(r: Resolved) { if (debug) debug.rerun?.(); else if (r.session?.run) { const s = r.session.run; - void startRun(s.config, s.id, s.baseConfig, { show: false }); + void startRun(rerunConfig(s), s.id, s.baseConfig, { show: false }); } else if (r.config) void startRun(r.config, undefined, undefined, { show: false }); } diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 5ed72249..e2f75430 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -1010,7 +1010,7 @@ function notifyExit(sessionId: string, code: number | null) { toolWindowService().show("run", true); }, }, - { label: "Rerun", run: () => void startRun(session.config, sessionId) }, + { label: "Rerun", run: () => void startRun(rerunConfig(session), sessionId, session.baseConfig) }, ], }); } @@ -1070,9 +1070,35 @@ export function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): }); } +/** + * What rerunning a session launches: its configuration as saved now (edited since, as IDEA), or + * the one it ran with when that was deleted or never saved. A derived run (Rerun Failed Tests) keeps its tests. + */ +export function rerunConfig(session: Pick): RunConfig { + if (session.baseConfig) return session.config; + return useRun.getState().configs.find((c) => c.id === session.config.id) ?? session.config; +} + +/** The session's original configuration, as saved now (see {@link rerunConfig}). */ +function baseConfigOf(session: RunSession): RunConfig { + const base = session.baseConfig ?? session.config; + return useRun.getState().configs.find((c) => c.id === base.id) ?? base; +} + +/** Ids of every configuration listed in this window, so a run of one deleted since can be told apart. */ +const listedConfigIds = new Set(); +useRun.subscribe((s, prev) => { + if (s.configs !== prev.configs) for (const c of s.configs) listedConfigIds.add(c.id); +}); + +/** The configuration was saved or temporary in this window and has been removed since. */ +export function configRemoved(id: string): boolean { + return listedConfigIds.has(id) && !useRun.getState().configs.some((c) => c.id === id); +} + export function rerunSession(sessionId: string) { const session = getSession(sessionId); - if (session) void startRun(session.config, sessionId, session.baseConfig); + if (session) void startRun(rerunConfig(session), sessionId, session.baseConfig && baseConfigOf(session)); } /** @@ -1082,7 +1108,7 @@ export function rerunSession(sessionId: string) { */ export function debugSession(sessionId: string) { const session = getSession(sessionId); - if (session) void executeAction("run.debugConfig", session.baseConfig ?? session.config); + if (session) void executeAction("run.debugConfig", baseConfigOf(session)); } /** Paths of the failed tests of a session's last run. */ @@ -1095,7 +1121,7 @@ export function failedTests(sessionId: string): string[][] { export function rerunFailed(sessionId: string): boolean { const session = getSession(sessionId); if (!session || session.status === "running" || session.status === "starting") return false; - const base = session.baseConfig ?? session.config; + const base = baseConfigOf(session); const derived = rerunFailedConfig(base, failedTests(sessionId)); if (!derived) return false; void startRun(derived, sessionId, base); @@ -1116,7 +1142,7 @@ export async function runSelectedTests( if (!session || !tests) return false; const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n); const paths = leafTestPaths(nodes); - const base = session.baseConfig ?? session.config; + const base = baseConfigOf(session); const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`; const derived = testsConfig(base, paths, name); if (!derived) return false;