feat(gradle): link and unlink independent Gradle builds (#1413)

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>
This commit is contained in:
yoo hyeong chan
2026-11-05 11:23:39 +08:00
committed by GitHub
parent 682fee633d
commit 0dfdbbc323
43 changed files with 1846 additions and 254 deletions
+3 -3
View File
@@ -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");
+97 -18
View File
@@ -147,7 +147,9 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
let rel = rel_str(root, dir);
if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| p.dir == rel)) || has(dir, &["pom.xml"]) {
Some(BuildSystem::Maven)
} else if has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"]) {
} else if model.gradle_for_module(root, dir).is_some()
|| has(dir, &["build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"])
{
Some(BuildSystem::Gradle)
} else if has(dir, &["Cargo.toml"]) {
Some(BuildSystem::Cargo)
@@ -162,16 +164,13 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
}
}
/// `root/rel`, without a trailing separator for the root itself.
/// `root` / `rel`, joined per `/`-separated component: on Windows a single join of
/// `a/b` keeps the `/`, and such a path does not compare equal to the same directory
/// as other tools (JDT LS) spell it.
/// Resolves a workspace-relative or absolute directory with native path separators.
fn subdir(root: &Path, rel: &str) -> 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<Vec<BuildStep>> {
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<BuildStep> {
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<BuildStep> {
@@ -811,6 +819,77 @@ fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<Path
#[cfg(test)]
mod tests {
#[test]
fn independent_gradle_builds_keep_their_launchers_and_task_paths() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for dir in ["a", "b"] {
std::fs::create_dir_all(root.join(dir).join("app/src/main/java")).unwrap();
std::fs::write(root.join(dir).join("settings.gradle"), "include 'app'").unwrap();
std::fs::write(root.join(dir).join("app/build.gradle"), "plugins { id 'java' }").unwrap();
std::fs::write(root.join(dir).join("gradlew"), "").unwrap();
}
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec!["a".into(), "b".into()]),
..Default::default()
};
let mut model = crate::load_linked(root, &root.join("cache"), None, &Default::default(), &settings);
model.gradle[0].env.insert("JAVA_HOME".into(), "/jdk-a".into());
model.gradle[1].env.insert("JAVA_HOME".into(), "/jdk-b".into());
let all = super::plan(root, &model, super::BuildKind::Build, None, None).unwrap();
assert_eq!(all.steps.len(), 2);
assert_eq!(all.steps[0].cwd, root.join("a"));
assert_eq!(all.steps[1].cwd, root.join("b"));
let module = super::plan(root, &model, super::BuildKind::Rebuild, Some(&root.join("b/app")), None).unwrap();
assert_eq!(module.steps.len(), 1);
assert_eq!(module.steps[0].program, root.join("b/gradlew").to_string_lossy());
assert_eq!(module.steps[0].cwd, root.join("b"));
assert_eq!(module.steps[0].env["JAVA_HOME"], "/jdk-b");
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
// A directly linked build owns its module even when another composite reports it too.
let direct = model.gradle[1].projects[0].clone();
model.gradle[0].projects.push(direct);
assert_eq!(model.gradle_for_module(root, &root.join("b")).unwrap().dir, "b");
let mut nested = model.gradle[1].projects[1].clone();
nested.path = ":b:app".into();
model.gradle[0].projects.push(nested);
let nested = super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).unwrap();
assert_eq!(nested.steps[0].program, root.join("b/gradlew").to_string_lossy());
assert_eq!(nested.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
model.gradle.clear();
assert!(super::plan(root, &model, super::BuildKind::Build, Some(&root.join("b/app")), None).is_err());
}
#[test]
fn absolute_linked_roots_build_the_project_and_external_source_module() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("workspace");
let external = base.join("external");
std::fs::create_dir(&root).unwrap();
std::fs::create_dir_all(external.join("app/src/main/java")).unwrap();
std::fs::write(external.join("settings.gradle"), "include 'app'").unwrap();
let source = external.join("app/src/main/java/App.java");
std::fs::write(&source, "class App {}").unwrap();
std::fs::write(external.join("gradlew"), "").unwrap();
let settings = ide_core::settings::GradleSettings {
linked_projects: Some(vec![external.to_string_lossy().into_owned()]),
..Default::default()
};
let mut model = crate::load_linked(&root, &base.join("cache"), None, &Default::default(), &settings);
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
assert_eq!(all.steps[0].cwd, external);
assert_eq!(all.steps[0].program, external.join("gradlew").to_string_lossy());
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
assert_eq!(module.steps[0].cwd, external);
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
assert_eq!(module.steps[0].args, ["--console=plain", ":app:clean", ":app:classes", ":app:testClasses"]);
let other = base.join("unlinked");
std::fs::create_dir(&other).unwrap();
std::fs::write(other.join("build.gradle"), "").unwrap();
assert!(super::plan(&root, &model, super::BuildKind::Build, Some(&other), None).is_err());
}
use super::*;
/// Launch outputs use the platform separator throughout, so they compare equal to
+94
View File
@@ -435,6 +435,15 @@ pub fn external_roots<'a>(root: &Path, dirs: impl IntoIterator<Item = &'a str>)
pub fn load(root: &Path) -> Option<GradleModel> {
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<GradleModel> {
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<GradleModel> {
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<Vec<String>> {
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<String> {
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<GradleModel> {
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<u32> {
let number = |s: &str| -> Option<u32> {
@@ -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::<Vec<_>>(), ["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#"
+16 -5
View File
@@ -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<String> =
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(),
+82 -14
View File
@@ -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<MavenModel>,
pub gradle: Option<GradleModel>,
#[serde(default)]
pub gradle: Vec<GradleModel>,
}
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<PathBuf>) -> 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<PathBuf>,
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<PathBuf> {
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<PathBuf> {
let builds = gradle::linked_dirs(root, settings);
let dirs: Vec<String> = 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::<Vec<_>>(),
)
})
.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::<Vec<_>>();
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<Dependency
});
}
}
if let Some(g) = &model.gradle {
for g in &model.gradle {
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
@@ -147,6 +195,26 @@ pub fn affects_build(rel: &Path) -> 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<String> = dependency_coordinates(root, &model)
.into_iter()
+148 -6
View File
@@ -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<String> = 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();
+40 -5
View File
@@ -26,12 +26,12 @@ pub fn artifacts(root: &Path, model: &BuildModel) -> Vec<WebArtifact> {
});
}
}
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<WebArtifact> {
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<String>,
@@ -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() {
+13
View File
@@ -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<Vec<String>>,
/// 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);
+183 -15
View File
@@ -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<BuildModel> {
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<Vec<String>> {
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<String> = 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<String>, link: bool) -> IdeResult<BuildModel> {
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::<BuildModel, IdeError>(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<String>) -> IdeResult<BuildModel> {
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<String>) -> IdeResult<BuildModel> {
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<String>,
build_dir: Option<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
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::<BuildModel, IdeError>(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<String>,
) -> IdeResult<Vec<ide_build::GradleDependency>> {
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")
+2 -1
View File
@@ -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.") {
+115 -10
View File
@@ -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<PathBuf> = 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<PathBuf>, Option<Vec<String>>) {
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<PathBuf> {
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<SourceFolder> {
fn with_gradle_roots(
root: &Path,
build_cache: &Path,
settings: &ide_core::settings::GradleSettings,
detected: &[SourceFolder],
) -> Vec<SourceFolder> {
use ide_build::gradle::ReportedRoot;
use ide_workspace::roots::RootKind;
let mut out: Vec<SourceFolder> = 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"), "<projectDescription/>").unwrap();
}
for dir in ["linked", "unlinked"] {
std::fs::write(root.join(dir).join("settings.gradle"), "").unwrap();
}
std::fs::write(root.join("maven/pom.xml"), "<project/>").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!(
+49
View File
@@ -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::<Vec<_>>(), ["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;
}
+8 -4
View File
@@ -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::<Vec<_>>(), ["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();
+1
View File
@@ -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(|_| {}),
)
};
+49
View File
@@ -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();
+9 -1
View File
@@ -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
+120
View File
@@ -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" });
});
+34 -2
View File
@@ -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<String>,
) -> IdeResult<BuildModel> {
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<String>,
) -> IdeResult<BuildModel> {
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<String>,
build_dir: Option<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
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<String>,
) -> IdeResult<Vec<ide_build::GradleDependency>> {
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
+2
View File
@@ -354,6 +354,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+7 -3
View File
@@ -664,14 +664,16 @@ export const commands = {
runTestReports: (module: string | null, sinceMs: number | null) => typedError<TestCaseReport[], IdeError>(__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<BuildModel, IdeError>(__TAURI_INVOKE("build_model")),
gradleLinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("gradle_link_projects", { paths })),
gradleUnlinkProjects: (paths: string[]) => typedError<BuildModel, IdeError>(__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<SyncProgress>) => typedError<BuildModel, IdeError>(__TAURI_INVOKE("build_sync", { tool, request, offline, profiles, onProgress })),
buildSync: (tool: BuildTool, request: RequestId, offline: boolean, profiles: string[], buildDir: string | null, onProgress: Channel<SyncProgress>) => typedError<BuildModel, IdeError>(__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<GradleDependency[], IdeError>(__TAURI_INVOKE("gradle_dependencies", { gradlePath, configuration, offline, request })),
gradleDependencies: (gradlePath: string, configuration: string, offline: boolean, request: RequestId, buildDir: string | null) => typedError<GradleDependency[], IdeError>(__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,
+46 -23
View File
@@ -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<BuildNode, { kind: "task" }> => n.kind === "task" && n.project.path === first.project.path,
(n): n is Extract<BuildNode, { kind: "task" }> =>
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<string, BuildNode[] | undefined>();
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[]
) : (
<IconButton icon="refresh" size="small" iconSize={16} action={`${tool}.reload`} />
)}
{maven && <IconButton icon="plus" size="small" iconSize={16} action="maven.addProjects" />}
<IconButton icon="plus" size="small" iconSize={16} action={maven ? "maven.addProjects" : "gradle.linkProjects"} />
<span className={styles.sep} />
<IconButton
icon="run"
size="small"
iconSize={16}
title={maven ? "Run Maven Build" : "Run Gradle Task"}
disabled={!runnable.length}
disabled={!config}
onClick={runSelected}
/>
<IconButton
@@ -181,7 +187,8 @@ function Toolbar({ tool, selection }: { tool: BuildTool; selection: BuildNode[]
function Banner({ tool }: { tool: BuildTool }) {
const sync = useBuild((s) => 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 (
<div className={styles.banner} role="status">
@@ -190,7 +197,10 @@ function Banner({ tool }: { tool: BuildTool }) {
{sync.fraction != null && <span className={styles.bannerPct}>{Math.round(sync.fraction * 100)}%</span>}
</div>
);
if (!info) return null;
return infos.map((info) => <BuildBanner key={"dir" in info ? info.dir : "maven"} info={info} tool={tool} />);
}
function BuildBanner({ info, tool }: { info: MavenModel | GradleModel; tool: BuildTool }) {
if (info.stale)
return (
<div className={styles.banner}>
@@ -198,7 +208,7 @@ function Banner({ tool }: { tool: BuildTool }) {
<span className={styles.bannerText}>
<T>Build files changed since the last reload</T>
</span>
<Button size="small" variant="ghost" onClick={() => void syncTool(tool)}>
<Button size="small" variant="ghost" onClick={() => void syncTool(tool, "dir" in info ? info.dir : null)}>
{tool === "maven" ? "Load Maven Changes" : "Load Gradle Changes"}
</Button>
</div>
@@ -260,15 +270,18 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
const root = useProject((s) => s.project?.root ?? "");
const [selection, setSelection] = useState<BuildNode[]>([]);
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") {
@@ -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(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
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(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
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(<DependencyAnalyzer input={{ kind: ANALYZER_KIND, key: ANALYZER_KIND }} />);
expect(await screen.findByText("Cannot resolve dependencies")).toBeTruthy();
expect(screen.getByText(/Could not resolve all files/)).toBeTruthy();
+29 -21
View File
@@ -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<string, MavenDependency[]>();
/** 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<GradleTree & { key: string | null }>({
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<Mode>("list");
const [selectedKey, setSelectedKey] = useState<string | null>(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 <EmptyState title="No Maven or Gradle projects" />;
if (!maven && !gradle?.length) return <EmptyState title="No Maven or Gradle projects" />;
const treeView = (
<div className={styles.body}>
+10 -3
View File
@@ -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<string | null>(() =>
@@ -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 });
+2 -2
View File
@@ -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);
});
+1 -1
View File
@@ -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();
+83 -20
View File
@@ -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<string>();
/** Registers / unregisters the tool windows to match the model; first sync of a never-synced build. */
function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, () => void>>) {
const has: Record<BuildTool, boolean> = { maven: !!model?.maven, gradle: !!model?.gradle };
const has: Record<BuildTool, boolean> = { 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<Record<BuildTool, (
order: 11,
component: GradleView,
});
} else if (!has[tool] && registered[tool]) {
} else if (!visible && registered[tool]) {
registered[tool]!();
delete registered[tool];
}
}
// IDEA imports a build when the project is first opened.
// Import each independent build once; linking another build must not be hidden by the first sync.
const root = useBuild.getState().root;
for (const tool of ["maven", "gradle"] as const) {
const info = tool === "maven" ? model?.maven : model?.gradle;
const key = `${tool}:${root}`;
if (info && (tool !== "maven" || model?.maven?.projects.length) && info.syncedAt == null && !synced.has(key)) {
const mavenKey = `maven:${root}`;
if (model?.maven?.projects.length && model.maven.syncedAt == null && !synced.has(mavenKey)) {
synced.add(mavenKey);
requestSync("maven");
}
for (const build of model?.gradle ?? []) {
const key = JSON.stringify([root, build.dir]);
if (build.syncedAt == null && !synced.has(key)) {
synced.add(key);
void syncTool(tool);
requestSync("gradle", build.dir);
}
}
}
export function activate(): () => void {
const registered: Partial<Record<BuildTool, () => void>> = {};
const showGradle = () => {
registered.gradle ??= registerToolWindow({
id: "gradle",
title: "Gradle",
icon: "toolwindow-gradle",
anchor: "right",
order: 11,
component: GradleView,
});
toolWindowService().show("gradle", true);
};
let timer: ReturnType<typeof setTimeout> | undefined;
const scheduleLoad = () => {
clearTimeout(timer);
@@ -187,6 +217,7 @@ export function activate(): () => void {
}),
useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
synced.clear();
resetBuild();
if (s.project) void loadModel();
}),
@@ -207,6 +238,30 @@ export function activate(): () => void {
if (e.key === "languages" || e.key.startsWith("languages.java")) scheduleLoad();
}),
registerActions([
{
id: "gradle.linkProjects",
title: "Link Gradle Projects",
category: "Gradle",
icon: "plus",
when: "projectOpen && !workspaceOpen",
run: async () => {
showGradle();
await linkGradleProjects();
},
},
{
id: "gradle.unlinkProjects",
title: "Unlink Gradle Project",
category: "Gradle",
hidden: true,
when: `${GRADLE_WHEN} && !workspaceOpen`,
run: async ({ arg }) => {
const dir = (arg as { buildDir?: unknown } | undefined)?.buildDir;
const root = useBuild.getState().root;
if (await unlinkGradleProjects(typeof dir === "string" ? dir : null))
synced.delete(JSON.stringify([root, dir]));
},
},
{
id: "maven.addProjects",
title: "Add Maven Projects",
@@ -319,7 +374,10 @@ export function activate(): () => void {
icon: "refresh",
when: GRADLE_WHEN,
keywords: ["gradle", "sync", "refresh"],
run: () => syncTool("gradle"),
run: ({ arg }) => {
const dir = (arg as { buildDir?: unknown } | undefined)?.buildDir;
requestSync("gradle", typeof dir === "string" ? dir : null);
},
},
{
id: "gradle.analyzeDependencies",
@@ -348,7 +406,11 @@ export function activate(): () => void {
icon: "run-anything",
when: GRADLE_WHEN,
keywords: ["gradle", "task", "run anything"],
run: () => openExecute("gradle"),
run: ({ arg }) => {
const key = (arg as { projectKey?: unknown } | undefined)?.projectKey;
if (typeof key === "string") setCurrentProject("gradle", key);
openExecute("gradle");
},
},
{
id: "gradle.toggleOffline",
@@ -388,6 +450,7 @@ export function activate(): () => void {
for (const d of Object.values(registered)) d?.();
setContext("build.maven", false);
setContext("build.gradle", false);
synced.clear();
resetBuild();
};
}
+169 -4
View File
@@ -1,11 +1,13 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { executeAction, registerAction, allActions, isActionEnabled } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, ProjectInfo } from "@/platform/ipc";
import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { MavenView } from "./BuildToolView";
import { GradleView, MavenView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, gradleProjectKey } from "./model";
import { executeBuildLine } from "./runAnything";
import { useBuild } from "./store";
const mocks = vi.hoisted(() => ({
@@ -16,6 +18,10 @@ const mocks = vi.hoisted(() => ({
open: vi.fn(),
confirm: vi.fn(),
menu: vi.fn(),
link: vi.fn(),
unlink: vi.fn(),
show: vi.fn(),
sync: vi.fn(),
}));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
@@ -29,9 +35,23 @@ vi.mock("@/platform/ipc", async (original) => {
settingsSet: mocks.set,
buildModel: mocks.model,
runConfigs: mocks.configs,
gradleLinkProjects: mocks.link,
gradleUnlinkProjects: mocks.unlink,
buildSync: mocks.sync,
cancelRequest: () => Promise.resolve(),
},
};
});
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage: unknown = null;
},
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
toolWindowService: () => ({ show: mocks.show }),
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.open }));
vi.mock("@/workbench/messages", () => ({ confirm: mocks.confirm }));
vi.mock("@/ui", async (original) => ({
@@ -40,7 +60,7 @@ vi.mock("@/ui", async (original) => ({
}));
const model: BuildModel = {
gradle: null,
gradle: [],
maven: {
syncedAt: 1,
stale: false,
@@ -81,6 +101,7 @@ beforeEach(async () => {
mocks.confirm.mockResolvedValue(true);
useProject.setState({ project: { root: "/p" } as ProjectInfo });
setContext("projectOpen", true);
setContext("workspaceOpen", false);
await act(async () => {
dispose = activate();
});
@@ -204,3 +225,147 @@ it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/
expect(mocks.set).not.toHaveBeenCalled();
},
);
const gradleBuild = (dir: string): GradleModel => ({
dir,
command: `/p/${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: "test", group: "verification", description: null }],
},
],
});
it("links from the toolbar and keeps Link available after unlinking the final build", async () => {
const build = gradleBuild("first");
const linked: BuildModel = { maven: null, gradle: [build] };
mocks.link.mockResolvedValue(linked);
mocks.unlink.mockResolvedValue({ maven: null, gradle: [] });
mocks.open.mockResolvedValue(["/p/first/settings.gradle"]);
act(() => useBuild.setState({ model: { maven: null, gradle: [] } }));
render(<GradleView />);
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Link Gradle Projects" })));
expect(mocks.link).toHaveBeenCalledWith(["/p/first/settings.gradle"]);
expect(screen.getByText("first")).toBeTruthy();
fireEvent.contextMenu(screen.getByText("first"));
expect(mocks.menu).toHaveBeenCalledWith(
expect.anything(),
expect.arrayContaining([expect.objectContaining({ action: "gradle.unlinkProjects", arg: { buildDir: "first" } })]),
);
await act(async () => executeAction("gradle.unlinkProjects", { buildDir: "first" }));
expect(mocks.unlink).toHaveBeenCalledWith(["first"]);
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining("Files will remain") }),
);
expect(screen.getByText("No Gradle projects")).toBeTruthy();
expect(screen.getByRole("button", { name: "Link Gradle Projects" })).toBeTruthy();
expect(useBuild.getState().model?.gradle).toEqual([]);
});
it("leaves links unchanged when unlink is cancelled or a module is targeted", async () => {
act(() => useBuild.setState({ model: { maven: null, gradle: [gradleBuild("first")] } }));
mocks.confirm.mockResolvedValue(false);
await executeAction("gradle.unlinkProjects", { buildDir: "first" });
await executeAction("gradle.unlinkProjects", { buildDir: "first/app" });
expect(mocks.unlink).not.toHaveBeenCalled();
expect(useBuild.getState().model?.gradle).toHaveLength(1);
});
it("runs duplicate task paths with the selected build's wrapper, cwd and offline setting", async () => {
const builds = [gradleBuild("first"), gradleBuild("second")];
const model: BuildModel = { maven: null, gradle: builds };
const roots = gradleRoots(builds);
const expanded = expandableKeys(roots, (n) => gradleChildren(n, builds, [], "/p"));
act(() =>
useBuild.setState({
model,
expanded: { maven: new Set(), gradle: expanded },
current: { gradle: gradleProjectKey(builds[1]!, builds[1]!.projects[1]!) },
settings: { ...useBuild.getState().settings, gradle: { offline: true } },
}),
);
const started: RunConfig[] = [];
const off = registerAction({
id: "run.runConfig",
title: "Run",
hidden: true,
run: ({ arg }) => {
started.push((arg as { config: RunConfig }).config);
},
});
render(<GradleView />);
const tasks = screen.getAllByText("test");
expect(tasks).toHaveLength(2);
await act(async () => fireEvent.doubleClick(tasks[1]!));
expect(executeBuildLine("gradle", "test")).toBe(true);
off();
expect(started).toHaveLength(2);
for (const config of started)
expect(config).toMatchObject({
command: "/p/second/gradlew",
cwd: "second",
args: [":app:test", "--offline"],
env: { JAVA_HOME: "/second/jdk" },
});
});
it("drops a late link result after the user changes projects", async () => {
let finish!: (model: BuildModel) => void;
mocks.open.mockResolvedValue("/p/first/build.gradle");
mocks.link.mockImplementation(
() =>
new Promise<BuildModel>((resolve) => {
finish = resolve;
}),
);
const pending = executeAction("gradle.linkProjects");
await vi.waitFor(() => expect(mocks.link).toHaveBeenCalled());
await act(async () => {
useProject.setState({ project: { root: "/other", trusted: false } as ProjectInfo });
finish({ maven: null, gradle: [gradleBuild("first")] });
await pending;
});
expect(useBuild.getState().model).toBeNull();
});
it("routes Reload to the chosen independent build", async () => {
const model: BuildModel = { maven: null, gradle: [gradleBuild("first"), gradleBuild("second")] };
mocks.sync.mockResolvedValue(model);
act(() => useBuild.setState({ model }));
await executeAction("gradle.reload", { buildDir: "second" });
await vi.waitFor(() =>
expect(mocks.sync).toHaveBeenCalledWith("gradle", expect.any(Number), false, [], "second", expect.anything()),
);
});
it("does not offer Link or Unlink for a native Workspace window", async () => {
act(() =>
useProject.setState({
project: { root: "/p", workspace: { version: 1, name: "workspace", projects: ["/p/first"] } } as ProjectInfo,
}),
);
setContext("workspaceOpen", true);
for (const id of ["gradle.linkProjects", "gradle.unlinkProjects"])
expect(isActionEnabled(allActions().find((a) => a.id === id)!)).toBe(false);
await executeAction("gradle.linkProjects");
expect(mocks.open).not.toHaveBeenCalled();
expect(mocks.link).not.toHaveBeenCalled();
});
it("does not link or start Gradle imports in an untrusted project", async () => {
act(() => useProject.setState({ project: { root: "/p", trusted: false } as ProjectInfo }));
await executeAction("gradle.linkProjects");
expect(mocks.open).not.toHaveBeenCalled();
expect(mocks.link).not.toHaveBeenCalled();
expect(mocks.sync).not.toHaveBeenCalled();
});
+63 -2
View File
@@ -5,6 +5,8 @@ import {
goalCompletions,
gradleChildren,
gradleRoots,
gradleRunConfigs,
gradleProjectKey,
gradleTaskConfig,
isRunnable,
mavenChildren,
@@ -284,10 +286,10 @@ describe("Gradle tree and runs", () => {
syncError: null,
};
const kids = (n: BuildNode) =>
gradleChildren(n, gradle, [runConfig({ id: "g", name: "app [run]", kind: "gradle" })]) ?? [];
gradleChildren(n, [gradle], [runConfig({ id: "g", name: "app [run]", kind: "gradle" })]) ?? [];
it("nests subprojects and groups tasks, other last", () => {
const roots = gradleRoots(gradle);
const roots = gradleRoots([gradle]);
expect(labels(roots)).toEqual(["gradle-demo"]);
expect(labels(kids(roots[0]!))).toEqual(["Tasks", "Run Configurations", "app", "core"]);
const app = kids(roots[0]!)[2]!;
@@ -315,6 +317,65 @@ describe("Gradle tree and runs", () => {
]);
});
it("keeps identical project paths and tasks of independent builds distinct", () => {
const builds = ["first", "second"].map((dir) => ({
...gradle,
dir,
command: `/p/${dir}/gradlew`,
env: { JAVA_HOME: `/${dir}/jdk` },
projects: gradle.projects.map((p) => ({ ...p, dir: `${dir}/${p.dir}`.replace(/\/$/, "") })),
}));
const roots = gradleRoots(builds);
expect(new Set(roots.map((n) => n.key)).size).toBe(2);
const tasks = roots.map((r) => {
const app = gradleChildren(r, builds, [])!.find((n) => n.kind === "gradleProject" && n.project.path === ":app")!;
const folder = gradleChildren(app, builds, [])![0]!;
const group = gradleChildren(folder, builds, [])!.find(
(n) => n.kind === "taskGroup" && n.group === "verification",
)!;
return gradleChildren(group, builds, [])![0]!;
});
expect(tasks[0]!.key).not.toBe(tasks[1]!.key);
const runs = builds.map((b) => gradleTaskConfig(b, b.projects[1]!, ["test"], { offline: true }));
expect(runs[0]!.id).not.toBe(runs[1]!.id);
expect(runs[1]).toMatchObject({
command: "/p/second/gradlew",
cwd: "second",
env: { JAVA_HOME: "/second/jdk" },
args: [":app:test", "--offline"],
});
expect(gradleProjectKey(builds[1]!, builds[1]!.projects[1]!)).toBe('gradle:["second",":app"]');
});
it("shows run configurations only under their owning linked build", () => {
const nested = {
...gradle,
dir: "nested",
projects: gradle.projects.map((p) => ({ ...p, dir: `nested/${p.dir}` })),
};
const configs = [
runConfig({ id: "root", kind: "gradle", cwd: "app" }),
runConfig({ id: "nested", kind: "gradle", cwd: "nested/app" }),
runConfig({ id: "absolute", kind: "gradle", cwd: "/p/nested/app" }),
runConfig({ id: "other", kind: "maven", cwd: "nested" }),
];
expect(gradleRunConfigs(gradle, [gradle, nested], configs, "/p").map((c) => c.id)).toEqual(["root"]);
expect(gradleRunConfigs(nested, [gradle, nested], configs, "/p").map((c) => c.id)).toEqual(["nested", "absolute"]);
});
it("places a saved run under its directly linked root instead of a longer sibling composite", () => {
const composite = {
...gradle,
dir: "very-long-composite",
projects: [{ ...gradle.projects[1]!, path: ":b:app", dir: "b/app", build: ":b" }],
};
const direct = { ...gradle, dir: "b", projects: [{ ...gradle.projects[1]!, dir: "b/app" }] };
const saved = runConfig({ id: "saved", kind: "gradle", cwd: "b/app" });
const builds = [composite, direct];
expect(gradleRunConfigs(composite, builds, [saved], "/p")).toEqual([]);
expect(gradleRunConfigs(direct, builds, [saved], "/p")).toEqual([saved]);
});
it("completes task names and paths", () => {
const labelsOf = taskCompletions("ru", gradle).map((c) => c.label);
expect(labelsOf).toEqual([":app:run"]);
+61 -17
View File
@@ -67,10 +67,10 @@ export type BuildNode =
| { kind: "dependency"; key: string; dependency: MavenDependency }
| { kind: "runConfigs"; key: string; configs: RunConfig[] }
| { kind: "runConfig"; key: string; config: RunConfig }
| { kind: "gradleProject"; key: string; project: GradleProject }
| { kind: "tasks"; key: string; project: GradleProject }
| { kind: "taskGroup"; key: string; project: GradleProject; group: string; tasks: GradleTask[] }
| { kind: "task"; key: string; project: GradleProject; task: GradleTask };
| { kind: "gradleProject"; key: string; build: GradleModel; project: GradleProject }
| { kind: "tasks"; key: string; build: GradleModel; project: GradleProject }
| { kind: "taskGroup"; key: string; build: GradleModel; project: GradleProject; group: string; tasks: GradleTask[] }
| { kind: "task"; key: string; build: GradleModel; project: GradleProject; task: GradleTask };
/** Options applied to every Maven run (toolbar toggles, Profiles checkboxes). */
export interface MavenRunOptions {
@@ -133,9 +133,28 @@ export function mavenRunConfigs(project: MavenProject, configs: readonly RunConf
return configs.filter((c) => c.kind === "maven" && normalizeDir(c.cwd) === project.dir);
}
/** Run configurations of a Gradle build: every `gradle` config. */
export function gradleRunConfigs(configs: readonly RunConfig[]): RunConfig[] {
return configs.filter((c) => c.kind === "gradle");
/** Run configurations under this build, with nested independent builds owning their own runs. */
export function gradleRunConfigs(
model: GradleModel,
models: readonly GradleModel[],
configs: readonly RunConfig[],
root: string,
): RunConfig[] {
const absolute = (path: string) =>
/^(?:[A-Za-z]:[\\/]|\/)/.test(path) ? normalizeDir(path) : normalizeDir(`${root}/${path}`);
return configs.filter((c) => {
if (c.kind !== "gradle") return false;
const cwd = absolute(c.cwd ?? "");
const contains = (dir: string) => cwd === dir || cwd.startsWith(`${dir}/`);
const ownerDepth = (build: GradleModel) => {
const dir = absolute(build.dir);
return contains(dir) ? dir.split("/").length : 0;
};
const owners = models
.filter((build) => [build.dir, ...build.projects.map((p) => p.dir)].some((dir) => contains(absolute(dir))))
.sort((a, b) => ownerDepth(b) - ownerDepth(a));
return owners[0]?.dir === model.dir;
});
}
function normalizeDir(cwd: string | null | undefined): string {
@@ -230,9 +249,26 @@ export function gradleTaskGroups(project: GradleProject): Array<{ group: string;
.map(([group, tasks]) => ({ group, tasks: [...tasks].sort((a, b) => a.name.localeCompare(b.name)) }));
}
export function gradleRoots(model: GradleModel): BuildNode[] {
const root = model.projects.find((p) => p.path === ":") ?? model.projects[0];
return root ? [{ kind: "gradleProject", key: `gradle:${root.path}`, project: root }] : [];
/** Identity of a project within one independently linked build. */
export const gradleProjectKey = (build: Pick<GradleModel, "dir">, project: Pick<GradleProject, "path">) =>
`gradle:${JSON.stringify([build.dir, project.path])}`;
export function gradleProjectForKey(models: readonly GradleModel[], key: string | null | undefined) {
for (const build of models) {
const project = build.projects.find((p) => gradleProjectKey(build, p) === key);
if (project) return { build, project };
}
if (key != null) return null;
const build = models[0];
const project = build?.projects[0];
return build && project ? { build, project } : null;
}
export function gradleRoots(models: readonly GradleModel[]): BuildNode[] {
return models.flatMap((build) => {
const root = build.projects.find((p) => p.path === ":") ?? build.projects[0];
return root ? [{ kind: "gradleProject" as const, key: gradleProjectKey(build, root), build, project: root }] : [];
});
}
/** Direct subprojects of `path` (`:a` and `:b` under `:`, `:a:x` under `:a`). */
@@ -248,31 +284,39 @@ export function gradleSubprojects(model: GradleModel, path: string): GradleProje
export function gradleChildren(
node: BuildNode,
model: GradleModel,
models: readonly GradleModel[],
configs: readonly RunConfig[],
root = "",
): BuildNode[] | undefined {
switch (node.kind) {
case "gradleProject": {
const p = node.project;
const out: BuildNode[] = [{ kind: "tasks", key: `${node.key}:tasks`, project: p }];
const out: BuildNode[] = [{ kind: "tasks", key: `${node.key}:tasks`, build: node.build, project: p }];
if (p.path === ":") {
const runs = gradleRunConfigs(configs);
const runs = gradleRunConfigs(node.build, models, configs, root);
if (runs.length) out.push({ kind: "runConfigs", key: `${node.key}:runs`, configs: runs });
}
for (const sub of gradleSubprojects(model, p.path))
out.push({ kind: "gradleProject", key: `gradle:${sub.path}`, project: sub });
for (const sub of gradleSubprojects(node.build, p.path))
out.push({ kind: "gradleProject", key: gradleProjectKey(node.build, sub), build: node.build, project: sub });
return out;
}
case "tasks":
return gradleTaskGroups(node.project).map(({ group, tasks }) => ({
kind: "taskGroup",
key: `${node.key}:${group}`,
build: node.build,
project: node.project,
group,
tasks,
}));
case "taskGroup":
return node.tasks.map((task) => ({ kind: "task", key: `${node.key}:${task.name}`, project: node.project, task }));
return node.tasks.map((task) => ({
kind: "task",
key: `${node.key}:${task.name}`,
build: node.build,
project: node.project,
task,
}));
case "runConfigs":
return node.configs.map((config) => ({ kind: "runConfig", key: `${node.key}:${config.id}`, config }));
default:
@@ -456,7 +500,7 @@ export function gradleTaskConfig(
if (options.offline && !args.includes("--offline")) args.push("--offline");
const names = tasks.filter((t) => !t.startsWith("-"));
return {
id: `gradle:task:${project.path}:${tasks.join(" ")}`,
id: `gradle:task:${JSON.stringify([model.dir, project.path])}:${tasks.join(" ")}`,
name: `${project.name} [${names.join(",")}]`,
kind: "gradle",
command: model.command,
@@ -37,7 +37,7 @@ const maven = {
],
projects: [],
} as unknown as MavenModel;
const model: BuildModel = { maven, gradle: null };
const model: BuildModel = { maven, gradle: [] };
/** The `-P` entries each sync was started with. */
const syncedProfiles = () => mocks.sync.mock.calls.map((c) => c[3] as string[]);
+17 -16
View File
@@ -8,6 +8,7 @@ import type { RunAnythingItem, RunAnythingProvider } from "@/workbench/contribut
import {
goalCompletions,
gradleTaskConfig,
gradleProjectForKey,
mavenGoalConfig,
parseGoalLine,
parseTaskLine,
@@ -25,19 +26,20 @@ const WORD: Record<BuildTool, string> = { maven: "mvn", gradle: "gradle" };
export function lineTool(line: string): BuildTool | null {
const model = useBuild.getState().model;
if (model?.maven && COMMANDS.maven.test(line)) return "maven";
if (model?.gradle && COMMANDS.gradle.test(line)) return "gradle";
if (model?.gradle?.length && COMMANDS.gradle.test(line)) return "gradle";
return null;
}
/** The project a line runs in: the given one, else the tool window's current one, else the first. */
function projectFor(tool: BuildTool, key?: string | null) {
function mavenProjectFor(key?: string | null) {
const { model, current } = useBuild.getState();
if (tool === "maven") {
const projects = model?.maven?.projects ?? [];
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
}
const projects = model?.gradle?.projects ?? [];
return projects.find((p) => p.path === (key ?? current.gradle)) ?? projects[0];
const projects = model?.maven?.projects ?? [];
return projects.find((p) => p.pom === (key ?? current.maven)) ?? projects[0];
}
function gradleProjectFor(key?: string | null) {
const { model, current } = useBuild.getState();
return gradleProjectForKey(model?.gradle ?? [], key ?? current.gradle);
}
/** Runs goals / tasks typed for `tool` in a project; false when there is nothing to run. */
@@ -45,28 +47,27 @@ export function executeBuildLine(tool: BuildTool, text: string, projectKey?: str
const { model, settings } = useBuild.getState();
if (tool === "maven") {
const maven = model?.maven;
const project = projectFor("maven", projectKey);
const project = mavenProjectFor(projectKey);
const goals = parseGoalLine(text);
if (!maven || !project || !("pom" in project) || !goals.length) return false;
runConfig(mavenGoalConfig(project, goals, maven, settings.maven), "run");
} else {
const gradle = model?.gradle;
const project = projectFor("gradle", projectKey);
const selected = gradleProjectFor(projectKey);
const tasks = parseTaskLine(text);
if (!gradle || !project || "pom" in project || !tasks.length) return false;
runConfig(gradleTaskConfig(gradle, project, tasks, settings.gradle), "run");
if (!selected || !tasks.length) return false;
runConfig(gradleTaskConfig(selected.build, selected.project, tasks, settings.gradle), "run");
}
pushHistory(tool, text.replace(COMMANDS[tool], "").trim());
return true;
}
function completions(tool: BuildTool, rest: string) {
const model = useBuild.getState().model;
if (tool === "maven") {
const project = projectFor("maven");
const project = mavenProjectFor();
return goalCompletions(rest, project && "pom" in project ? project.plugins : [], readHistory("maven"));
}
return model?.gradle ? taskCompletions(rest, model.gradle, readHistory("gradle")) : [];
const selected = gradleProjectFor();
return selected ? taskCompletions(rest, selected.build, readHistory("gradle")) : [];
}
export const buildToolsRunAnything: RunAnythingProvider = {
+99 -25
View File
@@ -27,7 +27,7 @@ import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
import { editorService, notifications } from "@/workbench/contributions";
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
import { gradleRoots, profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
export interface BuildSettings {
maven: MavenRunOptions;
@@ -58,7 +58,7 @@ interface BuildState {
expanded: Record<BuildTool, Set<string>>;
/** Execute Goal / Task popup. */
executeOpen: BuildTool | null;
/** Project selected in each tree (Maven `pom`, Gradle path): where Execute Goal runs. */
/** Project selected in each tree (Maven `pom`, Gradle build/project key): where commands run. */
current: Partial<Record<BuildTool, string>>;
}
@@ -198,9 +198,6 @@ export function changedOutsideIde(paths: readonly string[], now = Date.now()): b
return external;
}
/** Root whose never-synced Gradle build was imported on open (once per open). */
let autoImported: string | null = null;
/** Re-reads the build model (and the run configurations) of the open project. */
export async function loadModel(): Promise<void> {
const root = useProject.getState().project?.root ?? null;
@@ -230,11 +227,6 @@ export async function loadModel(): Promise<void> {
const maven = model.maven;
if (maven?.stale && maven.syncedAt !== null && !useBuild.getState().syncing.maven) void syncTool("maven");
}
// Like IDEA, a Gradle build is imported when first opened; later changes wait for Reload.
if (model.gradle && model.gradle.syncedAt === null && autoImported !== root) {
autoImported = root;
void syncTool("gradle");
}
} catch (e) {
if (gen === generation) useBuild.setState({ loading: false, error: e instanceof Error ? e.message : String(e) });
}
@@ -291,30 +283,96 @@ export async function addProjects() {
await setLinked(root, pom, true);
}
/** Links independent Gradle builds without changing their files or build scripts. */
export async function linkGradleProjects() {
const gen = generation;
const root = useProject.getState().project?.root;
if (
!root ||
useProject.getState().project?.workspace ||
useProject.getState().project?.trusted === false ||
!isTauri()
)
return;
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: tx`Link Gradle Projects`,
directory: false,
multiple: true,
defaultPath: root,
filters: [{ name: "Gradle build files", extensions: ["gradle", "kts"] }],
});
const paths = typeof picked === "string" ? [picked] : picked;
if (!paths?.length || useProject.getState().project?.root !== root || gen !== generation) return;
const model = await call(commands.gradleLinkProjects(paths));
if (useProject.getState().project?.root !== root || gen !== generation) return;
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
if (useProject.getState().project?.root === root && gen === generation)
useBuild.setState((s) => ({
model,
configs,
expanded: {
...s.expanded,
gradle: new Set([...s.expanded.gradle, ...initialExpanded("gradle", model)]),
},
}));
}
export async function unlinkGradleProjects(buildDir: string | null) {
const expectedGeneration = generation;
const { root, model } = useBuild.getState();
const build = model?.gradle?.find((g) => g.dir === buildDir);
if (
!root ||
useProject.getState().project?.workspace ||
!build ||
!(await confirm({
title: "Unlink Gradle project?",
message:
"Remove this build from Gradle? Files will remain in the Project view; link its build file again to restore it.",
ok: "Unlink",
}))
)
return;
if (useProject.getState().project?.root !== root || expectedGeneration !== generation) return;
const gen = ++generation;
queuedSyncs.delete("gradle");
cancelSync("gradle");
const next = await call(commands.gradleUnlinkProjects([build.dir]));
if (useProject.getState().project?.root !== root || gen !== generation) return;
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
if (useProject.getState().project?.root !== root || gen !== generation) return;
useBuild.setState({ model: next, configs, current: { ...useBuild.getState().current, gradle: undefined } });
return true;
}
/** Tree keys expanded on first show: the top-level projects. */
export function initialExpanded(tool: BuildTool, model: BuildModel): Set<string> {
if (tool === "maven")
return new Set(model.maven?.projects.filter((p) => !p.aggregated).map((p) => `mvn:${p.pom}`) ?? []);
return new Set(model.gradle ? ["gradle::"] : []);
return new Set(gradleRoots(model.gradle ?? []).map((n) => n.key));
}
const TITLES: Record<BuildTool, string> = { maven: "Syncing Maven projects", gradle: "Syncing Gradle projects" };
/** Tools to sync again once their running sync ends: its options changed meanwhile. */
const queuedSyncs = new Set<BuildTool>();
const queuedSyncs = new Map<BuildTool, Set<string | null>>();
/** Syncs `tool` now, or right after the sync in progress (which used the previous options). */
export function requestSync(tool: BuildTool) {
if (useBuild.getState().syncing[tool]) queuedSyncs.add(tool);
else void syncTool(tool);
export function requestSync(tool: BuildTool, buildDir: string | null = null) {
if (useBuild.getState().syncing[tool]) {
const queued = queuedSyncs.get(tool) ?? new Set<string | null>();
queued.add(buildDir);
queuedSyncs.set(tool, queued);
} else void syncTool(tool, buildDir);
}
/** Reloads all projects of `tool` from the build tool itself (effective POM, dependency tree, task list). */
export async function syncTool(tool: BuildTool): Promise<void> {
/** Reloads the build tool model; `buildDir` limits a Gradle reload to one linked build. */
export async function syncTool(tool: BuildTool, buildDir: string | null = null): Promise<void> {
const state = useBuild.getState();
const gen = generation;
const root = state.root;
if (!root || !isTauri() || state.syncing[tool]) return;
if (!root || useProject.getState().project?.trusted === false || !isTauri() || state.syncing[tool]) return;
const request = nextRequestId();
const taskId = `build-sync:${tool}`;
const cancel = () => void commands.cancelRequest(request);
@@ -331,11 +389,18 @@ export async function syncTool(tool: BuildTool): Promise<void> {
const profiles = tool === "maven" && model?.maven ? profilesList(model.maven.profiles, settings.maven.profiles) : [];
const offline = tool === "maven" ? settings.maven.offline : settings.gradle.offline;
try {
const next = await call(commands.buildSync(tool, request, offline, profiles, channel));
const next = await call(commands.buildSync(tool, request, offline, profiles, buildDir, channel));
if (useBuild.getState().root !== root || gen !== generation) return;
const configs = await call(commands.runConfigs()).catch(() => useBuild.getState().configs);
if (useBuild.getState().root !== root || gen !== generation) return;
useBuild.setState({ model: next, configs });
const error = tool === "maven" ? next.maven?.syncError : next.gradle?.syncError;
const error =
tool === "maven"
? next.maven?.syncError
: (next.gradle ?? [])
.map((g) => g.syncError)
.filter(Boolean)
.join("\n");
if (error)
notifications().notify({
group: "run",
@@ -355,13 +420,22 @@ export async function syncTool(tool: BuildTool): Promise<void> {
key: `build-sync-${tool}`,
});
} finally {
if (useBuild.getState().syncing[tool]?.request === request) setSync(undefined);
upsertTask({ id: taskId, title: TITLES[tool], message: null, fraction: 1, done: true });
if (queuedSyncs.delete(tool) && gen === generation) void syncTool(tool);
if (useBuild.getState().syncing[tool]?.request === request) {
setSync(undefined);
upsertTask({ id: taskId, title: TITLES[tool], message: null, fraction: 1, done: true });
const queued = queuedSyncs.get(tool);
const next = queued?.values().next();
if (next && !next.done && useBuild.getState().root === root) {
queued!.delete(next.value);
if (!queued!.size) queuedSyncs.delete(tool);
void syncTool(tool, next.value);
}
}
}
}
export function cancelSync(tool: BuildTool) {
queuedSyncs.delete(tool);
const s = useBuild.getState().syncing[tool];
if (!s) return;
const taskId = `build-sync:${tool}`;
@@ -419,8 +493,8 @@ export function setCurrentProject(tool: BuildTool, id: string | undefined) {
export const ANALYZER_KIND = "maven.dependencyAnalyzer";
/**
* Module the Dependency Analyzer shows: a Maven `pom` (project-relative) or `gradle:<path>`
* (`gradle::app`) for a Gradle project; `null` = the first one.
* Module the Dependency Analyzer shows: a Maven `pom` or a Gradle build/project key;
* `null` = the first one.
*/
export const useAnalyzer = create<{ module: string | null }>(() => ({ module: null }));
+7 -3
View File
@@ -116,9 +116,13 @@ export function activate(): () => void {
const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
// The Build tool window's model (one load per build change), not a load of our own.
const build = useBuildModel.getState();
const gradleProjects = build.root === project?.root ? (build.model?.gradle?.projects ?? []) : [];
const rootName = gradleProjects.find((p) => p.path === ":")?.name ?? "";
const groups = own && project ? gradleModuleGroups(gradleProjects, rootName, project.root) : [];
const gradleBuilds = build.root === project?.root ? (build.model?.gradle ?? []) : [];
const groups =
own && project
? gradleBuilds.flatMap((g) =>
gradleModuleGroups(g.projects, g.projects.find((p) => p.path === ":")?.name ?? "", project.root, g.dir),
)
: [];
const key = JSON.stringify([paths, groups]);
if (key === shownRoots) return;
shownRoots = key;
@@ -123,3 +123,15 @@ describe("module group nodes in the tree", () => {
expect(m.children.get(m.roots[0]!.key)!.map((n) => n.label)).toEqual(["billing", "core"]);
});
});
it("namespaces module group identities by independently linked build", () => {
const projects = [
{ path: ":", dir: "/w/first" },
{ path: ":app", dir: "/w/shared" },
];
const first = gradleModuleGroups(projects, "same", "/w/project", "first");
const second = gradleModuleGroups(projects, "same", "/w/project", "second");
expect(first.length).toBeGreaterThan(0);
expect(first[0]!.id).not.toBe(second[0]!.id);
expect(first.map(shape)).toEqual(second.map(shape));
});
+10 -2
View File
@@ -29,7 +29,9 @@ export function gradleModuleGroups(
projects: readonly { path: string; dir: string; build?: string | null }[],
rootName: string,
projectRoot: string,
buildDir?: string,
): ExtraRoot[] {
const identity = (name: string) => (buildDir === undefined ? name : JSON.stringify([buildDir, name]));
const modules: Module[] = projects.map((p) => {
const path = p.path.split(":").filter(Boolean);
// An included build's modules are named after that build (`lib.core`); buildSrc and
@@ -51,7 +53,13 @@ export function gradleModuleGroups(
const moduleNode = (m: Module): ExtraRoot => {
const label = nameOf(m.dir) || m.dir;
return { id: m.name, label, detail: m.name === label ? undefined : m.name, icon: "gradle-module", path: m.dir };
return {
id: identity(m.name),
label,
detail: m.name === label ? undefined : m.name,
icon: "gradle-module",
path: m.dir,
};
};
// `modulesAndGroups`: ungrouped top-level modules as they are (the project folder is
// shown anyway), one node for the common group path of the grouped ones.
@@ -82,7 +90,7 @@ export function gradleModuleGroups(
const seen = new Set<string>();
const moduleNodes = members.filter((m) => !seen.has(m.dir) && !!seen.add(m.dir)).map(moduleNode);
return {
id: `gradleGroup:${group.join(".")}`,
id: identity(`gradleGroup:${group.join(".")}`),
label: group[group.length - 1]!,
icon: "gradle-project",
children: [...[...childGroups.values()].map(node).sort(byLabel), ...moduleNodes.sort(byLabel)],
+2 -1
View File
@@ -50,7 +50,8 @@ async function ensureConfigs() {
export function projectContentRoots(): string[] {
const own = validateProjectStructure(useProject.getState().settings?.projectStructure).contentRoots;
// The build model keeps the folders of modules outside the project absolute.
const gradle = (useBuildModel.getState().model?.gradle?.projects ?? [])
const gradle = (useBuildModel.getState().model?.gradle ?? [])
.flatMap((g) => g.projects)
.map((p) => p.dir)
.filter((d) => isAbsolute(d));
return [...new Set([...own, ...gradle])];
+76 -3
View File
@@ -1,9 +1,31 @@
import { describe, expect, it } from "vitest";
import type { GradleModel, RunConfig } from "@/platform/ipc";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { applicationConfig } from "./application";
import { DELEGATED_DEBUG, delegatedGradleConfig, withTestEvents, wrapperGradleVersion } from "./gradleRun";
import {
DELEGATED_DEBUG,
delegatedGradleConfig,
gradleDelegation,
withTestEvents,
wrapperGradleVersion,
} from "./gradleRun";
import { jdwpLaunch } from "./javaDebug";
import { isJvmApplication } from "@/platform/javaLaunch";
import { useBuildModel } from "@/platform/buildModel";
import { useProject } from "@/platform/project";
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
call: (value: unknown) => value,
commands: {
...real.commands,
runGradleInitScript: () => Promise.resolve("/tmp/openidea-gradle-run.gradle"),
fsReadText: () => Promise.resolve("distributionUrl=https://x/gradle-8.10-bin.zip"),
},
};
});
const ROOT = "/w/shop";
const CORE = "/w/billing/modules/core";
@@ -143,3 +165,54 @@ describe("Gradle test runs", () => {
expect(withTestEvents(twice, script)).toBe(twice);
});
});
describe("delegation across independent builds", () => {
afterEach(() => {
useProject.setState({ project: null, settings: null });
useBuildModel.setState({ root: null, model: null, gradleOffline: false });
});
it("selects the module's linked build and preserves composite identity task paths", async () => {
const builds = ["first", "second"].map((dir) => ({
...gradle,
dir,
command: `${ROOT}/${dir}/gradlew`,
env: { JAVA_HOME: `/${dir}/jdk` },
projects: [{ path: ":app", name: "app", dir: `${dir}/app`, buildFile: null, tasks: [] }],
}));
useProject.setState({ project: { root: ROOT } as ProjectInfo, settings: null });
useBuildModel.setState({ root: ROOT, model: { maven: null, gradle: builds }, gradleOffline: true });
const app = {
...applicationConfig({ className: "a.Main" }, "second/app/src/main/java/a/Main.java", []),
module: "second/app",
};
const run = await gradleDelegation(app);
expect(run).toMatchObject({ command: `${ROOT}/second/gradlew`, cwd: "second", env: { JAVA_HOME: "/second/jdk" } });
expect(run!.args).toContain("--offline");
expect(run!.args.at(-1)).toBe(":app:openideaRun");
});
it("prefers the directly linked owner over a longer sibling composite root", async () => {
const composite = {
...gradle,
dir: "very-long-composite",
command: `${ROOT}/very-long-composite/gradlew`,
projects: [{ path: ":b:app", name: "app", dir: "b/app", buildFile: null, tasks: [], build: ":b" }],
};
const direct = {
...gradle,
dir: "b",
command: `${ROOT}/b/gradlew`,
projects: [{ path: ":app", name: "app", dir: "b/app", buildFile: null, tasks: [] }],
};
useProject.setState({ project: { root: ROOT } as ProjectInfo, settings: null });
useBuildModel.setState({ root: ROOT, model: { maven: null, gradle: [composite, direct] } });
const app = {
...applicationConfig({ className: "a.Main" }, "b/app/src/main/java/a/Main.java", []),
module: "b/app",
};
const run = await gradleDelegation(app);
expect(run).toMatchObject({ command: `${ROOT}/b/gradlew`, cwd: "b" });
expect(run!.args.at(-1)).toBe(":app:openideaRun");
});
});
+13 -2
View File
@@ -135,12 +135,23 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
const { project, settings } = useProject.getState();
if (!project?.root || settings?.languages?.java?.gradle?.runUsing === "ide") return null;
const { model, gradleOffline } = useBuildModel.getState();
if (!model?.gradle) return null;
if (!model?.gradle?.length) return null;
const module = config.module ?? (await findJavaModule(config));
if (module === null) return null;
const dir = absolute(project.root, module);
// A composite can report a sibling's module; prefer a linked root that contains it.
const ownerDepth = (g: GradleModel) => {
const root = absolute(project.root, g.dir).replace(/\\/g, "/").replace(/\/+$/, "");
const path = dir.replace(/\\/g, "/").replace(/\/+$/, "");
return path === root || path.startsWith(`${root}/`) ? root.split("/").length : 0;
};
const gradle = model.gradle
.filter((g) => g.projects.some((p) => absolute(project.root, p.dir) === dir))
.sort((a, b) => ownerDepth(b) - ownerDepth(a))[0];
if (!gradle) return null;
const ctx = {
root: project.root,
gradle: model.gradle,
gradle,
script: "",
offline: gradleOffline,
configurationCache: false,
+5 -1
View File
@@ -103,6 +103,7 @@ export interface LanguagesValid {
jdtlsJdk: string;
jdtlsVmArgs: string;
gradle: {
linkedProjects: string[] | null;
jvm: string;
distribution: "wrapper" | "local";
home: string;
@@ -160,7 +161,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = {
jdk: "",
jdtlsJdk: "",
jdtlsVmArgs: "",
gradle: { jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
gradle: { linkedProjects: null, jvm: "", distribution: "wrapper", home: "", userHome: "", runUsing: "gradle" },
maven: { home: "", userSettings: "", localRepository: "", unlinkedProjects: [], daemon: false, mvndHome: "" },
},
markdown: { previewFontSize: null },
@@ -292,6 +293,9 @@ export function validateLanguages(raw: unknown): LanguagesValid {
jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk),
jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs),
gradle: {
linkedProjects: Array.isArray(gradle.linkedProjects)
? gradle.linkedProjects.filter((p): p is string => typeof p === "string")
: null,
jvm: text(gradle.jvm, d.java.gradle.jvm),
distribution: oneOf(gradle.distribution, ["wrapper", "local"] as const, d.java.gradle.distribution),
home: text(gradle.home, d.java.gradle.home),
+5
View File
@@ -1,4 +1,9 @@
{
"Link Gradle Projects": "关联 Gradle 项目",
"Unlink Gradle Project": "取消关联 Gradle 项目",
"Unlink Gradle project?": "取消关联 Gradle 项目?",
"Remove this build from Gradle? Files will remain in the Project view; link its build file again to restore it.": "从 Gradle 中移除此构建?文件将保留在项目视图中;重新关联其构建文件即可恢复。",
"Gradle build files": "Gradle 构建文件",
"Unity": "Unity 项目",
"Show Meta Files": "显示 Meta 文件",
"Show Generated Files": "显示生成的文件",
+30 -10
View File
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import { buildPresentation, isModuleFolder, moduleFolderIcon } from "./projectPresentation";
import type { BuildModel } from "./ipc";
const model = (partial: unknown) => ({ maven: null, gradle: null, ...(partial as object) }) as BuildModel;
const model = (partial: unknown) => ({ maven: null, gradle: [], ...(partial as object) }) as BuildModel;
describe("build presentation context", () => {
it.each(["maven", "gradle"] as const)("uses distinct %s root and nested module glyphs", (tool) => {
const modules = new Map([
@@ -45,12 +45,14 @@ describe("build presentation context", () => {
it("includes Gradle root and nested project directories", () => {
const context = buildPresentation(
model({
gradle: {
projects: [
{ dir: "", tasks: [] },
{ dir: "lib/core", tasks: [] },
],
},
gradle: [
{
projects: [
{ dir: "", tasks: [] },
{ dir: "lib/core", tasks: [] },
],
},
],
}),
);
expect([...context.modules]).toEqual([
@@ -67,9 +69,27 @@ describe("build presentation context", () => {
}),
).spring,
).toBe(true);
expect(buildPresentation(model({ gradle: { projects: [{ dir: "", tasks: [{ name: "bootRun" }] }] } })).spring).toBe(
true,
);
expect(
buildPresentation(model({ gradle: [{ projects: [{ dir: "", tasks: [{ name: "bootRun" }] }] }] })).spring,
).toBe(true);
});
it("keeps plain folders plain", () => expect(buildPresentation(model({})).modules.size).toBe(0));
});
it("includes modules and Spring tasks from every independently linked Gradle build", () => {
const context = buildPresentation(
model({
gradle: [
{
projects: [
{ dir: "first", tasks: [] },
{ dir: "first/app", tasks: [] },
],
},
{ projects: [{ dir: "second", tasks: [{ name: "bootRun" }] }] },
],
}),
);
expect([...context.modules.keys()]).toEqual(["first", "first/app", "second"]);
expect(context.spring).toBe(true);
});
+2 -2
View File
@@ -14,13 +14,13 @@ export const useBuildPresentation = create<BuildPresentation>(() => EMPTY);
export function buildPresentation(model: BuildModel): BuildPresentation {
const modules = new Map<string, "maven" | "gradle">();
for (const p of model.maven?.projects ?? []) modules.set(p.dir, "maven");
for (const p of model.gradle?.projects ?? []) modules.set(p.dir, "gradle");
for (const p of (model.gradle ?? []).flatMap((g) => g.projects)) modules.set(p.dir, "gradle");
const spring =
(model.maven?.projects ?? []).some(
(p) =>
p.dependencies.some((d) => d.groupId.startsWith("org.springframework")) ||
p.plugins.some((p) => p.groupId.startsWith("org.springframework")),
) || (model.gradle?.projects ?? []).some((p) => p.tasks.some((t) => t.name === "bootRun"));
) || (model.gradle ?? []).some((g) => g.projects.some((p) => p.tasks.some((t) => t.name === "bootRun")));
return { modules, spring };
}