diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index b17c04c6..ee02d76d 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -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); diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index e7b25e91..a1b7b170 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -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], ); } diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 65b6bb01..59010e7c 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -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;