fix(run): allow removing detected run configurations (#296)

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
This commit is contained in:
l0ng-ai
2026-10-30 07:42:00 +08:00
committed by GitHub
parent 922bbf493a
commit c55fd9b86e
11 changed files with 304 additions and 28 deletions
+12 -4
View File
@@ -9,16 +9,24 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let project = host.project(window)?;
let root = project.root.clone();
let user = ide_run::load_user_configs(&root)?;
let mut all: Vec<RunConfig> =
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<RunConfig> = 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<OutputChunk>) -> IdeResult<RunInfo> {
+6 -1
View File
@@ -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::<Vec<ide_run::RunConfig>>("configs")?)
ops::run::save_configs(
&c.host,
&c.window,
&c.arg::<Vec<ide_run::RunConfig>>("configs")?,
&c.arg::<Option<Vec<String>>>("removedDetectedIds")?.unwrap_or_default(),
)
})
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
.add("run_start", |c| async move {
+53
View File
@@ -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<Host> {
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));
}
+25 -7
View File
@@ -55,6 +55,8 @@ pub(crate) fn local_path(root: &Path) -> PathBuf {
#[derive(Deserialize)]
struct FileDoc {
#[serde(default, rename = "suppressed-ids")]
suppressed_ids: Vec<String>,
#[serde(default)]
configurations: Vec<FileConfig>,
#[serde(default)]
@@ -195,6 +197,10 @@ pub(crate) fn load_templates(root: &Path) -> IdeResult<Vec<RunConfig>> {
Ok(read_doc(&config_path(root))?.map(|d| convert(d.templates, false)).unwrap_or_default())
}
pub(crate) fn load_suppressed(root: &Path) -> IdeResult<Vec<String>> {
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::<Array>()));
}
}
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::<Vec<_>>())
write_array(&config_path(root), TEMPLATES, &templates.iter().collect::<Vec<_>>(), None)
}
+16 -1
View File
@@ -197,7 +197,22 @@ pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
/// Writes `<root>/.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<Vec<String>> {
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`.
+23
View File
@@ -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"));
}
+7 -2
View File
@@ -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<RunConfig>) -> IdeResult<()> {
ide_host::ops::run::save_configs(&state.host, window.label(), &configs)
pub fn run_save_configs(
window: Window,
state: State<'_, AppState>,
configs: Vec<RunConfig>,
removed_detected_ids: Option<Vec<String>>,
) -> IdeResult<()> {
ide_host::ops::run::save_configs(&state.host, window.label(), &configs, &removed_detected_ids.unwrap_or_default())
}
#[tauri::command]
+1 -1
View File
@@ -528,7 +528,7 @@ export const commands = {
termAvailableShells: () => __TAURI_INVOKE<string[]>("term_available_shells"),
/** Detected configs followed by user-defined ones (user ids win on conflict). */
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
runSaveConfigs: (configs: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs })),
runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })),
/** Configuration templates: per-type defaults applied to new configurations. */
runTemplates: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_templates")),
runSaveTemplates: (templates: RunConfig_Deserialize[]) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_templates", { templates })),
+21 -4
View File
@@ -130,6 +130,7 @@ function EditConfigsDialog() {
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => 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<string[]>([]);
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 (
<Dialog
@@ -423,7 +428,19 @@ function EditConfigsDialog() {
onClick={() => move(1)}
/>
</div>
<div className={styles.editList} role="tree" aria-label={tx("Run configurations")}>
<div
className={styles.editList}
role="tree"
tabIndex={0}
aria-label={tx("Run configurations")}
onKeyDown={(e) => {
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 (
+126
View File
@@ -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<typeof import("@/platform/ipc")>()),
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(<EditConfigsOverlay />);
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(<EditConfigsOverlay />);
expect(screen.queryByRole("treeitem", { name: "Detected app" })).not.toBeInTheDocument();
});
it("handles Delete only inside the tree", async () => {
render(<EditConfigsOverlay />);
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(<EditConfigsOverlay />);
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(<ConfigChooserOverlay />);
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(<EditConfigsOverlay />);
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()]);
});
+14 -8
View File
@@ -287,14 +287,12 @@ export async function saveTemporary(id: string): Promise<void> {
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<void> {
if (!canDeleteConfig(id)) return;
const { configs, temporary, selectedId } = useRun.getState();
@@ -304,12 +302,20 @@ export async function deleteConfig(id: string): Promise<void> {
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<void> {
await call(commands.runSaveConfigs(user.map((c) => ({ ...c, detected: false }))));
export async function saveUserConfigs(user: RunConfig[], removedDetectedIds: string[] = []): Promise<void> {
await call(
commands.runSaveConfigs(
user.map((c) => ({ ...c, detected: false })),
removedDetectedIds,
),
);
await loadConfigs();
}