From 81e4430242e4d400688dc36399fbee42be4373b5 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:55:34 +0800 Subject: [PATCH] fix(run): keep temporary run configurations following the toolchain settings Temporary configurations (gutter and context runs, Maven tool window goals) were kept in local storage with what settings derived when they were made: the absolute mvn, -s / -Dmaven.repo.local and JAVA_HOME. They never went through the backend again, so after changing the Maven home, settings file or JDK they, and test runs narrowed from them, still ran the old ones. They are now kept without the derived values (run_without_derived, the same stripping saved configurations get) and get the current settings applied when the run list loads (run_apply_settings), which happens again after a settings change. Maven's user settings file and local repository now apply to every Maven configuration, each unless the configuration sets it itself, so they can be stripped and put back. Entries kept by earlier versions are dropped once (new storage key). --- crates/ide-host/src/ops/run.rs | 65 +++++++++++++++--- crates/ide-host/src/rpc.rs | 2 + crates/ide-sdk/src/apply.rs | 68 +++++++++++++----- crates/ide-sdk/src/tests.rs | 26 +++++++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/run.rs | 28 ++++++++ src/bindings.ts | 4 ++ src/features/run/java.test.ts | 2 +- src/features/run/store.ts | 25 +++++-- src/features/run/temporarySettings.test.ts | 80 ++++++++++++++++++++++ 10 files changed, 269 insertions(+), 33 deletions(-) create mode 100644 src/features/run/temporarySettings.test.ts diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index f172a4f39..511846bfb 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -82,6 +82,58 @@ pub fn save_configs( ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids) } +/// The project `config` runs in (a workspace member, by its working directory) and its settings. +fn settings_for( + host: &Host, + project: &crate::project::Project, + config: &RunConfig, +) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> { + let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| { + let cwd = config.cwd.as_deref()?; + workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count()) + }); + Ok(match member { + Some(root) => { + let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective(); + (root, settings) + } + None => (project.root.clone(), project.settings.effective()), + }) +} + +/// Temporary configurations as they are kept between sessions: without what settings derived +/// (toolchain commands, Maven options, `JAVA_HOME`…), so they follow later settings changes. +pub fn without_derived(host: &Host, window: &str, configs: Vec) -> IdeResult> { + host.require_trust(window)?; + let project = host.project(window)?; + configs + .into_iter() + .map(|c| { + if c.kind == "gradle" { + return Ok(c); + } + let (root, settings) = settings_for(host, &project, &c)?; + Ok(ide_sdk::apply::without_derived(&c, &root, &settings)) + }) + .collect() +} + +/// Kept temporary configurations with today's settings applied, as [`configs`] lists the others. +pub fn apply_settings(host: &Host, window: &str, configs: Vec) -> IdeResult> { + host.require_trust(window)?; + let project = host.project(window)?; + configs + .into_iter() + .map(|mut c| { + if c.kind != "gradle" { + let (root, settings) = settings_for(host, &project, &c)?; + ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings); + } + Ok(c) + }) + .collect() +} + pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit) -> IdeResult { host.require_trust(window)?; let project = host.project(window)?; @@ -100,15 +152,8 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit .run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c))) .await; } - let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| { - let cwd = config.cwd.as_deref()?; - workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count()) - }); - let root = member.as_ref().unwrap_or(&project.root); - let settings = match &member { - Some(root) => ide_workspace::SettingsStore::load(&host.config_dir, Some(root)).effective(), - None => project.settings.effective(), - }; + let (root, settings) = settings_for(host, &project, &config)?; + let root = &root; // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. let mut config = config; if matches!(config.kind.as_str(), "gradle" | "tomcat") { @@ -147,7 +192,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit let encoding = settings.console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()), - python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) }, + python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) }, }; project.require_trust()?; let mut sink = project.sink.clone(); diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 38795f7d0..034c40fbe 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -257,6 +257,8 @@ impl Dispatcher { &c.arg::>>("removedDetectedIds")?.unwrap_or_default(), ) }) + .add("run_without_derived", |c| async move { ops::run::without_derived(&c.host, &c.window, c.arg("configs")?) }) + .add("run_apply_settings", |c| async move { ops::run::apply_settings(&c.host, &c.window, c.arg("configs")?) }) .add("run_system_env", |_| async move { Ok(ide_run::system_env()) }) .add("run_start", |c| async move { let on_output = c.channel("onOutput")?; diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index bca51e308..7ff5e54c1 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -647,6 +647,37 @@ fn is_python_command(cmd: &str) -> bool { const PY_RUNNERS: [&str; 3] = ["uv", "poetry", "pdm"]; +/// [`maven_options`] as the groups they are passed in (`-s `, `-Dmaven.repo.local=…`). +fn maven_option_groups(options: &[String]) -> Vec<&[String]> { + let mut groups = Vec::new(); + let mut i = 0; + while i < options.len() { + let n = if options[i] == "-s" { 2.min(options.len() - i) } else { 1 }; + groups.push(&options[i..i + n]); + i += n; + } + groups +} + +/// Whether `args` set what `group` sets: a user settings file, or the local repository. +fn sets_maven_option(args: &[String], group: &[String]) -> bool { + match group.first().map(String::as_str) { + Some("-s") => args.iter().any(|a| a == "-s" || a == "--settings" || a.starts_with("--settings=")), + Some(flag) => { + let key = flag.split_once('=').map_or(flag, |(k, _)| k); + args.iter().any(|a| a.split_once('=').map_or(a.as_str(), |(k, _)| k) == key) + } + None => true, + } +} + +/// Settings › Maven's options ahead of the goals, each unless the configuration sets it itself. +fn add_maven_options(args: &mut Vec, options: &[String]) { + let missing: Vec = + maven_option_groups(options).into_iter().filter(|g| !sets_maven_option(args, g)).flatten().cloned().collect(); + args.splice(0..0, missing); +} + /// Points run configurations at the selected toolchains: Python configs use the /// project interpreter, npm scripts the chosen package manager and Node.js, /// cargo / go configs the selected toolchain, Maven / Gradle the project JDK @@ -783,24 +814,16 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings if let Some(jdk) = &jdk { c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned()); } - if c.kind == "maven" && c.detected { - if let Some(mvn) = &mvn { + if c.kind == "maven" { + // A configuration made or saved in the UI names the literal `mvn` / + // `mvn.cmd`; without a wrapper it cannot start when Maven lives on a + // version manager's shims or a configured home (#328). + if let Some(mvn) = &mvn + && (c.detected || matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat")) + { c.command = mvn.to_string_lossy().into_owned(); } - if !c.args.starts_with(&mvn_options) { - c.args.splice(0..0, mvn_options.iter().cloned()); - } - } else if c.kind == "maven" - && matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat") - && let Some(mvn) = &mvn - { - // A goal configuration saved from the tool window keeps the - // literal `mvn` / `mvn.cmd` the launcher resolved to; without a - // wrapper it cannot start when Maven lives on a version - // manager's shims or a configured home (#328). Detected configs - // take the Maven options too, but the saved ones already baked - // them into `args`. - c.command = mvn.to_string_lossy().into_owned(); + add_maven_options(&mut c.args, &mvn_options); } } _ => {} @@ -843,6 +866,19 @@ pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConf { out.env.remove(&key); } + // Settings › Maven's options, which applying puts back ahead of the goals. + if out.kind == "maven" { + let options = maven_options(root, s); + let mut probe = out.clone(); + for group in maven_option_groups(&options) { + if let Some(at) = probe.args.windows(group.len()).position(|w| w == group) { + probe.args.drain(at..at + group.len()); + } + } + if applied(&probe).args == out.args { + out.args = probe.args; + } + } let path = Path::new(&config.command); if path.is_absolute() && let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned()) diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 858af1135..af8bae61b 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1263,6 +1263,8 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() { s.languages.java.jdk = root.join("jdk17").to_string_lossy().into_owned(); touch("maven/bin/mvn"); s.languages.java.maven.home = root.join("maven").to_string_lossy().into_owned(); + s.languages.java.maven.user_settings = "/m2/settings.xml".into(); + s.languages.java.maven.local_repository = "/m2/repo".into(); s.languages.ruby.interpreter = touch("ruby/bin/ruby"); touch("ruby/bin/bundle"); s.tools.insert("godot".into(), ide_core::settings::ToolSettings { path: touch("Godot/godot") }); @@ -1316,4 +1318,28 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() { let ruby2 = root.join("ruby2/bin"); assert_eq!(next.command, ruby2.join("ruby").to_string_lossy()); assert!(next.env["PATH"].starts_with(&*ruby2.to_string_lossy()), "{}", next.env["PATH"]); + + // A Maven configuration (a detected one narrowed to a test, a tool window goal) follows a + // later Maven home, settings file, local repository and JDK; options it sets itself stay. + for detected in [true, false] { + let mut goal = saved("maven", "mvn", &["test", "-Dtest=AppTest"], &[]); + goal.detected = detected; + let mut own = saved("maven", "mvn", &["-s", "/mine.xml", "test"], &[]); + own.detected = detected; + let mut loaded = [goal, own]; + apply_to_run_configs(&mut loaded, root, &s); + let mut next = loaded.map(|c| without_derived(&c, root, &s)); + touch("maven2/bin/mvn"); + fake_jdk(&root.join("jdk21")); + let mut s2 = s.clone(); + s2.languages.java.maven.home = root.join("maven2").to_string_lossy().into_owned(); + s2.languages.java.maven.user_settings = "/m3/settings.xml".into(); + s2.languages.java.maven.local_repository = "/m3/repo".into(); + s2.languages.java.jdk = root.join("jdk21").to_string_lossy().into_owned(); + apply_to_run_configs(&mut next, root, &s2); + assert_eq!(next[0].command, root.join("maven2/bin/mvn").to_string_lossy()); + assert_eq!(next[0].args, ["-s", "/m3/settings.xml", "-Dmaven.repo.local=/m3/repo", "test", "-Dtest=AppTest"]); + assert!(next[0].env["JAVA_HOME"].ends_with("jdk21"), "{}", next[0].env["JAVA_HOME"]); + assert_eq!(next[1].args, ["-Dmaven.repo.local=/m3/repo", "-s", "/mine.xml", "test"]); + } } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index af70e0da8..2b4cee4e9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -316,6 +316,8 @@ pub fn builder() -> tauri_specta::Builder { run::run_configs, run::run_dotnet_resolve, run::run_save_configs, + run::run_without_derived, + run::run_apply_settings, run::run_templates, run::run_save_templates, run::run_import_sources, diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 14f5f3d16..fe4990c80 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -64,6 +64,34 @@ pub fn run_save_configs( ide_host::ops::run::save_configs(&state.host, window.label(), &configs, &removed_detected_ids.unwrap_or_default()) } +/// Temporary configurations as they are kept between sessions: without what settings derived. +#[tauri::command] +#[specta::specta] +pub async fn run_without_derived( + window: Window, + state: State<'_, AppState>, + configs: Vec, +) -> IdeResult> { + let (host, label) = (state.host.clone(), window.label().to_owned()); + tauri::async_runtime::spawn_blocking(move || ide_host::ops::run::without_derived(&host, &label, configs)) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))? +} + +/// Kept temporary configurations with the current settings applied. +#[tauri::command] +#[specta::specta] +pub async fn run_apply_settings( + window: Window, + state: State<'_, AppState>, + configs: Vec, +) -> IdeResult> { + let (host, label) = (state.host.clone(), window.label().to_owned()); + tauri::async_runtime::spawn_blocking(move || ide_host::ops::run::apply_settings(&host, &label, configs)) + .await + .map_err(|e| ide_core::IdeError::internal(e.to_string()))? +} + #[tauri::command] #[specta::specta] pub async fn run_start( diff --git a/src/bindings.ts b/src/bindings.ts index 3367ae16e..ab0d32e51 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -587,6 +587,10 @@ export const commands = { runConfigs: () => typedError(__TAURI_INVOKE("run_configs")), runDotnetResolve: (config: RunConfig_Deserialize, debug: boolean) => typedError(__TAURI_INVOKE("run_dotnet_resolve", { config, debug })), runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })), + /** Temporary configurations as they are kept between sessions: without what settings derived. */ + runWithoutDerived: (configs: RunConfig_Deserialize[]) => typedError(__TAURI_INVOKE("run_without_derived", { configs })), + /** Kept temporary configurations with the current settings applied. */ + runApplySettings: (configs: RunConfig_Deserialize[]) => typedError(__TAURI_INVOKE("run_apply_settings", { configs })), /** 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/java.test.ts b/src/features/run/java.test.ts index 4ea7b7cd2..1ee300d77 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -391,7 +391,7 @@ describe("temporary configurations", () => { expect(s.temporary).not.toContain(apps[0]!.id); expect(s.selectedId).toBe(apps[apps.length - 1]!.id); expect(s.configs.filter((c) => c.kind === "application")).toHaveLength(MAX_TEMPORARY); - expect(JSON.parse(localStorage.getItem("openidea.run.temporary:/p")!)).toHaveLength(MAX_TEMPORARY); + expect(JSON.parse(localStorage.getItem("openidea.run.temporary.v2:/p")!)).toHaveLength(MAX_TEMPORARY); // Reusing an existing temporary moves it last instead of duplicating it. adoptTemporary(apps[3]!); expect(useRun.getState().temporary.at(-1)).toBe(apps[3]!.id); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 9c8f5096f..1d1396781 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -198,7 +198,10 @@ export async function loadConfigs(): Promise { const saved = await call(commands.runConfigs()); if (useProject.getState().project?.root !== root) return; // Temporary configs survive reloads (and restarts) until saved or evicted; a saved one wins. - const temps = readTemporary(root).filter((t) => !saved.some((c) => c.id === t.id)); + // They are kept without what settings derived: today's toolchains apply to them, as to the others. + const kept = readTemporary(root).filter((t) => !saved.some((c) => c.id === t.id)); + const temps = kept.length ? await call(commands.runApplySettings(kept)).catch(() => kept) : kept; + if (useProject.getState().project?.root !== root) return; const configs = [...saved, ...temps]; const stored = useRun.getState().selectedId ?? readSelected(root); const selectedId = @@ -312,7 +315,8 @@ export function selectedConfig(): RunConfig | undefined { // --------------------------------------------------------------------------- // Temporary configurations (IDEA: created by gutter / context runs, at most 5) -const TEMPORARY_KEY = "openidea.run.temporary:"; +// v2: kept without settings-derived values (earlier entries froze the toolchain they were made with). +const TEMPORARY_KEY = "openidea.run.temporary.v2:"; export const MAX_TEMPORARY = 5; function readTemporary(root: string): RunConfig[] { @@ -335,14 +339,23 @@ function writeTemporary(root: string, configs: RunConfig[]) { } } +let persisting = 0; + function persistTemporary() { const root = useProject.getState().project?.root; if (!root) return; const { configs, temporary } = useRun.getState(); - writeTemporary( - root, - temporary.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c), - ); + const list = temporary.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c); + writeTemporary(root, list); + 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); + }) + .catch(() => {}); } /** IDEA's `isConfigurationFromContext` for an Application: the same main class of the same module. */ diff --git a/src/features/run/temporarySettings.test.ts b/src/features/run/temporarySettings.test.ts new file mode 100644 index 000000000..c07f4b24d --- /dev/null +++ b/src/features/run/temporarySettings.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => ({ + runConfigs: vi.fn(), + runWithoutDerived: vi.fn(), + runApplySettings: vi.fn(), +})); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + isTauri: () => true, + commands: ipc, +})); + +import type { RunConfig } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { adoptTemporary, loadConfigs, resetRuns, useRun } from "./store"; + +const KEY = "openidea.run.temporary.v2:/p"; +const ok = (data: T) => Promise.resolve({ status: "ok" as const, data }); + +/** A Maven test run made from the tool window, with the settings of the day baked in. */ +const goal: RunConfig = { + id: "maven:goal:pom.xml:test", + name: "app [test]", + kind: "maven", + command: "/old/maven/bin/mvn", + args: ["-s", "/old/settings.xml", "test"], + cwd: null, + env: { JAVA_HOME: "/old/jdk" }, + detected: false, + isTest: true, + debugAdapter: null, +}; + +let mavenHome = "/new/maven"; +beforeEach(() => { + resetRuns(); + localStorage.clear(); + useProject.setState({ project: { root: "/p" } as never }); + ipc.runConfigs.mockImplementation(() => ok([])); + // The backend drops what settings derived, and applies the current settings again. + ipc.runWithoutDerived.mockImplementation((configs: RunConfig[]) => + ok( + configs.map((c) => ({ + ...c, + command: "mvn", + args: c.args.filter((a, i, all) => a !== "-s" && all[i - 1] !== "-s"), + env: {}, + })), + ), + ); + ipc.runApplySettings.mockImplementation((configs: RunConfig[]) => + ok( + configs.map((c) => ({ + ...c, + command: `${mavenHome}/bin/mvn`, + args: ["-s", `${mavenHome}/conf/settings.xml`, ...c.args], + env: { JAVA_HOME: "/new/jdk" }, + })), + ), + ); +}); +afterEach(() => useProject.setState({ project: null })); + +it("keeps temporary configurations without settings-derived values and applies today's settings on load", async () => { + adoptTemporary(goal); + await vi.waitFor(() => expect(JSON.parse(localStorage.getItem(KEY)!)[0].command).toBe("mvn")); + const kept = JSON.parse(localStorage.getItem(KEY)!)[0] as RunConfig; + expect(kept.args).toEqual(["test"]); + expect(kept.env).toEqual({}); + + // Settings › Maven changed: the reload after it brings the temporary configuration along. + mavenHome = "/moved/maven"; + await loadConfigs(); + const loaded = useRun.getState().configs.find((c) => c.id === goal.id)!; + expect(useRun.getState().temporary).toEqual([goal.id]); + expect(loaded.command).toBe("/moved/maven/bin/mvn"); + expect(loaded.args).toEqual(["-s", "/moved/maven/conf/settings.xml", "test"]); + expect(loaded.env).toEqual({ JAVA_HOME: "/new/jdk" }); +});