mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
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&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();
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user