fix(build-tools): load Maven and Gradle builds of linked workspace projects (#2187)

The Maven and Gradle tool windows disappeared in a workspace, because detection only inspected the workspace metadata folder. They now discover builds in every linked project; models, caches, Maven profile choices, refreshes and run directories use the owning project, and Java reload notifications target its build files. CMake already handled linked projects. Spring's endpoint filter is unchanged.

Fixes #932
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 03:40:12 +08:00
1 джерело 3dae4ef729
коміт 7579ecbbe1
11 files changed
+704 -128

No files matched your search

+10 -3
Переглянути файл
@@ -71,6 +71,9 @@ pub struct MavenModel {
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProject {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
pub pom: String,
/// Project-relative directory; `""` = the project root.
@@ -169,6 +172,9 @@ pub enum Omission {
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct MavenProfile {
/// Owning project folder when displayed in a workspace.
#[specta(optional)]
pub project_root: Option<String>,
pub id: String,
pub active_by_default: bool,
}
@@ -503,7 +509,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
continue;
}
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
}
profiles
}
@@ -1212,11 +1218,12 @@ pub fn load_with_linked(
// The project can activate the profiles of its own POM and its ancestors'.
for (id, active) in &ctx.profiles {
if !model.profiles.iter().any(|x| x.id == *id) {
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
}
}
let artifact_id = i(&pom.artifact_id);
model.projects.push(MavenProject {
project_root: None,
pom: rel_path(root, &path),
dir: rel_path(root, &dir),
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
@@ -1836,7 +1843,7 @@ mod tests {
</settings>"#,
)
.unwrap();
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
+102 -89
Переглянути файл
@@ -1,5 +1,7 @@
//! Maven / Gradle tool windows (the build model and its background sync) and
//! Build › Build Project (compiling with the project's build system).
mod workspace;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use crate::{Host, Project, apply_workspace_edit};
@@ -10,7 +12,6 @@ use ide_build::pom_edit::{ArtifactKey, Exclusion};
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
use ide_core::settings::Settings;
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
use std::collections::BTreeMap;
use std::path::Path;
fn join_err(e: tokio::task::JoinError) -> IdeError {
@@ -125,8 +126,8 @@ fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || workspace::load_project(&project, &config)).await.map_err(join_err)?
}
fn linked_gradle_paths(
@@ -224,84 +225,89 @@ pub async fn build_sync(
) -> IdeResult<BuildModel> {
host.require_trust(window)?;
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let bridge_profiles = profiles.clone();
// What the Java language server and Build Project resolve the project with from now on.
let profiles_changed = tool == BuildTool::Maven
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
if profiles_changed {
// The modules of the chosen profiles are read from the POMs with them from now on (the
// project tree's module folders too), whether or not Maven itself can run.
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 bridge_profiles = profiles.clone();
let p = project.clone();
let config = host.config_dir.clone();
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
let env = match tool {
BuildTool::Maven => current
let scopes = workspace::scopes(&p, &config)?;
let selected: Vec<_> =
scopes.iter().filter(|scope| workspace::selected(scope, &p.root, tool, build_dir.as_deref())).collect();
if selected.is_empty() {
return Err(IdeError::not_found("Build project is no longer linked"));
}
let mut profiles_changed = false;
for scope in selected {
if token.is_cancelled() {
return Err(IdeError::cancelled());
}
let (root, cache, settings) = (&scope.root, &scope.cache, &scope.settings);
let current = load(root, cache, settings);
if tool == BuildTool::Maven && current.maven.is_some() {
let changed = ide_build::save_maven_profiles(cache, &profiles).unwrap_or_else(|e| {
tracing::warn!("Maven profiles not saved: {e}");
false
});
if changed {
p.sink.emit(IdeEvent::ProjectRootsChanged { root: p.root.clone().into() });
}
profiles_changed |= changed;
}
let env = current
.maven
.as_ref()
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
.map(|p| p.env.clone())
.unwrap_or_default(),
BuildTool::Gradle => BTreeMap::new(),
};
let options = SyncOptions {
offline,
profiles,
env,
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(&root, &settings),
maven_settings: settings.languages.java.maven.clone(),
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 => {
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"));
.unwrap_or_default();
let options = SyncOptions {
offline,
profiles: profiles.clone(),
env,
maven_command: ide_sdk::apply::maven_command(root, settings).map(|p| p.to_string_lossy().into_owned()),
maven_options: ide_sdk::apply::maven_options(root, settings),
maven_settings: settings.languages.java.maven.clone(),
gradle_command: None,
};
let progress = |p: SyncProgress| {
let _ = on_progress.send(p);
};
match tool {
BuildTool::Maven if current.maven.is_some() => {
ide_build::sync_maven(root, cache, &options, &token, &progress)?
}
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());
BuildTool::Maven => {}
BuildTool::Gradle => {
for g in current
.gradle
.iter()
.filter(|g| build_dir.as_ref().is_none_or(|dir| p.root.join(dir) == root.join(&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)?;
}
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))
let scopes = workspace::scopes(&p, &config)?;
let model = workspace::load_scopes(&p.root, &scopes);
let updates = workspace::update_files(&p.root, &scopes, &model, tool);
Ok::<_, IdeError>((model, profiles_changed, updates))
})
.await
.map_err(join_err);
host.end_request(request);
let model = result??;
let (model, profiles_changed, updates) = 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();
@@ -320,30 +326,17 @@ pub async fn build_sync(
// to update the project configurations re-resolves them (#436). The same
// holds for Gradle: it reuses its cached classpath on restart, so a
// dependency added to `build.gradle` stays unresolved until told to sync.
let build_files: Vec<String> = match tool {
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
BuildTool::Gradle => {
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
}
};
let lsp = project.lsp.clone();
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
// not the reactor: naming the reactor here would import it again, all modules included.
let imported = match tool {
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
BuildTool::Gradle => None,
};
let uris = match imported {
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
};
tokio::spawn(async move {
if profiles_changed {
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
lsp.reconfigure().await;
}
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
for file in updates {
let uri = ide_lsp::path_to_uri_string(&file);
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
tracing::debug!("java/projectConfigurationsUpdate: {e}");
}
}
});
Ok(model)
@@ -353,12 +346,25 @@ pub async fn build_sync(
/// module's own, else its nearest parent in the project; its path, text and declaration.
fn declaring_pom(
project: &Project,
config: &Path,
pom: &str,
dependency: &ArtifactKey,
) -> IdeResult<(std::path::PathBuf, String, Range)> {
let model = ide_build::maven::load(&project.root);
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
let path = project.root.join(&rel);
let scopes = workspace::scopes(project, config)?;
let path = project.root.join(pom);
let scope = scopes
.iter()
.filter(|s| path.starts_with(&s.root))
.max_by_key(|s| s.root.components().count())
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
let model = load(&scope.root, &scope.cache, &scope.settings).maven.unwrap_or_default();
let pom = model
.projects
.iter()
.find(|p| scope.root.join(&p.pom) == path)
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
for rel in ide_build::pom_edit::declaring_poms(&model, &pom.pom) {
let path = scope.root.join(&rel);
let text = project.text_of(&path)?;
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
return Ok((path, text, range));
@@ -398,14 +404,19 @@ pub async fn gradle_dependencies(
) -> 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 config = host.config_dir.clone();
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let model = load(&root, &cache, &settings);
let scopes = workspace::scopes(&project, &config)?;
let scope = scopes
.iter()
.find(|s| workspace::selected(s, &project.root, BuildTool::Gradle, build_dir.as_deref()))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let model = load(&scope.root, &scope.cache, &scope.settings);
let gradle = model
.gradle
.iter()
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
.find(|g| build_dir.as_ref().is_none_or(|dir| project.root.join(dir) == scope.root.join(&g.dir)))
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
let options = SyncOptions {
offline,
@@ -413,7 +424,7 @@ pub async fn gradle_dependencies(
gradle_command: Some(gradle.command.clone()),
..Default::default()
};
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
ide_build::gradle_dependencies(&scope.root, gradle, &options, &gradle_path, &configuration, &token)
})
.await
.map_err(join_err);
@@ -429,8 +440,9 @@ pub async fn maven_dependency_declaration(
dependency: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
let (path, _, range) = declaring_pom(&project, &config, &pom, &dependency)?;
Ok(Location::file(path, range))
})
.await
@@ -469,8 +481,9 @@ pub async fn maven_exclude_dependency(
exclusion: ArtifactKey,
) -> IdeResult<Location> {
let project = host.project(window)?;
let config = host.config_dir.clone();
tokio::task::spawn_blocking(move || {
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
let (path, text, range) = declaring_pom(&project, &config, &pom, &dependency)?;
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
Some(Exclusion::Edit(edit)) => {
let edit = WorkspaceEdit {
+231
Переглянути файл
@@ -0,0 +1,231 @@
use super::*;
use std::path::PathBuf;
pub(super) struct Scope {
pub root: PathBuf,
pub cache: PathBuf,
pub settings: Settings,
}
pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>> {
scopes_at(&project.root, &project.build_cache_dir(), config, project.settings.effective())
}
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
Ok(match ide_workspace::workspace::Workspace::load(root)? {
Some(workspace) => workspace
.projects
.into_iter()
.filter(|p| p.is_dir())
.map(|root| Scope {
cache: cache.join(crate::project::project_key(&root)),
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
root,
})
.collect(),
None => vec![Scope { root: root.to_owned(), cache: cache.to_owned(), settings }],
})
}
fn absolute(root: &Path, path: &str) -> String {
let path = if path.is_empty() { root.to_owned() } else { root.join(path) };
ide_core::ipc_path::to_ipc(&path)
}
pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
let mut combined = BuildModel::default();
for scope in scopes {
let mut model = load(&scope.root, &scope.cache, &scope.settings);
if scope.root == root {
return model;
}
if let Some(maven) = &mut model.maven {
let owner = ide_core::ipc_path::to_ipc(&scope.root);
for p in &mut maven.projects {
p.project_root = Some(owner.clone());
p.pom = absolute(&scope.root, &p.pom);
p.dir = absolute(&scope.root, &p.dir);
for module in &mut p.modules {
*module = absolute(&scope.root, module);
}
}
for profile in &mut maven.profiles {
profile.project_root = Some(owner.clone());
}
}
if let Some(maven) = model.maven {
match &mut combined.maven {
None => combined.maven = Some(maven),
Some(all) => {
all.projects.extend(maven.projects);
all.profiles.extend(maven.profiles);
all.synced_at = match (all.synced_at, maven.synced_at) {
(Some(a), Some(b)) => Some(a.min(b)),
_ => None,
};
all.stale |= maven.stale;
if let Some(error) = maven.sync_error {
all.sync_error = Some(match all.sync_error.take() {
Some(previous) => format!("{previous}\n{error}"),
None => error,
});
}
}
}
}
for g in &mut model.gradle {
g.dir = absolute(&scope.root, &g.dir);
for p in &mut g.projects {
p.dir = absolute(&scope.root, &p.dir);
p.build_file = p.build_file.as_ref().map(|f| absolute(&scope.root, f));
}
}
combined.gradle.extend(model.gradle);
}
combined
}
pub(super) fn load_project(project: &Project, config: &Path) -> IdeResult<BuildModel> {
Ok(load_scopes(&project.root, &scopes(project, config)?))
}
pub(super) fn selected(scope: &Scope, window_root: &Path, tool: BuildTool, dir: Option<&str>) -> bool {
let Some(dir) = dir else { return true };
match tool {
BuildTool::Maven => scope.root == Path::new(dir),
BuildTool::Gradle => load(&scope.root, &scope.cache, &scope.settings)
.gradle
.iter()
.any(|g| window_root.join(dir) == scope.root.join(&g.dir)),
}
}
/// Build files select their member's language server, including independent nested builds.
pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, tool: BuildTool) -> Vec<PathBuf> {
let mut files = Vec::new();
match tool {
BuildTool::Maven => {
for scope in scopes {
// A reactor with unlinked modules must not be imported as a whole again.
let imported = crate::project::jdtls_maven_import(&scope.root, &scope.settings).1;
let paths: Vec<_> = match imported {
Some(uris) => uris
.iter()
.filter_map(|u| ide_lsp::uri_to_path(u))
.filter(|p| p.file_name().is_some_and(|n| n == "pom.xml"))
.collect(),
None => model
.maven
.iter()
.flat_map(|m| &m.projects)
.filter(|p| p.project_root.as_ref().is_none_or(|owner| Path::new(owner) == scope.root))
.map(|p| root.join(&p.pom))
.collect(),
};
for path in paths {
if !files.contains(&path) {
files.push(path);
}
}
}
}
BuildTool::Gradle => {
for file in model.gradle.iter().flat_map(|g| &g.projects).filter_map(|p| p.build_file.as_ref()) {
let path = root.join(file);
if !files.contains(&path) {
files.push(path);
}
}
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_models_use_member_settings_paths_and_caches() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let workspace = base.join("workspace");
let config = base.join("config");
let cache = base.join("cache");
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
for member in &members {
std::fs::create_dir_all(member.join("extra")).unwrap();
std::fs::write(
member.join("pom.xml"),
r#"<project><artifactId>app</artifactId>
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles>
</project>"#,
)
.unwrap();
std::fs::write(member.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
std::fs::write(member.join("settings.gradle"), "rootProject.name = 'app'").unwrap();
std::fs::write(member.join("build.gradle"), "").unwrap();
}
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", members.clone(), true).unwrap();
let first_cache = cache.join(crate::project::project_key(&members[0]));
ide_build::save_maven_profiles(&first_cache, &["extra".into()]).unwrap();
let settings = ide_workspace::SettingsStore::load(&config, Some(&members[1]));
settings
.set(
ide_workspace::settings::SettingsScope::Project,
"languages.java.gradle.linkedProjects",
serde_json::json!([]),
)
.unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
assert_eq!(scopes[0].cache, first_cache);
let model = load_scopes(&workspace, &scopes);
let maven = model.maven.as_ref().unwrap();
assert_eq!(maven.projects.len(), 3);
assert_eq!(maven.projects[0].modules, [absolute(&members[0], "extra/pom.xml")]);
assert!(maven.projects.iter().all(|p| Path::new(&p.pom).is_absolute() && Path::new(&p.dir).is_absolute()));
assert_eq!(maven.profiles.len(), 2);
assert_ne!(maven.profiles[0].project_root, maven.profiles[1].project_root);
let updates = update_files(&workspace, &scopes, &model, BuildTool::Maven);
assert_eq!(updates.len(), 3);
assert!(updates.iter().all(|p| members.iter().any(|root| p.starts_with(root))));
assert_eq!(update_files(&workspace, &scopes, &model, BuildTool::Gradle), [members[0].join("build.gradle")]);
assert_eq!(model.gradle.len(), 1);
assert_eq!(model.gradle[0].dir, absolute(&members[0], ""));
assert!(selected(&scopes[0], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
assert!(!selected(&scopes[1], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
assert!(selected(&scopes[0], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
assert!(!selected(&scopes[1], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
assert_eq!(ide_build::maven_profiles(&scopes[1].cache), Vec::<String>::new());
let standalone = scopes_at(&members[0], &cache, &config, Settings::default()).unwrap();
assert_eq!(load_scopes(&members[0], &standalone), load(&members[0], &cache, &Settings::default()));
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", vec![members[1].clone()], false).unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
let model = load_scopes(&workspace, &scopes);
assert_eq!(model.maven.unwrap().projects.len(), 1);
assert!(model.gradle.is_empty());
std::fs::remove_dir_all(&members[1]).unwrap();
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
assert_eq!(load_scopes(&workspace, &scopes), BuildModel::default());
}
#[test]
fn reload_keeps_unlinked_modules_out_of_member_language_servers() {
let temp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(temp.path()).unwrap();
for name in ["enabled", "hidden"] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(
root.join(name).join("pom.xml"),
format!("<project><artifactId>{name}</artifactId></project>"),
)
.unwrap();
}
std::fs::write(root.join("pom.xml"), "<project><artifactId>parent</artifactId><packaging>pom</packaging><modules><module>enabled</module><module>hidden</module></modules></project>").unwrap();
let mut settings = Settings::default();
settings.languages.java.maven.unlinked_projects = vec!["hidden/pom.xml".into()];
let scope = Scope { root: root.clone(), cache: root.join("cache"), settings };
let model = load(&scope.root, &scope.cache, &scope.settings);
assert_eq!(update_files(&root, &[scope], &model, BuildTool::Maven), [root.join("enabled/pom.xml")]);
}
}
+41
Переглянути файл
@@ -165,3 +165,44 @@ async fn php_language_level_follows_the_member_of_the_file() {
assert_eq!(level_elsewhere.member, None);
host.close_project("main");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn build_tool_availability_follows_workspace_members_and_trust() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
for member in &members {
std::fs::create_dir(member).unwrap();
std::fs::write(member.join("pom.xml"), "<project><artifactId>app</artifactId></project>").unwrap();
std::fs::write(member.join("build.gradle"), "").unwrap();
}
let root = base.join("workspace");
Workspace::save(&root, "Build tools", members.clone(), true).unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("data"),
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
syntax: Arc::default(),
sink_for: Box::new(|_| Arc::new(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
});
for member in &members {
host.trust.decide(member, true, false).unwrap();
}
host.open_project("main", &root).unwrap();
assert!(ops::build::build_model(&host, "main").await.is_err());
host.close_project("main");
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let model = ops::build::build_model(&host, "main").await.unwrap();
assert_eq!(model.maven.unwrap().projects.len(), 2);
assert_eq!(model.gradle.len(), 2);
ops::project::save_workspace(&host, "main", "One member", vec![members[1].clone()]).unwrap();
let model = ops::build::build_model(&host, "main").await.unwrap();
assert_eq!(model.maven.unwrap().projects[0].dir, ide_core::ipc_path::to_ipc(&members[1]));
assert_eq!(model.gradle.len(), 1);
host.close_project("main");
}
+4
Переглянути файл
@@ -5581,11 +5581,15 @@ export type MavenPlugin = {
};
export type MavenProfile = {
/** Owning project folder when displayed in a workspace. */
projectRoot?: string | null,
id: string,
activeByDefault: boolean,
};
export type MavenProject = {
/** Owning project folder when displayed in a workspace. */
projectRoot?: string | null,
/** Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`). */
pom: string,
/** Project-relative directory; `""` = the project root. */
+13 -10
Переглянути файл
@@ -238,7 +238,7 @@ function NodeRow({ node, state }: { node: BuildNode; state: TreeRowState }) {
<span className={styles.row} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}>
<Checkbox
checked={profileChecked(node.profile, choices)}
onChange={(v) => setProfile(node.profile.id, v)}
onChange={(v) => setProfile(node.profile, v)}
label={node.profile.id}
className={styles.profile}
/>
@@ -246,13 +246,15 @@ function NodeRow({ node, state }: { node: BuildNode; state: TreeRowState }) {
);
}
const detail =
node.kind === "dependency" && node.dependency.scope !== "compile"
? `(${node.dependency.scope}${node.dependency.optional ? ", optional" : ""})`
: node.kind === "dependency" && node.dependency.optional
? "(optional)"
: node.kind === "task"
? node.task.description
: null;
node.kind === "profiles" && node.projectRoot
? node.projectRoot.split("/").pop()
: node.kind === "dependency" && node.dependency.scope !== "compile"
? `(${node.dependency.scope}${node.dependency.optional ? ", optional" : ""})`
: node.kind === "dependency" && node.dependency.optional
? "(optional)"
: node.kind === "task"
? node.task.description
: null;
return (
<span className={styles.row} title={node.kind === "task" ? (node.task.description ?? undefined) : undefined}>
<Icon name={nodeIcon(node)} size={16} className={styles.icon} />
@@ -338,7 +340,8 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
{
label: "Open pom.xml",
icon: "file-maven",
run: () => void editorService().openFile(`${root}/${node.project.pom}`),
run: () =>
void editorService().openFile(node.project.projectRoot ? node.project.pom : `${root}/${node.project.pom}`),
},
{ kind: "action", action: "maven.reload", label: "Reload All Maven Projects" },
{ kind: "action", action: "maven.executeGoal" },
@@ -415,7 +418,7 @@ function BuildToolView({ tool }: { tool: BuildTool }) {
renderNode={(n, state) => <NodeRow node={n} state={state} />}
onActivate={(n) => {
if (n.kind === "profile") {
setProfile(n.profile.id, !profileChecked(n.profile, useBuild.getState().settings.maven.profiles));
setProfile(n.profile, !profileChecked(n.profile, useBuild.getState().settings.maven.profiles));
return;
}
if (isRunnable(n)) run([n], "run");
+21 -11
Переглянути файл
@@ -22,7 +22,15 @@ import { ExecutePopupHost } from "./ExecutePopup";
import { pomNavigationExtension } from "./pomNavigation";
import { buildToolsRunAnything } from "./runAnything";
import { versionCatalogExtension } from "./versionCatalog";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, gradleProjectKey } from "./model";
import {
expandableKeys,
gradleChildren,
gradleRoots,
mavenChildren,
mavenRoots,
mavenPomAtPath,
gradleProjectKey,
} from "./model";
import {
ANALYZER_KIND,
addProjects,
@@ -81,8 +89,8 @@ function analyzerPom(arg: unknown): string | null {
const a = (arg ?? {}) as { pom?: unknown; path?: unknown };
if (typeof a.pom === "string") return a.pom;
if (typeof a.path === "string" && root) {
const rel = a.path.startsWith(`${root}/`) ? a.path.slice(root.length + 1) : a.path;
if (projects.some((p) => p.pom === rel)) return rel;
const pom = mavenPomAtPath(projects, root, a.path);
if (pom) return pom;
}
return current.maven ?? null;
}
@@ -143,10 +151,12 @@ function apply(model: BuildModel | null, registered: Partial<Record<BuildTool, (
}
// Import each independent build once; linking another build must not be hidden by the first sync.
const root = useBuild.getState().root;
const mavenKey = `maven:${root}`;
if (model?.maven?.projects.length && model.maven.syncedAt == null && !synced.has(mavenKey)) {
synced.add(mavenKey);
requestSync("maven");
for (const member of new Set(model?.maven?.projects.map((p) => p.projectRoot ?? null))) {
const key = JSON.stringify(["maven", root, member]);
if (model?.maven?.syncedAt == null && !synced.has(key)) {
synced.add(key);
requestSync("maven", member);
}
}
for (const build of model?.gradle ?? []) {
const key = JSON.stringify([root, build.dir]);
@@ -209,8 +219,8 @@ export function activate(): () => void {
const path = paths.length === 1 && !dirs?.[0] ? paths[0]! : null;
if (!path || !path.endsWith("/pom.xml")) return [];
const { model, root } = useBuild.getState();
const pom = root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : null;
if (!pom || !model?.maven?.projects.some((p) => p.pom === pom)) return [];
const pom = mavenPomAtPath(model?.maven?.projects ?? [], root, path);
if (!pom) return [];
return [{ kind: "action", action: "maven.analyzeDependencies", arg: { pom }, label: "Analyze Dependencies…" }];
},
}),
@@ -269,7 +279,7 @@ export function activate(): () => void {
title: "Add Maven Projects",
category: "Maven",
icon: "plus",
when: "projectOpen",
when: "projectOpen && !workspaceOpen",
run: () => addProjects(),
},
{
@@ -277,7 +287,7 @@ export function activate(): () => void {
title: "Unlink Maven Projects",
category: "Maven",
hidden: true,
when: MAVEN_WHEN,
when: `${MAVEN_WHEN} && !workspaceOpen`,
run: ({ arg }) => unlinkProjects(analyzerPom(arg)),
},
{
+12
Переглянути файл
@@ -12,6 +12,7 @@ import {
mavenChildren,
mavenGoalConfig,
mavenRoots,
mavenPomAtPath,
nodeIcon,
nodeLabel,
parseGoalLine,
@@ -395,3 +396,14 @@ describe("Gradle tree and runs", () => {
expect(taskCompletions("clean wr", gradle).map((c) => c.line)).toEqual(["clean wrapper"]);
});
});
it.each([
["pom.xml", "/workspace/pom.xml"],
["module/pom.xml", "/workspace/module/pom.xml"],
["/linked/pom.xml", "/linked/pom.xml"],
["/workspace/member/pom.xml", "/workspace/member/pom.xml"],
])("resolves the Maven model POM %s at %s", (pom, path) => {
const projects = [project({ pom })];
expect(mavenPomAtPath(projects, "/workspace", path)).toBe(pom);
expect(mavenPomAtPath(projects, "/workspace", "/other/pom.xml")).toBeNull();
});
+23 -6
Переглянути файл
@@ -55,7 +55,7 @@ export const ALL_PHASES = [
];
export type BuildNode =
| { kind: "profiles"; key: string }
| { kind: "profiles"; key: string; projectRoot?: string | null }
| { kind: "profile"; key: string; profile: MavenProfile }
| { kind: "mavenProject"; key: string; project: MavenProject }
| { kind: "lifecycle"; key: string; project: MavenProject }
@@ -76,7 +76,7 @@ export type BuildNode =
export interface MavenRunOptions {
offline: boolean;
skipTests: boolean;
/** Explicit profile choices: id → checked. Unlisted profiles keep their default. */
/** Explicit profile choices by profileKey. Unlisted profiles keep their default. */
profiles: Record<string, boolean>;
}
@@ -100,9 +100,14 @@ export function pluginLabel(p: MavenPlugin): string {
return `${p.prefix} (${p.groupId}:${p.artifactId}${p.version ? `:${p.version}` : ""})`;
}
/** Profile choices in different workspace members are independent. */
export function profileKey(profile: MavenProfile): string {
return profile.projectRoot ? JSON.stringify([profile.projectRoot, profile.id]) : profile.id;
}
/** Whether a profile is active for runs: the explicit choice, else `activeByDefault`. */
export function profileChecked(profile: MavenProfile, choices: Record<string, boolean>): boolean {
return choices[profile.id] ?? profile.activeByDefault;
return choices[profileKey(profile)] ?? profile.activeByDefault;
}
/** `-P` value for the choices that differ from the defaults (`a,!b`), or null. */
@@ -123,11 +128,17 @@ export function profilesList(profiles: readonly MavenProfile[], choices: Record<
export function mavenRoots(model: MavenModel): BuildNode[] {
const out: BuildNode[] = [];
if (model.profiles.length) out.push({ kind: "profiles", key: "mvn:profiles" });
for (const projectRoot of new Set(model.profiles.map((p) => p.projectRoot)))
out.push({ kind: "profiles", key: `mvn:profiles${projectRoot ?? ""}`, projectRoot });
for (const p of model.projects) if (!p.aggregated) out.push({ kind: "mavenProject", key: projectKey(p), project: p });
return out;
}
/** Resolves a POM from either a project-relative or workspace-absolute model path. */
export function mavenPomAtPath(projects: readonly MavenProject[], root: string | null, path: string): string | null {
return projects.find((p) => p.pom === path || (root && `${root}/${p.pom}` === path))?.pom ?? null;
}
/** Run configurations of a Maven project: `maven` configs whose directory is the project's. */
export function mavenRunConfigs(project: MavenProject, configs: readonly RunConfig[]): RunConfig[] {
return configs.filter((c) => c.kind === "maven" && normalizeDir(c.cwd) === project.dir);
@@ -171,7 +182,9 @@ export function mavenChildren(
): BuildNode[] | undefined {
switch (node.kind) {
case "profiles":
return model.profiles.map((profile) => ({ kind: "profile", key: `mvn:profile:${profile.id}`, profile }));
return model.profiles
.filter((p) => p.projectRoot === node.projectRoot)
.map((profile) => ({ kind: "profile", key: `mvn:profile:${profileKey(profile)}`, profile }));
case "mavenProject": {
const p = node.project;
const k = projectKey(p);
@@ -474,7 +487,11 @@ export function mavenGoalConfig(
model: Pick<MavenModel, "profiles">,
options: MavenRunOptions,
): RunConfig {
const args = mavenArgs(goals, model, options);
const args = mavenArgs(
goals,
{ profiles: model.profiles.filter((p) => p.projectRoot === project.projectRoot) },
options,
);
return {
id: `maven:goal:${project.pom}:${goals.join(" ")}`,
name: mavenConfigName(project, goals),
+33 -9
Переглянути файл
@@ -18,6 +18,7 @@ import {
nextRequestId,
type BuildModel,
type BuildTool,
type MavenProfile,
type RequestId,
type RunConfig,
type SyncProgress,
@@ -27,7 +28,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 { gradleRoots, profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
import { gradleRoots, profileKey, profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
export interface BuildSettings {
maven: MavenRunOptions;
@@ -126,15 +127,20 @@ export const toggleGradleOffline = () =>
/** Delay before reloading Maven after a profile toggle, so a few toggles in a row reload once. */
export const PROFILE_RELOAD_DELAY_MS = 800;
let profileReload: ReturnType<typeof setTimeout> | undefined;
const profileRoots = new Set<string | null>();
export function setProfile(id: string, checked: boolean) {
export function setProfile(profile: string | MavenProfile, checked: boolean) {
const id = typeof profile === "string" ? profile : profileKey(profile);
const projectRoot = typeof profile === "string" ? null : (profile.projectRoot ?? null);
updateSettings((s) => ({ ...s, maven: { ...s.maven, profiles: { ...s.maven.profiles, [id]: checked } } }));
// The Java language server and Build Project resolve the project with the profiles of the
// last sync, so a new choice reloads it (IDEA re-imports on a profile change too).
profileRoots.add(projectRoot);
clearTimeout(profileReload);
profileReload = setTimeout(() => {
profileReload = undefined;
requestSync("maven");
for (const root of profileRoots) requestSync("maven", root);
profileRoots.clear();
}, PROFILE_RELOAD_DELAY_MS);
}
@@ -240,7 +246,7 @@ const LINKED_PROJECTS = "languages.java.maven.linkedProjects";
* reactor lists them), or removes them (a linked one is dropped, a discovered one unlinked).
*/
async function setLinked(root: string, poms: string[], linked: boolean) {
if (useProject.getState().project?.root !== root) return;
if (useProject.getState().project?.root !== root || useProject.getState().project?.workspace) return;
const settings = await commands.settingsGet();
if (useProject.getState().project?.root !== root) return;
const maven = settings.languages.java?.maven;
@@ -266,7 +272,7 @@ async function setLinked(root: string, poms: string[], linked: boolean) {
export async function unlinkProjects(pom: string | null) {
const { root, model } = useBuild.getState();
const project = model?.maven?.projects.find((p) => p.pom === pom);
if (!root || !pom || !project) return;
if (!root || !pom || !project || useProject.getState().project?.workspace) return;
if (
!(await confirm({
title: "Unlink Maven projects?",
@@ -295,7 +301,7 @@ function projectPoms(root: string, picked: readonly string[]): string[] {
export async function addProjects() {
const root = useProject.getState().project?.root;
if (!root || !isTauri()) return;
if (!root || useProject.getState().project?.workspace || !isTauri()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
title: tx`Add Maven Projects`,
@@ -392,7 +398,7 @@ export function requestSync(tool: BuildTool, buildDir: string | null = null) {
} else void syncTool(tool, buildDir);
}
/** Reloads the build tool model; `buildDir` limits a Gradle reload to one linked build. */
/** Reloads one Maven workspace member / linked Gradle build, or all builds when `buildDir` is null. */
export async function syncTool(tool: BuildTool, buildDir: string | null = null): Promise<void> {
const state = useBuild.getState();
const gen = generation;
@@ -411,10 +417,27 @@ export async function syncTool(tool: BuildTool, buildDir: string | null = null):
upsertTask({ id: taskId, title: TITLES[tool], message: p.message, fraction: p.fraction, done: false, cancel });
};
const { settings, model } = state;
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, buildDir, channel));
const roots =
tool === "maven" && useProject.getState().project?.workspace
? [...new Set(model?.maven?.projects.map((p) => p.projectRoot ?? null))].filter(
(r) => !buildDir || r === buildDir,
)
: [buildDir];
let next = model;
for (const dir of roots) {
if (useBuild.getState().root !== root || useBuild.getState().syncing[tool]?.request !== request) return;
const profiles =
tool === "maven" && model?.maven
? profilesList(
model.maven.profiles.filter((p) => (p.projectRoot ?? null) === dir),
settings.maven.profiles,
)
: [];
next = await call(commands.buildSync(tool, request, offline, profiles, dir, channel));
}
if (!next) return;
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;
@@ -472,6 +495,7 @@ export function resetBuild() {
generation++;
clearTimeout(profileReload);
profileReload = undefined;
profileRoots.clear();
queuedSyncs.clear();
useAnalyzer.setState({ module: null });
for (const tool of ["maven", "gradle"] as const) {
+214
Переглянути файл
@@ -0,0 +1,214 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { BuildModel, ProjectInfo, RunConfig } from "@/platform/ipc";
import { registerAction } from "@/platform/actions";
import { setContext } from "@/platform/context";
import { useProject } from "@/platform/project";
import { MavenView, GradleView } from "./BuildToolView";
import { activate } from "./index";
import { expandableKeys, gradleChildren, gradleRoots, mavenChildren, mavenRoots, profileKey } from "./model";
import { executeBuildLine } from "./runAnything";
import { loadModel, PROFILE_RELOAD_DELAY_MS, resetBuild, setProfile, syncTool, useBuild } from "./store";
const mocks = vi.hoisted(() => ({ model: vi.fn(), sync: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
call: (value: unknown) => value,
commands: {
...real.commands,
buildModel: mocks.model,
runConfigs: () => Promise.resolve([]),
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;
},
}));
const members = ["/projects/first", "/projects/second"];
const model: BuildModel = {
maven: {
syncedAt: 1,
stale: false,
syncError: null,
profiles: members.map((projectRoot) => ({ id: "local", activeByDefault: false, projectRoot })),
projects: members.map((projectRoot) => ({
projectRoot,
pom: `${projectRoot}/pom.xml`,
dir: projectRoot,
groupId: "example",
artifactId: "app",
version: "1",
packaging: "jar",
displayName: projectRoot.split("/").pop()!,
parent: null,
modules: [],
aggregated: false,
plugins: [],
dependencies: [],
profiles: ["local"],
javaVersion: null,
command: `${projectRoot}/mvnw`,
options: [],
env: {},
})),
},
gradle: members.map((dir) => ({
dir,
command: `${dir}/gradlew`,
env: {},
javaVersion: null,
syncedAt: 1,
stale: false,
syncError: null,
projects: [
{
path: ":",
name: dir.split("/").pop()!,
dir,
buildFile: `${dir}/build.gradle`,
tasks: [{ name: "test", group: "verification", description: null }],
},
],
})),
};
let dispose: () => void;
beforeEach(async () => {
vi.clearAllMocks();
localStorage.clear();
resetBuild();
useProject.setState({
project: {
root: "/workspace",
trusted: true,
workspace: { version: 1, name: "Workspace", projects: members },
} as ProjectInfo,
});
setContext("projectOpen", true);
setContext("workspaceOpen", true);
mocks.model.mockResolvedValue(model);
mocks.sync.mockResolvedValue(model);
await act(async () => {
dispose = activate();
});
});
afterEach(() => {
dispose();
resetBuild();
useProject.setState({ project: null });
setContext("projectOpen", false);
setContext("workspaceOpen", false);
vi.useRealTimers();
});
it("lists linked Maven projects and keeps duplicate profile choices and goal directories independent", async () => {
const maven = model.maven!;
const roots = mavenRoots(maven);
act(() =>
useBuild.setState((s) => ({
expanded: { ...s.expanded, maven: expandableKeys(roots, (n) => mavenChildren(n, maven, [])) },
})),
);
render(<MavenView />);
expect(screen.getAllByText("first")).toHaveLength(2);
expect(screen.getAllByText("second")).toHaveLength(2);
const profiles = screen.getAllByRole("checkbox", { name: "local" });
expect(profiles).toHaveLength(2);
vi.useFakeTimers();
await act(async () => {
fireEvent.click(profiles[1]!);
});
expect(profiles[0]).not.toBeChecked();
expect(profiles[1]).toBeChecked();
await act(async () => {
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
});
expect(mocks.sync).toHaveBeenCalledWith("maven", expect.any(Number), false, ["local"], members[1], expect.anything());
const runs: RunConfig[] = [];
const off = registerAction({
id: "run.runConfig",
title: "Run",
hidden: true,
run: ({ arg }) => {
runs.push((arg as { config: RunConfig }).config);
},
});
await act(async () => {
fireEvent.doubleClick(screen.getAllByText("validate")[1]!);
});
expect(executeBuildLine("maven", "validate", maven.projects[0]!.pom)).toBe(true);
off();
expect(runs[0]).toMatchObject({ cwd: members[1], command: `${members[1]}/mvnw`, args: ["validate", "-Plocal"] });
expect(runs[1]).toMatchObject({ cwd: members[0], command: `${members[0]}/mvnw`, args: ["validate"] });
});
it("reloads all Maven members with their own profile choices", async () => {
vi.useFakeTimers();
setProfile(model.maven!.profiles[0]!, true);
setProfile(model.maven!.profiles[1]!, true);
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
expect(mocks.sync.mock.calls.map((c) => [c[3], c[4]])).toEqual([
[["local"], members[0]],
[["local"], members[1]],
]);
mocks.sync.mockClear();
useBuild.setState((s) => ({
settings: {
...s.settings,
maven: { ...s.settings.maven, profiles: { [profileKey(model.maven!.profiles[1]!)]: true } },
},
}));
await syncTool("maven");
expect(mocks.sync.mock.calls.map((c) => [c[3], c[4]])).toEqual([
[[], members[0]],
[["local"], members[1]],
]);
});
it("shows and runs duplicate Gradle tasks with each member's wrapper and refresh directory", async () => {
const roots = gradleRoots(model.gradle ?? []);
act(() =>
useBuild.setState((s) => ({
expanded: {
...s.expanded,
gradle: expandableKeys(roots, (n) => gradleChildren(n, model.gradle ?? [], [], "/workspace")),
},
})),
);
render(<GradleView />);
const runs: RunConfig[] = [];
const off = registerAction({
id: "run.runConfig",
title: "Run",
hidden: true,
run: ({ arg }) => {
runs.push((arg as { config: RunConfig }).config);
},
});
await act(async () => {
fireEvent.doubleClick(screen.getAllByText("test")[1]!);
});
off();
expect(runs[0]).toMatchObject({ cwd: members[1], command: `${members[1]}/gradlew`, args: ["test"] });
await syncTool("gradle", members[1]);
expect(mocks.sync).toHaveBeenCalledWith("gradle", expect.any(Number), false, [], members[1], expect.anything());
});
it("refreshes availability after workspace membership changes and does not load untrusted windows", async () => {
mocks.model.mockResolvedValue({ maven: null, gradle: [] });
await loadModel();
expect(useBuild.getState().model).toEqual({ maven: null, gradle: [] });
mocks.model.mockClear();
act(() => useProject.setState({ project: { ...useProject.getState().project!, trusted: false } }));
await loadModel();
expect(mocks.model).not.toHaveBeenCalled();
expect(useBuild.getState().model).toBeNull();
});