Compare commits

...
Author SHA1 Message Date
l0ng-ai 7f59e00ad7 feat(lsp): Show configuration errors as "Not started", not "Crashed"
A `launch_error` (e.g. the chosen jdtls JDK is too old) never starts the
server, so reporting it as a crash was misleading. It is now its own
status, `Misconfigured` ("Not started"), with the reason as the message;
changing the settings retries as before. Run/Debug report the reason
instead of "jdtls is not ready (Misconfigured)".

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
2026-10-24 18:16:06 +08:00
l0ng-ai f3d86d8ac7 fix(settings): Keep Settings for New Projects when the launch is taken first
The settings window reads its launch twice (the launch listener and the
initial open). When the listener won, the initial open reset the mode
to regular Settings.
2026-10-24 17:38:13 +08:00
l0ng-ai 1332e1f584 feat(java): Separate the jdtls JDK and add Settings for New Projects
- 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. A choice that is too old or not a JDK is reported, not
  silently replaced.
- Toolchain pages (Java, Maven, Python, Node.js, Go, Rust, TypeScript)
  get the Global / This project switch, defaulting to This project. The
  Global scope now shows the global layer instead of the project's
  effective values.
- File › New Projects Setup › Settings for New Projects… edits only the
  global layer, which new projects inherit.

Fixes #18
Fixes #19
2026-10-24 17:19:05 +08:00
38 changed files with 486 additions and 93 deletions

No files matched your search

+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": "新建…",