mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
feat(maven): import external Maven modules from Project Structure (#2922)
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>
This commit is contained in:
@@ -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<PathBuf> {
|
||||
let dirs = maven::external_pom_dirs(root, linked);
|
||||
let owned: Vec<String> = 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,
|
||||
|
||||
+300
-31
@@ -106,7 +106,9 @@ pub struct MavenProject {
|
||||
pub profiles: Vec<String>,
|
||||
/// Java feature version the project compiles for (`maven.compiler.release`, `java.version`…).
|
||||
pub java_version: Option<u32>,
|
||||
/// 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<String>,
|
||||
@@ -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<PathBuf>, 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<PathBuf> {
|
||||
let base = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut out: Vec<PathBuf> = 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<PathBuf, String> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf>,
|
||||
@@ -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<PathBuf, Option<usize>> = 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
|
||||
// (`<module>../</module>` 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<String> = 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 = "<project><groupId>demo</groupId><artifactId>a</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module></modules></project>";
|
||||
let b_pom = "<project><groupId>demo</groupId><artifactId>b</artifactId><version>1</version><packaging>pom</packaging><modules><module>mod</module><module>../A</module><module>../C</module><module>.</module></modules></project>";
|
||||
let c_pom = "<project><groupId>demo</groupId><artifactId>c</artifactId><version>1</version><modules><module>../B</module></modules></project>";
|
||||
std::fs::write(a.join("pom.xml"), a_pom).unwrap();
|
||||
std::fs::write(a.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
|
||||
std::fs::write(b.join("pom.xml"), b_pom).unwrap();
|
||||
std::fs::write(b.join("mod/pom.xml"), "<project><artifactId>bmod</artifactId></project>").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::<Vec<_>>(), ["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::<Vec<_>>(),
|
||||
linked.projects.iter().map(|p| &p.pom).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
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"), "<project><artifactId>a</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
b.join("pom.xml"),
|
||||
"<project><artifactId>b</artifactId><build><sourceDirectory>src/main/java</sourceDirectory></build></project>",
|
||||
)
|
||||
.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",
|
||||
"<project><groupId>demo</groupId><artifactId>app</artifactId><version>1.0</version><packaging>pom</packaging><modules><module>parent</module></modules></project>",
|
||||
);
|
||||
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]
|
||||
|
||||
@@ -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<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.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
|
||||
@@ -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<String> = 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) => {
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
.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<PathBuf> {
|
||||
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"), "<project/>").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();
|
||||
|
||||
@@ -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 = "<project><artifactId>a</artifactId><packaging>pom</packaging><modules><module>core</module></modules></project>";
|
||||
let b_pom = "<project><artifactId>b</artifactId></project>";
|
||||
std::fs::write(root.join("pom.xml"), a_pom).unwrap();
|
||||
std::fs::write(root.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").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::<Vec<_>>(),
|
||||
projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
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::<String>::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;
|
||||
}
|
||||
@@ -201,6 +201,10 @@ pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
content_roots: RwLock<Vec<PathBuf>>,
|
||||
/// 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<Vec<PathBuf>>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
sink: SharedSink,
|
||||
launcher: Arc<dyn Launcher>,
|
||||
@@ -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<PathBuf>) {
|
||||
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<PathBuf> {
|
||||
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<JdtlsSharedFn>) {
|
||||
*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<PathBuf> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
// 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<String> =
|
||||
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
|
||||
|
||||
@@ -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"), "<project><artifactId>b</artifactId></project>").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())], &[]);
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
@@ -80,6 +80,7 @@ describe("build tools feature", () => {
|
||||
"maven.analyzeDependencies",
|
||||
"maven.executeGoal",
|
||||
"maven.generateSources",
|
||||
"maven.importExternal",
|
||||
"maven.reload",
|
||||
"maven.settings",
|
||||
"maven.toggleOffline",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string>();
|
||||
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<boolean> {
|
||||
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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })),
|
||||
);
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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(<ProjectView />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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" }]);
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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(<ProjectStructureDialog />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
<div className={styles.page}>
|
||||
{moduleError && (
|
||||
<div role="status" className={styles.note}>
|
||||
<T>Select a module directory inside the project.</T>
|
||||
<T>
|
||||
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.
|
||||
</T>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.splitLayout}>
|
||||
@@ -870,6 +874,9 @@ function ModulesSection() {
|
||||
<Button size="small" variant="ghost" icon="plus" onClick={() => void addModule()}>
|
||||
<T>Add Module Overrides</T>
|
||||
</Button>
|
||||
<Button size="small" variant="ghost" icon="plus" onClick={() => void executeAction("maven.importExternal")}>
|
||||
<T>Import Module from Existing Sources</T>
|
||||
</Button>
|
||||
<span className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="minus"
|
||||
|
||||
@@ -508,7 +508,8 @@
|
||||
"Custom test output paths are not supported yet. Maven and Gradle manage their own test output.": "Custom test output paths are not supported yet. Maven and Gradle manage their own test output.",
|
||||
"Custom artifact packaging is not supported yet. Build and deploy artifacts defined by Maven or Gradle.": "Custom artifact packaging is not supported yet. Build and deploy artifacts defined by Maven or Gradle.",
|
||||
"Add Module Overrides": "Add Module Overrides",
|
||||
"Select a module directory inside the project.": "Select a module directory inside the project.",
|
||||
"Import Module from Existing Sources": "Import Module from Existing Sources",
|
||||
"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.": "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.",
|
||||
"Directory for production classes, relative to the project root and inside this module.": "Directory for production classes, relative to the project root and inside this module.",
|
||||
"Scope controls Run/Debug only. JARs are also available to Java code assistance.": "Scope controls Run/Debug only. JARs are also available to Java code assistance.",
|
||||
"Binary JAR archives support Java code assistance and Run/Debug. Class folders are used by Run/Debug only.": "Binary JAR archives support Java code assistance and Run/Debug. Class folders are used by Run/Debug only.",
|
||||
|
||||
@@ -510,8 +510,13 @@
|
||||
"Editor and Preview": "编辑器和预览",
|
||||
"Preview Only": "仅预览",
|
||||
"Add Maven Projects": "添加 Maven 项目",
|
||||
"Choose one or more pom.xml files inside or outside the opened project. The opened folder stays the project root.": "选择打开项目之内或之外的一个或多个 pom.xml。打开的文件夹仍是项目根。",
|
||||
"Import Module from Existing Sources": "导入已有模块",
|
||||
"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.": "选择打开项目之外的模块目录。若其中有 pom.xml,则挂接该 Maven 项目。打开的文件夹仍是项目根,两个 POM 都不会被修改。",
|
||||
"Maven project was not imported": "未导入 Maven 项目",
|
||||
"This directory has no pom.xml, so it was not imported.": "此目录没有 pom.xml,因此未导入。",
|
||||
"Some Maven projects were not added": "部分 Maven 项目未能添加",
|
||||
"These are outside the open project, and Maven only links POMs inside it: {join}{more}": "这些项目位于已打开项目之外,而 Maven 仅链接项目内的 POM:{join}{more}",
|
||||
"These files are not named pom.xml, so they were not added: {join}{more}": "这些文件不叫 pom.xml,因此未添加:{join}{more}",
|
||||
"Unlink Maven Projects": "取消链接 Maven 项目",
|
||||
"Unlink Maven projects?": "取消链接 Maven 项目?",
|
||||
"Remove this project and its modules from Maven? Files will remain in the Project view.": "从 Maven 中移除此项目及其模块?文件将保留在项目视图中。",
|
||||
@@ -6916,7 +6921,7 @@
|
||||
"Custom test output paths are not supported yet. Maven and Gradle manage their own test output.": "暂不支持自定义测试输出路径。测试输出由 Maven 和 Gradle 管理。",
|
||||
"Custom artifact packaging is not supported yet. Build and deploy artifacts defined by Maven or Gradle.": "暂不支持自定义制品打包。请构建和部署 Maven 或 Gradle 定义的制品。",
|
||||
"Add Module Overrides": "添加模块覆盖设置",
|
||||
"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.": "模块覆盖设置只适用于当前打开项目内的目录。要从外部导入已有模块,请使用「导入已有模块」。",
|
||||
"Directory for production classes, relative to the project root and inside this module.": "生产类文件目录,相对于项目根目录,且必须位于当前模块内。",
|
||||
"Scope controls Run/Debug only. JARs are also available to Java code assistance.": "作用域仅控制运行和调试。JAR 库也可用于 Java 代码辅助。",
|
||||
"Binary JAR archives support Java code assistance and Run/Debug. Class folders are used by Run/Debug only.": "二进制 JAR 支持 Java 代码辅助、运行和调试。类文件夹仅用于运行和调试。",
|
||||
|
||||
Reference in New Issue
Block a user