diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 2d4d0b92..857912ad 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -234,6 +234,9 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option< let Some(module) = module else { return Ok(tops.iter().map(|p| step(p, &[])).collect()) }; // The reactor root containing the module builds it with what it depends on (`-pl … -am`). let rel = rel_str(root, module); + if !m.projects.iter().any(|p| p.dir == rel) { + return Err(IdeError::not_found("no linked Maven project found for module")); + } let top = tops .iter() .filter(|t| t.dir.is_empty() || rel == t.dir || rel.starts_with(&format!("{}/", t.dir))) diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index 9e57b8b2..84783f3e 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -39,8 +39,19 @@ pub struct BuildModel { /// Static model of the builds under `root`, merged with the sync results cached in `cache_dir`. /// Parent POMs are looked up in `maven_repo` (the configured local repository), else the default one. pub fn load(root: &Path, cache_dir: &Path, maven_repo: Option) -> BuildModel { + load_linked(root, cache_dir, maven_repo, &ide_core::settings::MavenSettings::default()) +} + +/// Loads only linked Maven reactors, retaining an empty model so projects can be re-added. +pub fn load_linked( + root: &Path, + cache_dir: &Path, + maven_repo: Option, + settings: &ide_core::settings::MavenSettings, +) -> BuildModel { let maven = maven::is_maven_project(root).then(|| { let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo)); + maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m); sync::apply_maven_cache(root, cache_dir, &mut m); m }); diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index b5e29650..ae03adad 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -967,6 +967,67 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option) -> MavenM model } +/// Shared linkage decision for every Maven consumer, including modules outside a reactor directory. +#[derive(Default)] +pub struct UnlinkedProjects { + poms: std::collections::BTreeSet, +} + +impl UnlinkedProjects { + pub fn load(root: &Path, settings: &ide_core::settings::MavenSettings) -> Self { + if settings.unlinked_projects.is_empty() { + return Self::default(); + } + Self::from_model(&load(root), settings) + } + + pub fn from_model(model: &MavenModel, settings: &ide_core::settings::MavenSettings) -> Self { + let mut result = Self::default(); + let mut pending = settings.unlinked_projects.clone(); + while let Some(pom) = pending.pop() { + let pom = normalized_pom(&pom); + if !result.poms.insert(pom.clone()) { + continue; + } + if let Some(project) = model.projects.iter().find(|p| normalized_pom(&p.pom) == pom) { + pending.extend(project.modules.iter().cloned()); + } + } + result + } + + pub fn contains(&self, pom: &str) -> bool { + self.poms.contains(&normalized_pom(pom)) + } + + pub fn poms(&self) -> impl Iterator { + self.poms.iter().map(String::as_str) + } + + pub fn filter(&self, model: &mut MavenModel) { + model.projects.retain(|p| !self.contains(&p.pom)); + for project in &mut model.projects { + project.modules.retain(|p| !self.contains(p)); + } + model.profiles.retain(|p| model.projects.iter().any(|project| project.profiles.contains(&p.id))); + } +} + +fn normalized_pom(pom: &str) -> String { + let pom = pom.replace('\\', "/"); + let mut parts = Vec::new(); + for part in pom.split('/') { + match part { + "" | "." => {} + ".." if parts.last().is_some_and(|p| *p != "..") => { + parts.pop(); + } + part => parts.push(part), + } + } + parts.join("/") +} + /// Every POM of the model (for change detection). pub fn build_files(root: &Path, model: &MavenModel) -> Vec { model.projects.iter().map(|p| root.join(&p.pom)).collect() @@ -1459,3 +1520,45 @@ mod tests { } } } + +#[cfg(test)] +mod linkage_tests { + use super::*; + use ide_core::settings::MavenSettings; + + #[test] + fn excludes_reactor_modules_and_profiles_but_keeps_other_roots() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for (name, body) in [ + ( + "a", + "child../sharedonly-a", + ), + ("a/child", ""), + ("shared", ""), + ("b", ""), + ] { + std::fs::create_dir_all(root.join(name)).unwrap(); + std::fs::write( + root.join(name).join("pom.xml"), + format!("{name}{body}"), + ) + .unwrap(); + } + let original = load_with_repo(root, None); + let settings = MavenSettings { unlinked_projects: vec!["./a/pom.xml".into()], ..Default::default() }; + let unlinked = UnlinkedProjects::from_model(&original, &settings); + assert!(unlinked.contains("a/child/pom.xml")); + assert!(unlinked.contains("shared/pom.xml")); + assert!(unlinked.contains("a/../shared/pom.xml")); + assert!(!unlinked.contains("b/pom.xml")); + let mut model = original.clone(); + unlinked.filter(&mut model); + assert_eq!(model.projects.iter().map(|p| p.pom.as_str()).collect::>(), ["b/pom.xml"]); + assert!(model.profiles.is_empty()); + let mut restored = original.clone(); + UnlinkedProjects::from_model(&original, &MavenSettings::default()).filter(&mut restored); + assert_eq!(restored, original); + } +} diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index d29c1d19..7529e2fb 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -23,6 +23,7 @@ pub struct SyncProgress { #[derive(Clone, Debug, Default)] pub struct SyncOptions { + pub maven_settings: ide_core::settings::MavenSettings, /// `-o` / `--offline`. pub offline: bool, /// Maven profiles (`-P`), `!id` deactivates. @@ -198,7 +199,8 @@ pub fn sync_maven( cancel: &CancellationToken, progress: &dyn Fn(SyncProgress), ) -> IdeResult<()> { - let model = maven::load(root); + let mut model = maven::load(root); + maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model); std::fs::create_dir_all(cache_dir)?; let mut cache = MavenCache { hash: hash_files(&maven::build_files(root, &model)), verbose: true, ..MavenCache::default() }; diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 1b8a1413..541ca6f9 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -569,6 +569,8 @@ pub struct JavaSettings { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct MavenSettings { + /// Project-relative root POMs removed from the Maven project model. + pub unlinked_projects: Vec, /// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. pub home: String, /// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. @@ -691,3 +693,19 @@ mod tests { assert!(Settings::default().files.file_types.is_empty()); } } + +#[cfg(test)] +mod maven_linkage_tests { + use super::*; + + #[test] + fn maven_linkage_defaults_and_round_trips() { + let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap(); + assert!(old.unlinked_projects.is_empty()); + let settings = + MavenSettings { unlinked_projects: vec!["pom.xml".into(), "services/api/pom.xml".into()], ..old }; + let encoded = serde_json::to_string(&settings).unwrap(); + assert!(encoded.contains("unlinkedProjects")); + assert_eq!(serde_json::from_str::(&encoded).unwrap(), settings); + } +} diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 1366b9db..c60d880a 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -27,7 +27,12 @@ fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option) -> B /// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and /// the Maven home and options from Settings › Maven. pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { - let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings)); + let mut model = ide_build::load_linked( + root, + cache, + ide_sdk::apply::maven_local_repository(root, settings), + &settings.languages.java.maven, + ); let jdk = ide_sdk::apply::project_jdk(root, settings); if let Some(m) = &mut model.maven { let mvn = ide_sdk::apply::maven_command(root, settings); @@ -107,6 +112,7 @@ pub async fn build_sync( env, maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()), maven_options: ide_sdk::apply::maven_options(&root, &settings), + maven_settings: settings.languages.java.maven.clone(), }; let progress = |p: SyncProgress| { let _ = on_progress.send(p); diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 02e3202c..035bce38 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -129,7 +129,9 @@ pub fn set_settings( host.runtime.spawn(async move { lsp.reconfigure().await }); } } - let roots_changed = key == "projectStructure" || key.starts_with("projectStructure."); + let roots_changed = key == "projectStructure" + || key.starts_with("projectStructure.") + || "languages.java.maven.unlinkedProjects".starts_with(key); // Content roots and excludes (files.exclude, ignored names, excluded folders) reach the // index and the watchers without reopening the project (the rescan runs in the background). if roots_changed || key == "files" || key.starts_with("files.") { diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index fc987654..aaf1fdd3 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -10,13 +10,15 @@ pub fn configs(host: &Host, window: &str) -> IdeResult> { let root = project.root.clone(); let user = ide_run::load_user_configs(&root)?; let suppressed = ide_run::load_suppressed_config_ids(&root)?; - let mut all: Vec = ide_run::detect(&root) + let settings = project.settings.effective(); + let excluded_poms = crate::project::unlinked_maven_poms(&root, &settings); + let mut all: Vec = ide_run::detect_excluding(&root, &excluded_poms) .into_iter() .filter(|d| !suppressed.contains(&d.id) && !user.iter().any(|u| u.id == d.id)) .collect(); all.extend(user); // Project interpreter / package manager / toolchains from Languages & Frameworks. - ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective()); + ide_sdk::apply::apply_to_run_configs(&mut all, &root, &settings); Ok(all) } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index a332b8dd..dcdeeb60 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -81,7 +81,7 @@ impl Host { let post_start = self.environment.project_opened(&root, &sink); let settings = SettingsStore::load(&self.config_dir, Some(&root)); let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root))); - let excludes = exclude_globs(&settings.effective(), &detected_roots.read()); + let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read()); let extra_roots = content_roots(&settings.effective(), &root); mark("roots"); @@ -154,6 +154,8 @@ impl Host { ide_sdk::jdtls::launch_directly(cfg); } ide_sdk::apply::server_protocol_overrides(cfg, &effective); + let excluded_poms = unlinked_maven_poms(&root, &effective); + ide_sdk::apply::configure_maven_import(cfg, &root, &excluded_poms); })); } { @@ -224,7 +226,7 @@ impl Host { if *detected_roots.read() != fresh { // Detected excluded folders (`target`, `build`) leave the index too. let effective = settings.effective(); - index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh)); + index.set_scope(content_roots(&effective, &root), exclude_globs(&root, &effective, &fresh)); *detected_roots.write() = fresh; sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); } @@ -586,7 +588,7 @@ impl Project { /// Effective source / test / resource / excluded roots (detected + Project Structure marks). pub fn roots(&self) -> ProjectRoots { - ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure) + linked_roots(&self.root, &self.settings.effective(), &self.detected_roots.read()) } /// Where the Maven / Gradle tool windows cache what the build tools reported. @@ -642,7 +644,7 @@ impl Project { /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). pub fn exclude_globs(&self) -> Vec { - exclude_globs(&self.settings.effective(), &self.detected_roots.read()) + exclude_globs(&self.root, &self.settings.effective(), &self.detected_roots.read()) } /// Project Structure content roots besides the project folder (existing directories). @@ -792,9 +794,23 @@ fn content_root_watcher( .ok() } -fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec { +pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Settings) -> Vec { + ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven).poms().map(PathBuf::from).collect() +} + +fn linked_roots(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> ProjectRoots { + let unlinked = ide_build::maven::UnlinkedProjects::load(root, &settings.languages.java.maven); + let detected = detected + .iter() + .filter(|folder| !folder.detected_from.as_ref().is_some_and(|pom| unlinked.contains(pom))) + .cloned() + .collect(); + ProjectRoots::compute(detected, &settings.project_structure) +} + +fn exclude_globs(root: &Path, settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec { let mut globs = settings.file_excludes(); - globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs()); + globs.extend(linked_roots(root, settings, detected).excluded_globs()); globs } @@ -925,3 +941,50 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) { let exe = settings.vcs.git.executable.trim(); ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe))); } + +#[cfg(test)] +mod maven_linkage_tests { + #[test] + fn unlinked_roots_remain_plain_files() { + let tmp = tempfile::tempdir().unwrap(); + for name in ["a", "b"] { + std::fs::create_dir_all(tmp.path().join(name).join("src/main/java")).unwrap(); + std::fs::write(tmp.path().join(name).join("pom.xml"), "").unwrap(); + } + std::fs::create_dir_all(tmp.path().join("a/child/src/main/java")).unwrap(); + std::fs::write(tmp.path().join("a/pom.xml"), "child") + .unwrap(); + std::fs::write(tmp.path().join("a/child/pom.xml"), "").unwrap(); + let detected = ide_workspace::roots::detect(tmp.path()); + let mut settings = ide_core::settings::Settings::default(); + settings.languages.java.maven.unlinked_projects.push("a/pom.xml".into()); + let excluded_poms = super::unlinked_maven_poms(tmp.path(), &settings); + assert_eq!(excluded_poms, vec![std::path::PathBuf::from("a/child/pom.xml"), "a/pom.xml".into()]); + let configs = ide_run::detect_excluding(tmp.path(), &excluded_poms); + assert!(configs.iter().any(|c| c.kind == "maven")); + assert!( + configs + .iter() + .filter(|c| c.kind == "maven") + .all(|c| { c.cwd.as_ref().is_some_and(|dir| dir.as_ref() == std::path::Path::new("b")) }) + ); + let mut server = serde_json::from_value::( + serde_json::json!({"name": "jdtls", "command": "jdtls"}), + ) + .unwrap(); + ide_sdk::apply::configure_maven_import(&mut server, tmp.path(), &excluded_poms); + let options = serde_json::to_value(server.initialization_options).unwrap(); + let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap(); + assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a").to_string_lossy()))); + assert!(exclusions.contains(&serde_json::json!(tmp.path().join("a/child").to_string_lossy()))); + let roots = super::linked_roots(tmp.path(), &settings, &detected); + assert!(roots.roots.iter().any(|r| r.path == "b/src/main/java")); + assert!(!roots.roots.iter().any(|r| r.path.starts_with("a/"))); + assert!(tmp.path().join("a/pom.xml").is_file()); + let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings); + assert_eq!(model.maven.unwrap().projects.len(), 1); + settings.languages.java.maven.unlinked_projects.push("b/pom.xml".into()); + let model = crate::ops::build::load(tmp.path(), &tmp.path().join("cache"), &settings); + assert!(model.maven.unwrap().projects.is_empty()); + } +} diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 4207e0c1..b6b0c89e 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -10,12 +10,16 @@ use std::fs; use std::path::{Path, PathBuf}; pub(crate) fn detect(root: &Path) -> Vec { + detect_excluding(root, &[]) +} + +pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec { let mut out = Vec::new(); detect_node(root, &mut out); detect_deno(root, &mut out); detect_cargo(root, &mut out); detect_gradle(root, &mut out); - detect_maven(root, &mut out); + detect_maven(root, excluded_poms, &mut out); detect_go(root, &mut out); detect_python(root, &mut out); detect_make(root, &mut out); @@ -489,9 +493,13 @@ fn collect_texts_from_subdirs(root: &Path, names: &[&str]) -> String { text } -fn detect_maven(root: &Path, out: &mut Vec) { +fn detect_maven(root: &Path, excluded_poms: &[PathBuf], out: &mut Vec) { for dir in build_dirs(root, &["pom.xml"]) { - detect_maven_build(root, &dir, out); + let pom = dir.join("pom.xml"); + let relative = pom.strip_prefix(root).unwrap_or(&pom); + if !excluded_poms.iter().any(|excluded| excluded == relative) { + detect_maven_build(root, &dir, out); + } } } diff --git a/crates/ide-run/src/lib.rs b/crates/ide-run/src/lib.rs index 43ac71b0..31b6c8f3 100644 --- a/crates/ide-run/src/lib.rs +++ b/crates/ide-run/src/lib.rs @@ -189,6 +189,11 @@ pub fn detect(root: &Path) -> Vec { detect::detect(root) } +/// Detects configurations excluding the supplied project-relative Maven POM paths. +pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec { + detect::detect_excluding(root, excluded_poms) +} + /// Reads `/.openidea/run.toml`; a missing file yields no configs. pub fn load_user_configs(root: &Path) -> IdeResult> { config::load(root) @@ -233,3 +238,23 @@ pub fn system_env() -> BTreeMap { pub fn user_configs_path(root: &Path) -> PathBuf { config::config_path(root) } + +#[cfg(test)] +mod linkage_tests { + #[test] + fn detection_skips_unlinked_maven_roots() { + let tmp = tempfile::tempdir().unwrap(); + for name in ["a", "b"] { + std::fs::create_dir(tmp.path().join(name)).unwrap(); + std::fs::write(tmp.path().join(name).join("pom.xml"), "").unwrap(); + } + let configs = super::detect_excluding(tmp.path(), &["a/pom.xml".into()]); + assert!(configs.iter().any(|c| c.kind == "maven")); + assert!( + configs + .iter() + .filter(|c| c.kind == "maven") + .all(|c| c.cwd.as_ref().is_some_and(|d| d.as_ref() == std::path::Path::new("b"))) + ); + } +} diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index c7a1d191..1e61c27ed 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -173,6 +173,60 @@ fn merge(base: &mut Value, layer: Value) { } } +/// Excludes project-relative Maven POM paths from JDTLS import, preserving user exclusions. +pub fn configure_maven_import(cfg: &mut LanguageServerConfig, root: &Path, excluded_poms: &[PathBuf]) { + if cfg.name != "jdtls" { + return; + } + let mut options = + cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or_else(|| json!({})); + if let Some(settings) = &cfg.settings { + merge(&mut options, settings.clone()); + } + let mut exclusions = options + .pointer("/java/import/exclusions") + .or_else(|| options.pointer("/settings/java/import/exclusions")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_else(|| { + vec![ + json!("**/node_modules/**"), + json!("**/.metadata/**"), + json!("**/archetype-resources/**"), + json!("**/META-INF/maven/**"), + ] + }); + for pom in excluded_poms { + let directory = root.join(pom).parent().unwrap_or(root).to_path_buf(); + let directory = directory.to_string_lossy().replace('\\', "/"); + // Java NIO glob syntax treats these characters specially even in literal paths. + let escaped: String = + directory.chars().flat_map(|c| if "*?[]{}\\".contains(c) { vec!['\\', c] } else { vec![c] }).collect(); + exclusions.push(json!(escaped)); + } + if !excluded_poms.is_empty() { + merge_init_options(cfg, json!({"settings": {"java": {"import": {"exclusions": exclusions}}}})); + if let Some(settings) = &mut cfg.settings { + merge( + settings, + json!({"java": {"import": {"exclusions": exclusions}}, "settings": {"java": {"import": {"exclusions": exclusions}}}}), + ); + } + // A fresh workspace cannot retain Maven projects imported before unlinking. + if let Some(index) = cfg.args.iter().position(|arg| arg == "-data") + && let Some(workspace) = cfg.args.get_mut(index + 1) + { + let mut hash = 0xcbf29ce484222325u64; + for pom in excluded_poms { + for byte in pom.to_string_lossy().bytes().chain([0]) { + hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3); + } + } + *workspace = format!("{workspace}-maven-{hash:016x}"); + } + } +} + pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) { let mut current = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or_else(|| json!({})); @@ -616,3 +670,27 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op }; script(target).is_file().then_some(line) } + +#[cfg(test)] +mod maven_linkage_tests { + use super::*; + + #[test] + fn import_excludes_unlinked_modules_and_changes_persistent_workspace() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("a/child")).unwrap(); + std::fs::write(tmp.path().join("a/pom.xml"), "child") + .unwrap(); + std::fs::write(tmp.path().join("a/child/pom.xml"), "").unwrap(); + let mut cfg = custom_server("jdtls", &LanguageServerSettings::default()); + cfg.args = vec!["-data".into(), "/cache/jdtls".into()]; + merge_init_options(&mut cfg, json!({"settings":{"java":{"import":{"exclusions":["**/custom"]}}}})); + configure_maven_import(&mut cfg, tmp.path(), &["a/child/pom.xml".into(), "a/pom.xml".into()]); + let options = serde_json::to_value(cfg.initialization_options).unwrap(); + let exclusions = options.pointer("/settings/java/import/exclusions").unwrap().as_array().unwrap(); + assert!(exclusions.contains(&json!("**/custom"))); + assert!(exclusions.contains(&json!(tmp.path().join("a").to_string_lossy()))); + assert!(exclusions.contains(&json!(tmp.path().join("a/child").to_string_lossy()))); + assert!(cfg.args[1].starts_with("/cache/jdtls-maven-")); + } +} diff --git a/src/bindings.ts b/src/bindings.ts index d2171b29..6f56a1da 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -4075,6 +4075,8 @@ export type MavenProject = { /** Build Tools › Maven. */ export type MavenSettings = { + /** Project-relative root POMs removed from the Maven project model. */ + unlinkedProjects?: string[], /** Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`. */ home?: string, /** User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`. */ diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index 7060620a..ac3ab85f 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -131,6 +131,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[] ) : ( )} + {maven && } void createRunConfig(config) }); } if (node.kind === "mavenProject") { + if (!node.project.aggregated) + items.push({ kind: "action", action: "maven.unlinkProjects", arg: { pom: node.project.pom } }); items.push( { label: "Open pom.xml", diff --git a/src/features/build-tools/actions.test.tsx b/src/features/build-tools/actions.test.tsx index b6e896db..5c7b4359 100644 --- a/src/features/build-tools/actions.test.tsx +++ b/src/features/build-tools/actions.test.tsx @@ -74,6 +74,7 @@ describe("build tools feature", () => { .map((a) => a.id) .sort(), ).toEqual([ + "maven.addProjects", "maven.analyzeDependencies", "maven.executeGoal", "maven.reload", diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index cb6ad763..86c90829 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -21,6 +21,8 @@ import { buildToolsRunAnything } from "./runAnything"; import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model"; import { ANALYZER_KIND, + addProjects, + unlinkProjects, cancelSync, loadModel, openAnalyzer, @@ -110,7 +112,7 @@ function apply(model: BuildModel | null, registered: Partial void { if (e.key === "languages" || e.key.startsWith("languages.java")) scheduleLoad(); }), registerActions([ + { + id: "maven.addProjects", + title: "Add Maven Projects", + category: "Maven", + icon: "plus", + when: "projectOpen", + run: () => addProjects(), + }, + { + id: "maven.unlinkProjects", + title: "Unlink Maven Projects", + category: "Maven", + hidden: true, + when: MAVEN_WHEN, + run: ({ arg }) => unlinkProjects(analyzerPom(arg)), + }, { id: "maven.reload", title: "Reload All Maven Projects", diff --git a/src/features/build-tools/linkage.test.tsx b/src/features/build-tools/linkage.test.tsx new file mode 100644 index 00000000..f629cdd6 --- /dev/null +++ b/src/features/build-tools/linkage.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { executeAction } from "@/platform/actions"; +import { setContext } from "@/platform/context"; +import type { BuildModel, ProjectInfo } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { MavenView } from "./BuildToolView"; +import { activate } from "./index"; +import { useBuild } from "./store"; + +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + set: vi.fn(), + model: vi.fn(), + configs: vi.fn(), + open: vi.fn(), + confirm: vi.fn(), + menu: vi.fn(), +})); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + isTauri: () => true, + call: (value: unknown) => value, + commands: { + ...real.commands, + settingsGet: mocks.get, + settingsSet: mocks.set, + buildModel: mocks.model, + runConfigs: mocks.configs, + }, + }; +}); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open })); +vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm })); +vi.mock("@/ui", async (original) => ({ ...(await original()), showContextMenu: mocks.menu })); + +const model: BuildModel = { + gradle: null, + maven: { + syncedAt: 1, + stale: false, + syncError: null, + profiles: [], + projects: [ + { + pom: "a/pom.xml", + dir: "a", + groupId: "example", + artifactId: "a", + version: "1", + packaging: "pom", + displayName: "project-a", + parent: null, + modules: [], + aggregated: false, + plugins: [], + dependencies: [], + profiles: [], + javaVersion: null, + command: "mvn", + options: [], + env: {}, + }, + ], + }, +}; +let dispose: () => void; +beforeEach(async () => { + vi.clearAllMocks(); + mocks.get.mockResolvedValue({ languages: { java: { maven: { unlinkedProjects: [] } } } }); + mocks.set.mockResolvedValue({}); + mocks.model.mockResolvedValue(model); + mocks.configs.mockResolvedValue([]); + mocks.confirm.mockResolvedValue(true); + useProject.setState({ project: { root: "/p" } as ProjectInfo }); + setContext("projectOpen", true); + await act(async () => { + dispose = activate(); + }); +}); +afterEach(() => { + dispose(); + useProject.setState({ project: null }); + setContext("projectOpen", false); +}); + +it("offers unlink on a root's context menu and persists a confirmed project setting", async () => { + render(); + fireEvent.contextMenu(screen.getByText("project-a")); + expect(mocks.menu).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([expect.objectContaining({ action: "maven.unlinkProjects", arg: { pom: "a/pom.xml" } })]), + ); + await act(async () => { + await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); + }); + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Unlink Maven projects?" })); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["a/pom.xml"]'); + expect(mocks.model).toHaveBeenCalledTimes(2); +}); + +it("does not unlink modules or write after cancelled confirmation", async () => { + mocks.confirm.mockResolvedValue(false); + await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); + expect(mocks.set).not.toHaveBeenCalled(); + act(() => + useBuild.setState({ + model: { ...model, maven: { ...model.maven!, projects: [{ ...model.maven!.projects[0]!, aggregated: true }] } }, + }), + ); + render(); + await executeAction("maven.unlinkProjects", { pom: "a/pom.xml" }); + expect(mocks.confirm).toHaveBeenCalledTimes(1); +}); + +it("re-adds a picked POM using the plus button and preserves other unlinked roots", async () => { + mocks.get.mockResolvedValue({ languages: { java: { maven: { unlinkedProjects: ["a/pom.xml", "b/pom.xml"] } } } }); + mocks.open.mockResolvedValue("/p/a/pom.xml"); + act(() => useBuild.setState({ model: { ...model, maven: { ...model.maven!, projects: [] } } })); + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Add Maven Projects" })); + }); + expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ defaultPath: "/p", directory: false })); + expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["b/pom.xml"]'); +}); + +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", + async (picked) => { + mocks.open.mockResolvedValue(picked); + await executeAction("maven.addProjects"); + expect(mocks.set).not.toHaveBeenCalled(); + }, +); diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index cf7ef53c..95676481 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -5,6 +5,8 @@ * choices (per project, in localStorage) and running goals through the run * feature's actions. */ +import { confirm } from "@/workbench/messages"; +import { tx } from "@/platform/i18n"; import { create } from "zustand"; import { Channel } from "@tauri-apps/api/core"; import { executeAction } from "@/platform/actions"; @@ -170,6 +172,54 @@ export async function loadModel(): Promise { } } +const UNLINKED_PROJECTS = "languages.java.maven.unlinkedProjects"; + +async function setLinked(root: string, pom: string, linked: boolean) { + if (useProject.getState().project?.root !== root) return; + const settings = await commands.settingsGet(); + if (useProject.getState().project?.root !== root) return; + const previous = settings.languages.java?.maven?.unlinkedProjects ?? []; + const next = linked ? previous.filter((p) => p !== pom) : [...new Set([...previous, pom])]; + if (JSON.stringify(next) === JSON.stringify(previous)) return; + await call(commands.settingsSet("project", UNLINKED_PROJECTS, JSON.stringify(next))); + await loadModel(); +} + +export async function unlinkProjects(pom: string | null) { + const { root, model } = useBuild.getState(); + if (!root || !pom || !model?.maven?.projects.some((p) => p.pom === pom && !p.aggregated)) return; + if ( + !(await confirm({ + title: "Unlink Maven projects?", + message: "Remove this project and its modules from Maven? Files will remain in the Project view.", + ok: "Unlink", + })) + ) + return; + cancelSync("maven"); + await setLinked(root, pom, false); +} + +export async function addProjects() { + const root = useProject.getState().project?.root; + if (!root || !isTauri()) return; + const { open } = await import("@tauri-apps/plugin-dialog"); + const picked = await open({ + title: tx`Add Maven Projects`, + directory: false, + multiple: false, + defaultPath: root, + filters: [{ name: "pom.xml", extensions: ["xml"] }], + }); + if (typeof picked !== "string") return; + const base = root.replace(/\\/g, "/").replace(/\/$/, ""); + const path = picked.replace(/\\/g, "/"); + if (!path.startsWith(`${base}/`)) return; + const pom = path.slice(base.length + 1); + if (pom.split("/").some((part) => part === ".." || part === ".") || pom.split("/").pop() !== "pom.xml") return; + await setLinked(root, pom, true); +} + /** Tree keys expanded on first show: the top-level projects. */ export function initialExpanded(tool: BuildTool, model: BuildModel): Set { if (tool === "maven") @@ -182,6 +232,7 @@ const TITLES: Record = { maven: "Syncing Maven projects", gra /** Reloads all projects of `tool` from the build tool itself (effective POM, dependency tree, task list). */ export async function syncTool(tool: BuildTool): Promise { const state = useBuild.getState(); + const gen = generation; const root = state.root; if (!root || !isTauri() || state.syncing[tool]) return; const request = nextRequestId(); @@ -201,7 +252,7 @@ export async function syncTool(tool: BuildTool): Promise { const offline = tool === "maven" ? settings.maven.offline : settings.gradle.offline; try { const next = await call(commands.buildSync(tool, request, offline, profiles, channel)); - if (useBuild.getState().root !== root) return; + if (useBuild.getState().root !== root || gen !== generation) return; const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs); useBuild.setState({ model: next, configs }); const error = tool === "maven" ? next.maven?.syncError : next.gradle?.syncError; diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index 5a44781d..9468486b 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -31,7 +31,7 @@ describe("languages settings", () => { expect(v.java.jdtlsVmArgs).toBe("-Xmx2g"); expect(v.java.jdk).toBe(""); expect(v.java.jdtlsJdk).toBe("/opt/jdk-25"); - expect(v.java.maven).toEqual({ home: "/opt/maven", userSettings: "", localRepository: "" }); + expect(v.java.maven).toEqual({ home: "/opt/maven", userSettings: "", localRepository: "", unlinkedProjects: [] }); expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index d0729e63..cc325ae9 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -21,7 +21,7 @@ export interface LanguagesValid { jdk: string; jdtlsJdk: string; jdtlsVmArgs: string; - maven: { home: string; userSettings: string; localRepository: string }; + maven: { home: string; userSettings: string; localRepository: string; unlinkedProjects: string[] }; }; } @@ -54,7 +54,12 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "" }, typescript: { version: "bundled" }, - java: { jdk: "", jdtlsJdk: "", jdtlsVmArgs: "", maven: { home: "", userSettings: "", localRepository: "" } }, + java: { + jdk: "", + jdtlsJdk: "", + jdtlsVmArgs: "", + maven: { home: "", userSettings: "", localRepository: "", unlinkedProjects: [] }, + }, }; export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = { @@ -136,6 +141,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { home: text(maven.home, d.java.maven.home), userSettings: text(maven.userSettings, d.java.maven.userSettings), localRepository: text(maven.localRepository, d.java.maven.localRepository), + unlinkedProjects: folders(maven.unlinkedProjects), }, }, }; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index aba694d8..d75b64d5 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,9 @@ { + "Add Maven Projects": "添加 Maven 项目", + "Unlink Maven Projects": "取消链接 Maven 项目", + "Unlink Maven projects?": "取消链接 Maven 项目?", + "Remove this project and its modules from Maven? Files will remain in the Project view.": "从 Maven 中移除此项目及其模块?文件将保留在项目视图中。", + "Unlink": "取消链接", "(does not exist)": "(不存在)", ", e.g.": ",例如", ", then press Tab.": ",然后按 Tab。",