From 19a245fa046440b0a8ede6f873f65e0ff944b838 Mon Sep 17 00:00:00 2001 From: Qing Date: Thu, 5 Nov 2026 01:04:17 +0800 Subject: [PATCH] fix(run): drop a removed workspace member's sessions and Services groups (#1289) Removing a project from the workspace left its traces in Services: its run sessions, temporary configurations, remembered runs and type groups that now said there were no configurations. While the same project stays open, the run feature now watches the workspace member list and, per removed member, stops and forgets its sessions, drops its temporary configurations, reloads the configurations, removes the added types left empty and clears its remembered runs. Co-authored-by: qtvbwfn <5848026+qtvbwfn@users.noreply.github.com> --- src/features/run/index.ts | 11 ++++- src/features/run/services.test.ts | 74 +++++++++++++++++++++++++++++-- src/features/run/services.tsx | 61 ++++++++++++++++++++++++- src/features/run/store.ts | 2 +- 4 files changed, 141 insertions(+), 7 deletions(-) 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();