feat(java): Download JDK, and one-click "Download JDK N" where a JDK is missing (#61)

Root cause: the IDE only detected installed JDKs and had no way to get one, so any launch needing a newer JDK than the machine has (latest jdtls needs JDK 25) dead-ended in an error.

Fix: IDEA-style Download JDK (Eclipse Temurin via the Adoptium API, sha256-verified, reusing ide-tools' download/verify code; Windows zip, Linux tar.gz, macOS Contents/Home) into <app-data>/tools/jdks, registered with JDK discovery and rescanned after each download. "Download JDK…" in the Project SDK and Language Server JDK pickers; a one-click `java.downloadJdk` "Download JDK N" on "needs JDK N" errors, which also repoints a too-old language server JDK and restarts jdtls.

Siblings: language server status popup, jdtls launch notification, run console classpath error, debug launch, run configurations without a JDK ≥ the module's Java version, Maven/Gradle sync failures, single-file Java run without `java` on PATH.

Verified in the app on macOS: vendor/version/location queries, the Download JDK dialog from the Language Server JDK picker, a real Temurin 25.0.5 download that runs. Not verified on Windows/Linux.

Fixes #49

https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-27 12:09:51 +08:00
1 джерело beab7992f9
коміт 56fdc005a8
29 files changed
+1592 -111

No files matched your search

+26 -20
Переглянути файл
@@ -394,27 +394,33 @@ fn release_value(release: &str, key: &str) -> Option<String> {
}
fn jdk() -> Vec<Sdk> {
crate::java::rescan_jdks().iter().map(jdk_sdk).collect()
}
/// A JDK as the SDK pickers list it (`Eclipse Adoptium 25.0.1`).
pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk {
let java_home = std::env::var_os("JAVA_HOME").and_then(|h| dunce::canonicalize(h).ok());
crate::java::rescan_jdks()
.iter()
.map(|j| {
let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default();
let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string());
let name = match release_value(&release, "IMPLEMENTOR") {
Some(vendor) => format!("{vendor} {version}"),
None => format!("JDK {version}"),
};
let source = if java_home.as_ref() == Some(&j.home) { "JAVA_HOME" } else { "system" };
Sdk {
path: j.home.clone().into(),
name,
version: Some(version),
source: source.to_owned(),
home: Some(j.home.clone().into()),
extra: None,
}
})
.collect()
let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default();
let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string());
let name = match release_value(&release, "IMPLEMENTOR") {
Some(vendor) => format!("{vendor} {version}"),
None => format!("JDK {version}"),
};
let source = if java_home.as_ref() == Some(&j.home) {
"JAVA_HOME"
} else if crate::java::is_downloaded(&j.home) {
"downloaded"
} else {
"system"
};
Sdk {
path: j.home.clone().into(),
name,
version: Some(version),
source: source.to_owned(),
home: Some(j.home.clone().into()),
extra: None,
}
}
// ------------------------------------------------------------------ maven
+102 -9
Переглянути файл
@@ -49,9 +49,26 @@ fn children(dir: &Path) -> Vec<PathBuf> {
const WINDOWS_VENDORS: &[&str] =
&["Java", "Eclipse Adoptium", "Microsoft", "Zulu", "Amazon Corretto", "BellSoft", "Semeru", "OpenJDK"];
/// Folders the IDE downloads JDKs into ([`add_jdk_root`]).
static EXTRA_ROOTS: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
/// Scans the JDKs in `dir` (one per subfolder) too, e.g. where Download JDK puts them.
pub fn add_jdk_root(dir: &Path) {
let mut roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner());
if !roots.iter().any(|r| r == dir) {
roots.push(dir.to_path_buf());
}
}
/// Whether `home` lies in a folder registered with [`add_jdk_root`].
pub fn is_downloaded(home: &Path) -> bool {
let roots = EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner());
roots.iter().any(|r| home.starts_with(r) || dunce::canonicalize(r).is_ok_and(|r| home.starts_with(r)))
}
/// JDK homes in the usual install locations (macOS `JavaVirtualMachines`,
/// `/usr/lib/jvm`, SDKMAN, IDEA's `~/.jdks`, Homebrew, Windows vendor
/// directories), `JAVA_HOME` first, then the `java` on `PATH`.
/// directories, the IDE's downloads), `JAVA_HOME` first, then the `java` on `PATH`.
fn scan() -> Vec<Jdk> {
let mut homes: Vec<PathBuf> = Vec::new();
if let Some(h) = std::env::var_os("JAVA_HOME") {
@@ -74,6 +91,7 @@ fn scan() -> Vec<Jdk> {
if let Some(h) = &home {
roots.extend([h.join("Library/Java/JavaVirtualMachines"), h.join(".jdks"), h.join(".sdkman/candidates/java")]);
}
roots.extend(EXTRA_ROOTS.lock().unwrap_or_else(|e| e.into_inner()).iter().cloned());
if cfg!(windows) {
let bases = ["ProgramFiles", "ProgramW6432", "LOCALAPPDATA"].into_iter().filter_map(|v| {
let dir = PathBuf::from(std::env::var_os(v)?);
@@ -346,32 +364,51 @@ pub fn configure_jdtls(
/// Where the JDK that runs jdtls is chosen, named as the settings dialog shows it.
const SERVER_JDK_SETTING: &str = "Settings › Languages & Frameworks › Java › Language Server › JDK";
fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String {
/// `found JDK 8, 17` / `no JDK was found`.
fn found_jdks(jdks: &[Jdk]) -> String {
let mut found: Vec<u32> = jdks.iter().map(|j| j.major).collect();
found.sort_unstable();
found.dedup();
let found = if found.is_empty() {
if found.is_empty() {
"no JDK was found".to_owned()
} else {
let list: Vec<String> = found.iter().map(u32::to_string).collect();
format!("found JDK {}", list.join(", "))
};
}
}
// The UI offers "Download JDK N" for messages saying "needs JDK N or newer".
fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String {
format!(
"jdtls needs JDK {min_java} or newer to run ({found}). Pick one in {SERVER_JDK_SETTING} (it restarts \
automatically), or install a newer JDK and restart the server; the project can still target an older \
Java version."
"jdtls needs JDK {min_java} or newer to run ({}). Download JDK {min_java}, or pick an installed one in \
{SERVER_JDK_SETTING} (it restarts automatically); the project can still target an older Java version.",
found_jdks(jdks)
)
}
fn server_jdk_too_old_message(jdk: &Jdk, min_java: u32) -> String {
format!(
"The language server JDK ({}, JDK {}) is too old: jdtls needs JDK {min_java} or newer. Choose another \
one in {SERVER_JDK_SETTING}, or set it to Auto.",
"The language server JDK ({}, JDK {}) is too old: jdtls needs JDK {min_java} or newer. Download JDK \
{min_java}, choose another one in {SERVER_JDK_SETTING}, or set it to Auto.",
jdk.home.display(),
jdk.major
)
}
/// Why a module targeting Java `release` can't be built or run with the installed
/// JDKs (none is that new), or `None` when one is (or the release is unknown).
pub fn missing_jdk_message(jdks: &[Jdk], release: Option<u32>) -> Option<String> {
let release = release?;
if jdks.iter().any(|j| j.major >= release) {
return None;
}
Some(format!(
"This project targets Java {release}: it needs JDK {release} or newer ({}). Download JDK {release}, or \
choose a Project JDK in Settings › Languages & Frameworks › Java.",
found_jdks(jdks)
))
}
/// The `java` launcher of a JDK home.
pub fn java_executable(home: &Path) -> PathBuf {
home.join("bin").join(crate::exe("java"))
@@ -595,6 +632,62 @@ mod tests {
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, None, None, 25, &[], None);
let err = cfg.launch_error.unwrap();
assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}");
assert!(err.contains("Download JDK 25"), "offers the download: {err}");
}
#[test]
fn issue_49_jdtls_needs_25_with_8_17_21_installed() {
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
let installed = [
Jdk { major: 21, home: PathBuf::from("/jdk21") },
Jdk { major: 17, home: PathBuf::from("/jdk17") },
Jdk { major: 8, home: PathBuf::from("/jdk8") },
];
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
let err = cfg.launch_error.unwrap();
assert!(err.starts_with("jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21)"), "{err}");
assert!(err.contains("Download JDK 25"), "{err}");
// Once a JDK 25 has been downloaded, Auto runs jdtls on it.
let downloaded =
[installed.as_slice(), &[Jdk { major: 25, home: PathBuf::from("/tools/jdks/temurin-25") }]].concat();
let mut cfg = jdtls();
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None);
assert_eq!(cfg.launch_error, None);
assert_eq!(cfg.env["JAVA_HOME"], Path::new("/tools/jdks/temurin-25").to_string_lossy());
}
#[test]
fn missing_jdk_for_the_project_release() {
let jdks = [Jdk { major: 17, home: PathBuf::from("/jdk17") }, Jdk { major: 8, home: PathBuf::from("/jdk8") }];
assert_eq!(missing_jdk_message(&jdks, Some(17)), None);
assert_eq!(missing_jdk_message(&jdks, Some(11)), None);
assert_eq!(missing_jdk_message(&jdks, None), None);
let msg = missing_jdk_message(&jdks, Some(21)).unwrap();
assert!(msg.contains("needs JDK 21 or newer (found JDK 8, 17)") && msg.contains("Download JDK 21"), "{msg}");
assert!(missing_jdk_message(&[], Some(8)).unwrap().contains("no JDK was found"));
}
#[test]
fn downloaded_jdks_are_found() {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("jdks/temurin-25.0.1");
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(home.join("bin").join(crate::exe("java")), "").unwrap();
// macOS bundles: <dir>/Contents/Home
let bundle = tmp.path().join("jdks/temurin-21.0.5/Contents/Home");
std::fs::create_dir_all(bundle.join("bin")).unwrap();
std::fs::write(bundle.join("release"), "JAVA_VERSION=\"21.0.5\"\n").unwrap();
std::fs::write(bundle.join("bin").join(crate::exe("java")), "").unwrap();
add_jdk_root(&tmp.path().join("jdks"));
let found = rescan_jdks();
let home = dunce::canonicalize(&home).unwrap();
let bundle = dunce::canonicalize(&bundle).unwrap();
assert!(found.iter().any(|j| j.home == home && j.major == 25), "{found:?}");
assert!(found.iter().any(|j| j.home == bundle && j.major == 21), "{found:?}");
assert!(is_downloaded(&home) && !is_downloaded(Path::new("/elsewhere")));
}
#[test]
+1 -1
Переглянути файл
@@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
pub use discover::detect;
pub use discover::{detect, jdk_sdk};
pub use exec::search_path;
/// What [`detect`] looks for.
+35 -1
Переглянути файл
@@ -85,7 +85,11 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi:
.iter()
.map(|name| {
let path = valid_name(name)
.then(|| near.and_then(|n| local_bin(n, name)).or_else(|| on_path(name, search)))
.then(|| {
near.and_then(|n| local_bin(n, name))
.or_else(|| on_path(name, search))
.or_else(|| jdk_tool(name, &crate::java::installed_jdks()))
})
.flatten();
LocatedTool { name: name.clone(), path: path.map(Into::into), version: None }
})
@@ -106,6 +110,16 @@ pub fn locate_tools_in(names: &[String], near: Option<&Path>, search: &std::ffi:
})
}
/// `java` / `javac` / `jshell` of the newest installed JDK when none is on the
/// search path (JDKs installed without `PATH` changes, or downloaded by the IDE).
fn jdk_tool(name: &str, jdks: &[crate::java::Jdk]) -> Option<PathBuf> {
if !["java", "javac", "jshell"].contains(&name) {
return None;
}
let jdk = jdks.iter().max_by_key(|j| j.major)?;
Some(jdk.home.join("bin").join(crate::exe(name))).filter(|p| p.is_file())
}
/// Directory for binaries compiled from single source files (`rustc`, `clang`).
pub fn compile_dir() -> PathBuf {
std::env::temp_dir().join("openidea-run")
@@ -124,6 +138,26 @@ mod tests {
assert_eq!(parse_tool_version("no version here"), None);
}
#[test]
fn java_falls_back_to_the_newest_installed_jdk() {
let tmp = tempfile::tempdir().unwrap();
let jdk = |major: u32| {
let home = tmp.path().join(format!("jdk{major}"));
std::fs::create_dir_all(home.join("bin")).unwrap();
for t in ["java", "javac"] {
std::fs::write(home.join("bin").join(crate::exe(t)), "").unwrap();
}
crate::java::Jdk { major, home }
};
let jdks = [jdk(17), jdk(25), jdk(21)];
let newest = tmp.path().join("jdk25/bin");
assert_eq!(jdk_tool("java", &jdks), Some(newest.join(crate::exe("java"))));
assert_eq!(jdk_tool("javac", &jdks), Some(newest.join(crate::exe("javac"))));
assert_eq!(jdk_tool("jshell", &jdks), None, "not in this JDK");
assert_eq!(jdk_tool("node", &jdks), None);
assert_eq!(jdk_tool("java", &[]), None);
}
#[test]
fn rejects_paths_and_shell_syntax() {
assert!(valid_name("ts-node"));
+477
Переглянути файл
@@ -0,0 +1,477 @@
//! JDK downloads (IDEA's Download JDK): Eclipse Temurin builds from the
//! Adoptium API, unpacked into `<tools>/jdks/<vendor>-<version>` (or a chosen
//! location) where JDK discovery finds them. Parsing is pure so it is testable
//! without network.
use crate::platform::{Arch, Os, Platform};
use crate::process::{Emit, Http};
use crate::sources::Download;
use crate::{InstallEvent, InstallGuard, Tools, archive, clear_quarantine, fetch_verified};
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Path, PathBuf};
const ADOPTIUM_API: &str = "https://api.adoptium.net/v3";
/// A JDK distribution the IDE can download.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JdkVendor {
pub id: String,
pub name: String,
}
pub const TEMURIN: &str = "temurin";
pub fn vendors() -> Vec<JdkVendor> {
vec![JdkVendor { id: TEMURIN.into(), name: "Eclipse Temurin".into() }]
}
/// Java versions a vendor publishes.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct JdkReleases {
/// Feature versions, newest first.
pub versions: Vec<u32>,
/// The long-term-support ones among them.
pub lts: Vec<u32>,
/// The version offered first (the newest LTS).
pub recommended: u32,
}
/// A JDK the IDE downloaded.
#[derive(Clone, Debug, PartialEq, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct DownloadedJdk {
pub home: ide_core::IpcPath,
/// Full version, e.g. `25.0.1`.
pub version: String,
pub major: u32,
}
/// Where downloaded JDKs go by default.
pub fn jdks_dir(app_data: &Path) -> PathBuf {
crate::tools_dir(app_data).join("jdks")
}
fn proto(msg: impl Into<String>) -> IdeError {
IdeError::new(ErrorKind::Protocol, msg)
}
fn vendor_or_err(vendor: &str) -> IdeResult<()> {
if vendor == TEMURIN { Ok(()) } else { Err(IdeError::invalid(format!("unknown JDK vendor `{vendor}`"))) }
}
pub fn adoptium_releases_url() -> String {
format!("{ADOPTIUM_API}/info/available_releases")
}
#[derive(Deserialize)]
struct AvailableReleases {
available_releases: Vec<u32>,
#[serde(default)]
available_lts_releases: Vec<u32>,
#[serde(default)]
most_recent_lts: Option<u32>,
}
pub fn parse_releases(json: &str) -> IdeResult<JdkReleases> {
let r: AvailableReleases =
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?;
let mut versions = r.available_releases;
versions.sort_unstable_by(|a, b| b.cmp(a));
versions.dedup();
let recommended = r
.most_recent_lts
.or_else(|| r.available_lts_releases.iter().copied().max())
.or_else(|| versions.first().copied())
.ok_or_else(|| proto("Adoptium lists no Java versions"))?;
Ok(JdkReleases { versions, lts: r.available_lts_releases, recommended })
}
fn adoptium_os(os: Os) -> &'static str {
match os {
Os::Mac => "mac",
Os::Linux => "linux",
Os::Windows => "windows",
}
}
fn adoptium_arch(arch: Arch) -> &'static str {
match arch {
Arch::X64 => "x64",
Arch::Arm64 => "aarch64",
}
}
/// The latest Temurin JDK build of Java `feature` for `platform`.
pub fn adoptium_assets_url(feature: u32, platform: Platform) -> String {
format!(
"{ADOPTIUM_API}/assets/latest/{feature}/hotspot?architecture={}&image_type=jdk&os={}&vendor=eclipse",
adoptium_arch(platform.arch),
adoptium_os(platform.os),
)
}
#[derive(Deserialize)]
struct Asset {
binary: Binary,
version: AssetVersion,
}
#[derive(Deserialize)]
struct Binary {
#[serde(default)]
image_type: Option<String>,
#[serde(default)]
heap_size: Option<String>,
#[serde(default)]
os: Option<String>,
package: Package,
}
#[derive(Deserialize)]
struct Package {
name: String,
link: String,
#[serde(default)]
checksum: Option<String>,
#[serde(default)]
size: Option<u64>,
}
#[derive(Deserialize)]
struct AssetVersion {
major: u32,
#[serde(default)]
minor: u32,
#[serde(default)]
security: u32,
#[serde(default)]
openjdk_version: Option<String>,
}
impl AssetVersion {
/// `25.0.1` (`openjdk_version` without its build; `1.8.0_472` style for Java 8).
fn display(&self) -> String {
let plain = || format!("{}.{}.{}", self.major, self.minor, self.security);
match &self.openjdk_version {
Some(v) => {
let v = v.split(['+', '-']).next().unwrap_or(v);
if v.is_empty() { plain() } else { v.to_owned() }
}
None => plain(),
}
}
}
/// The JDK archive (`.tar.gz` on macOS / Linux, `.zip` on Windows) in an Adoptium
/// `assets/latest` response; `None` when there is no build for the platform.
pub fn adoptium_pick(json: &str, platform: Platform) -> IdeResult<Option<(Download, u32)>> {
let assets: Vec<Asset> =
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Adoptium response: {e}")))?;
let ext = if platform.os == Os::Windows { ".zip" } else { ".tar.gz" };
let os = adoptium_os(platform.os);
let found = assets.into_iter().find(|a| {
a.binary.image_type.as_deref().is_none_or(|t| t == "jdk")
&& a.binary.heap_size.as_deref().is_none_or(|h| h == "normal")
&& a.binary.os.as_deref().is_none_or(|o| o == os)
&& a.binary.package.name.ends_with(ext)
});
Ok(found.map(|a| {
let sha256 = a.binary.package.checksum.map(|c| c.trim().to_ascii_lowercase()).filter(|c| c.len() == 64);
(
Download {
version: a.version.display(),
name: a.binary.package.name,
url: a.binary.package.link,
size: a.binary.package.size,
sha256,
checksum_url: None,
},
a.version.major,
)
}))
}
/// Folder name of a downloaded JDK, as IDEA names them: `temurin-25.0.1`.
pub fn jdk_dir_name(vendor: &str, version: &str) -> String {
let safe: String =
version.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_') { c } else { '-' }).collect();
format!("{vendor}-{safe}")
}
fn is_jdk_home(dir: &Path, platform: Platform) -> bool {
dir.join("release").is_file() && dir.join("bin").join(platform.exe("java")).is_file()
}
/// The JDK home inside an unpacked JDK: `dir` itself, `dir/Contents/Home`
/// (a macOS bundle), or either of those below one top-level folder
/// (`jdk-25.0.1+8/`, `jdk-25.0.1+8/Contents/Home`).
pub fn find_jdk_home(dir: &Path, platform: Platform) -> Option<PathBuf> {
let candidates = |d: &Path| [d.to_path_buf(), d.join("Contents").join("Home")];
if let Some(home) = candidates(dir).into_iter().find(|h| is_jdk_home(h, platform)) {
return Some(home);
}
let mut subdirs: Vec<PathBuf> = std::fs::read_dir(dir)
.ok()?
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect();
subdirs.sort();
subdirs.iter().flat_map(|d| candidates(d)).find(|h| is_jdk_home(h, platform))
}
/// The directory to move into place: the archive's single top-level folder
/// holding the JDK, else everything that was unpacked.
fn unpacked_root(pkg: &Path, home: &Path) -> PathBuf {
match home.strip_prefix(pkg).ok().and_then(|rel| rel.components().next()) {
Some(first) if first.as_os_str() != "Contents" => pkg.join(first),
_ => pkg.to_path_buf(),
}
}
impl Tools {
fn http(&self) -> IdeResult<Http> {
Http::new(&self.system_path)
}
/// Java versions `vendor` publishes.
pub fn jdk_releases(&self, vendor: &str, cancel: &CancellationToken) -> IdeResult<JdkReleases> {
vendor_or_err(vendor)?;
parse_releases(&self.http()?.get_text(&adoptium_releases_url(), cancel)?)
}
/// Downloads the latest `vendor` JDK of Java `feature` into
/// `<location>/<vendor>-<version>` (`location` defaults to the tools' `jdks`
/// folder), verifying its SHA-256. A JDK already there is reused.
pub fn download_jdk(
&self,
vendor: &str,
feature: u32,
location: Option<&Path>,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<DownloadedJdk> {
vendor_or_err(vendor)?;
let _guard = InstallGuard::acquire(&format!("jdk-{vendor}-{feature}"))?;
let http = self.http()?;
let location = location.map(Path::to_path_buf).unwrap_or_else(|| self.root.join("jdks"));
emit(InstallEvent::Line { text: format!("Resolving the latest Eclipse Temurin {feature}…") });
let mut picked =
adoptium_pick(&http.get_text(&adoptium_assets_url(feature, self.platform), cancel)?, self.platform)?;
if picked.is_none() && self.platform.os == Os::Mac && self.platform.arch == Arch::Arm64 {
// Java 8 has no Apple Silicon build: the Intel one runs under Rosetta.
let intel = Platform { os: Os::Mac, arch: Arch::X64 };
picked = adoptium_pick(&http.get_text(&adoptium_assets_url(feature, intel), cancel)?, intel)?;
}
let (dl, major) = picked.ok_or_else(|| {
IdeError::unavailable(format!(
"Eclipse Temurin {feature} has no build for {:?} {:?}",
self.platform.os, self.platform.arch
))
})?;
let target = location.join(jdk_dir_name(vendor, &dl.version));
let done = |home: PathBuf| DownloadedJdk { home: home.into(), version: dl.version.clone(), major };
if let Some(home) = find_jdk_home(&target, self.platform) {
emit(InstallEvent::Line { text: format!("{} is already installed", target.display()) });
return Ok(done(home));
}
emit(InstallEvent::Line { text: format!("Downloading {} ({})", dl.name, dl.url) });
std::fs::create_dir_all(&location)?;
// Next to the target, so moving it into place never crosses file systems.
let staging = location.join(format!(".staging-{}", jdk_dir_name(vendor, &dl.version)));
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging)?;
let result = (|| {
let archive = fetch_verified(&http, &dl, &staging, cancel, emit)?;
emit(InstallEvent::Line { text: "Extracting…".into() });
let pkg = staging.join("pkg");
archive::extract(&archive, archive::ArchiveKind::from_name(&dl.name), &pkg, "java")?;
let home = find_jdk_home(&pkg, self.platform)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", dl.name)))?;
if target.exists() {
std::fs::remove_dir_all(&target)?;
}
std::fs::rename(unpacked_root(&pkg, &home), &target)?;
clear_quarantine(&target);
find_jdk_home(&target, self.platform)
.ok_or_else(|| IdeError::new(ErrorKind::Io, format!("no JDK found in {}", target.display())))
})();
let _ = std::fs::remove_dir_all(&staging);
let home = result?;
emit(InstallEvent::Line { text: format!("Eclipse Temurin {} installed in {}", dl.version, home.display()) });
Ok(done(home))
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAC_ARM: Platform = Platform { os: Os::Mac, arch: Arch::Arm64 };
const LINUX_X64: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
const WIN_X64: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
#[test]
fn releases() {
let json = r#"{"available_lts_releases":[8,11,17,21,25],"available_releases":[8,11,16,17,21,24,25,26],
"most_recent_feature_release":26,"most_recent_lts":25,"tip_version":27}"#;
let r = parse_releases(json).unwrap();
assert_eq!(r.versions, [26, 25, 24, 21, 17, 16, 11, 8]);
assert_eq!(r.lts, [8, 11, 17, 21, 25]);
assert_eq!(r.recommended, 25);
assert!(parse_releases("{}").is_err());
}
#[test]
fn asset_urls() {
assert_eq!(
adoptium_assets_url(25, MAC_ARM),
"https://api.adoptium.net/v3/assets/latest/25/hotspot?architecture=aarch64&image_type=jdk&os=mac&vendor=eclipse"
);
assert_eq!(
adoptium_assets_url(21, WIN_X64),
"https://api.adoptium.net/v3/assets/latest/21/hotspot?architecture=x64&image_type=jdk&os=windows&vendor=eclipse"
);
assert!(adoptium_assets_url(17, LINUX_X64).contains("os=linux"));
}
fn asset(name: &str, os: &str, heap: &str, checksum: &str) -> String {
format!(
r#"{{"binary":{{"architecture":"x64","heap_size":"{heap}","image_type":"jdk","os":"{os}",
"installer":{{"name":"x.msi","link":"https://e/x.msi","checksum":"00"}},
"package":{{"name":"{name}","link":"https://e/{name}","checksum":"{checksum}","size":1234}}}},
"release_name":"jdk-25.0.1+8","vendor":"eclipse",
"version":{{"major":25,"minor":0,"security":1,"build":8,"openjdk_version":"25.0.1+8-LTS"}}}}"#
)
}
#[test]
fn picks_the_platform_archive() {
let sum = "36DC14D797AAFF6D89AAB302CD053FDBA6207DFDCF772052FA7B023A94B0E857";
let json = format!(
"[{},{},{}]",
asset("OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip", "windows", "large", sum),
asset("OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip", "windows", "normal", sum),
asset("OpenJDK25U-jdk_x64_linux_hotspot_25.0.1_8.tar.gz", "linux", "normal", sum),
);
let (dl, major) = adoptium_pick(&json, WIN_X64).unwrap().unwrap();
assert_eq!(dl.name, "OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip");
assert_eq!(dl.url, "https://e/OpenJDK25U-jdk_x64_windows_hotspot_25.0.1_8.zip");
assert_eq!(dl.sha256.as_deref(), Some(sum.to_ascii_lowercase().as_str()));
assert_eq!((dl.version.as_str(), major, dl.size), ("25.0.1", 25, Some(1234)));
let (dl, _) = adoptium_pick(&json, LINUX_X64).unwrap().unwrap();
assert!(dl.name.ends_with(".tar.gz"));
// No build for the platform (Java 8 on Apple Silicon): an empty list.
assert_eq!(adoptium_pick("[]", MAC_ARM).unwrap(), None);
assert_eq!(adoptium_pick(&json, MAC_ARM).unwrap(), None);
assert!(adoptium_pick("{\"errorMessage\":\"x\"}", MAC_ARM).is_err());
}
#[test]
fn java_8_versions_and_dir_names() {
let v = AssetVersion { major: 8, minor: 0, security: 472, openjdk_version: Some("1.8.0_472-b08".into()) };
assert_eq!(v.display(), "1.8.0_472");
let v = AssetVersion { major: 21, minor: 0, security: 9, openjdk_version: None };
assert_eq!(v.display(), "21.0.9");
assert_eq!(jdk_dir_name(TEMURIN, "25.0.1"), "temurin-25.0.1");
assert_eq!(jdk_dir_name(TEMURIN, "1.8.0_472"), "temurin-1.8.0_472");
}
fn fake_jdk(home: &Path, platform: Platform) {
std::fs::create_dir_all(home.join("bin")).unwrap();
std::fs::write(home.join("release"), "JAVA_VERSION=\"25.0.1\"\n").unwrap();
std::fs::write(home.join("bin").join(platform.exe("java")), "").unwrap();
}
#[test]
fn finds_the_home_of_each_archive_layout() {
let tmp = tempfile::tempdir().unwrap();
// macOS: jdk-25.0.1+8/Contents/Home
let mac = tmp.path().join("mac");
fake_jdk(&mac.join("jdk-25.0.1+8/Contents/Home"), MAC_ARM);
std::fs::create_dir_all(mac.join("jdk-25.0.1+8/Contents/MacOS")).unwrap();
let home = find_jdk_home(&mac, MAC_ARM).unwrap();
assert_eq!(home, mac.join("jdk-25.0.1+8/Contents/Home"));
assert_eq!(unpacked_root(&mac, &home), mac.join("jdk-25.0.1+8"));
// Once moved into place, the bundle itself.
assert_eq!(find_jdk_home(&mac.join("jdk-25.0.1+8"), MAC_ARM).unwrap(), mac.join("jdk-25.0.1+8/Contents/Home"));
// Linux tar.gz / Windows zip: jdk-25.0.1+8/bin/java[.exe]
for platform in [LINUX_X64, WIN_X64] {
let dir = tmp.path().join(format!("{:?}", platform.os));
fake_jdk(&dir.join("jdk-25.0.1+8"), platform);
let home = find_jdk_home(&dir, platform).unwrap();
assert_eq!(home, dir.join("jdk-25.0.1+8"));
assert_eq!(unpacked_root(&dir, &home), dir.join("jdk-25.0.1+8"));
}
// Without a top-level folder, everything unpacked is the JDK.
let flat = tmp.path().join("flat");
fake_jdk(&flat, LINUX_X64);
assert_eq!(find_jdk_home(&flat, LINUX_X64).unwrap(), flat);
assert_eq!(unpacked_root(&flat, &flat), flat);
let bundle = tmp.path().join("bundle");
fake_jdk(&bundle.join("Contents/Home"), MAC_ARM);
assert_eq!(unpacked_root(&bundle, &bundle.join("Contents/Home")), bundle);
// A Windows layout isn't a JDK for Linux (no `bin/java`), nor is a folder without `release`.
assert_eq!(find_jdk_home(&tmp.path().join("Windows"), LINUX_X64), None);
std::fs::create_dir_all(tmp.path().join("norelease/bin")).unwrap();
std::fs::write(tmp.path().join("norelease/bin/java"), "").unwrap();
assert_eq!(find_jdk_home(&tmp.path().join("norelease"), LINUX_X64), None);
}
#[test]
fn unknown_vendors_are_refused() {
let tools = Tools::with_paths(PathBuf::from("/nonexistent"), Default::default(), LINUX_X64);
let err = tools.download_jdk("acme", 25, None, &CancellationToken::new(), &|_| {}).unwrap_err();
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
}
/// Downloads a real JDK from Adoptium: `OPENIDEA_LIVE_JDK=25 cargo nextest run -p ide-tools live_download`;
/// `OPENIDEA_LIVE_JDK_OS=windows|linux` fetches and unpacks another platform's archive (not run).
#[test]
fn live_download() {
let Some(feature) = std::env::var("OPENIDEA_LIVE_JDK").ok().and_then(|v| v.parse::<u32>().ok()) else {
return;
};
let tmp = tempfile::tempdir().unwrap();
let other = match std::env::var("OPENIDEA_LIVE_JDK_OS").as_deref() {
Ok("windows") => Some(WIN_X64),
Ok("linux") => Some(LINUX_X64),
_ => None,
};
if let Some(platform) = other {
let tools = Tools::with_paths(crate::tools_dir(tmp.path()), crate::default_search_path(), platform);
let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap();
let home = jdk.home.into_path_buf();
eprintln!("{}", home.display());
assert!(home.join("bin").join(platform.exe("java")).is_file());
assert_eq!(home.parent(), Some(jdks_dir(tmp.path()).as_path()), "no Contents/Home outside macOS");
return;
}
let tools = Tools::new(tmp.path());
let releases = tools.jdk_releases(TEMURIN, &CancellationToken::new()).unwrap();
assert!(releases.versions.contains(&feature), "{releases:?}");
let emit = |e: InstallEvent| {
if let InstallEvent::Line { text } = e {
eprintln!("{text}");
}
};
let jdk = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &emit).unwrap();
let home = jdk.home.clone().into_path_buf();
assert!(home.starts_with(jdks_dir(tmp.path())), "{}", home.display());
let out = std::process::Command::new(home.join("bin").join(Platform::current().exe("java")))
.arg("-version")
.output()
.unwrap();
let text = String::from_utf8_lossy(&out.stderr);
eprintln!("{text}");
assert!(out.status.success() && text.contains("Temurin"), "{text}");
// Downloading again reuses it.
let again = tools.download_jdk(TEMURIN, feature, None, &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(again.home, jdk.home);
}
}
+53 -32
Переглянути файл
@@ -10,6 +10,7 @@
//! [`tools_bin_dirs`] on the process `PATH` at startup.
pub mod archive;
pub mod jdk;
pub mod platform;
mod process;
pub mod registry;
@@ -948,33 +949,7 @@ impl Tools {
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<()> {
let archive = staging.join(&dl.name);
http.download(&dl.url, &archive, dl.size, cancel, emit)?;
let expected = match (&dl.sha256, &dl.checksum_url) {
(Some(h), _) => Some(h.clone()),
(None, Some(url)) => match http.get_text(url, cancel) {
Ok(text) => sources::parse_checksum(&text, &dl.name),
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(_) => None,
},
(None, None) => None,
};
match expected {
Some(expected) => {
let actual = sources::sha256_file(&archive)?;
if actual != expected {
return Err(IdeError::new(
ErrorKind::Io,
format!("checksum mismatch for {}: expected {expected}, got {actual}", dl.name),
));
}
emit(InstallEvent::Line { text: "Checksum verified (SHA-256)".into() });
}
None => emit(InstallEvent::Line { text: "No checksum published; skipping verification".into() }),
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
let archive = fetch_verified(http, dl, staging, cancel, emit)?;
emit(InstallEvent::Line { text: "Extracting…".into() });
let pkg = staging.join("pkg");
let kind = archive::ArchiveKind::from_name(&dl.name);
@@ -1018,11 +993,8 @@ impl Tools {
archive::link_executable(&bin, main, &target)?;
}
}
#[cfg(target_os = "macos")]
{
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(&final_dir).output();
}
// Downloads made by the IDE itself are not quarantined, but archives may carry the flag.
clear_quarantine(&final_dir);
self.write_manifest(spec.name, &Manifest { version: Some(dl.version.clone()), source: Some(dl.url.clone()) })
}
@@ -1091,5 +1063,54 @@ impl Tools {
}
}
/// Downloads `dl` into `staging` and checks its SHA-256 when one is published;
/// returns the downloaded file.
fn fetch_verified(
http: &Http,
dl: &sources::Download,
staging: &Path,
cancel: &CancellationToken,
emit: Emit,
) -> IdeResult<PathBuf> {
let archive = staging.join(&dl.name);
http.download(&dl.url, &archive, dl.size, cancel, emit)?;
let expected = match (&dl.sha256, &dl.checksum_url) {
(Some(h), _) => Some(h.clone()),
(None, Some(url)) => match http.get_text(url, cancel) {
Ok(text) => sources::parse_checksum(&text, &dl.name),
Err(e) if e.kind == ErrorKind::Cancelled => return Err(e),
Err(_) => None,
},
(None, None) => None,
};
match expected {
Some(expected) => {
let actual = sources::sha256_file(&archive)?;
if actual != expected {
return Err(IdeError::new(
ErrorKind::Io,
format!("checksum mismatch for {}: expected {expected}, got {actual}", dl.name),
));
}
emit(InstallEvent::Line { text: "Checksum verified (SHA-256)".into() });
}
None => emit(InstallEvent::Line { text: "No checksum published; skipping verification".into() }),
}
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
Ok(archive)
}
/// Removes the quarantine flag archives may carry, so macOS runs what was unpacked.
fn clear_quarantine(dir: &Path) {
#[cfg(target_os = "macos")]
{
let _ = Command::new("xattr").args(["-dr", "com.apple.quarantine"]).arg(dir).output();
}
#[cfg(not(target_os = "macos"))]
let _ = dir;
}
#[cfg(test)]
mod tests;
+22
Переглянути файл
@@ -42,6 +42,28 @@ pub(crate) fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel
if let Some(g) = &mut model.gradle {
g.env = java_env(jdk.as_ref(), g.java_version);
}
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
let missing = |release: Option<u32>| match jdk {
Some(_) => None,
None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release),
};
let explain = |error: &mut Option<String>, hint: Option<String>| {
if let (Some(e), Some(hint)) = (error.as_mut(), hint) {
*e = format!("{hint}\n{e}");
}
};
if let Some(m) = &mut model.maven
&& m.sync_error.is_some()
{
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
explain(&mut m.sync_error, hint);
}
if let Some(g) = &mut model.gradle
&& g.sync_error.is_some()
{
let hint = missing(g.java_version);
explain(&mut g.sync_error, hint);
}
model
}
+1
Переглянути файл
@@ -48,6 +48,7 @@ pub async fn java_resolve_launch(
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
let release = ide_sdk::java::project_java_version(&module);
let jdks = ide_sdk::java::installed_jdks();
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
});
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
+4
Переглянути файл
@@ -253,6 +253,10 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tools::tools_list,
tools::tools_install,
tools::tools_uninstall,
tools::jdk_download_vendors,
tools::jdk_download_releases,
tools::jdk_download_location,
tools::jdk_download,
sdk::sdk_detect,
sdk::sdk_python_effective,
sdk::sdk_project_libraries,
+79 -1
Переглянути файл
@@ -1,8 +1,11 @@
//! Language server / debug adapter installer (`ide-tools`).
//! Language server / debug adapter installer and JDK downloads (`ide-tools`).
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::Sdk;
use ide_tools::jdk::{JdkReleases, JdkVendor};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use std::path::PathBuf;
use tauri::ipc::Channel;
use tauri::{AppHandle, Manager, State, Window};
@@ -81,6 +84,81 @@ pub async fn tools_uninstall(
Ok(info)
}
/// JDK distributions Download JDK offers.
#[tauri::command]
#[specta::specta]
pub fn jdk_download_vendors() -> Vec<JdkVendor> {
ide_tools::jdk::vendors()
}
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
#[tauri::command]
#[specta::specta]
pub async fn jdk_download_releases(
app: AppHandle,
window: Window,
state: State<'_, AppState>,
vendor: String,
request: RequestId,
) -> IdeResult<JdkReleases> {
let tools = tools(&app, &state, &window)?;
let cancel = state.begin_request(request);
let result =
tauri::async_runtime::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
state.end_request(request);
result?
}
/// Where Download JDK puts JDKs unless told otherwise.
#[tauri::command]
#[specta::specta]
pub fn jdk_download_location(app: AppHandle) -> IdeResult<ide_core::IpcPath> {
let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?;
Ok(ide_tools::jdk::jdks_dir(&data).into())
}
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable
/// with `cancel_request(request)`. The JDK is then detected like an installed one,
/// and language servers waiting for a JDK (jdtls) start on it.
#[tauri::command]
#[specta::specta]
#[allow(clippy::too_many_arguments)]
pub async fn jdk_download(
app: AppHandle,
window: Window,
state: State<'_, AppState>,
vendor: String,
version: u32,
location: Option<PathBuf>,
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<Sdk> {
let tools = tools(&app, &state, &window)?;
let location = location.filter(|p| !p.as_os_str().is_empty());
let cancel = state.begin_request(request);
let result = tauri::async_runtime::spawn_blocking(move || {
let jdk = tools.download_jdk(&vendor, version, location.as_deref(), &cancel, &|e| {
let _ = on_event.send(e);
})?;
let home = jdk.home.into_path_buf();
// A chosen location is scanned from now on too (the default one is from startup).
if let Some(location) = &location {
ide_sdk::java::add_jdk_root(location);
}
ide_sdk::java::rescan_jdks();
let jdk = ide_sdk::java::jdk_at(&home)
.ok_or_else(|| IdeError::internal(format!("{} is not a JDK home", home.display())))?;
IdeResult::Ok(ide_sdk::jdk_sdk(&jdk))
})
.await
.map_err(join_err);
state.end_request(request);
let sdk = result??;
reconfigure_servers(&state);
Ok(sdk)
}
fn reconfigure_servers(state: &AppState) {
for project in state.projects() {
let lsp = project.lsp.clone();
+1
Переглянути файл
@@ -41,6 +41,7 @@ pub fn run() {
if let Some(data) = dirs::data_dir() {
ide_tools::extend_process_path(&data.join(&context.config().identifier));
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data.join(&context.config().identifier)));
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data.join(&context.config().identifier)));
}
if let Some(refresh) = refresh_shell_path {
refresh();
+29
Переглянути файл
@@ -497,6 +497,19 @@ export const commands = {
toolsInstall: (name: string, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_install", { name, request, onEvent })),
/** Removes a tool the IDE installed. */
toolsUninstall: (name: string) => typedError<ToolInfo, IdeError>(__TAURI_INVOKE("tools_uninstall", { name })),
/** JDK distributions Download JDK offers. */
jdkDownloadVendors: () => __TAURI_INVOKE<JdkVendor[]>("jdk_download_vendors"),
/** Java versions `vendor` publishes; cancellable with `cancel_request(request)`. */
jdkDownloadReleases: (vendor: string, request: RequestId) => typedError<JdkReleases, IdeError>(__TAURI_INVOKE("jdk_download_releases", { vendor, request })),
/** Where Download JDK puts JDKs unless told otherwise. */
jdkDownloadLocation: () => typedError<IpcPath, IdeError>(__TAURI_INVOKE("jdk_download_location")),
/**
* Downloads the latest `vendor` JDK of Java `version` into `location` (default:
* [`jdk_download_location`]), streaming progress through `on_event`; cancellable
* with `cancel_request(request)`. The JDK is then detected like an installed one,
* and language servers waiting for a JDK (jdtls) start on it.
*/
jdkDownload: (vendor: string, version: number, location: string | null, request: RequestId, onEvent: Channel<InstallEvent>) => typedError<Sdk, IdeError>(__TAURI_INVOKE("jdk_download", { vendor, version, location, request, onEvent })),
/** Installed interpreters / toolchains of `kind`; project-local ones first. */
sdkDetect: (kind: SdkKind) => typedError<Sdk[], IdeError>(__TAURI_INVOKE("sdk_detect", { kind })),
/** The interpreter the project uses: the configured one, else its virtualenv. */
@@ -2888,6 +2901,22 @@ export type JavaSettings = {
maven?: MavenSettings,
};
/** Java versions a vendor publishes. */
export type JdkReleases = {
/** Feature versions, newest first. */
versions: number[],
/** The long-term-support ones among them. */
lts: number[],
/** The version offered first (the newest LTS). */
recommended: number,
};
/** A JDK distribution the IDE can download. */
export type JdkVendor = {
id: string,
name: string,
};
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize;
+9 -1
Переглянути файл
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react";
import clsx from "clsx";
import type { BuildTool, GradleModel, MavenModel, RunConfig } from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { editorService } from "@/workbench/contributions";
import {
@@ -191,13 +192,20 @@ function Banner({ tool }: { tool: BuildTool }) {
</Button>
</div>
);
if (info.syncError)
if (info.syncError) {
const [jdkFix] = downloadJdkActions(info.syncError);
return (
<div className={clsx(styles.banner, styles.bannerWarning)} title={info.syncError}>
<Icon name="warning" size={16} />
<span className={styles.bannerText}>{info.syncError.split("\n")[0]}</span>
{jdkFix && (
<Button size="small" variant="ghost" onClick={jdkFix.run}>
{jdkFix.label}
</Button>
)}
</div>
);
}
return null;
}
+2
Переглянути файл
@@ -20,6 +20,7 @@ import {
type RunConfig,
type SyncProgress,
} from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { removeTask, upsertTask } from "@/workbench/tasks";
@@ -207,6 +208,7 @@ export async function syncTool(tool: BuildTool): Promise<void> {
title: tool === "maven" ? "Maven sync finished with errors" : "Gradle sync failed",
body: error,
key: `build-sync-${tool}`,
actions: downloadJdkActions(error),
});
} catch (e) {
if (!isCancelled(e))
+34
Переглянути файл
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { registerAction } from "@/platform/actions";
import { ServerStatus } from "./ServerStatus";
import { useServers, type ServerEntry } from "./servers";
const ISSUE_49 =
"jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21). Download JDK 25, or pick an installed one in Settings › Languages & Frameworks › Java › Language Server › JDK (it restarts automatically); the project can still target an older Java version.";
const server = (status: ServerEntry["status"], message: string | null): ServerEntry =>
({ id: 1, name: "jdtls", status, message, languages: ["Java"], root: null }) as unknown as ServerEntry;
afterEach(() => useServers.setState({ servers: new Map() }));
describe("Language server status popup", () => {
it("offers Download JDK N when jdtls can't start for want of a JDK", () => {
const run = vi.fn();
const off = registerAction({ id: "java.downloadJdk", title: "Download JDK…", run: ({ arg }) => run(arg) });
useServers.setState({ servers: new Map([[1 as never, server("misconfigured", ISSUE_49)]]) });
render(<ServerStatus />);
fireEvent.click(screen.getByRole("button", { name: /Language servers/ }));
fireEvent.click(screen.getByRole("button", { name: "Download JDK 25" }));
expect(run).toHaveBeenCalledWith({ version: 25, languageServer: true });
off();
});
it("keeps Restart for other failures", () => {
useServers.setState({ servers: new Map([[1 as never, server("crashed", "exit code 1")]]) });
render(<ServerStatus />);
fireEvent.click(screen.getByRole("button", { name: /Language servers/ }));
expect(screen.queryByRole("button", { name: /Download JDK/ })).toBeNull();
expect(screen.getByRole("button", { name: "Restart jdtls" })).toBeInTheDocument();
});
});
+52 -37
Переглянути файл
@@ -3,6 +3,7 @@ import { useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { getAction, executeAction } from "@/platform/actions";
import { BarButton, Button, IconButton, Popup, T } from "@/ui";
import { downloadJdkActions } from "@/platform/jdk";
import { relativePath, useProject } from "@/platform/project";
import { STATUS_LABEL, restartServer, serversSummary, useServers } from "./servers";
import styles from "./ServerStatus.module.css";
@@ -41,45 +42,59 @@ export function ServerStatus() {
<div className={styles.header}>
<T>Language Servers</T>
</div>
{servers.map((s) => (
<div key={s.id} className={styles.server}>
<div className={styles.line}>
<span className={clsx(styles.dot, styles[toneOf(s.status)])} aria-hidden />
<span className={styles.name}>{s.name}</span>
<span className={styles.status}>{STATUS_LABEL[s.status]}</span>
<span className={styles.spacer} />
{s.status === "missing" && canInstall ? (
<Button
size="small"
aria-label={`Install ${s.name}`}
onClick={() => {
setOpen(false);
void executeAction("tools.install", s.name);
}}
>
<T>Install</T>
</Button>
) : (
<IconButton
icon="refresh"
size="small"
title={`Restart ${s.name}`}
disabled={s.status === "missing" || s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
{servers.map((s) => {
// jdtls without a JDK new enough to run it: "Download JDK 25".
const [jdkFix] = s.status === "misconfigured" ? downloadJdkActions(s.message) : [];
return (
<div key={s.id} className={styles.server}>
<div className={styles.line}>
<span className={clsx(styles.dot, styles[toneOf(s.status)])} aria-hidden />
<span className={styles.name}>{s.name}</span>
<span className={styles.status}>{STATUS_LABEL[s.status]}</span>
<span className={styles.spacer} />
{jdkFix ? (
<Button
size="small"
onClick={() => {
setOpen(false);
jdkFix.run();
}}
>
{jdkFix.label}
</Button>
) : s.status === "missing" && canInstall ? (
<Button
size="small"
aria-label={`Install ${s.name}`}
onClick={() => {
setOpen(false);
void executeAction("tools.install", s.name);
}}
>
<T>Install</T>
</Button>
) : (
<IconButton
icon="refresh"
size="small"
title={`Restart ${s.name}`}
disabled={s.status === "missing" || s.status === "starting"}
onClick={() => void restartServer(s.id)}
/>
)}
</div>
{(s.languages.length > 0 || s.root) && (
<div className={styles.meta}>
{s.languages.join(", ")}
{s.root && s.root !== root ? ` · ${relativePath(root, s.root) || s.root}` : ""}
</div>
)}
{s.message && (
<div className={clsx(styles.message, s.status === "missing" && styles.install)}>{s.message}</div>
)}
</div>
{(s.languages.length > 0 || s.root) && (
<div className={styles.meta}>
{s.languages.join(", ")}
{s.root && s.root !== root ? ` · ${relativePath(root, s.root) || s.root}` : ""}
</div>
)}
{s.message && (
<div className={clsx(styles.message, s.status === "missing" && styles.install)}>{s.message}</div>
)}
</div>
))}
);
})}
</div>
</Popup>
</>
+4 -1
Переглянути файл
@@ -1,6 +1,7 @@
/** Debugger actions (ids match `keymaps/intellij-macos.ts`). */
import { registerActions, type Action } from "@/platform/actions";
import { call, commands, isCancelled, isTauri, nextRequestId, type RunConfig, type RunId } from "@/platform/ipc";
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { activeEditor } from "@/editor/api";
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
@@ -55,7 +56,9 @@ async function report(title: string, p: Promise<unknown>) {
try {
await p;
} catch (e) {
notifications().notify({ group: "run", level: "error", title, body: errorText(e), key: "debug.action" });
// A Java launch without a JDK new enough (for jdtls or the project) offers to download one.
const actions = downloadJdkActions(errorText(e));
notifications().notify({ group: "run", level: "error", title, body: errorText(e), key: "debug.action", actions });
}
}
+1 -1
Переглянути файл
@@ -269,7 +269,7 @@ const INSTALL: Record<string, string> = {
r: "Install R (`brew install r`) so that `Rscript` is on the PATH.",
go: "Install Go from https://go.dev/dl (or `brew install go`).",
rust: "Install Rust with rustup: https://rustup.rs",
java: "Install a JDK 11+ (`brew install openjdk`) so that `java` is on the PATH.",
java: "Install a JDK 11+ (`brew install openjdk`), or download one in Settings › Languages & Frameworks › Java (Project JDK › Download JDK…).",
kotlin: "Install the Kotlin compiler (`brew install kotlin`, or https://kotlinlang.org/docs/command-line.html) so that `kotlinc` is on the PATH.",
"kotlin-script": "Install Kotlin (`brew install kotlin`).",
swift: "Install the Xcode command line tools (`xcode-select --install`).",
+15 -1
Переглянути файл
@@ -7,6 +7,7 @@ import { create } from "zustand";
import { Channel } from "@tauri-apps/api/core";
import { call, commands, isTauri, type OutputChunk, type RunConfig, type RunId, type TestCaseReport } from "@/platform/ipc";
import { formatCompileErrors, isJvmApplication, parseJavaArgs, resolveJavaCommand } from "@/platform/javaLaunch";
import { downloadJdkActions } from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
import { workflowSettings } from "@/platform/workflowSettings";
@@ -590,6 +591,17 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
if (info.build === "failed") say("Build failed; launching with the last compiled classes.\n", "stderr");
const compileReport = formatCompileErrors(info);
if (compileReport) say(compileReport, "stderr");
// No installed JDK is new enough for the module: offer to download one.
const jdkHint = info.hints.find((h) => downloadJdkActions(h).length);
if (jdkHint)
notifications().notify({
group: "run",
level: "warning",
key: "run.jdk-missing",
title: `No JDK can run '${config.name}'`,
body: jdkHint,
actions: downloadJdkActions(jdkHint),
});
if (!info.classPaths.length) {
say("The classpath is empty: is the module imported by the Java language server?\n", "stderr");
return null;
@@ -598,7 +610,9 @@ async function resolveJvmApplication(sessionId: string, config: RunConfig): Prom
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
say(`Cannot resolve the classpath: ${message}\n`, "stderr");
notifications().notify({ group: "run", level: "error", title: `Cannot run '${config.name}'`, body: message });
// jdtls can't start without a newer JDK: "Download JDK 25".
const actions = downloadJdkActions(message);
notifications().notify({ group: "run", level: "error", title: `Cannot run '${config.name}'`, body: message, actions });
return null;
}
}
+71
Переглянути файл
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { InstallEvent, Sdk } from "@/platform/ipc";
const ok = <T,>(data: T) => ({ status: "ok" as const, data });
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: {
...real.commands,
jdkDownloadVendors: async () => [{ id: "temurin", name: "Eclipse Temurin" }],
jdkDownloadReleases: async () =>
ok({ versions: [26, 25, 21, 17, 11, 8], lts: [8, 11, 17, 21, 25], recommended: 25 }),
jdkDownloadLocation: async () => ok("/data/tools/jdks"),
cancelRequest: async () => ok(null),
},
};
});
const SDK: Sdk = {
path: "/data/tools/jdks/temurin-25.0.1",
name: "Eclipse Adoptium 25.0.1",
version: "25.0.1",
source: "downloaded",
home: "/data/tools/jdks/temurin-25.0.1",
extra: null,
};
let finish: (sdk: Sdk) => void = () => {};
const cancel = vi.fn();
const downloadJdk = vi.fn((_v: string, _n: number, _l: string | null, onEvent: (e: InstallEvent) => void) => {
onEvent({ type: "progress", fraction: 0.25, message: "Downloaded 50.0 of 200.0 MB" });
return { promise: new Promise<Sdk>((r) => (finish = r)), cancel };
});
vi.mock("@/platform/jdk", async (orig) => ({ ...(await orig<typeof import("@/platform/jdk")>()), downloadJdk }));
const { DownloadJdkDialog } = await import("../languages/DownloadJdkDialog");
const { useSdks } = await import("../languages/api");
afterEach(() => vi.clearAllMocks());
describe("Download JDK dialog", () => {
it("downloads the newest LTS into the IDE's JDK folder, with progress, and selects it", async () => {
const onDownloaded = vi.fn();
const onClose = vi.fn();
render(<DownloadJdkDialog onClose={onClose} onDownloaded={onDownloaded} />);
await waitFor(() => expect(screen.getByRole("button", { name: "Version" })).toHaveTextContent("25"));
expect(screen.getByRole("button", { name: "Vendor" })).toHaveTextContent("Eclipse Temurin");
await waitFor(() => expect(screen.getByLabelText("Location:")).toHaveValue("/data/tools/jdks"));
fireEvent.click(screen.getByRole("button", { name: "Download" }));
expect(downloadJdk).toHaveBeenCalledWith("temurin", 25, "/data/tools/jdks", expect.any(Function));
expect(screen.getByRole("progressbar", { name: "Downloading JDK" })).toHaveAttribute("aria-valuenow", "25");
expect(screen.getByRole("button", { name: "Download" })).toBeDisabled();
// Closing while downloading cancels the download.
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
expect(cancel).toHaveBeenCalled();
await act(async () => finish(SDK));
expect(onDownloaded).toHaveBeenCalledWith(SDK);
expect(useSdks.getState().byKind.jdk?.[0]).toEqual(SDK);
});
it("preselects the version a fix asks for", async () => {
render(<DownloadJdkDialog initialVersion={21} onClose={() => {}} onDownloaded={() => {}} />);
await waitFor(() => expect(screen.getByRole("button", { name: "Download" })).toBeEnabled());
expect(screen.getByRole("button", { name: "Version" })).toHaveTextContent("21");
});
});
+168
Переглянути файл
@@ -0,0 +1,168 @@
/**
* IDEA's Download JDK dialog: vendor, Java version and location; downloads the
* latest build (SHA-256 verified) with progress and Cancel, then selects it.
*/
import { useEffect, useId, useMemo, useState } from "react";
import { call, commands, isCancelled, isTauri, nextRequestId, type JdkReleases, type Sdk } from "@/platform/ipc";
import { downloadJdk, TEMURIN, type JdkDownload } from "@/platform/jdk";
import { Dialog, IconButton, Input, ProgressBar, Select, type SelectOption, T } from "@/ui";
import { choosePath, rememberSdk } from "./api";
import styles from "./Sdk.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
export function DownloadJdkDialog({
initialVersion,
onClose,
onDownloaded,
}: {
/** Preselected Java version (e.g. the one jdtls needs); default: the newest LTS. */
initialVersion?: number;
onClose: () => void;
onDownloaded: (sdk: Sdk) => void;
}) {
const [vendors, setVendors] = useState<SelectOption[]>([{ value: TEMURIN, label: "Eclipse Temurin" }]);
const [vendor, setVendor] = useState(TEMURIN);
const [releases, setReleases] = useState<JdkReleases | null>(null);
const [version, setVersion] = useState<string | null>(initialVersion ? String(initialVersion) : null);
const [location, setLocation] = useState("");
const [job, setJob] = useState<JdkDownload | null>(null);
const [progress, setProgress] = useState<{ fraction: number | null; message: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const locationId = useId();
useEffect(() => {
if (!isTauri()) return;
commands
.jdkDownloadVendors()
.then((list) => setVendors(list.map((v) => ({ value: v.id, label: v.name }))))
.catch(() => {});
void call(commands.jdkDownloadLocation())
.then((dir) => setLocation((cur) => cur || dir))
.catch(() => {});
}, []);
useEffect(() => {
if (!isTauri()) return;
const request = nextRequestId();
let live = true;
call(commands.jdkDownloadReleases(vendor, request))
.then((r) => {
if (!live) return;
setReleases(r);
setVersion((v) => (v && r.versions.includes(Number(v)) ? v : String(r.recommended)));
})
.catch((e: unknown) => live && !isCancelled(e) && setError(`Cannot list the Java versions: ${errorText(e)}`));
return () => {
live = false;
void commands.cancelRequest(request).catch(() => {});
};
}, [vendor]);
const versionOptions = useMemo<SelectOption[]>(
() =>
(releases?.versions ?? []).map((v) => ({
value: String(v),
label: String(v),
detail: releases?.lts.includes(v) ? "LTS" : undefined,
})),
[releases],
);
const download = () => {
if (!version) return;
setError(null);
setProgress({ fraction: null, message: "Resolving the latest build…" });
const next = downloadJdk(vendor, Number(version), location.trim() || null, (e) =>
setProgress((p) =>
e.type === "progress"
? { fraction: e.fraction, message: e.message }
: { fraction: p?.fraction ?? null, message: e.text },
),
);
setJob(next);
next.promise
.then((sdk) => {
rememberSdk("jdk", sdk);
onDownloaded(sdk);
})
.catch((e: unknown) => {
if (!isCancelled(e)) setError(errorText(e));
setProgress(null);
})
.finally(() => setJob(null));
};
const vendorName = vendors.find((v) => v.value === vendor)?.label ?? vendor;
return (
<Dialog
open
onClose={() => (job ? job.cancel() : onClose())}
title="Download JDK"
width={520}
aria-label="Download JDK"
buttons={[
{ label: "Download", role: "default", disabled: !version || !!job, onClick: download },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.form}>
<div className={styles.downloadGrid}>
<span>
<T>Vendor:</T>
</span>
<Select value={vendor} options={vendors} onChange={setVendor} disabled={!!job} aria-label="Vendor" />
<span>
<T>Version:</T>
</span>
<Select
value={version}
options={versionOptions}
onChange={setVersion}
disabled={!!job}
placeholder={releases ? "Select a version" : "Loading…"}
aria-label="Version"
/>
<label htmlFor={locationId}>
<T>Location:</T>
</label>
<div className={styles.pathRow}>
<Input
id={locationId}
mono
className={styles.pathInput}
value={location}
disabled={!!job}
onChange={(e) => setLocation(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
disabled={!!job}
onClick={async () => {
const p = await choosePath({ directory: true, title: "JDK Location", defaultPath: location });
if (p) setLocation(p);
}}
/>
</div>
</div>
<div className={styles.note}>
{vendorName} {version ?? ""} is unpacked into a folder of its own in {location || "…"}, where JDK detection
finds it.
</div>
{progress && (
<div className={styles.downloadProgress}>
<ProgressBar value={progress.fraction} aria-label="Downloading JDK" />
<span className={styles.note}>{progress.message}</span>
</div>
)}
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
</div>
</Dialog>
);
}
+1 -1
Переглянути файл
@@ -244,7 +244,7 @@ export function JavaPage() {
directory
browseTitle="Select JDK Home Directory"
aria-label="Language server JDK"
hint="Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25). The jdtls executable itself is set in Language Servers."
hint="Runs jdtls only, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. jdtls needs JDK 21 or newer (recent releases 25); Download JDK… in the list fetches one. The jdtls executable itself is set in Language Servers."
/>
<Row
label="JVM options"
+13
Переглянути файл
@@ -357,3 +357,16 @@
color: var(--fg-subtle);
font-size: var(--fs-small);
}
/* Download JDK dialog */
.downloadGrid {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
gap: 6px 10px;
}
.downloadProgress {
display: flex;
flex-direction: column;
gap: 4px;
}
+19 -4
Переглянути файл
@@ -1,9 +1,10 @@
/** Shared controls of the interpreter / toolchain settings pages. */
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { IconButton, Select, Spinner, type SelectOption } from "@/ui";
import { useProject } from "@/platform/project";
import { useSetting } from "../components/context";
import { choosePath, detectSdks, useSdks, type Sdk, type SdkKind } from "./api";
import { DownloadJdkDialog } from "./DownloadJdkDialog";
import styles from "./Sdk.module.css";
/**
@@ -48,7 +49,10 @@ export interface SdkFieldProps {
extra?: SelectOption[];
}
/** IDEA's SDK combo: detected installations, the current custom path, and a browse button. */
/** The "Download JDK…" entry of JDK combos (not a value). */
const DOWNLOAD_JDK = "\u0000download-jdk";
/** IDEA's SDK combo: detected installations, the current custom path, and a browse button; JDK combos also offer Download JDK…. */
export function SdkField({
kind,
value,
@@ -67,8 +71,10 @@ export function SdkField({
out.push(...(extra ?? []));
for (const sdk of list) out.push({ value: sdk.path, label: sdk.name, detail: tildify(sdk.path) });
if (value && !out.some((o) => o.value === value)) out.push({ value, label: tildify(value), detail: "custom" });
if (kind === "jdk") out.push({ value: DOWNLOAD_JDK, label: "Download JDK…", icon: "plus" });
return out;
}, [list, value, emptyLabel, emptyDetail, extra]);
}, [list, value, emptyLabel, emptyDetail, extra, kind]);
const [downloading, setDownloading] = useState(false);
const browse = async () => {
const picked = await choosePath({ directory: !!directory, title: browseTitle, defaultPath: value || undefined });
if (picked) onChange(picked);
@@ -79,7 +85,7 @@ export function SdkField({
className={styles.sdkSelect}
value={value}
options={options}
onChange={onChange}
onChange={(v) => (v === DOWNLOAD_JDK ? setDownloading(true) : onChange(v))}
aria-label={aria["aria-label"]}
placeholder={loading ? "Detecting…" : "<No interpreter>"}
/>
@@ -89,6 +95,15 @@ export function SdkField({
<IconButton icon="refresh" size="small" title="Detect again" onClick={refresh} />
)}
<IconButton icon="folder-open" size="small" title="Browse…" onClick={() => void browse()} />
{downloading && (
<DownloadJdkDialog
onClose={() => setDownloading(false)}
onDownloaded={(sdk) => {
setDownloading(false);
onChange(sdk.path);
}}
/>
)}
</span>
);
}
+116
Переглянути файл
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { InstallEvent, Sdk, Settings } from "@/bindings";
import type { NotificationService } from "@/workbench/contributions";
const settingsSet = vi.fn(async (_scope: string, _key: string, value: string) => ({
status: "ok" as const,
data: { languages: { java: { jdtlsJdk: JSON.parse(value) } } } as unknown as Settings,
}));
vi.mock("@/platform/ipc", async (orig) => {
const actual = await orig<typeof import("@/platform/ipc")>();
return { ...actual, commands: { ...actual.commands, settingsSet } };
});
const SDK: Sdk = {
path: "/data/tools/jdks/temurin-25.0.1/Contents/Home",
name: "Eclipse Adoptium 25.0.1",
version: "25.0.1",
source: "downloaded",
home: "/data/tools/jdks/temurin-25.0.1/Contents/Home",
extra: null,
};
let outcome: () => Promise<Sdk>;
const cancel = vi.fn();
const downloadJdk = vi.fn(
(_vendor: string, _version: number, _location: string | null, onEvent: (e: InstallEvent) => void) => {
onEvent({ type: "progress", fraction: 0.5, message: "Downloaded 100.0 of 200.0 MB" });
return { promise: outcome(), cancel };
},
);
vi.mock("@/platform/jdk", async (orig) => ({ ...(await orig<typeof import("@/platform/jdk")>()), downloadJdk }));
const { downloadJdkFor } = await import("../jdk");
const { useProject } = await import("@/platform/project");
const { IpcError } = await import("@/platform/ipc");
const { provideNotificationService } = await import("@/workbench/contributions");
const { useTasks } = await import("@/workbench/tasks");
type Note = Parameters<NotificationService["notify"]>[0];
let notes: Note[];
const withServerJdk = (jdtlsJdk: string, project = true) =>
useProject.setState({
project: project ? ({ root: "/p" } as never) : null,
settings: { languages: { java: { jdk: "", jdtlsJdk } } } as never,
});
beforeEach(() => {
notes = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
outcome = async () => SDK;
});
afterEach(() => {
useProject.setState({ project: null, settings: null });
vi.clearAllMocks();
});
describe("Download JDK N (one click)", () => {
it("downloads Temurin N into the IDE's JDK folder, with progress in the background tasks", async () => {
withServerJdk("");
const seen: Array<number | null> = [];
const unsub = useTasks.subscribe((s) => {
const t = s.tasks.find((x) => x.id === "jdk-download:25");
if (t && !t.done) seen.push(t.fraction);
});
const sdk = await downloadJdkFor({ version: 25, languageServer: true });
unsub();
expect(sdk).toEqual(SDK);
expect(downloadJdk).toHaveBeenCalledWith("temurin", 25, null, expect.any(Function));
expect(seen).toContain(0.5);
// Auto: the backend's rescan makes jdtls pick the new JDK by itself.
expect(settingsSet).not.toHaveBeenCalled();
expect(notes.at(-1)).toMatchObject({ level: "info", title: "Eclipse Adoptium 25.0.1 downloaded" });
});
it("replaces a language server JDK that is too old (in the project, whose value wins)", async () => {
withServerJdk("C:\\Java\\jdk-21");
await downloadJdkFor({ version: 25, languageServer: true });
expect(settingsSet).toHaveBeenCalledWith("project", "languages.java.jdtlsJdk", JSON.stringify(SDK.path));
expect(useProject.getState().settings?.languages?.java?.jdtlsJdk).toBe(SDK.path);
withServerJdk("/old", false);
await downloadJdkFor({ version: 25, languageServer: true });
expect(settingsSet).toHaveBeenLastCalledWith("global", "languages.java.jdtlsJdk", JSON.stringify(SDK.path));
});
it("leaves the settings alone for a project JDK (Auto picks it per module)", async () => {
withServerJdk("/old");
await downloadJdkFor({ version: 21 });
expect(settingsSet).not.toHaveBeenCalled();
});
it("deduplicates concurrent downloads of one version", async () => {
withServerJdk("");
const a = downloadJdkFor({ version: 25 });
const b = downloadJdkFor({ version: 25 });
expect(a).toBe(b);
await a;
expect(downloadJdk).toHaveBeenCalledTimes(1);
});
it("reports failures but not cancellation", async () => {
withServerJdk("");
outcome = async () => {
throw new IpcError({ kind: "unavailable", message: "Eclipse Temurin 8 has no build for Linux Arm64" });
};
expect(await downloadJdkFor({ version: 8 })).toBeNull();
expect(notes.at(-1)).toMatchObject({ level: "error", title: "Failed to download JDK 8" });
notes = [];
outcome = async () => {
throw new IpcError({ kind: "cancelled", message: "cancelled" });
};
expect(await downloadJdkFor({ version: 8 })).toBeNull();
expect(notes).toEqual([]);
});
});
+5 -1
Переглянути файл
@@ -2,11 +2,13 @@
* Tools installer: Settings › Languages & Frameworks › Language Servers, plus
* install prompts for missing language servers.
*
* Actions: `tools.languageServers` (open the page), `tools.install` (arg: tool name).
* Actions: `tools.languageServers` (open the page), `tools.install` (arg: tool name),
* `java.downloadJdk` (arg: `DownloadJdkArgs`).
*/
import { lazyComponent } from "@/ui";
import { executeAction, registerActions } from "@/platform/actions";
import { registerSettingsPage } from "@/workbench/contributions";
import { installJdkPrompts, registerJdkActions } from "./jdk";
import { installMissingServerPrompts } from "./missing";
import { installTool, refreshTools, SETTINGS_PAGE_ID, toolByName, useTools } from "./store";
@@ -55,6 +57,8 @@ export function activate(): () => void {
},
]),
installMissingServerPrompts(),
registerJdkActions(),
installJdkPrompts(),
];
return () => disposers.reverse().forEach((d) => d());
}
+122
Переглянути файл
@@ -0,0 +1,122 @@
/**
* `java.downloadJdk` (arg: `DownloadJdkArgs`): the one-click fix offered where
* jdtls, a run configuration or a Maven / Gradle sync lacks a new enough JDK.
* Downloads Eclipse Temurin N into the IDE's JDK folder (found by JDK detection
* from then on), makes it the language server JDK when a too-old one is chosen
* there, and lets the backend restart jdtls on it. Progress and Cancel live in
* the status bar's background tasks.
*/
import type { Sdk } from "@/bindings";
import { executeAction, registerActions } from "@/platform/actions";
import { call, commands, isCancelled, isTauri, onIdeEvent } from "@/platform/ipc";
import {
DOWNLOAD_JDK_ACTION,
downloadJdk,
downloadJdkActions,
isDownloadJdkArgs,
requiredJdk,
TEMURIN,
type DownloadJdkArgs,
} from "@/platform/jdk";
import { useProject } from "@/platform/project";
import { notifications } from "@/workbench/contributions";
import { upsertTask } from "@/workbench/tasks";
const JAVA_SETTINGS = "languages.java";
const running = new Map<number, Promise<Sdk | null>>();
/** Makes `home` the language server JDK if one that can't run jdtls is chosen (Auto finds the new JDK by itself). */
export async function chooseForLanguageServer(home: string): Promise<void> {
const chosen = useProject.getState().settings?.languages?.java?.jdtlsJdk?.trim();
if (!chosen || chosen === home) return;
// The project value wins over the global one, so writing it there always takes effect.
const scope = useProject.getState().project ? "project" : "global";
const settings = await call(commands.settingsSet(scope, "languages.java.jdtlsJdk", JSON.stringify(home)));
useProject.setState({ settings });
}
export function downloadJdkFor(args: DownloadJdkArgs): Promise<Sdk | null> {
const existing = running.get(args.version);
if (existing) return existing;
const title = `Downloading JDK ${args.version}`;
const taskId = `jdk-download:${args.version}`;
const key = `jdk-download-${args.version}`;
const cancel = () => job.cancel();
const job = downloadJdk(TEMURIN, args.version, null, (e) => {
const message = e.type === "line" ? e.text : e.message;
const fraction = e.type === "progress" ? e.fraction : null;
upsertTask({ id: taskId, title, message, fraction, done: false, cancel });
});
upsertTask({ id: taskId, title, message: "Eclipse Temurin", fraction: null, done: false, cancel });
const p = (async () => {
try {
const sdk = await job.promise;
if (args.languageServer) await chooseForLanguageServer(sdk.path);
notifications().notify({
group: "tools",
level: "info",
key,
title: `${sdk.name} downloaded`,
body: args.languageServer
? "The Java language server restarts on it."
: `Installed in ${sdk.path}; it is used for projects targeting Java ${args.version}.`,
});
return sdk;
} catch (e) {
if (isCancelled(e)) return null;
notifications().notify({
group: "tools",
level: "error",
key,
title: `Failed to download JDK ${args.version}`,
body: e instanceof Error ? e.message : String(e),
actions: [{ label: "Java Settings…", run: () => void executeAction("file.settings", JAVA_SETTINGS) }],
});
return null;
} finally {
upsertTask({ id: taskId, title, message: null, fraction: 1, done: true });
running.delete(args.version);
}
})();
running.set(args.version, p);
return p;
}
/** A language server that can't start without a newer JDK (jdtls): say so once, with the one-click download. */
export function installJdkPrompts(): () => void {
const prompted = new Set<string>();
return onIdeEvent("languageServer", (e) => {
if (e.status !== "misconfigured") return;
const need = requiredJdk(e.message);
if (!need || prompted.has(`${e.name}:${need.version}`)) return;
prompted.add(`${e.name}:${need.version}`);
notifications().notify({
group: "lsp",
level: "warning",
key: `jdk-required-${e.name}`,
sticky: true,
title: `${e.name} needs JDK ${need.version} to run`,
body: e.message ?? undefined,
actions: [
...downloadJdkActions(e.message),
{ label: "Java Settings…", run: () => void executeAction("file.settings", JAVA_SETTINGS) },
],
});
});
}
export function registerJdkActions(): () => void {
return registerActions([
{
id: DOWNLOAD_JDK_ACTION,
title: "Download JDK…",
category: "Java",
hidden: true,
// Without a version: the Java settings, whose JDK pickers offer Download JDK….
run: async ({ arg }) => {
if (isDownloadJdkArgs(arg) && isTauri()) await downloadJdkFor(arg);
else await executeAction("file.settings", JAVA_SETTINGS);
},
},
]);
}
+63
Переглянути файл
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const executeAction = vi.fn(async () => true);
let registered = true;
vi.mock("./actions", async (orig) => ({
...(await orig<typeof import("./actions")>()),
executeAction,
getAction: (id: string) => (registered && id === "java.downloadJdk" ? { id } : undefined),
}));
const { downloadJdkActions, isDownloadJdkArgs, requiredJdk } = await import("./jdk");
afterEach(() => {
registered = true;
vi.clearAllMocks();
});
// Issue #49, verbatim from the run console (Windows, JDK 8 / 17 / 21 installed).
const ISSUE_49 =
"jdtls needs JDK 25 or newer to run (found JDK 8, 17, 21). Pick one in Settings › Languages & Frameworks › Java › Language Server › JDK (it restarts automatically), or install a newer JDK and restart the server; the project can still target an older Java version.";
describe("JDK requirements in launch / sync errors", () => {
it.each([
[ISSUE_49, { version: 25, languageServer: true }],
[
"The language server JDK (C:\\Java\\jdk-21, JDK 21) is too old: jdtls needs JDK 25 or newer. Download JDK 25, choose another one in Settings › Languages & Frameworks › Java › Language Server › JDK, or set it to Auto.",
{ version: 25, languageServer: true },
],
[
"This project targets Java 21: it needs JDK 21 or newer (found JDK 17). Download JDK 21, or choose a Project JDK in Settings › Languages & Frameworks › Java.",
{ version: 21, languageServer: false },
],
[
"This project targets Java 17: it needs JDK 17 or newer (no JDK was found). Download JDK 17, or choose a Project JDK in Settings › Languages & Frameworks › Java.\n[ERROR] Failed to execute goal",
{ version: 17, languageServer: false },
],
["The language server JDK /x is not a JDK home (Settings › Languages & Frameworks › Java).", null],
["`rust-analyzer` was not found on PATH", null],
[null, null],
])("%s", (message, expected) => {
expect(requiredJdk(message)).toEqual(expected);
});
it("offers Download JDK N, which runs the download action", () => {
const [fix, ...rest] = downloadJdkActions(ISSUE_49);
expect(rest).toEqual([]);
expect(fix!.label).toBe("Download JDK 25");
fix!.run();
expect(executeAction).toHaveBeenCalledWith("java.downloadJdk", { version: 25, languageServer: true });
expect(downloadJdkActions("jdtls crashed")).toEqual([]);
});
it("offers nothing where no window can download (e.g. without the tools feature)", () => {
registered = false;
expect(downloadJdkActions(ISSUE_49)).toEqual([]);
});
it("recognises the action's argument", () => {
expect(isDownloadJdkArgs({ version: 25 })).toBe(true);
expect(isDownloadJdkArgs({ version: "25" })).toBe(false);
expect(isDownloadJdkArgs(undefined)).toBe(false);
});
});
+67
Переглянути файл
@@ -0,0 +1,67 @@
/**
* Download JDK (`jdk_download*` commands), shared by the JDK pickers in Settings
* and the "Download JDK N" fixes offered where a launch or sync lacks a JDK.
* The one-click fix is the `java.downloadJdk` action (features/tools).
*/
import { Channel } from "@tauri-apps/api/core";
import { executeAction, getAction } from "./actions";
import { call, commands, nextRequestId, type InstallEvent, type Sdk } from "./ipc";
export const DOWNLOAD_JDK_ACTION = "java.downloadJdk";
export const TEMURIN = "temurin";
export interface DownloadJdkArgs {
/** Java feature version, e.g. 25. */
version: number;
/** Run jdtls on it: chosen as the language server JDK unless Auto already picks it. */
languageServer?: boolean;
}
export interface JdkRequirement {
version: number;
/** The JDK runs the Java language server (jdtls), not the project. */
languageServer: boolean;
}
/**
* The JDK a launch / sync error asks for: the backend's messages say
* "jdtls needs JDK 25 or newer" / "it needs JDK 21 or newer".
*/
export function requiredJdk(message: string | null | undefined): JdkRequirement | null {
const m = /needs JDK (\d+) or newer/.exec(message ?? "");
if (!m) return null;
return { version: Number(m[1]), languageServer: /\bjdtls\b|language server JDK/i.test(message ?? "") };
}
/** "Download JDK N" notification / banner actions for `message`; none when it names no JDK or nothing can download. */
export function downloadJdkActions(message: string | null | undefined): Array<{ label: string; run: () => void }> {
const need = requiredJdk(message);
if (!need || !getAction(DOWNLOAD_JDK_ACTION)) return [];
const args: DownloadJdkArgs = { version: need.version, languageServer: need.languageServer };
return [{ label: `Download JDK ${need.version}`, run: () => void executeAction(DOWNLOAD_JDK_ACTION, args) }];
}
export function isDownloadJdkArgs(arg: unknown): arg is DownloadJdkArgs {
return !!arg && typeof arg === "object" && typeof (arg as { version?: unknown }).version === "number";
}
export interface JdkDownload {
promise: Promise<Sdk>;
cancel: () => void;
}
/** Downloads the latest `vendor` JDK `version` into `location` (null: the IDE's JDK folder). */
export function downloadJdk(
vendor: string,
version: number,
location: string | null,
onEvent: (e: InstallEvent) => void,
): JdkDownload {
const request = nextRequestId();
const channel = new Channel<InstallEvent>();
channel.onmessage = onEvent;
return {
promise: call(commands.jdkDownload(vendor, version, location, request, channel)),
cancel: () => void commands.cancelRequest(request).catch(() => {}),
};
}