From 2ea47873cf2e444b25ac101b42110a7c4d4076ae Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 30 Oct 2026 08:12:49 +0800 Subject: [PATCH] feat(maven): unlink and re-add Maven projects (#297) As in IntelliJ IDEA, a root Maven project can be unlinked from the Maven tool window's context menu (Unlink Maven Projects, with confirmation) and linked again with the toolbar's Add Maven Projects (pick a pom.xml). Unlinked root POMs are stored in the project's Maven settings (unlinkedProjects). ide-host computes the unlinked roots and their modules once with ide-build and applies the same exclusion to the build model, Maven sync, project source roots, detected run configurations and the Java language server import (java.import.exclusions). The project's files stay visible in the Project view as plain files. Refs #271 --- crates/ide-build/src/compile/mod.rs | 3 + crates/ide-build/src/lib.rs | 11 ++ crates/ide-build/src/maven.rs | 103 +++++++++++++ crates/ide-build/src/sync.rs | 4 +- crates/ide-core/src/settings.rs | 18 +++ crates/ide-host/src/ops/build.rs | 8 +- crates/ide-host/src/ops/project.rs | 4 +- crates/ide-host/src/ops/run.rs | 6 +- crates/ide-host/src/project.rs | 75 +++++++++- crates/ide-run/src/detect.rs | 14 +- crates/ide-run/src/lib.rs | 25 ++++ crates/ide-sdk/src/apply.rs | 78 ++++++++++ src/bindings.ts | 2 + src/features/build-tools/BuildToolView.tsx | 3 + src/features/build-tools/actions.test.tsx | 1 + src/features/build-tools/index.ts | 20 ++- src/features/build-tools/linkage.test.tsx | 137 ++++++++++++++++++ src/features/build-tools/store.ts | 53 ++++++- .../settings/__tests__/languages.test.ts | 2 +- src/platform/languageSettings.ts | 10 +- src/platform/locales/text/zh.json | 5 + 21 files changed, 563 insertions(+), 19 deletions(-) create mode 100644 src/features/build-tools/linkage.test.tsx 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。",