From 068ce8e7218ce12eb2253db5ea0fdf8232ee170e Mon Sep 17 00:00:00 2001 From: hwlLL0703 <94688503+hwlLL0703@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:52:00 +0900 Subject: [PATCH] feat(maven): import external Maven modules from Project Structure (#2922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Maven project outside the opened root can be linked explicitly: Add Maven Projects accepts an external pom.xml, and Project Structure › Modules › Import Module from Existing Sources (also on the project root's context menu) imports a directory with a pom.xml. The linked project becomes an extra project-tree root and an independent Maven project with its own working directory and wrapper; links are stored as canonical paths, survive reloads and reopening, and unlinking removes only the association. The opened project's jdtls gets the external directory as an extra workspace folder, keeping automatic discovery under the opened root. Neither POM is edited. Merged with main's aggregation fix: a project an aggregator lists later becomes its module only when the aggregator lies inside the opened root and is not on the same chain, so a linked sibling does not take over the opened project. Closes #2884 Co-authored-by: hwlLL0703 <94688503+hwlLL0703@users.noreply.github.com> --- crates/ide-build/src/lib.rs | 8 + crates/ide-build/src/maven.rs | 331 ++++++++++++++++-- crates/ide-core/src/settings.rs | 4 +- crates/ide-host/src/ops/project.rs | 13 +- crates/ide-host/src/project.rs | 38 +- crates/ide-host/tests/maven_external.rs | 71 ++++ crates/ide-lsp/src/lib.rs | 66 +++- crates/ide-lsp/tests/mock_server.rs | 34 ++ src/bindings.ts | 12 +- src/features/build-tools/actions.test.tsx | 1 + src/features/build-tools/index.ts | 14 + src/features/build-tools/linkage.test.tsx | 125 ++++++- src/features/build-tools/model.test.ts | 13 + src/features/build-tools/store.ts | 168 +++++++-- src/features/project-tree/ProjectView.tsx | 1 + src/features/project-tree/contextMenu.test.ts | 14 + src/features/project-tree/contextMenu.ts | 6 +- src/features/project-tree/index.ts | 19 +- .../project-tree/mavenRootIcon.test.tsx | 124 +++++++ src/features/project-tree/mavenRoots.test.ts | 22 ++ src/features/project-tree/mavenRoots.ts | 61 ++++ .../__tests__/moduleOverrides.test.tsx | 89 +++++ .../languages/ProjectStructurePage.tsx | 9 +- src/platform/locales/text/en.json | 3 +- src/platform/locales/text/zh.json | 9 +- 25 files changed, 1165 insertions(+), 90 deletions(-) create mode 100644 crates/ide-host/tests/maven_external.rs create mode 100644 src/features/project-tree/mavenRootIcon.test.tsx create mode 100644 src/features/project-tree/mavenRoots.test.ts create mode 100644 src/features/project-tree/mavenRoots.ts create mode 100644 src/features/settings/__tests__/moduleOverrides.test.tsx diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index ebc8e2bf8..81a415750 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -122,6 +122,14 @@ pub fn gradle_external_roots( gradle::external_roots(root, dirs.iter().map(String::as_str)) } +/// Directories of Maven POMs linked from outside `root`, outermost only. +/// Reads just those explicit paths (no POM parse and no directory walk). +pub fn maven_external_roots(root: &Path, linked: &[String]) -> Vec { + let dirs = maven::external_pom_dirs(root, linked); + let owned: Vec = dirs.iter().map(|dir| dir.to_string_lossy().into_owned()).collect(); + gradle::external_roots(root, owned.iter().map(String::as_str)) +} + /// Source, resource and build folders of the last Gradle sync, absolute. pub fn gradle_source_roots( root: &Path, diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 2c997630a..41081193e 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -106,7 +106,9 @@ pub struct MavenProject { pub profiles: Vec, /// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…). pub java_version: Option, - /// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. + /// Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened + /// project the search stops at its root; outside it, the search stops before + /// that root so a linked project keeps its own wrapper. pub command: String, /// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings. pub options: Vec, @@ -1032,10 +1034,6 @@ pub(crate) fn plugin( const MAX_PROJECTS: usize = 1000; -fn rel_path(root: &Path, p: &Path) -> String { - p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/") -} - /// The POMs that are not modules of another one: the root `pom.xml`, else the /// shallowest `pom.xml` of each branch (a folder holding several builds). An /// aggregate POM directly inside `src/` counts too (issue #1001: `src/pom.xml` @@ -1049,18 +1047,29 @@ pub fn is_maven_project(root: &Path) -> bool { !top_level_poms(root).is_empty() } -/// The `mvnw` nearest to `dir`, looking up to `root`. +/// The `mvnw` nearest to `dir`. Inside `root` the walk stops at `root`. Outside it, +/// the walk stops before entering `root` or any ancestor of `root`: a sibling project +/// keeps its own wrapper, and the search does not continue to the filesystem root. fn wrapper(root: &Path, dir: &Path) -> String { let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" }; - let mut cur = Some(dir); + let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let dir = dunce::canonicalize(dir).unwrap_or_else(|_| dir.to_path_buf()); + let external = !dir.starts_with(&root); + let mut cur = Some(dir.as_path()); while let Some(d) = cur { if d.join(name).is_file() { return d.join(name).to_string_lossy().into_owned(); } - if d == root { + if !external && d == root { break; } - cur = d.parent(); + let Some(parent) = d.parent() else { break }; + // `parent` inside the opened project, or an ancestor of it (the common parent, + // then the filesystem root). Stop before inspecting those. + if external && (parent.starts_with(&root) || root.starts_with(parent)) { + break; + } + cur = Some(parent); } if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() } } @@ -1104,24 +1113,118 @@ pub(crate) fn load_with(root: &Path, local_repo: Option, profiles: &[St load_with_linked(root, local_repo, profiles, &[]) } -/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`. +/// The `pom.xml` files `linked` names. Each entry is used as written (project-relative, +/// `../sibling/pom.xml`, or absolute) and canonicalized; a path that is not an existing +/// `pom.xml` is skipped. Nothing here lists a directory or walks parents looking for builds. 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) { + let Some(path) = dunce::canonicalize(base.join(pom.trim())).ok() else { continue }; + if is_pom_file(&path) && !out.contains(&path) { out.push(path); } } out } +fn is_pom_file(path: &Path) -> bool { + path.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" }) +} + +/// Identity of `path` (both canonical) within `root`: project-relative inside it, +/// otherwise the absolute path with forward slashes. +fn project_rel(root: &Path, path: &Path) -> String { + path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/") +} + +/// Canonical `pom.xml` paths named by `linked`, keyed by the spelling to store and to +/// show so unlink removes the same entry settings hold. The first spelling wins. +fn linked_identities(root: &Path, linked: &[String]) -> HashMap { + let mut out = HashMap::new(); + for entry in linked { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let Some(path) = dunce::canonicalize(root.join(entry)).ok() else { continue }; + if is_pom_file(&path) { + out.entry(path).or_insert_with(|| entry.replace('\\', "/")); + } + } + out +} + +/// Stable, deduplicated form of `poms` for `languages.java.maven.linkedProjects`. +/// An existing `pom.xml` inside `root` is project-relative; one outside it is the +/// canonical absolute path. Spellings of the same file (`../B/pom.xml`, an absolute +/// path, a symlink) collapse to one entry. A path that exists and is not a `pom.xml` +/// is dropped. A `pom.xml` that cannot be resolved yet is kept, so a link is not +/// discarded while its volume is offline. No directory is scanned. +pub fn normalize_linked_poms(root: &Path, poms: &[String]) -> Vec { + let Some(root) = dunce::canonicalize(root).ok() else { + let mut out = Vec::new(); + for pom in poms { + let pom = normalized_pom(pom); + if !pom.is_empty() && pom_name_ok(&pom) && !out.contains(&pom) { + out.push(pom); + } + } + return out; + }; + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for pom in poms { + let pom = pom.trim(); + if pom.is_empty() { + continue; + } + let key = match dunce::canonicalize(root.join(pom)) { + Ok(path) if is_pom_file(&path) => project_rel(&root, &path), + Ok(_) => continue, + Err(_) => { + let fallback = normalized_pom(pom); + let joined = root.join(pom); + if !pom_name_ok(&fallback) || (joined.exists() && !is_pom_file(&joined)) { + continue; + } + fallback + } + }; + if key.is_empty() || !seen.insert(key.clone()) { + continue; + } + out.push(key); + } + out +} + +fn pom_name_ok(path: &str) -> bool { + let name = path.rsplit('/').next().unwrap_or(path); + if cfg!(windows) { name.eq_ignore_ascii_case("pom.xml") } else { name == "pom.xml" } +} + +/// Directories of linked `pom.xml` files that sit outside `root` (the opened project +/// stays the root). Callers keep the outermost directory only. Modules a POM declares +/// outside its own directory are not included: a linked file cannot widen the indexed +/// tree to an arbitrary path. +pub fn external_pom_dirs(root: &Path, linked: &[String]) -> Vec { + let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let mut out = Vec::new(); + for pom in linked_poms(root, linked) { + let Some(dir) = pom.parent() else { continue }; + if dir.starts_with(&base) || out.iter().any(|have| have == dir) { + continue; + } + out.push(dir.to_path_buf()); + } + 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() @@ -1130,7 +1233,9 @@ pub fn has_projects(root: &Path, settings: &ide_core::settings::MavenSettings) - /// 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. +/// top-level projects, including a `pom.xml` outside the opened root. A linked POM that +/// turns out to be a module of the root reactor stays that module. The same canonical file +/// is loaded once, so a cycle or a second spelling does not duplicate it or edit either POM. pub fn load_with_linked( root: &Path, local_repo: Option, @@ -1141,6 +1246,8 @@ pub fn load_with_linked( let mut model = MavenModel::default(); // Canonical POM → its project, for a module listed again by a later aggregator. let mut seen: HashMap> = HashMap::new(); + let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + let linked_ids = linked_identities(&canonical_root, linked); let mut tops = top_level_poms(root); for pom in linked_poms(root, linked) { if !tops.contains(&pom) { @@ -1148,8 +1255,11 @@ pub fn load_with_linked( } } // (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() { + // (pom, listed by): a module carries the top-level project its chain started from and + // whether the aggregator listing it lies inside the opened root. + let mut stack: Vec<(PathBuf, Option<(usize, bool)>)> = tops.into_iter().rev().map(|p| (p, None)).collect(); + while let Some((path, listed_by)) = stack.pop() { + let aggregated = listed_by.is_some(); if model.projects.len() >= MAX_PROJECTS { break; } @@ -1157,14 +1267,19 @@ pub fn load_with_linked( if let Some(known) = seen.get(&canonical) { // Found as a top-level POM first, then listed by an aggregator elsewhere // (`../` of `parent/pom.xml`): it is that aggregator's module. - if let (Some(known), true) = (known, aggregated) { - model.projects[*known].aggregated = true; + // Not when the chain leads back to where it started (a cycle), and not for an + // aggregator outside the opened root: a linked sibling does not take it over. + if let (Some(known), Some((origin, true))) = (*known, listed_by) + && known != origin + { + model.projects[known].aggregated = true; } continue; } seen.insert(canonical.clone(), None); let Some(pom) = resolver.pom(&path) else { continue }; let dir = path.parent().unwrap_or(root).to_path_buf(); + let dir_canon = dunce::canonicalize(&dir).unwrap_or_else(|_| dir.clone()); let ctx = resolver.context(&path, 0); let props = resolver.properties_for(&pom, &ctx, &dir); let i = |s: &str| interpolate(s, &props); @@ -1239,29 +1354,39 @@ pub fn load_with_linked( } } let artifact_id = i(&pom.artifact_id); + let module_ids: Vec = modules + .iter() + .map(|m| { + let canonical = dunce::canonicalize(m).unwrap_or_else(|_| m.clone()); + linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)) + }) + .collect(); model.projects.push(MavenProject { project_root: None, - pom: rel_path(root, &path), - dir: rel_path(root, &dir), + pom: linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)), + dir: project_rel(&canonical_root, &dir_canon), group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()), version: i(props.get("project.version").map(String::as_str).unwrap_or_default()), display_name: pom.name.as_deref().map(i).filter(|n| !n.is_empty()).unwrap_or_else(|| artifact_id.clone()), artifact_id, packaging, parent: pom.parent.as_ref().map(|p| format!("{}:{}:{}", p.group_id, p.artifact_id, i(&p.version))), - modules: modules.iter().map(|m| rel_path(root, m)).collect(), + modules: module_ids, aggregated, plugins, dependencies, profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(), java_version: jv, - command: wrapper(root, &dir), + command: wrapper(&canonical_root, &dir_canon), options: Vec::new(), env: BTreeMap::new(), }); - seen.insert(canonical, Some(model.projects.len() - 1)); + let index = model.projects.len() - 1; + seen.insert(canonical, Some(index)); + let origin = listed_by.map_or(index, |(origin, _)| origin); + let inside = dir_canon.starts_with(&canonical_root); for m in modules.into_iter().rev() { - stack.push((m, true)); + stack.push((m, Some((origin, inside)))); } } model @@ -1349,17 +1474,27 @@ impl UnlinkedProjects { fn normalized_pom(pom: &str) -> String { let pom = pom.replace('\\', "/"); - let mut parts = Vec::new(); + let unc = pom.starts_with("//"); + let absolute = pom.starts_with('/'); + let mut parts: Vec<&str> = Vec::new(); for part in pom.split('/') { match part { "" | "." => {} - ".." if parts.last().is_some_and(|p| *p != "..") => { + // Keep a drive letter (`C:`) and a leading `..` that has nothing to climb. + ".." if parts.last().is_some_and(|p| *p != ".." && !p.ends_with(':')) => { parts.pop(); } part => parts.push(part), } } - parts.join("/") + let joined = parts.join("/"); + if unc { + format!("//{joined}") + } else if absolute { + format!("/{joined}") + } else { + joined + } } /// Every POM of the model (for change detection). @@ -2155,6 +2290,130 @@ mod tests { assert_eq!(top(&load_linked(deep.path(), &settings)), [("a/b/c/tool".into(), false)]); } + /// A sibling Maven project is linked beside the opened root. Its reactor, including a + /// module that points back at the opened project, is read once. Neither POM is rewritten, + /// and only the linked project's directory is an extra content root — not the common + /// parent and not a module that lives outside that directory. + #[test] + fn external_sibling_keeps_its_directory_and_does_not_edit_poms() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let a = base.join("A"); + let b = base.join("B"); + let c = base.join("C"); + std::fs::create_dir_all(a.join("core")).unwrap(); + std::fs::create_dir_all(b.join("mod")).unwrap(); + std::fs::create_dir_all(&c).unwrap(); + let a_pom = "demoa1pomcore"; + let b_pom = "demob1pommod../A../C."; + let c_pom = "democ1../B"; + std::fs::write(a.join("pom.xml"), a_pom).unwrap(); + std::fs::write(a.join("core/pom.xml"), "core").unwrap(); + std::fs::write(b.join("pom.xml"), b_pom).unwrap(); + std::fs::write(b.join("mod/pom.xml"), "bmod").unwrap(); + std::fs::write(c.join("pom.xml"), c_pom).unwrap(); + let wrapper = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" }; + std::fs::write(a.join(wrapper), "#!/bin/sh\n").unwrap(); + std::fs::write(b.join(wrapper), "#!/bin/sh\n").unwrap(); + std::fs::write(base.join(wrapper), "#!/bin/sh\n").unwrap(); + + let discovered = load_with_linked(&a, None, &[], &[]); + assert_eq!(discovered.projects.iter().map(|p| p.artifact_id.as_str()).collect::>(), ["a", "core"]); + + let stored = vec!["../B/pom.xml".into()]; + let linked = load_with_linked(&a, None, &[], &stored); + let b_project = linked.projects.iter().find(|p| p.artifact_id == "b").unwrap(); + assert!(!b_project.aggregated); + assert_eq!(b_project.pom, "../B/pom.xml"); + assert_eq!(dunce::canonicalize(a.join(&b_project.dir)).unwrap(), b); + assert_eq!(dunce::canonicalize(&b_project.command).unwrap(), dunce::canonicalize(b.join(wrapper)).unwrap()); + let a_project = linked.projects.iter().find(|p| p.artifact_id == "a").unwrap(); + assert!(!a_project.aggregated); + assert_eq!(a_project.modules, ["core/pom.xml"]); + assert_eq!(dunce::canonicalize(&a_project.command).unwrap(), dunce::canonicalize(a.join(wrapper)).unwrap()); + assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "a").count(), 1); + assert_eq!(linked.projects.iter().filter(|p| p.artifact_id == "b").count(), 1); + assert!(linked.projects.iter().any(|p| p.artifact_id == "c" && p.aggregated)); + assert!(linked.projects.iter().any(|p| p.artifact_id == "bmod" && p.aggregated)); + let again = load_with_linked(&a, None, &[], &stored); + assert_eq!( + again.projects.iter().map(|p| &p.pom).collect::>(), + linked.projects.iter().map(|p| &p.pom).collect::>() + ); + + let absolute = b.join("pom.xml").to_string_lossy().replace('\\', "/"); + let via_a = a.join("../B/pom.xml").to_string_lossy().replace('\\', "/"); + let normalized = normalize_linked_poms( + &a, + &[ + "../B/pom.xml".into(), + absolute, + via_a, + a.join("pom.xml").to_string_lossy().into_owned(), + b.to_string_lossy().into_owned(), + "stub/pom.xml".into(), + "notes.xml".into(), + ], + ); + assert_eq!(normalized.len(), 3, "{normalized:?}"); + assert_eq!(normalized[0], dunce::canonicalize(b.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/")); + assert_eq!(normalized[1], "pom.xml"); + assert_eq!(normalized[2], "stub/pom.xml"); + assert_eq!(crate::maven_external_roots(&a, &normalized), vec![b.clone()]); + assert_eq!(crate::maven_external_roots(&a, &stored), vec![b.clone()]); + + let removed = load_with_linked(&a, None, &[], &[]); + assert!(removed.projects.iter().all(|p| p.artifact_id != "b")); + assert_eq!(std::fs::read_to_string(a.join("pom.xml")).unwrap(), a_pom); + assert_eq!(std::fs::read_to_string(b.join("pom.xml")).unwrap(), b_pom); + assert_eq!(std::fs::read_to_string(c.join("pom.xml")).unwrap(), c_pom); + assert!(b.join("mod/pom.xml").is_file()); + } + + /// The launcher recorded on an external project is that project's wrapper, and + /// running it uses the external directory as the working directory. + #[cfg(unix)] + #[test] + fn external_project_wrapper_runs_in_its_own_directory() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let a = base.join("A"); + let b = base.join("B"); + std::fs::create_dir_all(b.join("src/main/java")).unwrap(); + std::fs::create_dir_all(&a).unwrap(); + std::fs::write(a.join("pom.xml"), "a").unwrap(); + std::fs::write( + b.join("pom.xml"), + "bsrc/main/java", + ) + .unwrap(); + std::fs::write(b.join("src/main/java/B.java"), "class B {}").unwrap(); + let script = b.join("mvnw"); + std::fs::write(&script, "#!/bin/sh\npwd > \"$1\"\nprintf 'BUILD SUCCESS\\n'\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let model = load_with_linked(&a, None, &[], &["../B/pom.xml".into()]); + let project = model.projects.iter().find(|p| p.artifact_id == "b").unwrap(); + let cwd = a.join(&project.dir); + assert_eq!(dunce::canonicalize(&cwd).unwrap(), b); + let marker = tmp.path().join("cwd"); + let out = crate::process::run( + &project.command, + &[marker.to_string_lossy().into_owned()], + &cwd, + &std::collections::BTreeMap::new(), + &ide_core::CancellationToken::new(), + |_, _| {}, + ) + .unwrap(); + assert!(out.success, "{}", out.text); + let recorded = dunce::canonicalize(std::fs::read_to_string(&marker).unwrap().trim()).unwrap(); + assert_eq!(recorded, b); + } + /// #2877: an aggregator in a subfolder lists the root POM as `../`; linked, it holds every /// module, the root one included, under the same POM paths the projects have. #[test] @@ -2182,6 +2441,16 @@ mod tests { for pom in &parent.modules { assert!(model.projects.iter().any(|p| &p.pom == pom), "{pom}"); } + + // A cycle (the root lists the aggregator, which lists the root) keeps the root on top. + write( + root, + "pom.xml", + "demoapp1.0pomparent", + ); + let model = load_with_linked(root, None, &[], &[]); + let tops: Vec<&str> = model.projects.iter().filter(|p| !p.aggregated).map(|p| p.artifact_id.as_str()).collect(); + assert_eq!(tops, ["app"]); } #[test] diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index bebe7f3ce..099a63dea 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -1182,7 +1182,9 @@ pub struct MavenSettings { pub runner_jre: String, /// 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. + /// POMs added as independent top-level projects. Inside the opened project the path is + /// project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking + /// does not edit either POM. pub linked_projects: Vec, /// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. pub home: String, diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 707763e9a..69cc0a9fb 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -164,10 +164,21 @@ pub fn set_settings( // A LightEdit window has no project layer: its values are global. let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light); let is_proxy = key == "proxy" || key.starts_with("proxy."); - let Some(value) = host.take_proxy_password(key, value)? else { + let Some(mut value) = host.take_proxy_password(key, value)? else { host.apply_proxy(); return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None }); }; + // One canonical spelling per linked POM, including a project outside the opened root. + if project_scope + && key == "languages.java.maven.linkedProjects" + && let Some(project) = current.as_ref() + && let Some(list) = value.as_array() + { + let poms: Vec = list.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect(); + if poms.len() == list.len() { + value = serde_json::json!(ide_build::maven::normalize_linked_poms(&project.root, &poms)); + } + } let settings = match ¤t { Some(p) if project_scope => p.settings.set(scope, key, value)?, Some(p) => { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index a2b27e9e3..ffdda5eee 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -142,6 +142,10 @@ impl Host { crate::plugins::injections::configure_lsp(&lsp); lsp.set_execution_allowed(trusted.load(Ordering::Acquire)); lsp.set_content_roots(extra_roots.clone()); + lsp.set_jdtls_import_roots(ide_build::maven_external_roots( + &root, + &settings.effective().languages.java.maven.linked_projects, + )); let settings = Arc::new(settings); { let settings = settings.clone(); @@ -1120,6 +1124,10 @@ impl Project { } let (roots, excludes) = (self.content_roots(), self.exclude_globs()); self.lsp.set_content_roots(roots.clone()); + self.lsp.set_jdtls_import_roots(ide_build::maven_external_roots( + &self.root, + &self.settings.effective().languages.java.maven.linked_projects, + )); self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone()); self.watcher.set_excludes(&excludes); let mut watchers = self.content_watchers.lock(); @@ -1239,8 +1247,9 @@ pub(crate) fn workspace_member_roots(root: &Path) -> Vec { .unwrap_or_default() } -/// Workspace members in manifest order, or extra Project Structure directories and the -/// Gradle project folders outside the project as of the last sync (`build_cache`). +/// Workspace members in manifest order, or extra Project Structure directories, Gradle +/// project folders outside the project, and directories of Maven POMs linked from +/// outside it (`build_cache` is the Gradle sync cache). fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec { if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) { if workspace.is_container(root) { @@ -1332,7 +1341,10 @@ fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, bui .iter() .chain(settings.project_structure.dependencies.iter()) .map(|r| PathBuf::from(r.trim())); - for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) { + for p in user + .chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) + .chain(ide_build::maven_external_roots(root, &settings.languages.java.maven.linked_projects)) + { if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) { out.push(p); } @@ -1768,6 +1780,26 @@ mod maven_linkage_tests { assert_eq!(super::jdtls_maven_import(root, &settings).1, None); } + #[test] + fn external_maven_link_is_a_content_root_and_not_its_sibling_module() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let a = base.join("A"); + let b = base.join("B"); + let c = base.join("C"); + for dir in [&a, &b, &c] { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("pom.xml"), "").unwrap(); + } + let mut settings = ide_core::settings::Settings::default(); + settings.languages.java.maven.linked_projects = vec!["../B/pom.xml".into()]; + assert_eq!(super::content_roots(&settings, &a, &base.join("cache")), vec![b]); + // External links must not replace jdtls's scan of the opened root with a build-file list. + assert_eq!(super::jdtls_maven_import(&a, &settings), (Vec::new(), None)); + settings.languages.java.maven.linked_projects.clear(); + assert!(super::content_roots(&settings, &a, &base.join("cache")).is_empty()); + } + #[test] fn unlinked_roots_remain_plain_files() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/tests/maven_external.rs b/crates/ide-host/tests/maven_external.rs new file mode 100644 index 000000000..e3aba47de --- /dev/null +++ b/crates/ide-host/tests/maven_external.rs @@ -0,0 +1,71 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use ide_workspace::settings::SettingsScope; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn external_maven_link_persists_reloads_and_unlink_keeps_files() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("A"); + let sibling = base.join("B"); + std::fs::create_dir_all(root.join("core")).unwrap(); + std::fs::create_dir_all(&sibling).unwrap(); + let a_pom = "apomcore"; + let b_pom = "b"; + std::fs::write(root.join("pom.xml"), a_pom).unwrap(); + std::fs::write(root.join("core/pom.xml"), "core").unwrap(); + std::fs::write(sibling.join("pom.xml"), b_pom).unwrap(); + let config = base.join("config"); + let host = Host::new(HostConfig { + config_dir: config.clone(), + data_dir: base.join("data"), + local_data_dir: base.join("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.open_project("main", &root).unwrap(); + host.set_project_trust(&root, true, false).await.unwrap(); + + let written = + serde_json::to_string(&vec!["../B/pom.xml".to_owned(), sibling.join("pom.xml").to_string_lossy().into_owned()]) + .unwrap(); + ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", &written) + .unwrap(); + let saved = + || ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.maven.linked_projects; + let canonical = dunce::canonicalize(sibling.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/"); + assert_eq!(saved(), vec![canonical.clone()]); + + let model = ops::build::build_model(&host, "main").await.unwrap(); + let projects = &model.maven.unwrap().projects; + assert_eq!(projects.iter().filter(|p| p.artifact_id == "a").count(), 1); + let b = projects.iter().find(|p| p.artifact_id == "b").unwrap(); + assert!(!b.aggregated); + assert_eq!(b.pom, canonical); + assert_eq!(dunce::canonicalize(root.join(&b.dir)).unwrap(), sibling); + assert_eq!(host.project("main").unwrap().content_roots(), vec![sibling.clone()]); + assert_eq!(host.project("main").unwrap().lsp.jdtls_import_roots(), vec![sibling.clone()]); + + let reloaded = ops::build::build_model(&host, "main").await.unwrap(); + assert_eq!( + reloaded.maven.unwrap().projects.iter().map(|p| p.pom.clone()).collect::>(), + projects.iter().map(|p| p.pom.clone()).collect::>() + ); + assert_eq!(saved(), vec![canonical]); + + ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", "[]") + .unwrap(); + let gone = ops::build::build_model(&host, "main").await.unwrap(); + assert!(gone.maven.unwrap().projects.iter().all(|p| p.artifact_id != "b")); + assert!(host.project("main").unwrap().content_roots().is_empty()); + assert!(host.project("main").unwrap().lsp.jdtls_import_roots().is_empty()); + assert_eq!(saved(), Vec::::new()); + assert_eq!(std::fs::read_to_string(root.join("pom.xml")).unwrap(), a_pom); + assert_eq!(std::fs::read_to_string(sibling.join("pom.xml")).unwrap(), b_pom); + host.shutdown().await; +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index eec3a2112..94220fe0c 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -201,6 +201,10 @@ pub struct LspManager { me: Weak, project_root: PathBuf, content_roots: RwLock>, + /// Maven projects linked from outside `project_root`. jdtls scans them as extra + /// workspace folders; `projectConfigurations` stays unset so the opened root is + /// still discovered automatically. + jdtls_import_roots: RwLock>, languages: Arc, sink: SharedSink, launcher: Arc, @@ -444,6 +448,7 @@ impl LspManager { me: me.clone(), project_root, content_roots: RwLock::new(Vec::new()), + jdtls_import_roots: RwLock::new(Vec::new()), languages, sink: { let me = me.clone(); @@ -503,6 +508,28 @@ impl LspManager { } } + /// Directories of Maven projects linked from outside the opened root. The jdtls + /// rooted at the opened project scans them together with that root. An empty list + /// leaves discovery as the single `rootUri` scan. + pub fn set_jdtls_import_roots(&self, mut roots: Vec) { + roots.retain(|root| root.is_absolute() && root.is_dir() && !root.starts_with(&self.project_root)); + roots.sort(); + roots.dedup(); + let changed = { + let mut current = self.jdtls_import_roots.write(); + let changed = *current != roots; + *current = roots; + changed + }; + if changed && let Some(me) = self.me.upgrade() { + me.reconfigure_soon(); + } + } + + pub fn jdtls_import_roots(&self) -> Vec { + self.jdtls_import_roots.read().clone() + } + /// Shares JDT LS within this manager; callers reconfigure after the hook's value changes. pub fn set_jdtls_shared_hook(&self, hook: Box) { *self.jdtls_shared_hook.write() = Some(Arc::from(hook)); @@ -537,10 +564,29 @@ impl LspManager { if state.shared { return state.roots.clone(); } + // The opened project's server also scans linked external Maven projects. + // Other in-root builds keep their own server and their own root. + if server.root == self.project_root { + return self.project_and_import_roots(); + } } vec![server.root.clone()] } + fn project_and_import_roots(&self) -> Vec { + let mut roots = vec![self.project_root.clone()]; + for root in self.jdtls_import_roots.read().iter() { + if root.is_absolute() && root.is_dir() && !roots.contains(root) { + roots.push(root.clone()); + } + } + roots + } + + fn imports_external_java(&self, config: &LanguageServerConfig, path: &Path) -> bool { + is_local_jdtls(config) && self.jdtls_import_roots.read().iter().any(|root| path.starts_with(root)) + } + fn project_root_for(&self, path: &Path) -> PathBuf { self.content_roots .read() @@ -557,7 +603,9 @@ impl LspManager { let mut servers = self.servers.lock(); let project = self.project_root_for(path); let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten(); - let root = if shared.as_ref().is_some_and(|state| state.shared) { + let root = if shared.as_ref().is_some_and(|state| state.shared) || self.imports_external_java(cfg, path) { + // One server for the opened root and a linked external Maven project, so + // Java completion and navigation see the import jdtls made of both. self.project_root.clone() } else { sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| { @@ -953,13 +1001,15 @@ impl LspManager { } init_options["settings"] = handler::all_settings(&cfg); let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared; - if shared { - init_options["workspaceFolders"] = json!( - self.server_workspace_roots(server) - .iter() - .map(|root| uri::path_to_uri_string(root)) - .collect::>() - ); + // jdtls imports `initializationOptions.workspaceFolders` by scanning each + // directory. It ignores the LSP workspace folder list for that first import, + // and a `projectConfigurations` list replaces the scan. Extra Maven projects + // are therefore folders here, not an explicit build-file list, so projects + // under the opened root are still discovered. + let folders: Vec = + self.server_workspace_roots(server).iter().map(|root| uri::path_to_uri_string(root)).collect(); + if shared || folders.len() > 1 { + init_options["workspaceFolders"] = json!(folders); } let triggers: Vec<_> = self .docs diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 901ce989d..14f3b1c85 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -759,6 +759,40 @@ async fn java_classpath_does_not_retry_permanently_unavailable_servers() { } } +#[tokio::test(flavor = "multi_thread")] +async fn external_maven_import_joins_the_opened_root_without_replacing_discovery() { + let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); + let external = tempfile::tempdir().unwrap(); + let external = dunce::canonicalize(external.path()).unwrap(); + std::fs::write(external.join("pom.xml"), "b").unwrap(); + let outside = external.join("src/Main.java"); + std::fs::create_dir_all(outside.parent().unwrap()).unwrap(); + std::fs::write(&outside, "class External {}").unwrap(); + let inside = h.root.join("src/App.java"); + std::fs::create_dir_all(inside.parent().unwrap()).unwrap(); + std::fs::write(&inside, "class App {}").unwrap(); + // An in-root build with its own marker keeps a separate server. + let nested = h.root.join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join("mock.toml"), "").unwrap(); + let nested_file = nested.join("Nested.java"); + std::fs::write(&nested_file, "class Nested {}").unwrap(); + + h.mgr.set_jdtls_import_roots(vec![external.clone()]); + h.mgr.did_open(DocId::next(), &inside, &h.lang, 1, "class App {}"); + h.mgr.did_open(DocId::next(), &outside, &h.lang, 1, "class External {}"); + h.mgr.did_open(DocId::next(), &nested_file, &h.lang, 1, "class Nested {}"); + let state = h.launcher.state("jdtls"); + eventually(|| state.requested("initialize").len() == 2).await; + let inits = state.requested("initialize"); + let project = inits.iter().find(|init| init["rootUri"] == uri_of(&h.root)).unwrap(); + assert_eq!(project["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)])); + assert!(project["initializationOptions"].get("projectConfigurations").is_none()); + assert!(state.requested("initialize").iter().any(|init| init["rootUri"] == uri_of(&nested))); + assert_eq!(h.mgr.servers().len(), 2); + h.mgr.shutdown_all().await; +} + #[tokio::test(flavor = "multi_thread")] async fn shared_java_external_roots_reach_initialization_queries_and_watches() { let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]); diff --git a/src/bindings.ts b/src/bindings.ts index 78079a07c..1cf7c7a6d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -6849,7 +6849,11 @@ export type MavenProject = { profiles: string[], /** Java feature version the project compiles for (`maven.compiler.release`, `java.version`…). */ javaVersion: number | null, - /** Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`. */ + /** + * Maven launcher: the nearest `mvnw` (absolute), else `mvn`. Inside the opened + * project the search stops at its root; outside it, the search stops before + * that root so a linked project keeps its own wrapper. + */ command: string, /** Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings. */ options: string[], @@ -6863,7 +6867,11 @@ export type MavenSettings = { runnerJre?: string, /** 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. */ + /** + * POMs added as independent top-level projects. Inside the opened project the path is + * project-relative (`tools/pom.xml`); outside it, the canonical absolute path. Linking + * does not edit either POM. + */ linkedProjects?: string[], /** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */ home?: string, diff --git a/src/features/build-tools/actions.test.tsx b/src/features/build-tools/actions.test.tsx index 229e40bfe..4495fbd15 100644 --- a/src/features/build-tools/actions.test.tsx +++ b/src/features/build-tools/actions.test.tsx @@ -80,6 +80,7 @@ describe("build tools feature", () => { "maven.analyzeDependencies", "maven.executeGoal", "maven.generateSources", + "maven.importExternal", "maven.reload", "maven.settings", "maven.toggleOffline", diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 491e8232b..dce5263a1 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -35,6 +35,7 @@ import { import { ANALYZER_KIND, addProjects, + importModuleFromExistingSources, linkGradleProjects, unlinkGradleProjects, requestSync, @@ -280,11 +281,24 @@ export function activate(): () => void { { id: "maven.addProjects", title: "Add Maven Projects", + description: + "Choose one or more pom.xml files inside or outside the opened project. The opened folder stays the project root.", category: "Maven", icon: "plus", when: "projectOpen && !workspaceOpen", run: () => addProjects(), }, + { + id: "maven.importExternal", + title: "Import Module from Existing Sources", + description: + "Choose a module directory outside the opened project. If it contains pom.xml, that Maven project is attached. The opened folder stays the project root, and neither POM is edited.", + category: "Maven", + icon: "plus", + when: "projectOpen && !workspaceOpen", + keywords: ["import", "module", "existing", "sources", "external", "maven", "pom"], + run: () => importModuleFromExistingSources(), + }, { id: "maven.unlinkProjects", title: "Unlink Maven Projects", diff --git a/src/features/build-tools/linkage.test.tsx b/src/features/build-tools/linkage.test.tsx index 41da6c6fa..b3eac09a5 100644 --- a/src/features/build-tools/linkage.test.tsx +++ b/src/features/build-tools/linkage.test.tsx @@ -10,7 +10,7 @@ import { GradleView, MavenView } from "./BuildToolView"; import { activate } from "./index"; import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model"; import { executeBuildLine } from "./runAnything"; -import { useBuild } from "./store"; +import { classifyPickedPoms, mavenPomForImport, useBuild } from "./store"; const mocks = vi.hoisted(() => ({ get: vi.fn(), @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ model: vi.fn(), configs: vi.fn(), open: vi.fn(), + readDir: vi.fn(), confirm: vi.fn(), menu: vi.fn(), link: vi.fn(), @@ -41,6 +42,7 @@ vi.mock("@/platform/ipc", async (original) => { gradleLinkProjects: mocks.link, gradleUnlinkProjects: mocks.unlink, buildSync: mocks.sync, + fsReadDir: (path: string) => mocks.readDir(path), cancelRequest: () => Promise.resolve(), }, }; @@ -241,8 +243,8 @@ it("re-adds a picked POM using the plus button and preserves other unlinked root ); }); -it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/p/a/pom.xml"])( - "ignores cancelled, invalid, outside or already linked picks: %s", +it.each([null, "/p/a/build.xml", "/p/a/pom.xml", "not-a-file"])( + "ignores cancelled, invalid or already linked picks: %s", async (picked) => { mocks.open.mockResolvedValue(picked); await executeAction("maven.addProjects"); @@ -250,6 +252,76 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/ }, ); +it("imports a module directory from Project Structure when that directory contains pom.xml", async () => { + mocks.get.mockResolvedValue({ + languages: { java: { maven: { unlinkedProjects: [], linkedProjects: [] } } }, + }); + mocks.open.mockResolvedValue("/B"); + mocks.readDir.mockResolvedValue([ + { + name: "pom.xml", + path: "/B/pom.xml", + kind: "file", + size: 1, + mtimeMs: 1, + ignored: false, + excluded: false, + hidden: false, + }, + ]); + await act(async () => { + await executeAction("maven.importExternal"); + }); + expect(mocks.show).toHaveBeenCalledWith("maven"); + expect(mocks.open).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Import Module from Existing Sources", + directory: true, + defaultPath: "/p", + }), + ); + expect(mocks.readDir).toHaveBeenCalledWith("/B"); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", '["/B/pom.xml"]'); +}); + +it("does not link a module directory that has no pom.xml", async () => { + mocks.open.mockResolvedValue("/B"); + mocks.readDir.mockResolvedValue([]); + await act(async () => { + await executeAction("maven.importExternal"); + }); + expect(mocks.set).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: "warning", + title: "Maven project was not imported", + body: expect.stringContaining("pom.xml"), + }), + ); +}); + +it("maps a module directory or a pom.xml onto the same link", () => { + expect(mavenPomForImport("/code/B")).toBe("/code/B/pom.xml"); + expect(mavenPomForImport("/code/B/pom.xml")).toBe("/code/B/pom.xml"); +}); + +it("links a sibling pom.xml outside the opened project and collapses duplicate spellings", async () => { + mocks.get.mockResolvedValue({ + languages: { java: { maven: { unlinkedProjects: [], linkedProjects: [] } } }, + }); + mocks.open.mockResolvedValue(["/p/../B/pom.xml", "/B/pom.xml", "/other/notes.xml"]); + await act(async () => { + await executeAction("maven.addProjects"); + }); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", '["/B/pom.xml"]'); + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: "warning", + body: expect.stringContaining("/other/notes.xml"), + }), + ); +}); + it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () => { const os = usePlatform.getState().os; usePlatform.setState({ os: "windows" }); @@ -272,8 +344,8 @@ it("links `\\\\?\\`-prefixed and differently-cased picks on Windows", async () = } }); -it("warns about picks outside the project instead of silently doing nothing", async () => { - mocks.open.mockResolvedValue("/other/pom.xml"); +it("warns about a pick that is not a pom.xml instead of silently doing nothing", async () => { + mocks.open.mockResolvedValue("/other/readme.xml"); await act(async () => { await executeAction("maven.addProjects"); }); @@ -282,11 +354,31 @@ it("warns about picks outside the project instead of silently doing nothing", as expect.objectContaining({ level: "warning", title: expect.stringContaining("not added"), - body: expect.stringContaining("/other/pom.xml"), + body: expect.stringContaining("/other/readme.xml"), }), ); }); +it("classifies inside, sibling and lexical paths without a filesystem walk", () => { + expect(classifyPickedPoms("/p", ["/p/a/../stub/pom.xml", "/p/../B/pom.xml", "/B/pom.xml"])).toEqual({ + link: ["stub/pom.xml", "/B/pom.xml"], + rejected: [], + }); + expect(classifyPickedPoms("/p", ["/p/a/build.xml"])).toEqual({ link: [], rejected: ["/p/a/build.xml"] }); + const os = usePlatform.getState().os; + usePlatform.setState({ os: "windows" }); + try { + expect( + classifyPickedPoms("C:\\Projects\\demo", [ + "\\\\?\\C:\\Projects\\demo\\..\\B\\pom.xml", + "c:/Projects/demo/svc/pom.xml", + ]), + ).toEqual({ link: ["C:/Projects/B/pom.xml", "svc/pom.xml"], rejected: [] }); + } finally { + usePlatform.setState({ os }); + } +}); + 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"] } } }, @@ -304,6 +396,27 @@ it("links picked POMs that no reactor lists as independent top-level projects", ); }); +it("unlinking an external project removes the link and does not delete it from disk", async () => { + mocks.get.mockResolvedValue({ languages: { java: { maven: { linkedProjects: ["/B/pom.xml"] } } } }); + const external = { + ...model.maven!.projects[0]!, + pom: "/B/pom.xml", + dir: "/B", + artifactId: "b", + displayName: "project-b", + }; + const linked: BuildModel = { ...model, maven: { ...model.maven!, projects: [...model.maven!.projects, external] } }; + mocks.model.mockResolvedValue(linked); + await act(async () => { + useBuild.setState({ model: linked }); + }); + await act(async () => { + await executeAction("maven.unlinkProjects", { pom: "/B/pom.xml" }); + }); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.linkedProjects", "[]"); + expect(mocks.set).not.toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", expect.anything()); +}); + 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 () => { diff --git a/src/features/build-tools/model.test.ts b/src/features/build-tools/model.test.ts index 913fbf250..5d9368605 100644 --- a/src/features/build-tools/model.test.ts +++ b/src/features/build-tools/model.test.ts @@ -228,6 +228,19 @@ describe("Maven runs", () => { }); expect(root.cwd).toBeNull(); expect(root.isTest).toBe(false); + const external = mavenGoalConfig( + project({ + pom: "/code/B/pom.xml", + dir: "/code/B", + artifactId: "b", + displayName: "b", + command: "/code/B/mvnw", + }), + ["compile"], + maven, + { offline: false, skipTests: false, profiles: {} }, + ); + expect(external).toMatchObject({ command: "/code/B/mvnw", cwd: "/code/B", args: ["compile"] }); const configured = { ...core, command: "//?/UNC/server/share/maven/bin/mvn.cmd", diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 1d58d3292..adbb061b8 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -305,40 +305,73 @@ function comparablePath(p: string) { return slashes.replace(/^\/\/\?\//, ""); } -/** - * Splits `picked` into project-relative `pom.xml` paths inside `root` and the rest - * (outside the project, not a `pom.xml`, or escaping via `.`/`..`). Drive-letter - * case and `\\?\` prefixes must not matter on Windows, where the dialog may hand - * back either form (#2626). - */ -function projectPoms(root: string, picked: readonly string[]): { inside: string[]; outside: string[] } { - const base = comparablePath(root).replace(/\/$/, ""); - const ignoreCase = hostOs() === "windows"; - const inside: string[] = []; - const outside: string[] = []; - for (const p of picked) { - const path = comparablePath(p); - const underRoot = ignoreCase - ? path.slice(0, base.length + 1).toLowerCase() === `${base.toLowerCase()}/` - : path.startsWith(`${base}/`); - if (!underRoot) { - outside.push(p); - continue; - } - const pom = path.slice(base.length + 1); - const parts = pom.split("/"); - if (parts.some((part) => part === ".." || part === ".") || parts.pop() !== "pom.xml") outside.push(p); - else inside.push(pom); - } - return { inside, outside }; +function isPomFileName(path: string): boolean { + const name = path.split("/").pop() ?? ""; + return hostOs() === "windows" ? name.toLowerCase() === "pom.xml" : name === "pom.xml"; } -export async function addProjects() { +/** Lexical `.` / `..` collapse. A drive letter stays the first component. */ +function lexicalPath(path: string): string { + const unc = path.startsWith("//"); + const rooted = path.startsWith("/"); + const drive = /^[A-Za-z]:\//.test(path); + const parts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + const top = parts[parts.length - 1]; + if (top && top !== ".." && !(drive && parts.length === 1)) parts.pop(); + else if (!rooted && !drive) parts.push(".."); + continue; + } + parts.push(part); + } + if (unc) return `//${parts.join("/")}`; + if (rooted) return `/${parts.join("/")}`; + return parts.join("/"); +} + +function isInsideRoot(root: string, path: string): boolean { + const prefix = `${root}/`; + return hostOs() === "windows" ? path.toLowerCase().startsWith(prefix.toLowerCase()) : path.startsWith(prefix); +} + +/** + * POMs to link, and picks to reject. Inside the opened project the link is + * project-relative; outside it the link is the absolute `pom.xml`. `.` and `..` + * collapse first, so `root/a/../b/pom.xml` is `b/pom.xml` and `root/../B/pom.xml` + * is that sibling. The backend canonicalizes spellings of the same file. A pick + * that is not a `pom.xml` is rejected. Drive-letter case and `\\?\` prefixes must + * not matter on Windows (#2626). + */ +export function classifyPickedPoms(root: string, picked: readonly string[]): { link: string[]; rejected: string[] } { + const base = lexicalPath(comparablePath(root).replace(/\/$/, "")); + const link: string[] = []; + const rejected: string[] = []; + const seen = new Set(); + for (const raw of picked) { + const path = lexicalPath(comparablePath(raw)); + const absolute = path.startsWith("/") || path.startsWith("//") || /^[A-Za-z]:\//.test(path); + if (!isPomFileName(path) || (!isInsideRoot(base, path) && !absolute)) { + rejected.push(raw); + continue; + } + const key = isInsideRoot(base, path) ? path.slice(base.length + 1) : path; + const id = hostOs() === "windows" ? key.toLowerCase() : key; + if (seen.has(id)) continue; + seen.add(id); + link.push(key); + } + return { link, rejected }; +} + +/** Choose `pom.xml` files and link them. `dialogTitle` is already translated. */ +export async function addProjects(dialogTitle?: string) { const root = useProject.getState().project?.root; if (!root || useProject.getState().project?.workspace || !isTauri()) return; const { open } = await import("@/platform/dialog"); const picked = await open({ - title: tx`Add Maven Projects`, + title: dialogTitle ?? tx`Add Maven Projects`, directory: false, multiple: true, defaultPath: root, @@ -346,19 +379,90 @@ export async function addProjects() { }); const paths = typeof picked === "string" ? [picked] : (picked ?? []); if (!paths.length) return; - const { inside: poms, outside } = projectPoms(root, paths); - if (poms.length) await setLinked(root, poms, true); + const { link, rejected } = classifyPickedPoms(root, paths); + if (link.length) await setLinked(root, link, true); // Dropping every pick silently looks exactly like the button doing nothing (#2626). - if (outside.length) + if (rejected.length) notifications().notify({ group: "tools", level: "warning", title: tx`Some Maven projects were not added`, - body: tx`These are outside the open project, and Maven only links POMs inside it: ${outside.slice(0, 3).join(", ")}${outside.length > 3 ? ` (+${outside.length - 3} more)` : ""}`, + body: tx`These files are not named pom.xml, so they were not added: ${rejected.slice(0, 3).join(", ")}${rejected.length > 3 ? ` (+${rejected.length - 3} more)` : ""}`, key: "maven-add-outside-root", }); } +/** `pom.xml` for a module directory, or the path itself when it already names that file. */ +export function mavenPomForImport(picked: string): string { + const path = lexicalPath(comparablePath(picked)).replace(/\/$/, ""); + return isPomFileName(path) ? path : `${path}/pom.xml`; +} + +/** + * Project Structure › Modules › Import Module from Existing Sources. + * A directory is the module folder; a `pom.xml` is that file. Either way the + * opened folder stays the project root and the existing link pipeline stores the POM. + */ +export async function importModuleFromExistingSources() { + const root = useProject.getState().project?.root; + if (!root || useProject.getState().project?.workspace || !isTauri()) return; + try { + const { toolWindowService } = await import("@/workbench/contributions"); + toolWindowService().show("maven"); + } catch { + /* Tests and hosts without the tool-window service still link the POM. */ + } + const { open } = await import("@/platform/dialog"); + const picked = await open({ + title: tx`Import Module from Existing Sources`, + directory: true, + multiple: false, + defaultPath: root, + }); + const path = typeof picked === "string" ? picked : picked?.[0]; + if (!path) return; + const pom = mavenPomForImport(path); + if (!(await directoryListsPom(pom))) { + notifications().notify({ + group: "tools", + level: "warning", + title: tx`Maven project was not imported`, + body: tx`This directory has no pom.xml, so it was not imported.`, + key: "maven-import-no-pom", + }); + return; + } + const { link, rejected } = classifyPickedPoms(root, [pom]); + if (link.length) await setLinked(root, link, true); + if (rejected.length) + notifications().notify({ + group: "tools", + level: "warning", + title: tx`Some Maven projects were not added`, + body: tx`These files are not named pom.xml, so they were not added: ${rejected.slice(0, 3).join(", ")}${rejected.length > 3 ? ` (+${rejected.length - 3} more)` : ""}`, + key: "maven-add-outside-root", + }); +} + +/** One directory listing: the selection's own `pom.xml`, not a scan of its children. */ +async function directoryListsPom(pom: string): Promise { + const slash = pom.lastIndexOf("/"); + const dir = slash > 0 ? pom.slice(0, slash) : slash === 0 ? "/" : ""; + const name = pom.slice(slash + 1); + if (!dir) return false; + try { + const { call, commands } = await import("@/platform/ipc"); + const entries = await call(commands.fsReadDir(dir)); + return entries.some( + (entry) => + (hostOs() === "windows" ? entry.name.toLowerCase() === name.toLowerCase() : entry.name === name) && + (entry.kind === "file" || (entry.kind === "symlink" && entry.targetKind === "file")), + ); + } catch { + return false; + } +} + /** Links independent Gradle builds without changing their files or build scripts. */ export async function linkGradleProjects() { const gen = generation; diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 91d87eb65..6e4b37cad 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -305,6 +305,7 @@ const Row = memo(function Row({ className={clsx(styles.content, cut && styles.cut, dropTarget && styles.dropTarget)} data-project-path={node.placeholder ? undefined : node.path} data-project-key={node.key} + data-project-icon={icon} data-dir={node.dir || undefined} title={deco.tooltip ?? hint ?? undefined} onClick={(e) => onClick(node, e)} diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index b4bd46377..f167dc1b2 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -213,6 +213,20 @@ describe("project view context menu", () => { expect(l).not.toContain(fileOnly); }); + it("offers Import Module from Existing Sources on the project root only", () => { + const off = registerActions([ + { id: "maven.importExternal", title: "Import Module from Existing Sources", run: () => undefined }, + ]); + try { + expect(labels(contextMenuItems([node(ROOT, true, true)]))).toContain("Import Module from Existing Sources"); + expect(labels(contextMenuItems([node(`${ROOT}/src`, true)]))).not.toContain( + "Import Module from Existing Sources", + ); + } finally { + off(); + } + }); + it("keeps the project root menu short and disables what cannot apply", () => { const items = contextMenuItems([node(ROOT, true, true)]); const l = labels(items); diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index 4dc61991a..b3bab2c1a 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -170,7 +170,11 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { ); } - push({ id: "new", label: "New", submenu: newMenuItems(folder) }, { kind: "separator" }); + push( + { id: "new", label: "New", submenu: newMenuItems(folder) }, + single?.root && act("maven.importExternal", undefined, "Import Module from Existing Sources"), + { kind: "separator" }, + ); // Cut / Copy / Copy Path/Reference… / Paste push( diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index 6e5769d96..e67295df5 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -18,6 +18,7 @@ import { useProjectViewRoots, } from "@/workbench/contributions"; import { registerProjectActions } from "./actions"; +import { mavenExternalRoots } from "./mavenRoots"; import { gradleModuleGroups } from "./moduleGroups"; import { FileRefactorDialogHost } from "./FileRefactorDialogs"; import { NewPopupHost } from "./NewPopup"; @@ -126,7 +127,11 @@ export function activate(): () => void { gradleModuleGroups(g.projects, g.projects.find((p) => p.path === ":")?.name ?? "", project.root, g.dir), ) : []; - const key = JSON.stringify([paths, groups]); + const mavenRoots = + own && project && build.root === project.root + ? mavenExternalRoots(build.model?.maven?.projects ?? [], project.root) + : []; + const key = JSON.stringify([paths, groups, mavenRoots]); if (key === shownRoots) return; shownRoots = key; contentRoots.forEach((d) => d()); @@ -140,6 +145,18 @@ export function activate(): () => void { order: -100 + i, }), ); + mavenRoots.forEach((root, i) => + contentRoots.push( + registerProjectViewRoot({ + id: root.id, + label: root.label, + detail: root.detail, + icon: "maven-project", + path: root.path, + order: -50 + i, + }), + ), + ); groups.forEach((g, i) => contentRoots.push(registerProjectViewRoot({ ...g, id: g.id!, order: -200 + i, beforeProject: true })), ); diff --git a/src/features/project-tree/mavenRootIcon.test.tsx b/src/features/project-tree/mavenRootIcon.test.tsx new file mode 100644 index 000000000..f26ac1a09 --- /dev/null +++ b/src/features/project-tree/mavenRootIcon.test.tsx @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor, within } from "@testing-library/react"; +import type { BuildModel } from "@/platform/ipc"; +import { useBuildModel } from "@/platform/buildModel"; +import { useProject } from "@/platform/project"; +import { provideEditorService, useProjectViewRoots, type EditorService } from "@/workbench/contributions"; +import { activate } from "./index"; +import { MemoryFs, setProjectFsForTests } from "./fs"; +import { ProjectView } from "./ProjectView"; + +const backend = vi.hoisted(() => ({ + model: null as BuildModel | null, +})); + +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + buildModel: () => Promise.resolve({ status: "ok", data: backend.model }), + projectRoots: () => Promise.resolve({ status: "ok", data: [] }), + projectCurrent: () => Promise.resolve({ status: "ok", data: null }), + fsReadDir: () => Promise.resolve({ status: "ok", data: [] }), + }, + }; +}); +vi.mock("@tauri-apps/api/path", () => ({ homeDir: async () => "/home/me" })); +vi.mock("@tauri-apps/api/webview", () => ({ + getCurrentWebview: () => ({ onDragDropEvent: async () => () => {} }), +})); + +const ROOT = "/code/erms"; +const LINKED = ["/code/cloud-auth-common", "/code/cloud-auth-server", "/code/cloud-auth-gateway"]; +const NOTES = "/data/notes"; + +function mavenModel(dirs: string[]): BuildModel { + return { + gradle: [], + maven: { + profiles: [], + syncedAt: 1, + stale: false, + syncError: null, + projects: dirs.map((dir) => ({ + pom: dir ? `${dir}/pom.xml` : "pom.xml", + dir, + groupId: "example", + artifactId: dir ? dir.slice(dir.lastIndexOf("/") + 1) : "erms", + version: "1", + packaging: "jar", + displayName: dir ? dir.slice(dir.lastIndexOf("/") + 1) : "erms", + parent: null, + modules: [], + aggregated: false, + plugins: [], + dependencies: [], + profiles: [], + javaVersion: null, + command: "mvn", + options: [], + env: {}, + })), + }, + }; +} + +let dispose: () => void = () => {}; + +beforeEach(() => { + localStorage.clear(); + backend.model = mavenModel(["", ...LINKED]); + setProjectFsForTests( + new MemoryFs( + { + [`${ROOT}/pom.xml`]: "x", + [`${NOTES}/readme.txt`]: "x", + ...Object.fromEntries(LINKED.map((dir) => [`${dir}/pom.xml`, "x"])), + }, + ROOT, + ), + ROOT, + ); + provideEditorService({ openFile: async () => {}, activePath: () => null } as unknown as EditorService); + useProject.setState({ + project: { name: "erms", root: ROOT, trusted: true } as never, + settings: { projectStructure: { contentRoots: [NOTES] } } as never, + }); + useBuildModel.setState({ root: ROOT, model: backend.model, gradleOffline: false }); + dispose = activate(); +}); + +afterEach(() => { + dispose(); + useProjectViewRoots.setState({ roots: new Map() }); + useBuildModel.setState({ root: null, model: null, gradleOffline: false }); + useProject.setState({ project: null, settings: null }); + setProjectFsForTests(null); +}); + +const iconOf = (path: string) => + document.querySelector(`[data-project-path="${path}"]`)?.getAttribute("data-project-icon"); + +describe("external Maven roots in the project tree", () => { + it("uses the Maven project icon for linked roots and drops them when they are unlinked", async () => { + render(); + const tree = screen.getByRole("tree", { name: "Project" }); + await waitFor(() => expect(iconOf(ROOT)).toBe("maven-project")); + for (const path of LINKED) { + expect(within(tree).getByText(path.slice(path.lastIndexOf("/") + 1))).toBeInTheDocument(); + expect(iconOf(path)).toBe("maven-project"); + } + // A plain content root stays a folder. It is not a Maven project. + expect(iconOf(NOTES)).toBe("module"); + + backend.model = mavenModel([""]); + act(() => useBuildModel.setState({ root: ROOT, model: backend.model, gradleOffline: false })); + await waitFor(() => expect(document.querySelector(`[data-project-path="${LINKED[0]}"]`)).toBeNull()); + for (const path of LINKED) expect(document.querySelector(`[data-project-path="${path}"]`)).toBeNull(); + expect(iconOf(ROOT)).toBe("maven-project"); + expect(iconOf(NOTES)).toBe("module"); + }); +}); diff --git a/src/features/project-tree/mavenRoots.test.ts b/src/features/project-tree/mavenRoots.test.ts new file mode 100644 index 000000000..c8c993e08 --- /dev/null +++ b/src/features/project-tree/mavenRoots.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { mavenExternalRoots, resolveMavenDir } from "./mavenRoots"; + +it("resolves a sibling directory and keeps an absolute one", () => { + expect(resolveMavenDir("/code/A", "../B")).toBe("/code/B"); + expect(resolveMavenDir("/code/A", "/code/B")).toBe("/code/B"); + expect(resolveMavenDir("/code/A", "core")).toBe("/code/A/core"); +}); + +it("lists an external Maven project and not a module nested inside it", () => { + const roots = mavenExternalRoots( + [ + { dir: "", aggregated: false }, + { dir: "core", aggregated: true }, + { dir: "/code/B", aggregated: false }, + { dir: "/code/B/mod", aggregated: true }, + { dir: "/code/B/mod", aggregated: false }, + ], + "/code/A", + ); + expect(roots).toEqual([{ id: "mavenExternal:/code/B", label: "B", path: "/code/B", detail: "/code/B" }]); +}); diff --git a/src/features/project-tree/mavenRoots.ts b/src/features/project-tree/mavenRoots.ts new file mode 100644 index 000000000..8df2e3b80 --- /dev/null +++ b/src/features/project-tree/mavenRoots.ts @@ -0,0 +1,61 @@ +/** Project-view roots for Maven projects linked from outside the opened folder. */ +import { ipcPath, isSameOrInside } from "@/platform/paths"; + +export interface MavenExternalRoot { + id: string; + label: string; + path: string; + detail: string; +} + +const absolute = (path: string) => path.startsWith("/") || path.startsWith("//") || /^[A-Za-z]:\//.test(path); + +/** `dir` against `projectRoot`, with `.` and `..` collapsed. Absolute paths stay absolute. */ +export function resolveMavenDir(projectRoot: string, dir: string): string { + const root = ipcPath(projectRoot).replace(/\/$/, ""); + const raw = ipcPath(dir); + return lexical(absolute(raw) ? raw : `${root}/${raw}`); +} + +function lexical(path: string): string { + const unc = path.startsWith("//"); + const rooted = path.startsWith("/"); + const drive = /^[A-Za-z]:\//.test(path); + const parts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + const top = parts[parts.length - 1]; + if (top && top !== ".." && !(drive && parts.length === 1)) parts.pop(); + else if (!rooted && !drive) parts.push(".."); + continue; + } + parts.push(part); + } + if (unc) return `//${parts.join("/")}`; + if (rooted) return `/${parts.join("/")}`; + return parts.join("/"); +} + +/** + * Top-level Maven projects whose directory is outside `projectRoot`. A module + * nested in another linked directory is left to that directory's listing. + */ +export function mavenExternalRoots( + projects: readonly { dir: string; aggregated: boolean }[], + projectRoot: string, +): MavenExternalRoot[] { + const root = ipcPath(projectRoot).replace(/\/$/, ""); + const items = projects + .filter((project) => !project.aggregated) + .map((project) => resolveMavenDir(root, project.dir)) + .filter((path) => path.length > 0 && !isSameOrInside(path, root)); + const top = items.filter((path) => !items.some((other) => other !== path && isSameOrInside(path, other))); + top.sort((a, b) => a.localeCompare(b)); + return top.map((path) => ({ + id: `mavenExternal:${path}`, + label: path.slice(path.lastIndexOf("/") + 1) || path, + path, + detail: path, + })); +} diff --git a/src/features/settings/__tests__/moduleOverrides.test.tsx b/src/features/settings/__tests__/moduleOverrides.test.tsx new file mode 100644 index 000000000..f21d7b9ca --- /dev/null +++ b/src/features/settings/__tests__/moduleOverrides.test.tsx @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import type { Settings } from "@/platform/ipc"; + +const open = vi.fn(); +const imported = vi.fn(); + +vi.mock("@/platform/dialog", () => ({ open: (...args: unknown[]) => open(...args) })); +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { + ...real.commands, + settingsSet: vi.fn(async () => ({ status: "ok", data: {} })), + settingsGet: vi.fn(async () => ({})), + settingsGetGlobal: vi.fn(async () => ({})), + }, + }; +}); + +import { registerAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import { useProject } from "@/platform/project"; +import { DEFAULT_SETTINGS } from "../model/schema"; +import { openProjectStructure, useSettingsDialog } from "../model/staging"; +import { ProjectStructureDialog } from "../languages/ProjectStructurePage"; + +let disposeAction: () => void = () => {}; + +beforeEach(() => { + open.mockReset(); + imported.mockReset(); + useProject.setState({ + settings: structuredClone(DEFAULT_SETTINGS) as Settings, + project: { name: "demo", root: "/project" } as never, + }); + setContext("projectOpen", true); + setContext("workspaceOpen", false); + disposeAction = registerAction({ + id: "maven.importExternal", + title: "Import Module from Existing Sources", + when: "projectOpen && !workspaceOpen", + run: () => imported(), + }); + render(); + act(() => openProjectStructure("modules")); +}); + +afterEach(() => { + disposeAction(); + act(() => useSettingsDialog.setState({ projectStructureOpen: false })); + useProject.setState({ project: null, settings: null }); + setContext("projectOpen", false); +}); + +describe("Project Structure module overrides", () => { + it("keeps an outside directory out of module overrides and names the import action", async () => { + open.mockResolvedValue("/sibling/B"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Add Module Overrides" })); + }); + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ directory: true, title: "Select Module Directory", defaultPath: "/project" }), + ); + const note = screen.getByText(/Module overrides only apply to a directory inside the opened project/); + expect(note).toHaveTextContent("Import Module from Existing Sources"); + expect(screen.queryByText("B")).not.toBeInTheDocument(); + expect(imported).not.toHaveBeenCalled(); + }); + + it("still stores an in-project directory as a module override", async () => { + open.mockResolvedValue("/project/mod"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Add Module Overrides" })); + }); + expect(screen.queryByText(/Module overrides only apply/)).not.toBeInTheDocument(); + expect(screen.getAllByText("mod").length).toBeGreaterThan(0); + }); + + it("imports an external Maven project from this page without the directory override picker", async () => { + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Import Module from Existing Sources" })); + }); + expect(imported).toHaveBeenCalledOnce(); + expect(open).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/settings/languages/ProjectStructurePage.tsx b/src/features/settings/languages/ProjectStructurePage.tsx index 115bb0d06..1f3176c3a 100644 --- a/src/features/settings/languages/ProjectStructurePage.tsx +++ b/src/features/settings/languages/ProjectStructurePage.tsx @@ -64,6 +64,7 @@ import { choosePath, detectSdks, useSdks, type SdkKind } from "./api"; import { DownloadJdkDialog } from "./DownloadJdkDialog"; import { SdkField, tildify, useDetected, useJdtlsJdk, useProjectSetting } from "./components"; import styles from "./Sdk.module.css"; +import { executeAction } from "@/platform/actions"; import { tx } from "@/platform/i18n"; export type ProjectStructureSectionId = @@ -861,7 +862,10 @@ function ModulesSection() {
{moduleError && (
- Select a module directory inside the project. + + Module overrides only apply to a directory inside the opened project. To import an existing module from + outside it, use Import Module from Existing Sources. +
)}
@@ -870,6 +874,9 @@ function ModulesSection() { +