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 <noreply@anthropic.com>
This commit is contained in:
l0ng-ai
2026-11-05 23:12:13 +08:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d1acf9f9ac
commit f665beb17e
6 changed files with 119 additions and 15 deletions
@@ -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<RunSession, "config">) => 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");
+5 -4
View File
@@ -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<RunSession, "id" | "config"> & { 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();
+36 -1
View File
@@ -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<RunConfig> & { 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: [] });
});
});
+11
View File
@@ -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", () => {
+25 -5
View File
@@ -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 });
}
+31 -5
View File
@@ -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<RunSession, "config" | "baseConfig">): 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<string>();
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;