From c55fd9b86ec34fb080c63f5d553f3a35220f68ec Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:42:00 +0800 Subject: [PATCH] fix(run): allow removing detected run configurations (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detected run configurations (Spring Boot and Java applications, npm scripts, Maven/Gradle tasks, pytest, Cargo, Go, …) could not be removed: the − button was disabled because a removed detection would be found again. As in IntelliJ IDEA, they can now be removed and stay removed: the removed id is stored in the workspace-local run.local.toml (suppressed-ids) and detection skips it; saving a configuration with that id again lifts the suppression. Applies to the Run/Debug Configurations − button, the Delete key in its tree, and Delete in the run configuration chooser. Removing a saved override still restores the detected configuration; temporary configurations are unchanged. Refs #271 --- crates/ide-host/src/ops/run.rs | 16 +++- crates/ide-host/src/rpc.rs | 7 +- crates/ide-host/tests/run_configs.rs | 53 +++++++++++ crates/ide-run/src/config.rs | 32 +++++-- crates/ide-run/src/lib.rs | 17 +++- crates/ide-run/tests/config.rs | 23 +++++ src-tauri/src/commands/run.rs | 9 +- src/bindings.ts | 2 +- src/features/run/EditConfigsDialog.tsx | 25 ++++- src/features/run/removal.test.tsx | 126 +++++++++++++++++++++++++ src/features/run/store.ts | 22 +++-- 11 files changed, 304 insertions(+), 28 deletions(-) create mode 100644 crates/ide-host/tests/run_configs.rs create mode 100644 src/features/run/removal.test.tsx diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index d5442f80..fc987654 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -9,16 +9,24 @@ pub fn configs(host: &Host, window: &str) -> IdeResult> { let project = host.project(window)?; let root = project.root.clone(); let user = ide_run::load_user_configs(&root)?; - let mut all: Vec = - ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect(); + let suppressed = ide_run::load_suppressed_config_ids(&root)?; + let mut all: Vec = ide_run::detect(&root) + .into_iter() + .filter(|d| !suppressed.contains(&d.id) && !user.iter().any(|u| u.id == d.id)) + .collect(); all.extend(user); // Project interpreter / package manager / toolchains from Languages & Frameworks. ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective()); Ok(all) } -pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> { - ide_run::save_user_configs(&host.project(window)?.root, configs) +pub fn save_configs( + host: &Host, + window: &str, + configs: &[RunConfig], + removed_detected_ids: &[String], +) -> IdeResult<()> { + ide_run::save_user_configs_with_removals(&host.project(window)?.root, configs, removed_detected_ids) } pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit) -> IdeResult { diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 79ebaef5..eff39a1e 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -200,7 +200,12 @@ impl Dispatcher { .add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) }) .add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) }) .add("run_save_configs", |c| async move { - ops::run::save_configs(&c.host, &c.window, &c.arg::>("configs")?) + ops::run::save_configs( + &c.host, + &c.window, + &c.arg::>("configs")?, + &c.arg::>>("removedDetectedIds")?.unwrap_or_default(), + ) }) .add("run_system_env", |_| async move { Ok(ide_run::system_env()) }) .add("run_start", |c| async move { diff --git a/crates/ide-host/tests/run_configs.rs b/crates/ide-host/tests/run_configs.rs new file mode 100644 index 00000000..5485d212 --- /dev/null +++ b/crates/ide-host/tests/run_configs.rs @@ -0,0 +1,53 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +fn host(base: &std::path::Path) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn removed_detections_stay_removed_and_overrides_restore() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap(); + let host = host(&root.join("app")); + host.open_project("main", &root).unwrap(); + let detected = ops::run::configs(&host, "main").unwrap(); + assert!(detected.len() >= 2); + assert!(detected.iter().all(|c| c.detected)); + let id = detected[0].id.clone(); + ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap(); + let remaining = ops::run::configs(&host, "main").unwrap(); + assert!(!remaining.iter().any(|c| c.id == id)); + assert_eq!(remaining.len(), detected.len() - 1); + // Reopening a project reads suppression from disk. + host.open_project("again", &root).unwrap(); + assert!(!ops::run::configs(&host, "again").unwrap().iter().any(|c| c.id == id)); + let mut saved = detected[0].clone(); + saved.detected = false; + ops::run::save_configs(&host, "main", &[saved], &[]).unwrap(); + assert!(ide_run::load_suppressed_config_ids(&root).unwrap().is_empty()); + assert!(!ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected); + ops::run::save_configs(&host, "main", &[], &[]).unwrap(); + assert!(ops::run::configs(&host, "main").unwrap().iter().find(|c| c.id == id).unwrap().detected); + // An override may coexist with suppression (e.g. a shared file added elsewhere). + ops::run::save_configs(&host, "main", &[], std::slice::from_ref(&id)).unwrap(); + std::fs::write( + ide_run::user_configs_path(&root), + format!("[[configurations]]\nid = {id:?}\nname = \"Override\"\ncommand = \"node\"\n"), + ) + .unwrap(); + assert!(ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id && !c.detected)); + ops::run::save_configs(&host, "main", &[], &[]).unwrap(); + assert!(!ops::run::configs(&host, "main").unwrap().iter().any(|c| c.id == id)); +} diff --git a/crates/ide-run/src/config.rs b/crates/ide-run/src/config.rs index 9125a4c3..ada02e1c 100644 --- a/crates/ide-run/src/config.rs +++ b/crates/ide-run/src/config.rs @@ -55,6 +55,8 @@ pub(crate) fn local_path(root: &Path) -> PathBuf { #[derive(Deserialize)] struct FileDoc { + #[serde(default, rename = "suppressed-ids")] + suppressed_ids: Vec, #[serde(default)] configurations: Vec, #[serde(default)] @@ -195,6 +197,10 @@ pub(crate) fn load_templates(root: &Path) -> IdeResult> { Ok(read_doc(&config_path(root))?.map(|d| convert(d.templates, false)).unwrap_or_default()) } +pub(crate) fn load_suppressed(root: &Path) -> IdeResult> { + Ok(read_doc(&local_path(root))?.map(|d| d.suppressed_ids).unwrap_or_default()) +} + fn to_table(c: &RunConfig) -> Table { let mut t = Table::new(); t.insert("id", value(&c.id)); @@ -284,13 +290,13 @@ fn to_table(c: &RunConfig) -> Table { } /// Replaces the `key` array of tables in the file at `path`, keeping unrelated keys and comments. -fn write_array(path: &Path, key: &str, configs: &[&RunConfig]) -> IdeResult<()> { +fn write_array(path: &Path, key: &str, configs: &[&RunConfig], suppressed: Option<&[String]>) -> IdeResult<()> { let existing = match fs::read_to_string(path) { Ok(t) => Some(t), Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => return Err(e.into()), }; - if existing.is_none() && configs.is_empty() { + if existing.is_none() && configs.is_empty() && suppressed.is_none_or(|ids| ids.is_empty()) { return Ok(()); } // A file we cannot parse is replaced. @@ -304,6 +310,13 @@ fn write_array(path: &Path, key: &str, configs: &[&RunConfig]) -> IdeResult<()> } doc.insert(key, Item::ArrayOfTables(array)); } + if let Some(ids) = suppressed { + if ids.is_empty() { + doc.remove("suppressed-ids"); + } else { + doc.insert("suppressed-ids", value(ids.iter().collect::())); + } + } let text = doc.to_string(); if existing.as_deref() == Some(text.as_str()) { return Ok(()); @@ -343,15 +356,20 @@ fn ignore_local(root: &Path) -> IdeResult<()> { Ok(()) } -pub(crate) fn save(root: &Path, configs: &[RunConfig]) -> IdeResult<()> { +pub(crate) fn save(root: &Path, configs: &[RunConfig], removed_detected_ids: &[String]) -> IdeResult<()> { + let mut suppressed = load_suppressed(root)?; + suppressed.extend_from_slice(removed_detected_ids); + suppressed.sort(); + suppressed.dedup(); + suppressed.retain(|id| !configs.iter().any(|c| &c.id == id)); let (local, shared): (Vec<&RunConfig>, Vec<&RunConfig>) = configs.iter().partition(|c| c.extras.local); - write_array(&config_path(root), CONFIGURATIONS, &shared)?; - if !local.is_empty() { + write_array(&config_path(root), CONFIGURATIONS, &shared, None)?; + if !local.is_empty() || !suppressed.is_empty() { ignore_local(root)?; } - write_array(&local_path(root), CONFIGURATIONS, &local) + write_array(&local_path(root), CONFIGURATIONS, &local, Some(&suppressed)) } pub(crate) fn save_templates(root: &Path, templates: &[RunConfig]) -> IdeResult<()> { - write_array(&config_path(root), TEMPLATES, &templates.iter().collect::>()) + write_array(&config_path(root), TEMPLATES, &templates.iter().collect::>(), None) } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 6f24ae21..43ac71b0 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -197,7 +197,22 @@ pub fn load_user_configs(root: &Path) -> IdeResult> { /// Writes `/.openidea/run.toml` (and the workspace-local `run.local.toml` for /// configurations not stored as project files), preserving unrelated content. pub fn save_user_configs(root: &Path, configs: &[RunConfig]) -> IdeResult<()> { - config::save(root, configs) + config::save(root, configs, &[]) +} + +/// Detected ids explicitly removed in this workspace. +pub fn load_suppressed_config_ids(root: &Path) -> IdeResult> { + config::load_suppressed(root) +} + +/// Saves user configurations and suppresses explicitly removed detections. +/// Saving a configuration with a suppressed id restores that id. +pub fn save_user_configs_with_removals( + root: &Path, + configs: &[RunConfig], + removed_detected_ids: &[String], +) -> IdeResult<()> { + config::save(root, configs, removed_detected_ids) } /// Configuration templates (per-type defaults for new configurations) from `run.toml`. diff --git a/crates/ide-run/tests/config.rs b/crates/ide-run/tests/config.rs index 6fd423f1..17761870 100644 --- a/crates/ide-run/tests/config.rs +++ b/crates/ide-run/tests/config.rs @@ -211,3 +211,26 @@ fn jvm_options_persist_in_the_project_file() { ide_run::save_templates(dir.path(), std::slice::from_ref(&template)).unwrap(); assert_eq!(ide_run::load_templates(dir.path()).unwrap()[0].extras.params["vmOptions"], "-Xmx512m"); } + +#[test] +fn suppression_round_trip_and_resaving() { + use ide_run::{load_suppressed_config_ids, save_user_configs_with_removals}; + let dir = tempfile::tempdir().unwrap(); + assert!(load_suppressed_config_ids(dir.path()).unwrap().is_empty()); + let configs = sample(); + save_user_configs(dir.path(), &configs).unwrap(); + // Old files without the field still load. + assert!(load_suppressed_config_ids(dir.path()).unwrap().is_empty()); + let ids = vec!["npm:dev".to_owned(), "spring-boot:App".to_owned()]; + save_user_configs_with_removals(dir.path(), &configs, &ids).unwrap(); + assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), ids); + assert_eq!(load_user_configs(dir.path()).unwrap(), configs); + save_user_configs(dir.path(), &[]).unwrap(); + assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), ids); + let mut restored = configs[0].clone(); + restored.id = ids[0].clone(); + restored.extras.local = true; + save_user_configs(dir.path(), &[restored]).unwrap(); + assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), vec![ids[1].clone()]); + assert!(fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap().contains("/run.local.toml")); +} diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index a99eaf2a..09701bb7 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -21,8 +21,13 @@ pub async fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResul #[tauri::command] #[specta::specta] -pub fn run_save_configs(window: Window, state: State<'_, AppState>, configs: Vec) -> IdeResult<()> { - ide_host::ops::run::save_configs(&state.host, window.label(), &configs) +pub fn run_save_configs( + window: Window, + state: State<'_, AppState>, + configs: Vec, + removed_detected_ids: Option>, +) -> IdeResult<()> { + ide_host::ops::run::save_configs(&state.host, window.label(), &configs, &removed_detected_ids.unwrap_or_default()) } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 2b70878c..d2171b29 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -528,7 +528,7 @@ export const commands = { termAvailableShells: () => __TAURI_INVOKE("term_available_shells"), /** Detected configs followed by user-defined ones (user ids win on conflict). */ runConfigs: () => typedError(__TAURI_INVOKE("run_configs")), - runSaveConfigs: (configs: RunConfig_Deserialize[]) => typedError(__TAURI_INVOKE("run_save_configs", { configs })), + runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })), /** Configuration templates: per-type defaults applied to new configurations. */ runTemplates: () => typedError(__TAURI_INVOKE("run_templates")), runSaveTemplates: (templates: RunConfig_Deserialize[]) => typedError(__TAURI_INVOKE("run_save_templates", { templates })), diff --git a/src/features/run/EditConfigsDialog.tsx b/src/features/run/EditConfigsDialog.tsx index 0f331609..50767c5e 100644 --- a/src/features/run/EditConfigsDialog.tsx +++ b/src/features/run/EditConfigsDialog.tsx @@ -130,6 +130,7 @@ function EditConfigsDialog() { const [collapsed, setCollapsed] = useState>(() => new Set()); // "Modify Run Configuration…": the would-be configuration is saved on OK even when unedited. const [dirty, setDirty] = useState(() => useRun.getState().editDirty); + const [removedDetectedIds, setRemovedDetectedIds] = useState([]); const [saving, setSaving] = useState(false); const runTargets = useWorkflowSettings((s) => s.runTargets); const [adding, setAdding] = useState<{ x: number; y: number } | null>(null); @@ -209,7 +210,10 @@ function EditConfigsDialog() { setDirty(true); return; } - if (!current || current.detected) return; + if (!current) return; + if (current.detected && !temporary.includes(current.id)) { + setRemovedDetectedIds((ids) => [...ids, current.id]); + } const index = draft.findIndex((c) => c.id === current.id); const next = removeConfig(draft, current.id); setDraft(next); @@ -262,8 +266,9 @@ function EditConfigsDialog() { } setSaving(true); try { - if (dirty) await saveUserConfigs(userConfigs(draft)); + if (dirty) await saveUserConfigs(userConfigs(draft), removedDetectedIds); if (templatesDirty && templates) await call(commands.runSaveTemplates(templates)); + setRemovedDetectedIds([]); setDirty(false); setTemplatesDirty(false); if (andClose) { @@ -325,7 +330,7 @@ function EditConfigsDialog() { }; const templateTypes = runConfigTypes(types).filter((t) => t.id !== COMPOUND); - const canRemove = selection?.type === "folder" || (!!current && !current.detected); + const canRemove = selection?.type === "folder" || !!current; return ( move(1)} /> -
+
{ + if (e.key === "Delete" && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && canRemove) { + e.preventDefault(); + e.stopPropagation(); + remove(); + } + }} + > {rows.map((r) => { if (r.type === "group") return ( diff --git a/src/features/run/removal.test.tsx b/src/features/run/removal.test.tsx new file mode 100644 index 00000000..442ad07f --- /dev/null +++ b/src/features/run/removal.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; + +const ipc = vi.hoisted(() => ({ + runSaveConfigs: vi.fn(), + runConfigs: vi.fn(), + runTemplates: vi.fn(async () => ({ status: "ok", data: [] })), +})); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: () => true, + commands: ipc, +})); + +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 { registerBuiltinTypes } from "./types"; + +const config = (kind = "npm"): RunConfig => ({ + id: `${kind}:app`, + name: "Detected app", + kind, + command: "run", + args: [], + cwd: null, + env: {}, + detected: true, + isTest: false, + debugAdapter: null, +}); +let dispose: () => void; +let backend: RunConfig[]; +beforeEach(() => { + resetRuns(); + localStorage.clear(); + dispose = registerBuiltinTypes(); + backend = [config()]; + useProject.setState({ project: { root: "/test-removal" } as never }); + ipc.runConfigs.mockImplementation(async () => ({ status: "ok", data: backend })); + ipc.runSaveConfigs.mockReset(); + ipc.runSaveConfigs.mockImplementation(async (saved: RunConfig[], removed: string[]) => { + backend = [...backend.filter((c) => c.detected && !removed.includes(c.id)), ...saved]; + return { status: "ok", data: null }; + }); + useRun.setState({ configs: backend, loaded: true, selectedId: backend[0]!.id, editorOpen: true }); +}); +afterEach(() => { + cleanup(); + dispose(); + useProject.setState({ project: null }); +}); + +it("enables minus for detections and stays removed after Apply and backend reload", async () => { + render(); + const minus = screen.getByRole("button", { name: "Remove Configuration" }); + expect(minus).toBeEnabled(); + fireEvent.click(minus); + expect(ipc.runSaveConfigs).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], ["npm:app"])); + await act(() => loadConfigs()); + expect(useRun.getState().configs).toEqual([]); + cleanup(); + render(); + expect(screen.queryByRole("treeitem", { name: "Detected app" })).not.toBeInTheDocument(); +}); + +it("handles Delete only inside the tree", async () => { + render(); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Name" }), { key: "Delete" }); + expect(screen.getByRole("treeitem", { name: "Detected app" })).toBeInTheDocument(); + const tree = screen.getByRole("tree"); + tree.focus(); + fireEvent.keyDown(tree, { key: "Delete" }); + expect(screen.queryByRole("treeitem", { name: "Detected app" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(useRun.getState().editorOpen).toBe(false)); + expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], ["npm:app"]); +}); + +it("keeps the override label and removes overrides without suppression", async () => { + useRun.setState({ configs: [{ ...config(), detected: false }] }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove Override (restore detected)" })); + fireEvent.click(screen.getByRole("button", { name: "OK" })); + await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], [])); +}); + +it.each(["spring-boot", "application", "maven", "gradle", "npm", "pytest", "cargo", "go"])( + "chooser deletion suppresses detected %s configurations", + async (kind) => { + const c = config(kind); + backend = [c]; + useRun.setState({ configs: backend, selectedId: c.id }); + expect(canDeleteConfig(c.id)).toBe(true); + await deleteConfig(c.id); + expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], [c.id]); + expect(useRun.getState().configs).toEqual([]); + }, +); + +it("temporary deletion does not persist suppression", async () => { + useRun.setState({ temporary: ["npm:app"] }); + await deleteConfig("npm:app"); + expect(ipc.runSaveConfigs).not.toHaveBeenCalled(); + expect(useRun.getState().configs).toEqual([]); +}); + +it("chooser popup Delete removes the selected detection", async () => { + useRun.setState({ chooserOpen: true, editorOpen: false }); + render(); + fireEvent.keyDown(document.querySelector("input[data-autofocus]")!, { key: "Delete" }); + await waitFor(() => expect(ipc.runSaveConfigs).toHaveBeenCalledWith([], ["npm:app"])); + await waitFor(() => expect(useRun.getState().configs).toEqual([])); +}); + +it("Cancel discards a pending detected removal", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove Configuration" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(ipc.runSaveConfigs).not.toHaveBeenCalled(); + expect(useRun.getState().configs).toEqual([config()]); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index b3fe9099..21dff33d 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -287,14 +287,12 @@ export async function saveTemporary(id: string): Promise { selectConfig(id); } -/** Whether the chooser's Delete may remove `id` (user and temporary configurations; detected ones come back). */ +/** Whether the chooser's Delete may remove `id`. */ export function canDeleteConfig(id: string): boolean { - const { configs, temporary } = useRun.getState(); - const config = configs.find((c) => c.id === id); - return !!config && (temporary.includes(id) || !config.detected); + return useRun.getState().configs.some((c) => c.id === id); } -/** Deletes a user or temporary configuration. */ +/** Deletes a configuration, suppressing detections and preserving temporary behavior. */ export async function deleteConfig(id: string): Promise { if (!canDeleteConfig(id)) return; const { configs, temporary, selectedId } = useRun.getState(); @@ -304,12 +302,20 @@ export async function deleteConfig(id: string): Promise { persistTemporary(); return; } - await saveUserConfigs(configs.filter((c) => !c.detected && !temporary.includes(c.id) && c.id !== id)); + await saveUserConfigs( + configs.filter((c) => !c.detected && !temporary.includes(c.id) && c.id !== id), + configs.find((c) => c.id === id)?.detected ? [id] : [], + ); } /** Persists user configurations and reloads the merged list. */ -export async function saveUserConfigs(user: RunConfig[]): Promise { - await call(commands.runSaveConfigs(user.map((c) => ({ ...c, detected: false })))); +export async function saveUserConfigs(user: RunConfig[], removedDetectedIds: string[] = []): Promise { + await call( + commands.runSaveConfigs( + user.map((c) => ({ ...c, detected: false })), + removedDetectedIds, + ), + ); await loadConfigs(); }