diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 153081698..461c84b96 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -22,13 +22,12 @@ import { kindLabel, removeConfig, upsertConfig, - userConfigs, validateConfig, type ConfigErrors, } from "./configs"; import { importConfigs, importLabel } from "./importConfigs"; import { targetName } from "./remote"; -import { saveUserConfigs, selectConfig, useRun } from "./store"; +import { saveEditedConfigs, selectConfig, useRun } from "./store"; import { blankTemplate, COMPOUND, missingParams, newConfigOfType, templateId, typeFor } from "./types"; import styles from "./Run.module.css"; import { tx } from "@/platform/i18n"; @@ -305,7 +304,7 @@ function EditConfigsDialog() { } setSaving(true); try { - if (dirty) await saveUserConfigs(userConfigs(draft), removedDetectedIds); + if (dirty) await saveEditedConfigs(draft, removedDetectedIds); if (templatesDirty && templates) await call(commands.runSaveTemplates(templates)); setRemovedDetectedIds([]); setDirty(false); diff --git a/src/features/run/removal.test.tsx b/src/features/run/removal.test.tsx index 29a35c2e5..29733e03a 100644 --- a/src/features/run/removal.test.tsx +++ b/src/features/run/removal.test.tsx @@ -6,6 +6,8 @@ const ipc = vi.hoisted(() => ({ runConfigs: vi.fn(), runTemplates: vi.fn(async () => ({ status: "ok", data: [] })), runImportSources: vi.fn(async () => ({ status: "ok", data: [] })), + runWithoutDerived: vi.fn(async (configs: unknown) => ({ status: "ok", data: configs })), + runApplySettings: vi.fn(async (configs: unknown) => ({ status: "ok", data: configs })), })); vi.mock("@/platform/ipc", async (importOriginal) => ({ ...(await importOriginal()), @@ -17,7 +19,7 @@ import type { RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { EditConfigsOverlay } from "./EditConfigsDialog"; import { ConfigChooserOverlay } from "./ConfigChooser"; -import { canDeleteConfig, deleteConfig, loadConfigs, resetRuns, useRun } from "./store"; +import { adoptTemporary, canDeleteConfig, deleteConfig, loadConfigs, resetRuns, useRun } from "./store"; import { registerBuiltinTypes } from "./types"; const config = (kind = "npm"): RunConfig => ({ @@ -125,3 +127,43 @@ it("Cancel discards a pending detected removal", () => { expect(ipc.runSaveConfigs).not.toHaveBeenCalled(); expect(useRun.getState().configs).toEqual([config()]); }); + +const temporaryOf = () => + JSON.parse(localStorage.getItem("openidea.run.temporary.v2:/test-removal") ?? "[]") as RunConfig[]; +const mainGo: RunConfig = { ...config("go"), id: "go:file:/test-removal/app/main.go", name: "main.go" }; + +it("removes a temporary configuration from Edit Configurations for good", async () => { + adoptTemporary(mainGo); + useRun.setState({ editTarget: mainGo.id }); + render(); + fireEvent.click(screen.getByRole("treeitem", { name: "main.go" })); + fireEvent.click(screen.getByRole("button", { name: "Remove Configuration" })); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(useRun.getState().editorOpen).toBe(false)); + // Not a suppressed detection: it was never detected by the backend. + expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], []); + expect(temporaryOf()).toEqual([]); + await act(() => loadConfigs()); + expect(useRun.getState().configs.map((c) => c.id)).toEqual(["npm:app"]); + expect(useRun.getState().temporary).toEqual([]); +}); + +it("keeps unedited temporary configurations temporary and saves edited ones", async () => { + const goal: RunConfig = { ...config("maven"), id: "maven:goal:pom.xml:test", name: "app [test]", detected: false }; + const other: RunConfig = { + ...config("maven"), + id: "maven:goal:pom.xml:verify", + name: "app [verify]", + detected: false, + }; + adoptTemporary(goal); + adoptTemporary(other); + useRun.setState({ editTarget: other.id }); + render(); + fireEvent.change(screen.getByRole("textbox", { name: "Name" }), { target: { value: "verify app" } }); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(useRun.getState().editorOpen).toBe(false)); + expect(ipc.runSaveConfigs).toHaveBeenCalledWith([expect.objectContaining({ id: other.id, name: "verify app" })], []); + expect(useRun.getState().temporary).toEqual([goal.id]); + expect(temporaryOf().map((c) => c.id)).toEqual([goal.id]); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 1d1396781..e91b56297 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -347,10 +347,11 @@ function persistTemporary() { const { configs, temporary } = useRun.getState(); const list = temporary.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c); writeTemporary(root, list); + // A rewrite still pending from an earlier call must not bring back what was removed since. + const generation = ++persisting; if (!list.length || !isTauri()) return; // Then rewritten without what settings derived (the project's Maven, JDK, interpreter…), so a // later settings change reaches them as it does detected and saved configurations. - const generation = ++persisting; void call(commands.runWithoutDerived(list)) .then((kept) => { if (generation === persisting && useProject.getState().project?.root === root) writeTemporary(root, kept); @@ -432,6 +433,30 @@ export async function deleteConfig(id: string): Promise { ); } +/** + * Edit Configurations' OK / Apply. A temporary configuration stays temporary unless it was edited + * there (then it is saved, as IDEA makes it permanent), and one removed there is forgotten. + */ +export async function saveEditedConfigs(draft: readonly RunConfig[], removedDetectedIds: string[] = []): Promise { + const { configs, temporary } = useRun.getState(); + const unchanged = (c: RunConfig) => { + const before = configs.find((o) => o.id === c.id); + return !!before && JSON.stringify(before) === JSON.stringify(c); + }; + const kept = temporary.filter((id) => draft.some((c) => c.id === id && unchanged(c))); + if (kept.length !== temporary.length) { + useRun.setState({ + configs: configs.filter((c) => !temporary.includes(c.id) || kept.includes(c.id)), + temporary: kept, + }); + persistTemporary(); + } + await saveUserConfigs( + draft.filter((c) => !c.detected && !kept.includes(c.id)), + removedDetectedIds, + ); +} + /** Persists user configurations and reloads the merged list. */ export async function saveUserConfigs(user: RunConfig[], removedDetectedIds: string[] = []): Promise { user = await Promise.all(