Compare commits

...
Author SHA1 Message Date
l0ng-ai 30821f4c98 Merge main 2026-11-05 23:10:04 +08:00
l0ng-ai a00f0c3dd6 fix(run): rerun with the configuration as edited, and drop deleted ones from Services
Rerunning a run tab (Run tool window, Services, the exit notification,
Update Application's restart) launched the configuration snapshot taken
when the tab first ran, so edits made in Edit Configurations were ignored
until the tab was closed. Reruns now use the saved configuration with the
same id, falling back to the snapshot when it was deleted or never saved;
Rerun Failed Tests keeps its derived tests and derives from the current
configuration.

Services also kept listing a configuration deleted after it ran. Such an
entry now stays only while its run is live; runs of configurations that
were never listed (build tasks, external tools) are unaffected.
2026-11-05 22:57:11 +08:00
6 changed files with 119 additions and 15 deletions

No files matched your search

@@ -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;