diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index f172a4f3..511846bf 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 38795f7d..034c40fb 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 bca51e30..7ff5e54c 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 858af113..af8bae61 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 af70e0da..2b4cee4e 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 14f5f3d1..fe4990c8 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 3367ae16..ab0d32e5 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 4ea7b7cd..1ee300d7 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 9c8f5096..1d139678 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 00000000..c07f4b24 --- /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" }); +});