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).
Цей коміт міститься в:
l0ng-ai committed 2026-11-04 11:55:34 +08:00
1 джерело fc6a27d4ba
коміт 81e4430242
10 files changed
+269 -33

No files matched your search

+55 -10
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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. */
+80
Переглянути файл
@@ -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" });
});