дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
3dae4ef729
коміт
7579ecbbe1
11 files changed
+704
-128
No files matched your search
@@ -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());
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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();
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Заблокувати користувача