diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 3cfabb35..1db2bd6c 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -106,6 +106,9 @@ pub enum ServerStatus { Crashed, Stopped, Missing, + /// Not started: its configuration can't launch it (e.g. no JDK new enough); + /// the message says what to change, and a settings change retries. + Misconfigured, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 579c8993..ac68f0b0 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -422,6 +422,10 @@ pub struct JavaSettings { /// Project JDK home; empty = per module, the installed JDK matching the /// Java version it targets. pub jdk: String, + /// JDK home the Java language server (jdtls) runs on, apart from the project + /// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough, + /// else the first installed one that is. + pub jdtls_jdk: String, /// Extra JVM options for the Java language server (jdtls), space-separated /// (VS Code's `java.jdt.ls.vmargs`). pub jdtls_vm_args: String, diff --git a/crates/ide-lsp/src/jdtls.rs b/crates/ide-lsp/src/jdtls.rs index a3821b13..03c30b33 100644 --- a/crates/ide-lsp/src/jdtls.rs +++ b/crates/ide-lsp/src/jdtls.rs @@ -179,6 +179,9 @@ impl LspManager { ide_core::event::ServerStatus::Missing => { "jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned() } + ide_core::event::ServerStatus::Misconfigured => { + server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned()) + } _ => format!("jdtls is not ready ({status:?})"), })) } diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index 728e1083..8e4c24b5 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -10,6 +10,8 @@ use std::sync::{Arc, OnceLock}; pub enum LaunchError { /// The server binary is not installed / not on `PATH`. Missing, + /// The configuration can't be launched (`LanguageServerConfig::launch_error`). + Config(String), Failed(String), } diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index bbc8e018..90033735 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -339,7 +339,7 @@ impl LspManager { if let Some(message) = &cfg.launch_error { // A missing binary still asks to be installed first. let missing = !me.launcher.available(&cfg, &root); - return Err(if missing { LaunchError::Missing } else { LaunchError::Failed(message.clone()) }); + return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) }); } me.launcher.launch(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line))) }) @@ -358,6 +358,13 @@ impl LspManager { } return; } + Err(LaunchError::Config(message)) => { + tracing::warn!("{}: {message}", server.name); + if server.finish_start(generation, Conn::Failed) { + server.set_status(&self.sink, ide_core::event::ServerStatus::Misconfigured, Some(message)); + } + return; + } Err(LaunchError::Failed(message)) => { tracing::warn!("{}: {message}", server.name); if server.finish_start(generation, Conn::Failed) { @@ -1604,8 +1611,10 @@ impl LspManager { s.reset_for_restart(); self.ensure_started(&s); } - // Missing / crashed servers retry with the new configuration (e.g. a new PATH). - ide_core::event::ServerStatus::Missing | ide_core::event::ServerStatus::Crashed => { + // Missing / crashed / misconfigured servers retry with the new configuration (e.g. a new PATH). + ide_core::event::ServerStatus::Missing + | ide_core::event::ServerStatus::Crashed + | ide_core::event::ServerStatus::Misconfigured => { s.reset_for_restart(); self.ensure_started(&s); } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index c5f65521..8853ce80 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -710,13 +710,15 @@ async fn launch_error_fails_without_starting_the_server() { h.mgr.set_config_hook(Box::new(|cfg, _| cfg.launch_error = Some("needs JDK 21".into()))); assert!(!h.mgr.serves_path(&h.root.join("a.mk")), "batch inspection skips it"); h.open("a.mk", "x"); - h.wait_status("mock", ServerStatus::Crashed).await; + // Not a crash: nothing ran, the configuration has to change. + h.wait_status("mock", ServerStatus::Misconfigured).await; assert_eq!(h.launcher.state("mock").launches.load(Ordering::SeqCst), 0); - let crashed = h.events.lock().iter().find_map(|e| match e { - IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(), + let reason = h.events.lock().iter().find_map(|e| match e { + IdeEvent::LanguageServer { status: ServerStatus::Misconfigured, message, .. } => message.clone(), _ => None, }); - assert_eq!(crashed.as_deref(), Some("needs JDK 21")); + assert_eq!(reason.as_deref(), Some("needs JDK 21")); + assert!(!h.statuses("mock").contains(&ServerStatus::Crashed)); } #[tokio::test(flavor = "multi_thread")] diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index 582253b2..42f23597 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -124,7 +124,7 @@ async fn rust_analyzer_end_to_end() { mgr.did_open(doc, &main, &LanguageId::new("rust"), 1, MAIN_RS); retry("rust-analyzer ready", Duration::from_secs(60), || { let s = last_status(&events, "rust-analyzer"); - if let Some((ServerStatus::Crashed | ServerStatus::Missing, msg)) = &s { + if let Some((ServerStatus::Crashed | ServerStatus::Missing | ServerStatus::Misconfigured, msg)) = &s { panic!("rust-analyzer failed: {msg:?}"); } async move { matches!(s, Some((ServerStatus::Ready | ServerStatus::Busy, _))).then_some(()) } diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 695b8f90..9e956450 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -56,14 +56,30 @@ fn goroot(root: &Path, s: &Settings) -> Option { setting_path(root, &s.languages.go.goroot) } -/// The project JDK (`languages.java.jdk`): a JDK home, its `bin` or its `bin/java`. +/// A JDK setting: a JDK home, its `bin` or its `bin/java`. +fn jdk_setting(root: &Path, value: &str) -> Option> { + let p = setting_path(root, value)?; + Some(p.ancestors().take(3).find_map(crate::java::jdk_at).ok_or(p)) +} + +/// The project JDK (`languages.java.jdk`). pub fn project_jdk(root: &Path, s: &Settings) -> Option { - let p = setting_path(root, &s.languages.java.jdk)?; - let jdk = p.ancestors().take(3).find_map(crate::java::jdk_at); - if jdk.is_none() { - tracing::warn!("project JDK {} is not a JDK home", p.display()); + jdk_setting(root, &s.languages.java.jdk)? + .inspect_err(|p| tracing::warn!("project JDK {} is not a JDK home", p.display())) + .ok() +} + +/// The JDK chosen to run jdtls (`languages.java.jdtlsJdk`): `Ok(None)` when not +/// set, `Err` (the message) when it is not a JDK home. +pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result, String> { + match jdk_setting(root, &s.languages.java.jdtls_jdk) { + None => Ok(None), + Some(Ok(jdk)) => Ok(Some(jdk)), + Some(Err(p)) => Err(format!( + "The language server JDK {} is not a JDK home (Settings › Languages & Frameworks › Java).", + p.display() + )), } - jdk } /// `mvn` of the configured Maven home (`languages.java.maven.home`): the diff --git a/crates/ide-sdk/src/java.rs b/crates/ide-sdk/src/java.rs index 2eeaf733..e85fc61c 100644 --- a/crates/ide-sdk/src/java.rs +++ b/crates/ide-sdk/src/java.rs @@ -265,7 +265,8 @@ const METADATA_PROPERTY: &str = "java.import.generatesMetadataFilesAtProjectRoot /// 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. The project -/// JDK, when set, is the default runtime and preferred for running the server. +/// JDK, when set, is the default runtime and preferred for running the server; +/// `server_jdk` (the user's choice for the server alone) overrides that. #[allow(clippy::too_many_arguments)] pub fn configure_jdtls( cfg: &mut LanguageServerConfig, @@ -273,6 +274,7 @@ pub fn configure_jdtls( data_dir: &Path, jdks: &[Jdk], project_jdk: Option<&Jdk>, + server_jdk: Option<&Jdk>, min_java: u32, bundles: &[PathBuf], lombok: Option<&Path>, @@ -303,15 +305,24 @@ pub fn configure_jdtls( { cfg.args.push(format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy())); } - let jdks: Vec = - project_jdk.into_iter().chain(jdks.iter().filter(|j| Some(*j) != project_jdk)).cloned().collect(); - let jdks = jdks.as_slice(); + let mut all: Vec = Vec::new(); + for j in project_jdk.into_iter().chain(jdks).chain(server_jdk) { + if !all.contains(j) { + all.push(j.clone()); + } + } + let jdks = all.as_slice(); if !cfg.env.contains_key("JAVA_HOME") { - match jdtls_runtime(jdks, min_java) { - Some(jdk) => { + let runtime = match server_jdk { + Some(jdk) if jdk.major < min_java => Err(server_jdk_too_old_message(jdk, min_java)), + Some(jdk) => Ok(jdk), + None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)), + }; + match runtime { + Ok(jdk) => { cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned()); } - None => cfg.launch_error = Some(missing_runtime_message(jdks, min_java)), + Err(e) => cfg.launch_error = Some(e), } } let mut runtimes = runtimes(jdks); @@ -348,6 +359,15 @@ fn missing_runtime_message(jdks: &[Jdk], min_java: u32) -> String { ) } +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 Settings › Languages & Frameworks › Java, or set it to Auto.", + jdk.home.display(), + jdk.major + ) +} + /// The `java` launcher of a JDK home. pub fn java_executable(home: &Path) -> PathBuf { home.join("bin").join(crate::exe("java")) @@ -450,6 +470,7 @@ mod tests { Path::new("/data"), &jdks, None, + None, JDTLS_MIN_JAVA, &bundles, None, @@ -474,6 +495,7 @@ mod tests { Path::new("/data"), &jdks, None, + None, JDTLS_MIN_JAVA, &bundles, None, @@ -489,6 +511,7 @@ mod tests { Path::new("/data"), &jdks, Some(&project), + None, JDTLS_MIN_JAVA, &[], None, @@ -499,7 +522,17 @@ mod tests { assert_eq!(cfg.env["JAVA_HOME"], "/jdk21b"); let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap(); - configure_jdtls(&mut other, Path::new("/p"), Path::new("/data"), &jdks, None, JDTLS_MIN_JAVA, &bundles, None); + configure_jdtls( + &mut other, + Path::new("/p"), + Path::new("/data"), + &jdks, + None, + None, + JDTLS_MIN_JAVA, + &bundles, + None, + ); assert!(other.args.is_empty() && other.initialization_options.is_none()); } @@ -508,23 +541,23 @@ mod tests { let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; let jar = Path::new("/m2/lombok-1.18.48.jar"); let mut cfg = jdtls(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], Some(jar)); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar)); let agent = format!("--jvm-arg=-javaagent:{}", jar.to_string_lossy()); assert!(cfg.args.contains(&agent), "{:?}", cfg.args); let mut plain = jdtls(); - configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], None); + configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None); assert_ne!(cfg.args[1], plain.args[1], "a separate workspace, so everything is rebuilt with the agent"); let before = cfg.clone(); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], Some(jar)); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar)); assert_eq!(cfg, before, "idempotent"); let mut cfg = jdtls(); cfg.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into()); - configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], Some(jar)); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar)); assert!(!cfg.args.contains(&agent), "the user's agent wins"); let mut own = jdtls(); own.args.push("--jvm-arg=-javaagent:/own/lombok.jar".into()); - configure_jdtls(&mut own, Path::new("/p"), Path::new("/data"), &[], None, JDTLS_MIN_JAVA, &[], None); + configure_jdtls(&mut own, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None); assert_eq!(cfg.args, own.args, "its workspace follows the agent in use"); } @@ -533,19 +566,19 @@ mod tests { 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, None, JDTLS_MIN_JAVA, &[], None); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None); 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"), &[], None, JDTLS_MIN_JAVA, &[], None); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None); 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, None, JDTLS_MIN_JAVA, &[], None); + configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &old, None, None, JDTLS_MIN_JAVA, &[], None); 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") }]; @@ -553,11 +586,55 @@ mod tests { // 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, None, 25, &[], None); + 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}"); } + #[test] + fn jdtls_runs_on_the_chosen_server_jdk() { + let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() }; + let jdk = |major: u32, home: &str| Jdk { major, home: PathBuf::from(home) }; + let installed = [jdk(25, "/jdk25"), jdk(21, "/jdk21"), jdk(17, "/jdk17")]; + let project = jdk(17, "/jdk17"); + let run = |server: Option<&Jdk>, min_java: u32| { + let mut cfg = jdtls(); + configure_jdtls( + &mut cfg, + Path::new("/p"), + Path::new("/data"), + &installed, + Some(&project), + server, + min_java, + &[], + None, + ); + cfg + }; + + // Project on JDK 17, server on the chosen 21: each keeps its own JDK. + let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA); + assert_eq!((cfg.env["JAVA_HOME"].as_str(), cfg.launch_error), ("/jdk21", None)); + let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap(); + let rt = &opts["settings"]["java"]["configuration"]["runtimes"]; + assert_eq!(rt[0], json!({ "name": "JavaSE-17", "path": "/jdk17", "default": true }), "still compiles on 17"); + + // Not set: the first installed JDK new enough, as before. + assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], "/jdk25"); + + // A chosen JDK outside the scanned locations. + let own = jdk(25, "/opt/own-jdk"); + let cfg = run(Some(&own), 25); + assert_eq!(cfg.env["JAVA_HOME"], "/opt/own-jdk"); + + // Too old for this jdtls: the user's choice is not overridden, it is explained. + let cfg = run(Some(&jdk(21, "/jdk21")), 25); + assert!(!cfg.env.contains_key("JAVA_HOME")); + let err = cfg.launch_error.unwrap(); + assert!(err.contains("/jdk21") && err.contains("JDK 25 or newer"), "{err}"); + } + #[test] fn bundle_manifests_give_the_java_jdtls_needs() { let ee = |m: &str| manifest_min_java(m); diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index a090e96c..ae2dce42 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -1,5 +1,5 @@ use crate::apply::{ - apply_to_run_configs, configure_server, maven_command, maven_options, project_jdk, python_interpreter, + apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter, terminal_activation, tsdk, }; use crate::python::parse_pip_list; @@ -202,6 +202,25 @@ fn project_jdk_and_maven_settings() { assert_eq!(project_jdk(tmp.path(), &s), None); } +#[test] +fn jdtls_jdk_setting() { + if cfg!(windows) { + return; // Unix paths + } + let tmp = tempfile::tempdir().unwrap(); + fake_jdk(&tmp.path().join("jdk17")); + let mut s = Settings::default(); + assert_eq!(jdtls_jdk(tmp.path(), &s), Ok(None)); + // Separate from the project JDK, which run configurations keep using. + s.languages.java.jdk = "jdk17".into(); + assert_eq!(jdtls_jdk(tmp.path(), &s), Ok(None)); + s.languages.java.jdtls_jdk = "jdk17/bin".into(); + assert_eq!(jdtls_jdk(tmp.path(), &s).unwrap().map(|j| j.major), Some(17)); + // A chosen path that is not a JDK is an error, not a silent fallback. + s.languages.java.jdtls_jdk = "missing".into(); + assert!(jdtls_jdk(tmp.path(), &s).unwrap_err().contains("is not a JDK home")); +} + #[test] fn terminal_activation_for_project_venv() { if cfg!(windows) { diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index b485a993..4aa1ff34 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -109,6 +109,14 @@ pub fn settings_get(window: Window, state: State<'_, AppState>) -> Settings { } } +/// Defaults plus the global layer, without any project's values: what new +/// projects (and projects without their own value) get. +#[tauri::command] +#[specta::specta] +pub fn settings_get_global(state: State<'_, AppState>) -> Settings { + state.global_settings.effective() +} + /// `value` is JSON text, e.g. `14` or `"dark"`. Every window (for a project /// value: that project's windows) is told (`IdeEvent::SettingsChanged`) so it /// re-reads its effective settings. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index fd3d8e27..86ef2178 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -48,6 +48,7 @@ pub fn builder() -> tauri_specta::Builder { app::close_project, app::languages, app::settings_get, + app::settings_get_global, app::settings_set, app::project_roots, app::cancel_request, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 894ae18b..64725def 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -24,7 +24,8 @@ pub async fn window_new(app: AppHandle) -> IdeResult { windows::create_window(&app, None).map(|w| w.label().to_owned()) } -/// Opens this window's Settings window on `page` (or focuses it and navigates). +/// Opens this window's Settings window on `page` (or focuses it and navigates); +/// `new_projects` edits the global layer only ("Settings for New Projects"). /// `background` (`#rrggbb`) paints a new window until its page has loaded. #[tauri::command] #[specta::specta] @@ -32,9 +33,10 @@ pub async fn window_open_settings( app: AppHandle, window: WebviewWindow, page: Option, + new_projects: bool, background: Option, ) -> IdeResult<()> { - windows::open_settings_window(&app, &window, page, background) + windows::open_settings_window(&app, &window, page, new_projects, background) } /// Focuses the next / previous window (⌘` / ⇧⌘`). diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2701f130..63441fef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -79,7 +79,7 @@ pub fn run() { if let Ok(cwd) = std::env::current_dir() && let Some(path) = windows::cli_path(&std::env::args().collect::>(), &cwd) { - windows.set_launch("main", windows::Launch { project: Some(path.into()), settings_page: None }); + windows.set_launch("main", windows::Launch { project: Some(path.into()), ..Default::default() }); } let menu_state = menu::MenuState::default(); menu::install(app.handle(), &menu_state, &state.recent.list()); @@ -135,7 +135,11 @@ fn on_menu_event(app: &tauri::AppHandle, event: tauri::menu::MenuEvent) { Some(w) => { // Settings is modal for its project window: only app-level items get through, to the parent. let label = match windows::settings_parent(w.label()) { - Some(parent) if matches!(route.action.as_str(), "app.quit" | "file.settings") => parent, + Some(parent) + if matches!(route.action.as_str(), "app.quit" | "file.settings" | "file.newProjectsSettings") => + { + parent + } Some(_) => return, None => w.label(), }; diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index cf2f5b21..02c41f7f 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -208,6 +208,11 @@ pub fn spec(os: Os) -> Vec { act("file.save", "Save All"), sub("fileProperties", "File Properties", vec![act("file.toggleReadOnly", "Make File Read-only / Writable")]), SEP, + sub( + "newProjectsSetup", + "New Projects Setup", + vec![act("file.newProjectsSettings", "Settings for New Projects…")], + ), act("file.invalidateCaches", "Invalidate Caches…"), ]; if !mac { @@ -972,6 +977,7 @@ mod tests { "file.closeProject", "file.save", "file.settings", + "file.newProjectsSettings", "window.new", "window.next", "window.previous", diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index be5a3d1e..402046c4 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -259,12 +259,17 @@ impl AppState { if let Some(problem) = lombok.problem() { tracing::warn!("{problem}"); } + let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| { + cfg.launch_error = Some(e); + None + }); ide_sdk::java::configure_jdtls( cfg, server_root, &data_dir, &jdks, ide_sdk::apply::project_jdk(&root, &effective).as_ref(), + server_jdk.as_ref(), min_java, &bundles, lombok.jar(), diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index de8ff3ad..bf625819 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -39,6 +39,8 @@ pub struct Launch { pub project: Option, /// Settings windows: the page to show. pub settings_page: Option, + /// Settings windows: edit the global layer only ("Settings for New Projects"). + pub new_projects: bool, } #[derive(Default)] @@ -271,7 +273,7 @@ pub fn create_window(app: &AppHandle, project: Option) -> IdeResult().set_launch(&label, Launch { project: project.map(Into::into), settings_page: None }); + app.state::().set_launch(&label, Launch { project: project.map(Into::into), ..Default::default() }); let window = build_project_window(app, config)?; focus(&window); Ok(window) @@ -338,7 +340,7 @@ pub fn open_path(app: &AppHandle, path: &Path) { match free { Some(w) => { app.state::() - .set_launch(w.label(), Launch { project: Some(root.into()), settings_page: None }); + .set_launch(w.label(), Launch { project: Some(root.into()), ..Default::default() }); let _ = app.emit_to(w.label(), LAUNCH_EVENT, ()); focus(&w); } @@ -375,6 +377,7 @@ pub fn open_settings_window( app: &AppHandle, parent: &WebviewWindow, page: Option, + new_projects: bool, background: Option, ) -> IdeResult<()> { // Asked from a settings window itself: navigate it. @@ -382,8 +385,8 @@ pub fn open_settings_window( let label = settings_label(&parent_label); let windows = app.state::(); if let Some(existing) = app.get_webview_window(&label) { - if page.is_some() { - windows.set_launch(&label, Launch { project: None, settings_page: page }); + if page.is_some() || new_projects { + windows.set_launch(&label, Launch { project: None, settings_page: page, new_projects }); let _ = app.emit_to(label.as_str(), LAUNCH_EVENT, ()); } focus(&existing); @@ -406,7 +409,7 @@ pub fn open_settings_window( if let Some(color) = background.and_then(|c| serde_json::from_value(serde_json::Value::String(c)).ok()) { config.background_color = Some(color); } - windows.set_launch(&label, Launch { project: None, settings_page: page }); + windows.set_launch(&label, Launch { project: None, settings_page: page, new_projects }); let window = WebviewWindowBuilder::from_config(app, &config) .and_then(|b| b.parent(&parent)) .and_then(|b| b.build()) @@ -485,7 +488,7 @@ mod tests { #[test] fn launches_are_taken_once() { let st = WindowsState::default(); - st.set_launch("project-1", Launch { project: Some("/p".into()), settings_page: None }); + st.set_launch("project-1", Launch { project: Some("/p".into()), ..Default::default() }); assert_eq!(st.take_launch("project-1").and_then(|l| l.project), Some("/p".into())); assert_eq!(st.take_launch("project-1"), None); } diff --git a/src/bindings.ts b/src/bindings.ts index 8fb2473d..71749fbc 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -22,6 +22,11 @@ export const commands = { languages: () => __TAURI_INVOKE("languages"), /** Effective settings for the window's project, or global settings without one. */ settingsGet: () => __TAURI_INVOKE("settings_get"), + /** + * Defaults plus the global layer, without any project's values: what new + * projects (and projects without their own value) get. + */ + settingsGetGlobal: () => __TAURI_INVOKE("settings_get_global"), /** * `value` is JSON text, e.g. `14` or `"dark"`. Every window (for a project * value: that project's windows) is told (`IdeEvent::SettingsChanged`) so it @@ -429,10 +434,11 @@ export const commands = { /** Opens an empty (welcome) window; returns its label. */ windowNew: () => typedError(__TAURI_INVOKE("window_new")), /** - * Opens this window's Settings window on `page` (or focuses it and navigates). + * Opens this window's Settings window on `page` (or focuses it and navigates); + * `new_projects` edits the global layer only ("Settings for New Projects"). * `background` (`#rrggbb`) paints a new window until its page has loaded. */ - windowOpenSettings: (page: string | null, background: string | null) => typedError(__TAURI_INVOKE("window_open_settings", { page, background })), + windowOpenSettings: (page: string | null, newProjects: boolean, background: string | null) => typedError(__TAURI_INVOKE("window_open_settings", { page, newProjects, background })), /** Focuses the next / previous window (⌘` / ⇧⌘`). */ windowCycle: (forward: boolean) => __TAURI_INVOKE("window_cycle", { forward }), windowToggleFullscreen: () => typedError(__TAURI_INVOKE("window_toggle_fullscreen")), @@ -442,6 +448,8 @@ export const commands = { project: IpcPath | null, /** Settings windows: the page to show. */ settingsPage: string | null, + /** Settings windows: edit the global layer only ("Settings for New Projects"). */ + newProjects: boolean, } | null>("window_take_launch"), windowCloseContext: () => __TAURI_INVOKE("window_close_context"), /** Projects open in project windows, in window order (title bar project widget). */ @@ -2367,6 +2375,12 @@ export type JavaSettings = { * Java version it targets. */ jdk?: string, + /** + * JDK home the Java language server (jdtls) runs on, apart from the project + * JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough, + * else the first installed one that is. + */ + jdtlsJdk?: string, /** * Extra JVM options for the Java language server (jdtls), space-separated * (VS Code's `java.jdt.ls.vmargs`). @@ -2442,6 +2456,8 @@ export type Launch = { project: IpcPath | null, /** Settings windows: the page to show. */ settingsPage: string | null, + /** Settings windows: edit the global layer only ("Settings for New Projects"). */ + newProjects: boolean, }; export type LaunchConfig = { @@ -3381,7 +3397,12 @@ export type ServerInfo = { pid: number | null, }; -export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" | "missing"; +export type ServerStatus = "starting" | "ready" | "busy" | "crashed" | "stopped" | "missing" | +/** + * Not started: its configuration can't launch it (e.g. no JDK new enough); + * the message says what to change, and a settings change retries. + */ +"misconfigured"; /** Exception filters of one session (see [`DebugManager::exception_filters`]). */ export type SessionExceptionFilters = { diff --git a/src/features/code-insight/ServerStatus.tsx b/src/features/code-insight/ServerStatus.tsx index 50357887..4c521518 100644 --- a/src/features/code-insight/ServerStatus.tsx +++ b/src/features/code-insight/ServerStatus.tsx @@ -89,6 +89,6 @@ export function ServerStatus() { function toneOf(status: string): "ok" | "busy" | "error" | "idle" { if (status === "ready") return "ok"; if (status === "starting" || status === "busy") return "busy"; - if (status === "crashed" || status === "missing") return "error"; + if (status === "crashed" || status === "missing" || status === "misconfigured") return "error"; return "idle"; } diff --git a/src/features/code-insight/servers.ts b/src/features/code-insight/servers.ts index aa91614d..38f3ede0 100644 --- a/src/features/code-insight/servers.ts +++ b/src/features/code-insight/servers.ts @@ -126,12 +126,13 @@ export const STATUS_LABEL: Record = { crashed: "Crashed", stopped: "Stopped", missing: "Not installed", + misconfigured: "Not started", }; /** One-line summary for the status bar. */ export function serversSummary(servers: readonly ServerEntry[]): { text: string; tone: "ok" | "busy" | "error" | "idle" } | null { if (!servers.length) return null; - const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing"); + const bad = servers.filter((s) => s.status === "crashed" || s.status === "missing" || s.status === "misconfigured"); const busy = servers.filter((s) => s.status === "starting" || s.status === "busy"); const tone = bad.length ? "error" : busy.length ? "busy" : servers.some((s) => s.status === "ready") ? "ok" : "idle"; const names = [...new Set(servers.map((s) => s.name))]; diff --git a/src/features/code-insight/views.test.ts b/src/features/code-insight/views.test.ts index 3b91b010..5000bd96 100644 --- a/src/features/code-insight/views.test.ts +++ b/src/features/code-insight/views.test.ts @@ -44,6 +44,7 @@ describe("serversSummary", () => { expect(serversSummary([s("rust-analyzer", "ready")])).toEqual({ text: "rust-analyzer", tone: "ok" }); expect(serversSummary([s("vtsls", "ready"), s("eslint", "starting")])).toEqual({ text: "vtsls +1", tone: "busy" }); expect(serversSummary([s("gopls", "missing")])!.tone).toBe("error"); + expect(serversSummary([s("jdtls", "misconfigured")])!.tone).toBe("error"); }); }); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 743701e6..775e66a1 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -4,6 +4,7 @@ import type { Settings } from "@/platform/ipc"; const settingsSet = vi.fn(); const settingsGet = vi.fn(); +const settingsGetGlobal = vi.fn(); vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); @@ -14,6 +15,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { ...real.commands, settingsSet: (...a: unknown[]) => settingsSet(...a), settingsGet: () => settingsGet(), + settingsGetGlobal: () => settingsGetGlobal(), }, }; }); @@ -38,6 +40,7 @@ beforeEach(() => { return { status: "ok", data: current }; }); settingsGet.mockReset().mockImplementation(async () => current); + settingsGetGlobal.mockReset().mockImplementation(async () => current); useProject.setState({ settings: current, project: null }); dispose = activate(); }); @@ -171,6 +174,48 @@ describe("Settings dialog", () => { expect(useKeymap.getState().overrides).toEqual({ "t.dup": ["Ctrl+Alt+Shift+Meta+F9"] }); }); + describe("toolchain settings with a project open", () => { + let global: Settings; + beforeEach(() => { + // Global layer: -Xmx1g; the project overrides it with -Xmx4g. + global = setAt(structuredClone(DEFAULT_SETTINGS) as Settings, "languages.java.jdtlsVmArgs", "-Xmx1g"); + current = setAt(global, "languages.java.jdtlsVmArgs", "-Xmx4g"); + settingsGetGlobal.mockImplementation(async () => global); + useProject.setState({ + settings: current, + project: { root: "/p", name: "p", settings: current } as never, + }); + }); + + it("edit the project first; Global shows and writes the global layer", async () => { + const dialog = await openDialog("languages.java"); + expect(within(dialog).getByRole("radio", { name: "This project" })).toHaveAttribute("aria-checked", "true"); + expect(within(dialog).getByLabelText("JVM options")).toHaveValue("-Xmx4g"); + + fireEvent.click(within(dialog).getByRole("radio", { name: "Global" })); + expect(within(dialog).getByLabelText("JVM options")).toHaveValue("-Xmx1g"); + expect(within(dialog).getByText(/used by new projects/)).toBeInTheDocument(); + + fireEvent.change(within(dialog).getByLabelText("JVM options"), { target: { value: "-Xmx2g" } }); + await act(async () => { + fireEvent.click(within(dialog).getByRole("button", { name: "Apply" })); + }); + expect(settingsSet.mock.calls).toEqual([["global", "languages.java.jdtlsVmArgs", '"-Xmx2g"']]); + }); + + it("Settings for New Projects edits the global layer on every page", async () => { + render(); + await act(async () => { + await executeAction("file.newProjectsSettings"); + }); + const dialog = screen.getByRole("dialog", { name: "Settings for New Projects" }); + expect(within(dialog).queryByRole("radiogroup", { name: "Settings scope" })).toBeNull(); + expect(within(dialog).getByLabelText("JVM options")).toHaveValue("-Xmx1g"); + expect(within(dialog).getByLabelText("Language server JDK")).toBeInTheDocument(); + expect(within(dialog).getByText(/used by new projects/)).toBeInTheDocument(); + }); + }); + it("survives invalid settings values", async () => { act(() => useProject.setState({ diff --git a/src/features/settings/__tests__/languages.test.ts b/src/features/settings/__tests__/languages.test.ts index b2b9e079..b7a05a6d 100644 --- a/src/features/settings/__tests__/languages.test.ts +++ b/src/features/settings/__tests__/languages.test.ts @@ -21,7 +21,7 @@ describe("languages settings", () => { node: { packageManager: "pip" }, rust: { checkCommand: "clippy", checkOnSave: false }, typescript: { version: "" }, - java: { jdtlsVmArgs: " -Xmx2g ", jdk: 21, maven: { home: " /opt/maven " } }, + java: { jdtlsVmArgs: " -Xmx2g ", jdk: 21, jdtlsJdk: " /opt/jdk-25 ", maven: { home: " /opt/maven " } }, }); expect(v.python).toEqual({ interpreter: "/usr/bin/python3", activateVirtualenv: true }); expect(v.node.packageManager).toBe(""); @@ -30,6 +30,7 @@ describe("languages settings", () => { expect(v.typescript.version).toBe("bundled"); expect(v.java.jdtlsVmArgs).toBe("-Xmx2g"); expect(v.java.jdk).toBe(""); + expect(v.java.jdtlsJdk).toBe("/opt/jdk-25"); expect(v.java.maven).toEqual({ home: "/opt/maven", userSettings: "", localRepository: "" }); expect(validateSettings({}).languages).toEqual(DEFAULT_LANGUAGES); }); diff --git a/src/features/settings/__tests__/staging.test.ts b/src/features/settings/__tests__/staging.test.ts index f4465dd3..be3668ae 100644 --- a/src/features/settings/__tests__/staging.test.ts +++ b/src/features/settings/__tests__/staging.test.ts @@ -40,6 +40,27 @@ describe("settings staging", () => { expect(useProject.getState().settings?.editor?.showWhitespace).toBe(false); }); + it("compares the Global scope with the global layer, not the project's values", () => { + const projectValue = setAt(useSettingsDialog.getState().base, "editor.tabSize", 8); + useSettingsDialog.setState({ base: projectValue }); + const s = () => useSettingsDialog.getState(); + expect(stagedValue(s(), "global", "editor.tabSize")).toBe(4); + expect(stagedValue(s(), "project", "editor.tabSize")).toBe(8); + // 8 is new for the global layer even though the project already has it. + stage("editor.general", "global", "editor.tabSize", 8); + expect(pendingChanges(s())).toEqual([{ scope: "global", key: "editor.tabSize", value: 8, page: "editor.general" }]); + stage("editor.general", "project", "editor.tabSize", 8); + expect(pendingChanges(s())).toHaveLength(1); + }); + + it("opens Settings for New Projects on request", () => { + expect(useSettingsDialog.getState().newProjects).toBe(false); + openSettings("languages.java", { newProjects: true }); + expect(useSettingsDialog.getState()).toMatchObject({ newProjects: true, pageId: "languages.java" }); + openSettings(); + expect(useSettingsDialog.getState().newProjects).toBe(false); + }); + it("drops edits that return to the effective value", () => { stage("editor.general", "global", "editor.tabSize", 2); stage("editor.general", "global", "editor.tabSize", 4); diff --git a/src/features/settings/dialog/SettingsDialog.tsx b/src/features/settings/dialog/SettingsDialog.tsx index 28e58d35..4ded82dd 100644 --- a/src/features/settings/dialog/SettingsDialog.tsx +++ b/src/features/settings/dialog/SettingsDialog.tsx @@ -33,6 +33,7 @@ import { selectPage, setPageScope, setQuery, + settingsTitle, useSettingsDialog, type Scope, } from "../model/staging"; @@ -99,10 +100,13 @@ function PageHost({ }) { const pageId = node?.key ?? ""; const hasProject = useProject((s) => !!s.project); + const newProjects = useSettingsDialog((s) => s.newProjects); const spec = page ? builtinSpec(page.id) : undefined; - const projectScope = !!spec?.projectScope && hasProject; - const chosen = useSettingsDialog((s) => s.scopes[pageId]); + const projectScope = !!spec?.projectScope && hasProject && !newProjects; + const chosen = useSettingsDialog((s) => s.scopes[pageId]) ?? spec?.defaultScope; const scope: Scope = projectScope && chosen === "project" ? "project" : "global"; + // Where a project value is the norm, say what the global one is for. + const globalNote = newProjects || (projectScope && scope === "global" && spec?.defaultScope === "project"); const ctx = useMemo(() => ({ pageId, scope }), [pageId, scope]); if (!node) return ; const Body = page?.component; @@ -142,6 +146,12 @@ function PageHost({ Saved to .openidea/settings.toml in this project; overrides the global value. )} + {globalNote && ( +
+ + Saved to the global settings: used by new projects and by projects without their own value. +
+ )}
{Body ? : }
@@ -155,12 +165,16 @@ export function SettingsDialog() { return ; } +export function useSettingsTitle(): string { + return useSettingsDialog((s) => settingsTitle(s.newProjects)); +} + function SettingsDialogInner() { return ( } @@ -283,8 +297,8 @@ export function SettingsLayout() { /** OK / Cancel / Apply, ordered per platform by the dialog primitive. */ function useSettingsButtons(): DialogButton[] { - const { staged, base, saving } = useSettingsDialog(); - const pending = useMemo(() => pendingChanges({ base, staged }).length > 0, [base, staged]); + const { staged, base, globalBase, saving } = useSettingsDialog(); + const pending = useMemo(() => pendingChanges({ base, globalBase, staged }).length > 0, [base, globalBase, staged]); const ok = async () => { if (await applySettings()) closeSettings(); }; diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts index 55085051..d7f440d1 100644 --- a/src/features/settings/index.ts +++ b/src/features/settings/index.ts @@ -7,7 +7,9 @@ * On the desktop Settings open in a native child window (`activateSettingsWindow` * runs there); in the browser build they open as an in-page dialog. * - * `executeAction("file.settings", "keymap")` opens a specific page. + * `executeAction("file.settings", "keymap")` opens a specific page; + * `file.newProjectsSettings` (IDEA's Settings for New Projects) edits the + * global layer only, which new projects inherit. */ import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { registerActions, type Action } from "@/platform/actions"; @@ -16,7 +18,15 @@ import { isPopupOpen, lazyComponent } from "@/ui"; import { loadSettings, useProject } from "@/platform/project"; import { registerOverlay } from "@/workbench/contributions"; import { LazySettingsDialog, loadBuiltinPages, unloadBuiltinPages } from "./dialog/LazySettingsDialog"; -import { closeSettings, openSettings, selectPage, setQuery, useSettingsDialog } from "./model/staging"; +import { + closeSettings, + openSettings, + selectPage, + setNewProjects, + setQuery, + settingsTitle, + useSettingsDialog, +} from "./model/staging"; import { zoomActions } from "./appearanceRuntime"; import { trackRuntimeSettings } from "./runtime"; import { installActionProxies, installSettingsHost } from "./window/bridge"; @@ -27,7 +37,10 @@ export const SettingsWindow = lazyComponent(() => import("./window/SettingsWindo /** Same event as `windows::LAUNCH_EVENT`: a pending launch (page) is waiting. */ const LAUNCH_EVENT = "openidea://launch"; -function settingsActions(open: (page?: string) => void): Action[] { +/** Java's page first: JDK, Maven and the language server are what new projects most often need. */ +const NEW_PROJECTS_PAGE = "languages.java"; + +function settingsActions(open: (page?: string, newProjects?: boolean) => void): Action[] { return [ { id: "file.settings", @@ -37,6 +50,14 @@ function settingsActions(open: (page?: string) => void): Action[] { keywords: ["preferences", "options", "configure"], run: ({ arg }) => open(typeof arg === "string" ? arg : undefined), }, + { + id: "file.newProjectsSettings", + title: "Settings for New Projects…", + category: "File", + icon: "settings", + keywords: ["new projects setup", "default settings", "default project", "default jdk", "global settings"], + run: ({ arg }) => open(typeof arg === "string" ? arg : NEW_PROJECTS_PAGE, true), + }, { id: "settings.keymap", title: "Keymap…", @@ -57,18 +78,18 @@ function settingsActions(open: (page?: string) => void): Action[] { } /** Native Settings window when the backend can make one, else the dialog. */ -async function showSettings(page?: string) { +async function showSettings(page?: string, newProjects = false) { if (isTauri()) { try { const frame = getComputedStyle(document.documentElement).getPropertyValue("--bg-frame").trim(); - await call(commands.windowOpenSettings(page ?? null, frame || null)); + await call(commands.windowOpenSettings(page ?? null, newProjects, frame || null)); return; } catch (e) { console.warn("settings window unavailable, using the dialog", e); } } await loadBuiltinPages(); - openSettings(page); + openSettings(page, { newProjects }); } export function activate(): () => void { @@ -77,7 +98,7 @@ export function activate(): () => void { unloadBuiltinPages, registerOverlay({ id: "settings.dialog", component: LazySettingsDialog }), registerActions(zoomActions()), - registerActions(settingsActions((page) => void showSettings(page))), + registerActions(settingsActions((page, newProjects) => void showSettings(page, newProjects))), isTauri() ? installSettingsHost() : () => {}, ]; return () => disposers.reverse().forEach((d) => d()); @@ -89,21 +110,33 @@ function navigate(page: string | undefined) { selectPage(page); } -async function takeLaunchPage(): Promise { +interface SettingsLaunch { + page?: string; + newProjects: boolean; +} + +async function takeLaunch(): Promise { const launch = await commands.windowTakeLaunch().catch(() => null); - return launch?.settingsPage ?? undefined; + return launch && { page: launch.settingsPage ?? undefined, newProjects: launch.newProjects }; +} + +/** A launch for the open window: its page, and the mode when it asks for new projects. */ +function relaunch(launch: SettingsLaunch | null) { + if (!launch) return; + if (launch.newProjects) setNewProjects(true); + navigate(launch.page); } function listenLaunch(): () => void { let disposed = false; let off: (() => void) | undefined; void getCurrentWebviewWindow() - .listen(LAUNCH_EVENT, () => void takeLaunchPage().then(navigate)) + .listen(LAUNCH_EVENT, () => void takeLaunch().then(relaunch)) .then((u) => { if (disposed) return u(); off = u; // A page requested before we listened: its event is gone, the launch is not. - void takeLaunchPage().then(navigate); + void takeLaunch().then(relaunch); }); return () => { disposed = true; @@ -135,12 +168,17 @@ export function activateSettingsWindow(parent: string): () => void { const disposers = [ trackRuntimeSettings(), unloadBuiltinPages, - registerActions(settingsActions(navigate)), + registerActions(settingsActions((page, newProjects) => relaunch({ page, newProjects: !!newProjects }))), installActionProxies(parent), listenLaunch(), installWindowKeys(), useSettingsDialog.subscribe((s, prev) => { if (prev.open && !s.open) void getCurrentWebviewWindow().close(); + if (s.newProjects !== prev.newProjects) { + void getCurrentWebviewWindow() + .setTitle(settingsTitle(s.newProjects)) + .catch(() => {}); + } }), ]; void (async () => { @@ -148,7 +186,9 @@ export function activateSettingsWindow(parent: string): () => void { const project = await commands.projectCurrent().catch(() => null); if (project) useProject.setState({ project, settings: project.settings }); else await loadSettings().catch((e) => console.warn("cannot load settings", e)); - openSettings(await takeLaunchPage()); + // `listenLaunch` may have taken the launch first and applied it already. + const launch = await takeLaunch(); + openSettings(launch?.page, { newProjects: launch?.newProjects ?? useSettingsDialog.getState().newProjects }); })(); return () => disposers.reverse().forEach((d) => d()); } diff --git a/src/features/settings/languages/LanguagePages.tsx b/src/features/settings/languages/LanguagePages.tsx index f6beda6d..5b028484 100644 --- a/src/features/settings/languages/LanguagePages.tsx +++ b/src/features/settings/languages/LanguagePages.tsx @@ -7,7 +7,8 @@ import { useId, useMemo } from "react"; import { IconButton, Input, type SelectOption } from "@/ui"; import { Row, Section, Select, Toggle } from "../components/Form"; import { choosePath } from "./api"; -import { SdkField, tildify, useDetected, useProjectSetting } from "./components"; +import { useSetting } from "../components/context"; +import { SdkField, tildify, useDetected } from "./components"; import styles from "./Sdk.module.css"; function PathInput({ @@ -26,7 +27,7 @@ function PathInput({ /** Browse for a file instead of a directory. */ file?: boolean; }) { - const [value, set] = useProjectSetting(k); + const [value, set] = useSetting(k); const id = useId(); return ( @@ -59,7 +60,7 @@ function SdkRow({ hint, ...field }: { k: string; label: string; hint?: string } & Omit[0], "value" | "onChange">) { - const [value, set] = useProjectSetting(k); + const [value, set] = useSetting(k); return ( @@ -71,7 +72,7 @@ function SdkRow({ // Node.js export function NodePage() { - const [pm, setPm] = useProjectSetting("languages.node.packageManager"); + const [pm, setPm] = useSetting("languages.node.packageManager"); const { list } = useDetected("packageManager"); const pmOptions = useMemo( () => [ @@ -112,10 +113,10 @@ export function NodePage() { // Rust export function RustPage() { - const [toolchain] = useProjectSetting("languages.rust.toolchain"); - const [checkOnSave, setCheckOnSave] = useProjectSetting("languages.rust.checkOnSave"); - const [checkCommand, setCheckCommand] = useProjectSetting("languages.rust.checkCommand"); - const [rustfmtArgs, setRustfmtArgs] = useProjectSetting("languages.rust.rustfmtArgs"); + const [toolchain] = useSetting("languages.rust.toolchain"); + const [checkOnSave, setCheckOnSave] = useSetting("languages.rust.checkOnSave"); + const [checkCommand, setCheckCommand] = useSetting("languages.rust.checkCommand"); + const [rustfmtArgs, setRustfmtArgs] = useSetting("languages.rust.rustfmtArgs"); const { list } = useDetected("rust"); const selected = list.find((s) => s.path === toolchain); const argsId = useId(); @@ -216,7 +217,7 @@ export function GoPage() { // Java export function JavaPage() { - const [vmArgs, setVmArgs] = useProjectSetting("languages.java.jdtlsVmArgs"); + const [vmArgs, setVmArgs] = useSetting("languages.java.jdtlsVmArgs"); const id = useId(); return (
@@ -234,6 +235,17 @@ export function JavaPage() { />
+ ("languages.typescript.version"); + const [version, setVersion] = useSetting("languages.typescript.version"); const { list } = useDetected("typeScript"); const workspace = list.find((s) => s.source === "workspace"); const options = useMemo(() => { diff --git a/src/features/settings/languages/PythonInterpreterPage.tsx b/src/features/settings/languages/PythonInterpreterPage.tsx index 4fc0b93b..0c11c443 100644 --- a/src/features/settings/languages/PythonInterpreterPage.tsx +++ b/src/features/settings/languages/PythonInterpreterPage.tsx @@ -12,7 +12,8 @@ import { useProject } from "@/platform/project"; import { Section, Toggle } from "../components/Form"; import { effectivePython, listPackages, pip, useSdks, type PythonPackage } from "./api"; import { AddInterpreterDialog } from "./AddInterpreterDialog"; -import { SdkField, tildify, useProjectSetting } from "./components"; +import { useSetting } from "../components/context"; +import { SdkField, tildify } from "./components"; import styles from "./Sdk.module.css"; type Op = "install" | "upgrade" | "uninstall"; @@ -61,8 +62,8 @@ function usePackages(python: string | null) { } export function PythonInterpreterPage() { - const [interpreter, setInterpreter] = useProjectSetting("languages.python.interpreter"); - const [activate, setActivate] = useProjectSetting("languages.python.activateVirtualenv"); + const [interpreter, setInterpreter] = useSetting("languages.python.interpreter"); + const [activate, setActivate] = useSetting("languages.python.activateVirtualenv"); const root = useProject((s) => s.project?.root ?? null); const [auto, setAuto] = useState(null); const detected = useSdks((s) => s.byKind.python); diff --git a/src/features/settings/languages/components.tsx b/src/features/settings/languages/components.tsx index f70383e6..1b3ffc99 100644 --- a/src/features/settings/languages/components.tsx +++ b/src/features/settings/languages/components.tsx @@ -7,8 +7,9 @@ import { choosePath, detectSdks, useSdks, type Sdk, type SdkKind } from "./api"; import styles from "./Sdk.module.css"; /** - * Interpreter / toolchain settings belong to the project (like IDEA's project - * SDK): with a project open they are written to `.openidea/settings.toml`. + * Settings that only make sense per project (Project Structure): with a project + * open they are written to `.openidea/settings.toml`. Toolchain pages instead + * follow the page's Global / This project switch (`defaultScope: "project"`). */ export function useProjectSetting(key: string): [T, (v: T) => void] { const hasProject = useProject((s) => !!s.project); diff --git a/src/features/settings/model/staging.ts b/src/features/settings/model/staging.ts index 8ef53f39..2a65cc79 100644 --- a/src/features/settings/model/staging.ts +++ b/src/features/settings/model/staging.ts @@ -24,6 +24,10 @@ export interface SettingsDialogState { query: string; /** Effective settings when the dialog opened / last applied. */ base: ValidSettings; + /** Defaults + global layer (no project values): what the Global scope edits. */ + globalBase: ValidSettings; + /** "Settings for New Projects": every page edits the global layer. */ + newProjects: boolean; staged: Record; /** Scope chosen per page (Global / This project). */ scopes: Record; @@ -38,6 +42,8 @@ export const useSettingsDialog = create(() => ({ pageId: DEFAULT_PAGE, query: "", base: validateSettings(undefined), + globalBase: validateSettings(undefined), + newProjects: false, staged: {}, scopes: {}, saving: false, @@ -46,19 +52,45 @@ export const useSettingsDialog = create(() => ({ const stageKey = (scope: Scope, key: string) => `${scope}|${key}`; -export function openSettings(pageId?: string) { +export function openSettings(pageId?: string, opts: { newProjects?: boolean } = {}) { const s = useSettingsDialog.getState(); + const base = validateSettings(useProject.getState().settings); useSettingsDialog.setState({ open: true, pageId: pageId ?? s.pageId, query: "", - base: validateSettings(useProject.getState().settings), + base, + globalBase: base, + newProjects: opts.newProjects ?? false, staged: {}, saving: false, error: null, }); + void loadGlobalBase(); } +/** With a project open the effective settings include its values; the Global scope shows the global layer. */ +async function loadGlobalBase() { + if (!isTauri() || !useProject.getState().project) return; + try { + const globalBase = validateSettings(await commands.settingsGetGlobal()); + useSettingsDialog.setState((s) => (s.open ? { globalBase } : {})); + } catch (e) { + console.warn("cannot load global settings", e); + } +} + +/** IDEA's File › New Projects Setup › Settings for New Projects. */ +export const settingsTitle = (newProjects: boolean) => (newProjects ? "Settings for New Projects" : "Settings"); + +export function setNewProjects(newProjects: boolean) { + useSettingsDialog.setState({ newProjects }); +} + +type Bases = Pick; + +const baseFor = (s: Bases, scope: Scope) => (scope === "global" ? s.globalBase : s.base); + export function closeSettings() { useSettingsDialog.setState({ open: false, staged: {}, error: null, saving: false }); } @@ -76,17 +108,17 @@ export function setPageScope(pageId: string, scope: Scope) { } /** Current value of `key` as the page shows it: staged edit, else the effective value. */ -export function stagedValue(s: Pick, scope: Scope, key: string): unknown { +export function stagedValue(s: Bases & Pick, scope: Scope, key: string): unknown { const hit = s.staged[stageKey(scope, key)]; - return hit ? hit.value : getAt(s.base, key); + return hit ? hit.value : getAt(baseFor(s, scope), key); } export function stage(page: string, scope: Scope, key: string, value: unknown) { useSettingsDialog.setState((s) => { const staged = { ...s.staged }; const id = stageKey(scope, key); - // Back to the effective value: nothing to write. - if (deepEqual(value, getAt(s.base, key))) delete staged[id]; + // Back to the scope's current value: nothing to write. + if (deepEqual(value, getAt(baseFor(s, scope), key))) delete staged[id]; else staged[id] = { scope, key, value, page }; return { staged, error: null }; }); @@ -101,9 +133,9 @@ export function resetPage(page: string) { } /** Writes to perform, global layer first so a project value still wins afterwards. */ -export function pendingChanges(s: Pick): StagedChange[] { +export function pendingChanges(s: Bases & Pick): StagedChange[] { return Object.values(s.staged) - .filter((c) => !deepEqual(c.value, getAt(s.base, c.key))) + .filter((c) => !deepEqual(c.value, getAt(baseFor(s, c.scope), c.key))) .sort((a, b) => (a.scope === b.scope ? a.key.localeCompare(b.key) : a.scope === "global" ? -1 : 1)); } @@ -164,11 +196,19 @@ export async function applySettings(deps?: PersistDeps): Promise { useSettingsDialog.setState({ saving: true, error: null }); const { written, error } = await persistChanges(changes, deps); const done = new Set(written); + const base = validateSettings(useProject.getState().settings); useSettingsDialog.setState((st) => ({ saving: false, error, - base: validateSettings(useProject.getState().settings), + base, + // Written global values right away; the backend's copy follows. + globalBase: useProject.getState().project + ? changes + .filter((c) => c.scope === "global" && done.has(stageKey(c.scope, c.key))) + .reduce((g, c) => setAt(g, c.key, c.value), st.globalBase) + : base, staged: Object.fromEntries(Object.entries(st.staged).filter(([k]) => !done.has(k))), })); + await loadGlobalBase(); return error === null; } diff --git a/src/features/settings/pages/execution.tsx b/src/features/settings/pages/execution.tsx index 9bcbf46d..1a3fec90 100644 --- a/src/features/settings/pages/execution.tsx +++ b/src/features/settings/pages/execution.tsx @@ -92,6 +92,8 @@ function TooltipDelayRow() { export const EXECUTION_PAGES: PageSpec[] = [ { id: "build.maven", + projectScope: true, + defaultScope: "project", title: "Maven", group: BUILD_GROUP, // IntelliJ IDEA: Build Tools › Maven, before Console / Debugger. diff --git a/src/features/settings/pages/languages.tsx b/src/features/settings/pages/languages.tsx index b6597b85..2f2ff3e1 100644 --- a/src/features/settings/pages/languages.tsx +++ b/src/features/settings/pages/languages.tsx @@ -13,6 +13,8 @@ export const LANGUAGES_GROUP = "Languages & Frameworks"; export const LANGUAGE_PAGES: PageSpec[] = [ { id: "python.interpreter", + projectScope: true, + defaultScope: "project", title: "Python Interpreter", group: LANGUAGES_GROUP, order: 403, @@ -41,6 +43,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [ }, { id: "languages.go", + projectScope: true, + defaultScope: "project", title: "Go", group: LANGUAGES_GROUP, order: 390, @@ -49,6 +53,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [ }, { id: "languages.java", + projectScope: true, + defaultScope: "project", title: "Java", group: LANGUAGES_GROUP, order: 395, @@ -57,6 +63,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [ "project sdk", "java home", "jdtls", + "language server jdk", + "jdk for importer", "jvm options", "vmargs", "lombok", @@ -67,6 +75,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [ }, { id: "languages.node", + projectScope: true, + defaultScope: "project", title: "Node.js", group: LANGUAGES_GROUP, order: 402, @@ -75,6 +85,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [ }, { id: "languages.rust", + projectScope: true, + defaultScope: "project", title: "Rust", group: LANGUAGES_GROUP, order: 404, @@ -92,6 +104,8 @@ export const LANGUAGE_PAGES: PageSpec[] = [ }, { id: "languages.typescript", + projectScope: true, + defaultScope: "project", title: "TypeScript", group: LANGUAGES_GROUP, order: 405, diff --git a/src/features/settings/pages/spec.ts b/src/features/settings/pages/spec.ts index 61886f2d..03e186ab 100644 --- a/src/features/settings/pages/spec.ts +++ b/src/features/settings/pages/spec.ts @@ -37,6 +37,8 @@ export interface PageSpec { keywords?: string[]; /** Offers the Global / This project scope switch. */ projectScope?: boolean; + /** Scope shown first with a project open (toolchains belong to the project, like IDEA's project SDK). */ + defaultScope?: "global" | "project"; sections?: SectionSpec[]; /** Rendered below the sections (previews). */ footer?: ComponentType; diff --git a/src/features/settings/window/SettingsWindow.tsx b/src/features/settings/window/SettingsWindow.tsx index d1dfe817..e5509490 100644 --- a/src/features/settings/window/SettingsWindow.tsx +++ b/src/features/settings/window/SettingsWindow.tsx @@ -4,7 +4,7 @@ import { OverlayHost } from "@/workbench/OverlayHost"; import { usePlatform } from "@/platform/platformInfo"; import { TitleBar } from "@/workbench/TitleBar"; import { useApplyTheme } from "@/workbench/theme"; -import { SettingsFooter, SettingsLayout } from "../dialog/SettingsDialog"; +import { SettingsFooter, SettingsLayout, useSettingsTitle } from "../dialog/SettingsDialog"; import { useSettingsDialog } from "../model/staging"; import styles from "../dialog/SettingsDialog.module.css"; @@ -12,6 +12,7 @@ export function SettingsWindow() { useApplyTheme(); const ownTitleBar = usePlatform((s) => s.overlayTitleBar || s.framelessTitleBar); const open = useSettingsDialog((s) => s.open); + const title = useSettingsTitle(); const ref = useRef(null); useEffect(() => { @@ -21,7 +22,7 @@ export function SettingsWindow() { return (
{/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */} - {ownTitleBar && } + {ownTitleBar && } {open && ( <>
diff --git a/src/platform/languageSettings.ts b/src/platform/languageSettings.ts index f2743594..1215e2cf 100644 --- a/src/platform/languageSettings.ts +++ b/src/platform/languageSettings.ts @@ -13,6 +13,7 @@ export interface LanguagesValid { typescript: { version: string }; java: { jdk: string; + jdtlsJdk: string; jdtlsVmArgs: string; maven: { home: string; userSettings: string; localRepository: string }; }; @@ -34,7 +35,7 @@ export const DEFAULT_LANGUAGES: LanguagesValid = { rust: { toolchain: "", stdlib: "", checkOnSave: true, checkCommand: "check", rustfmtArgs: "" }, go: { goroot: "", gopath: "" }, typescript: { version: "bundled" }, - java: { jdk: "", jdtlsVmArgs: "", maven: { home: "", userSettings: "", localRepository: "" } }, + java: { jdk: "", jdtlsJdk: "", jdtlsVmArgs: "", maven: { home: "", userSettings: "", localRepository: "" } }, }; export const DEFAULT_PROJECT_STRUCTURE: ProjectStructureValid = { @@ -101,6 +102,7 @@ export function validateLanguages(raw: unknown): LanguagesValid { typescript: { version: text(ts.version, d.typescript.version) || d.typescript.version }, java: { jdk: text(java.jdk, d.java.jdk), + jdtlsJdk: text(java.jdtlsJdk, d.java.jdtlsJdk), jdtlsVmArgs: text(java.jdtlsVmArgs, d.java.jdtlsVmArgs), maven: { home: text(maven.home, d.java.maven.home), diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 3ca20f16..9b82ba2c 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -141,6 +141,8 @@ "menu.editor.unsplit": "Unsplit", "menu.file": "File", "menu.fileProperties": "File Properties", + "menu.newProjectsSetup": "New Projects Setup", + "menu.file.newProjectsSettings": "Settings for New Projects…", "menu.file.toggleReadOnly": "Make File Read-only / Writable", "menu.file.closeProject": "Close Project", "menu.file.new": "New…", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c50c4fc0..acf5e0e6 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1330,6 +1330,8 @@ "Passed as -s (mirrors, servers, proxies).": "以 -s 传入(镜像、服务器、代理)。", "Passed to rustfmt when reformatting, e.g. --edition 2024": "重新格式化时传递给 rustfmt,例如 --edition 2024", "Passed to the JVM running jdtls (java.jdt.ls.vmargs), e.g. -Xmx2g. A Lombok agent is added automatically when the project uses Lombok.": "传递给运行 jdtls 的 JVM (java.jdt.ls.vmargs),例如 -Xmx2g。项目使用 Lombok 时会自动添加 Lombok 代理。", + "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.": "仅用于运行 jdtls(类似 IDEA 的「导入器 JDK」);项目仍使用项目 JDK 编译和运行。jdtls 需要 JDK 21 或更高版本(较新版本需要 25)。jdtls 可执行文件本身在「语言服务器」中设置。", + "Language server JDK": "语言服务器 JDK", "Password": "密码", "Password:": "密码:", "Paste": "粘贴", @@ -1807,6 +1809,9 @@ "Settings pages": "设置页面", "Settings scope": "设置范围", "Settings…": "设置…", + "Settings for New Projects": "新项目的设置", + "Settings for New Projects…": "新项目的设置…", + "Saved to the global settings: used by new projects and by projects without their own value.": "保存到全局设置:新项目以及未单独设置的项目都会使用。", "Shelf": "搁置", "Shell integration": "Shell 集成", "Shell path": "Shell 路径", @@ -2310,6 +2315,7 @@ "or click a line number to set a breakpoint.": "或点击行号设置断点。", "or pick one in the toolbar.": "或在工具栏中选择一个。", "per module, the JDK of its Java version": "按模块使用与其 Java 版本匹配的 JDK", + "the project JDK if new enough, else the first installed JDK that is": "项目 JDK 版本足够时使用它,否则使用第一个版本足够的已安装 JDK", "pip output": "pip 输出", "places the caret. A line with": "放置光标。包含", "println! with Debug": "使用 Debug 的 println!", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 158933fd..c1c4f04b 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -141,6 +141,8 @@ "menu.editor.unsplit": "取消拆分", "menu.file": "文件", "menu.fileProperties": "文件属性", + "menu.newProjectsSetup": "新项目设置", + "menu.file.newProjectsSettings": "新项目的设置…", "menu.file.toggleReadOnly": "将文件设为只读/可写", "menu.file.closeProject": "关闭项目", "menu.file.new": "新建…",