diff --git a/Cargo.lock b/Cargo.lock index ed22c6b25..92ae7cbd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4272,6 +4272,7 @@ dependencies = [ "tempfile", "toml 0.9.12+spec-1.1.0", "tracing", + "zip 2.4.2", ] [[package]] diff --git a/crates/ide-sdk/Cargo.toml b/crates/ide-sdk/Cargo.toml index 5ea28d80c..cce2ab5b1 100644 --- a/crates/ide-sdk/Cargo.toml +++ b/crates/ide-sdk/Cargo.toml @@ -15,6 +15,7 @@ serde_json.workspace = true specta.workspace = true toml.workspace = true tracing.workspace = true +zip = { version = "=2.4.2", default-features = false, features = ["deflate"] } [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 1f16029e2..c21c6eebe 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -126,23 +126,101 @@ pub fn installed_jdks() -> Arc<[Jdk]> { } } -/// Like [`installed_jdks`], but while none can run jdtls the list is rescanned -/// (at most every [`RESCAN_AFTER`]), so a JDK installed after startup is picked -/// up when the server is restarted. -pub fn jdtls_jdks() -> Arc<[Jdk]> { +/// Like [`installed_jdks`], but while none can run jdtls (on Java `min_java`) +/// the list is rescanned (at most every [`RESCAN_AFTER`]), so a JDK installed +/// after startup is picked up when the server is restarted. +pub fn jdtls_jdks(min_java: u32) -> Arc<[Jdk]> { let mut slot = JDKS.lock().unwrap_or_else(|e| e.into_inner()); match &*slot { - Some(s) if jdtls_runtime(&s.jdks).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(), + Some(s) if jdtls_runtime(&s.jdks, min_java).is_some() || s.at.elapsed() < RESCAN_AFTER => s.jdks.clone(), _ => rescan(&mut slot), } } -/// Oldest Java version jdtls itself runs on (projects may target older ones). +/// Oldest Java version jdtls runs on when its install can't tell (its launcher's check). pub const JDTLS_MIN_JAVA: u32 = 21; /// The JDK to run jdtls with: the first found that is new enough (`JAVA_HOME` wins). -pub fn jdtls_runtime(jdks: &[Jdk]) -> Option<&Jdk> { - jdks.iter().find(|j| j.major >= JDTLS_MIN_JAVA) +pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> { + jdks.iter().find(|j| j.major >= min_java) +} + +/// The Java version the jdtls that `cfg` launches needs: the highest any of its +/// bundles requires, never below [`JDTLS_MIN_JAVA`]. Its launcher only checks +/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on +/// an older JDK the server dies resolving its bundles. +pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 { + let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH")); + let required = search + .and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) + .and_then(|exe| dunce::canonicalize(exe).ok()) + .and_then(|exe| Some(exe.parent()?.parent()?.to_path_buf())) + .and_then(|home| bundles_min_java(&home)); + required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA)) +} + +/// Per install: (plugins directory mtime, its requirement). +static BUNDLE_JAVA: Mutex)>> = Mutex::new(BTreeMap::new()); + +/// Highest Java version required by the bundles in `home`'s `plugins` (or Homebrew's +/// `libexec/plugins`); `None` when there are none to read. +fn bundles_min_java(home: &Path) -> Option { + let plugins = [home.join("plugins"), home.join("libexec").join("plugins")].into_iter().find(|p| p.is_dir())?; + let modified = std::fs::metadata(&plugins).and_then(|m| m.modified()).ok()?; + let mut cache = BUNDLE_JAVA.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((at, v)) = cache.get(&plugins) + && *at == modified + { + return *v; + } + let required = std::fs::read_dir(&plugins) + .ok()? + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x == "jar")) + .filter_map(|jar| jar_manifest(&jar)) + .filter_map(|manifest| manifest_min_java(&manifest)) + .max(); + cache.insert(plugins, (modified, required)); + required +} + +fn jar_manifest(jar: &Path) -> Option { + use std::io::Read; + let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).ok()?).ok()?; + let mut entry = zip.by_name("META-INF/MANIFEST.MF").ok()?; + let mut text = String::new(); + entry.read_to_string(&mut text).ok()?; + Some(text) +} + +/// The Java version an OSGi bundle manifest requires: its `osgi.ee` capability +/// (`version=25`), else the oldest of its `Bundle-RequiredExecutionEnvironment`s. +fn manifest_min_java(manifest: &str) -> Option { + // Long headers continue on lines starting with a space. + let mut headers: Vec = Vec::new(); + for line in manifest.lines() { + match (line.strip_prefix(' '), headers.last_mut()) { + (Some(rest), Some(last)) => last.push_str(rest), + _ => headers.push(line.to_owned()), + } + } + let header = |name: &str| headers.iter().find_map(|h| h.strip_prefix(name)?.strip_prefix(':')).map(str::trim); + if let Some(caps) = header("Require-Capability") { + let ee = caps.split(',').filter(|c| c.trim_start().starts_with("osgi.ee")).find_map(|c| { + let v = c.split("version=").nth(1)?; + let digits: String = v.chars().take_while(char::is_ascii_digit).collect(); + digits.parse::().ok() + }); + if ee.is_some() { + return ee; + } + } + header("Bundle-RequiredExecutionEnvironment")? + .split(',') + .filter_map(|ee| ee.trim().strip_prefix("JavaSE-")) + .filter_map(|v| if v == "1.8" { Some(8) } else { v.parse().ok() }) + .min() } /// jdtls `java.configuration.runtimes`: one JDK per Java version (the first @@ -178,14 +256,15 @@ const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot /// jdtls: its Eclipse workspace (`-data`) lives under the IDE's data /// directory, one per server root, and no Eclipse metadata is written into -/// the project; it runs on a JDK 21+ (via `JAVA_HOME`, which its launcher -/// reads), installed JDKs are registered as runtimes and `bundles` (e.g. the -/// java-debug plugin jar) are loaded into the server. +/// the project; it runs on a JDK `min_java`+ (via `JAVA_HOME`, which its +/// launcher reads), installed JDKs are registered as runtimes and `bundles` +/// (e.g. the java-debug plugin jar) are loaded into the server. pub fn configure_jdtls( cfg: &mut LanguageServerConfig, server_root: &Path, data_dir: &Path, jdks: &[Jdk], + min_java: u32, bundles: &[PathBuf], ) { if cfg.name != "jdtls" { @@ -201,11 +280,11 @@ pub fn configure_jdtls( cfg.args.push(format!("--jvm-arg=-D{METADATA_PROPERTY}=false")); } if !cfg.env.contains_key("JAVA_HOME") { - match jdtls_runtime(jdks) { + match jdtls_runtime(jdks, min_java) { Some(jdk) => { cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned()); } - None => cfg.launch_error = Some(missing_runtime_message(jdks)), + None => cfg.launch_error = Some(missing_runtime_message(jdks, min_java)), } } let runtimes = runtimes(jdks); @@ -218,7 +297,7 @@ pub fn configure_jdtls( } } -fn missing_runtime_message(jdks: &[Jdk]) -> String { +fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { let mut found: Vec = jdks.iter().map(|j| j.major).collect(); found.sort_unstable(); found.dedup(); @@ -229,7 +308,7 @@ fn missing_runtime_message(jdks: &[Jdk]) -> String { format!("found JDK {}", list.join(", ")) }; format!( - "jdtls needs JDK {JDTLS_MIN_JAVA} or newer to run ({found}). Install one (or point JAVA_HOME at it) and \ + "jdtls needs JDK {min_java} or newer to run ({found}). Install one (or point JAVA_HOME at it) and \ restart the server; the project can still target an older Java version." ) } @@ -330,7 +409,7 @@ mod tests { Jdk { major: 8, home: PathBuf::from("/jdk8") }, ]; let bundles = [PathBuf::from("/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar")]; - configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, &bundles); + configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); assert_eq!(cfg.args[0], "-data"); assert!(cfg.args[1].starts_with("/data/lsp/jdtls/"), "{}", cfg.args[1]); assert_eq!(cfg.args[2], "--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=false"); @@ -345,36 +424,74 @@ mod tests { // Deterministic (reconfigure compares configs) and idempotent. let before = cfg.clone(); - configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, &bundles); + configure_jdtls(&mut cfg, Path::new("/p/demo"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); assert_eq!(cfg, before); let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap(); - configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, &bundles); + configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, JDTLS_MIN_JAVA, &bundles); assert!(other.args.is_empty() && other.initialization_options.is_none()); } #[test] - fn jdtls_needs_a_jdk_21_to_run() { + fn jdtls_needs_a_new_enough_jdk_to_run() { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let old = [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"), &old, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]); assert!(!cfg.env.contains_key("JAVA_HOME")); let err = cfg.launch_error.unwrap(); assert!(err.contains("JDK 21 or newer") && err.contains("found JDK 8, 17"), "{err}"); let mut cfg = jdtls(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], JDTLS_MIN_JAVA, &[]); assert!(cfg.launch_error.unwrap().contains("no JDK was found")); // An explicit JAVA_HOME is the user's call. let mut cfg = jdtls(); cfg.env.insert("JAVA_HOME".into(), "/custom".into()); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, &[]); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, JDTLS_MIN_JAVA, &[]); assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/custom", None)); let mixed = [old[0].clone(), Jdk { major: 21, home: PathBuf::from("/jdk21") }]; - assert_eq!(jdtls_runtime(&mixed).unwrap().major, 21); + assert_eq!(jdtls_runtime(&mixed, 21).unwrap().major, 21); + + // A jdtls whose bundles need 25 doesn't start on 21, and says what it needs. + let mut cfg = jdtls(); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &mixed, 25, &[]); + let err = cfg.launch_error.unwrap(); + assert!(err.contains("JDK 25 or newer") && err.contains("found JDK 17, 21"), "{err}"); + } + + #[test] + fn bundle_manifests_give_the_java_jdtls_needs() { + let ee = |m: &str| manifest_min_java(m); + assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-25\n"), Some(25)); + assert_eq!(ee("Bundle-RequiredExecutionEnvironment: JavaSE-17, JavaSE-1.8\n"), Some(8)); + // The capability wins, also when folded over continuation lines. + let folded = "Require-Capability: osgi.service;filter:=\"(x=1)\",osgi.ee;filter:=\"(&(osgi.ee=Ja\r\n \ + vaSE)(version=21))\"\r\nBundle-RequiredExecutionEnvironment: JavaSE-17\r\n"; + assert_eq!(ee(folded), Some(21)); + assert_eq!(ee("Bundle-Name: x\n"), None); + + let home = tempfile::tempdir().unwrap(); + let plugins = home.path().join("plugins"); + std::fs::create_dir(&plugins).unwrap(); + let jar = |name: &str, manifest: &str| { + let mut zip = zip::ZipWriter::new(std::fs::File::create(plugins.join(name)).unwrap()); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + zip.start_file("META-INF/MANIFEST.MF", opts).unwrap(); + std::io::Write::write_all(&mut zip, manifest.as_bytes()).unwrap(); + zip.finish().unwrap(); + }; + jar( + "org.eclipse.jdt.ls.core_1.63.0.jar", + "Require-Capability: osgi.ee;filter:=\"(&(osgi.ee=JavaSE)(version=21))\"\n", + ); + jar("org.eclipse.core.filesystem_1.12.0.jar", "Bundle-RequiredExecutionEnvironment: JavaSE-25\n"); + jar("plain.jar", "Manifest-Version: 1.0\n"); + std::fs::write(plugins.join("notes.txt"), "not a jar").unwrap(); + assert_eq!(bundles_min_java(home.path()), Some(25)); + assert_eq!(bundles_min_java(&home.path().join("missing")), None); } #[test] diff --git a/crates/ide-vcs/src/status.rs b/crates/ide-vcs/src/status.rs index 88057afcb..a98c3d369 100644 --- a/crates/ide-vcs/src/status.rs +++ b/crates/ide-vcs/src/status.rs @@ -87,7 +87,10 @@ pub(crate) fn status(r: &Repo) -> IdeResult> { } } // Persist refreshed stat info so the next status is cheaper (like `git status` does). + // Only when something changed: `write_changes` writes even an empty change set, and + // the rewritten `.git/index` comes back as a VCS change that triggers the next status. if let Some(mut outcome) = iter.into_outcome() + && outcome.has_changes() && let Some(Err(e)) = outcome.write_changes() { tracing::debug!("could not write refreshed index: {e}"); diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index a49f31fd9..fa12939ff 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -145,6 +145,26 @@ fn repository_without_commits() { assert!(repo.status().unwrap().is_empty()); } +#[test] +fn status_leaves_an_up_to_date_index_alone() { + // The watcher reports `.git/index` writes as VCS changes, which refresh the + // status: rewriting it unconditionally made that a loop. + let fx = Fixture::new(); + fx.write("a.txt", "a\n"); + // Older than the index, so it isn't racily clean (that settles after a write or two). + let past = std::time::SystemTime::now() - std::time::Duration::from_secs(60); + std::fs::File::options().write(true).open(fx.path("a.txt")).unwrap().set_modified(past).unwrap(); + fx.commit_all("init"); + let repo = fx.repo(); + repo.status().unwrap(); // may persist refreshed stat info once + let index = fx.path(".git/index"); + let before = std::fs::metadata(&index).unwrap().modified().unwrap(); + for _ in 0..3 { + assert!(repo.status().unwrap().is_empty()); + } + assert_eq!(std::fs::metadata(&index).unwrap().modified().unwrap(), before); +} + #[test] fn status_matrix() { let fx = Fixture::new(); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index bc03077ed..77aed1065 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -246,8 +246,9 @@ impl AppState { if cfg.name == "jdtls" { let bundles: Vec = ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect(); - let jdks = ide_sdk::java::jdtls_jdks(); - ide_sdk::java::configure_jdtls(cfg, server_root, &data_dir, &jdks, &bundles); + let min_java = ide_sdk::java::jdtls_min_java(cfg); + let jdks = ide_sdk::java::jdtls_jdks(min_java); + ide_sdk::java::configure_jdtls(cfg, server_root, &data_dir, &jdks, min_java, &bundles); } })); } diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 45568af18..c4cadfd4a 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -6,7 +6,7 @@ import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri } from "@/platform/ipc"; -import { basename, confirmLeaveProject, openProject, useProject } from "@/platform/project"; +import { basename, confirmLeaveProject, openProject, provideStartupLaunch, useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; import { ask } from "./prompt"; @@ -195,7 +195,7 @@ export function installLaunchHandling(): () => void { if (!isTauri()) return () => {}; let disposed = false; let unlisten: (() => void) | undefined; - void consumeLaunch(); + provideStartupLaunch(consumeLaunch()); void import("@tauri-apps/api/webviewWindow").then(async ({ getCurrentWebviewWindow }) => { const off = await getCurrentWebviewWindow().listen(LAUNCH_EVENT, () => void consumeLaunch()); if (disposed) off(); diff --git a/src/features/welcome/session.test.ts b/src/features/welcome/session.test.ts new file mode 100644 index 000000000..a45f5413b --- /dev/null +++ b/src/features/welcome/session.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const openProject = vi.fn(); + +vi.mock("@/platform/ipc", async (orig) => { + const real = await orig(); + return { + ...real, + isTauri: () => true, + commands: { ...real.commands, settingsGet: async () => ({ general: { reopenLastProject: true } }) }, + }; +}); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ label: "main" }) })); +vi.mock("@/platform/project", async (orig) => { + const real = await orig(); + return { ...real, openProject: (p: string) => openProject(p) }; +}); + +const { reopenLastProject, _resetSessionForTests } = await import("./session"); +const { provideStartupLaunch, useProject } = await import("@/platform/project"); + +beforeEach(() => { + openProject.mockReset(); + useProject.setState({ project: null }); + sessionStorage.clear(); + localStorage.setItem("openidea.session.openProject", "/p/last"); + _resetSessionForTests(); +}); + +describe("reopenLastProject", () => { + it("waits for the command-line project instead of opening the last one too", async () => { + let finishLaunch!: () => void; + provideStartupLaunch( + new Promise((resolve) => { + finishLaunch = () => { + useProject.setState({ project: { root: "/p/launch" } as never }); + resolve(); + }; + }), + ); + const reopened = reopenLastProject(); + await new Promise((r) => setTimeout(r, 0)); + expect(openProject).not.toHaveBeenCalled(); + finishLaunch(); + await reopened; + expect(openProject).not.toHaveBeenCalled(); + }); + + it("reopens the last project when the launch named none", async () => { + provideStartupLaunch(Promise.resolve()); + await reopenLastProject(); + expect(openProject).toHaveBeenCalledWith("/p/last"); + }); +}); diff --git a/src/features/welcome/session.ts b/src/features/welcome/session.ts index 08440d41b..6035159af 100644 --- a/src/features/welcome/session.ts +++ b/src/features/welcome/session.ts @@ -6,7 +6,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { create } from "zustand"; import { commands, isTauri, type Settings } from "@/platform/ipc"; -import { openProject, useProject } from "@/platform/project"; +import { openProject, startupLaunchHandled, useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; const SESSION_KEY = "openidea.session.openProject"; @@ -104,6 +104,8 @@ export async function reopenLastProject(): Promise { /* use the local preference */ } if (!reopenEnabled(settings, useSession.getState().reopenPref)) return; + // A project passed on the command line wins over the last session's. + await startupLaunchHandled(); if (useProject.getState().project) return; await reopen(path); } diff --git a/src/platform/project.ts b/src/platform/project.ts index f23c11fd9..e5888bd64 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -27,6 +27,18 @@ export function confirmLeaveProject(): Promise { return useProject.getState().project && leaveGuard ? leaveGuard() : Promise.resolve(true); } +let startupLaunch: Promise | null = null; + +/** Installs this window's startup launch (the project it was started or created for, features/app/windows.ts). */ +export function provideStartupLaunch(launch: Promise) { + startupLaunch = launch; +} + +/** Settles once the startup launch has opened its project (if any), so a restore doesn't race it. */ +export function startupLaunchHandled(): Promise { + return (startupLaunch ?? Promise.resolve()).catch(() => {}); +} + export async function openProject(path: string): Promise { useProject.setState({ opening: true }); try {