fix(java): resolve Maven projects with the profiles chosen in the Maven tool window (#1044)

The profiles checked in the Maven tool window only reached `mvn` (sync and
goal runs) and the Kotlin bridge. jdtls imported every module without them,
so a dependency whose version, or module, comes from a profile stayed
unresolved and its classes were reported as missing, while Build Project ran
without `-P` as well.

- A Maven sync records the profiles it ran with. jdtls gets a copy of the
  user settings.xml with them in `<activeProfiles>` (m2e has no other input
  for profiles), sent as a setting so a running server re-imports instead of
  restarting; the file name follows its content so m2e reloads it.
- Toggling a profile reloads the Maven projects (once for several toggles in
  a row, again after a sync that started with the previous choice).
- Build Project passes the synced profiles with `-P`.
- When jdtls finishes an import or project update it does not re-check open
  files, so their errors stayed until the next edit; open Java files are now
  re-checked on its ProjectStatus report. This also covers a POM edit or a
  dependency added to ~/.m2.

Fixes #993
This commit is contained in:
l0ng-ai
2026-11-03 22:02:40 +08:00
committed by GitHub
parent 9dbae1d189
commit ae406ca2fb
11 changed files with 476 additions and 3 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ mod xml;
pub use gradle::{GradleModel, GradleProject, GradleTask};
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
pub use sync::{SyncOptions, SyncProgress, sync_gradle, sync_maven};
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
use serde::{Deserialize, Serialize};
use specta::Type;
+18
View File
@@ -88,6 +88,24 @@ fn maven_sync_goals(effective: &Path, offline: bool) -> Vec<String> {
]
}
const GRADLE_CACHE: &str = "gradle.json";
const MAVEN_PROFILES: &str = "maven-profiles.json";
/// The Maven profiles (`-P` entries, `!id` deactivates) the project was last synced
/// with: what the IDE resolves it with besides `mvn` (the Java language server, Build Project).
pub fn maven_profiles(cache_dir: &Path) -> Vec<String> {
read_cache(cache_dir, MAVEN_PROFILES).unwrap_or_default()
}
/// Records the profiles of a sync for [`maven_profiles`]; `true` when they changed.
pub fn save_maven_profiles(cache_dir: &Path, profiles: &[String]) -> IdeResult<bool> {
if maven_profiles(cache_dir) == profiles {
return Ok(false);
}
std::fs::create_dir_all(cache_dir)?;
let json = serde_json::to_vec(profiles).map_err(|e| IdeError::internal(e.to_string()))?;
std::fs::write(cache_dir.join(MAVEN_PROFILES), json)?;
Ok(true)
}
fn read_cache<T: DeserializeOwned>(dir: &Path, name: &str) -> Option<T> {
serde_json::from_slice(&std::fs::read(dir.join(name)).ok()?).ok()
+25 -2
View File
@@ -74,6 +74,18 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
model
}
/// `model` building with the Maven profiles of the last sync (`-P`), as Maven runs from
/// the tool window do.
fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
let profiles = ide_build::maven_profiles(cache);
if let (Some(m), false) = (&mut model.maven, profiles.is_empty()) {
for p in &mut m.projects {
p.options.push(format!("-P{}", profiles.join(",")));
}
}
model
}
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
host.require_trust(window)?;
@@ -98,6 +110,12 @@ pub async fn build_sync(
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
});
let token = host.begin_project_request(window, request);
let result = tokio::task::spawn_blocking(move || {
let current = load(&root, &cache, &settings);
@@ -152,6 +170,10 @@ pub async fn build_sync(
let lsp = project.lsp.clone();
let uris = 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}");
}
@@ -257,7 +279,8 @@ pub async fn build_run(
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
let member_cache = cache.join(crate::project::project_key(member));
let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?;
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
let plan = compile::plan(member, &model, kind, file.as_deref())?;
match &mut combined {
Some(combined) => combined.steps.extend(plan.steps),
None => combined = Some(plan),
@@ -267,7 +290,7 @@ pub async fn build_run(
plan.title = workspace.name;
plan
} else {
compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?
compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())?
};
let result = compile::run(&root, &plan, &token, &|e| {
let _ = on_event.send(e);
+10
View File
@@ -129,6 +129,7 @@ impl Host {
let root = root.clone();
let data_dir = self.local_data_dir.clone();
let config_dir = self.config_dir.clone();
let build_cache = self.local_data_dir.join("cache").join(&project_key).join("build");
let sink = sink.clone();
let formatter_problem = std::sync::Mutex::new(None);
lsp.set_config_hook(Box::new(move |cfg, server_root| {
@@ -142,6 +143,15 @@ impl Host {
let root = member.as_ref().unwrap_or(&root);
ide_sdk::apply::server_launch_overrides(cfg, &effective);
ide_sdk::apply::configure_server(cfg, root, server_root, &effective);
if cfg.name == "jdtls" {
// A workspace member keeps its build model under its own key (Build Project).
let cache = match &member {
Some(member) => build_cache.join(crate::project::project_key(member)),
None => build_cache.clone(),
};
let profiles = ide_build::maven_profiles(&cache);
ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache);
}
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
if cfg.name == "jdtls" {
let problem = ide_lsp::jdtls::configure_formatter(
+3
View File
@@ -241,6 +241,9 @@ impl Handler for ClientHandler {
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
mgr.java_import_ready(self.server.clone(), self.generation);
}
"language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => {
mgr.java_projects_updated(self.server.clone(), self.generation);
}
// The Angular server's project loading, shown like `$/progress`; one
// task while several projects load.
"angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self
+29
View File
@@ -336,6 +336,35 @@ impl LspManager {
});
}
/// jdtls reports `ProjectStatus` when an import or project update ends (a POM
/// change, a Maven sync, other Maven profiles), but does not check open documents
/// again against the new classpath: their errors would stay until the next edit.
pub(crate) fn java_projects_updated(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
let uris: Vec<String> = self
.docs
.lock()
.values()
.filter(|d| d.language.as_str() == "java" && d.path.starts_with(&server.root))
.map(|d| crate::uri::path_to_uri_string(&d.path))
.collect();
if uris.is_empty() {
return;
}
self.runtime.spawn(async move {
let Some(run) = server.running_gen(generation) else { return };
for uri in uris {
let params =
json!({ "command": "java.project.refreshDiagnostics", "arguments": [uri, "thisFile", false] });
let request = run.peer.request::<_, Value>("workspace/executeCommand", params, None);
if let Ok(Err(e)) | Err(e) =
tokio::time::timeout(COMMAND_TIMEOUT, request).await.map_err(|_| ide_jsonrpc::RpcError::Timeout)
{
tracing::debug!("jdtls: java.project.refreshDiagnostics {uri}: {e}");
}
}
});
}
/// The jdtls instance serving `path` (a module directory or source file),
/// started if needed and waited for.
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
+34
View File
@@ -1200,6 +1200,40 @@ async fn java_launch_serializes_the_classpath_with_the_build_lock() {
assert!(commands.contains(&"java.project.getClasspaths".to_owned()));
}
#[tokio::test(flavor = "multi_thread")]
async fn open_java_files_are_checked_again_after_a_project_update() {
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
let (_, a) = h.open("A.java", "class A {}");
let (_, b) = h.open("B.java", "class B {}");
h.wait_status("jdtls", ServerStatus::Ready).await;
let state = h.launcher.state("jdtls");
let refreshed = || {
let mut uris: Vec<Value> = state
.requested("workspace/executeCommand")
.iter()
.filter(|p| p["command"] == "java.project.refreshDiagnostics")
.map(|p| {
assert_eq!(p["arguments"][1], "thisFile");
p["arguments"][0].clone()
})
.collect();
uris.sort_by_key(|u| u.to_string());
uris
};
// Other status messages leave the documents alone.
state
.peer()
.notify("language/status", json!({ "type": "Message", "message": "Updating project configurations..." }))
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(refreshed().is_empty());
state.peer().notify("language/status", json!({ "type": "ProjectStatus", "message": "OK" })).unwrap();
let mut expected = vec![json!(uri_of(&a)), json!(uri_of(&b))];
expected.sort_by_key(|u| u.to_string());
eventually(|| refreshed() == expected).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
// Preferred installed: the alternative is never started.
+1
View File
@@ -16,6 +16,7 @@ pub mod jdtls;
pub mod libraries;
pub mod locate;
pub mod lombok;
pub mod maven_profiles;
mod npm;
pub mod pinned;
pub mod python;
+239
View File
@@ -0,0 +1,239 @@
//! Maven profiles for the Java language server. jdtls (m2e) has no setting for the
//! profiles a project is imported with; it takes them from `<activeProfiles>` of the
//! user `settings.xml`. The profiles chosen in the Maven tool window are therefore
//! passed as a copy of the user's settings with those profiles activated.
use crate::apply::setting_path;
use ide_core::settings::Settings;
use ide_language::LanguageServerConfig;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
const PREFIX: &str = "jdtls-maven-settings-";
/// Points jdtls at a user `settings.xml` activating `profiles` (`-P` entries of the
/// last Maven sync), written to `dir`. Through `settings`, so a running server
/// re-imports its Maven projects instead of restarting. `!id` entries can't be
/// expressed in `settings.xml` and are left to the POMs' own activation.
pub fn configure_jdtls(cfg: &mut LanguageServerConfig, root: &Path, s: &Settings, profiles: &[String], dir: &Path) {
if cfg.name != "jdtls" {
return;
}
let active: Vec<&str> =
profiles.iter().map(|p| p.trim()).filter(|p| !p.is_empty() && !p.starts_with('!')).collect();
let Some(file) = (!active.is_empty()).then(|| write_settings(root, s, &active, dir)).flatten() else {
remove_generated(dir, None);
return;
};
let layer = json!({ "java": { "configuration": { "maven": { "userSettings": file.to_string_lossy() } } } });
match &mut cfg.settings {
Some(settings) => merge(settings, layer),
None => cfg.settings = Some(layer),
}
}
/// The user's settings (Settings › Maven, else `~/.m2/settings.xml`) with `active`
/// added to `<activeProfiles>`. Named after its content: m2e reloads a settings file
/// only when the configured path changes.
fn write_settings(root: &Path, s: &Settings, active: &[&str], dir: &Path) -> Option<PathBuf> {
let user = setting_path(root, &s.languages.java.maven.user_settings)
.or_else(|| crate::home_dir().map(|h| h.join(".m2").join("settings.xml")));
let base = user.and_then(|f| std::fs::read_to_string(f).ok()).unwrap_or_else(|| "<settings>\n</settings>\n".into());
let Some(text) = with_active_profiles(&base, active) else {
tracing::warn!("Maven profiles not passed to jdtls: the user settings.xml has no <settings> element");
return None;
};
let mut hash = 0xcbf29ce484222325u64;
for byte in text.bytes() {
hash = (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3);
}
let file = dir.join(format!("{PREFIX}{hash:016x}.xml"));
if !file.is_file() {
let written = std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&file, &text));
if let Err(e) = written {
tracing::warn!("Maven profiles not passed to jdtls: {}: {e}", file.display());
return None;
}
}
remove_generated(dir, Some(&file));
Some(file)
}
/// Deletes the settings files written for earlier profile choices.
fn remove_generated(dir: &Path, keep: Option<&Path>) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
let path = entry.path();
let generated = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with(PREFIX));
if generated && Some(path.as_path()) != keep {
let _ = std::fs::remove_file(path);
}
}
}
/// `text` (a `settings.xml`) with `ids` appended to its `<activeProfiles>`, created
/// when missing; `None` when there is no `<settings>` element to add it to.
pub fn with_active_profiles(text: &str, ids: &[&str]) -> Option<String> {
let entries: String = ids.iter().map(|id| format!(" <activeProfile>{}</activeProfile>\n", escape(id))).collect();
let masked = mask_comments(text);
if let Some(start) = find_tag(&masked, "<activeProfiles", 0) {
let end = start + masked[start..].find('>')?;
if masked[..end].ends_with('/') {
let element = format!("<activeProfiles>\n{entries} </activeProfiles>");
return Some(format!("{}{element}{}", &text[..start], &text[end + 1..]));
}
let close = end + masked[end..].find("</activeProfiles")?;
return Some(format!("{}\n{entries} {}", text[..close].trim_end(), &text[close..]));
}
let close = masked.rfind("</settings")?;
let element = format!(" <activeProfiles>\n{entries} </activeProfiles>\n");
Some(format!("{}\n{element}{}", text[..close].trim_end(), &text[close..]))
}
/// Start of the element `open` (`<name`), not a longer name sharing its prefix.
fn find_tag(masked: &str, open: &str, from: usize) -> Option<usize> {
let mut at = from;
while let Some(i) = masked[at..].find(open) {
let start = at + i;
let next = masked[start + open.len()..].chars().next();
if next.is_some_and(|c| c == '>' || c == '/' || c.is_whitespace()) {
return Some(start);
}
at = start + open.len();
}
None
}
/// `text` with comments and CDATA blanked out, byte offsets unchanged.
fn mask_comments(text: &str) -> String {
let mut out = text.as_bytes().to_vec();
let mut at = 0;
while at < text.len() {
let rest = &text[at..];
let (open, close) = match (rest.find("<!--"), rest.find("<![CDATA[")) {
(Some(c), Some(d)) if d < c => (d, "]]>"),
(Some(c), _) => (c, "-->"),
(None, Some(d)) => (d, "]]>"),
(None, None) => break,
};
let start = at + open;
let end = text[start..].find(close).map_or(text.len(), |i| start + i + close.len());
out[start..end].fill(b' ');
at = end;
}
String::from_utf8(out).unwrap_or_default()
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
fn merge(base: &mut Value, layer: Value) {
match (base, layer) {
(Value::Object(base), Value::Object(layer)) => {
for (k, v) in layer {
match base.get_mut(&k) {
Some(existing) => merge(existing, v),
None => {
base.insert(k, v);
}
}
}
}
(base, layer) => *base = layer,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn jdtls() -> LanguageServerConfig {
serde_json::from_value(json!({ "name": "jdtls", "command": "jdtls" })).unwrap()
}
fn user_settings(cfg: &LanguageServerConfig) -> Option<String> {
cfg.settings.as_ref()?.pointer("/java/configuration/maven/userSettings")?.as_str().map(Into::into)
}
#[test]
fn adds_profiles_to_every_shape_of_settings_xml() {
let ids = ["native", "本地"];
let added = "<activeProfile>native</activeProfile>\n <activeProfile>本地</activeProfile>";
for (text, keeps) in [
("<settings>\n</settings>\n", ""),
("<settings><mirrors><mirror><id>m</id></mirror></mirrors></settings>", "<mirror><id>m</id></mirror>"),
(
"<settings>\n <activeProfiles>\n <activeProfile>nexus</activeProfile>\n </activeProfiles>\n</settings>",
"<activeProfile>nexus</activeProfile>",
),
("<settings><activeProfiles/></settings>", ""),
("<settings><activeProfiles /></settings>", ""),
// Commented-out elements are not where the profiles go.
(
"<settings>\n<!-- <activeProfiles></activeProfiles> </settings> -->\n</settings>",
"<!-- <activeProfiles></activeProfiles> </settings> -->",
),
(
"<?xml version=\"1.0\"?>\n<settings xmlns=\"http://maven.apache.org/SETTINGS/1.0.0\"><activeProfilesX/></settings>",
"<activeProfilesX/>",
),
] {
let out = with_active_profiles(text, &ids).unwrap_or_else(|| panic!("{text}"));
assert!(out.contains(added), "{text}\n→\n{out}");
assert!(out.contains(keeps), "{text}\n→\n{out}");
let out = if keeps.starts_with("<!--") { out.replace(keeps, "") } else { out };
assert_eq!(out.matches("<activeProfiles>").count(), 1, "{text}\n→\n{out}");
let close = out.find("</activeProfiles>").unwrap();
assert!(close < out.rfind("</settings>").unwrap(), "{out}");
assert!(out.find(added).unwrap() < close, "{out}");
}
assert_eq!(with_active_profiles("<settings/>", &ids), None);
assert!(with_active_profiles("<settings></settings>", &["a&b"]).unwrap().contains("a&amp;b"));
}
#[test]
fn jdtls_imports_with_the_synced_profiles() {
let tmp = tempfile::tempdir().unwrap();
let user = tmp.path().join("user-settings.xml");
std::fs::write(&user, "<settings><localRepository>/repo</localRepository></settings>").unwrap();
let mut s = Settings::default();
s.languages.java.maven.user_settings = user.to_string_lossy().into_owned();
let dir = tmp.path().join("build");
let profiles = |p: &[&str]| p.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let mut cfg = jdtls();
configure_jdtls(&mut cfg, tmp.path(), &s, &profiles(&["native", "!dev", "本地"]), &dir);
let first = user_settings(&cfg).expect("generated settings");
let text = std::fs::read_to_string(&first).unwrap();
assert!(text.contains("<localRepository>/repo</localRepository>"), "{text}");
assert!(
text.contains("<activeProfile>native</activeProfile>")
&& text.contains("<activeProfile>本地</activeProfile>")
);
assert!(!text.contains("dev"), "deactivations are not activations: {text}");
// Same choice → same file; another choice → another path (m2e reloads on a new path).
let mut again = jdtls();
configure_jdtls(&mut again, tmp.path(), &s, &profiles(&["native", "本地"]), &dir);
assert_eq!(user_settings(&again).as_deref(), Some(first.as_str()));
let mut other = jdtls();
configure_jdtls(&mut other, tmp.path(), &s, &profiles(&["pro"]), &dir);
let second = user_settings(&other).unwrap();
assert_ne!(second, first);
assert!(!Path::new(&first).exists(), "the previous file is removed");
// No profile (or only deactivations) → the user's settings as configured.
for none in [&[][..], &["!dev"][..]] {
let mut plain = jdtls();
configure_jdtls(&mut plain, tmp.path(), &s, &profiles(none), &dir);
assert_eq!(user_settings(&plain), None);
}
assert!(!Path::new(&second).exists());
let mut other_server: LanguageServerConfig =
serde_json::from_value(json!({ "name": "kotlin-lsp", "command": "kotlin-lsp" })).unwrap();
configure_jdtls(&mut other_server, tmp.path(), &s, &profiles(&["native"]), &dir);
assert_eq!(other_server.settings, None);
}
}
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { BuildModel, MavenModel, ProjectInfo } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { loadModel, PROFILE_RELOAD_DELAY_MS, resetBuild, setProfile, 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 maven = {
syncedAt: 1,
stale: false,
syncError: null,
profiles: [
{ id: "native", activeByDefault: false },
{ id: "local", activeByDefault: false },
{ id: "dev", activeByDefault: true },
],
projects: [],
} as unknown as MavenModel;
const model: BuildModel = { maven, gradle: null };
/** The `-P` entries each sync was started with. */
const syncedProfiles = () => mocks.sync.mock.calls.map((c) => c[3] as string[]);
beforeEach(async () => {
vi.clearAllMocks();
localStorage.clear();
resetBuild();
useBuild.setState({ syncing: {} });
useProject.setState({ project: { root: "/p", trusted: true } as ProjectInfo });
mocks.model.mockResolvedValue(model);
await loadModel();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("reloads Maven once with the new profiles after a few toggles", async () => {
let finish = () => {};
mocks.sync.mockImplementation(() => new Promise((resolve) => (finish = () => resolve(model))));
setProfile("native", true);
setProfile("local", true);
setProfile("dev", false);
expect(mocks.sync).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
expect(syncedProfiles()).toEqual([["native", "local", "!dev"]]);
finish();
await vi.runAllTimersAsync();
expect(mocks.sync).toHaveBeenCalledTimes(1);
});
it("reloads again after a sync that started with the previous profiles", async () => {
const finishes: (() => void)[] = [];
mocks.sync.mockImplementation(() => new Promise((resolve) => finishes.push(() => resolve(model))));
setProfile("native", true);
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
setProfile("local", true);
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
expect(syncedProfiles()).toEqual([["native"]]);
finishes[0]!();
await vi.runAllTimersAsync();
expect(syncedProfiles()).toEqual([["native"], ["native", "local"]]);
});
it("does not reload a project closed before the delay ran out", async () => {
setProfile("native", true);
resetBuild();
await vi.advanceTimersByTimeAsync(PROFILE_RELOAD_DELAY_MS);
expect(mocks.sync).not.toHaveBeenCalled();
});
+24
View File
@@ -114,8 +114,19 @@ export const toggleSkipTests = () =>
export const toggleGradleOffline = () =>
updateSettings((s) => ({ ...s, gradle: { ...s.gradle, offline: !s.gradle.offline } }));
/** 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;
export function setProfile(id: string, checked: boolean) {
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).
clearTimeout(profileReload);
profileReload = setTimeout(() => {
profileReload = undefined;
requestSync("maven");
}, PROFILE_RELOAD_DELAY_MS);
}
export function readHistory(tool: BuildTool): string[] {
@@ -269,6 +280,15 @@ export function initialExpanded(tool: BuildTool, model: BuildModel): Set<string>
const TITLES: Record<BuildTool, string> = { maven: "Syncing Maven projects", gradle: "Syncing Gradle projects" };
/** Tools to sync again once their running sync ends: its options changed meanwhile. */
const queuedSyncs = new Set<BuildTool>();
/** Syncs `tool` now, or right after the sync in progress (which used the previous options). */
export function requestSync(tool: BuildTool) {
if (useBuild.getState().syncing[tool]) queuedSyncs.add(tool);
else void syncTool(tool);
}
/** Reloads all projects of `tool` from the build tool itself (effective POM, dependency tree, task list). */
export async function syncTool(tool: BuildTool): Promise<void> {
const state = useBuild.getState();
@@ -317,6 +337,7 @@ export async function syncTool(tool: BuildTool): Promise<void> {
} finally {
if (useBuild.getState().syncing[tool]?.request === request) setSync(undefined);
upsertTask({ id: taskId, title: TITLES[tool], message: null, fraction: 1, done: true });
if (queuedSyncs.delete(tool) && gen === generation) void syncTool(tool);
}
}
@@ -330,6 +351,9 @@ export function cancelSync(tool: BuildTool) {
export function resetBuild() {
generation++;
clearTimeout(profileReload);
profileReload = undefined;
queuedSyncs.clear();
useAnalyzer.setState({ pom: null });
for (const tool of ["maven", "gradle"] as const) {
cancelSync(tool);