From d3cb7c14ca4a8c76351c9f7c335a482774577dcb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:01:03 +0800 Subject: [PATCH] feat(maven): add POMs outside the root reactor as Maven projects (#1446) feat(maven): add POMs outside the root reactor as Maven projects Add Maven Projects only re-linked POMs Maven had already discovered: picking a pom.xml inside the project that the root POM doesn't list as a module (an independent build kept out of the reactor) closed the dialog and changed nothing. Such POMs are now kept in the project's Maven settings (linkedProjects) and loaded as independent top-level projects, after the root reactor, by the static model, the sync, the Java language server import and the content roots; each runs its goals in its own directory. A picked POM that the root reactor lists stays its module. The dialog takes several POMs at once, and unlinking a linked project drops the link instead of hiding it. --- crates/ide-build/src/lib.rs | 5 +- crates/ide-build/src/maven.rs | 98 +++++++++++++++++-- crates/ide-build/src/sync.rs | 7 +- crates/ide-core/src/settings.rs | 2 + crates/ide-host/src/ops/project.rs | 1 + crates/ide-host/src/project.rs | 2 +- src/bindings.ts | 2 + src/features/build-tools/linkage.test.tsx | 26 +++++ src/features/build-tools/store.ts | 53 +++++++--- .../settings/__tests__/languages.test.ts | 1 + src/platform/languageSettings.ts | 12 ++- 11 files changed, 183 insertions(+), 26 deletions(-) diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 315418546..861f5a06d 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -75,9 +75,10 @@ pub fn load_linked( settings: &ide_core::settings::MavenSettings, gradle_settings: &ide_core::settings::GradleSettings, ) -> BuildModel { - let maven = maven::is_maven_project(root).then(|| { + let maven = maven::has_projects(root, settings).then(|| { let profiles = sync::maven_profiles(cache_dir); - let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles); + let repo = maven_repo.or_else(maven::default_local_repo); + let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects); maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m); sync::apply_maven_cache(root, cache_dir, &mut m); m diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 12c8b14a8..fd105ee81 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -1065,10 +1065,9 @@ pub fn load(root: &Path) -> MavenModel { load_with_repo(root, default_local_repo()) } -/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules, -/// dependencies and properties are part of the model, those turned off are not. -pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel { - load_with(root, default_local_repo(), profiles) +/// [`load`] with the POMs `settings` link (Add Maven Projects). +pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel { + load_with_linked(root, default_local_repo(), &[], &settings.linked_projects) } pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenModel { @@ -1090,11 +1089,53 @@ fn profile_choice(chosen: &[String], id: &str) -> Option { } pub(crate) fn load_with(root: &Path, local_repo: Option, profiles: &[String]) -> MavenModel { + load_with_linked(root, local_repo, profiles, &[]) +} + +/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`. +fn linked_poms(root: &Path, linked: &[String]) -> Vec { + let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let mut out: Vec = Vec::new(); + for pom in linked { + let pom = normalized_pom(pom); + if pom.is_empty() || pom.starts_with("../") || pom == ".." { + continue; + } + let path = root.join(&pom); + let inside = dunce::canonicalize(&path).is_ok_and(|p| p.starts_with(&base)); + if path.is_file() && inside && !out.contains(&path) { + out.push(path); + } + } + out +} + +/// Whether `root` holds a Maven build or the settings link one inside it. +pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) -> bool { + is_maven_project(root) || !linked_poms(root, &settings.linked_projects).is_empty() +} + +/// The model with the chosen `profiles` (`-P` entries; `!id` deactivates: their modules, +/// dependencies and properties are part of the model, those turned off are not) and the +/// `linked` POMs (Add Maven Projects): builds that no reactor lists, shown as independent +/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module. +pub fn load_with_linked( + root: &Path, + local_repo: Option, + profiles: &[String], + linked: &[String], +) -> MavenModel { let mut resolver = Resolver::new(local_repo, profiles); let mut model = MavenModel::default(); let mut seen: HashSet = HashSet::new(); - // (pom, aggregated) - let mut stack: Vec<(PathBuf, bool)> = top_level_poms(root).into_iter().rev().map(|p| (p, false)).collect(); + let mut tops = top_level_poms(root); + for pom in linked_poms(root, linked) { + if !tops.contains(&pom) { + tops.push(pom); + } + } + // (pom, aggregated); the root reactor and its modules come before linked POMs. + let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect(); while let Some((path, aggregated)) = stack.pop() { if model.projects.len() >= MAX_PROJECTS { break; @@ -1216,7 +1257,7 @@ impl UnlinkedProjects { if settings.unlinked_projects.is_empty() { return Self::default(); } - Self::from_model(&load(root), settings) + Self::from_model(&load_linked(root, settings), settings) } pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self { @@ -1733,6 +1774,49 @@ mod tests { assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" })); } + /// Add Maven Projects: a POM inside the project that no reactor lists becomes an + /// independent top-level project; one the root reactor lists stays its module. + #[test] + fn linked_poms_outside_the_reactor_are_top_level_projects() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + write( + root, + "pom.xml", + "demomain1.0pomcore", + ); + write( + root, + "core/pom.xml", + "democore1.0", + ); + write( + root, + "stub/pom.xml", + "demostub1.0", + ); + let top = |m: &MavenModel| m.projects.iter().map(|p| (p.dir.clone(), p.aggregated)).collect::>(); + + assert_eq!(top(&load_with_linked(root, None, &[], &[])), [("".into(), false), ("core".into(), true)]); + let linked: Vec = + ["stub/pom.xml", "./core/pom.xml", "../outside/pom.xml", "missing/pom.xml"].map(String::from).into(); + assert_eq!( + top(&load_with_linked(root, None, &[], &linked)), + [("".into(), false), ("core".into(), true), ("stub".into(), false)] + ); + + // Deeper than the folder search reaches: only the link makes it a Maven project. + let deep = tempfile::tempdir().unwrap(); + write(deep.path(), "a/b/c/tool/pom.xml", "tool"); + let settings = ide_core::settings::MavenSettings { + linked_projects: vec!["a/b/c/tool/pom.xml".into()], + ..Default::default() + }; + assert!(!has_projects(deep.path(), &Default::default())); + assert!(has_projects(deep.path(), &settings)); + assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]); + } + #[test] fn chosen_profiles_bring_their_modules_and_dependencies() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index eca3daa41..b390b2cca 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -283,7 +283,12 @@ pub fn sync_maven( cancel: &CancellationToken, progress: &dyn Fn(SyncProgress), ) -> IdeResult<()> { - let mut model = maven::load_with_profiles(root, &options.profiles); + let mut model = maven::load_with_linked( + root, + maven::default_local_repo(), + &options.profiles, + &options.maven_settings.linked_projects, + ); maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model); std::fs::create_dir_all(cache_dir)?; let mut cache = MavenCache { diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index b0b86e6d0..6f65eff4c 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -866,6 +866,8 @@ pub enum GradleDistribution { pub struct MavenSettings { /// Project-relative root POMs removed from the Maven project model. pub unlinked_projects: Vec, + /// Project-relative POMs outside the root reactor added as independent top-level projects. + pub linked_projects: Vec, /// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. pub home: String, /// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index f739d95c4..8d71234c8 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -197,6 +197,7 @@ pub fn set_settings( let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.") || "languages.java.maven.unlinkedProjects".starts_with(key) + || "languages.java.maven.linkedProjects".starts_with(key) || "languages.java.gradle.linkedProjects".starts_with(key); // Content roots and excludes (files.exclude, ignored names, excluded folders) reach the // index and the watchers without reopening the project (the rescan runs in the background). diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 0c5e96483..6b935077d 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -1197,7 +1197,7 @@ pub(crate) fn jdtls_maven_import( if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() { return (Vec::new(), None); } - let model = ide_build::maven::load(root); + let model = ide_build::maven::load_linked(root, maven); let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven); let excluded = unlinked.poms().map(PathBuf::from).collect(); let poms = unlinked.import_poms(&model).or_else(|| { diff --git a/src/bindings.ts b/src/bindings.ts index 93bbd9089..1b10a6f03 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -5131,6 +5131,8 @@ export type MavenProject = { export type MavenSettings = { /** Project-relative root POMs removed from the Maven project model. */ unlinkedProjects?: string[], + /** Project-relative POMs outside the root reactor added as independent top-level projects. */ + linkedProjects?: string[], /** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */ home?: string, /** User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. */ diff --git a/src/features/build-tools/linkage.test.tsx b/src/features/build-tools/linkage.test.tsx index c51c5ac83..2006e1c7c 100644 --- a/src/features/build-tools/linkage.test.tsx +++ b/src/features/build-tools/linkage.test.tsx @@ -226,6 +226,32 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/ }, ); +it("links picked POMs that no reactor lists as independent top-level projects", async () => { + mocks.get.mockResolvedValue({ + languages: { java: { maven: { unlinkedProjects: ["b/pom.xml"], linkedProjects: ["tools/pom.xml"] } } }, + }); + mocks.open.mockResolvedValue(["/p/stub/pom.xml", "/p/b/pom.xml", "/p/a/pom.xml", "/p/tools/pom.xml"]); + await act(async () => { + await executeAction("maven.addProjects"); + }); + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ multiple: true })); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", "[]"); + expect(mocks.set).toHaveBeenCalledWith( + "project", + "languages.java.maven.linkedProjects", + '["tools/pom.xml","stub/pom.xml"]', + ); +}); + +it("unlinking a linked independent project removes the link instead of hiding it", async () => { + mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["a/pom.xml"] } } } }); + await act(async () => { + await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); + }); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", "[]"); + expect(mocks.set).not.toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", expect.anything()); +}); + const gradleBuild = (dir: string): GradleModel => ({ dir, command: `/p/${dir}/gradlew`, diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 47212cccd..c4976709b 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -233,15 +233,33 @@ export async function loadModel(): Promise { } const UNLINKED_PROJECTS = "languages.java.maven.unlinkedProjects"; +const LINKED_PROJECTS = "languages.java.maven.linkedProjects"; -async function setLinked(root: string, pom: string, linked: boolean) { +/** + * Adds `poms` to Maven (back from unlinked, or as independent top-level projects when no + * reactor lists them), or removes them (a linked one is dropped, a discovered one unlinked). + */ +async function setLinked(root: string, poms: string[], linked: boolean) { if (useProject.getState().project?.root !== root) return; const settings = await commands.settingsGet(); if (useProject.getState().project?.root !== root) return; - const previous = settings.languages.java?.maven?.unlinkedProjects ?? []; - const next = linked ? previous.filter((p) => p !== pom) : [...new Set([...previous, pom])]; - if (JSON.stringify(next) === JSON.stringify(previous)) return; - await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(next))); + const maven = settings.languages.java?.maven; + const unlinkedBefore = maven?.unlinkedProjects ?? []; + const linkedBefore = maven?.linkedProjects ?? []; + const known = new Set(useBuild.getState().model?.maven?.projects.map((p) => p.pom)); + let unlinked = unlinkedBefore; + let extra = linkedBefore; + for (const pom of poms) { + if (linked && unlinked.includes(pom)) unlinked = unlinked.filter((p) => p !== pom); + else if (linked) { + if (!known.has(pom) && !extra.includes(pom)) extra = [...extra, pom]; + } else if (extra.includes(pom)) extra = extra.filter((p) => p !== pom); + else if (!unlinked.includes(pom)) unlinked = [...unlinked, pom]; + } + if (unlinked === unlinkedBefore && extra === linkedBefore) return; + if (unlinked !== unlinkedBefore) + await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(unlinked))); + if (extra !== linkedBefore) await call(commands.settingsSet("project", LINKED_PROJECTS, JSON.stringify(extra))); await loadModel(); } @@ -260,7 +278,19 @@ export async function unlinkProjects(pom: string | null) { ) return; cancelSync("maven"); - await setLinked(root, pom, false); + await setLinked(root, [pom], false); +} + +/** Project-relative `pom.xml` paths of `picked` inside `root`; others are skipped. */ +function projectPoms(root: string, picked: readonly string[]): string[] { + const base = root.replace(/\\/g, "/").replace(/\/$/, ""); + return picked.flatMap((p) => { + const path = p.replace(/\\/g, "/"); + if (!path.startsWith(`${base}/`)) return []; + const pom = path.slice(base.length + 1); + const parts = pom.split("/"); + return parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml" ? [] : [pom]; + }); } export async function addProjects() { @@ -270,17 +300,12 @@ export async function addProjects() { const picked = await open({ title: tx`Add Maven Projects`, directory: false, - multiple: false, + multiple: true, defaultPath: root, filters: [{ name: "pom.xml", extensions: ["xml"] }], }); - if (typeof picked !== "string") return; - const base = root.replace(/\\/g, "/").replace(/\/$/, ""); - const path = picked.replace(/\\/g, "/"); - if (!path.startsWith(`${base}/`)) return; - const pom = path.slice(base.length + 1); - if (pom.split("/").some((part) => part === ".." || part === ".") || pom.split("/").pop() !== "pom.xml") return; - await setLinked(root, pom, true); + const poms = projectPoms(root, typeof picked === "string" ? [picked] : (picked ?? [])); + if (poms.length) await setLinked(root, poms, true); } /** Links independent Gradle builds without changing their files or build scripts. */ diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index c4b266812..46d86a3f7 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -42,6 +42,7 @@ describe("languages settings", () => { userSettings: "", localRepository: "", unlinkedProjects: [], + linkedProjects: [], daemon: false, mvndHome: "/opt/mvnd", }); diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index 8ed81941f..ad9fd4eb5 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -116,6 +116,7 @@ export interface LanguagesValid { userSettings: string; localRepository: string; unlinkedProjects: string[]; + linkedProjects: string[]; daemon: boolean; mvndHome: string; }; @@ -162,7 +163,15 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { jdtlsJdk: "", jdtlsVmArgs: "", gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" }, - maven: { home: "", userSettings: "", localRepository: "", unlinkedProjects: [], daemon: false, mvndHome: "" }, + maven: { + home: "", + userSettings: "", + localRepository: "", + unlinkedProjects: [], + linkedProjects: [], + daemon: false, + mvndHome: "", + }, }, markdown: { previewFontSize: null }, }; @@ -307,6 +316,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { userSettings: text(maven.userSettings, d.java.maven.userSettings), localRepository: text(maven.localRepository, d.java.maven.localRepository), unlinkedProjects: folders(maven.unlinkedProjects), + linkedProjects: folders(maven.linkedProjects), daemon: bool(maven.daemon, d.java.maven.daemon), mvndHome: text(maven.mvndHome, d.java.maven.mvndHome), },