From 0dfdbbc3237be02b830a023bdd3bf7c08ac3555f Mon Sep 17 00:00:00 2001 From: yoo hyeong chan <84506708+gudcks0305@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:23:39 +0900 Subject: [PATCH] feat(gradle): link and unlink independent Gradle builds (#1413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a folder of independent Gradle builds selected only the first build. Link Gradle Projects… (pick a build's settings or build file) and Unlink on a build's root let an ordinary project window manage several independent builds without changing their scripts. The build model holds a list of Gradle builds; each keeps its own wrapper, working directory, environment, task routing and sync cache, keyed by the owning build directory. The links persist in the project's Gradle settings (linkedProjects); unlinking leaves files and saved run configurations alone. Existing includeBuild support is unchanged, and an explicitly linked build takes precedence over a composite that also includes it. Workspace windows keep Link/Unlink disabled. Co-authored-by: gudcks0305 <84506708+gudcks0305@users.noreply.github.com> --- crates/ide-build/src/application_update.rs | 6 +- crates/ide-build/src/compile/mod.rs | 115 ++++++++-- crates/ide-build/src/gradle.rs | 94 +++++++++ crates/ide-build/src/gradle_deps.rs | 21 +- crates/ide-build/src/lib.rs | 96 +++++++-- crates/ide-build/src/sync.rs | 154 +++++++++++++- crates/ide-build/src/web.rs | 45 +++- crates/ide-core/src/settings.rs | 13 ++ crates/ide-host/src/ops/build.rs | 198 ++++++++++++++++-- crates/ide-host/src/ops/project.rs | 3 +- crates/ide-host/src/project.rs | 125 ++++++++++- crates/ide-host/tests/gradle_linkage.rs | 49 +++++ crates/ide-host/tests/project_smoke.rs | 12 +- crates/ide-host/tests/trust.rs | 1 + crates/ide-sdk/src/apply.rs | 49 +++++ docs/workspaces-and-window-groups.md | 10 +- e2e/linkedGradle.spec.ts | 120 +++++++++++ src-tauri/src/commands/build.rs | 36 +++- src-tauri/src/commands/mod.rs | 2 + src/bindings.ts | 10 +- src/features/build-tools/BuildToolView.tsx | 69 ++++-- .../build-tools/DependencyAnalyzer.test.tsx | 39 +++- .../build-tools/DependencyAnalyzer.tsx | 50 +++-- src/features/build-tools/ExecutePopup.tsx | 13 +- src/features/build-tools/actions.test.tsx | 4 +- src/features/build-tools/autoReload.test.ts | 2 +- src/features/build-tools/index.ts | 103 +++++++-- src/features/build-tools/linkage.test.tsx | 173 ++++++++++++++- src/features/build-tools/model.test.ts | 65 +++++- src/features/build-tools/model.ts | 78 +++++-- .../build-tools/profileReload.test.ts | 2 +- src/features/build-tools/runAnything.ts | 33 +-- src/features/build-tools/store.ts | 124 ++++++++--- src/features/project-tree/index.ts | 10 +- .../project-tree/moduleGroups.test.ts | 12 ++ src/features/project-tree/moduleGroups.ts | 12 +- src/features/run/contextRun.ts | 3 +- src/features/run/gradleRun.test.ts | 79 ++++++- src/features/run/gradleRun.ts | 15 +- src/platform/languageSettings.ts | 6 +- src/platform/locales/text/zh.json | 5 + src/platform/projectPresentation.test.ts | 40 +++- src/platform/projectPresentation.ts | 4 +- 43 files changed, 1846 insertions(+), 254 deletions(-) create mode 100644 crates/ide-host/tests/gradle_linkage.rs create mode 100644 e2e/linkedGradle.spec.ts diff --git a/crates/ide-build/src/application_update.rs b/crates/ide-build/src/application_update.rs index cabd8a73..96a7ad0d 100644 --- a/crates/ide-build/src/application_update.rs +++ b/crates/ide-build/src/application_update.rs @@ -40,7 +40,7 @@ pub fn plan(root: &Path, model: &BuildModel, module: &Path, cache: &Path) -> Ide kind: "maven", }); } - if let Some(g) = &model.gradle + if let Some(g) = model.gradle_for_module(root, &module) && let Some(p) = g.projects.iter().find(|p| root.join(&p.dir) == module) { let script = cache.join("resources.init.gradle"); @@ -163,11 +163,11 @@ mod tests { assert!(maven.args.contains(&"-Dexpression=project.build.outputDirectory".into())); assert_eq!(maven.env["JAVA_HOME"], "/jdk"); model.maven = None; - model.gradle = Some(crate::GradleModel { + model.gradle = vec![crate::GradleModel { command: "/tools/gradlew".into(), projects: vec![crate::GradleProject { path: ":app".into(), dir: "app".into(), ..Default::default() }], ..Default::default() - }); + }]; let gradle = plan(&root, &model, Path::new("app"), &cache).unwrap(); assert_eq!(gradle.command, "/tools/gradlew"); assert_eq!(gradle.args.last().unwrap(), ":app:openideaUpdateResources"); diff --git a/crates/ide-build/src/compile/mod.rs b/crates/ide-build/src/compile/mod.rs index 603d5b3f..fcfa45f0 100644 --- a/crates/ide-build/src/compile/mod.rs +++ b/crates/ide-build/src/compile/mod.rs @@ -147,7 +147,9 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option Option PathBuf { - rel.split('/').filter(|part| !part.is_empty()).fold(root.to_path_buf(), |path, part| path.join(part)) + root.join(rel).components().collect() } fn rel_str(root: &Path, dir: &Path) -> String { - dir.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default() + dir.strip_prefix(root).unwrap_or(dir).to_string_lossy().replace('\\', "/") } /// Directories from `file`'s folder up to `root` (inclusive). @@ -192,7 +191,14 @@ pub fn plan( let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned()); let module_dir = match file { Some(f) => { - let dir = ancestors_within(root, f) + let scope = model + .gradle + .iter() + .flat_map(|g| std::iter::once(root.join(&g.dir)).chain(g.projects.iter().map(|p| root.join(&p.dir)))) + .filter(|dir| f.starts_with(dir)) + .max_by_key(|dir| dir.components().count()) + .unwrap_or_else(|| root.to_path_buf()); + let dir = ancestors_within(&scope, f) .find(|d| detect_dir(d, model, root).is_some()) .ok_or_else(|| IdeError::not_found(format!("{} is not in a module", f.display())))?; Some(dir.to_path_buf()) @@ -202,7 +208,7 @@ pub fn plan( let dir = module_dir.clone().unwrap_or_else(|| root.to_path_buf()); // A folder of sibling projects has no build file itself; its Maven projects are built one by one. let system = detect_dir(&dir, model, root) - .or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None }) + .or((!model.gradle.is_empty()).then_some(BuildSystem::Gradle)) .or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven)) .ok_or_else(|| { IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found") @@ -270,14 +276,16 @@ fn maven_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option< } fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option<&Path>) -> IdeResult> { - let fallback; - let g = match &model.gradle { - Some(g) => g, - None => { - fallback = crate::gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build found"))?; - &fallback - } - }; + if module.is_none() && !model.gradle.is_empty() { + return Ok(model.gradle.iter().flat_map(|g| gradle_step(root, g, kind, None)).collect()); + } + let g = module + .and_then(|m| model.gradle_for_module(root, m)) + .ok_or_else(|| IdeError::not_found("module does not belong to a linked Gradle build"))?; + Ok(gradle_step(root, g, kind, module)) +} + +fn gradle_step(root: &Path, g: &crate::GradleModel, kind: BuildKind, module: Option<&Path>) -> Vec { let prefix = module .and_then(|m| { let rel = rel_str(root, m); @@ -296,7 +304,7 @@ fn gradle_steps(root: &Path, model: &BuildModel, kind: BuildKind, module: Option let mut s = BuildStep::new(&g.command, &["--console=plain"], &subdir(root, &g.dir), Format::Gradle); s.args.extend(tasks); s.env = g.env.clone(); - Ok(vec![s]) + vec![s] } fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec { @@ -811,6 +819,77 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec(root: &Path, dirs: impl IntoIterator) pub fn load(root: &Path) -> Option { let dir = build_dir(root)?; + load_build(root, dir.strip_prefix(root).unwrap_or(&dir)) +} + +/// Loads exactly this build, without searching its child folders. +pub fn load_build(root: &Path, dir: &Path) -> Option { + let dir = root.join(dir); + if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) { + return None; + } let settings = read_first(&dir, SETTINGS_FILES).map(|(_, t)| parse_settings(&t)).unwrap_or_default(); let root_name = settings.name.unwrap_or_else(|| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); @@ -455,6 +464,56 @@ pub fn load(root: &Path) -> Option { Some(GradleModel { dir: rel(root, &dir), projects, java_version, command: wrapper(&dir), ..GradleModel::default() }) } +/// Validates user-selected build folders and stores a stable, deduplicated identity. +pub fn normalize_linked_projects(root: &Path, dirs: &[String]) -> ide_core::IdeResult> { + let root = dunce::canonicalize(root)?; + let mut out = Vec::new(); + for dir in dirs { + let path = dunce::canonicalize(root.join(dir))?; + if !path.is_dir() || !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| path.join(name).is_file()) { + return Err(ide_core::IdeError::invalid(format!("No Gradle build in {}", path.display()))); + } + let dir = rel(&root, &path); + if !out.contains(&dir) { + out.push(dir); + } + } + Ok(out) +} + +/// Linked build identities without reading their scripts (also used by index hot paths). +pub(crate) fn linked_dirs(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec { + match &settings.linked_projects { + None => build_dir(root).map(|dir| rel(root, &dir)).into_iter().collect(), + Some(dirs) => { + let mut out = Vec::new(); + for dir in dirs { + if let Ok(dirs) = normalize_linked_projects(root, std::slice::from_ref(dir)) { + for dir in dirs { + if !out.contains(&dir) { + out.push(dir); + } + } + } + } + out + } + } +} + +pub fn load_linked(root: &Path, settings: &ide_core::settings::GradleSettings) -> Vec { + linked_dirs(root, settings).iter().filter_map(|dir| load_build(root, Path::new(dir))).collect() +} + +/// A build's task paths are local to it, so sync caches must be local to its identity too. +pub fn cache_dir(cache: &Path, build_dir: &str) -> PathBuf { + if build_dir.is_empty() { + return cache.to_path_buf(); + } + let hash = build_dir.bytes().fold(0xcbf29ce484222325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100000001b3)); + cache.join("gradle").join(format!("{hash:016x}")) +} + /// `JavaLanguageVersion.of(21)` / `sourceCompatibility = JavaVersion.VERSION_17` in a build script. pub fn script_java_version(text: &str) -> Option { let number = |s: &str| -> Option { @@ -616,6 +675,41 @@ pub(crate) fn has_war_plugin(script: &str) -> bool { #[cfg(test)] mod tests { + #[test] + fn explicit_linkage_preserves_default_discovery_and_can_unlink_every_build() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for dir in ["a", "b"] { + std::fs::create_dir(root.join(dir)).unwrap(); + std::fs::write(root.join(dir).join("settings.gradle"), format!("rootProject.name = '{dir}'")).unwrap(); + } + assert_eq!(super::load_linked(root, &Default::default())[0].dir, "a"); + let mut settings = ide_core::settings::GradleSettings { + linked_projects: Some(vec!["b".into(), "./b".into(), "a".into()]), + ..Default::default() + }; + let models = super::load_linked(root, &settings); + assert_eq!(models.iter().map(|g| g.dir.as_str()).collect::>(), ["b", "a"]); + settings.linked_projects = Some(vec![]); + assert!(super::load_linked(root, &settings).is_empty()); + assert!(super::normalize_linked_projects(root, &["missing".into()]).is_err()); + assert!(super::normalize_linked_projects(root, &["".into()]).is_err()); + assert!(super::load_build(root, root).is_none(), "exact loading never picks a child"); + } + + #[cfg(unix)] + #[test] + fn linked_directories_deduplicate_symlinks_and_keep_external_builds() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("workspace"); + let external = temp.path().join("external"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(&external).unwrap(); + std::fs::write(external.join("build.gradle"), "").unwrap(); + std::os::unix::fs::symlink(&external, root.join("alias")).unwrap(); + let dirs = super::normalize_linked_projects(&root, &["alias".into(), "../external".into()]).unwrap(); + assert_eq!(dirs, [external.canonicalize().unwrap().to_string_lossy().to_string()]); + } #[test] fn script_dependencies_read_string_and_map_notations() { let script = r#" diff --git a/crates/ide-build/src/gradle_deps.rs b/crates/ide-build/src/gradle_deps.rs index 40c90202..109fea96 100644 --- a/crates/ide-build/src/gradle_deps.rs +++ b/crates/ide-build/src/gradle_deps.rs @@ -126,6 +126,7 @@ fn project_path(text: &str) -> String { /// `configuration` and returns its tree. pub fn gradle_dependencies( root: &Path, + model: &gradle::GradleModel, options: &SyncOptions, project: &str, configuration: &str, @@ -137,7 +138,6 @@ pub fn gradle_dependencies( if !project.starts_with(':') || project.chars().any(char::is_whitespace) { return Err(IdeError::invalid(format!("not a Gradle project path: {project}"))); } - let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?; let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") }; let mut args: Vec = vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()]; @@ -226,8 +226,12 @@ runtimeClasspath - Runtime classpath of source set 'main'. let tmp = tempfile::tempdir().unwrap(); let options = SyncOptions::default(); let cancel = CancellationToken::new(); - assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err()); - assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err()); + assert!( + gradle_dependencies(tmp.path(), &Default::default(), &options, ":", "x --init-script y", &cancel).is_err() + ); + assert!( + gradle_dependencies(tmp.path(), &Default::default(), &options, "app", "runtimeClasspath", &cancel).is_err() + ); } #[cfg(unix)] @@ -246,8 +250,15 @@ runtimeClasspath - Runtime classpath of source set 'main'. offline: true, ..Default::default() }; - let tree = - gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap(); + let tree = gradle_dependencies( + root, + &gradle::load(root).unwrap(), + &options, + ":app", + "testRuntimeClasspath", + &CancellationToken::new(), + ) + .unwrap(); assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]); assert_eq!( std::fs::read_to_string(root.join("args")).unwrap().trim(), diff --git a/crates/ide-build/src/lib.rs b/crates/ide-build/src/lib.rs index ab0588c4..31541854 100644 --- a/crates/ide-build/src/lib.rs +++ b/crates/ide-build/src/lib.rs @@ -20,7 +20,9 @@ mod xml; pub use gradle::{GradleModel, GradleProject, GradleTask}; pub use gradle_deps::{GradleDependency, gradle_dependencies}; pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission}; -pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven}; +pub use sync::{ + SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_gradle_model, sync_maven, +}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -38,13 +40,31 @@ pub enum BuildTool { #[serde(rename_all = "camelCase")] pub struct BuildModel { pub maven: Option, - pub gradle: Option, + #[serde(default)] + pub gradle: Vec, +} + +impl BuildModel { + /// Prefer the deepest linked build owning the module over a composite including it. + pub fn gradle_for_module(&self, root: &Path, module: &Path) -> Option<&GradleModel> { + let module = root.join(module); + self.gradle.iter().filter(|g| g.projects.iter().any(|p| root.join(&p.dir) == module)).min_by_key(|g| { + let dir = root.join(&g.dir); + std::cmp::Reverse(if module.starts_with(&dir) { dir.components().count() } else { 0 }) + }) + } } /// 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()) + load_linked( + root, + cache_dir, + maven_repo, + &ide_core::settings::MavenSettings::default(), + &ide_core::settings::GradleSettings::default(), + ) } /// Loads only linked Maven reactors, retaining an empty model so projects can be re-added. @@ -53,6 +73,7 @@ pub fn load_linked( cache_dir: &Path, maven_repo: Option, settings: &ide_core::settings::MavenSettings, + gradle_settings: &ide_core::settings::GradleSettings, ) -> BuildModel { let maven = maven::is_maven_project(root).then(|| { let profiles = sync::maven_profiles(cache_dir); @@ -61,23 +82,50 @@ pub fn load_linked( sync::apply_maven_cache(root, cache_dir, &mut m); m }); - let gradle = gradle::load(root).map(|mut g| { - sync::apply_gradle_cache(root, cache_dir, &mut g); - g - }); + let gradle = gradle::load_linked(root, gradle_settings) + .into_iter() + .map(|mut g| { + sync::apply_gradle_cache(root, &gradle::cache_dir(cache_dir, &g.dir), &mut g); + g + }) + .collect(); BuildModel { maven, gradle } } /// Gradle project folders outside `root` as of the last sync (`includeFlat` siblings): /// extra content roots, as IDEA lists module roots beside the project folder. /// Reads only the sync's cached projects (no build script), so it is cheap enough for hot paths. -pub fn gradle_external_roots(root: &Path, cache_dir: &Path) -> Vec { - gradle::external_roots(root, sync::reported_gradle_projects(cache_dir).iter().map(|p| p.dir.as_str())) +pub fn gradle_external_roots( + root: &Path, + cache_dir: &Path, + settings: &ide_core::settings::GradleSettings, +) -> Vec { + let builds = gradle::linked_dirs(root, settings); + let dirs: Vec = builds + .iter() + .flat_map(|dir| { + std::iter::once(root.join(dir).to_string_lossy().into_owned()).chain( + sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)) + .iter() + .map(|p| p.dir.clone()) + .collect::>(), + ) + }) + .collect(); + gradle::external_roots(root, dirs.iter().map(String::as_str)) } /// Source, resource and build folders of the last Gradle sync, absolute. -pub fn gradle_source_roots(cache_dir: &Path) -> Vec<(PathBuf, gradle::ReportedRoot)> { - gradle::reported_source_roots(&sync::reported_gradle_projects(cache_dir)) +pub fn gradle_source_roots( + root: &Path, + cache_dir: &Path, + settings: &ide_core::settings::GradleSettings, +) -> Vec<(PathBuf, gradle::ReportedRoot)> { + let reported = gradle::linked_dirs(root, settings) + .iter() + .flat_map(|dir| sync::reported_gradle_projects(&gradle::cache_dir(cache_dir, dir)).as_ref().clone()) + .collect::>(); + gradle::reported_source_roots(&reported) } /// A library the project depends on, as `groupId:artifactId:version`. @@ -121,7 +169,7 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec bool { mod tests { use super::*; + #[test] + fn external_linked_build_is_indexed_before_sync_and_disappears_when_unlinked() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("workspace"); + let external = temp.path().join("external"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(&external).unwrap(); + std::fs::write(external.join("settings.gradle"), "").unwrap(); + let mut settings = ide_core::settings::GradleSettings { + linked_projects: Some(vec![external.to_string_lossy().into_owned()]), + ..Default::default() + }; + assert_eq!( + gradle_external_roots(&root, &temp.path().join("cache"), &settings), + [external.canonicalize().unwrap()] + ); + settings.linked_projects = Some(vec![]); + assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty()); + } + #[test] fn detects_build_systems_and_build_files() { let tmp = tempfile::tempdir().unwrap(); @@ -158,7 +226,7 @@ mod tests { std::fs::write(root.join("build.gradle"), "").unwrap(); let m = load(&root, &cache, None); assert_eq!(m.maven.unwrap().projects[0].pom, "svc/pom.xml"); - assert_eq!(m.gradle.unwrap().projects[0].path, ":"); + assert_eq!(m.gradle[0].projects[0].path, ":"); assert!(affects_build(Path::new("a/pom.xml")) && affects_build(Path::new("settings.gradle.kts"))); assert!(!affects_build(Path::new("src/Main.java"))); } @@ -205,7 +273,7 @@ mod tests { ], ..Default::default() }; - let gradle = gradle::load(root); + let gradle = gradle::load(root).into_iter().collect(); let model = BuildModel { maven: Some(maven), gradle }; let names: Vec = dependency_coordinates(root, &model) .into_iter() diff --git a/crates/ide-build/src/sync.rs b/crates/ide-build/src/sync.rs index 1a252440..eca3daa4 100644 --- a/crates/ide-build/src/sync.rs +++ b/crates/ide-build/src/sync.rs @@ -373,8 +373,25 @@ pub fn sync_gradle( cancel: &CancellationToken, progress: &dyn Fn(SyncProgress), ) -> IdeResult<()> { - let mut model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?; - std::fs::create_dir_all(cache_dir)?; + let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?; + sync_gradle_model(root, cache_dir, &model, options, cancel, progress) +} + +/// Syncs this exact linked build; another build's task paths and cache cannot interfere. +pub fn sync_gradle_model( + root: &Path, + cache_dir: &Path, + model: &GradleModel, + options: &SyncOptions, + cancel: &CancellationToken, + progress: &dyn Fn(SyncProgress), +) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + let mut model = model.clone(); + let cache_dir = gradle::cache_dir(cache_dir, &model.dir); + std::fs::create_dir_all(&cache_dir)?; let init_script = cache_dir.join("openidea-model.gradle"); std::fs::write(&init_script, MODEL_INIT_SCRIPT)?; let mut args: Vec = vec![ @@ -389,14 +406,24 @@ pub fn sync_gradle( args.push("--offline".into()); } progress(SyncProgress { message: "Loading Gradle tasks".into(), fraction: None }); - let out = run( + let out = match run( options.gradle_command.as_deref().unwrap_or(&model.command), &args, &root.join(&model.dir), &options.env, cancel, |_| {}, - )?; + ) { + Ok(out) => out, + Err(_) if cancel.is_cancelled() => return Err(IdeError::cancelled()), + Err(error) if error.kind == ide_core::error::ErrorKind::Unavailable => { + Output { success: false, text: error.message } + } + Err(error) => return Err(error), + }; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } let projects = if out.success { parse_reported_projects(&out.text) } else { Vec::new() }; // Hash the build files of the reported projects, or the model is stale right after the sync. gradle::merge_reported_projects(root, &mut model, &projects); @@ -407,13 +434,128 @@ pub fn sync_gradle( tasks: if out.success { gradle::parse_tasks(&out.text) } else { Vec::new() }, projects, }; - write_cache(cache_dir, GRADLE_CACHE, &cache) + write_cache(&cache_dir, GRADLE_CACHE, &cache) } #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + #[test] + fn unavailable_gradle_launchers_are_cached_without_stopping_other_builds() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for dir in ["a", "b"] { + std::fs::create_dir(root.join(dir)).unwrap(); + std::fs::write(root.join(dir).join("build.gradle"), "").unwrap(); + } + let wrapper = root.join("b/gradlew"); + std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' 'Build tasks' '-----------' 'healthy - ok'\n").unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let settings = ide_core::settings::GradleSettings { + linked_projects: Some(vec!["a".into(), "b".into()]), + ..Default::default() + }; + let mut models = gradle::load_linked(root, &settings); + models[0].command = root.join("a/missing-gradlew").to_string_lossy().into_owned(); + let cache = root.join("cache"); + let run = |model: &GradleModel| { + sync_gradle_model(root, &cache, model, &SyncOptions::default(), &CancellationToken::new(), &|_| {}) + }; + for model in &models { + run(model).unwrap(); + } + let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings); + assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start")); + assert!(loaded.gradle[1].sync_error.is_none()); + assert_eq!(loaded.gradle[1].projects[0].tasks[0].name, "healthy"); + let bad_wrapper = root.join("a/gradlew"); + std::fs::write(&bad_wrapper, "#!/bin/sh\nexit 0\n").unwrap(); + std::fs::set_permissions(&bad_wrapper, std::fs::Permissions::from_mode(0o644)).unwrap(); + models[0].command = bad_wrapper.to_string_lossy().into_owned(); + run(&models[0]).unwrap(); + let loaded = crate::load_linked(root, &cache, None, &Default::default(), &settings); + assert!(loaded.gradle[0].sync_error.as_deref().unwrap().contains("cannot start")); + assert!(loaded.gradle[1].sync_error.is_none()); + let blocked_cache = root.join("blocked-cache"); + std::fs::write(&blocked_cache, "a file cannot contain sync caches").unwrap(); + assert!( + sync_gradle_model( + root, + &blocked_cache, + &models[0], + &SyncOptions::default(), + &CancellationToken::new(), + &|_| {} + ) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn independent_syncs_do_not_share_tasks_launchers_environments_or_failures() { + use std::os::unix::fs::PermissionsExt; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for dir in ["a", "b"] { + let build = root.join(dir); + std::fs::create_dir_all(build.join("app")).unwrap(); + std::fs::write(build.join("settings.gradle"), "include 'app'").unwrap(); + std::fs::write(build.join("app/build.gradle"), "").unwrap(); + let report = serde_json::json!({"path": ":app", "dir": build.join("app")}); + let launcher = build.join("gradlew"); + std::fs::write( + &launcher, + format!("#!/bin/sh\npwd > actual-cwd\nprintf '%s' \"$JAVA_HOME\" > actual-env\nprintf '%s\\n' '@openidea-model\t{report}' 'Build tasks' '-----------' 'app:custom{dir} - custom task'\n"), + ).unwrap(); + std::fs::set_permissions(launcher, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let settings = ide_core::settings::GradleSettings { + linked_projects: Some(vec!["a".into(), "b".into()]), + ..Default::default() + }; + let cache = root.join("cache"); + let models = gradle::load_linked(root, &settings); + for model in &models { + let options = SyncOptions { + env: BTreeMap::from([("JAVA_HOME".into(), format!("/jdk-{}", model.dir))]), + ..Default::default() + }; + sync_gradle_model(root, &cache, model, &options, &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!( + std::fs::read_to_string(root.join(&model.dir).join("actual-env")).unwrap(), + format!("/jdk-{}", model.dir) + ); + assert_eq!( + std::fs::read_to_string(root.join(&model.dir).join("actual-cwd")).unwrap().trim(), + root.join(&model.dir).canonicalize().unwrap().to_string_lossy() + ); + } + let load = || crate::load_linked(root, &cache, None, &Default::default(), &settings); + let model = load(); + assert_eq!(model.gradle[0].projects[0].tasks[0].name, "customa"); + assert_eq!(model.gradle[1].projects[0].tasks[0].name, "customb"); + assert_ne!(gradle::cache_dir(&cache, "a"), gradle::cache_dir(&cache, "b")); + let before = std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(); + std::fs::write(root.join("b/gradlew"), "#!/bin/sh\nprintf 'FAILURE: broken b\\n'\nexit 1\n").unwrap(); + sync_gradle_model(root, &cache, &models[1], &SyncOptions::default(), &CancellationToken::new(), &|_| {}) + .unwrap(); + let model = load(); + assert!(model.gradle[0].sync_error.is_none()); + assert!(model.gradle[1].sync_error.is_some()); + assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!(sync_gradle_model(root, &cache, &models[0], &SyncOptions::default(), &cancel, &|_| {}).is_err()); + assert_eq!(std::fs::read(gradle::cache_dir(&cache, "a").join(GRADLE_CACHE)).unwrap(), before); + let unlinked = ide_core::settings::GradleSettings { linked_projects: Some(vec![]), ..Default::default() }; + assert!(crate::load_linked(root, &cache, None, &Default::default(), &unlinked).gradle.is_empty()); + assert!(crate::gradle_source_roots(root, &cache, &unlinked).is_empty()); + } + #[cfg(unix)] #[test] fn gradle_sync_uses_resolved_command_and_environment() { @@ -601,7 +743,7 @@ mod tests { write(&projects); let first = reported_gradle_projects(&cache_dir); assert!(Arc::ptr_eq(&first, &reported_gradle_projects(&cache_dir)), "unchanged: not parsed again"); - assert_eq!(crate::gradle_external_roots(&root, &cache_dir), [billing]); + assert_eq!(crate::gradle_external_roots(&root, &cache_dir, &Default::default()), [billing]); write(&projects[..1]); assert_eq!(reported_gradle_projects(&cache_dir).len(), 1, "a new sync is read"); std::fs::remove_file(cache_dir.join(GRADLE_CACHE)).unwrap(); diff --git a/crates/ide-build/src/web.rs b/crates/ide-build/src/web.rs index 9f25039a..fbff22b7 100644 --- a/crates/ide-build/src/web.rs +++ b/crates/ide-build/src/web.rs @@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec { }); } } - if let Some(gradle) = &model.gradle { + for gradle in &model.gradle { for p in &gradle.projects { let script = p.build_file.as_ref().and_then(|f| fs::read_to_string(root.join(f)).ok()).unwrap_or_default(); if p.tasks.iter().any(|t| t.name == "war") || crate::gradle::has_war_plugin(&script) { out.push(WebArtifact { - id: format!("gradle:{}", p.path), + id: gradle_artifact_id(gradle, &p.path), name: p.name.clone(), module: p.dir.clone(), tool: "gradle".into(), @@ -42,6 +42,14 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec { out } +fn gradle_artifact_id(build: &crate::GradleModel, path: &str) -> String { + if build.dir.is_empty() { + format!("gradle:{path}") + } else { + format!("gradle:{}", serde_json::to_string(&(&build.dir, path)).unwrap()) + } +} + pub struct WebBuild { pub command: String, pub args: Vec, @@ -150,9 +158,14 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu maven_project: Some((probe, output)), }); } - if let Some(path) = id.strip_prefix("gradle:") { - let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?; - if !artifacts(root, model).iter().any(|a| a.id == id) { + if let Some(legacy_path) = id.strip_prefix("gradle:") { + let identity = serde_json::from_str::<(String, String)>(legacy_path).ok(); + let (g, path) = match &identity { + Some((dir, path)) => (model.gradle.iter().find(|g| g.dir == *dir), path.as_str()), + None => (model.gradle.first(), legacy_path), + }; + let g = g.ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?; + if !artifacts(root, model).iter().any(|a| a.id == gradle_artifact_id(g, path)) { return Err(IdeError::invalid("Choose a Gradle WAR module")); } let script = cache.join("war.init.gradle"); @@ -186,6 +199,28 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu #[cfg(test)] mod tests { + #[test] + fn independent_war_modules_have_distinct_ids_and_build_in_their_own_directory() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path(); + for dir in ["a", "b"] { + std::fs::create_dir(root.join(dir)).unwrap(); + std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap(); + } + let settings = ide_core::settings::GradleSettings { + linked_projects: Some(vec!["a".into(), "b".into()]), + ..Default::default() + }; + let model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings); + let artifacts = super::artifacts(root, &model); + assert_eq!(artifacts.len(), 2); + assert_ne!(artifacts[0].id, artifacts[1].id); + for (artifact, dir) in artifacts.iter().zip(["a", "b"]) { + let plan = super::build(root, &model, &artifact.id, &root.join(format!("cache/{dir}"))).unwrap(); + assert_eq!(plan.cwd, dir); + } + assert_eq!(super::build(root, &model, "gradle::", &root.join("legacy")).unwrap().cwd, "a"); + } use super::*; #[test] fn maven_war_modules_use_resolved_artifact_file() { diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 4b41cfe7..b0b86e6d 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -828,6 +828,8 @@ pub struct JavaSettings { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct GradleSettings { + /// Linked build directories; absent preserves automatic discovery, empty unlinks all builds. + pub linked_projects: Option>, /// Empty = Project JDK; otherwise a JDK home. pub jvm: String, pub distribution: GradleDistribution, @@ -1124,6 +1126,16 @@ mod maven_linkage_tests { mod gradle_settings_tests { use super::*; + #[test] + fn absent_and_explicitly_empty_gradle_links_remain_distinct() { + let automatic: GradleSettings = serde_json::from_str("{}").unwrap(); + let unlinked: GradleSettings = serde_json::from_str(r#"{"linkedProjects":[]}"#).unwrap(); + assert_eq!(automatic.linked_projects, None); + assert_eq!(unlinked.linked_projects, Some(vec![])); + let restored: GradleSettings = serde_json::from_str(&serde_json::to_string(&unlinked).unwrap()).unwrap(); + assert_eq!(restored.linked_projects, Some(vec![])); + } + #[test] fn defaults_and_round_trip() { let old: JavaSettings = serde_json::from_str(r#"{"jdk":"/jdk"}"#).unwrap(); @@ -1136,6 +1148,7 @@ mod gradle_settings_tests { home: "/gradle".into(), user_home: "/cache/gradle".into(), run_using: GradleRunUsing::Ide, + linked_projects: Some(vec!["backend".into(), "../shared".into()]), }; let restored: Settings = serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap(); assert_eq!(restored.languages.java.gradle, settings.languages.java.gradle); diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 15337380..5d0fb052 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -32,6 +32,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { cache, ide_sdk::apply::maven_local_repository(root, settings), &settings.languages.java.maven, + &settings.languages.java.gradle, ); let jdk = ide_sdk::apply::project_jdk(root, settings); if let Some(m) = &mut model.maven { @@ -45,7 +46,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { p.options = options.clone(); } } - if let Some(g) = &mut model.gradle { + for g in &mut model.gradle { g.env = ide_sdk::apply::gradle_env(root, settings, java_env(jdk.as_ref(), g.java_version)); g.command = ide_sdk::apply::gradle_command(root, &root.join(&g.dir), settings).to_string_lossy().into_owned(); } @@ -65,11 +66,12 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { let hint = m.projects.iter().find_map(|p| missing(p.java_version)); explain(&mut m.sync_error, hint); } - if let Some(g) = &mut model.gradle - && g.sync_error.is_some() - { - let hint = if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None }; - explain(&mut g.sync_error, hint); + for g in &mut model.gradle { + if g.sync_error.is_some() { + let hint = + if settings.languages.java.gradle.jvm.trim().is_empty() { missing(g.java_version) } else { None }; + explain(&mut g.sync_error, hint); + } } model } @@ -94,9 +96,88 @@ pub async fn build_model(host: &Host, window: &str) -> IdeResult { tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err) } +fn linked_gradle_paths( + root: &Path, + settings: &ide_core::settings::GradleSettings, + paths: &[String], + link: bool, +) -> IdeResult> { + let existing = settings + .linked_projects + .clone() + .unwrap_or_else(|| ide_build::gradle::load_linked(root, settings).into_iter().map(|g| g.dir).collect()); + let mut linked = Vec::new(); + for path in existing { + let normalized = ide_build::gradle::normalize_linked_projects(root, std::slice::from_ref(&path)) + .unwrap_or_else(|_| vec![path]); + for path in normalized { + if !linked.contains(&path) { + linked.push(path); + } + } + } + if link { + let directories: Vec = paths + .iter() + .map(|path| { + let path = root.join(path); + let is_script = path.file_name().and_then(|n| n.to_str()).is_some_and(|name| { + ide_build::gradle::SETTINGS_FILES.contains(&name) || ide_build::gradle::BUILD_FILES.contains(&name) + }); + if is_script && path.is_file() { + path.parent().unwrap_or(root).to_string_lossy().into_owned() + } else { + path.to_string_lossy().into_owned() + } + }) + .collect(); + for path in ide_build::gradle::normalize_linked_projects(root, &directories)? { + if !linked.contains(&path) { + linked.push(path); + } + } + } else { + linked.retain(|path| !paths.contains(path)); + } + Ok(linked) +} + +async fn set_gradle_projects(host: &Host, window: &str, paths: Vec, link: bool) -> IdeResult { + host.require_trust(window)?; + let project = host.project(window)?; + tokio::task::spawn_blocking(move || { + if ide_workspace::workspace::Workspace::load(&project.root)?.is_some() { + return Err(IdeError::invalid("Open a workspace member as a project to link Gradle builds")); + } + let linked = + linked_gradle_paths(&project.root, &project.settings.effective().languages.java.gradle, &paths, link)?; + let key = "languages.java.gradle.linkedProjects"; + let settings = + project.settings.set(ide_workspace::settings::SettingsScope::Project, key, serde_json::json!(linked))?; + project.refresh_index_scope(); + project.lsp.reconfigure_soon(); + project.sink.emit(IdeEvent::SettingsChanged { key: key.into() }); + project.sink.emit(IdeEvent::ProjectRootsChanged { root: project.root.clone().into() }); + Ok::(load(&project.root, &project.build_cache_dir(), &settings)) + }) + .await + .map_err(join_err)? +} + +/// Links independent builds without changing their Gradle scripts. +pub async fn gradle_link_projects(host: &Host, window: &str, paths: Vec) -> IdeResult { + set_gradle_projects(host, window, paths, true).await +} + +/// Stops importing the selected independent builds; files and saved run configurations stay intact. +pub async fn gradle_unlink_projects(host: &Host, window: &str, paths: Vec) -> IdeResult { + set_gradle_projects(host, window, paths, false).await +} + /// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the /// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`. /// Returns the refreshed model; a failed build is reported in its `syncError`. +#[allow(clippy::too_many_arguments)] pub async fn build_sync( host: &Host, window: &str, @@ -104,6 +185,7 @@ pub async fn build_sync( request: RequestId, offline: bool, profiles: Vec, + build_dir: Option, on_progress: Channel, ) -> IdeResult { host.require_trust(window)?; @@ -122,6 +204,8 @@ pub async fn build_sync( project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() }); } let token = host.begin_project_request(window, request); + let completion_token = token.clone(); + let original_links = settings.languages.java.gradle.linked_projects.clone(); let result = tokio::task::spawn_blocking(move || { let current = load(&root, &cache, &settings); let env = match tool { @@ -131,7 +215,7 @@ pub async fn build_sync( .and_then(|m| m.projects.iter().find(|p| !p.env.is_empty())) .map(|p| p.env.clone()) .unwrap_or_default(), - BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(), + BuildTool::Gradle => BTreeMap::new(), }; let options = SyncOptions { offline, @@ -140,14 +224,31 @@ pub async fn build_sync( 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(), - gradle_command: current.gradle.as_ref().map(|g| g.command.clone()), + gradle_command: None, }; let progress = |p: SyncProgress| { let _ = on_progress.send(p); }; match tool { BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?, - BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?, + BuildTool::Gradle => { + if let Some(dir) = &build_dir + && !current.gradle.iter().any(|g| &g.dir == dir) + { + return Err(IdeError::not_found("Gradle project is no longer linked")); + } + for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) { + if token.is_cancelled() { + return Err(IdeError::cancelled()); + } + let options = + SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() }; + ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?; + } + if token.is_cancelled() { + return Err(IdeError::cancelled()); + } + } } Ok::(load(&root, &cache, &settings)) }) @@ -155,6 +256,18 @@ pub async fn build_sync( .map_err(join_err); host.end_request(request); let model = result??; + if completion_token.is_cancelled() { + return Err(IdeError::cancelled()); + } + if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects + { + // A late sync must not re-import a build that was unlinked while it ran. + return tokio::task::spawn_blocking(move || { + load(&project.root, &project.build_cache_dir(), &project.settings.effective()) + }) + .await + .map_err(join_err); + } if tool == BuildTool::Gradle { // The sync may report project folders outside the project (`includeFlat`): index and list them. project.refresh_index_scope(); @@ -247,20 +360,26 @@ pub async fn gradle_dependencies( configuration: String, offline: bool, request: RequestId, + build_dir: Option, ) -> IdeResult> { host.require_trust(window)?; let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); let token = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { - let gradle = load(&root, &cache, &settings).gradle; + let model = load(&root, &cache, &settings); + let gradle = model + .gradle + .iter() + .find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir)) + .ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?; let options = SyncOptions { offline, - env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(), - gradle_command: gradle.as_ref().map(|g| g.command.clone()), + env: gradle.env.clone(), + gradle_command: Some(gradle.command.clone()), ..Default::default() }; - ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token) + ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token) }) .await .map_err(join_err); @@ -382,6 +501,11 @@ pub async fn build_run( /// Commands of this module the remote server answers. pub(crate) fn register(d: &mut Dispatcher) { d.add("build_model", |c| async move { build_model(&c.host, &c.window).await }); + d.add("gradle_link_projects", |c| async move { gradle_link_projects(&c.host, &c.window, c.arg("paths")?).await }); + d.add( + "gradle_unlink_projects", + |c| async move { gradle_unlink_projects(&c.host, &c.window, c.arg("paths")?).await }, + ); d.add("build_sync", |c| async move { build_sync( &c.host, @@ -390,6 +514,7 @@ pub(crate) fn register(d: &mut Dispatcher) { c.arg("request")?, c.arg("offline")?, c.arg("profiles")?, + c.arg("buildDir")?, c.sender("onProgress")?, ) .await @@ -405,6 +530,7 @@ pub(crate) fn register(d: &mut Dispatcher) { c.arg("configuration")?, c.arg("offline")?, c.arg("request")?, + c.arg("buildDir")?, ) .await }); @@ -432,6 +558,48 @@ pub(crate) fn register(d: &mut Dispatcher) { mod gradle_settings_tests { use super::*; + #[test] + fn linking_retains_the_implicit_build_and_unlinking_all_stays_empty() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + for name in ["a", "b"] { + std::fs::create_dir(root.join(name)).unwrap(); + std::fs::write(root.join(name).join("settings.gradle"), format!("rootProject.name = '{name}'")).unwrap(); + } + let mut settings = ide_core::settings::GradleSettings::default(); + let linked = linked_gradle_paths(&root, &settings, &["b/settings.gradle".into()], true).unwrap(); + assert_eq!(linked, ["a", "b"]); + settings.linked_projects = Some(linked); + assert_eq!(linked_gradle_paths(&root, &settings, &["b".into()], true).unwrap(), ["a", "b"]); + assert!(linked_gradle_paths(&root, &settings, &["missing".into()], true).is_err()); + settings.linked_projects = Some(vec!["./a".into(), root.join("b").to_string_lossy().into_owned()]); + settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["a".into()], false).unwrap()); + assert_eq!(settings.linked_projects.as_ref().unwrap(), &["b"]); + settings.linked_projects = Some(linked_gradle_paths(&root, &settings, &["b".into()], false).unwrap()); + assert!(ide_build::gradle::load_linked(&root, &settings).is_empty()); + assert!(root.join("a/settings.gradle").is_file()); + assert!(root.join("b/settings.gradle").is_file()); + } + + #[test] + fn settings_resolve_each_linked_builds_own_wrapper() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let mut settings = Settings::default(); + settings.languages.java.gradle.linked_projects = Some(vec!["a".into(), "b".into()]); + let executable = if cfg!(windows) { "gradlew.bat" } else { "gradlew" }; + for dir in ["a", "b"] { + std::fs::create_dir(root.join(dir)).unwrap(); + std::fs::write(root.join(dir).join("build.gradle"), "plugins { id 'java' }").unwrap(); + std::fs::write(root.join(dir).join(executable), "").unwrap(); + } + let model = load(&root, &root.join("cache"), &settings); + assert_eq!(model.gradle.len(), 2); + for g in model.gradle { + assert_eq!(g.command, root.join(&g.dir).join(executable).to_string_lossy()); + } + } + #[test] fn model_for_sync_tasks_and_compile_uses_gradle_settings() { let tmp = tempfile::tempdir().unwrap(); @@ -440,13 +608,13 @@ mod gradle_settings_tests { let wrapper = root.join(if cfg!(windows) { "gradlew.bat" } else { "gradlew" }); std::fs::write(&wrapper, "").unwrap(); let mut settings = Settings::default(); - let default = load(root, &root.join("cache"), &settings).gradle.unwrap(); + let default = load(root, &root.join("cache"), &settings).gradle.remove(0); assert_eq!(default.command, wrapper.to_string_lossy()); settings.languages.java.gradle.jvm = "jdk21".into(); settings.languages.java.gradle.user_home = "user-home".into(); settings.languages.java.gradle.distribution = ide_core::settings::GradleDistribution::Local; settings.languages.java.gradle.home = "gradle-install".into(); - let model = load(root, &root.join("cache"), &settings).gradle.unwrap(); + let model = load(root, &root.join("cache"), &settings).gradle.remove(0); assert_eq!( model.command, root.join("gradle-install") diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 459a96d5..f739d95c 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -196,7 +196,8 @@ pub fn set_settings( } let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.") - || "languages.java.maven.unlinkedProjects".starts_with(key); + || "languages.java.maven.unlinkedProjects".starts_with(key) + || "languages.java.gradle.linkedProjects".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/project.rs b/crates/ide-host/src/project.rs index af9ebb4a..0c5e9648 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -230,6 +230,11 @@ impl Host { if cfg.name == "jdtls" { let (excluded_poms, build_files) = jdtls_maven_import(root, &effective); ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms, build_files.as_deref()); + ide_sdk::apply::configure_gradle_import( + cfg, + effective.languages.java.gradle.linked_projects.as_deref(), + build_files.as_deref(), + ); } })); } @@ -876,8 +881,14 @@ impl Project { /// Effective source / test / resource / excluded roots (detected + Project Structure marks). pub fn roots(&self) -> ProjectRoots { - let detected = with_gradle_roots(&self.root, &self.build_cache_dir(), &self.detected_roots.read()); - linked_roots(&self.root, &self.settings.effective(), &detected) + let settings = self.settings.effective(); + let detected = with_gradle_roots( + &self.root, + &self.build_cache_dir(), + &settings.languages.java.gradle, + &self.detected_roots.read(), + ); + linked_roots(&self.root, &settings, &detected) } /// Where the Maven / Gradle tool windows cache what the build tools reported. @@ -1078,7 +1089,7 @@ fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cac } let mut out: Vec = Vec::new(); let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim())); - for p in user.chain(ide_build::gradle_external_roots(root, build_cache)) { + for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) { if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) { out.push(p); } @@ -1182,31 +1193,93 @@ pub(crate) fn jdtls_maven_import( settings: &ide_core::settings::Settings, ) -> (Vec, Option>) { let maven = &settings.languages.java.maven; - if maven.unlinked_projects.is_empty() { + let gradle = &settings.languages.java.gradle; + if maven.unlinked_projects.is_empty() && gradle.linked_projects.is_none() { return (Vec::new(), None); } let model = ide_build::maven::load(root); let unlinked = ide_build::maven::UnlinkedProjects::from_model(&model, maven); let excluded = unlinked.poms().map(PathBuf::from).collect(); - let build_files = unlinked.import_poms(&model).map(|poms| { - let gradle = ide_build::gradle::load(root).and_then(|g| { + let poms = unlinked.import_poms(&model).or_else(|| { + gradle.linked_projects.as_ref().map(|_| { + model + .projects + .iter() + .filter(|p| !p.aggregated && !unlinked.contains(&p.pom)) + .map(|p| p.pom.clone()) + .collect() + }) + }); + let build_files = poms.map(|poms| { + let gradle = ide_build::gradle::load_linked(root, gradle).into_iter().filter_map(|g| { let dir = root.join(&g.dir); let names = ide_build::gradle::SETTINGS_FILES.iter().chain(ide_build::gradle::BUILD_FILES); names.map(|n| dir.join(n)).find(|f| f.is_file()) }); - poms.iter().map(|pom| root.join(pom)).chain(gradle).map(|f| ide_lsp::path_to_uri_string(&f)).collect() + let eclipse = if settings.languages.java.gradle.linked_projects.is_some() { + independent_eclipse_projects(root) + } else { + Vec::new() + }; + poms.iter() + .map(|pom| root.join(pom)) + .chain(gradle) + .chain(eclipse) + .map(|f| ide_lsp::path_to_uri_string(&f)) + .collect() }); (excluded, build_files) } +/// Explicit Gradle imports must retain Eclipse projects without re-importing generated build metadata. +fn independent_eclipse_projects(root: &Path) -> Vec { + let managed = |dir: &Path| { + std::iter::once("pom.xml") + .chain(ide_build::gradle::SETTINGS_FILES.iter().copied()) + .chain(ide_build::gradle::BUILD_FILES.iter().copied()) + .any(|name| dir.join(name).is_file()) + }; + if managed(root) { + return Vec::new(); + } + let walker = ignore::WalkBuilder::new(root) + .standard_filters(false) + .filter_entry(move |entry| { + if !entry.file_type().is_some_and(|kind| kind.is_dir()) { + return false; + } + if entry.depth() > 0 + && (entry.file_name().to_string_lossy().starts_with('.') + || matches!( + entry.file_name().to_str(), + Some("node_modules" | "target" | "build" | "dist" | "out" | "bin" | "vendor") + ) + || entry.path().parent().is_some_and(|parent| parent.join(".project").is_file())) + { + return false; + } + !managed(entry.path()) + }) + .build(); + let mut projects: Vec<_> = + walker.flatten().map(|entry| entry.path().join(".project")).filter(|path| path.is_file()).collect(); + projects.sort(); + projects +} + /// The detected roots with the last Gradle sync's source sets first: Gradle evaluated what /// static detection guesses. Folders outside the project keep their absolute path, which /// `normalize` turns into the same form the UI compares (`Users/me/svc/src/main/java`). -fn with_gradle_roots(root: &Path, build_cache: &Path, detected: &[SourceFolder]) -> Vec { +fn with_gradle_roots( + root: &Path, + build_cache: &Path, + settings: &ide_core::settings::GradleSettings, + detected: &[SourceFolder], +) -> Vec { use ide_build::gradle::ReportedRoot; use ide_workspace::roots::RootKind; let mut out: Vec = Vec::new(); - for (dir, kind) in ide_build::gradle_source_roots(build_cache) { + for (dir, kind) in ide_build::gradle_source_roots(root, build_cache, settings) { if !dir.is_dir() { continue; } @@ -1376,6 +1449,37 @@ pub fn apply_git_executable(settings: &ide_core::settings::Settings) { #[cfg(test)] mod maven_linkage_tests { + #[test] + fn explicit_gradle_links_preserve_independent_eclipse_projects() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + for dir in + ["linked", "unlinked", "eclipse", "nested/eclipse", "unlinked/subproject", "maven", "build/generated"] + { + std::fs::create_dir_all(root.join(dir)).unwrap(); + std::fs::write(root.join(dir).join(".project"), "").unwrap(); + } + for dir in ["linked", "unlinked"] { + std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap(); + } + std::fs::write(root.join("maven/pom.xml"), "").unwrap(); + let mut settings = ide_core::settings::Settings::default(); + settings.languages.java.gradle.linked_projects = Some(vec!["linked".into()]); + let (_, files) = super::jdtls_maven_import(root, &settings); + let files = files.unwrap(); + let uri = |path: &str| ide_lsp::path_to_uri_string(&root.join(path)); + assert!(files.contains(&uri("linked/settings.gradle"))); + assert!(files.contains(&uri("maven/pom.xml"))); + assert!(files.contains(&uri("eclipse/.project"))); + assert!(files.contains(&uri("nested/eclipse/.project"))); + assert!(!files.iter().any(|path| path.contains("unlinked") || path.contains("build/generated"))); + assert!(!files.contains(&uri("linked/.project"))); + assert!(!files.contains(&uri("maven/.project"))); + settings.languages.java.gradle.linked_projects = Some(vec![]); + let (_, files) = super::jdtls_maven_import(root, &settings); + assert_eq!(files.unwrap(), [uri("maven/pom.xml"), uri("eclipse/.project"), uri("nested/eclipse/.project")]); + } + #[test] fn jdtls_imports_the_linked_modules_and_the_gradle_build_once_a_reactor_module_is_unlinked() { let tmp = tempfile::tempdir().unwrap(); @@ -1473,7 +1577,8 @@ mod gradle_roots_tests { std::fs::write(cache.join("gradle.json"), json.to_string()).unwrap(); let guessed = SourceFolder { path: "src/it/java".into(), kind: RootKind::Source, detected_from: None }; let other = SourceFolder { path: "web".into(), kind: RootKind::Source, detected_from: None }; - let out = with_gradle_roots(&root, &cache, &[guessed, other.clone()]); + std::fs::write(root.join("build.gradle"), "").unwrap(); + let out = with_gradle_roots(&root, &cache, &Default::default(), &[guessed, other.clone()]); let abs = |p: PathBuf| ide_workspace::roots::normalize(&s(p)); let got: Vec<(String, RootKind)> = out.iter().map(|f| (f.path.clone(), f.kind)).collect(); assert_eq!( diff --git a/crates/ide-host/tests/gradle_linkage.rs b/crates/ide-host/tests/gradle_linkage.rs new file mode 100644 index 00000000..d53c41f4 --- /dev/null +++ b/crates/ide-host/tests/gradle_linkage.rs @@ -0,0 +1,49 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn links_are_validated_persisted_and_unlinked_without_removing_files() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let root = base.join("workspace"); + for dir in [root.join("a"), root.join("b"), base.join("external")] { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("settings.gradle"), "rootProject.name = 'same-name'").unwrap(); + } + let config = base.join("config"); + let host = Host::new(HostConfig { + config_dir: config.clone(), + data_dir: base.join("data"), + local_data_dir: base.join("local"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |window| window, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.open_project("main", &root).unwrap(); + assert!(ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.is_err()); + host.set_project_trust(&root, true, false).await.unwrap(); + let model = ops::build::gradle_link_projects(&host, "main", vec!["b/settings.gradle".into()]).await.unwrap(); + assert_eq!(model.gradle.iter().map(|g| g.dir.as_str()).collect::>(), ["a", "b"]); + let saved = + || ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.gradle.linked_projects; + assert_eq!(saved(), Some(vec!["a".into(), "b".into()])); + assert!(ops::build::gradle_link_projects(&host, "main", vec!["missing".into()]).await.is_err()); + assert_eq!(saved(), Some(vec!["a".into(), "b".into()])); + let outside = base.join("external").to_string_lossy().into_owned(); + let model = ops::build::gradle_link_projects(&host, "main", vec![outside.clone()]).await.unwrap(); + assert_eq!(model.gradle.len(), 3); + let model = ops::build::gradle_unlink_projects(&host, "main", vec!["a".into(), "b".into(), outside]).await.unwrap(); + assert!(model.gradle.is_empty()); + assert_eq!(saved(), Some(vec![])); + for dir in [root.join("a"), root.join("b"), base.join("external")] { + assert!(dir.join("settings.gradle").is_file()); + } + let model = ops::build::gradle_link_projects(&host, "main", vec!["b".into()]).await.unwrap(); + assert_eq!(model.gradle.len(), 1); + assert_eq!(model.gradle[0].dir, "b"); + host.shutdown().await; +} diff --git a/crates/ide-host/tests/project_smoke.rs b/crates/ide-host/tests/project_smoke.rs index f8e7aed0..9ed4cd4f 100644 --- a/crates/ide-host/tests/project_smoke.rs +++ b/crates/ide-host/tests/project_smoke.rs @@ -223,7 +223,8 @@ async fn kotlin_gradle() { .await; let p = open("kotlin-gradle").await; let model = ops::build::build_model(&p.host, WINDOW).await.unwrap(); - let gradle = model.gradle.expect("a Gradle model"); + assert_eq!(model.gradle.len(), 1, "one independent Gradle build"); + let gradle = &model.gradle[0]; assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::>(), ["openidea-fixture-library"]); } @@ -231,8 +232,9 @@ async fn kotlin_gradle() { async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel { let p = open(fixture).await; let progress = ops::Channel::new(|_: ide_build::SyncProgress| {}); - let model = - ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap(); + let model = ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], None, progress) + .await + .unwrap(); // The cached sync is what the next open shows. let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap(); assert_eq!(reloaded, model); @@ -253,7 +255,9 @@ async fn maven_sync() { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[ignore = "project smoke: needs Gradle and a JDK"] async fn gradle_sync() { - let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap(); + let model = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await; + assert_eq!(model.gradle.len(), 1, "one independent Gradle build"); + let gradle = &model.gradle[0]; assert_eq!(gradle.sync_error, None); assert!(gradle.synced_at.is_some()); let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect(); diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index a5ec8d05..b50d5abc 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -249,6 +249,7 @@ async fn malicious_project_stays_editable_then_starts_in_place_after_trust() { ide_core::RequestId(472), false, vec![], + None, ops::Channel::new(|_| {}), ) }; diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index bfa33139..11888554 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -429,6 +429,36 @@ pub fn configure_maven_import( } } +/// Explicit Gradle links constrain discovery and isolate JDT's persisted import state. +pub fn configure_gradle_import( + cfg: &mut LanguageServerConfig, + linked: Option<&[String]>, + build_files: Option<&[String]>, +) { + let Some(linked) = linked.filter(|_| cfg.name == "jdtls") else { return }; + merge_init_options( + cfg, + json!({ + "settings": {"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}}, + "projectConfigurations": build_files.unwrap_or_default() + }), + ); + if let Some(settings) = &mut cfg.settings { + merge(settings, json!({"java": {"import": {"gradle": {"enabled": !linked.is_empty()}}}})); + } + 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 dir in linked { + for byte in dir.bytes().chain([0]) { + hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3); + } + } + *workspace = format!("{workspace}-gradle-{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!({})); @@ -1218,6 +1248,25 @@ mod java_compiler_tests { mod maven_linkage_tests { use super::*; + #[test] + fn explicit_gradle_links_scope_jdt_import_and_unlink_all_disables_gradle() { + let mut cfg = custom_server("jdtls", &LanguageServerSettings::default()); + cfg.args = vec!["-data".into(), "/cache/jdtls".into()]; + let files = ["file:///p/a/settings.gradle".to_owned(), "file:///p/maven/pom.xml".to_owned()]; + configure_gradle_import(&mut cfg, Some(&["a".into()]), Some(&files)); + let options = serde_json::to_value(&cfg.initialization_options).unwrap(); + assert_eq!(options["projectConfigurations"], json!(files)); + assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(true))); + assert!(cfg.args[1].starts_with("/cache/jdtls-gradle-")); + let linked_cache = cfg.args[1].clone(); + cfg.args[1] = "/cache/jdtls".into(); + configure_gradle_import(&mut cfg, Some(&[]), Some(&files[1..])); + let options = serde_json::to_value(&cfg.initialization_options).unwrap(); + assert_eq!(options["projectConfigurations"], json!(&files[1..])); + assert_eq!(options.pointer("/settings/java/import/gradle/enabled"), Some(&json!(false))); + assert_ne!(linked_cache, cfg.args[1]); + } + #[test] fn import_excludes_unlinked_modules_and_changes_persistent_workspace() { let tmp = tempfile::tempdir().unwrap(); diff --git a/docs/workspaces-and-window-groups.md b/docs/workspaces-and-window-groups.md index 48937ffb..02136699 100644 --- a/docs/workspaces-and-window-groups.md +++ b/docs/workspaces-and-window-groups.md @@ -22,7 +22,15 @@ Paths in an existing manifest may be relative to the workspace directory. New an Linked folders appear as roots in Project view and participate in indexing, Find in Files, Git repository discovery and language-server root selection. Detected and saved run configurations have project-qualified names and use their member's working directory and launch settings. Build Project combines the linked projects' build plans; Compile File targets its owning project. Workspace editor/settings state belongs to its own directory. Each member retains its repository and project configuration. -The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. +The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. To link independent Gradle builds in one Gradle tree, open their parent folder as an ordinary project instead of creating a Workspace manifest, then use **Link Gradle Projects…** in the Gradle toolbar or Find Action. + +## Linked Gradle builds in a project + +In an ordinary project window, **Link Gradle Projects…** accepts one or more `settings.gradle`, `settings.gradle.kts`, `build.gradle`, or `build.gradle.kts` files. Each linked build has its own root in the Gradle tool window. Its tasks, dependencies, wrapper, working directory and sync cache stay separate, even when another build has the same `:app` project or task names. Included builds remain under the build that includes them. + +Right-click a build root for **Reload Gradle Project** or **Unlink Gradle Project**. Unlinking only removes the IDE connection; it does not delete files or saved run configurations. The Link button remains available after the last build is unlinked. Linked builds may live outside the opened folder. + +Links are saved in project settings as `languages.java.gradle.linkedProjects`. Until links are configured, existing automatic discovery is unchanged. An explicit empty list means that no Gradle builds are linked. Opening the project again restores its links. A folder first opened by an older version may need one fresh sync because independent builds now have separate caches. ## Window group diff --git a/e2e/linkedGradle.spec.ts b/e2e/linkedGradle.spec.ts new file mode 100644 index 00000000..0ee3dc8c --- /dev/null +++ b/e2e/linkedGradle.spec.ts @@ -0,0 +1,120 @@ +/** Linked Gradle builds use isolated native stubs; no real builds or files are touched. */ +import type { GradleModel, RunConfig } from "../src/platform/ipc"; +import { expect, ipcCalls, stubDesktop, test } from "./fixtures"; + +const build = (dir: string): GradleModel => ({ + dir, + command: `/dev/acme-api/${dir}/gradlew`, + env: { JAVA_HOME: `/${dir}/jdk` }, + javaVersion: 21, + syncedAt: 1, + stale: false, + syncError: null, + projects: [ + { path: ":", name: dir, dir, buildFile: `${dir}/build.gradle`, tasks: [] }, + { + path: ":app", + name: "app", + dir: `${dir}/app`, + buildFile: `${dir}/app/build.gradle`, + tasks: [{ name: "assemble", group: "build", description: "Assembles this project" }], + }, + ], +}); + +test("links independent Gradle roots, runs identical task paths in their own builds and unlinks without deletion", async ({ + workbench: page, +}, info) => { + await stubDesktop(page); + await page.evaluate( + async ({ first, second }) => { + let linked = [first]; + let runId = 0; + const model = () => ({ maven: null, gradle: linked }); + const h = window.__ipc.handlers; + h.build_model = model; + h.build_sync = model; + h.run_configs = () => []; + h.run_apply_settings = (args) => args.configs; + h.gradle_link_projects = () => { + if (!linked.some((g) => g.dir === second.dir)) linked = [...linked, second]; + return model(); + }; + h.gradle_unlink_projects = (args) => { + linked = linked.filter((g) => !(args.paths as string[]).includes(g.dir)); + return model(); + }; + h["plugin:dialog|open"] = () => ["/dev/acme-api/second/settings.gradle"]; + h.run_start = (args) => { + const config = args.config as RunConfig; + const id = ++runId; + setTimeout(() => window.__ipc.emit({ type: "runExited", run: id, code: 0 }), 50); + return { id, configId: config.id, name: config.name, pid: 4242 + id, running: true, pty: false }; + }; + const storeModule = "/src/features/build-tools/store.ts"; + const { loadModel } = await import(/* @vite-ignore */ storeModule); + await loadModel(); + const contributionsModule = "/src/workbench/contributions.ts"; + const { toolWindowService } = await import(/* @vite-ignore */ contributionsModule); + toolWindowService().show("gradle", true); + }, + { first: build("first"), second: build("second") }, + ); + + const tree = page.getByRole("tree", { name: "Gradle projects" }); + await expect(tree.getByRole("treeitem", { name: "first", exact: true })).toBeVisible(); + await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toHaveCount(0); + await page.screenshot({ path: info.outputPath("gradle-one-root.png"), animations: "disabled" }); + + await page.getByRole("button", { name: "Link Gradle Projects", exact: true }).click(); + await expect + .poll(() => ipcCalls(page, "gradle_link_projects")) + .toEqual([{ paths: ["/dev/acme-api/second/settings.gradle"] }]); + await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toBeVisible(); + // Reloading the model reads the persisted linkage again. + await page.evaluate(async () => { + const storeModule = "/src/features/build-tools/store.ts"; + const { loadModel } = await import(/* @vite-ignore */ storeModule); + await loadModel(); + const actionModule = "/src/platform/actions.ts"; + const { executeAction } = await import(/* @vite-ignore */ actionModule); + await executeAction("gradle.expandAll"); + await executeAction("gradle.toggleOffline"); + }); + const tasks = tree.getByRole("treeitem", { name: /^assemble\b/ }); + await expect(tasks).toHaveCount(2); + await page.screenshot({ path: info.outputPath("gradle-two-roots.png"), animations: "disabled" }); + + await tasks.nth(0).dblclick(); + await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(1); + await tasks.nth(1).dblclick(); + await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(2); + const runs = (await ipcCalls(page, "run_start")).map((args) => args.config as RunConfig); + expect(runs[0]!.id).not.toBe(runs[1]!.id); + for (const [index, dir] of ["first", "second"].entries()) { + expect(runs[index]).toMatchObject({ + command: `/dev/acme-api/${dir}/gradlew`, + cwd: dir, + args: [":app:assemble", "--offline"], + env: { JAVA_HOME: `/${dir}/jdk` }, + }); + } + + for (const dir of ["second", "first"]) { + await tree.getByRole("treeitem", { name: dir, exact: true }).click({ button: "right" }); + await page.getByRole("menuitem", { name: "Unlink Gradle Project", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "Unlink Gradle project?" }); + await expect(dialog).toContainText("Files will remain"); + await dialog.getByRole("button", { name: "Unlink", exact: true }).click(); + await expect(tree.getByRole("treeitem", { name: dir, exact: true })).toHaveCount(0); + } + await expect + .poll(() => ipcCalls(page, "gradle_unlink_projects")) + .toEqual([{ paths: ["second"] }, { paths: ["first"] }]); + await expect(page.getByText("No Gradle projects", { exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Link Gradle Projects", exact: true })).toBeVisible(); + expect( + await page.evaluate(() => window.__ipc.calls.filter((c) => /^fs_(?:delete|remove|trash)/.test(c.cmd))), + ).toEqual([]); + await page.screenshot({ path: info.outputPath("gradle-unlinked.png"), animations: "disabled" }); +}); diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index c55634b7..fd157cad 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -17,11 +17,32 @@ pub async fn build_model(window: Window, state: State<'_, AppState>) -> IdeResul ide_host::ops::build::build_model(&state.host, window.label()).await } +#[tauri::command] +#[specta::specta] +pub async fn gradle_link_projects( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult { + ide_host::ops::build::gradle_link_projects(&state.host, window.label(), paths).await +} + +#[tauri::command] +#[specta::specta] +pub async fn gradle_unlink_projects( + window: Window, + state: State<'_, AppState>, + paths: Vec, +) -> IdeResult { + ide_host::ops::build::gradle_unlink_projects(&state.host, window.label(), paths).await +} + /// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the /// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`. /// Returns the refreshed model; a failed build is reported in its `syncError`. #[tauri::command] #[specta::specta] +#[allow(clippy::too_many_arguments)] pub async fn build_sync( window: Window, state: State<'_, AppState>, @@ -29,6 +50,7 @@ pub async fn build_sync( request: RequestId, offline: bool, profiles: Vec, + build_dir: Option, on_progress: Channel, ) -> IdeResult { ide_host::ops::build::build_sync( @@ -38,6 +60,7 @@ pub async fn build_sync( request, offline, profiles, + build_dir, crate::commands::sender(on_progress), ) .await @@ -53,9 +76,18 @@ pub async fn gradle_dependencies( configuration: String, offline: bool, request: RequestId, + build_dir: Option, ) -> IdeResult> { - ide_host::ops::build::gradle_dependencies(&state.host, window.label(), gradle_path, configuration, offline, request) - .await + ide_host::ops::build::gradle_dependencies( + &state.host, + window.label(), + gradle_path, + configuration, + offline, + request, + build_dir, + ) + .await } /// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7d8de235..d882ce9e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -354,6 +354,8 @@ pub fn builder() -> tauri_specta::Builder { run::run_tomcat_update, run::run_test_reports, build::build_model, + build::gradle_link_projects, + build::gradle_unlink_projects, build::build_sync, build::gradle_dependencies, build::gradle_published_catalog, diff --git a/src/bindings.ts b/src/bindings.ts index d46c6bb6..93bbd908 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -664,14 +664,16 @@ export const commands = { runTestReports: (module: string | null, sinceMs: number | null) => typedError(__TAURI_INVOKE("run_test_reports", { module, sinceMs })), /** Maven and Gradle projects of the window's project, from the build files plus the last sync. */ buildModel: () => typedError(__TAURI_INVOKE("build_model")), + gradleLinkProjects: (paths: string[]) => typedError(__TAURI_INVOKE("gradle_link_projects", { paths })), + gradleUnlinkProjects: (paths: string[]) => typedError(__TAURI_INVOKE("gradle_unlink_projects", { paths })), /** * Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the * background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`. * Returns the refreshed model; a failed build is reported in its `syncError`. */ - buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], onProgress: Channel) => typedError(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })), + buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], buildDir: string | null, onProgress: Channel) => typedError(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, buildDir, onProgress })), /** The resolved dependency tree of a Gradle project for one configuration (Dependency Analyzer). */ - gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId) => typedError(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request })), + gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId, buildDir: string | null) => typedError(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request, buildDir })), /** * The `.toml` of a version catalog the build imports as `group:artifact:version`, once a * build has downloaded it. @@ -1858,7 +1860,7 @@ export type BuildMessage = { /** The build systems of a project; `None` when the project does not use one. */ export type BuildModel = { maven: MavenModel | null, - gradle: GradleModel | null, + gradle?: GradleModel[], }; export type BuildResult = { @@ -3924,6 +3926,8 @@ export type GradleRunUsing = /** Build Tools › Gradle. */ export type GradleSettings = { + /** Linked build directories; absent preserves automatic discovery, empty unlinks all builds. */ + linkedProjects?: string[] | null, /** Empty = Project JDK; otherwise a JDK home. */ jvm?: string, distribution?: GradleDistribution, diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index 3680ba41..898ebd1f 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -21,6 +21,7 @@ import { import { gradleChildren, gradleRoots, + gradleProjectKey, gradleTaskConfig, isRunnable, mavenChildren, @@ -56,7 +57,7 @@ export function GradleView() { /** The run configuration a runnable node (or several of one project) starts. */ export function nodeConfig( nodes: readonly BuildNode[], - model: { maven?: MavenModel | null; gradle?: GradleModel | null }, + model: { maven?: MavenModel | null; gradle?: readonly GradleModel[] }, ) { const settings = useBuild.getState().settings; const first = nodes[0]; @@ -71,13 +72,17 @@ export function nodeConfig( const goals = same.map((n) => (n.kind === "phase" ? n.phase : `${n.plugin.prefix}:${n.goal}`)); return mavenGoalConfig(first.project, goals, model.maven, settings.maven); } - if (first.kind === "task" && model.gradle) { + if (first.kind === "task") { + const build = model.gradle?.find((g) => g.dir === first.build.dir); + const project = build?.projects.find((p) => p.path === first.project.path); + if (!build || !project) return null; const same = nodes.filter( - (n): n is Extract => n.kind === "task" && n.project.path === first.project.path, + (n): n is Extract => + n.kind === "task" && n.build.dir === first.build.dir && n.project.path === first.project.path, ); return gradleTaskConfig( - model.gradle, - first.project, + build, + project, same.map((n) => n.task.name), settings.gradle, ); @@ -89,8 +94,9 @@ export function nodeConfig( function childrenOf( tool: BuildTool, maven: MavenModel | null, - gradle: GradleModel | null, + gradle: readonly GradleModel[], configs: readonly RunConfig[], + root: string, ) { const cache = new Map(); return (n: BuildNode) => { @@ -101,7 +107,7 @@ function childrenOf( ? mavenChildren(n, maven, configs) : undefined : gradle - ? gradleChildren(n, gradle, configs) + ? gradleChildren(n, gradle, configs, root) : undefined; cache.set(n.key, kids); return kids; @@ -110,7 +116,8 @@ function childrenOf( /** Maven `pom` / Gradle project path of the project a node belongs to. */ function nodeProject(n: BuildNode): string | undefined { - if ("project" in n) return "pom" in n.project ? n.project.pom : n.project.path; + if ("project" in n) + return "pom" in n.project ? n.project.pom : "build" in n ? gradleProjectKey(n.build, n.project) : undefined; return undefined; } @@ -118,9 +125,8 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[] const syncing = useBuild((s) => !!s.syncing[tool]); const settings = useBuild((s) => s.settings); const model = useBuild((s) => s.model); - const runnable = selection.filter(isRunnable); + const config = model && nodeConfig(selection.filter(isRunnable), model); const runSelected = () => { - const config = model && nodeConfig(runnable, model); if (config) runConfig(config, "run"); }; const maven = tool === "maven"; @@ -131,14 +137,14 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[] ) : ( )} - {maven && } + s.syncing[tool]); - const info = useBuild((s) => (tool === "maven" ? s.model?.maven : s.model?.gradle)); + const model = useBuild((s) => s.model); + const infos = tool === "maven" ? (model?.maven ? [model.maven] : []) : (model?.gradle ?? []); if (sync) return (
@@ -190,7 +197,10 @@ function Banner({ tool }: { tool: BuildTool }) { {sync.fraction != null && {Math.round(sync.fraction * 100)}%}
); - if (!info) return null; + return infos.map((info) => ); +} + +function BuildBanner({ info, tool }: { info: MavenModel | GradleModel; tool: BuildTool }) { if (info.stale) return (
@@ -198,7 +208,7 @@ function Banner({ tool }: { tool: BuildTool }) { Build files changed since the last reload -
@@ -260,15 +270,18 @@ function BuildToolView({ tool }: { tool: BuildTool }) { const root = useProject((s) => s.project?.root ?? ""); const [selection, setSelection] = useState([]); const maven = model?.maven ?? null; - const gradle = model?.gradle ?? null; + const gradle = model?.gradle; const roots = useMemo( - () => (tool === "maven" ? (maven ? mavenRoots(maven) : []) : gradle ? gradleRoots(gradle) : []), + () => (tool === "maven" ? (maven ? mavenRoots(maven) : []) : gradleRoots(gradle ?? [])), [tool, maven, gradle], ); - const getChildren = useMemo(() => childrenOf(tool, maven, gradle, configs), [tool, maven, gradle, configs]); + const getChildren = useMemo( + () => childrenOf(tool, maven, gradle ?? [], configs, root), + [tool, maven, gradle, configs, root], + ); - const hasModel = tool === "maven" ? !!maven : !!gradle; + const hasModel = tool === "maven" ? !!maven : !!gradle?.length; useEffect(() => { if (model && hasModel && useBuild.getState().expanded[tool].size === 0) setExpanded(tool, initialExpanded(tool, model)); @@ -331,12 +344,22 @@ function BuildToolView({ tool }: { tool: BuildTool }) { items.push({ label: tx`Open ${file.split("/").pop()}`, icon: "file-gradle", - run: () => void editorService().openFile(`${root}/${file}`), + run: () => void editorService().openFile(/^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? file : `${root}/${file}`), }); + if (node.project.path === ":") + items.push({ kind: "action", action: "gradle.unlinkProjects", arg: { buildDir: node.build.dir } }); items.push( - { kind: "action", action: "gradle.reload", label: "Reload All Gradle Projects" }, - { kind: "action", action: "gradle.executeTask" }, - { kind: "action", action: "gradle.analyzeDependencies", arg: { gradlePath: node.project.path } }, + { kind: "action", action: "gradle.reload", label: "Reload Gradle Project", arg: { buildDir: node.build.dir } }, + { + kind: "action", + action: "gradle.executeTask", + arg: { projectKey: gradleProjectKey(node.build, node.project) }, + }, + { + kind: "action", + action: "gradle.analyzeDependencies", + arg: { buildDir: node.build.dir, gradlePath: node.project.path }, + }, ); } if (node.kind === "dependency") { diff --git a/src/features/build-tools/DependencyAnalyzer.test.tsx b/src/features/build-tools/DependencyAnalyzer.test.tsx index 59210be3..0cb9d1bd 100644 --- a/src/features/build-tools/DependencyAnalyzer.test.tsx +++ b/src/features/build-tools/DependencyAnalyzer.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { executeAction } from "@/platform/actions"; import { setContext } from "@/platform/context"; import type { BuildModel, GradleDependency, GradleModel, MavenDependency } from "@/platform/ipc"; @@ -72,7 +72,7 @@ const model: BuildModel = { stale: false, syncError: null, }, - gradle: null, + gradle: [], }; describe("dependency analyzer", () => { @@ -159,7 +159,7 @@ describe("dependency analyzer for Gradle", () => { provideEditorService({ openInput, activePath: () => null } as unknown as EditorService); setContext("projectOpen", true); dispose = activate(); - act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle } })); + act(() => useBuild.setState({ root: "/g", model: { maven: null, gradle: [gradle] } })); }); afterEach(() => { dispose(); @@ -170,13 +170,13 @@ describe("dependency analyzer for Gradle", () => { it("opens for a project from the action and from its build script's menus", async () => { await executeAction("gradle.analyzeDependencies", { gradlePath: ":app" }); expect(openInput).toHaveBeenCalledWith(expect.objectContaining({ kind: ANALYZER_KIND }), { focus: true }); - expect(useAnalyzer.getState().module).toBe("gradle::app"); + expect(useAnalyzer.getState().module).toBe('gradle:["",":app"]'); const items = menuGroupItems("projectView", { paths: ["/g/app/build.gradle.kts"] }); expect(items).toContainEqual( expect.objectContaining({ action: "gradle.analyzeDependencies", arg: { path: "/g/app/build.gradle.kts" } }), ); await executeAction("gradle.analyzeDependencies", { path: "/g/build.gradle" }); - expect(useAnalyzer.getState().module).toBe("gradle::"); + expect(useAnalyzer.getState().module).toBe('gradle:["",":"]'); expect(menuGroupItems("editor", { paths: ["/g/other/build.gradle"] })).toEqual([]); }); @@ -187,9 +187,9 @@ describe("dependency analyzer for Gradle", () => { ]), gradleDep("com.fasterxml.jackson.core:jackson-databind:2.15.3", null), ]); - act(() => useAnalyzer.setState({ module: "gradle::app" })); + act(() => useAnalyzer.setState({ module: 'gradle:["",":app"]' })); render(); - expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number)); + expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number), ""); await screen.findByText("com.fasterxml.jackson.core:jackson-databind"); fireEvent.click(screen.getByRole("checkbox", { name: "Show conflicts only" })); expect(screen.queryByText("org.springframework.boot:spring-boot-starter-json")).toBeNull(); @@ -203,9 +203,32 @@ describe("dependency analyzer for Gradle", () => { expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull(); }); + it("routes identical project paths and report caches by linked build", async () => { + const builds = ["one", "two"].map((dir) => ({ + ...gradle, + dir, + syncedAt: 1, + projects: gradle.projects.map((p) => ({ ...p, dir: `${dir}/${p.dir}`, buildFile: `${dir}/${p.buildFile}` })), + })); + ipc.gradle.mockResolvedValue([]); + act(() => { + useBuild.setState({ model: { maven: null, gradle: builds } }); + useAnalyzer.setState({ module: 'gradle:["one",":app"]' }); + }); + render(); + await waitFor(() => + expect(ipc.gradle).toHaveBeenCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "one"), + ); + act(() => useAnalyzer.setState({ module: 'gradle:["two",":app"]' })); + await waitFor(() => + expect(ipc.gradle).toHaveBeenLastCalledWith(":app", "runtimeClasspath", false, expect.any(Number), "two"), + ); + expect(ipc.gradle).toHaveBeenCalledTimes(2); + }); + it("shows why Gradle could not resolve the configuration", async () => { ipc.gradle.mockRejectedValue(new Error("Could not resolve all files for configuration ':app:runtimeClasspath'.")); - act(() => useAnalyzer.setState({ module: "gradle::" })); + act(() => useAnalyzer.setState({ module: 'gradle:["",":"]' })); render(); expect(await screen.findByText("Cannot resolve dependencies")).toBeTruthy(); expect(screen.getByText(/Could not resolve all files/)).toBeTruthy(); diff --git a/src/features/build-tools/DependencyAnalyzer.tsx b/src/features/build-tools/DependencyAnalyzer.tsx index 7fb71f98..2b48e5bf 100644 --- a/src/features/build-tools/DependencyAnalyzer.tsx +++ b/src/features/build-tools/DependencyAnalyzer.tsx @@ -11,7 +11,7 @@ import { commands, isCancelled, nextRequestId, - type GradleProject, + type GradleModel, type MavenDependency, type MavenProject, } from "@/platform/ipc"; @@ -53,6 +53,7 @@ import { type DependencyTreeNode, type DependencyUsage, } from "./analyzer"; +import { gradleProjectKey } from "./model"; import { syncTool, useAnalyzer, useBuild } from "./store"; import styles from "./DependencyAnalyzer.module.css"; @@ -67,13 +68,13 @@ const GRADLE = "gradle:"; const CONFIGURATIONS = ["compileClasspath", "runtimeClasspath", "testCompileClasspath", "testRuntimeClasspath"]; type GradleTree = { deps: MavenDependency[] | null; error: string | null; loading: boolean }; -/** Reports already read in this session, by `root|path|configuration`: reopening the tab doesn't run Gradle again. */ +/** Reports already read in this session, by workspace, build, project and configuration: reopening the tab doesn't run Gradle again. */ const gradleTrees = new Map(); /** The `dependencies` report of `path` for `configuration`, cached; a new `generation` runs Gradle again. */ -function useGradleTree(path: string | null, configuration: string, generation: number) { +function useGradleTree(path: string | null, buildDir: string | null, configuration: string, generation: number) { const root = useBuild((s) => s.root); - const key = path && root ? `${root}|${path}|${configuration}` : null; + const key = path && root ? JSON.stringify([root, buildDir, path, configuration]) : null; const [state, setState] = useState({ key: null, deps: null, @@ -98,7 +99,7 @@ function useGradleTree(path: string | null, configuration: string, generation: n let done = false; setState({ key, deps: cached ?? null, error: null, loading: true }); const offline = useBuild.getState().settings.gradle.offline; - call(commands.gradleDependencies(path, configuration, offline, request)) + call(commands.gradleDependencies(path, configuration, offline, request, buildDir)) .then((deps) => { const tree = fromGradle(deps); gradleTrees.set(key, tree); @@ -115,7 +116,7 @@ function useGradleTree(path: string | null, configuration: string, generation: n live = false; if (!done) void commands.cancelRequest(request).catch(() => {}); }; - }, [key, path, configuration, generation]); + }, [key, path, buildDir, configuration, generation]); const current: GradleTree = state.key === key ? state : { deps: null, error: null, loading: !!key }; const cancel = () => { if (running.current != null) void commands.cancelRequest(running.current).catch(() => {}); @@ -162,20 +163,22 @@ function versionLabel(d: MavenDependency) { return d.version ?? ""; } -function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleProject[]): SelectOption[] { +function moduleOptions(maven: readonly MavenProject[], gradle: readonly GradleModel[]): SelectOption[] { return [ ...maven .filter((p) => p.packaging !== "pom" || p.dependencies.length > 0) .map((p) => ({ value: p.pom, label: p.displayName, detail: p.pom, icon: "maven-project" })), // buildSrc's tasks can't be run from the build. - ...gradle - .filter((p) => p.build !== ":buildSrc") - .map((p) => ({ - value: `${GRADLE}${p.path}`, - label: p.name, - detail: p.path, - icon: p.path === ":" ? "gradle-project" : "gradle-module", - })), + ...gradle.flatMap((build) => + build.projects + .filter((p) => !p.build?.endsWith(":buildSrc")) + .map((p) => ({ + value: gradleProjectKey(build, p), + label: p.name, + detail: `${build.dir || "."} ${p.path}`, + icon: p.path === ":" ? "gradle-project" : "gradle-module", + })), + ), ]; } @@ -332,7 +335,7 @@ function UsagesPane({ export function DependencyAnalyzer(_props: { input: EditorInput }) { const tx = useTx(); const maven = useBuild((s) => s.model?.maven ?? null); - const gradle = useBuild((s) => s.model?.gradle ?? null); + const gradle = useBuild((s) => s.model?.gradle); const syncing = useBuild((s) => s.syncing.maven); const chosen = useAnalyzer((s) => s.module); const [query, setQuery] = useState(""); @@ -343,13 +346,18 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) { const [mode, setMode] = useState("list"); const [selectedKey, setSelectedKey] = useState(null); - const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle?.projects ?? []), [maven, gradle]); + const options = useMemo(() => moduleOptions(maven?.projects ?? [], gradle ?? []), [maven, gradle]); const value = options.some((o) => o.value === chosen) ? chosen! : (options[0]?.value ?? null); - const gradlePath = value?.startsWith(GRADLE) ? value.slice(GRADLE.length) : null; + const selectedGradle = value?.startsWith(GRADLE) + ? (gradle ?? []) + .flatMap((build) => build.projects.map((project) => ({ build, project }))) + .find(({ build, project }) => gradleProjectKey(build, project) === value) + : null; + const gradlePath = selectedGradle?.project.path ?? null; const pom = value != null && gradlePath == null ? value : null; const module = pom ? (maven?.projects.find((p) => p.pom === pom) ?? null) : null; - const gradleProject = gradlePath != null ? (gradle?.projects.find((p) => p.path === gradlePath) ?? null) : null; - const report = useGradleTree(gradlePath, configuration, generation); + const gradleProject = selectedGradle?.project ?? null; + const report = useGradleTree(gradlePath, selectedGradle?.build.dir ?? null, configuration, generation); const deps = useMemo( () => (gradlePath != null ? (report.deps ?? []) : (module?.dependencies ?? [])), [gradlePath, report.deps, module], @@ -428,7 +436,7 @@ export function DependencyAnalyzer(_props: { input: EditorInput }) { showContextMenu({ x: e.clientX, y: e.clientY }, items); }; - if (!maven && !gradle) return ; + if (!maven && !gradle?.length) return ; const treeView = (
diff --git a/src/features/build-tools/ExecutePopup.tsx b/src/features/build-tools/ExecutePopup.tsx index 7327a929..a4ac9485 100644 --- a/src/features/build-tools/ExecutePopup.tsx +++ b/src/features/build-tools/ExecutePopup.tsx @@ -8,7 +8,7 @@ import { useMemo, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import type { BuildTool } from "@/platform/ipc"; import { HighlightedText, Input, List, Popup, Select, listNavigate, T } from "@/ui"; -import { goalCompletions, taskCompletions, type Completion } from "./model"; +import { goalCompletions, gradleProjectForKey, gradleProjectKey, taskCompletions, type Completion } from "./model"; import { executeBuildLine } from "./runAnything"; import { readHistory, useBuild } from "./store"; import styles from "./BuildTools.module.css"; @@ -32,7 +32,13 @@ function ExecutePopup({ tool }: { tool: BuildTool }) { () => tool === "maven" ? (maven?.projects ?? []).map((p) => ({ value: p.pom, label: p.displayName, detail: p.dir || "." })) - : (gradle?.projects ?? []).map((p) => ({ value: p.path, label: p.name, detail: p.path })), + : (gradle ?? []).flatMap((build) => + build.projects.map((p) => ({ + value: gradleProjectKey(build, p), + label: p.name, + detail: `${build.dir || "."} ${p.path}`, + })), + ), [tool, maven, gradle], ); const [project, setProject] = useState(() => @@ -44,7 +50,8 @@ function ExecutePopup({ tool }: { tool: BuildTool }) { const p = maven?.projects.find((x) => x.pom === project); return goalCompletions(line, p?.plugins ?? [], history).slice(0, 200); } - return gradle ? taskCompletions(line, gradle, history).slice(0, 200) : []; + const selected = gradleProjectForKey(gradle ?? [], project); + return selected ? taskCompletions(line, selected.build, history).slice(0, 200) : []; }, [tool, maven, gradle, project, line, history]); const close = () => useBuild.setState({ executeOpen: null }); diff --git a/src/features/build-tools/actions.test.tsx b/src/features/build-tools/actions.test.tsx index af8ffa03..9944b0f0 100644 --- a/src/features/build-tools/actions.test.tsx +++ b/src/features/build-tools/actions.test.tsx @@ -36,7 +36,7 @@ const mavenModel: BuildModel = { stale: true, syncError: null, }, - gradle: null, + gradle: [], }; describe("build tools feature", () => { @@ -62,7 +62,7 @@ describe("build tools feature", () => { expect(getContext()["build.maven"]).toBe(true); expect(toolWindows()).not.toContain("gradle"); - act(() => useBuild.setState({ model: { maven: null, gradle: null } })); + act(() => useBuild.setState({ model: { maven: null, gradle: [] } })); expect(toolWindows()).not.toContain("maven"); expect(getContext()["build.maven"]).toBe(false); }); diff --git a/src/features/build-tools/autoReload.test.ts b/src/features/build-tools/autoReload.test.ts index 90eaa015..1109e295 100644 --- a/src/features/build-tools/autoReload.test.ts +++ b/src/features/build-tools/autoReload.test.ts @@ -28,7 +28,7 @@ vi.mock("@tauri-apps/api/core", async (original) => ({ const maven = (stale: boolean) => ({ syncedAt: 1, stale, syncError: null, profiles: [], projects: [] }) as unknown as MavenModel; -const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: null }); +const model = (stale: boolean): BuildModel => ({ maven: maven(stale), gradle: [] }); beforeEach(() => { vi.clearAllMocks(); diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index a6707986..16dd1313 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -14,16 +14,20 @@ import { registerOverlay, registerRunAnythingProvider, registerToolWindow, + toolWindowService, } from "@/workbench/contributions"; import { lazyComponent } from "@/ui"; import { GradleView, MavenView } from "./BuildToolView"; import { ExecutePopupHost } from "./ExecutePopup"; import { buildToolsRunAnything } from "./runAnything"; import { versionCatalogExtension } from "./versionCatalog"; -import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots } from "./model"; +import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, gradleProjectKey } from "./model"; import { ANALYZER_KIND, addProjects, + linkGradleProjects, + unlinkGradleProjects, + requestSync, unlinkProjects, cancelSync, loadModel, @@ -33,6 +37,7 @@ import { openAnalyzer, resetBuild, setExpanded, + setCurrentProject, syncTool, toggleGradleOffline, toggleMavenOffline, @@ -55,7 +60,7 @@ function expandAll(tool: BuildTool) { tool, expandableKeys(mavenRoots(m), (n) => mavenChildren(n, m, configs)), ); - } else if (tool === "gradle" && model?.gradle) { + } else if (tool === "gradle" && model?.gradle?.length) { const g = model.gradle; setExpanded( tool, @@ -84,24 +89,34 @@ function analyzerPom(arg: unknown): string | null { /** The analyzer module of a Gradle project from an action argument: `{ gradlePath }`, or `{ path }` of its build script. */ function analyzerGradle(arg: unknown): string | null { const { model, root } = useBuild.getState(); - const a = (arg ?? {}) as { gradlePath?: unknown; path?: unknown }; - if (typeof a.gradlePath === "string") return `gradle:${a.gradlePath}`; - const project = - typeof a.path === "string" && root - ? model?.gradle?.projects.find((p) => p.buildFile && `${root}/${p.buildFile}` === a.path) - : undefined; - return project ? `gradle:${project.path}` : null; + const a = (arg ?? {}) as { buildDir?: unknown; gradlePath?: unknown; path?: unknown }; + for (const build of model?.gradle ?? []) { + const project = build.projects.find((p) => { + if (typeof a.gradlePath === "string") + return p.path === a.gradlePath && (a.buildDir === undefined || a.buildDir === build.dir); + const file = p.buildFile; + return ( + typeof a.path === "string" && + root && + file && + (/^(?:[A-Za-z]:[\\/]|\/)/.test(file) ? file : `${root}/${file}`) === a.path + ); + }); + if (project) return gradleProjectKey(build, project); + } + return null; } const synced = new Set(); /** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */ function apply(model: BuildModel | null, registered: Partial void>>) { - const has: Record = { maven: !!model?.maven, gradle: !!model?.gradle }; + const has: Record = { maven: !!model?.maven, gradle: !!model?.gradle?.length }; setContext("build.maven", has.maven); setContext("build.gradle", has.gradle); for (const tool of ["maven", "gradle"] as const) { - if (has[tool] && !registered[tool]) { + const visible = has[tool] || (tool === "gradle" && model !== null && !!registered.gradle); + if (visible && !registered[tool]) { registered[tool] = tool === "maven" ? registerToolWindow({ @@ -120,25 +135,40 @@ function apply(model: BuildModel | null, registered: Partial