fix(services): keep non-Spring-Boot run configurations in the Services tree (#443)

Services listed run configurations only while their session was alive (or when their whole type was added), so after a restart only Spring Boot configurations were back. Configurations that ran are now remembered per project and listed under their type, dimmed until they run again; ids of deleted configurations are ignored.

Fixes #388

Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
arookieofc
2026-10-30 19:29:35 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent b983e3d0b2
commit d277fc1522
3 changed files with 88 additions and 6 deletions
+33 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { RunConfig } from "@/platform/ipc";
import { DEFAULT_TYPES, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services";
import { DEFAULT_TYPES, readRan, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services";
import { startedPort } from "./serverPort";
import type { RunSession } from "./store";
@@ -106,6 +106,38 @@ describe("types added to Services", () => {
});
});
describe("configurations that ran", () => {
const dev = config("dev", "npm");
const scratch = config("context:file", "python");
beforeEach(() => localStorage.clear());
it("persists the ran configuration ids per project", () => {
expect(readRan("/p")).toEqual([]);
localStorage.setItem("openidea.services.ran:/p", JSON.stringify(["dev"]));
expect(readRan("/p")).toEqual(["dev"]);
expect(readRan("/q")).toEqual([]);
});
it("lists previously run configurations without a session", () => {
const items = runServiceItems([dev, scratch], [], [], ["dev", "context:file"]);
expect(items.map((i) => [i.key, i.label])).toEqual([
["run:type:npm", "npm"],
["run:type:python", "Python"],
]);
expect(items[0]!.children!.map((c) => [c.label, c.dim])).toEqual([["dev", true]]);
expect(items[1]!.children!.map((c) => c.key)).toEqual(["run:config:context:file"]);
});
it("ignores ran ids whose configuration is gone and merges sessions", () => {
const gone = runServiceItems([dev], [], [], ["missing"]);
expect(gone).toEqual([]);
const running = session("s1", dev, "running", 1);
const items = runServiceItems([dev], [running], [], ["dev"]);
expect(items).toHaveLength(1);
expect(items[0]!.children!.map((c) => [c.label, c.detail])).toEqual([["dev", "Running"]]);
});
});
describe("embedded web server port", () => {
it("reads the port from the started line", () => {
expect(startedPort("TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'")).toBe(8080);
+52 -5
View File
@@ -1,7 +1,8 @@
/**
* Run configurations in the Services tool window (IDEA's Run Dashboard): Spring
* Boot by default, the configuration types added there ("Add Service › Run
* Configuration Type") and the types of this window's run sessions, one group
* Configuration Type"), the types of this window's run sessions and the
* configurations that ran before (kept per project across restarts), one group
* each, with run / debug / stop / rerun per configuration. A configuration that
* ran shows its console in the detail pane, a running web application its HTTP port.
*/
@@ -30,6 +31,7 @@ export const RUN_PROVIDER = "runConfigurations";
const TYPE_PREFIX = "run:type:";
const CONFIG_PREFIX = "run:config:";
const STORAGE_KEY = "openidea.services.runTypes:";
const RAN_KEY = "openidea.services.ran:";
/** Types shown in Services until the user changes the list. */
export const DEFAULT_TYPES: readonly string[] = ["spring-boot"];
@@ -77,6 +79,41 @@ export function removeType(kind: string) {
setAddedTypes(currentTypes().filter((t) => t !== kind));
}
// ---------------------------------------------------------------------------
// Configurations that ran, per project (shown again after a restart)
const useRanConfigs = create<{ root: string | null; ids: string[] }>(() => ({ root: null, ids: [] }));
/** The project's previously run configuration ids, most recent last. */
export function readRan(root: string): string[] {
try {
const raw = localStorage.getItem(RAN_KEY + root);
if (raw === null) return [];
const v: unknown = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
function ranIds(root: string | null): string[] {
const s = useRanConfigs.getState();
return s.root === root ? s.ids : root ? readRan(root) : [];
}
/** Records a run, so its configuration is back in Services after a restart. */
export function recordRun(configId: string) {
const root = useProject.getState().project?.root ?? null;
const ids = [...ranIds(root).filter((id) => id !== configId), configId];
useRanConfigs.setState({ root, ids });
if (!root) return;
try {
localStorage.setItem(RAN_KEY + root, JSON.stringify(ids));
} catch {
/* storage unavailable */
}
}
// ---------------------------------------------------------------------------
// Tree
@@ -113,12 +150,19 @@ export function runServiceItems(
configs: readonly RunConfig[],
sessions: readonly RunSession[],
added: readonly string[],
ran: readonly string[] = [],
): ServiceTreeItem[] {
const latest = latestSessions(sessions);
const kinds = [...added];
for (const s of sessions) if (!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);
}
return kinds.flatMap((kind) => {
const entries: RunConfig[] = added.includes(kind) ? configs.filter((c) => c.kind === kind) : [];
const entries: RunConfig[] = added.includes(kind)
? 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 (!entries.length && DEFAULT_TYPES.includes(kind)) return [];
@@ -155,12 +199,15 @@ function useRoots(): ServiceTreeItem[] {
const sessions = useRun((s) => s.sessions);
const types = useAddedTypes((s) => s.types);
const typesRoot = useAddedTypes((s) => s.root);
const ran = useRanConfigs((s) => s.ids);
const ranRoot = useRanConfigs((s) => s.root);
useEffect(() => {
if (typesRoot !== root) useAddedTypes.setState({ root, types: root ? readTypes(root) : [] });
}, [root, typesRoot]);
if (ranRoot !== root) useRanConfigs.setState({ root, ids: root ? readRan(root) : [] });
}, [root, typesRoot, ranRoot]);
return useMemo(
() => runServiceItems(configs, sessions, typesRoot === root ? types : []),
[configs, sessions, types, typesRoot, root],
() => runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : []),
[configs, sessions, types, typesRoot, ran, ranRoot, root],
);
}
+3
View File
@@ -37,6 +37,7 @@ import { compoundChildren } from "./configs";
import { remoteLabel, startWithRemedies } from "./remote";
import { TerminalOutput } from "./terminalOutput";
import { typeFor } from "./types";
import { recordRun } from "./services";
import { startedPort } from "./serverPort";
import { tomcatBrowserUrl } from "./tomcat";
import { openUrl } from "./navigate";
@@ -571,6 +572,8 @@ export async function startRun(
useRun.setState((s) => ({ sessions: [...s.sessions, created], activeSession: created.id }));
}
bump(session.id);
// The Services tool window lists the configuration again after a restart.
recordRun(config.id);
const sessionId = session.id;
const rt = runtimes.get(sessionId)!;
const generation = rt.generation;