From be70dab2bc8653a02dd3b6ac13af469367359f57 Mon Sep 17 00:00:00 2001 From: Jay Date: Mon, 21 Sep 2026 00:07:01 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(windows):=20=E5=9C=A8=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E6=96=B0=E5=A2=9E=20Maven=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=B8=89=E5=A4=84?= =?UTF-8?q?=20Maven=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- windows/tauri/src-tauri/src/main.rs | 1 + windows/tauri/src-tauri/src/maven.rs | 262 ++++++++++++- windows/tauri/src-tauri/src/run.rs | 12 +- .../constants/settings-actions.tsx | 1 + .../src/features/maven/api/maven-host-api.ts | 23 ++ .../maven/components/maven-detected-value.tsx | 31 ++ .../features/maven/components/maven-pane.tsx | 7 + .../maven/services/maven-module-debug.test.ts | 1 + .../features/maven/stores/maven.store.test.ts | 87 ++++- .../src/features/maven/stores/maven.store.ts | 346 +++++++++++------- .../src/features/maven/types/maven.types.ts | 11 + .../components/run-configuration-editor.tsx | 115 +++++- .../run/stores/run-maven-context.test.ts | 2 + .../features/run/stores/run-prelaunch.test.ts | 1 + .../src/features/run/stores/run.store.ts | 45 ++- .../components/macos-settings-panels.tsx | 4 + .../settings/components/settings-dialog.tsx | 3 + .../components/tabs/maven-settings-panel.tsx | 154 ++++++++ .../features/settings/config/search-index.ts | 34 ++ .../features/settings/lib/settings-search.ts | 1 + .../stores/ui-state/types/ui-state.types.ts | 1 + windows/tauri/src/i18n/locale.ts | 9 + 22 files changed, 992 insertions(+), 159 deletions(-) create mode 100644 windows/tauri/src/features/maven/components/maven-detected-value.tsx create mode 100644 windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index e0809086e..452711907 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -175,6 +175,7 @@ fn main() { lsp::lsp_resolve_java_launch, lsp::lsp_rebuild_java_index, maven::maven_load_configuration, + maven::maven_resolve_effective_configuration, maven::maven_write_configuration, run::run_list_java_sources, run::run_write_generated, diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs index 6aef0c935..1de87fb0a 100644 --- a/windows/tauri/src-tauri/src/maven.rs +++ b/windows/tauri/src-tauri/src/maven.rs @@ -3,7 +3,7 @@ //! Portable selections stay below the workspace `.lithe` directory. Maven, //! JDK, and settings paths are stored only in the application data directory. -use crate::run::atomic_write; +use crate::run::{atomic_write, resolve_java_home, resolve_maven_executable}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; @@ -83,6 +83,157 @@ pub fn maven_write_configuration( ) } +/// Arguments for resolving the configuration a Maven launch would use. Every +/// value mirrors one of the machine-local fields; empty means "detect it". +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveEffectiveConfigurationArgs { + pub root: PathBuf, + /// Reactor path relative to the workspace root; empty means the root itself. + #[serde(default)] + pub working_directory: String, + #[serde(default)] + pub settings_path: String, + #[serde(default)] + pub local_repository_path: String, + #[serde(default)] + pub maven_executable_path: String, + #[serde(default)] + pub java_home_path: String, +} + +/// The values a Maven launch would actually use. `None` means the machine-level +/// detection found nothing for that field. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenEffectiveConfiguration { + pub settings_path: Option, + pub local_repository_path: Option, + pub maven_executable_path: Option, + pub java_home_path: Option, +} + +/// Resolves the effective Maven configuration for the saved settings, so the +/// configuration surfaces can show what their empty fields resolve to. +/// +/// The Maven executable and JDK come from the same resolution the launch path +/// uses, which keeps the reported values identical to the launched ones. +#[tauri::command] +pub fn maven_resolve_effective_configuration( + args: ResolveEffectiveConfigurationArgs, +) -> Result { + let root = existing_directory(&args.root)?; + let working_directory = match args.working_directory.trim() { + "" => root.clone(), + reactor => root.join(reactor), + }; + let maven_executable_path = + resolve_maven_executable(&root, &working_directory, &args.maven_executable_path).ok(); + let java_home_path = resolve_java_home(&root, &args.java_home_path) + .ok() + .flatten(); + let home = user_home_directory(); + let settings_path = effective_settings_path( + &args.settings_path, + home.as_deref(), + maven_executable_path.as_deref(), + ); + let local_repository_path = effective_local_repository_path( + &args.local_repository_path, + settings_path.as_deref(), + home.as_deref(), + ); + Ok(MavenEffectiveConfiguration { + settings_path, + local_repository_path, + maven_executable_path, + java_home_path, + }) +} + +/// The current user's home directory, which owns the Maven user-level defaults +/// (`~/.m2/settings.xml` and `~/.m2/repository`). +fn user_home_directory() -> Option { + std::env::var("USERPROFILE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +/// The settings file Maven itself would read: the configured path, then the +/// user-level default, then the detected installation's global settings. +/// +/// A configured path is reported even when the file is missing, because that is +/// what the launch would ask for and the surface must not hide the user's input. +fn effective_settings_path( + configured: &str, + home: Option<&Path>, + maven_executable_path: Option<&str>, +) -> Option { + let configured = configured.trim(); + if !configured.is_empty() { + return Some(configured.to_string()); + } + let mut candidates = Vec::new(); + if let Some(home) = home { + candidates.push(home.join(".m2").join("settings.xml")); + } + if let Some(executable) = maven_executable_path { + // `/bin/mvn.cmd` -> `/conf/settings.xml`. + if let Some(installation) = Path::new(executable).parent().and_then(Path::parent) { + candidates.push(installation.join("conf").join("settings.xml")); + } + } + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .map(|path| path.to_string_lossy().into_owned()) +} + +/// The local repository Maven would use: the configured path, then the +/// `` of the effective settings, then the `~/.m2/repository` +/// default. +fn effective_local_repository_path( + configured: &str, + settings_path: Option<&str>, + home: Option<&Path>, +) -> Option { + let configured = configured.trim(); + if !configured.is_empty() { + return Some(configured.to_string()); + } + if let Some(settings_path) = settings_path { + let contents = fs::read_to_string(settings_path).unwrap_or_default(); + if let Some(repository) = parse_local_repository(&contents) { + return Some(repository); + } + } + home.map(|home| { + home.join(".m2") + .join("repository") + .to_string_lossy() + .into_owned() + }) +} + +/// Extracts the `` element of a Maven settings document. +/// +/// Hand-parsed rather than regex-matched: the element is a single tag with no +/// nested markup, and a missing closing tag must stay undetected instead of +/// being reported as a repository path. +fn parse_local_repository(settings: &str) -> Option { + const OPENING_TAG: &str = ""; + const CLOSING_TAG: &str = ""; + let remainder = &settings[settings.find(OPENING_TAG)? + OPENING_TAG.len()..]; + let value = remainder + .split_once(CLOSING_TAG) + .map(|(value, _)| value) + .unwrap_or(remainder) + .trim(); + (!value.is_empty()).then(|| value.to_string()) +} + fn validate_versions( portable: Option<&MavenPortableConfiguration>, local: Option<&MavenLocalConfiguration>, @@ -182,6 +333,115 @@ mod tests { path } + #[test] + fn parses_the_local_repository_element() { + let settings = + "\n D:\\repo\\maven\n"; + assert_eq!( + parse_local_repository(settings).as_deref(), + Some("D:\\repo\\maven") + ); + // Surrounding whitespace is formatting, not part of the path. + assert_eq!( + parse_local_repository("\n D:/repo \n").as_deref(), + Some("D:/repo") + ); + // Empty and absent elements are undetected, and an unterminated element + // must not turn the rest of the document into a repository path. + assert_eq!( + parse_local_repository(" "), + None + ); + assert_eq!(parse_local_repository(""), None); + assert_eq!(parse_local_repository(""), None); + } + + #[test] + fn local_repository_prefers_configured_then_settings_then_default() { + let home = temp_directory(); + let settings = home.join("user-settings.xml"); + fs::write( + &settings, + "C:\\from-settings", + ) + .expect("write settings"); + let settings = settings.to_string_lossy(); + let home_default = home + .join(".m2") + .join("repository") + .to_string_lossy() + .into_owned(); + + assert_eq!( + effective_local_repository_path("D:\\custom", Some(&settings), Some(&home)).as_deref(), + Some("D:\\custom") + ); + assert_eq!( + effective_local_repository_path("", Some(&settings), Some(&home)).as_deref(), + Some("C:\\from-settings") + ); + assert_eq!( + effective_local_repository_path("", None, Some(&home)).as_deref(), + Some(home_default.as_str()) + ); + // Without a home directory there is no default to fall back to. + assert_eq!(effective_local_repository_path("", None, None), None); + fs::remove_dir_all(home).ok(); + } + + #[test] + fn settings_path_prefers_configured_then_user_then_installation() { + let home = temp_directory(); + let installation = temp_directory(); + let configured = home.join("configured.xml"); + fs::write(&configured, "").expect("write configured settings"); + let executable = installation + .join("bin") + .join("mvn.cmd") + .to_string_lossy() + .into_owned(); + let user_settings = home.join(".m2").join("settings.xml"); + let global_settings = installation.join("conf").join("settings.xml"); + fs::create_dir_all(installation.join("conf")).expect("conf directory"); + + // The configured path wins even when the file does not exist yet: the + // launch would fail on it, so the surface must keep showing it. + assert_eq!( + effective_settings_path( + &configured.to_string_lossy(), + Some(&home), + Some(&executable) + ) + .as_deref(), + Some(configured.to_string_lossy().as_ref()) + ); + + // Neither default exists yet. + assert_eq!( + effective_settings_path("", Some(&home), Some(&executable)), + None + ); + + // The user-level file wins over the installation-level one. + fs::create_dir_all(home.join(".m2")).expect("user m2"); + fs::write(&user_settings, "").expect("write user settings"); + fs::write(&global_settings, "").expect("write global settings"); + assert_eq!( + effective_settings_path("", Some(&home), Some(&executable)).as_deref(), + Some(user_settings.to_string_lossy().as_ref()) + ); + + // Removing the user-level file falls back to the installation. + fs::remove_file(&user_settings).expect("remove user settings"); + assert_eq!( + effective_settings_path("", Some(&home), Some(&executable)).as_deref(), + Some(global_settings.to_string_lossy().as_ref()) + ); + + fs::remove_dir_all(home).ok(); + fs::remove_dir_all(installation).ok(); + } + #[test] fn portable_configuration_round_trips_without_local_paths() { let root = temp_directory(); diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index e47d11eb8..dfe51b667 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -995,7 +995,12 @@ fn java_home_from_executable(executable: &Path) -> Option { .map(Path::to_path_buf) } -fn resolve_java_home(root: &Path, override_path: &str) -> Result, String> { +/// Resolves the JDK a Java or Maven launch would run with. Shared with the +/// Maven configuration surfaces so they report the value a launch actually uses. +pub(crate) fn resolve_java_home( + root: &Path, + override_path: &str, +) -> Result, String> { let configured = override_path.trim(); if !configured.is_empty() { let path = if Path::new(configured).is_absolute() { @@ -1199,7 +1204,10 @@ fn prepend_runtime_paths( Ok(()) } -fn resolve_maven_executable( +/// Resolves the Maven executable a launch would run, including the wrapper, +/// PATH, and `MAVEN_HOME` candidate chain. Shared with the Maven configuration +/// surfaces so they report the value a launch actually uses. +pub(crate) fn resolve_maven_executable( root: &Path, working_directory: &Path, override_path: &str, diff --git a/windows/tauri/src/features/command-palette/constants/settings-actions.tsx b/windows/tauri/src/features/command-palette/constants/settings-actions.tsx index bd14dccd1..c8f6ab110 100644 --- a/windows/tauri/src/features/command-palette/constants/settings-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/settings-actions.tsx @@ -71,6 +71,7 @@ const settingsTabLabels: Record = { logs: "Logs", advanced: "Advanced", terminal: "Terminal", + maven: "Maven", "file-explorer": "Files", }; diff --git a/windows/tauri/src/features/maven/api/maven-host-api.ts b/windows/tauri/src/features/maven/api/maven-host-api.ts index e4eb900fd..00c3f7320 100644 --- a/windows/tauri/src/features/maven/api/maven-host-api.ts +++ b/windows/tauri/src/features/maven/api/maven-host-api.ts @@ -1,8 +1,10 @@ import { invoke } from "@/platform/tauri-core"; import { resolveRunLaunch, startRunProcess, stopRunProcess } from "@/features/run/api/run-host-api"; import type { + MavenEffectiveConfiguration, MavenLaunchContext, MavenLaunchPlan, + MavenSettings, MavenStoredConfiguration, } from "../types/maven.types"; @@ -23,6 +25,27 @@ export function writeMavenConfiguration( }); } +/** + * Resolves what a Maven launch would actually use for the given settings, so the + * configuration surfaces can show what their empty fields fall back to. + */ +export function resolveMavenEffectiveConfiguration( + root: string, + workingDirectory: string, + settings: MavenSettings, +) { + return invoke("maven_resolve_effective_configuration", { + args: { + root, + workingDirectory, + settingsPath: settings.settingsPath, + localRepositoryPath: settings.localRepositoryPath, + mavenExecutablePath: settings.mavenExecutablePath, + javaHomePath: settings.javaHomePath, + }, + }); +} + export async function resolveMavenLaunch( root: string, context: MavenLaunchContext, diff --git a/windows/tauri/src/features/maven/components/maven-detected-value.tsx b/windows/tauri/src/features/maven/components/maven-detected-value.tsx new file mode 100644 index 000000000..dc89556be --- /dev/null +++ b/windows/tauri/src/features/maven/components/maven-detected-value.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from "@/i18n/locale-provider"; +import type { MavenEffectiveConfiguration, MavenSettings } from "../types/maven.types"; + +/** + * The gray line shown under a blank Maven path field: what the launch would use + * instead, or that nothing was detected. Renders nothing once the field holds a + * custom value (the input is then the effective value) and nothing before the + * detection has resolved. + */ +export function MavenDetectedValue({ + field, + value, + effective, +}: { + field: keyof MavenSettings; + value: string; + effective: MavenEffectiveConfiguration | null; +}) { + const { t } = useTranslation(); + if (value || !effective) return null; + const detected = effective[field]; + return ( +

+ {detected ? t("maven.detectedValue", { value: detected }) : t("maven.detectedMissing")} +

+ ); +} diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index 5955c91d9..ed05fa6a8 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -50,6 +50,7 @@ import { type MavenModule, type MavenSettings, } from "../types/maven.types"; +import { MavenDetectedValue } from "./maven-detected-value"; import { MavenSourceRootRows } from "./maven-source-root-rows"; import { MavenLifecycleContextMenu, @@ -148,6 +149,7 @@ function MavenSettingsDialog({ onSave: (settings: MavenSettings) => void; }) { const { t } = useTranslation(); + const effectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); const [draft, setDraft] = useState(initial); const choosePath = async (field: keyof MavenSettings, directory: boolean) => { @@ -251,6 +253,11 @@ function MavenSettingsDialog({ + ))} diff --git a/windows/tauri/src/features/maven/services/maven-module-debug.test.ts b/windows/tauri/src/features/maven/services/maven-module-debug.test.ts index 8f57bdcbc..2d772a3e3 100644 --- a/windows/tauri/src/features/maven/services/maven-module-debug.test.ts +++ b/windows/tauri/src/features/maven/services/maven-module-debug.test.ts @@ -233,6 +233,7 @@ for (const execution of ["service", "application"] as const) { processes.set(sessionId, executionId!); }, stopRunProcess, + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: async () => null, }; const selected = { ...configuration, execution }; diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index d35d35096..0caf13656 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -86,7 +86,9 @@ const dependencyTree: MavenDependenciesResponse = { ], }; -const scanMavenProject = mock(async (_root: string, _paths?: string[]) => project); +const scanMavenProject = mock( + async (_root: string, _paths?: string[]): Promise => project, +); const createMavenLaunchPlan = mock(async () => launchPlan); const createMavenDependencyPlan = mock(async () => launchPlan); const parseMavenDependencies = mock( @@ -121,6 +123,13 @@ const resolveMavenLaunch = mock(async () => ({ environment: {}, })); const saveWorkspaceBeforeLaunch = mock(async (_workspaceId: string): Promise => undefined); +const effectiveConfiguration = { + settingsPath: "C:/Users/example/.m2/settings.xml", + localRepositoryPath: "C:/Users/example/.m2/repository", + mavenExecutablePath: "D:/Tools/apache-maven/bin/mvn.cmd", + javaHomePath: "C:/Java/jdk-21", +}; +const resolveMavenEffectiveConfiguration = mock(async () => effectiveConfiguration); const startMavenProcess = mock(async () => undefined); const stopMavenProcess = mock(async () => undefined); const trace = mock(() => undefined); @@ -140,6 +149,7 @@ const dependencies = { parseMavenTestResults, parseMavenDependencies, resolveMavenLaunch, + resolveMavenEffectiveConfiguration, saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, @@ -173,6 +183,8 @@ beforeEach(() => { parseMavenDependencies.mockReset(); parseMavenDependencies.mockResolvedValue(dependencyTree); resolveMavenLaunch.mockClear(); + resolveMavenEffectiveConfiguration.mockReset(); + resolveMavenEffectiveConfiguration.mockResolvedValue(effectiveConfiguration); saveWorkspaceBeforeLaunch.mockReset(); saveWorkspaceBeforeLaunch.mockResolvedValue(undefined); startMavenProcess.mockClear(); @@ -406,6 +418,79 @@ describe("Maven workspace state", () => { }); }); + test("skips seeding when no Maven project is loaded", () => { + const store = createMavenStore("workspace", dependencies); + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + }); + expect(store.getState().mavenExecutablePath).toBe(""); + }); + + test("seeds only blank local fields without flagging a reload", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + store.getState().actions.updateLocalConfiguration({ + settingsPath: "C:/custom/settings.xml", + localRepositoryPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }); + store.getState().actions.acknowledgeReload(); + expect(store.getState().reloadRequired).toBe(false); + + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + + expect(store.getState().settingsPath).toBe("C:/custom/settings.xml"); + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + expect(store.getState().javaHomePath).toBe("C:/Java/jdk-21"); + expect(store.getState().reloadRequired).toBe(false); + + // Existing values win, so a second seed cannot overwrite them. + store.getState().actions.seedLocalConfiguration({ + settingsPath: "C:/Other/settings.xml", + mavenExecutablePath: "D:/Other/maven", + }); + expect(store.getState().settingsPath).toBe("C:/custom/settings.xml"); + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + }); + + test("shares the effective machine configuration with every surface", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + // Loading the project already refreshes the detected values. + expect(store.getState().effectiveConfiguration).toEqual(effectiveConfiguration); + expect(resolveMavenEffectiveConfiguration).toHaveBeenCalledWith( + "D:/work", + "reactor", + expect.objectContaining({ mavenExecutablePath: "", javaHomePath: "" }), + ); + + // A detection failure hides the detected values instead of reporting one. + resolveMavenEffectiveConfiguration.mockRejectedValueOnce(new Error("detection failed")); + await store.getState().actions.resolveEffectiveConfiguration(); + expect(store.getState().effectiveConfiguration).toBeNull(); + }); + + test("clears the effective configuration for a workspace without Maven", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + expect(store.getState().effectiveConfiguration).toEqual(effectiveConfiguration); + resolveMavenEffectiveConfiguration.mockClear(); + + // A workspace without a detected Maven project never shows detected values, + // and never asks the host to detect them either. + scanMavenProject.mockResolvedValue(null); + await store.getState().actions.loadProject("D:/plain", []); + await store.getState().actions.resolveEffectiveConfiguration(); + + expect(store.getState().effectiveConfiguration).toBeNull(); + expect(resolveMavenEffectiveConfiguration).not.toHaveBeenCalled(); + }); + test("serializes rapid configuration writes so the newest value wins", async () => { const firstStarted = deferred(); const firstWrite = deferred(); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index fbeac83f8..b3e833dca 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -13,6 +13,7 @@ import { } from "../api/maven-core-api"; import { loadMavenConfiguration, + resolveMavenEffectiveConfiguration, resolveMavenLaunch, startMavenProcess, stopMavenProcess, @@ -26,6 +27,7 @@ import { import type { MavenDependencyLoad, MavenDiagnostic, + MavenEffectiveConfiguration, MavenLaunchContext, MavenLocalConfiguration, MavenPortableConfiguration, @@ -66,6 +68,7 @@ export interface MavenStoreDependencies { parseMavenDependencies: typeof parseMavenDependencies; parseMavenTestResults: typeof parseMavenTestResults; resolveMavenLaunch: typeof resolveMavenLaunch; + resolveMavenEffectiveConfiguration: typeof resolveMavenEffectiveConfiguration; saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; scanMavenProject: typeof scanMavenProject; startMavenProcess: typeof startMavenProcess; @@ -83,6 +86,7 @@ const defaultMavenStoreDependencies: MavenStoreDependencies = { parseMavenDependencies, parseMavenTestResults, resolveMavenLaunch, + resolveMavenEffectiveConfiguration, saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, @@ -118,6 +122,11 @@ export interface MavenState { mavenExecutablePath: string; javaHomePath: string; configurationSaveError: string | null; + /** + * What a Maven launch would use for the saved configuration, shared by every + * surface that shows these fields. `null` hides the detected values. + */ + effectiveConfiguration: MavenEffectiveConfiguration | null; reloadRequired: boolean; projectReloadRequired: boolean; reloadRevision: number; @@ -150,6 +159,8 @@ export interface MavenState { restoreDefaultProfiles: () => void; setSkipTests: (enabled: boolean) => void; updateLocalConfiguration: (settings: MavenSettings) => void; + seedLocalConfiguration: (settings: Partial) => void; + resolveEffectiveConfiguration: () => Promise; acknowledgeReload: (revision?: number) => void; runGoals: ( goals: string[], @@ -295,6 +306,7 @@ export const createMavenStore = ( ) => { let projectLoadRevision = 0; let configurationRevision = 0; + let effectiveConfigurationRevision = 0; let launchRevision = 0; let diagnosticsRevision = 0; let dependencyRevision = 0; @@ -496,6 +508,7 @@ export const createMavenStore = ( mavenExecutablePath: "", javaHomePath: "", configurationSaveError: null, + effectiveConfiguration: null, reloadRequired: false, projectReloadRequired: false, reloadRevision: 0, @@ -516,58 +529,148 @@ export const createMavenStore = ( dependencyOutput: "", actions: { loadProject: async (root, visiblePaths = []) => { - const revision = ++projectLoadRevision; - configurationRevision += 1; - diagnosticsRevision += 1; - invalidateDependencies(); - const previous = get(); - if (previous.root && previous.root !== root && previous.activeSessionId) { - launchRevision += 1; - clearTestTimer(); + try { + const revision = ++projectLoadRevision; + configurationRevision += 1; diagnosticsRevision += 1; - await dependencies.stopMavenProcess(previous.activeSessionId).catch(() => undefined); - releaseMavenSessionWorkspace(previous.activeSessionId); - } - if (previous.root && previous.root !== root) { - await synchronizePomWatches(new Set()); - if (projectLoadRevision !== revision) return; - } - set({ - root, - visiblePaths: [...visiblePaths], - projectStatus: "loading", - projectError: null, - configurationSaveError: - previous.root === root ? previous.configurationSaveError : null, - testResults: null, - activeTestRun: null, - ...(previous.root !== root - ? { reloadRequired: false, projectReloadRequired: false } - : {}), - ...(previous.root && previous.root !== root - ? { + invalidateDependencies(); + const previous = get(); + if (previous.root && previous.root !== root && previous.activeSessionId) { + launchRevision += 1; + clearTestTimer(); + diagnosticsRevision += 1; + await dependencies.stopMavenProcess(previous.activeSessionId).catch(() => undefined); + releaseMavenSessionWorkspace(previous.activeSessionId); + } + if (previous.root && previous.root !== root) { + await synchronizePomWatches(new Set()); + if (projectLoadRevision !== revision) return; + } + set({ + root, + visiblePaths: [...visiblePaths], + projectStatus: "loading", + projectError: null, + configurationSaveError: + previous.root === root ? previous.configurationSaveError : null, + testResults: null, + activeTestRun: null, + ...(previous.root !== root + ? { reloadRequired: false, projectReloadRequired: false } + : {}), + ...(previous.root && previous.root !== root + ? { + project: null, + taskStatus: "idle" as const, + taskError: null, + activeSessionId: null, + taskTitle: null, + output: "", + issues: [], + lastExitCode: null, + testResults: null, + activeTestRun: null, + lastTestRun: null, + } + : {}), + }); + try { + const project = await dependencies.scanMavenProject(root, visiblePaths); + if (projectLoadRevision !== revision || get().root !== root) return; + if (!project) { + await synchronizePomWatches(new Set()); + if (projectLoadRevision !== revision || get().root !== root) return; + set({ + projectStatus: "ready", project: null, - taskStatus: "idle" as const, - taskError: null, - activeSessionId: null, - taskTitle: null, - output: "", - issues: [], - lastExitCode: null, + selectedProfiles: [], + customProfiles: [], + skipTests: false, + settingsPath: "", + localRepositoryPath: "", + mavenExecutablePath: "", + javaHomePath: "", + reloadRequired: false, testResults: null, activeTestRun: null, lastTestRun: null, - } - : {}), - }); - try { - const project = await dependencies.scanMavenProject(root, visiblePaths); - if (projectLoadRevision !== revision || get().root !== root) return; - if (!project) { - await synchronizePomWatches(new Set()); + }); + return; + } + const configurationRevisionBeforeWriteWait = configurationRevision; + const pendingConfigurationWriteTask = configurationWriteTask; + let configurationWriteSucceeded = true; + let configurationWriteError: unknown; + try { + await pendingConfigurationWriteTask; + } catch (error) { + configurationWriteSucceeded = false; + configurationWriteError = error; + } + if (projectLoadRevision !== revision || get().root !== root) return; + const preserveInMemoryConfiguration = + previous.root === root && !configurationWriteSucceeded; + if (preserveInMemoryConfiguration) { + set({ + configurationSaveError: + configurationWriteError instanceof Error + ? configurationWriteError.message + : "Unable to save Maven configuration.", + }); + } + const loadedConfiguration = preserveInMemoryConfiguration + ? null + : await dependencies.loadMavenConfiguration(root, project.relativePath); if (projectLoadRevision !== revision || get().root !== root) return; + await synchronizePomWatches(mavenPomPaths(root, project)); + if (projectLoadRevision !== revision || get().root !== root) return; + const preserveLatestInMemoryConfiguration = + previous.root === root && + (!configurationWriteSucceeded || + configurationRevision !== configurationRevisionBeforeWriteWait); + const stored = preserveLatestInMemoryConfiguration + ? storedConfiguration(get()) + : (loadedConfiguration ?? {}); + const customProfiles = normalizedProfiles(stored.portable?.customProfiles ?? []); + const knownProfiles = new Set([ + ...project.profiles.map((profile) => profile.id), + ...customProfiles, + ]); + const defaultProfiles = project.profiles + .filter((profile) => profile.isActiveByDefault) + .map((profile) => profile.id); + const selectedProfiles = normalizedProfiles( + stored.portable?.selectedProfiles ?? defaultProfiles, + ).filter((profile) => knownProfiles.has(profile)); set({ projectStatus: "ready", + projectError: null, + project, + selectedProfiles, + customProfiles, + skipTests: stored.portable?.skipTests ?? false, + settingsPath: normalizedPath(stored.local?.settingsPath), + localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), + mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), + javaHomePath: normalizedPath(stored.local?.javaHomePath), + }); + } catch (error) { + if (projectLoadRevision !== revision || get().root !== root) return; + const message = + error instanceof Error ? error.message : "Unable to scan the Maven project."; + if (previous.root === root && previous.project) { + set((state) => ({ + projectStatus: "failed", + projectError: message, + reloadRequired: true, + projectReloadRequired: true, + reloadRevision: state.reloadRevision + 1, + })); + return; + } + set({ + projectStatus: "failed", + projectError: message, project: null, selectedProfiles: [], customProfiles: [], @@ -576,99 +679,17 @@ export const createMavenStore = ( localRepositoryPath: "", mavenExecutablePath: "", javaHomePath: "", - reloadRequired: false, testResults: null, activeTestRun: null, lastTestRun: null, }); - return; - } - const configurationRevisionBeforeWriteWait = configurationRevision; - const pendingConfigurationWriteTask = configurationWriteTask; - let configurationWriteSucceeded = true; - let configurationWriteError: unknown; - try { - await pendingConfigurationWriteTask; - } catch (error) { - configurationWriteSucceeded = false; - configurationWriteError = error; - } - if (projectLoadRevision !== revision || get().root !== root) return; - const preserveInMemoryConfiguration = - previous.root === root && !configurationWriteSucceeded; - if (preserveInMemoryConfiguration) { - set({ - configurationSaveError: - configurationWriteError instanceof Error - ? configurationWriteError.message - : "Unable to save Maven configuration.", - }); - } - const loadedConfiguration = preserveInMemoryConfiguration - ? null - : await dependencies.loadMavenConfiguration(root, project.relativePath); - if (projectLoadRevision !== revision || get().root !== root) return; - await synchronizePomWatches(mavenPomPaths(root, project)); - if (projectLoadRevision !== revision || get().root !== root) return; - const preserveLatestInMemoryConfiguration = - previous.root === root && - (!configurationWriteSucceeded || - configurationRevision !== configurationRevisionBeforeWriteWait); - const stored = preserveLatestInMemoryConfiguration - ? storedConfiguration(get()) - : (loadedConfiguration ?? {}); - const customProfiles = normalizedProfiles(stored.portable?.customProfiles ?? []); - const knownProfiles = new Set([ - ...project.profiles.map((profile) => profile.id), - ...customProfiles, - ]); - const defaultProfiles = project.profiles - .filter((profile) => profile.isActiveByDefault) - .map((profile) => profile.id); - const selectedProfiles = normalizedProfiles( - stored.portable?.selectedProfiles ?? defaultProfiles, - ).filter((profile) => knownProfiles.has(profile)); - set({ - projectStatus: "ready", - projectError: null, - project, - selectedProfiles, - customProfiles, - skipTests: stored.portable?.skipTests ?? false, - settingsPath: normalizedPath(stored.local?.settingsPath), - localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), - mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), - javaHomePath: normalizedPath(stored.local?.javaHomePath), - }); - } catch (error) { - if (projectLoadRevision !== revision || get().root !== root) return; - const message = - error instanceof Error ? error.message : "Unable to scan the Maven project."; - if (previous.root === root && previous.project) { - set((state) => ({ - projectStatus: "failed", - projectError: message, - reloadRequired: true, - projectReloadRequired: true, - reloadRevision: state.reloadRevision + 1, - })); - return; } - set({ - projectStatus: "failed", - projectError: message, - project: null, - selectedProfiles: [], - customProfiles: [], - skipTests: false, - settingsPath: "", - localRepositoryPath: "", - mavenExecutablePath: "", - javaHomePath: "", - testResults: null, - activeTestRun: null, - lastTestRun: null, - }); + } finally { + // Deliberately not awaited: the scan has already produced the project + // state, and detection probes the machine, which must not delay + // opening a workspace. The action resolves once detection settles. + const resolveEffectiveConfiguration = get().actions.resolveEffectiveConfiguration; + void resolveEffectiveConfiguration(); } }, @@ -757,6 +778,67 @@ export const createMavenStore = ( } set(next); configurationDidChange(); + void get().actions.resolveEffectiveConfiguration(); + }, + + // Fills blank local fields from another configuration surface, such as + // the run feature's legacy project toolchain. Existing values win and the + // reload prompt stays off: this migrates an already effective value + // instead of recording a user edit. Workspaces without a Maven project + // never seed, because the shared configuration exists only with one. + seedLocalConfiguration: (settings) => { + const state = get(); + if (!state.project) return; + const next = { + settingsPath: state.settingsPath || normalizedPath(settings.settingsPath), + localRepositoryPath: + state.localRepositoryPath || normalizedPath(settings.localRepositoryPath), + mavenExecutablePath: + state.mavenExecutablePath || normalizedPath(settings.mavenExecutablePath), + javaHomePath: state.javaHomePath || normalizedPath(settings.javaHomePath), + }; + if ( + next.settingsPath === state.settingsPath && + next.localRepositoryPath === state.localRepositoryPath && + next.mavenExecutablePath === state.mavenExecutablePath && + next.javaHomePath === state.javaHomePath + ) { + return; + } + set(next); + persistConfiguration(); + void get().actions.resolveEffectiveConfiguration(); + }, + + // Resolves the values a launch would use for the saved configuration, so + // every surface showing these fields can display what blank ones fall + // back to. A detection failure hides them instead of reporting an error, + // and a workspace without a Maven project clears them without asking the + // host at all. + resolveEffectiveConfiguration: async () => { + const state = get(); + if (!state.root || !state.project) { + if (get().effectiveConfiguration !== null) set({ effectiveConfiguration: null }); + return; + } + const revision = ++effectiveConfigurationRevision; + try { + const resolved = await dependencies.resolveMavenEffectiveConfiguration( + state.root, + state.project.relativePath, + { + settingsPath: state.settingsPath, + localRepositoryPath: state.localRepositoryPath, + mavenExecutablePath: state.mavenExecutablePath, + javaHomePath: state.javaHomePath, + }, + ); + if (effectiveConfigurationRevision !== revision) return; + set({ effectiveConfiguration: resolved }); + } catch { + if (effectiveConfigurationRevision !== revision) return; + set({ effectiveConfiguration: null }); + } }, acknowledgeReload: (revision) => { diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts index 58323eed4..cd5c610a3 100644 --- a/windows/tauri/src/features/maven/types/maven.types.ts +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -156,6 +156,17 @@ export interface MavenSettings { javaHomePath: string; } +/** + * What a Maven launch would use for the current settings. A `null` field means + * the machine-level detection found nothing, not that the field is disabled. + */ +export interface MavenEffectiveConfiguration { + settingsPath: string | null; + localRepositoryPath: string | null; + mavenExecutablePath: string | null; + javaHomePath: string | null; +} + export const MAVEN_LIFECYCLE_PHASES = [ "clean", "validate", diff --git a/windows/tauri/src/features/run/components/run-configuration-editor.tsx b/windows/tauri/src/features/run/components/run-configuration-editor.tsx index c8b018592..09cd75133 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -1,5 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { open } from "@tauri-apps/plugin-dialog"; +import { MavenDetectedValue } from "@/features/maven/components/maven-detected-value"; +import { useMavenStore } from "@/features/maven/stores/maven.store"; import { Button } from "@/ui/button"; import Dialog from "@/ui/dialog"; import { Field, FieldDescription, FieldLabel } from "@/ui/field"; @@ -51,6 +53,11 @@ interface ToolchainFieldProps { candidates: Array<{ value: string; label: string }>; onSelect: (value: string) => void; onPick: () => void; + /** + * Extra content under the field, used by the Maven paths to show what a blank + * field resolves to. Left undefined by fields without a detected value. + */ + footer?: ReactNode; } function ToolchainField({ @@ -63,6 +70,7 @@ function ToolchainField({ candidates, onSelect, onPick, + footer, }: ToolchainFieldProps) { const options = [{ value: "", label: autoLabel }, ...candidates]; const hasCustomValue = Boolean(value) && !options.some((option) => option.value === value); @@ -90,6 +98,7 @@ function ToolchainField({ {hint} + {footer} ); } @@ -106,12 +115,45 @@ export function RunConfigurationEditor({ onSave, }: RunConfigurationEditorProps) { const { t } = useTranslation(); - const [draft, setDraft] = useState(() => configurationOverrides(options, globalToolchain)); - const [toolchainDraft, setToolchainDraft] = useState(globalToolchain); + // The Maven settings page owns the project-wide Maven paths, so this editor + // shows and edits the same values instead of a private copy. + const mavenSettingsPath = useMavenStore((state) => state.settingsPath); + const mavenLocalRepositoryPath = useMavenStore((state) => state.localRepositoryPath); + const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); + const mavenJavaHomePath = useMavenStore((state) => state.javaHomePath); + const mavenEffectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const updateMavenConfiguration = useMavenStore((state) => state.actions.updateLocalConfiguration); + const [draft, setDraft] = useState(() => ({ + ...configurationOverrides(options, globalToolchain), + mavenExecutablePath, + mavenJavaHomePath, + })); + const [toolchainDraft, setToolchainDraft] = useState(() => ({ + ...globalToolchain, + mavenExecutablePath, + mavenJavaHomePath, + })); const [scope, setScope] = useState("local"); const [envText, setEnvText] = useState(environmentText(options.environment)); const [saving, setSaving] = useState(false); + // Both sections edit the same shared Maven paths, so one edit updates the + // other section and vice versa. + const setSharedMavenPaths = (patch: { + mavenExecutablePath?: string; + mavenJavaHomePath?: string; + }) => { + setToolchainDraft((current) => ({ ...current, ...patch })); + setDraft((current) => ({ ...current, ...patch })); + }; + + // The drafts above snapshot the store when this editor mounts; mirror later + // changes so values saved from the settings page (or loaded afterwards) show. + useEffect(() => { + setSharedMavenPaths({ mavenExecutablePath, mavenJavaHomePath }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- setSharedMavenPaths updates both drafts and is recreated each render + }, [mavenExecutablePath, mavenJavaHomePath]); + const projectUsesMaven = configurationUsesMaven(configuration); const projectUsesJava = configurationUsesJava(configuration); const projectUsesNode = configurationUsesNode(configuration); @@ -134,10 +176,27 @@ export function RunConfigurationEditor({ : runtime.executablePath, })); + // The Maven paths show what a blank field resolves to, exactly as the settings + // page does; other toolchain fields have no detected counterpart here. + const detectedMavenValue = ( + field: "mavenExecutablePath" | "mavenJavaHomePath", + value: string, + ) => { + const effectiveField = + field === "mavenExecutablePath" ? "mavenExecutablePath" : "javaHomePath"; + return ( + + ); + }; + const pickDirectory = (field: "javaHomePath" | "mavenJavaHomePath" | "workingDirectoryPath") => { void open({ directory: true, multiple: false }).then((selected) => { if (typeof selected === "string" && selected) { - setDraft((current) => ({ ...current, [field]: selected })); + if (field === "mavenJavaHomePath") { + setSharedMavenPaths({ mavenJavaHomePath: selected }); + } else { + setDraft((current) => ({ ...current, [field]: selected })); + } } }); }; @@ -145,19 +204,19 @@ export function RunConfigurationEditor({ const pickToolchainDirectory = (field: "javaHomePath" | "mavenJavaHomePath") => { void open({ directory: true, multiple: false }).then((selected) => { if (typeof selected === "string" && selected) { - setToolchainDraft((current) => ({ ...current, [field]: selected })); + if (field === "mavenJavaHomePath") { + setSharedMavenPaths({ mavenJavaHomePath: selected }); + } else { + setToolchainDraft((current) => ({ ...current, [field]: selected })); + } } }); }; - const pickMavenHome = (target: "configuration" | "project") => { + const pickMavenHome = () => { void open({ directory: true, multiple: false }).then((selected) => { if (typeof selected === "string" && selected) { - if (target === "project") { - setToolchainDraft((current) => ({ ...current, mavenExecutablePath: selected })); - } else { - setDraft((current) => ({ ...current, mavenExecutablePath: selected })); - } + setSharedMavenPaths({ mavenExecutablePath: selected }); } }); }; @@ -181,7 +240,17 @@ export function RunConfigurationEditor({ const runOptions = { ...draft, environment: environmentFromText(envText) }; try { const saved = await onSave(runOptions, toolchainDraft, scope); - if (saved) onClose(); + if (saved) { + // These fields are the shared Maven settings, so a saved override is + // written back for every surface that reads them. + updateMavenConfiguration({ + settingsPath: mavenSettingsPath, + localRepositoryPath: mavenLocalRepositoryPath, + mavenExecutablePath: toolchainDraft.mavenExecutablePath, + javaHomePath: toolchainDraft.mavenJavaHomePath, + }); + onClose(); + } } finally { setSaving(false); } @@ -235,8 +304,12 @@ export function RunConfigurationEditor({ autoLabel={t("run.toolchainAuto")} customLabel={t("run.toolchainCurrent")} candidates={mavenCandidates} - onSelect={(value) => setToolchainDraft((current) => ({ ...current, mavenExecutablePath: value }))} - onPick={() => pickMavenHome("project")} + onSelect={(value) => setSharedMavenPaths({ mavenExecutablePath: value })} + onPick={() => pickMavenHome()} + footer={detectedMavenValue( + "mavenExecutablePath", + toolchainDraft.mavenExecutablePath, + )} /> setToolchainDraft((current) => ({ ...current, mavenJavaHomePath: value }))} + onSelect={(value) => setSharedMavenPaths({ mavenJavaHomePath: value })} onPick={() => pickToolchainDirectory("mavenJavaHomePath")} + footer={detectedMavenValue( + "mavenJavaHomePath", + toolchainDraft.mavenJavaHomePath, + )} /> ) : null} @@ -339,8 +416,9 @@ export function RunConfigurationEditor({ autoLabel={t("run.toolchainProjectDefault")} customLabel={t("run.toolchainCurrent")} candidates={mavenCandidates} - onSelect={(value) => setDraft((current) => ({ ...current, mavenExecutablePath: value }))} - onPick={() => pickMavenHome("configuration")} + onSelect={(value) => setSharedMavenPaths({ mavenExecutablePath: value })} + onPick={() => pickMavenHome()} + footer={detectedMavenValue("mavenExecutablePath", draft.mavenExecutablePath)} /> setDraft((current) => ({ ...current, mavenJavaHomePath: value }))} + onSelect={(value) => setSharedMavenPaths({ mavenJavaHomePath: value })} onPick={() => pickDirectory("mavenJavaHomePath")} + footer={detectedMavenValue("mavenJavaHomePath", draft.mavenJavaHomePath)} /> {t("run.mavenTests")} diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts index 977aff1b0..6881ae489 100644 --- a/windows/tauri/src/features/run/stores/run-maven-context.test.ts +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -84,6 +84,7 @@ describe("Maven-backed Run context", () => { saveWorkspaceBeforeLaunch, startRunProcess, stopRunProcess, + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: mock(async () => null), }; const store = createRunStore("workspace", dependencies); @@ -145,6 +146,7 @@ describe("Maven-backed Run context", () => { executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })), startRunProcess, stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: mock(async () => null), }; const store = createRunStore("workspace", dependencies); diff --git a/windows/tauri/src/features/run/stores/run-prelaunch.test.ts b/windows/tauri/src/features/run/stores/run-prelaunch.test.ts index cd15fcc05..acbff237a 100644 --- a/windows/tauri/src/features/run/stores/run-prelaunch.test.ts +++ b/windows/tauri/src/features/run/stores/run-prelaunch.test.ts @@ -60,6 +60,7 @@ function standaloneDependencies(overrides: Partial = {}): saveWorkspaceBeforeLaunch: mock(async () => undefined), startRunProcess, stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: mock(async () => null), ...overrides, }; diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index ba95a81e4..4ec8e05bd 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -2,7 +2,11 @@ import { createStore } from "zustand/vanilla"; import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; -import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; +import { + mavenLaunchContextForWorkspace, + useMavenStore, +} from "@/features/maven/stores/maven.store"; +import type { MavenSettings } from "@/features/maven/types/maven.types"; import { createLaunchPlan, generateRunConfiguration, @@ -121,17 +125,29 @@ export interface RunStoreDependencies { resolveRunLaunch: typeof resolveRunLaunch; executePreLaunchStep: typeof executePreLaunchStep; saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; + seedMavenLocalConfiguration: (workspaceId: string, settings: Partial) => void; startRunProcess: typeof startRunProcess; stopRunProcess: typeof stopRunProcess; prepareJavaRunLaunch: typeof prepareJavaRunLaunch; } +// The Maven settings own the project-wide Maven paths. The run feature's legacy +// per-project toolchain values migrate into them so both surfaces report and use +// the same configuration. +function seedMavenLocalConfiguration( + workspaceId: string, + settings: Partial, +): void { + useMavenStore.getStore(workspaceId).getState().actions.seedLocalConfiguration(settings); +} + const defaultRunStoreDependencies: RunStoreDependencies = { createLaunchPlan, mavenLaunchContextForWorkspace, resolveRunLaunch, executePreLaunchStep, saveWorkspaceBeforeLaunch, + seedMavenLocalConfiguration, startRunProcess, stopRunProcess, prepareJavaRunLaunch, @@ -384,6 +400,12 @@ export const createRunStore = ( return; } set(readyRunState(snapshot, get().selectedConfigurationId)); + // Migrate any legacy per-project Maven paths into the shared Maven + // settings so the settings page shows the values already in effect. + dependencies.seedMavenLocalConfiguration(workspaceId, { + mavenExecutablePath: snapshot.globalToolchain.mavenExecutablePath, + javaHomePath: snapshot.globalToolchain.mavenJavaHomePath, + }); } catch (error) { const message = error instanceof Error ? error.message : "Project run configuration is invalid"; @@ -507,6 +529,15 @@ export const createRunStore = ( } await save; if (!isCurrent()) return null; + // The Maven settings own the project-wide Maven paths. Seed them from + // the run project before reading the shared context, so the launch + // resolves the values this feature already had in effect. + if (configurationUsesMaven(configuration)) { + dependencies.seedMavenLocalConfiguration(workspaceId, { + mavenExecutablePath: state.globalToolchain.mavenExecutablePath, + javaHomePath: state.globalToolchain.mavenJavaHomePath, + }); + } const mavenContext = configurationUsesMaven(configuration) ? await dependencies.mavenLaunchContextForWorkspace(root, [], workspaceId) : null; @@ -530,10 +561,12 @@ export const createRunStore = ( executable: plan.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, + // The shared Maven settings win; the per-configuration copy is only a + // fallback for configurations saved before the paths were unified. mavenExecutablePath: - configuration.mavenExecutablePath || mavenContext?.mavenExecutablePath || "", + mavenContext?.mavenExecutablePath || configuration.mavenExecutablePath || "", mavenJavaHomePath: - configuration.mavenJavaHomePath || mavenContext?.javaHomePath || "", + mavenContext?.javaHomePath || configuration.mavenJavaHomePath || "", runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); @@ -614,10 +647,12 @@ export const createRunStore = ( executable: step.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, + // The shared Maven settings win; the per-configuration copy is only + // a fallback for configurations saved before the paths were unified. mavenExecutablePath: - configuration.mavenExecutablePath || mavenContext?.mavenExecutablePath || "", + mavenContext?.mavenExecutablePath || configuration.mavenExecutablePath || "", mavenJavaHomePath: - configuration.mavenJavaHomePath || mavenContext?.javaHomePath || "", + mavenContext?.javaHomePath || configuration.mavenJavaHomePath || "", runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); diff --git a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx index 52db503c0..0dd1c266e 100644 --- a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx +++ b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx @@ -16,6 +16,7 @@ import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Switch from "@/ui/switch"; import { LogSettingsPanel } from "./log-settings-panel"; +import { MavenSettingsPanel } from "./tabs/maven-settings-panel"; import { GitSettings } from "./tabs/git-settings"; export type MacSettingsCategory = @@ -25,6 +26,7 @@ export type MacSettingsCategory = | "keyboard" | "terminal" | "lsp" + | "maven" | "ai" | "logs" | "updates"; @@ -596,6 +598,8 @@ export function MacSettingsPanel({ return ; case "lsp": return ; + case "maven": + return ; case "ai": return ; case "logs": diff --git a/windows/tauri/src/features/settings/components/settings-dialog.tsx b/windows/tauri/src/features/settings/components/settings-dialog.tsx index 15d25fa1e..ff29a541b 100644 --- a/windows/tauri/src/features/settings/components/settings-dialog.tsx +++ b/windows/tauri/src/features/settings/components/settings-dialog.tsx @@ -14,6 +14,7 @@ import { KeyboardIcon, MagicWandIcon, FileTextIcon, + PackageIcon, TerminalWindowIcon, type Icon, } from "@/ui/icons"; @@ -37,6 +38,7 @@ const categories: CategoryItem[] = [ { id: "keyboard", labelKey: "settings.tabs.keyboard", icon: KeyboardIcon }, { id: "terminal", labelKey: "settings.tabs.terminal", icon: TerminalWindowIcon }, { id: "lsp", labelKey: "settings.tabs.lsp", icon: DatabaseIcon }, + { id: "maven", labelKey: "settings.tabs.maven", icon: PackageIcon }, { id: "ai", labelKey: "settings.tabs.aiCommit", icon: MagicWandIcon }, { id: "git", labelKey: "settings.tabs.git", icon: CodeBlockIcon }, { id: "logs", labelKey: "settings.tabs.logs", icon: FileTextIcon }, @@ -50,6 +52,7 @@ function categoryFromRequestedTab(tab: SettingsTab | null): MacSettingsCategory case "editor": case "keyboard": case "terminal": + case "maven": case "ai": case "logs": return tab; diff --git a/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx new file mode 100644 index 000000000..2bb024889 --- /dev/null +++ b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx @@ -0,0 +1,154 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { useEffect, useState, type ReactNode } from "react"; +import { MavenDetectedValue } from "@/features/maven/components/maven-detected-value"; +import type { MavenSettings } from "@/features/maven/types/maven.types"; +import { useMavenStore } from "@/features/maven/stores/maven.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { FolderIcon, TrashIcon } from "@/ui/icons"; + +const controlClassName = + "h-8 rounded-md border border-input bg-background px-2.5 text-foreground outline-none focus:border-primary"; + +// The settings dialog renders each category from a self-contained panel; this +// group mirrors the container the sibling panels define. +function SettingsGroup({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +interface MavenField { + field: keyof MavenSettings; + label: string; + /** Directories are picked as folders; settings.xml is picked as a file. */ + directory: boolean; +} + +/** + * The Maven page of the application settings. It edits the same project-scoped + * configuration as the Maven tool window, so both surfaces always agree, and it + * shows what blank fields fall back to on this machine. + */ +export function MavenSettingsPanel() { + const { t } = useTranslation(); + const project = useMavenStore((state) => state.project); + const projectStatus = useMavenStore((state) => state.projectStatus); + const settingsPath = useMavenStore((state) => state.settingsPath); + const localRepositoryPath = useMavenStore((state) => state.localRepositoryPath); + const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); + const javaHomePath = useMavenStore((state) => state.javaHomePath); + const configurationSaveError = useMavenStore((state) => state.configurationSaveError); + const effectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const updateLocalConfiguration = useMavenStore((state) => state.actions.updateLocalConfiguration); + + const saved: MavenSettings = { + settingsPath, + localRepositoryPath, + mavenExecutablePath, + javaHomePath, + }; + const [draft, setDraft] = useState(saved); + + // The project scan finishes after this page can already be open, and the Maven + // tool window edits the same values, so keep the draft on the saved state. + useEffect(() => { + setDraft({ settingsPath, localRepositoryPath, mavenExecutablePath, javaHomePath }); + }, [settingsPath, localRepositoryPath, mavenExecutablePath, javaHomePath]); + + const dirty = (Object.keys(saved) as Array).some( + (field) => draft[field] !== saved[field], + ); + + const fields: MavenField[] = [ + { field: "settingsPath", label: "settings.xml", directory: false }, + { field: "localRepositoryPath", label: t("maven.localRepository"), directory: true }, + { field: "mavenExecutablePath", label: t("maven.mavenExecutable"), directory: true }, + { field: "javaHomePath", label: t("maven.javaHome"), directory: true }, + ]; + + const browse = async (field: keyof MavenSettings, directory: boolean) => { + const selected = await open({ + directory, + multiple: false, + ...(directory ? {} : { filters: [{ name: "Maven settings", extensions: ["xml"] }] }), + }); + if (typeof selected === "string") setDraft((current) => ({ ...current, [field]: selected })); + }; + + return ( + +

+ {t("maven.settingsDescription")} +

+ {project ? null : ( +

+ {projectStatus === "loading" ? t("maven.scanning") : t("maven.notDetected")} +

+ )} + {fields.map(({ field, label, directory }) => ( + + ))} + {configurationSaveError ? ( +

+ {configurationSaveError} +

+ ) : null} +
+ +
+
+ ); +} diff --git a/windows/tauri/src/features/settings/config/search-index.ts b/windows/tauri/src/features/settings/config/search-index.ts index 3d22adac3..2537fff9d 100644 --- a/windows/tauri/src/features/settings/config/search-index.ts +++ b/windows/tauri/src/features/settings/config/search-index.ts @@ -620,6 +620,40 @@ export const settingsSearchIndex: SettingSearchRecord[] = [ keywords: ["ai", "agent", "chat", "clear", "delete", "history"], }, + // Maven Settings + { + id: "maven-settings-xml", + tab: "maven", + section: "Maven", + label: "settings.xml", + description: "Custom Maven settings.xml used for builds and language tooling", + keywords: ["maven", "settings", "xml", "repository", "mirror", "build"], + }, + { + id: "maven-local-repository", + tab: "maven", + section: "Maven", + label: "Maven Local Repository", + description: "Local Maven repository directory used to cache build artifacts", + keywords: ["maven", "local", "repository", "cache", "artifacts", "repo", "build"], + }, + { + id: "maven-executable", + tab: "maven", + section: "Maven", + label: "Maven Home", + description: "Maven installation directory or executable used to launch builds", + keywords: ["maven", "mvn", "home", "executable", "installation", "build"], + }, + { + id: "maven-jdk-home", + tab: "maven", + section: "Maven", + label: "Maven JDK Home", + description: "JDK used to run Maven builds", + keywords: ["maven", "jdk", "java", "home", "runtime", "build"], + }, + // Keyboard Settings { id: "keyboard-quick-file-open", diff --git a/windows/tauri/src/features/settings/lib/settings-search.ts b/windows/tauri/src/features/settings/lib/settings-search.ts index c2abe24cd..9cba87abd 100644 --- a/windows/tauri/src/features/settings/lib/settings-search.ts +++ b/windows/tauri/src/features/settings/lib/settings-search.ts @@ -11,6 +11,7 @@ export const SETTINGS_SEARCH_TAB_LABELS: Record = { "file-explorer": "Files", git: "Git", terminal: "Terminal", + maven: "Maven", keyboard: "Keybindings", ai: "Agent", advanced: "Advanced", diff --git a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts index ac416ce85..b1d7b560f 100644 --- a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts +++ b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts @@ -9,6 +9,7 @@ export type SettingsTab = | "logs" | "advanced" | "terminal" + | "maven" | "file-explorer"; export type BottomPaneTab = diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 237279d0e..6e36cf699 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1600,6 +1600,9 @@ const catalogs = { "maven.omittedDuplicate": "duplicate omitted", "maven.omittedConflict": "conflict with", "maven.settings": "Maven Settings", + "maven.settingsDescription": "Leave a field empty to let Lithe detect it automatically.", + "maven.detectedValue": "Detected: {value}", + "maven.detectedMissing": "Not detected on this machine", "maven.automatic": "Automatic", "maven.localRepository": "Local repository", "maven.mavenExecutable": "Maven home or executable", @@ -1753,11 +1756,13 @@ const catalogs = { "settings.tabs.keyboard": "Keybindings", "settings.tabs.advanced": "Advanced", "settings.tabs.lsp": "LSP", + "settings.tabs.maven": "Maven", "settings.tabs.aiCommit": "AI & Commit", "settings.tabs.logs": "Logs", "settings.tabs.updates": "Updates", "settings.mac.categories": "Settings categories", "settings.mac.restoreDefaults": "Restore Defaults", + "settings.mac.restoreDefaultsConfirm": "⚠️Reset all settings to defaults?", "settings.mac.done": "Done", "settings.mac.appearance": "Appearance", "settings.mac.colorTheme": "Color theme", @@ -5952,6 +5957,9 @@ const catalogs = { "maven.omittedDuplicate": "已省略重复依赖", "maven.omittedConflict": "版本冲突,采用", "maven.settings": "Maven 设置", + "maven.settingsDescription": "留空表示由 Lithe 自动检测。", + "maven.detectedValue": "检测到:{value}", + "maven.detectedMissing": "本机未检测到", "maven.automatic": "自动检测", "maven.localRepository": "本地仓库", "maven.mavenExecutable": "Maven 主目录 / 可执行文件", @@ -6098,6 +6106,7 @@ const catalogs = { "settings.tabs.keyboard": "快捷键", "settings.tabs.advanced": "高级", "settings.tabs.lsp": "LSP", + "settings.tabs.maven": "Maven", "settings.tabs.aiCommit": "AI 与提交", "settings.tabs.logs": "日志", "settings.tabs.updates": "更新", From 671a4f86ffb264d34e76a8053f897f16b4b90e50 Mon Sep 17 00:00:00 2001 From: Jay Date: Wed, 23 Sep 2026 23:34:12 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(windows):=20=E5=9C=A8=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E6=96=B0=E5=A2=9E=20Maven=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=B8=89=E5=A4=84?= =?UTF-8?q?=20Maven=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- windows/tauri/src-tauri/src/maven.rs | 179 ++++++++++++++++-- .../maven/components/maven-detected-value.tsx | 83 +++++++- .../features/maven/components/maven-pane.tsx | 4 + .../features/maven/stores/maven.store.test.ts | 68 ++++++- .../src/features/maven/stores/maven.store.ts | 149 +++++++++++---- .../src/features/maven/types/maven.types.ts | 11 +- .../components/run-configuration-editor.tsx | 32 +++- .../run/stores/run-maven-context.test.ts | 50 +++++ .../src/features/run/stores/run.store.ts | 35 ++-- .../components/tabs/maven-settings-panel.tsx | 31 ++- windows/tauri/src/i18n/locale.ts | 2 + 11 files changed, 558 insertions(+), 86 deletions(-) diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs index 1de87fb0a..618cb5b72 100644 --- a/windows/tauri/src-tauri/src/maven.rs +++ b/windows/tauri/src-tauri/src/maven.rs @@ -102,8 +102,9 @@ pub struct ResolveEffectiveConfigurationArgs { pub java_home_path: String, } -/// The values a Maven launch would actually use. `None` means the machine-level -/// detection found nothing for that field. +/// The values a Maven launch would actually use, alongside the values detection +/// found when every saved override is ignored. `None` means detection found +/// nothing for that field. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct MavenEffectiveConfiguration { @@ -111,6 +112,10 @@ pub struct MavenEffectiveConfiguration { pub local_repository_path: Option, pub maven_executable_path: Option, pub java_home_path: Option, + pub detected_settings_path: Option, + pub detected_local_repository_path: Option, + pub detected_maven_executable_path: Option, + pub detected_java_home_path: Option, } /// Resolves the effective Maven configuration for the saved settings, so the @@ -127,28 +132,82 @@ pub fn maven_resolve_effective_configuration( "" => root.clone(), reactor => root.join(reactor), }; - let maven_executable_path = - resolve_maven_executable(&root, &working_directory, &args.maven_executable_path).ok(); - let java_home_path = resolve_java_home(&root, &args.java_home_path) - .ok() - .flatten(); - let home = user_home_directory(); - let settings_path = effective_settings_path( + // Detection is resolved once with empty overrides. A saved path is resolved + // only when the user set one, so the automatic case does not probe twice. + let detected_maven = resolve_maven_executable(&root, &working_directory, "").ok(); + let detected_java = resolve_java_home(&root, "").ok().flatten(); + let selected_maven = if args.maven_executable_path.trim().is_empty() { + detected_maven.clone() + } else { + resolve_maven_executable(&root, &working_directory, &args.maven_executable_path).ok() + }; + let selected_java = if args.java_home_path.trim().is_empty() { + detected_java.clone() + } else { + resolve_java_home(&root, &args.java_home_path).ok().flatten() + }; + Ok(assemble_effective_configuration( &args.settings_path, - home.as_deref(), + &args.local_repository_path, + &args.maven_executable_path, + &args.java_home_path, + detected_maven, + detected_java, + selected_maven, + selected_java, + user_home_directory().as_deref(), + )) +} + +/// Combines saved overrides with already resolved executables. +/// +/// `detected_*` arguments are what the machine finds with every override blank. +/// `selected_*` arguments are the executables a launch would use, which equal +/// the detected ones when the corresponding override is blank. +fn assemble_effective_configuration( + configured_settings: &str, + configured_repository: &str, + configured_maven: &str, + configured_java: &str, + detected_maven: Option, + detected_java: Option, + selected_maven: Option, + selected_java: Option, + home: Option<&Path>, +) -> MavenEffectiveConfiguration { + let maven_executable_path = if configured_maven.trim().is_empty() { + detected_maven.clone() + } else { + selected_maven + }; + let java_home_path = if configured_java.trim().is_empty() { + detected_java.clone() + } else { + selected_java + }; + let detected_settings_path = effective_settings_path("", home, detected_maven.as_deref()); + let settings_path = effective_settings_path( + configured_settings, + home, maven_executable_path.as_deref(), ); + let detected_local_repository_path = + effective_local_repository_path("", detected_settings_path.as_deref(), home); let local_repository_path = effective_local_repository_path( - &args.local_repository_path, + configured_repository, settings_path.as_deref(), - home.as_deref(), + home, ); - Ok(MavenEffectiveConfiguration { + MavenEffectiveConfiguration { settings_path, local_repository_path, maven_executable_path, java_home_path, - }) + detected_settings_path, + detected_local_repository_path, + detected_maven_executable_path: detected_maven, + detected_java_home_path: detected_java, + } } /// The current user's home directory, which owns the Maven user-level defaults @@ -442,6 +501,98 @@ mod tests { fs::remove_dir_all(installation).ok(); } + #[test] + fn detected_paths_stay_visible_when_overrides_are_configured() { + let home = temp_directory(); + fs::create_dir_all(home.join(".m2")).expect("user m2"); + let user_settings = home.join(".m2").join("settings.xml"); + fs::write( + &user_settings, + "C:\\from-settings", + ) + .expect("write user settings"); + + let resolved = assemble_effective_configuration( + "D:\\custom-settings.xml", + "D:\\custom-repo", + "D:\\custom-maven", + "D:\\custom-jdk", + Some("D:\\detected\\mvn.cmd".into()), + Some("D:\\detected-jdk".into()), + Some("D:\\custom-maven\\bin\\mvn.cmd".into()), + Some("D:\\custom-jdk".into()), + Some(&home), + ); + + assert_eq!( + resolved.maven_executable_path.as_deref(), + Some("D:\\custom-maven\\bin\\mvn.cmd") + ); + assert_eq!( + resolved.detected_maven_executable_path.as_deref(), + Some("D:\\detected\\mvn.cmd") + ); + assert_eq!(resolved.java_home_path.as_deref(), Some("D:\\custom-jdk")); + assert_eq!( + resolved.detected_java_home_path.as_deref(), + Some("D:\\detected-jdk") + ); + assert_eq!( + resolved.settings_path.as_deref(), + Some("D:\\custom-settings.xml") + ); + assert_eq!( + resolved.detected_settings_path.as_deref(), + Some(user_settings.to_string_lossy().as_ref()) + ); + assert_eq!( + resolved.local_repository_path.as_deref(), + Some("D:\\custom-repo") + ); + assert_eq!( + resolved.detected_local_repository_path.as_deref(), + Some("C:\\from-settings") + ); + fs::remove_dir_all(home).ok(); + } + + #[test] + fn blank_configuration_uses_the_detected_values() { + let home = temp_directory(); + let resolved = assemble_effective_configuration( + "", + "", + "", + "", + Some("D:\\detected\\mvn.cmd".into()), + None, + Some("D:\\detected\\mvn.cmd".into()), + None, + Some(&home), + ); + let repository = home.join(".m2").join("repository"); + + assert_eq!( + resolved.maven_executable_path.as_deref(), + Some("D:\\detected\\mvn.cmd") + ); + assert_eq!( + resolved.detected_maven_executable_path.as_deref(), + Some("D:\\detected\\mvn.cmd") + ); + assert_eq!(resolved.java_home_path, None); + assert_eq!(resolved.detected_java_home_path, None); + assert_eq!( + resolved.local_repository_path.as_deref(), + Some(repository.to_string_lossy().as_ref()) + ); + assert_eq!( + resolved.detected_local_repository_path, + resolved.local_repository_path + ); + fs::remove_dir_all(home).ok(); + } + #[test] fn portable_configuration_round_trips_without_local_paths() { let root = temp_directory(); diff --git a/windows/tauri/src/features/maven/components/maven-detected-value.tsx b/windows/tauri/src/features/maven/components/maven-detected-value.tsx index dc89556be..96ce8c1d7 100644 --- a/windows/tauri/src/features/maven/components/maven-detected-value.tsx +++ b/windows/tauri/src/features/maven/components/maven-detected-value.tsx @@ -1,31 +1,98 @@ +import { useRunStore } from "@/features/run/stores/run.store"; import { useTranslation } from "@/i18n/locale-provider"; -import type { MavenEffectiveConfiguration, MavenSettings } from "../types/maven.types"; +import type { + MavenEffectiveConfiguration, + MavenEffectiveConfigurationStatus, + MavenSettings, +} from "../types/maven.types"; + +const DETECTED_FIELD = { + settingsPath: "detectedSettingsPath", + localRepositoryPath: "detectedLocalRepositoryPath", + mavenExecutablePath: "detectedMavenExecutablePath", + javaHomePath: "detectedJavaHomePath", +} as const satisfies Record; + +function canonicalPath(value: string): string { + return value.trim().replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase(); +} + +function versionForPath(path: string, candidates: Array<{ path: string; version: string }>): string { + const target = canonicalPath(path); + return ( + candidates.find((candidate) => candidate.path && canonicalPath(candidate.path) === target) + ?.version.trim() ?? "" + ); +} /** - * The gray line shown under a blank Maven path field: what the launch would use - * instead, or that nothing was detected. Renders nothing once the field holds a - * custom value (the input is then the effective value) and nothing before the - * detection has resolved. + * The line under a Maven path field showing what automatic detection found on + * this machine. A custom value does not hide it: the field is the saved + * override, and this line is the detection result. */ export function MavenDetectedValue({ field, value, effective, + status = "idle", }: { field: keyof MavenSettings; value: string; effective: MavenEffectiveConfiguration | null; + status?: MavenEffectiveConfigurationStatus; }) { const { t } = useTranslation(); - if (value || !effective) return null; - const detected = effective[field]; + const discoveredMaven = useRunStore((state) => state.discoveredMaven); + const discoveredJava = useRunStore((state) => state.discoveredJava); + if (!effective) { + if (status === "loading") { + return ( +

+ {t("maven.detecting")} +

+ ); + } + if (status === "failed") { + return ( +

+ {t("maven.detectedMissing")} +

+ ); + } + return null; + } + + const detectedField = DETECTED_FIELD[field]; + const reported = effective[detectedField]; + const detected = reported !== undefined ? reported : value ? null : effective[field]; + const version = + !detected + ? "" + : field === "mavenExecutablePath" + ? versionForPath( + detected, + discoveredMaven.map((runtime) => ({ + path: runtime.executablePath, + version: runtime.version, + })), + ) + : field === "javaHomePath" + ? versionForPath( + detected, + discoveredJava.map((runtime) => ({ + path: runtime.homePath, + version: runtime.version, + })), + ) + : ""; + const label = detected && version ? `${detected} (${version})` : detected; return (

- {detected ? t("maven.detectedValue", { value: detected }) : t("maven.detectedMissing")} + {label ? t("maven.detectedValue", { value: label }) : t("maven.detectedMissing")}

); } diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index ed05fa6a8..966fe6129 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -150,6 +150,9 @@ function MavenSettingsDialog({ }) { const { t } = useTranslation(); const effectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const effectiveConfigurationStatus = useMavenStore( + (state) => state.effectiveConfigurationStatus, + ); const [draft, setDraft] = useState(initial); const choosePath = async (field: keyof MavenSettings, directory: boolean) => { @@ -257,6 +260,7 @@ function MavenSettingsDialog({ field={field} value={draft[field]} effective={effectiveConfiguration} + status={effectiveConfigurationStatus} /> ))} diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index 0caf13656..a870b21e9 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -128,6 +128,10 @@ const effectiveConfiguration = { localRepositoryPath: "C:/Users/example/.m2/repository", mavenExecutablePath: "D:/Tools/apache-maven/bin/mvn.cmd", javaHomePath: "C:/Java/jdk-21", + detectedSettingsPath: "C:/Users/example/.m2/settings.xml", + detectedLocalRepositoryPath: "C:/Users/example/.m2/repository", + detectedMavenExecutablePath: "D:/Tools/apache-maven/bin/mvn.cmd", + detectedJavaHomePath: "C:/Java/jdk-21", }; const resolveMavenEffectiveConfiguration = mock(async () => effectiveConfiguration); const startMavenProcess = mock(async () => undefined); @@ -426,7 +430,42 @@ describe("Maven workspace state", () => { expect(store.getState().mavenExecutablePath).toBe(""); }); - test("seeds only blank local fields without flagging a reload", async () => { + test("imports a legacy toolchain only before Maven settings exist", async () => { + const store = createMavenStore("workspace", dependencies); + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + expect(store.getState().mavenExecutablePath).toBe(""); + + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + expect(store.getState().javaHomePath).toBe("C:/Java/jdk-21"); + expect(store.getState().reloadRequired).toBe(false); + + store.getState().actions.seedLocalConfiguration({ + settingsPath: "C:/Other/settings.xml", + mavenExecutablePath: "D:/Other/maven", + }); + expect(store.getState().settingsPath).toBe(""); + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + }); + + test("imports a legacy toolchain after a project loads with no saved settings", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + expect(store.getState().mavenExecutablePath).toBe(""); + + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + }); + + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + expect(store.getState().reloadRequired).toBe(false); + }); + + test("keeps automatic fields empty after Maven settings are saved", async () => { const store = createMavenStore("workspace", dependencies); await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); store.getState().actions.updateLocalConfiguration({ @@ -444,17 +483,28 @@ describe("Maven workspace state", () => { }); expect(store.getState().settingsPath).toBe("C:/custom/settings.xml"); - expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); - expect(store.getState().javaHomePath).toBe("C:/Java/jdk-21"); - expect(store.getState().reloadRequired).toBe(false); + expect(store.getState().mavenExecutablePath).toBe(""); + expect(store.getState().javaHomePath).toBe(""); + }); - // Existing values win, so a second seed cannot overwrite them. + test("does not import a legacy toolchain over a saved automatic configuration", async () => { + loadMavenConfiguration.mockResolvedValue({ + local: { + version: 1, + settingsPath: null, + localRepositoryPath: null, + mavenExecutablePath: null, + javaHomePath: null, + }, + }); + const store = createMavenStore("workspace", dependencies); store.getState().actions.seedLocalConfiguration({ - settingsPath: "C:/Other/settings.xml", - mavenExecutablePath: "D:/Other/maven", + mavenExecutablePath: "D:/Tools/apache-maven", }); - expect(store.getState().settingsPath).toBe("C:/custom/settings.xml"); - expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(store.getState().mavenExecutablePath).toBe(""); }); test("shares the effective machine configuration with every surface", async () => { diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index b3e833dca..aa0b3efe6 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -32,6 +32,7 @@ import type { MavenLocalConfiguration, MavenPortableConfiguration, MavenProfile, + MavenEffectiveConfigurationStatus, MavenProject, MavenProjectStatus, MavenSettings, @@ -127,6 +128,7 @@ export interface MavenState { * surface that shows these fields. `null` hides the detected values. */ effectiveConfiguration: MavenEffectiveConfiguration | null; + effectiveConfigurationStatus: MavenEffectiveConfigurationStatus; reloadRequired: boolean; projectReloadRequired: boolean; reloadRevision: number; @@ -219,6 +221,42 @@ function normalizedPath(value: string | null | undefined): string { return value?.trim() ?? ""; } +type MavenLocalPaths = Pick< + MavenState, + "settingsPath" | "localRepositoryPath" | "mavenExecutablePath" | "javaHomePath" +>; + +function mavenLocalPaths(settings: Partial): MavenLocalPaths { + return { + settingsPath: normalizedPath(settings.settingsPath), + localRepositoryPath: normalizedPath(settings.localRepositoryPath), + mavenExecutablePath: normalizedPath(settings.mavenExecutablePath), + javaHomePath: normalizedPath(settings.javaHomePath), + }; +} + +function withBlankFieldsFilled( + current: MavenLocalPaths, + incoming: Partial, +): MavenLocalPaths { + const next = mavenLocalPaths(incoming); + return { + settingsPath: current.settingsPath || next.settingsPath, + localRepositoryPath: current.localRepositoryPath || next.localRepositoryPath, + mavenExecutablePath: current.mavenExecutablePath || next.mavenExecutablePath, + javaHomePath: current.javaHomePath || next.javaHomePath, + }; +} + +function sameLocalPaths(left: MavenLocalPaths, right: MavenLocalPaths): boolean { + return ( + left.settingsPath === right.settingsPath && + left.localRepositoryPath === right.localRepositoryPath && + left.mavenExecutablePath === right.mavenExecutablePath && + left.javaHomePath === right.javaHomePath + ); +} + export function availableMavenProfiles(state: Pick) { const profiles = new Map(); for (const profile of state.project?.profiles ?? []) profiles.set(profile.id, profile); @@ -307,6 +345,11 @@ export const createMavenStore = ( let projectLoadRevision = 0; let configurationRevision = 0; let effectiveConfigurationRevision = 0; + // True once this workspace has a Maven local document, including one the user + // saved with blank fields. Blank then means automatic, so a legacy run + // toolchain must not fill those fields back in. + let localConfigurationPersisted = false; + let pendingLocalSeed: MavenLocalPaths | null = null; let launchRevision = 0; let diagnosticsRevision = 0; let dependencyRevision = 0; @@ -317,6 +360,34 @@ export const createMavenStore = ( let pomWatchTask = Promise.resolve(); let watchedPomPaths = new Set(); + const rememberPendingSeed = (settings: Partial) => { + pendingLocalSeed = withBlankFieldsFilled( + pendingLocalSeed ?? { + settingsPath: "", + localRepositoryPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }, + settings, + ); + }; + + // Applies a toolchain seed only while this workspace has never saved Maven + // settings. A saved document, even one whose paths are blank, already chose + // automatic for those fields. + const adoptPendingLocalSeed = (settings: MavenLocalPaths) => { + if (localConfigurationPersisted) { + pendingLocalSeed = null; + return { settings, migrated: false }; + } + if (!pendingLocalSeed) return { settings, migrated: false }; + const next = withBlankFieldsFilled(settings, pendingLocalSeed); + pendingLocalSeed = null; + const migrated = !sameLocalPaths(settings, next); + if (migrated) localConfigurationPersisted = true; + return { settings: next, migrated }; + }; + return createStore()((set, get) => { const pomWatchOperations = dependencies.createMavenPomWatchOperations(workspaceId); const synchronizePomWatches = (desiredPaths: ReadonlySet) => { @@ -509,6 +580,7 @@ export const createMavenStore = ( javaHomePath: "", configurationSaveError: null, effectiveConfiguration: null, + effectiveConfigurationStatus: "idle", reloadRequired: false, projectReloadRequired: false, reloadRevision: 0, @@ -543,6 +615,8 @@ export const createMavenStore = ( releaseMavenSessionWorkspace(previous.activeSessionId); } if (previous.root && previous.root !== root) { + pendingLocalSeed = null; + localConfigurationPersisted = false; await synchronizePomWatches(new Set()); if (projectLoadRevision !== revision) return; } @@ -580,6 +654,7 @@ export const createMavenStore = ( if (!project) { await synchronizePomWatches(new Set()); if (projectLoadRevision !== revision || get().root !== root) return; + localConfigurationPersisted = false; set({ projectStatus: "ready", project: null, @@ -642,6 +717,18 @@ export const createMavenStore = ( const selectedProfiles = normalizedProfiles( stored.portable?.selectedProfiles ?? defaultProfiles, ).filter((profile) => knownProfiles.has(profile)); + if (!preserveLatestInMemoryConfiguration) { + localConfigurationPersisted = stored.local != null; + } + const loadedPaths = { + settingsPath: normalizedPath(stored.local?.settingsPath), + localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), + mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), + javaHomePath: normalizedPath(stored.local?.javaHomePath), + }; + const adopted = preserveLatestInMemoryConfiguration + ? { settings: loadedPaths, migrated: false } + : adoptPendingLocalSeed(loadedPaths); set({ projectStatus: "ready", projectError: null, @@ -649,11 +736,9 @@ export const createMavenStore = ( selectedProfiles, customProfiles, skipTests: stored.portable?.skipTests ?? false, - settingsPath: normalizedPath(stored.local?.settingsPath), - localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), - mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), - javaHomePath: normalizedPath(stored.local?.javaHomePath), + ...adopted.settings, }); + if (adopted.migrated) persistConfiguration(); } catch (error) { if (projectLoadRevision !== revision || get().root !== root) return; const message = @@ -668,6 +753,7 @@ export const createMavenStore = ( })); return; } + localConfigurationPersisted = false; set({ projectStatus: "failed", projectError: message, @@ -776,52 +862,51 @@ export const createMavenStore = ( ) { return; } + localConfigurationPersisted = true; + pendingLocalSeed = null; set(next); configurationDidChange(); void get().actions.resolveEffectiveConfiguration(); }, - // Fills blank local fields from another configuration surface, such as - // the run feature's legacy project toolchain. Existing values win and the - // reload prompt stays off: this migrates an already effective value - // instead of recording a user edit. Workspaces without a Maven project - // never seed, because the shared configuration exists only with one. + // Imports a legacy run-toolchain path into a blank field, once, before + // this workspace has its own Maven settings document. A document that + // exists — including one the user saved with blank paths — already owns + // those fields, so a later toolchain value cannot overwrite automatic. + // The reload prompt stays off: this migrates an already effective value. seedLocalConfiguration: (settings) => { + if (localConfigurationPersisted) return; const state = get(); - if (!state.project) return; - const next = { - settingsPath: state.settingsPath || normalizedPath(settings.settingsPath), - localRepositoryPath: - state.localRepositoryPath || normalizedPath(settings.localRepositoryPath), - mavenExecutablePath: - state.mavenExecutablePath || normalizedPath(settings.mavenExecutablePath), - javaHomePath: state.javaHomePath || normalizedPath(settings.javaHomePath), - }; - if ( - next.settingsPath === state.settingsPath && - next.localRepositoryPath === state.localRepositoryPath && - next.mavenExecutablePath === state.mavenExecutablePath && - next.javaHomePath === state.javaHomePath - ) { + if (!state.project) { + rememberPendingSeed(settings); return; } + const next = withBlankFieldsFilled(state, settings); + if (sameLocalPaths(state, next)) return; + localConfigurationPersisted = true; + pendingLocalSeed = null; set(next); persistConfiguration(); void get().actions.resolveEffectiveConfiguration(); }, - // Resolves the values a launch would use for the saved configuration, so - // every surface showing these fields can display what blank ones fall - // back to. A detection failure hides them instead of reporting an error, - // and a workspace without a Maven project clears them without asking the - // host at all. + // Resolves the launch values and the machine-detected values for the + // saved configuration. A detection failure clears the result so the + // surfaces can say detection did not finish, and a workspace without a + // Maven project clears it without asking the host at all. resolveEffectiveConfiguration: async () => { const state = get(); if (!state.root || !state.project) { - if (get().effectiveConfiguration !== null) set({ effectiveConfiguration: null }); + if ( + get().effectiveConfiguration !== null || + get().effectiveConfigurationStatus !== "idle" + ) { + set({ effectiveConfiguration: null, effectiveConfigurationStatus: "idle" }); + } return; } const revision = ++effectiveConfigurationRevision; + set({ effectiveConfigurationStatus: "loading" }); try { const resolved = await dependencies.resolveMavenEffectiveConfiguration( state.root, @@ -834,10 +919,10 @@ export const createMavenStore = ( }, ); if (effectiveConfigurationRevision !== revision) return; - set({ effectiveConfiguration: resolved }); + set({ effectiveConfiguration: resolved, effectiveConfigurationStatus: "ready" }); } catch { if (effectiveConfigurationRevision !== revision) return; - set({ effectiveConfiguration: null }); + set({ effectiveConfiguration: null, effectiveConfigurationStatus: "failed" }); } }, diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts index cd5c610a3..0e7692cad 100644 --- a/windows/tauri/src/features/maven/types/maven.types.ts +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -156,15 +156,22 @@ export interface MavenSettings { javaHomePath: string; } +export type MavenEffectiveConfigurationStatus = "idle" | "loading" | "ready" | "failed"; + /** - * What a Maven launch would use for the current settings. A `null` field means - * the machine-level detection found nothing, not that the field is disabled. + * What a Maven launch would use for the current settings, plus the values + * machine detection found while ignoring saved overrides. A `null` field means + * detection found nothing, not that the field is disabled. */ export interface MavenEffectiveConfiguration { settingsPath: string | null; localRepositoryPath: string | null; mavenExecutablePath: string | null; javaHomePath: string | null; + detectedSettingsPath: string | null; + detectedLocalRepositoryPath: string | null; + detectedMavenExecutablePath: string | null; + detectedJavaHomePath: string | null; } export const MAVEN_LIFECYCLE_PHASES = [ diff --git a/windows/tauri/src/features/run/components/run-configuration-editor.tsx b/windows/tauri/src/features/run/components/run-configuration-editor.tsx index 09cd75133..c77720187 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -122,6 +122,8 @@ export function RunConfigurationEditor({ const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); const mavenJavaHomePath = useMavenStore((state) => state.javaHomePath); const mavenEffectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const mavenEffectiveStatus = useMavenStore((state) => state.effectiveConfigurationStatus); + const mavenProject = useMavenStore((state) => state.project); const updateMavenConfiguration = useMavenStore((state) => state.actions.updateLocalConfiguration); const [draft, setDraft] = useState(() => ({ ...configurationOverrides(options, globalToolchain), @@ -185,7 +187,12 @@ export function RunConfigurationEditor({ const effectiveField = field === "mavenExecutablePath" ? "mavenExecutablePath" : "javaHomePath"; return ( - + ); }; @@ -237,20 +244,31 @@ export function RunConfigurationEditor({ const save = async () => { setSaving(true); - const runOptions = { ...draft, environment: environmentFromText(envText) }; + // A Maven project keeps these paths in the Maven settings document. The run + // documents must not keep a second copy, or a blank automatic field gets + // filled back in from the toolchain the next time the project opens. + const mavenPathsOwnedBySettings = Boolean(mavenProject); + const runOptions = { + ...draft, + environment: environmentFromText(envText), + ...(mavenPathsOwnedBySettings + ? { mavenExecutablePath: "", mavenJavaHomePath: "" } + : {}), + }; + const toolchainToSave = mavenPathsOwnedBySettings + ? { ...toolchainDraft, mavenExecutablePath: "", mavenJavaHomePath: "" } + : toolchainDraft; try { - const saved = await onSave(runOptions, toolchainDraft, scope); - if (saved) { - // These fields are the shared Maven settings, so a saved override is - // written back for every surface that reads them. + if (mavenPathsOwnedBySettings) { updateMavenConfiguration({ settingsPath: mavenSettingsPath, localRepositoryPath: mavenLocalRepositoryPath, mavenExecutablePath: toolchainDraft.mavenExecutablePath, javaHomePath: toolchainDraft.mavenJavaHomePath, }); - onClose(); } + const saved = await onSave(runOptions, toolchainToSave, scope); + if (saved) onClose(); } finally { setSaving(false); } diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts index 6881ae489..c2afac10c 100644 --- a/windows/tauri/src/features/run/stores/run-maven-context.test.ts +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -170,4 +170,54 @@ describe("Maven-backed Run context", () => { }), ]); }); + + test("uses a blank Maven setting instead of a legacy run toolchain path", async () => { + const automaticContext: MavenLaunchContext = { + ...mavenContext, + mavenExecutablePath: null, + javaHomePath: null, + }; + const resolveRunLaunch = mock(async () => ({ + executable: "D:/Tools/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })); + const dependencies: RunStoreDependencies = { + createLaunchPlan: mock(async () => ({ + executable: { toolchain: "project-maven" as const }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + })), + mavenLaunchContextForWorkspace: mock(async () => automaticContext), + resolveRunLaunch, + saveWorkspaceBeforeLaunch: mock(async () => undefined), + executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })), + startRunProcess: mock(async () => undefined), + stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, + prepareJavaRunLaunch: mock(async () => null), + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [ + { + ...configuration, + mavenExecutablePath: "D:/legacy/mvn.cmd", + mavenJavaHomePath: "C:/legacy/jdk", + }, + ], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + await store.getState().actions.runConfiguration(configuration.id); + + expect(resolveRunLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + mavenExecutablePath: "", + mavenJavaHomePath: "", + }), + ); + }); }); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 4ec8e05bd..686a1ee05 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -6,7 +6,7 @@ import { mavenLaunchContextForWorkspace, useMavenStore, } from "@/features/maven/stores/maven.store"; -import type { MavenSettings } from "@/features/maven/types/maven.types"; +import type { MavenLaunchContext, MavenSettings } from "@/features/maven/types/maven.types"; import { createLaunchPlan, generateRunConfiguration, @@ -250,6 +250,25 @@ function flushStampedOutput(sessionId: string, existing: string): string { return trimOutput(existing + stamperFor(sessionId).flush()); } +// A loaded Maven project owns the executable and JDK, including a blank value +// that means automatic. The run configuration's copy is only for projects that +// have no Maven settings document. +function mavenProcessPaths( + mavenContext: MavenLaunchContext | null, + configuration: { mavenExecutablePath: string; mavenJavaHomePath: string }, +) { + if (mavenContext) { + return { + mavenExecutablePath: mavenContext.mavenExecutablePath ?? "", + mavenJavaHomePath: mavenContext.javaHomePath ?? "", + }; + } + return { + mavenExecutablePath: configuration.mavenExecutablePath || "", + mavenJavaHomePath: configuration.mavenJavaHomePath || "", + }; +} + function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { return { javaHomePath: configuration.javaHomePath, @@ -561,12 +580,7 @@ export const createRunStore = ( executable: plan.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, - // The shared Maven settings win; the per-configuration copy is only a - // fallback for configurations saved before the paths were unified. - mavenExecutablePath: - mavenContext?.mavenExecutablePath || configuration.mavenExecutablePath || "", - mavenJavaHomePath: - mavenContext?.javaHomePath || configuration.mavenJavaHomePath || "", + ...mavenProcessPaths(mavenContext, configuration), runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); @@ -647,12 +661,7 @@ export const createRunStore = ( executable: step.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, - // The shared Maven settings win; the per-configuration copy is only - // a fallback for configurations saved before the paths were unified. - mavenExecutablePath: - mavenContext?.mavenExecutablePath || configuration.mavenExecutablePath || "", - mavenJavaHomePath: - mavenContext?.javaHomePath || configuration.mavenJavaHomePath || "", + ...mavenProcessPaths(mavenContext, configuration), runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); diff --git a/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx index 2bb024889..ba1fb2983 100644 --- a/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx +++ b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx @@ -3,6 +3,8 @@ import { useEffect, useState, type ReactNode } from "react"; import { MavenDetectedValue } from "@/features/maven/components/maven-detected-value"; import type { MavenSettings } from "@/features/maven/types/maven.types"; import { useMavenStore } from "@/features/maven/stores/maven.store"; +import { WorkspaceStoreScopeContext } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import { FolderIcon, TrashIcon } from "@/ui/icons"; @@ -33,9 +35,25 @@ interface MavenField { /** * The Maven page of the application settings. It edits the same project-scoped * configuration as the Maven tool window, so both surfaces always agree, and it - * shows what blank fields fall back to on this machine. + * shows what automatic detection found on this machine. + * + * The settings dialog sits outside the workbench's workspace provider, so this + * wrapper pins it to the active project tab — the same store the Maven tool + * window uses. */ export function MavenSettingsPanel() { + const projectTabId = useWorkspaceTabsStore( + (state) => state.projectTabs.find((tab) => tab.isActive)?.id ?? null, + ); + if (!projectTabId) return ; + return ( + + + + ); +} + +function MavenSettingsForm() { const { t } = useTranslation(); const project = useMavenStore((state) => state.project); const projectStatus = useMavenStore((state) => state.projectStatus); @@ -45,7 +63,17 @@ export function MavenSettingsPanel() { const javaHomePath = useMavenStore((state) => state.javaHomePath); const configurationSaveError = useMavenStore((state) => state.configurationSaveError); const effectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const effectiveConfigurationStatus = useMavenStore( + (state) => state.effectiveConfigurationStatus, + ); const updateLocalConfiguration = useMavenStore((state) => state.actions.updateLocalConfiguration); + const resolveEffectiveConfiguration = useMavenStore( + (state) => state.actions.resolveEffectiveConfiguration, + ); + + useEffect(() => { + void resolveEffectiveConfiguration(); + }, [project, resolveEffectiveConfiguration]); const saved: MavenSettings = { settingsPath, @@ -131,6 +159,7 @@ export function MavenSettingsPanel() { field={field} value={draft[field]} effective={effectiveConfiguration} + status={effectiveConfigurationStatus} /> ))} diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 6e36cf699..ffabd9e13 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1603,6 +1603,7 @@ const catalogs = { "maven.settingsDescription": "Leave a field empty to let Lithe detect it automatically.", "maven.detectedValue": "Detected: {value}", "maven.detectedMissing": "Not detected on this machine", + "maven.detecting": "Detecting…", "maven.automatic": "Automatic", "maven.localRepository": "Local repository", "maven.mavenExecutable": "Maven home or executable", @@ -5960,6 +5961,7 @@ const catalogs = { "maven.settingsDescription": "留空表示由 Lithe 自动检测。", "maven.detectedValue": "检测到:{value}", "maven.detectedMissing": "本机未检测到", + "maven.detecting": "正在检测…", "maven.automatic": "自动检测", "maven.localRepository": "本地仓库", "maven.mavenExecutable": "Maven 主目录 / 可执行文件", From 77cb398a6b8a34e13229ad2864ce394122e14808 Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 25 Sep 2026 00:11:32 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(windows):=20=E5=9C=A8=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E6=96=B0=E5=A2=9E=20Maven=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=B8=89=E5=A4=84?= =?UTF-8?q?=20Maven=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/maven/components/maven-pane.tsx | 1 - .../src/features/maven/stores/maven.store.ts | 37 +++++-- .../components/run-configuration-editor.tsx | 96 +------------------ .../src/features/run/stores/run.store.ts | 8 ++ .../project-environment-settings.tsx | 33 ++++++- .../settings/components/settings-dialog.tsx | 2 + 6 files changed, 75 insertions(+), 102 deletions(-) diff --git a/windows/tauri/src/features/maven/components/maven-pane.tsx b/windows/tauri/src/features/maven/components/maven-pane.tsx index 0fa539299..8d8e0df79 100644 --- a/windows/tauri/src/features/maven/components/maven-pane.tsx +++ b/windows/tauri/src/features/maven/components/maven-pane.tsx @@ -49,7 +49,6 @@ import { type MavenLifecyclePhase, type MavenModule, } from "../types/maven.types"; -import { MavenDetectedValue } from "./maven-detected-value"; import { MavenSourceRootRows } from "./maven-source-root-rows"; import { MavenIcon } from "./maven-icon"; import { diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index 140e65ef5..ff07475a6 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -686,6 +686,8 @@ export const createMavenStore = ( releaseMavenSessionWorkspace(previous.activeSessionId); } if (previous.root && previous.root !== root) { + pendingLocalSeed = null; + localConfigurationPersisted = false; await synchronizePomWatches(new Set()); if (projectLoadRevision !== revision) return; } @@ -724,6 +726,7 @@ export const createMavenStore = ( if (!project) { await synchronizePomWatches(new Set()); if (projectLoadRevision !== revision || get().root !== root) return; + localConfigurationPersisted = false; set({ projectStatus: "ready", project: null, @@ -788,12 +791,26 @@ export const createMavenStore = ( const selectedProfiles = normalizedProfiles( stored.portable?.selectedProfiles ?? defaultProfiles, ).filter((profile) => knownProfiles.has(profile)); - const mavenExecutablePath = normalizedPath(stored.local?.mavenExecutablePath); + if (!preserveLatestInMemoryConfiguration) { + localConfigurationPersisted = stored.local != null; + } + const loadedPaths = { + settingsPath: normalizedPath(stored.local?.settingsPath), + localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), + mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), + javaHomePath: normalizedPath(stored.local?.javaHomePath), + }; + const adopted = preserveLatestInMemoryConfiguration + ? { settings: loadedPaths, migrated: false } + : adoptPendingLocalSeed(loadedPaths); // Project import must follow the same installation the command line // uses, so JDT LS reads its repository and mirrors instead of the - // embedded defaults. + // embedded defaults. The adopted path is the one launch will use. const resolvedMavenExecutablePath = - await dependencies.resolveEffectiveMavenExecutable(root, mavenExecutablePath); + await dependencies.resolveEffectiveMavenExecutable( + root, + adopted.settings.mavenExecutablePath, + ); if (projectLoadRevision !== revision || get().root !== root) return; set({ projectStatus: "ready", @@ -802,12 +819,10 @@ export const createMavenStore = ( selectedProfiles, customProfiles, skipTests: stored.portable?.skipTests ?? false, - settingsPath: normalizedPath(stored.local?.settingsPath), - localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), - mavenExecutablePath, + ...adopted.settings, resolvedMavenExecutablePath, - javaHomePath: normalizedPath(stored.local?.javaHomePath), }); + if (adopted.migrated) persistConfiguration(); } catch (error) { if (projectLoadRevision !== revision || get().root !== root) return; const message = @@ -822,6 +837,7 @@ export const createMavenStore = ( })); return; } + localConfigurationPersisted = false; set({ projectStatus: "failed", projectError: message, @@ -839,6 +855,12 @@ export const createMavenStore = ( lastTestRun: null, testOutcomes: [], }); + } finally { + // Deliberately not awaited: the scan has already produced the project + // state, and detection probes the machine, which must not delay + // opening a workspace. The action resolves once detection settles. + const resolveEffectiveConfiguration = get().actions.resolveEffectiveConfiguration; + void resolveEffectiveConfiguration(); } }, @@ -936,7 +958,6 @@ export const createMavenStore = ( }); localConfigurationPersisted = true; pendingLocalSeed = null; - set(next); configurationDidChange(); void get().actions.resolveEffectiveConfiguration(); }, diff --git a/windows/tauri/src/features/run/components/run-configuration-editor.tsx b/windows/tauri/src/features/run/components/run-configuration-editor.tsx index e030c9862..68c209230 100644 --- a/windows/tauri/src/features/run/components/run-configuration-editor.tsx +++ b/windows/tauri/src/features/run/components/run-configuration-editor.tsx @@ -1,9 +1,6 @@ import { useState, type ReactNode } from "react"; import { useUIState } from "@/features/window/stores/ui-state.store"; -import { useEffect, useState, type ReactNode } from "react"; import { open } from "@tauri-apps/plugin-dialog"; -import { MavenDetectedValue } from "@/features/maven/components/maven-detected-value"; -import { useMavenStore } from "@/features/maven/stores/maven.store"; import { Button } from "@/ui/button"; import { Field, FieldDescription, FieldLabel } from "@/ui/field"; import Input from "@/ui/input"; @@ -70,11 +67,6 @@ interface ToolchainFieldProps { onPick: () => void; /** What a launch would use for the current value. */ effective?: ReactNode; - /** - * Extra content under the field, used by the Maven paths to show what a blank - * field resolves to. Left undefined by fields without a detected value. - */ - footer?: ReactNode; } function ToolchainField({ @@ -135,47 +127,12 @@ export function RunConfigurationEditor({ onSave, }: RunConfigurationEditorProps) { const { t } = useTranslation(); - // The Maven settings page owns the project-wide Maven paths, so this editor - // shows and edits the same values instead of a private copy. - const mavenSettingsPath = useMavenStore((state) => state.settingsPath); - const mavenLocalRepositoryPath = useMavenStore((state) => state.localRepositoryPath); - const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); - const mavenJavaHomePath = useMavenStore((state) => state.javaHomePath); - const mavenEffectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); - const mavenEffectiveStatus = useMavenStore((state) => state.effectiveConfigurationStatus); - const mavenProject = useMavenStore((state) => state.project); - const updateMavenConfiguration = useMavenStore((state) => state.actions.updateLocalConfiguration); - const [draft, setDraft] = useState(() => ({ - ...configurationOverrides(options, globalToolchain), - mavenExecutablePath, - mavenJavaHomePath, - })); - const [toolchainDraft, setToolchainDraft] = useState(() => ({ - ...globalToolchain, - mavenExecutablePath, - mavenJavaHomePath, - })); + const [draft, setDraft] = useState(() => configurationOverrides(options, globalToolchain)); + const [toolchainDraft, setToolchainDraft] = useState(globalToolchain); const [scope, setScope] = useState("local"); const [envText, setEnvText] = useState(environmentText(options.environment)); const [saving, setSaving] = useState(false); - // Both sections edit the same shared Maven paths, so one edit updates the - // other section and vice versa. - const setSharedMavenPaths = (patch: { - mavenExecutablePath?: string; - mavenJavaHomePath?: string; - }) => { - setToolchainDraft((current) => ({ ...current, ...patch })); - setDraft((current) => ({ ...current, ...patch })); - }; - - // The drafts above snapshot the store when this editor mounts; mirror later - // changes so values saved from the settings page (or loaded afterwards) show. - useEffect(() => { - setSharedMavenPaths({ mavenExecutablePath, mavenJavaHomePath }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- setSharedMavenPaths updates both drafts and is recreated each render - }, [mavenExecutablePath, mavenJavaHomePath]); - const projectUsesMaven = configurationUsesMaven(configuration); const projectUsesJava = configurationUsesJava(configuration); const projectUsesNode = configurationUsesNode(configuration); @@ -209,32 +166,10 @@ export function RunConfigurationEditor({ : runtime.executablePath, })); - // The Maven paths show what a blank field resolves to, exactly as the settings - // page does; other toolchain fields have no detected counterpart here. - const detectedMavenValue = ( - field: "mavenExecutablePath" | "mavenJavaHomePath", - value: string, - ) => { - const effectiveField = - field === "mavenExecutablePath" ? "mavenExecutablePath" : "javaHomePath"; - return ( - - ); - }; - const pickDirectory = (field: "javaHomePath" | "mavenJavaHomePath" | "workingDirectoryPath") => { void open({ directory: true, multiple: false }).then((selected) => { if (typeof selected === "string" && selected) { - if (field === "mavenJavaHomePath") { - setSharedMavenPaths({ mavenJavaHomePath: selected }); - } else { - setDraft((current) => ({ ...current, [field]: selected })); - } + setDraft((current) => ({ ...current, [field]: selected })); } }); }; @@ -263,30 +198,9 @@ export function RunConfigurationEditor({ const save = async () => { setSaving(true); - // A Maven project keeps these paths in the Maven settings document. The run - // documents must not keep a second copy, or a blank automatic field gets - // filled back in from the toolchain the next time the project opens. - const mavenPathsOwnedBySettings = Boolean(mavenProject); - const runOptions = { - ...draft, - environment: environmentFromText(envText), - ...(mavenPathsOwnedBySettings - ? { mavenExecutablePath: "", mavenJavaHomePath: "" } - : {}), - }; - const toolchainToSave = mavenPathsOwnedBySettings - ? { ...toolchainDraft, mavenExecutablePath: "", mavenJavaHomePath: "" } - : toolchainDraft; + const runOptions = { ...draft, environment: environmentFromText(envText) }; try { - if (mavenPathsOwnedBySettings) { - updateMavenConfiguration({ - settingsPath: mavenSettingsPath, - localRepositoryPath: mavenLocalRepositoryPath, - mavenExecutablePath: toolchainDraft.mavenExecutablePath, - javaHomePath: toolchainDraft.mavenJavaHomePath, - }); - } - const saved = await onSave(runOptions, toolchainToSave, scope); + const saved = await onSave(runOptions, toolchainDraft, scope); if (saved) onClose(); } finally { setSaving(false); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index aa7355fdd..423fe9d62 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -570,6 +570,14 @@ export const createRunStore = ( return; } set(readyRunState(snapshot, get().selectedConfigurationId)); + // Migrate any legacy per-project Maven paths into the shared Maven + // settings so the settings page shows the values already in effect. + // Fingerprint checking below still owns freshness; this only copies + // paths, and a saved Maven document keeps its own blank fields. + dependencies.seedMavenLocalConfiguration(workspaceId, { + mavenExecutablePath: snapshot.globalToolchain.mavenExecutablePath, + javaHomePath: snapshot.globalToolchain.mavenJavaHomePath, + }); const ownsSnapshot = () => revision === projectLoadRevision && get().root === root && get().configurations === snapshot.configurations; try { diff --git a/windows/tauri/src/features/settings/components/project-environment-settings.tsx b/windows/tauri/src/features/settings/components/project-environment-settings.tsx index 4e13372cd..0199965af 100644 --- a/windows/tauri/src/features/settings/components/project-environment-settings.tsx +++ b/windows/tauri/src/features/settings/components/project-environment-settings.tsx @@ -6,9 +6,10 @@ import { EffectiveToolchain } from "@/features/run/components/effective-toolchai import { useResolvedToolchains } from "@/features/run/hooks/use-resolved-toolchains"; import { toolchainRequirementMessages } from "@/features/run/utils/effective-toolchain"; import type { RunDiagnostic } from "@/features/run/types/run.types"; +import { MavenDetectedValue } from "@/features/maven/components/maven-detected-value"; import { mavenLaunchContextForWorkspace, useMavenStore } from "@/features/maven/stores/maven.store"; -import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import type { MavenSettings } from "@/features/maven/types/maven.types"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Input from "@/ui/input"; @@ -43,6 +44,17 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace const mounted = useRef(true); const revision = useRef(0); const saveRevision = useRef(0); + const effectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const effectiveConfigurationStatus = useMavenStore((state) => state.effectiveConfigurationStatus); + const mavenProject = useMavenStore((state) => state.project); + const resolveEffectiveConfiguration = useMavenStore( + (state) => state.actions.resolveEffectiveConfiguration, + ); + + useEffect(() => { + if (!mavenProject) return; + void resolveEffectiveConfiguration(); + }, [mavenProject, resolveEffectiveConfiguration]); const load = async () => { const current = ++revision.current; @@ -156,6 +168,7 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace path: runtime.homePath, version: runtime.version, })), + detectedField: null, }, { key: "mavenExecutablePath" as const, @@ -173,6 +186,7 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace path: runtime.executablePath, version: runtime.version, })), + detectedField: "mavenExecutablePath" as const, }, { key: "mavenJavaHomePath" as const, @@ -190,6 +204,7 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace path: runtime.homePath, version: runtime.version, })), + detectedField: "javaHomePath" as const, }, ] : []; @@ -217,7 +232,7 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace )}
{environment && - fields.map(({ key, label, automatic, candidates, effective }) => ( + fields.map(({ key, label, automatic, candidates, effective, detectedField }) => ( ))}
diff --git a/windows/tauri/src/features/settings/components/settings-dialog.tsx b/windows/tauri/src/features/settings/components/settings-dialog.tsx index a5a101104..b637890cc 100644 --- a/windows/tauri/src/features/settings/components/settings-dialog.tsx +++ b/windows/tauri/src/features/settings/components/settings-dialog.tsx @@ -15,6 +15,7 @@ import { MagicWandIcon, FileTextIcon, FolderIcon, + PackageIcon, TerminalWindowIcon, type Icon, } from "@/ui/icons"; @@ -40,6 +41,7 @@ const categories: CategoryItem[] = [ { id: "keyboard", labelKey: "settings.tabs.keyboard", icon: KeyboardIcon }, { id: "terminal", labelKey: "settings.tabs.terminal", icon: TerminalWindowIcon }, { id: "lsp", labelKey: "settings.tabs.lsp", icon: DatabaseIcon }, + { id: "maven", labelKey: "settings.tabs.maven", icon: PackageIcon }, { id: "ai", labelKey: "settings.tabs.ai", icon: MagicWandIcon }, { id: "ai-commit", labelKey: "settings.tabs.aiCommit", icon: MagicWandIcon }, { id: "git", labelKey: "settings.tabs.git", icon: CodeBlockIcon }, From ffb27a878c0d8f2c35c01d10bbdd1f02ab351e2f Mon Sep 17 00:00:00 2001 From: Jay Date: Fri, 25 Sep 2026 00:49:15 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat(windows):=20=E5=9C=A8=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E6=96=B0=E5=A2=9E=20Maven=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E5=B9=B6=E7=BB=9F=E4=B8=80=E4=B8=89=E5=A4=84?= =?UTF-8?q?=20Maven=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- windows/tauri/src-tauri/src/debug.rs | 10 +- windows/tauri/src-tauri/src/diagnostics.rs | 3 +- windows/tauri/src-tauri/src/host.rs | 6 +- windows/tauri/src-tauri/src/main.rs | 5 +- windows/tauri/src-tauri/src/maven.rs | 95 ++++++++++++++++--- windows/tauri/src-tauri/src/platform.rs | 10 +- windows/tauri/src-tauri/src/watcher.rs | 4 +- .../run/stores/run-maven-context.test.ts | 55 ++++++++++- .../src/features/run/stores/run.store.ts | 41 ++------ .../project-environment-settings.tsx | 13 +-- 10 files changed, 164 insertions(+), 78 deletions(-) diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 2d7ae3aaa..480c535a5 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -1558,10 +1558,7 @@ mod tests { ) .unwrap(); assert_eq!( - received - .recv_timeout(Duration::from_secs(1)) - .unwrap() - .1["message"]["type"], + received.recv_timeout(Duration::from_secs(1)).unwrap().1["message"]["type"], "initialized" ); } @@ -1927,7 +1924,10 @@ mod tests { struct FailingWriter; impl Write for FailingWriter { fn write(&mut self, _buffer: &[u8]) -> std::io::Result { - Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed")) + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "closed", + )) } fn flush(&mut self) -> std::io::Result<()> { diff --git a/windows/tauri/src-tauri/src/diagnostics.rs b/windows/tauri/src-tauri/src/diagnostics.rs index 100ed075f..28e71797a 100644 --- a/windows/tauri/src-tauri/src/diagnostics.rs +++ b/windows/tauri/src-tauri/src/diagnostics.rs @@ -388,7 +388,8 @@ mod tests { // redaction provably shrinks the content; that keeps the raw-size vs // redacted-size distinction below meaningful. (A short secret like // `hunter2` would grow into `` and invert the comparison.) - let raw = "token=0123456789abcdef0123456789abcdef0123 trailing text to keep the line long\n"; + let raw = + "token=0123456789abcdef0123456789abcdef0123 trailing text to keep the line long\n"; fs::write(directory.join(planted_name), raw).unwrap(); let planted_relative_path = format!("logs/{planted_name}"); diff --git a/windows/tauri/src-tauri/src/host.rs b/windows/tauri/src-tauri/src/host.rs index 396584847..9490b20b9 100644 --- a/windows/tauri/src-tauri/src/host.rs +++ b/windows/tauri/src-tauri/src/host.rs @@ -3,8 +3,8 @@ use serde_json::Value; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, Theme, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri_plugin_opener::OpenerExt; @@ -308,8 +308,8 @@ pub async fn create_app_window(app: AppHandle, request: Option) -> Result #[cfg(test)] mod tests { use super::{ - WINDOW_TASKBAR_ICON, cli_payloads, copy_path, create_app_window, read_bounded, - read_local_file_bounded, unique_destination, + cli_payloads, copy_path, create_app_window, read_bounded, read_local_file_bounded, + unique_destination, WINDOW_TASKBAR_ICON, }; use std::fs; use std::future::Future; diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index ea78bdfd3..116d3c521 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -18,9 +18,9 @@ mod terminal; mod watcher; use file_events::TauriFileChangeEmitter; -use lithe_project::FileWatcher; use lithe_project::document_watcher::DocumentWatcher; use lithe_project::git_watcher::GitMetadataWatcher; +use lithe_project::FileWatcher; use lithe_terminal::TerminalManager; use std::sync::Arc; use tauri::Manager; @@ -33,8 +33,7 @@ fn main() { &arguments.next().unwrap_or_default(), )); } - if std::env::var("LITHE_GIT_ASKPASS_MODE").as_deref() == Ok("1") - && std::env::args().len() == 2 + if std::env::var("LITHE_GIT_ASKPASS_MODE").as_deref() == Ok("1") && std::env::args().len() == 2 { std::process::exit(lithe_core::git_askpass_main( &std::env::args().nth(1).unwrap_or_default(), diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs index 618cb5b72..575129712 100644 --- a/windows/tauri/src-tauri/src/maven.rs +++ b/windows/tauri/src-tauri/src/maven.rs @@ -144,7 +144,9 @@ pub fn maven_resolve_effective_configuration( let selected_java = if args.java_home_path.trim().is_empty() { detected_java.clone() } else { - resolve_java_home(&root, &args.java_home_path).ok().flatten() + resolve_java_home(&root, &args.java_home_path) + .ok() + .flatten() }; Ok(assemble_effective_configuration( &args.settings_path, @@ -186,18 +188,12 @@ fn assemble_effective_configuration( selected_java }; let detected_settings_path = effective_settings_path("", home, detected_maven.as_deref()); - let settings_path = effective_settings_path( - configured_settings, - home, - maven_executable_path.as_deref(), - ); + let settings_path = + effective_settings_path(configured_settings, home, maven_executable_path.as_deref()); let detected_local_repository_path = effective_local_repository_path("", detected_settings_path.as_deref(), home); - let local_repository_path = effective_local_repository_path( - configured_repository, - settings_path.as_deref(), - home, - ); + let local_repository_path = + effective_local_repository_path(configured_repository, settings_path.as_deref(), home); MavenEffectiveConfiguration { settings_path, local_repository_path, @@ -265,7 +261,7 @@ fn effective_local_repository_path( if let Some(settings_path) = settings_path { let contents = fs::read_to_string(settings_path).unwrap_or_default(); if let Some(repository) = parse_local_repository(&contents) { - return Some(repository); + return Some(expand_user_home(&repository, home)); } } home.map(|home| { @@ -280,11 +276,14 @@ fn effective_local_repository_path( /// /// Hand-parsed rather than regex-matched: the element is a single tag with no /// nested markup, and a missing closing tag must stay undetected instead of -/// being reported as a repository path. +/// being reported as a repository path. Comments are removed first because +/// Maven's bundled `conf/settings.xml` documents the element inside an example +/// comment. fn parse_local_repository(settings: &str) -> Option { const OPENING_TAG: &str = ""; const CLOSING_TAG: &str = ""; - let remainder = &settings[settings.find(OPENING_TAG)? + OPENING_TAG.len()..]; + let visible = without_xml_comments(settings); + let remainder = &visible[visible.find(OPENING_TAG)? + OPENING_TAG.len()..]; let value = remainder .split_once(CLOSING_TAG) .map(|(value, _)| value) @@ -293,6 +292,34 @@ fn parse_local_repository(settings: &str) -> Option { (!value.is_empty()).then(|| value.to_string()) } +/// Drops `` regions. An unclosed comment hides the rest of the +/// document, which is how an XML parser would treat it. +fn without_xml_comments(settings: &str) -> String { + let mut output = String::with_capacity(settings.len()); + let mut rest = settings; + while let Some(start) = rest.find("") { + Some(end) => rest = &rest[end + 3..], + None => return output, + } + } + output.push_str(rest); + output +} + +/// Maven's default settings use `${user.home}` for the repository. Other +/// unresolved properties stay literal; inventing a path for them would hide +/// the value the file actually contains. +fn expand_user_home(value: &str, home: Option<&Path>) -> String { + const TOKEN: &str = "${user.home}"; + match home { + Some(home) if value.contains(TOKEN) => value.replace(TOKEN, &home.to_string_lossy()), + _ => value.to_string(), + } +} + fn validate_versions( portable: Option<&MavenPortableConfiguration>, local: Option<&MavenLocalConfiguration>, @@ -413,6 +440,46 @@ mod tests { ); assert_eq!(parse_local_repository(""), None); assert_eq!(parse_local_repository(""), None); + // Maven's bundled conf/settings.xml documents the element inside a + // comment. That example must not become the detected repository, and + // a later real element still does. + let bundled = "\ + + + ${user.home}/.m2/repository +"; + assert_eq!( + parse_local_repository(bundled).as_deref(), + Some("${user.home}/.m2/repository") + ); + assert_eq!( + parse_local_repository( + "" + ), + None + ); + } + + #[test] + fn local_repository_expands_user_home_and_ignores_the_bundled_example() { + let home = temp_directory(); + let settings = home.join("settings.xml"); + fs::write( + &settings, + "\n\ + ${user.home}/.m2/repository", + ) + .expect("write settings"); + let settings_path = settings.to_string_lossy(); + let expected = format!("{}/.m2/repository", home.to_string_lossy()); + assert_eq!( + effective_local_repository_path("", Some(&settings_path), Some(&home)).as_deref(), + Some(expected.as_str()) + ); + fs::remove_dir_all(home).ok(); } #[test] diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 0944c7f99..7cd72fac2 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -43,7 +43,11 @@ pub async fn platform_invoke( | "git.configureIdentity" ) || matches!( command.as_str(), - "git_add_remote" | "git_remove_remote" | "git_create_tag" | "git_delete_tag" | "git.command" + "git_add_remote" + | "git_remove_remote" + | "git_create_tag" + | "git_delete_tag" + | "git.command" ); // Observe every Git request at the shared boundary. Core suppresses its // parser-only probes, so new operation entry points cannot miss the console. @@ -97,7 +101,9 @@ pub async fn platform_invoke( } fn observes_git_execution(command: &str) -> bool { - command.starts_with("git.") && command != "git.authRespond" && command != "git.consolePresentation" + command.starts_with("git.") + && command != "git.authRespond" + && command != "git.consolePresentation" } fn core_response( diff --git a/windows/tauri/src-tauri/src/watcher.rs b/windows/tauri/src-tauri/src/watcher.rs index 763d7a050..950761625 100644 --- a/windows/tauri/src-tauri/src/watcher.rs +++ b/windows/tauri/src-tauri/src/watcher.rs @@ -1,6 +1,6 @@ -use lithe_project::FileWatcher; use lithe_project::git_watcher::{GitMetadataWatcher, GitWatchContext}; -use serde_json::{Value, json}; +use lithe_project::FileWatcher; +use serde_json::{json, Value}; use std::sync::Arc; use tauri::State; diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts index a900e0501..eab684105 100644 --- a/windows/tauri/src/features/run/stores/run-maven-context.test.ts +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -172,11 +172,11 @@ describe("Maven-backed Run context", () => { ]); }); - test("uses a blank Maven setting instead of a legacy run toolchain path", async () => { + test("a configured Maven path overrides the project Maven context", async () => { const automaticContext: MavenLaunchContext = { ...mavenContext, - mavenExecutablePath: null, - javaHomePath: null, + mavenExecutablePath: "D:/project/mvn.cmd", + javaHomePath: "C:/project/jdk", }; const resolveRunLaunch = mock(async () => ({ executable: "D:/Tools/mvn.cmd", @@ -216,8 +216,53 @@ describe("Maven-backed Run context", () => { expect(resolveRunLaunch).toHaveBeenCalledWith( expect.objectContaining({ - mavenExecutablePath: "", - mavenJavaHomePath: "", + mavenExecutablePath: "D:/legacy/mvn.cmd", + mavenJavaHomePath: "C:/legacy/jdk", + }), + ); + }); + + test("an empty run configuration Maven path uses the project Maven context", async () => { + const resolveRunLaunch = mock(async () => ({ + executable: "D:/Tools/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })); + const dependencies: RunStoreDependencies = { + createLaunchPlan: mock(async () => ({ + executable: { toolchain: "project-maven" as const }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + })), + mavenLaunchContextForWorkspace: mock(async () => mavenContext), + resolveRunLaunch, + saveWorkspaceBeforeLaunch: mock(async () => undefined), + executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })), + startRunProcess: mock(async () => undefined), + stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, + prepareJavaRunLaunch: mock(async () => null), + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [ + { + ...configuration, + mavenExecutablePath: " ", + mavenJavaHomePath: "", + }, + ], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + await store.getState().actions.runConfiguration(configuration.id); + + expect(resolveRunLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + mavenExecutablePath: "D:/Tools/apache-maven", + mavenJavaHomePath: "C:/Java/jdk-21", }), ); }); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 423fe9d62..379ee3a9c 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -180,9 +180,9 @@ export interface RunStoreDependencies { writeGeneratedRunDocuments?: typeof writeGeneratedRunDocuments; } -// The Maven settings own the project-wide Maven paths. The run feature's legacy -// per-project toolchain values migrate into them so both surfaces report and use -// the same configuration. +// Explicit import of a legacy run toolchain into blank Maven settings. Load and +// launch do not call it: a per-configuration override stays on the run document, +// and project settings are written only when the user saves them. function seedMavenLocalConfiguration( workspaceId: string, settings: Partial, @@ -312,22 +312,18 @@ function flushStampedOutput(sessionId: string, existing: string): string { return trimOutput(existing + stamperFor(sessionId).flush()); } -// A loaded Maven project owns the executable and JDK, including a blank value -// that means automatic. The run configuration's copy is only for projects that -// have no Maven settings document. +// A value written on the run configuration wins. An empty field falls back to +// the Maven project context, matching macOS RunService.effectiveOptions. function mavenProcessPaths( mavenContext: MavenLaunchContext | null, configuration: { mavenExecutablePath: string; mavenJavaHomePath: string }, ) { - if (mavenContext) { - return { - mavenExecutablePath: mavenContext.mavenExecutablePath ?? "", - mavenJavaHomePath: mavenContext.javaHomePath ?? "", - }; - } + const configuredExecutable = configuration.mavenExecutablePath.trim(); + const configuredJavaHome = configuration.mavenJavaHomePath.trim(); return { - mavenExecutablePath: configuration.mavenExecutablePath || "", - mavenJavaHomePath: configuration.mavenJavaHomePath || "", + mavenExecutablePath: + configuredExecutable || mavenContext?.mavenExecutablePath || "", + mavenJavaHomePath: configuredJavaHome || mavenContext?.javaHomePath || "", }; } @@ -570,14 +566,6 @@ export const createRunStore = ( return; } set(readyRunState(snapshot, get().selectedConfigurationId)); - // Migrate any legacy per-project Maven paths into the shared Maven - // settings so the settings page shows the values already in effect. - // Fingerprint checking below still owns freshness; this only copies - // paths, and a saved Maven document keeps its own blank fields. - dependencies.seedMavenLocalConfiguration(workspaceId, { - mavenExecutablePath: snapshot.globalToolchain.mavenExecutablePath, - javaHomePath: snapshot.globalToolchain.mavenJavaHomePath, - }); const ownsSnapshot = () => revision === projectLoadRevision && get().root === root && get().configurations === snapshot.configurations; try { @@ -817,15 +805,6 @@ export const createRunStore = ( } await save; if (!isCurrent()) return null; - // The Maven settings own the project-wide Maven paths. Seed them from - // the run project before reading the shared context, so the launch - // resolves the values this feature already had in effect. - if (configurationUsesMaven(configuration)) { - dependencies.seedMavenLocalConfiguration(workspaceId, { - mavenExecutablePath: state.globalToolchain.mavenExecutablePath, - javaHomePath: state.globalToolchain.mavenJavaHomePath, - }); - } const mavenContext = configurationUsesMaven(configuration) ? await dependencies.mavenLaunchContextForWorkspace(root, [], workspaceId) : null; diff --git a/windows/tauri/src/features/settings/components/project-environment-settings.tsx b/windows/tauri/src/features/settings/components/project-environment-settings.tsx index 0199965af..48faf9aba 100644 --- a/windows/tauri/src/features/settings/components/project-environment-settings.tsx +++ b/windows/tauri/src/features/settings/components/project-environment-settings.tsx @@ -168,7 +168,6 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace path: runtime.homePath, version: runtime.version, })), - detectedField: null, }, { key: "mavenExecutablePath" as const, @@ -186,7 +185,6 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace path: runtime.executablePath, version: runtime.version, })), - detectedField: "mavenExecutablePath" as const, }, { key: "mavenJavaHomePath" as const, @@ -204,7 +202,6 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace path: runtime.homePath, version: runtime.version, })), - detectedField: "javaHomePath" as const, }, ] : []; @@ -232,7 +229,7 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace )}
{environment && - fields.map(({ key, label, automatic, candidates, effective, detectedField }) => ( + fields.map(({ key, label, automatic, candidates, effective }) => (