diff --git a/src/features/run/index.ts b/src/features/run/index.ts index c51262f7f..e32a1eec6 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -40,7 +40,7 @@ import { registerGoActions } from "./goActions"; import { invalidateGoModules } from "./goModules"; import { RunWidget } from "./RunWidget"; import { runConfigsRunAnything } from "./runAnything"; -import { recordRun, runServiceProvider } from "./services"; +import { cleanupRemovedProject, recordRun, runServiceProvider } from "./services"; import { runBeforeLaunch } from "./beforeLaunch"; import { compoundChildren } from "./configs"; import { remoteDebugBlocked } from "./remote"; @@ -299,6 +299,15 @@ export function activate(): () => void { listenForApplicationDeactivation(), trackRecentConfigurations(), useDebugRunSessions.subscribe(() => setContext("run.updatableRunning", updatableSessions().length > 0)), + // A removed workspace member (or a closed project) takes its sessions, + // temporary configurations and Services groups with it. + // Only while the same project stays open: closing it tears its runs down separately. + useProject.subscribe((s, prev) => { + if (!s.project || s.project.root !== prev.project?.root) return; + const before = prev.project.workspace?.projects ?? []; + const after = s.project.workspace?.projects ?? []; + for (const root of before) if (!after.includes(root)) void cleanupRemovedProject(root); + }), registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }), registerEditorContribution({ id: "run.npmScripts", order: 58, extensions: npmGutter }), registerMenuGroup({ diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 8a5ceb93e..2072ecf8e 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -1,8 +1,25 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RunConfig } from "@/platform/ipc"; -import { DEFAULT_TYPES, readRan, readTypes, runServiceItems, sessionDetail, sessionStatus } from "./services"; +import { useProject } from "@/platform/project"; + +const ipc = vi.hoisted(() => ({ runConfigs: vi.fn() })); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: () => true, + commands: ipc, +})); + +import { + cleanupRemovedProject, + DEFAULT_TYPES, + readRan, + readTypes, + runServiceItems, + sessionDetail, + sessionStatus, +} from "./services"; import { startedPort } from "./serverPort"; -import type { RunSession } from "./store"; +import { useRun, type RunSession } from "./store"; import type { DebugServiceSession } from "@/workbench/contributions"; const config = (id: string, kind: string): RunConfig => ({ @@ -248,9 +265,58 @@ describe("embedded web server port", () => { expect(startedPort("o.s.b.web.embedded.netty.NettyWebServer : Netty started on port 9090")).toBe(9090); expect(startedPort("Netty started on port(s): 9091")).toBe(9091); expect(startedPort("Undertow started on port(s) 8082 (http)")).toBe(8082); - expect(startedPort("Jetty started on port 8083 (http/1.1) with context path '/'")).toBe(8083); + expect(startedPort("Jetty started on port 9083 (http/1.1) with context path '/'")).toBe(9083); expect(startedPort("Tomcat initialized with port 8080 (http)")).toBeNull(); expect(startedPort("Started DemoApplication in 1.2 seconds")).toBeNull(); expect(startedPort("Tomcat started on port 99999")).toBeNull(); }); }); + +describe("removing a workspace member from Services", () => { + const main = { ...config("app", "spring-boot"), cwd: "/w/main" }; + const vue = { ...config("dev", "npm"), cwd: "/w/vue" }; + + beforeEach(() => { + localStorage.clear(); + useProject.setState({ project: { root: "/w/main" } as never }); + ipc.runConfigs.mockImplementation(async () => ({ status: "ok", data: [main] })); + }); + afterEach(() => { + useProject.setState({ project: null }); + useRun.setState({ configs: [], sessions: [], temporary: [], selectedId: null }); + }); + + it("stops and forgets the member's sessions and drops its empty type groups", async () => { + useRun.setState({ + configs: [main, vue], + temporary: ["dev"], + sessions: [session("s1", main, "finished", 1), session("s2", vue, "running", 2, { runId: 7 })], + }); + localStorage.setItem("openidea.services.runTypes:/w/main", JSON.stringify(["spring-boot", "npm"])); + localStorage.setItem("openidea.services.ran:/w/main", JSON.stringify(["app", "dev"])); + + await cleanupRemovedProject("/w/vue"); + + const s = useRun.getState(); + expect(s.sessions.map((x) => x.id)).toEqual(["s1"]); + expect(s.configs.map((c) => c.id)).toEqual(["app"]); + expect(s.temporary).toEqual([]); + expect(readTypes("/w/main")).toEqual(["spring-boot"]); + expect(readRan("/w/main")).toEqual(["app"]); + expect(ipc.runConfigs).toHaveBeenCalled(); + }); + + it("keeps a type another project still uses, and a sibling with a shared prefix", async () => { + const other = { ...config("serve", "npm"), cwd: "/w/main" }; + const sibling = { ...config("dev2", "npm"), cwd: "/w/vue2" }; + useRun.setState({ configs: [main, other, vue, sibling], sessions: [] }); + localStorage.setItem("openidea.services.runTypes:/w/main", JSON.stringify(["npm"])); + // What the backend lists once /w/vue is no longer a workspace member. + ipc.runConfigs.mockImplementation(async () => ({ status: "ok", data: [main, other, sibling] })); + + await cleanupRemovedProject("/w/vue"); + + expect(useRun.getState().configs.map((c) => c.id)).toEqual(["app", "serve", "dev2"]); + expect(readTypes("/w/main")).toEqual(["npm"]); + }); +}); diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index a7644d643..41af0d17d 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -29,7 +29,7 @@ import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs"; import { SessionPanel } from "./RunView"; import { showApplicationUpdate } from "./applicationUpdate"; import { isUpdatable } from "./updatePolicies"; -import { startRun, useRun, type RunSession } from "./store"; +import { loadConfigs, persistTemporary, startRun, stopSession, useRun, type RunSession } from "./store"; import { currentServiceSession, currentServiceSessions, @@ -137,6 +137,65 @@ export function recordRun(configId: string) { } } +// --------------------------------------------------------------------------- +// Project removal: a workspace member's data leaves with it + +/** Whether `path` is inside the directory `root` (a removed member, not a sibling with a shared prefix). */ +function insideRoot(path: string, root: string): boolean { + return path === root || path.startsWith(`${root}/`); +} + +/** + * Drops a removed workspace member's data from Services: stops and forgets its + * sessions (a finished one still groups the tree), removes its temporary + * configurations, reloads (the backend no longer lists the member) and clears + * the added types and remembered runs that would otherwise render empty groups. + */ +export async function cleanupRemovedProject(removedRoot: string): Promise { + const { configs, sessions, temporary } = useRun.getState(); + const ofRemoved = (c: RunConfig) => !!c.cwd && insideRoot(c.cwd, removedRoot); + const removedIds = new Set(configs.filter(ofRemoved).map((c) => c.id)); + + const removedSessions = sessions.filter((s) => removedIds.has(s.config.id)); + for (const s of removedSessions) if (busy(s)) await stopSession(s.id); + if (removedSessions.length) { + const ids = new Set(removedSessions.map((s) => s.id)); + useRun.setState((s) => ({ sessions: s.sessions.filter((x) => !ids.has(x.id)) })); + } + + if (temporary.some((id) => removedIds.has(id))) { + const temporaryLeft = temporary.filter((id) => !removedIds.has(id)); + useRun.setState({ + configs: useRun.getState().configs.filter((c) => !removedIds.has(c.id)), + temporary: temporaryLeft, + }); + persistTemporary(); + } + + // The kinds the member had configurations of; after the reload those that are + // left without any (and without a session) are removed from the added types. + const kinds = new Set(configs.filter(ofRemoved).map((c) => c.kind)); + await loadConfigs(); + + const { configs: fresh, sessions: liveSessions } = useRun.getState(); + const liveKinds = new Set([...fresh.map((c) => c.kind), ...liveSessions.map((s) => s.config.kind)]); + for (const kind of kinds) if (!liveKinds.has(kind)) removeType(kind); + + const root = useProject.getState().project?.root ?? null; + if (root) { + const freshIds = new Set(fresh.map((c) => c.id)); + const ids = ranIds(root).filter((id) => freshIds.has(id)); + if (ids.length !== ranIds(root).length) { + useRanConfigs.setState({ root, ids }); + try { + localStorage.setItem(RAN_KEY + root, JSON.stringify(ids)); + } catch { + /* storage unavailable */ + } + } + } +} + // --------------------------------------------------------------------------- // Tree diff --git a/src/features/run/store.ts b/src/features/run/store.ts index ef8616d3b..ff4ae8450 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -342,7 +342,7 @@ function writeTemporary(root: string, configs: RunConfig[]) { let persisting = 0; -function persistTemporary() { +export function persistTemporary() { const root = useProject.getState().project?.root; if (!root) return; const { configs, temporary } = useRun.getState();