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 <[email protected]>
Цей коміт міститься в:
Qingтаqtvbwfn authored and GitHub committed 2026-11-05 01:04:17 +08:00
1 джерело 62b5f11ae3
коміт 19a245fa04
4 files changed
+141 -7

No files matched your search

+10 -1
Переглянути файл
@@ -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({
+70 -4
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>()),
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"]);
});
});
+60 -1
Переглянути файл
@@ -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<void> {
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
+1 -1
Переглянути файл
@@ -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();