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() { +