mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 })),
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()]);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user