diff --git a/crates/ide-build/src/maven.rs b/crates/ide-build/src/maven.rs index 0268adb63..346049bc4 100644 --- a/crates/ide-build/src/maven.rs +++ b/crates/ide-build/src/maven.rs @@ -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, /// 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, pub id: String, pub active_by_default: bool, } @@ -503,7 +509,7 @@ pub fn settings_profiles(settings: &Path) -> Vec { 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 { "#, ) .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()); diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 7bcc9cc99..1fa72128e 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -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 { 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 { 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::(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 = 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> { 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 { 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 { 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 { diff --git a/crates/ide-host/src/ops/build/workspace.rs b/crates/ide-host/src/ops/build/workspace.rs new file mode 100644 index 000000000..c05337b1b --- /dev/null +++ b/crates/ide-host/src/ops/build/workspace.rs @@ -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> { + scopes_at(&project.root, &project.build_cache_dir(), config, project.settings.effective()) +} + +fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult> { + 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 { + 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 { + 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#"app + extraextra + "#, + ) + .unwrap(); + std::fs::write(member.join("extra/pom.xml"), "extra").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::::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!("{name}"), + ) + .unwrap(); + } + std::fs::write(root.join("pom.xml"), "parentpomenabledhidden").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")]); + } +} diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs index 1b86eeca3..077615cfe 100644 --- a/crates/ide-host/tests/workspaces.rs +++ b/crates/ide-host/tests/workspaces.rs @@ -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"), "app").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"); +} diff --git a/src/bindings.ts b/src/bindings.ts index 7ab772183..1da620525 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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. */ diff --git a/src/features/build-tools/BuildToolView.tsx b/src/features/build-tools/BuildToolView.tsx index 5bec54b98..0fe43f20b 100644 --- a/src/features/build-tools/BuildToolView.tsx +++ b/src/features/build-tools/BuildToolView.tsx @@ -238,7 +238,7 @@ function NodeRow({ node, state }: { node: BuildNode; state: TreeRowState }) { e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}> 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 ( @@ -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) => } 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"); diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index cfca30600..5703f6af7 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -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