fix(run): keep temporary run configurations following the toolchain settings (#1167)

Temporary run configurations were kept with what settings derived when they were made (absolute mvn, -s / -Dmaven.repo.local, JAVA_HOME), and never went through the backend again, so after a Maven home / settings file / JDK change they and gutter test runs narrowed from them still ran the old ones. They are now kept without derived values (run_without_derived) and get the current settings applied when the run list loads (run_apply_settings). Maven's settings file and local repository apply to every Maven configuration unless it sets them itself. Earlier stored entries are dropped once.

Refs #822
This commit is contained in:
l0ng-ai
2026-11-04 11:56:03 +08:00
committed by GitHub
parent 59567f1af0
commit d3b9a39831
10 changed files with 269 additions and 33 deletions
+55 -10
View File
@@ -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<RunConfig>) -> IdeResult<Vec<RunConfig>> {
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<RunConfig>) -> IdeResult<Vec<RunConfig>> {
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<OutputChunk>) -> IdeResult<RunInfo> {
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();
+2
View File
@@ -257,6 +257,8 @@ impl Dispatcher {
&c.arg::<Option<Vec<String>>>("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")?;
+52 -16
View File
@@ -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 <file>`, `-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<String>, options: &[String]) {
let missing: Vec<String> =
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())
+26
View File
@@ -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"]);
}
}
+2
View File
@@ -316,6 +316,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+28
View File
@@ -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<RunConfig>,
) -> IdeResult<Vec<RunConfig>> {
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<RunConfig>,
) -> IdeResult<Vec<RunConfig>> {
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(
+4
View File
@@ -587,6 +587,10 @@ export const commands = {
runConfigs: () => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_configs")),
runDotnetResolve: (config: RunConfig_Deserialize, debug: boolean) => typedError<RunConfig_Serialize, IdeError>(__TAURI_INVOKE("run_dotnet_resolve", { config, debug })),
runSaveConfigs: (configs: RunConfig_Deserialize[], removedDetectedIds: string[] | null) => typedError<null, IdeError>(__TAURI_INVOKE("run_save_configs", { configs, removedDetectedIds })),
/** Temporary configurations as they are kept between sessions: without what settings derived. */
runWithoutDerived: (configs: RunConfig_Deserialize[]) => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_without_derived", { configs })),
/** Kept temporary configurations with the current settings applied. */
runApplySettings: (configs: RunConfig_Deserialize[]) => typedError<RunConfig_Serialize[], IdeError>(__TAURI_INVOKE("run_apply_settings", { configs })),
/** 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 })),
+1 -1
View File
@@ -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);
+19 -6
View File
@@ -198,7 +198,10 @@ export async function loadConfigs(): Promise<void> {
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. */
@@ -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<typeof import("@/platform/ipc")>()),
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 = <T>(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" });
});