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:
l0ng-ai
2026-10-24 18:26:31 +08:00
committed by GitHub
parent a765959d48
commit 059f25f13e
38 changed files with 486 additions and 93 deletions
+3
View File
@@ -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)]
+4
View File
@@ -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,
+3
View File
@@ -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:?})"),
}))
}
+2
View File
@@ -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),
}
+12 -3
View File
@@ -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);
}
+6 -4
View File
@@ -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")]
+1 -1
View File
@@ -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(()) }
+22 -6
View File
@@ -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
View File
@@ -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);
+20 -1
View File
@@ -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) {
+8
View File
@@ -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.
+1
View File
@@ -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,
+4 -2
View File
@@ -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 (⌘` / ⇧⌘`).
+6 -2
View File
@@ -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(),
};
+6
View File
@@ -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",
+5
View File
@@ -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(),
+9 -6
View File
@@ -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
View File
@@ -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 = {
+1 -1
View File
@@ -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";
}
+2 -1
View File
@@ -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))];
+1
View File
@@ -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();
};
+53 -13
View File
@@ -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);
+49 -9
View File
@@ -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.
+14
View File
@@ -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,
+2
View File
@@ -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}>
+3 -1
View File
@@ -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),
+2
View File
@@ -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…",
+6
View File
@@ -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!",
+2
View File
@@ -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": "新建…",