mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat(java): Separate the jdtls JDK and add Settings for New Projects (#21)
- Languages & Frameworks › Java › Language Server › JDK (languages.java.jdtlsJdk) runs jdtls on its own JDK, like IDEA's JDK for importer; the project keeps compiling and running with the Project JDK. - Toolchain pages get the Global / This project switch (default This project); Global shows and edits the global layer. - File › New Projects Setup › Settings for New Projects… edits only the global layer, which new projects inherit. - Configuration errors (launch_error) show as "Not started" instead of "Crashed". Fixes #18 Fixes #19 Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?})"),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(()) }
|
||||
|
||||
@@ -56,14 +56,30 @@ fn goroot(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
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<Result<crate::java::Jdk, PathBuf>> {
|
||||
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<crate::java::Jdk> {
|
||||
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<Option<crate::java::Jdk>, 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
|
||||
|
||||
+94
-17
@@ -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<Jdk> =
|
||||
project_jdk.into_iter().chain(jdks.iter().filter(|j| Some(*j) != project_jdk)).cloned().collect();
|
||||
let jdks = jdks.as_slice();
|
||||
let mut all: Vec<Jdk> = 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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,6 +48,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
app::close_project,
|
||||
app::languages,
|
||||
app::settings_get,
|
||||
app::settings_get_global,
|
||||
app::settings_set,
|
||||
app::project_roots,
|
||||
app::cancel_request,
|
||||
|
||||
@@ -24,7 +24,8 @@ pub async fn window_new(app: AppHandle) -> IdeResult<String> {
|
||||
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<String>,
|
||||
new_projects: bool,
|
||||
background: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
windows::open_settings_window(&app, &window, page, background)
|
||||
windows::open_settings_window(&app, &window, page, new_projects, background)
|
||||
}
|
||||
|
||||
/// Focuses the next / previous window (⌘` / ⇧⌘`).
|
||||
|
||||
@@ -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::<Vec<_>>(), &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(),
|
||||
};
|
||||
|
||||
@@ -208,6 +208,11 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -39,6 +39,8 @@ pub struct Launch {
|
||||
pub project: Option<ide_core::IpcPath>,
|
||||
/// Settings windows: the page to show.
|
||||
pub settings_page: Option<String>,
|
||||
/// 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<PathBuf>) -> IdeResult<Web
|
||||
config.y = Some(pos.y + CASCADE);
|
||||
config.center = false;
|
||||
}
|
||||
app.state::<WindowsState>().set_launch(&label, Launch { project: project.map(Into::into), settings_page: None });
|
||||
app.state::<WindowsState>().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::<WindowsState>()
|
||||
.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<String>,
|
||||
new_projects: bool,
|
||||
background: Option<String>,
|
||||
) -> 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::<WindowsState>();
|
||||
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);
|
||||
}
|
||||
|
||||
+24
-3
@@ -22,6 +22,11 @@ export const commands = {
|
||||
languages: () => __TAURI_INVOKE<LanguageInfo_Serialize[]>("languages"),
|
||||
/** Effective settings for the window's project, or global settings without one. */
|
||||
settingsGet: () => __TAURI_INVOKE<Settings_Serialize>("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_Serialize>("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<string, IdeError>(__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<null, IdeError>(__TAURI_INVOKE("window_open_settings", { page, background })),
|
||||
windowOpenSettings: (page: string | null, newProjects: boolean, background: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("window_open_settings", { page, newProjects, background })),
|
||||
/** Focuses the next / previous window (⌘` / ⇧⌘`). */
|
||||
windowCycle: (forward: boolean) => __TAURI_INVOKE<void>("window_cycle", { forward }),
|
||||
windowToggleFullscreen: () => typedError<boolean, IdeError>(__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<CloseContext>("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 = {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -126,12 +126,13 @@ export const STATUS_LABEL: Record<ServerStatus, string> = {
|
||||
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))];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
@@ -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(<SettingsDialog />);
|
||||
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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <EmptyState title="Nothing found" icon="search" />;
|
||||
const Body = page?.component;
|
||||
@@ -142,6 +146,12 @@ function PageHost({
|
||||
<T>Saved to .openidea/settings.toml in this project; overrides the global value.</T>
|
||||
</div>
|
||||
)}
|
||||
{globalNote && (
|
||||
<div className={styles.scopeNote}>
|
||||
<Icon name="info" size={14} />
|
||||
<T>Saved to the global settings: used by new projects and by projects without their own value.</T>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.pageBody}>
|
||||
<PageErrorBoundary pageId={pageId}>{Body ? <Body /> : <GroupPage node={node} />}</PageErrorBoundary>
|
||||
</div>
|
||||
@@ -155,12 +165,16 @@ export function SettingsDialog() {
|
||||
return <SettingsDialogInner />;
|
||||
}
|
||||
|
||||
export function useSettingsTitle(): string {
|
||||
return useSettingsDialog((s) => settingsTitle(s.newProjects));
|
||||
}
|
||||
|
||||
function SettingsDialogInner() {
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={closeSettings}
|
||||
title="Settings"
|
||||
title={useSettingsTitle()}
|
||||
width={980}
|
||||
className={styles.dialog}
|
||||
footerStart={<SettingsStatus />}
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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<string | undefined> {
|
||||
interface SettingsLaunch {
|
||||
page?: string;
|
||||
newProjects: boolean;
|
||||
}
|
||||
|
||||
async function takeLaunch(): Promise<SettingsLaunch | null> {
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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<string>(k);
|
||||
const [value, set] = useSetting<string>(k);
|
||||
const id = useId();
|
||||
return (
|
||||
<Row label={label} htmlFor={id} hint={hint}>
|
||||
@@ -59,7 +60,7 @@ function SdkRow({
|
||||
hint,
|
||||
...field
|
||||
}: { k: string; label: string; hint?: string } & Omit<Parameters<typeof SdkField>[0], "value" | "onChange">) {
|
||||
const [value, set] = useProjectSetting<string>(k);
|
||||
const [value, set] = useSetting<string>(k);
|
||||
return (
|
||||
<Row label={label} hint={hint}>
|
||||
<SdkField {...field} value={value} onChange={set} />
|
||||
@@ -71,7 +72,7 @@ function SdkRow({
|
||||
// Node.js
|
||||
|
||||
export function NodePage() {
|
||||
const [pm, setPm] = useProjectSetting<string>("languages.node.packageManager");
|
||||
const [pm, setPm] = useSetting<string>("languages.node.packageManager");
|
||||
const { list } = useDetected("packageManager");
|
||||
const pmOptions = useMemo<SelectOption[]>(
|
||||
() => [
|
||||
@@ -112,10 +113,10 @@ export function NodePage() {
|
||||
// Rust
|
||||
|
||||
export function RustPage() {
|
||||
const [toolchain] = useProjectSetting<string>("languages.rust.toolchain");
|
||||
const [checkOnSave, setCheckOnSave] = useProjectSetting<boolean>("languages.rust.checkOnSave");
|
||||
const [checkCommand, setCheckCommand] = useProjectSetting<string>("languages.rust.checkCommand");
|
||||
const [rustfmtArgs, setRustfmtArgs] = useProjectSetting<string>("languages.rust.rustfmtArgs");
|
||||
const [toolchain] = useSetting<string>("languages.rust.toolchain");
|
||||
const [checkOnSave, setCheckOnSave] = useSetting<boolean>("languages.rust.checkOnSave");
|
||||
const [checkCommand, setCheckCommand] = useSetting<string>("languages.rust.checkCommand");
|
||||
const [rustfmtArgs, setRustfmtArgs] = useSetting<string>("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<string>("languages.java.jdtlsVmArgs");
|
||||
const [vmArgs, setVmArgs] = useSetting<string>("languages.java.jdtlsVmArgs");
|
||||
const id = useId();
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
@@ -234,6 +235,17 @@ export function JavaPage() {
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Language Server">
|
||||
<SdkRow
|
||||
k="languages.java.jdtlsJdk"
|
||||
label="JDK"
|
||||
kind="jdk"
|
||||
emptyLabel="Auto"
|
||||
emptyDetail="the project JDK if new enough, else the first installed JDK that is"
|
||||
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."
|
||||
/>
|
||||
<Row
|
||||
label="JVM options"
|
||||
htmlFor={id}
|
||||
@@ -295,7 +307,7 @@ export function MavenPage() {
|
||||
// TypeScript
|
||||
|
||||
export function TypeScriptPage() {
|
||||
const [version, setVersion] = useProjectSetting<string>("languages.typescript.version");
|
||||
const [version, setVersion] = useSetting<string>("languages.typescript.version");
|
||||
const { list } = useDetected("typeScript");
|
||||
const workspace = list.find((s) => s.source === "workspace");
|
||||
const options = useMemo<SelectOption[]>(() => {
|
||||
|
||||
@@ -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<string>("languages.python.interpreter");
|
||||
const [activate, setActivate] = useProjectSetting<boolean>("languages.python.activateVirtualenv");
|
||||
const [interpreter, setInterpreter] = useSetting<string>("languages.python.interpreter");
|
||||
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const [auto, setAuto] = useState<string | null>(null);
|
||||
const detected = useSdks((s) => s.byKind.python);
|
||||
|
||||
@@ -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<T>(key: string): [T, (v: T) => void] {
|
||||
const hasProject = useProject((s) => !!s.project);
|
||||
|
||||
@@ -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<string, StagedChange>;
|
||||
/** Scope chosen per page (Global / This project). */
|
||||
scopes: Record<string, Scope>;
|
||||
@@ -38,6 +42,8 @@ export const useSettingsDialog = create<SettingsDialogState>(() => ({
|
||||
pageId: DEFAULT_PAGE,
|
||||
query: "",
|
||||
base: validateSettings(undefined),
|
||||
globalBase: validateSettings(undefined),
|
||||
newProjects: false,
|
||||
staged: {},
|
||||
scopes: {},
|
||||
saving: false,
|
||||
@@ -46,19 +52,45 @@ export const useSettingsDialog = create<SettingsDialogState>(() => ({
|
||||
|
||||
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<SettingsDialogState, "base" | "globalBase">;
|
||||
|
||||
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<SettingsDialogState, "base" | "staged">, scope: Scope, key: string): unknown {
|
||||
export function stagedValue(s: Bases & Pick<SettingsDialogState, "staged">, 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<SettingsDialogState, "base" | "staged">): StagedChange[] {
|
||||
export function pendingChanges(s: Bases & Pick<SettingsDialogState, "staged">): 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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,7 +22,7 @@ export function SettingsWindow() {
|
||||
return (
|
||||
<div ref={ref} className={styles.window}>
|
||||
{/* macOS overlays traffic lights on it; Windows is frameless. Linux keeps the OS title bar. */}
|
||||
{ownTitleBar && <TitleBar minimal dialog title="Settings" />}
|
||||
{ownTitleBar && <TitleBar minimal dialog title={title} />}
|
||||
{open && (
|
||||
<>
|
||||
<div className={styles.windowBody}>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": "新建…",
|
||||
|
||||
Reference in New Issue
Block a user