mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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
This commit is contained in:
@@ -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)))
|
||||
|
||||
@@ -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<PathBuf>) -> 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<PathBuf>,
|
||||
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
|
||||
});
|
||||
|
||||
@@ -967,6 +967,67 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
model
|
||||
}
|
||||
|
||||
/// Shared linkage decision for every Maven consumer, including modules outside a reactor directory.
|
||||
#[derive(Default)]
|
||||
pub struct UnlinkedProjects {
|
||||
poms: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
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<Item = &str> {
|
||||
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<PathBuf> {
|
||||
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",
|
||||
"<modules><module>child</module><module>../shared</module></modules><profiles><profile><id>only-a</id></profile></profiles>",
|
||||
),
|
||||
("a/child", ""),
|
||||
("shared", ""),
|
||||
("b", ""),
|
||||
] {
|
||||
std::fs::create_dir_all(root.join(name)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(name).join("pom.xml"),
|
||||
format!("<project><artifactId>{name}</artifactId>{body}</project>"),
|
||||
)
|
||||
.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::<Vec<_>>(), ["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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> 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);
|
||||
|
||||
@@ -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.") {
|
||||
|
||||
@@ -10,13 +10,15 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
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<RunConfig> = ide_run::detect(&root)
|
||||
let settings = project.settings.effective();
|
||||
let excluded_poms = crate::project::unlinked_maven_poms(&root, &settings);
|
||||
let mut all: Vec<RunConfig> = 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
pub(crate) fn unlinked_maven_poms(root: &Path, settings: &ide_core::settings::Settings) -> Vec<PathBuf> {
|
||||
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<String> {
|
||||
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"), "<project/>").unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(tmp.path().join("a/child/src/main/java")).unwrap();
|
||||
std::fs::write(tmp.path().join("a/pom.xml"), "<project><modules><module>child</module></modules></project>")
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("a/child/pom.xml"), "<project/>").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::<ide_language::LanguageServerConfig>(
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,16 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn detect(root: &Path) -> Vec<RunConfig> {
|
||||
detect_excluding(root, &[])
|
||||
}
|
||||
|
||||
pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
|
||||
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<RunConfig>) {
|
||||
fn detect_maven(root: &Path, excluded_poms: &[PathBuf], out: &mut Vec<RunConfig>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -189,6 +189,11 @@ pub fn detect(root: &Path) -> Vec<RunConfig> {
|
||||
detect::detect(root)
|
||||
}
|
||||
|
||||
/// Detects configurations excluding the supplied project-relative Maven POM paths.
|
||||
pub fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<RunConfig> {
|
||||
detect::detect_excluding(root, excluded_poms)
|
||||
}
|
||||
|
||||
/// Reads `<root>/.openidea/run.toml`; a missing file yields no configs.
|
||||
pub fn load_user_configs(root: &Path) -> IdeResult<Vec<RunConfig>> {
|
||||
config::load(root)
|
||||
@@ -233,3 +238,23 @@ pub fn system_env() -> BTreeMap<String, String> {
|
||||
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"), "<project/>").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")))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"), "<project><modules><module>child</module></modules></project>")
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("a/child/pom.xml"), "<project/>").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-"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -131,6 +131,7 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
|
||||
) : (
|
||||
<IconButton icon="refresh" size="small" iconSize={16} action={`${tool}.reload`} />
|
||||
)}
|
||||
{maven && <IconButton icon="plus" size="small" iconSize={16} action="maven.addProjects" />}
|
||||
<span className={styles.sep} />
|
||||
<IconButton
|
||||
icon="run"
|
||||
@@ -302,6 +303,8 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
|
||||
else items.push({ label: "Create Run Configuration…", icon: "edit", run: () => 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",
|
||||
|
||||
@@ -74,6 +74,7 @@ describe("build tools feature", () => {
|
||||
.map((a) => a.id)
|
||||
.sort(),
|
||||
).toEqual([
|
||||
"maven.addProjects",
|
||||
"maven.analyzeDependencies",
|
||||
"maven.executeGoal",
|
||||
"maven.reload",
|
||||
|
||||
@@ -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<Record<BuildTool, (
|
||||
for (const tool of ["maven", "gradle"] as const) {
|
||||
const info = tool === "maven" ? model?.maven : model?.gradle;
|
||||
const key = `${tool}:${root}`;
|
||||
if (info && info.syncedAt == null && !synced.has(key)) {
|
||||
if (info && (tool !== "maven" || model?.maven?.projects.length) && info.syncedAt == null && !synced.has(key)) {
|
||||
synced.add(key);
|
||||
void syncTool(tool);
|
||||
}
|
||||
@@ -165,6 +167,22 @@ export function activate(): () => 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",
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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<typeof import("@/ui")>()), 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(<MavenView />);
|
||||
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(<MavenView />);
|
||||
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(<MavenView />);
|
||||
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();
|
||||
},
|
||||
);
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
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<string> {
|
||||
if (tool === "maven")
|
||||
@@ -182,6 +232,7 @@ const TITLES: Record<BuildTool, string> = { 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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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。",
|
||||
|
||||
Reference in New Issue
Block a user