diff --git a/Cargo.lock b/Cargo.lock index d0cddcb9d..467975ef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4031,6 +4031,7 @@ dependencies = [ "serde", "serde_json", "specta", + "specta-typescript", "thiserror 2.0.21", "tokio-util", ] @@ -4221,6 +4222,7 @@ dependencies = [ "parking_lot", "regex-lite", "serde", + "serde_json", "specta", "toml 0.9.12+spec-1.1.0", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 37bed70fc..e35265ce3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ serde_json = "1" toml = "0.9" toml_edit = "0.25" specta = { version = "=2.0.0-rc.25", features = ["derive"] } +specta-typescript = "0.0.12" # async & concurrency tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs"] } diff --git a/crates/ide-core/Cargo.toml b/crates/ide-core/Cargo.toml index 14cf7ab3c..21618ba11 100644 --- a/crates/ide-core/Cargo.toml +++ b/crates/ide-core/Cargo.toml @@ -10,5 +10,6 @@ dunce.workspace = true serde.workspace = true serde_json.workspace = true specta.workspace = true +specta-typescript.workspace = true thiserror.workspace = true tokio-util.workspace = true diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 435763832..ca94467e6 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -42,6 +42,9 @@ pub struct Settings { /// Language servers / debug adapters at a user-chosen location, by the /// command the language definition launches (`elixir-ls`, `codelldb`). pub tools: BTreeMap, + /// Per language server, by name (`rust-analyzer`): launch and protocol + /// options on top of the language definition, or a server defined here. + pub language_servers: BTreeMap, /// Editor › Inspections profiles. pub inspections: InspectionSettings, /// Editor › Natural Languages › Spelling. @@ -61,6 +64,58 @@ pub struct ToolSettings { pub path: String, } +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct LanguageServerSettings { + /// `false` keeps the server from starting for any language. + pub enabled: bool, + /// Executable to launch instead of the definition's; required for a server + /// defined here (one with `languages`). + pub command: String, + /// Replaces the definition's arguments. + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional)] + pub args: Option>, + /// Added to the server's environment. + pub env: BTreeMap, + /// Merged over the definition's `initializationOptions` (sent once at startup). + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional, type = Option)] + pub initialization_options: Option, + /// Answers the server's `workspace/configuration` requests; changes are pushed + /// with `workspace/didChangeConfiguration` without a restart. + #[serde(skip_serializing_if = "Option::is_none")] + #[specta(optional, type = Option)] + pub settings: Option, + /// Language ids a server defined here serves (`python`); empty for the servers + /// of the language definitions. + pub languages: Vec, + /// Files marking a workspace root for a server defined here (`pyproject.toml`). + pub root_markers: Vec, +} + +impl Default for LanguageServerSettings { + fn default() -> Self { + Self { + enabled: true, + command: String::new(), + args: None, + env: BTreeMap::new(), + initialization_options: None, + settings: None, + languages: Vec::new(), + root_markers: Vec::new(), + } + } +} + +impl LanguageServerSettings { + /// A server defined in settings rather than by a language definition. + pub fn is_custom(&self) -> bool { + !self.languages.is_empty() + } +} + impl Settings { /// The configured executable for `command`, if any (`~/` not yet expanded). pub fn tool_path(&self, command: &str) -> Option<&str> { @@ -89,6 +144,7 @@ impl Default for Settings { actions_on_save: ActionsOnSaveSettings::default(), external_tools: Vec::new(), tools: BTreeMap::new(), + language_servers: BTreeMap::new(), inspections: InspectionSettings::default(), spelling: SpellingSettings::default(), ssh: SshSettings::default(), diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 2e624f25c..b631f6be2 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -113,7 +113,7 @@ pub fn set_settings( host.ssh.set_configs(host.global_settings.effective().ssh.configs); } // A new interpreter / toolchain / server executable restarts the language servers it affects. - if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) { + if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) { for project in affected() { let lsp = project.lsp.clone(); host.runtime.spawn(async move { lsp.reconfigure().await }); diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index db0ab6651..be3d13733 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -26,6 +26,17 @@ fn tools(host: &Host, window: &str) -> IdeResult { Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) } +/// The language definitions plus the servers defined in the window's settings. +fn languages(host: &Host, window: &str) -> Vec { + let settings = match host.project(window) { + Ok(project) => project.settings.effective(), + Err(_) => host.global_settings.effective(), + }; + let mut langs = host.languages.all(); + ide_sdk::apply::with_custom_servers(&mut langs, &settings); + langs +} + fn join_err(e: impl std::fmt::Display) -> IdeError { IdeError::internal(e.to_string()) } @@ -40,7 +51,7 @@ fn reconfigure_servers(host: &Host) { /// Status of every known language server / debug adapter and the languages using it. pub async fn tools_list(host: &Host, window: &str) -> IdeResult> { let tools = tools(host, window)?; - let langs = host.languages.all(); + let langs = languages(host, window); tokio::task::spawn_blocking(move || tools.list(&langs)).await.map_err(join_err) } @@ -54,7 +65,7 @@ pub async fn tools_install( on_event: Channel, ) -> IdeResult { let tools = tools(host, window)?; - let langs = host.languages.all(); + let langs = languages(host, window); let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { tools.install(&name, &langs, &cancel, &|e| { @@ -74,7 +85,7 @@ pub async fn tools_install( /// Removes a tool the IDE installed. pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult { let tools = tools(host, window)?; - let langs = host.languages.all(); + let langs = languages(host, window); let info = tokio::task::spawn_blocking(move || tools.uninstall(&name, &langs)).await.map_err(join_err)??; reconfigure_servers(host); Ok(info) diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 869541659..ee92e219c 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -117,6 +117,7 @@ impl Host { let data_dir = self.data_dir.clone(); lsp.set_config_hook(Box::new(move |cfg, server_root| { let effective = settings.effective(); + ide_sdk::apply::server_launch_overrides(cfg, &effective); ide_sdk::apply::configure_server(cfg, &root, server_root, &effective); if cfg.name == "jdtls" { let bundles: Vec = @@ -149,6 +150,13 @@ impl Host { ); ide_sdk::jdtls::launch_directly(cfg); } + ide_sdk::apply::server_protocol_overrides(cfg, &effective); + })); + } + { + let settings = settings.clone(); + lsp.set_servers_hook(Box::new(move |language, defined| { + ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective()) })); } { diff --git a/crates/ide-language/Cargo.toml b/crates/ide-language/Cargo.toml index 39207b942..12ff2717e 100644 --- a/crates/ide-language/Cargo.toml +++ b/crates/ide-language/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true ide-core.workspace = true ide-text.workspace = true serde.workspace = true +serde_json.workspace = true specta.workspace = true toml.workspace = true tree-sitter.workspace = true diff --git a/crates/ide-language/src/lib.rs b/crates/ide-language/src/lib.rs index 6af20c66a..0a016a6d1 100644 --- a/crates/ide-language/src/lib.rs +++ b/crates/ide-language/src/lib.rs @@ -72,6 +72,10 @@ pub struct LanguageServerConfig { /// JSON passed as `initializationOptions`. #[serde(default)] pub initialization_options: Option, + /// Answers `workspace/configuration`; without it the sections are looked up + /// in `initialization_options`. + #[serde(default)] + pub settings: Option, /// Human hint shown when the binary is missing. #[serde(default)] pub install_hint: Option, diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index c5df73986..35be7f5b8 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -125,13 +125,7 @@ impl ClientHandler { } fn configuration(&self, params: &Value) -> Value { - let options = self - .server - .effective_config() - .initialization_options - .as_ref() - .and_then(|v| serde_json::to_value(v).ok()) - .unwrap_or(Value::Null); + let options = all_settings(&self.server.effective_config()); let items = params.get("items").and_then(Value::as_array).cloned().unwrap_or_default(); Value::Array( items @@ -244,3 +238,29 @@ impl Handler for ClientHandler { } } } + +/// What `workspace/configuration` sections are looked up in: the server's +/// `initializationOptions` with its `settings` merged over them. +pub(crate) fn all_settings(cfg: &ide_language::LanguageServerConfig) -> Value { + let mut out = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null); + if let Some(settings) = &cfg.settings { + merge(&mut out, settings.clone()); + } + out +} + +fn merge(base: &mut Value, layer: Value) { + match (base, layer) { + (Value::Object(base), Value::Object(layer)) => { + for (k, v) in layer { + match base.get_mut(&k) { + Some(existing) => merge(existing, v), + None => { + base.insert(k, v); + } + } + } + } + (base, layer) => *base = layer, + } +} diff --git a/crates/ide-lsp/src/inspect.rs b/crates/ide-lsp/src/inspect.rs index 3aa6c645f..95f4fb626 100644 --- a/crates/ide-lsp/src/inspect.rs +++ b/crates/ide-lsp/src/inspect.rs @@ -65,8 +65,7 @@ impl LspManager { /// run doesn't prompt to install every language it meets. pub fn serves_path(&self, path: &Path) -> bool { let lang = self.languages.detect(path, None); - let Some(config) = self.languages.get(&lang) else { return false }; - config.language_servers.iter().any(|ls| { + self.language_servers(&lang).iter().any(|ls| { let mut cfg = ls.clone(); if let Some(hook) = self.config_hook.read().clone() { hook(&mut cfg, &self.project_root); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 9a0489acf..f003d36df 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -68,6 +68,9 @@ pub type ApplyEditHook = Arc bool + Send + Sync>; /// settings before each launch; receives the server's workspace root. pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync; pub type ConfigHook = Arc; +/// The servers a language uses, from its definition's list (user settings +/// disable some and add others). +pub type ServersFn = dyn Fn(&LanguageId, &[LanguageServerConfig]) -> Vec + Send + Sync; struct Attached { server: Arc, @@ -146,6 +149,7 @@ pub struct LspManager { docs: Mutex>, apply_edit_hook: RwLock>, config_hook: RwLock>, + servers_hook: RwLock>>, candidates_hook: RwLock>>, shutting_down: AtomicBool, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. @@ -264,6 +268,7 @@ impl LspManager { docs: Mutex::new(HashMap::new()), apply_edit_hook: RwLock::new(None), config_hook: RwLock::new(None), + servers_hook: RwLock::new(None), candidates_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), shown_messages: Mutex::new(HashSet::new()), @@ -297,10 +302,9 @@ impl LspManager { /// The servers serving `path`: every regular one, and each `fallback` one /// only while all servers listed before it are missing. fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec> { - let Some(cfg) = self.languages.get(language) else { return Vec::new() }; let mut out = Vec::new(); let mut available_before = false; - for sc in &cfg.language_servers { + for sc in &self.language_servers(language) { if sc.fallback && available_before { continue; } @@ -311,8 +315,18 @@ impl LspManager { out } + /// The definitions of the servers `language` uses. + fn language_servers(&self, language: &LanguageId) -> Vec { + let Some(cfg) = self.languages.get(language) else { return Vec::new() }; + match self.servers_hook.read().clone() { + Some(hook) => hook(language, &cfg.language_servers), + None => cfg.language_servers, + } + } + /// A server turned out to be missing: attach the fallbacks it was hiding - /// to the open documents (and start them). + /// to the open documents (and start them). Also attaches servers newly + /// enabled in settings. fn attach_fallbacks(&self) { let open: Vec<(DocId, LanguageId, PathBuf)> = self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect(); @@ -432,6 +446,14 @@ impl LspManager { if let Err(e) = peer.notify("initialized", json!({})) { tracing::warn!("{}: {e}", server.name); } + // Servers that don't ask with `workspace/configuration` only learn settings this way. + let cfg = server.effective_config(); + if cfg.settings.is_some() + && let Err(e) = + peer.notify("workspace/didChangeConfiguration", json!({ "settings": handler::all_settings(&cfg) })) + { + tracing::debug!("{}: didChangeConfiguration: {e}", server.name); + } let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) }; // Publish readiness and (re)open attached documents atomically w.r.t. did_change. @@ -1778,6 +1800,11 @@ impl LspManager { *self.config_hook.write() = Some(Arc::from(hook)); } + /// Installed by `src-tauri` to apply the servers enabled / defined in settings. + pub fn set_servers_hook(&self, hook: Box) { + *self.servers_hook.write() = Some(Arc::from(hook)); + } + /// The server's base configuration with the settings hook applied. fn configured(&self, server: &Server) -> LanguageServerConfig { let mut cfg = server.config.clone(); @@ -1789,11 +1816,26 @@ impl LspManager { /// Restarts running servers whose settings-derived configuration changed /// (e.g. a new project interpreter); stopped ones pick it up on their next start. + /// A change of `settings` alone reaches a running server without a restart. + /// Servers no longer enabled stop; newly enabled ones attach to open documents. pub async fn reconfigure(&self) { + self.stop_unlisted().await; let servers: Vec> = self.servers.lock().clone(); for s in servers { let next = self.configured(&s); - if next == s.effective_config() { + let current = s.effective_config(); + if next == current { + continue; + } + let without_settings = |c: &LanguageServerConfig| LanguageServerConfig { settings: None, ..c.clone() }; + if let Some(run) = s.running() + && without_settings(&next) == without_settings(¤t) + { + s.set_effective_config(next); + let params = json!({ "settings": handler::all_settings(&s.effective_config()) }); + if let Err(e) = run.peer.notify("workspace/didChangeConfiguration", params) { + tracing::debug!("{}: didChangeConfiguration: {e}", s.name); + } continue; } match s.status() { @@ -1815,6 +1857,96 @@ impl LspManager { ide_core::event::ServerStatus::Stopped => {} } } + self.attach_fallbacks(); + self.detach_shadowed_fallbacks().await; + } + + /// A server enabled again shadows the fallbacks attached in its place: they + /// leave the open documents, and stop once no document uses them. + async fn detach_shadowed_fallbacks(&self) { + let open: Vec<(DocId, LanguageId, PathBuf)> = + self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect(); + let mut shadowed: Vec<(DocId, Vec)> = Vec::new(); + for (id, language, path) in open { + let configs = self.language_servers(&language); + let wanted = self.servers_for(&language, &path); + let docs = self.docs.lock(); + let Some(d) = docs.get(&id) else { continue }; + let gone: Vec = d + .servers + .iter() + .filter(|a| { + !wanted.iter().any(|w| w.id == a.server.id) + && configs.iter().any(|c| c.fallback && c.name == a.server.name) + }) + .map(|a| a.server.id) + .collect(); + if !gone.is_empty() { + shadowed.push((id, gone)); + } + } + if shadowed.is_empty() { + return; + } + let mut detached: Vec> = Vec::new(); + { + let mut docs = self.docs.lock(); + for (id, gone) in shadowed { + let Some(d) = docs.get_mut(&id) else { continue }; + let (leaving, staying): (Vec, Vec) = + d.servers.drain(..).partition(|a| gone.contains(&a.server.id)); + d.servers = staying; + for a in leaving { + if let Some(run) = a.server.running() + && a.opened_gen == Some(run.generation) + && run.caps.read().open_close + && let Err(e) = + run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": d.uri } })) + { + tracing::debug!("{}: didClose: {e}", a.server.name); + } + self.sink.emit(ide_core::IdeEvent::Diagnostics { + path: d.path.clone().into(), + source: a.server.name.clone(), + items: Vec::new(), + }); + if !detached.iter().any(|s| s.id == a.server.id) { + detached.push(a.server); + } + } + } + let in_use = |s: &Arc| docs.values().any(|d| d.servers.iter().any(|a| a.server.id == s.id)); + detached.retain(|s| !in_use(s)); + } + if detached.is_empty() { + return; + } + for s in &detached { + tracing::info!("{}: shadowed by an enabled server, stopping", s.name); + self.stop_instance(s, true).await; + } + self.servers.lock().retain(|s| !detached.iter().any(|d| d.id == s.id)); + } + + /// Stops and forgets the servers that none of their languages uses any more. + async fn stop_unlisted(&self) { + let servers: Vec> = self.servers.lock().clone(); + let unlisted: Vec> = servers + .into_iter() + .filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name))) + .collect(); + if unlisted.is_empty() { + return; + } + for s in &unlisted { + tracing::info!("{}: no longer enabled, stopping", s.name); + self.stop_instance(s, true).await; + } + let gone = |s: &Arc| unlisted.iter().any(|u| u.id == s.id); + self.servers.lock().retain(|s| !gone(s)); + for d in self.docs.lock().values_mut() { + d.servers.retain(|a| !gone(&a.server)); + } } pub fn set_apply_edit_hook(&self, hook: Box bool + Send + Sync>) { diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 98d7af078..9ca8e202a 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -203,6 +203,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig { env: Default::default(), root_markers: vec!["mock.toml".into()], initialization_options: Some(toml::Value::Table(toml::toml! { flag = true nested = { deep = 1 } })), + settings: None, install_hint: Some(format!("install {name}")), connect: None, fallback: false, @@ -795,6 +796,54 @@ async fn launch_error_fails_without_starting_the_server() { assert!(!h.statuses("mock").contains(&ServerStatus::Crashed)); } +#[tokio::test(flavor = "multi_thread")] +async fn settings_changes_reach_servers_without_restarts() { + let h = harness(&[("mock", incremental_caps()), ("extra", incremental_caps())]); + let settings: Arc>> = Default::default(); + let disabled: Arc>> = Default::default(); + { + let settings = settings.clone(); + h.mgr.set_config_hook(Box::new(move |cfg, _| cfg.settings = settings.lock().clone())); + } + { + let disabled = disabled.clone(); + h.mgr.set_servers_hook(Box::new(move |_, defined| { + defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect() + })); + } + h.open("a.mk", "x"); + h.wait_status("mock", ServerStatus::Ready).await; + h.wait_status("extra", ServerStatus::Ready).await; + + // Only `settings` changed: pushed to the running server, which keeps running. + let s = h.launcher.state("mock"); + *settings.lock() = Some(json!({ "nested": { "other": 2 } })); + h.mgr.reconfigure().await; + eventually(|| !s.notified("workspace/didChangeConfiguration").is_empty()).await; + assert_eq!( + s.notified("workspace/didChangeConfiguration")[0], + json!({ "settings": { "flag": true, "nested": { "deep": 1, "other": 2 } } }) + ); + assert_eq!(s.launches.load(Ordering::SeqCst), 1); + let cfg: Value = s + .peer() + .request("workspace/configuration", json!({ "items": [{ "section": "nested.other" }] }), None) + .await + .unwrap(); + assert_eq!(cfg, json!([2])); + + // Disabled: stopped and forgotten; enabled again: attached to the open document. + disabled.lock().push("extra".into()); + h.mgr.reconfigure().await; + assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::>(), ["mock"]); + assert_eq!(h.statuses("extra").last(), Some(&ServerStatus::Stopped)); + disabled.lock().clear(); + h.mgr.reconfigure().await; + h.wait_status("extra", ServerStatus::Ready).await; + eventually(|| h.launcher.state("extra").notified("textDocument/didOpen").len() == 2).await; + assert_eq!(s.launches.load(Ordering::SeqCst), 1); +} + #[tokio::test(flavor = "multi_thread")] async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { // Preferred installed: the alternative is never started. @@ -819,6 +868,40 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() { assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok()); } +#[tokio::test(flavor = "multi_thread")] +async fn a_fallback_leaves_when_the_preferred_server_is_enabled_again() { + let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]); + let disabled: Arc>> = Default::default(); + { + let disabled = disabled.clone(); + h.mgr.set_servers_hook(Box::new(move |_, defined| { + defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect() + })); + } + let (doc, path) = h.open("a.mk", "x"); + let uri = uri_of(&path); + h.wait_status("main", ServerStatus::Ready).await; + assert!(h.statuses("alt").is_empty()); + + // Preferred disabled: the fallback takes over the open document. + disabled.lock().push("main".into()); + h.mgr.reconfigure().await; + h.wait_status("alt", ServerStatus::Ready).await; + eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await; + assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::>(), ["alt"]); + + // Enabled again: the fallback closes the document and stops. + disabled.lock().clear(); + h.mgr.reconfigure().await; + h.wait_status("main", ServerStatus::Ready).await; + eventually(|| h.launcher.state("main").docs.lock().contains_key(&uri)).await; + assert!(!h.launcher.state("alt").notified("textDocument/didClose").is_empty()); + assert_eq!(h.statuses("alt").last(), Some(&ServerStatus::Stopped)); + assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::>(), ["main"]); + assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok()); + assert_eq!(h.launcher.state("alt").requested("textDocument/hover").len(), 0); +} + #[tokio::test(flavor = "multi_thread")] async fn implementations_per_position() { let caps = json!({ "textDocumentSync": 1, "implementationProvider": true }); diff --git a/crates/ide-lsp/tests/real_servers.rs b/crates/ide-lsp/tests/real_servers.rs index e0bba0013..121bb0279 100644 --- a/crates/ide-lsp/tests/real_servers.rs +++ b/crates/ide-lsp/tests/real_servers.rs @@ -70,6 +70,7 @@ async fn missing_binary_is_reported_with_install_hint() { env: Default::default(), root_markers: vec![], initialization_options: None, + settings: None, install_hint: Some("npm i -g nope-ls".into()), connect: None, fallback: false, @@ -198,6 +199,7 @@ async fn typescript_language_server_end_to_end() { env: Default::default(), root_markers: vec!["tsconfig.json".into()], initialization_options: None, + settings: None, install_hint: None, connect: None, fallback: false, @@ -240,6 +242,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option Vec { + let enabled = |name: &str| s.language_servers.get(name).is_none_or(|ls| ls.enabled); + let mut out: Vec = defined.iter().filter(|c| enabled(&c.name)).cloned().collect(); + for (name, ls) in &s.language_servers { + if ls.enabled && ls.languages.iter().any(|l| l == language) && !out.iter().any(|c| &c.name == name) { + out.push(custom_server(name, ls)); + } + } + out +} + +/// Adds the servers defined in settings to the languages they serve (to list +/// them with the tools); disabled ones stay so they can be turned back on. +pub fn with_custom_servers(langs: &mut [LanguageConfig], s: &Settings) { + for (name, ls) in s.language_servers.iter().filter(|(_, ls)| ls.is_custom()) { + for lang in langs.iter_mut().filter(|l| ls.languages.iter().any(|id| id == l.id.as_str())) { + if !lang.language_servers.iter().any(|c| &c.name == name) { + lang.language_servers.push(custom_server(name, ls)); + } + } + } +} + +fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfig { + LanguageServerConfig { + name: name.to_owned(), + command: ls.command.trim().to_owned(), + args: Vec::new(), + env: Default::default(), + root_markers: ls.root_markers.clone(), + initialization_options: None, + settings: None, + install_hint: Some(match ls.command.trim() { + "" => format!("Set the command of `{name}` in Settings › Language Servers"), + command => format!("`{command}` was not found: install it, or set its path in Settings › Language Servers"), + }), + connect: None, + fallback: false, + launch_error: None, + } +} + +/// Command and arguments from Settings › Language Servers; applied before +/// [`configure_server`] so toolchain adjustments (a custom path, JVM args) still follow. +pub fn server_launch_overrides(cfg: &mut LanguageServerConfig, s: &Settings) { + let Some(ls) = s.language_servers.get(&cfg.name) else { return }; + if let Some(command) = non_empty(ls.command.trim()) { + cfg.command = command.to_owned(); + } + if let Some(args) = &ls.args { + cfg.args = args.clone(); + } +} + +/// Environment, `initializationOptions` and `workspace/configuration` settings +/// from Settings › Language Servers; applied last so they win over derived values. +pub fn server_protocol_overrides(cfg: &mut LanguageServerConfig, s: &Settings) { + let Some(ls) = s.language_servers.get(&cfg.name) else { return }; + cfg.env.extend(ls.env.iter().map(|(k, v)| (k.clone(), v.clone()))); + if let Some(options) = &ls.initialization_options { + merge_init_options(cfg, options.clone()); + } + if let Some(settings) = &ls.settings { + let mut current = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({})); + merge(&mut current, settings.clone()); + cfg.settings = Some(current); + } +} + /// vue-language-server loads TypeScript as a library (`--tsdk`, else its own /// `require('typescript')`): the selected version, else its own, else the /// project's — whichever still has the language-service API. diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 57b899f18..fb6699609 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -28,6 +28,7 @@ fn server(name: &str) -> LanguageServerConfig { env: BTreeMap::new(), root_markers: vec![], initialization_options: None, + settings: None, install_hint: None, connect: None, fallback: false, @@ -205,6 +206,59 @@ fn project_jdk_and_maven_settings() { assert_eq!(project_jdk(tmp.path(), &s), None); } +#[test] +fn language_server_settings_disable_add_and_override() { + use crate::apply::{language_servers, server_launch_overrides, server_protocol_overrides, with_custom_servers}; + use ide_core::settings::LanguageServerSettings; + use serde_json::json; + + let mut s = Settings::default(); + s.language_servers.insert("pylsp".into(), LanguageServerSettings { enabled: false, ..Default::default() }); + s.language_servers.insert( + "ruff".into(), + LanguageServerSettings { + command: "ruff".into(), + args: Some(vec!["server".into()]), + languages: vec!["python".into()], + root_markers: vec!["pyproject.toml".into()], + ..Default::default() + }, + ); + let defined = [server("basedpyright"), server("pylsp")]; + let names = |v: Vec| v.into_iter().map(|c| c.name).collect::>(); + assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "ruff"]); + assert_eq!(names(language_servers("rust", &[server("rust-analyzer")], &s)), ["rust-analyzer"]); + + // Listed with the tools even while disabled. + let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all(); + with_custom_servers(&mut langs, &s); + let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap(); + let mut ruff = python.language_servers.iter().find(|c| c.name == "ruff").unwrap().clone(); + assert_eq!(ruff.root_markers, ["pyproject.toml"]); + server_launch_overrides(&mut ruff, &s); + assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", &["server".to_string()][..])); + + s.language_servers.insert( + "rust-analyzer".into(), + LanguageServerSettings { + env: BTreeMap::from([("RA_LOG".into(), "info".into())]), + initialization_options: Some(json!({ "check": { "command": "clippy" } })), + settings: Some(json!({ "rust-analyzer": { "cargo": { "features": "all" } } })), + ..Default::default() + }, + ); + let mut ra = server("rust-analyzer"); + ra.args = vec!["--stdio".into()]; + ra.initialization_options = Some(toml::Value::Table(toml::toml! { checkOnSave = false })); + server_launch_overrides(&mut ra, &s); + assert_eq!(ra.args, ["--stdio"], "no args setting keeps the definition's"); + server_protocol_overrides(&mut ra, &s); + assert_eq!(ra.env["RA_LOG"], "info"); + let init = serde_json::to_value(ra.initialization_options.unwrap()).unwrap(); + assert_eq!(init, json!({ "checkOnSave": false, "check": { "command": "clippy" } })); + assert_eq!(ra.settings, Some(json!({ "rust-analyzer": { "cargo": { "features": "all" } } }))); +} + #[test] fn jdtls_jdk_setting() { if cfg!(windows) { diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index bd0e52bad..6ac47b35e 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -72,6 +72,10 @@ pub struct ToolInfo { pub homepage: Option, /// Why the executable chosen in settings cannot be used (status is then `Missing`). pub custom_error: Option, + /// A language server's arguments from its language definition. + pub default_args: Vec, + /// A language server's `initializationOptions` from its language definition, as JSON text. + pub default_initialization_options: Option, } /// Streamed install progress. @@ -209,6 +213,8 @@ struct Usage { command: Option, kind: Option, install_hint: Option, + args: Vec, + initialization_options: Option, } fn usages(langs: &[LanguageConfig]) -> BTreeMap { @@ -243,6 +249,14 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap { let languages = map.get(*host).map(|u| u.languages.clone()).unwrap_or_default(); map.entry(plugin.to_string()).or_default().languages = languages; } + // A server's defaults come from the first definition using it. + for s in langs.iter().flat_map(|l| &l.language_servers).rev() { + if let Some(u) = map.get_mut(&s.name) { + u.args = s.args.clone(); + u.initialization_options = + s.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).map(|v| v.to_string()); + } + } map } @@ -455,6 +469,8 @@ impl Tools { install_hint, homepage: None, custom_error: None, + default_args: u.args.clone(), + default_initialization_options: u.initialization_options.clone(), }) .map(|info| self.apply_custom(info, None)); }; @@ -477,6 +493,8 @@ impl Tools { install_hint, homepage: Some(spec.homepage.to_string()), custom_error: None, + default_args: usage.map(|u| u.args.clone()).unwrap_or_default(), + default_initialization_options: usage.and_then(|u| u.initialization_options.clone()), }) .map(|info| self.apply_custom(info, Some(spec))) } diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index b7afa4b12..f814cb393 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -106,20 +106,33 @@ fn json_to_toml(value: &Json) -> IdeResult { }) } -/// Sets (or removes, for `None`) a dotted key in a TOML document. -fn set_in_document(doc: &mut DocumentMut, segments: &[&str], value: Option) { +/// Sets (or removes, for `None`) a dotted key in a TOML document. A new +/// `map_entry` object becomes a `[languageServers.ruff]` table rather than an +/// inline table under an explicit `[languageServers]` header. +fn set_in_document(doc: &mut DocumentMut, segments: &[&str], value: Option, map_entry: bool) { let (last, parents) = segments.split_last().expect("non-empty key"); let mut table: &mut dyn TableLike = doc.as_table_mut(); + let mut in_inline = false; for seg in parents { if !table.get(seg).is_some_and(Item::is_table_like) { if value.is_none() { return; } - table.insert(seg, Item::Table(Table::new())); + // Implicit: no `[languageServers]` header above `[languageServers.ruff]`. + let mut new = Table::new(); + new.set_implicit(true); + table.insert(seg, Item::Table(new)); } - table = table.get_mut(seg).and_then(Item::as_table_like_mut).expect("table just ensured"); + let item = table.get_mut(seg).expect("table just ensured"); + in_inline |= item.is_inline_table(); + table = item.as_table_like_mut().expect("table just ensured"); } match value { + Some(toml_edit::Value::InlineTable(t)) + if map_entry && !in_inline && !matches!(table.get(last), Some(Item::Value(_))) => + { + table.insert(last, Item::Table(t.into_table())); + } Some(v) => match table.get_mut(last) { // Keep the key's decor (comments, spacing) when replacing a value. Some(Item::Value(existing)) => { @@ -188,9 +201,19 @@ impl SettingsStore { if segments.iter().any(|s| s.is_empty()) { return Err(IdeError::invalid(format!("invalid settings key {key:?}"))); } - let mut cursor = &defaults_json(); - for seg in &segments { - cursor = cursor.get(seg).ok_or_else(|| IdeError::invalid(format!("unknown setting {key:?}")))?; + let defaults = defaults_json(); + let mut cursor = &defaults; + let mut map_entry = false; + for (i, seg) in segments.iter().enumerate() { + match cursor.get(seg) { + Some(next) => cursor = next, + // An entry of a map setting (`languageServers.rust-analyzer`): the merge below validates it. + None if cursor.as_object().is_some_and(|m| m.is_empty()) => { + map_entry = i + 1 == segments.len(); + break; + } + None => return Err(IdeError::invalid(format!("unknown setting {key:?}"))), + } } let text = match std::fs::read_to_string(&file) { @@ -202,7 +225,7 @@ impl SettingsStore { .parse() .map_err(|e| IdeError::invalid(format!("{} is not valid TOML; fix it first: {e}", file.display())))?; let new_value = if value.is_null() { None } else { Some(json_to_toml(&value)?) }; - set_in_document(&mut doc, &segments, new_value); + set_in_document(&mut doc, &segments, new_value, map_entry); let new_text = doc.to_string(); // Validate the resulting stack before touching the disk. @@ -350,6 +373,47 @@ mod tests { let s = store.set(SettingsScope::Global, "appearance.statusBarWidgets", json!({"editor.caret": false})).unwrap(); assert_eq!(s.appearance.status_bar_widgets.len(), 1); + // One entry of a map setting, set and removed on its own. + let s = store + .set( + SettingsScope::Global, + "languageServers.rust-analyzer", + json!({"args": ["--log"], "initializationOptions": {"check": {"command": "clippy"}}}), + ) + .unwrap(); + let ra = &s.language_servers["rust-analyzer"]; + assert!(ra.enabled); + assert_eq!(ra.args.as_deref(), Some(&["--log".to_string()][..])); + assert_eq!(ra.initialization_options, Some(json!({"check": {"command": "clippy"}}))); + assert_eq!( + store + .set(SettingsScope::Global, "languageServers.rust-analyzer", json!({"enabled": "no"})) + .unwrap_err() + .kind, + ide_core::error::ErrorKind::InvalidInput + ); + let s = store.set(SettingsScope::Global, "languageServers.rust-analyzer", json!(null)).unwrap(); + assert!(s.language_servers.is_empty()); + // New parent tables stay implicit: no empty `[languageServers]` header. + let fresh = tmp.path().join("fresh"); + let s = SettingsStore::load(&fresh, None) + .set(SettingsScope::Global, "languageServers.ruff.languages", json!(["python"])) + .unwrap(); + assert!(s.language_servers["ruff"].is_custom()); + let text = std::fs::read_to_string(fresh.join("settings.toml")).unwrap(); + assert_eq!(text, "[languageServers.ruff]\nlanguages = [\"python\"]\n"); + // A whole new entry: a table of its own, still under an implicit parent. + let added = tmp.path().join("added"); + let s = SettingsStore::load(&added, None) + .set( + SettingsScope::Global, + "languageServers.ruff", + json!({"command": "ruff", "args": ["server"], "languages": ["python"]}), + ) + .unwrap(); + assert_eq!(s.language_servers["ruff"].command, "ruff"); + let text = std::fs::read_to_string(added.join("settings.toml")).unwrap(); + assert_eq!(text, "[languageServers.ruff]\nargs = [\"server\"]\ncommand = \"ruff\"\nlanguages = [\"python\"]\n"); // Project layer is created on demand and wins. let s = store.set(SettingsScope::Project, "editor.fontSize", json!(12)).unwrap(); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index be5f963e0..4fcbc9e78 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" tracing-appender = "0.2" tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } -specta-typescript = "0.0.12" +specta-typescript.workspace = true specta.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/src/bindings.ts b/src/bindings.ts index 2114830d4..f8e6e914f 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -3433,6 +3433,64 @@ export type LanguageInfo_Serialize = { firstLine?: string | null, }; +export type LanguageServerSettings = LanguageServerSettings_Serialize | LanguageServerSettings_Deserialize; + +export type LanguageServerSettings_Deserialize = { + /** `false` keeps the server from starting for any language. */ + enabled?: boolean, + /** + * Executable to launch instead of the definition's; required for a server + * defined here (one with `languages`). + */ + command?: string, + /** Replaces the definition's arguments. */ + args?: string[] | null, + /** Added to the server's environment. */ + env?: { [key in string]: string }, + /** Merged over the definition's `initializationOptions` (sent once at startup). */ + initializationOptions?: unknown | null, + /** + * Answers the server's `workspace/configuration` requests; changes are pushed + * with `workspace/didChangeConfiguration` without a restart. + */ + settings?: unknown | null, + /** + * Language ids a server defined here serves (`python`); empty for the servers + * of the language definitions. + */ + languages?: string[], + /** Files marking a workspace root for a server defined here (`pyproject.toml`). */ + rootMarkers?: string[], +}; + +export type LanguageServerSettings_Serialize = { + /** `false` keeps the server from starting for any language. */ + enabled: boolean, + /** + * Executable to launch instead of the definition's; required for a server + * defined here (one with `languages`). + */ + command: string, + /** Replaces the definition's arguments. */ + args?: string[] | null, + /** Added to the server's environment. */ + env: { [key in string]: string }, + /** Merged over the definition's `initializationOptions` (sent once at startup). */ + initializationOptions?: unknown | null, + /** + * Answers the server's `workspace/configuration` requests; changes are pushed + * with `workspace/didChangeConfiguration` without a restart. + */ + settings?: unknown | null, + /** + * Language ids a server defined here serves (`python`); empty for the servers + * of the language definitions. + */ + languages: string[], + /** Files marking a workspace root for a server defined here (`pyproject.toml`). */ + rootMarkers: string[], +}; + export type LanguagesSettings = { python?: PythonSettings, node?: NodeSettings, @@ -4859,6 +4917,11 @@ export type Settings_Deserialize = { * command the language definition launches (`elixir-ls`, `codelldb`). */ tools?: { [key in string]: ToolSettings }, + /** + * Per language server, by name (`rust-analyzer`): launch and protocol + * options on top of the language definition, or a server defined here. + */ + languageServers?: { [key in string]: LanguageServerSettings_Deserialize }, /** Editor › Inspections profiles. */ inspections?: InspectionSettings, /** Editor › Natural Languages › Spelling. */ @@ -4907,6 +4970,11 @@ export type Settings_Serialize = { * command the language definition launches (`elixir-ls`, `codelldb`). */ tools: { [key in string]: ToolSettings }, + /** + * Per language server, by name (`rust-analyzer`): launch and protocol + * options on top of the language definition, or a server defined here. + */ + languageServers: { [key in string]: LanguageServerSettings_Serialize }, /** Editor › Inspections profiles. */ inspections: InspectionSettings, /** Editor › Natural Languages › Spelling. */ @@ -5406,6 +5474,10 @@ export type ToolInfo = { homepage: string | null, /** Why the executable chosen in settings cannot be used (status is then `Missing`). */ customError: string | null, + /** A language server's arguments from its language definition. */ + defaultArgs: string[], + /** A language server's `initializationOptions` from its language definition, as JSON text. */ + defaultInitializationOptions: string | null, }; export type ToolKind = "languageServer" | "debugger" | diff --git a/src/features/settings/__tests__/staging.test.ts b/src/features/settings/__tests__/staging.test.ts index be3668ae0..67542ccd4 100644 --- a/src/features/settings/__tests__/staging.test.ts +++ b/src/features/settings/__tests__/staging.test.ts @@ -53,6 +53,33 @@ describe("settings staging", () => { expect(pendingChanges(s())).toHaveLength(1); }); + it("stages entries of map settings and fields inside them", () => { + const page = "languages.servers"; + const s = () => useSettingsDialog.getState(); + // A field of an entry nobody has set yet; reading the map shows it. + stage(page, "global", "languageServers.rust-analyzer.args", ["--log"]); + expect(stagedValue(s(), "global", "languageServers")).toEqual({ "rust-analyzer": { args: ["--log"] } }); + // Cleared again: nothing to write. + stage(page, "global", "languageServers.rust-analyzer.args", null); + expect(pendingChanges(s())).toEqual([]); + + // A new entry, then an edit inside it: one write of the whole entry. + stage(page, "global", "languageServers.ruff", { command: "ruff", languages: ["python"] }); + stage(page, "global", "languageServers.ruff.args", ["server"]); + stage(page, "global", "languageServers.ruff.command", null); + expect(stagedValue(s(), "global", "languageServers.ruff.args")).toEqual(["server"]); + expect(pendingChanges(s()).map((c) => [c.key, c.value])).toEqual([ + ["languageServers.ruff", { languages: ["python"], args: ["server"] }], + ]); + + // Removing an entry drops the edits staged inside it. + stage(page, "global", "languageServers.gopls.env", { GOFLAGS: "-mod=mod" }); + stage(page, "global", "languageServers.gopls", null); + stage(page, "global", "languageServers.ruff", null); + expect(pendingChanges(s())).toEqual([]); + expect(stagedValue(s(), "global", "languageServers")).toEqual({}); + }); + it("opens Settings for New Projects on request", () => { expect(useSettingsDialog.getState().newProjects).toBe(false); openSettings("languages.java", { newProjects: true }); diff --git a/src/features/settings/components/context.ts b/src/features/settings/components/context.ts index 96c0be058..9e762f663 100644 --- a/src/features/settings/components/context.ts +++ b/src/features/settings/components/context.ts @@ -1,5 +1,6 @@ /** Page context for the form components: which page / scope they edit and the search query. */ -import { createContext, useCallback, useContext } from "react"; +import { createContext, useCallback, useContext, useMemo } from "react"; +import type { SettingsForm } from "@/workbench/contributions"; import { stage, stagedValue, useSettingsDialog, type Scope } from "../model/staging"; import { labelMatches } from "../model/pages"; @@ -23,6 +24,25 @@ export function useSetting(key: string, scopeOverride?: Scope): [T, (value: T return [value, set]; } +/** `stagedValue` for components; recomputed only when the base or the staged edits change. */ +export function useStagedValue(scope: Scope, key: string): unknown { + const base = useSettingsDialog((s) => (scope === "global" ? s.globalBase : s.base)); + const staged = useSettingsDialog((s) => s.staged); + return useMemo(() => stagedValue({ base, globalBase: base, staged }, scope, key), [base, staged, scope, key]); +} + +/** The form contributed pages edit through (`useSettingsForm`). */ +export function useSettingsFormFor(pageId: string, scope: Scope): SettingsForm { + return useMemo( + () => ({ + scope, + useValue: (key: string) => useStagedValue(scope, key), + set: (key: string, value: unknown) => stage(pageId, scope, key, value), + }), + [pageId, scope], + ); +} + export function useSearchQuery(): string { return useSettingsDialog((s) => s.query); } diff --git a/src/features/settings/dialog/SettingsDialog.tsx b/src/features/settings/dialog/SettingsDialog.tsx index 9b562d546..0fcd87345 100644 --- a/src/features/settings/dialog/SettingsDialog.tsx +++ b/src/features/settings/dialog/SettingsDialog.tsx @@ -9,7 +9,7 @@ import { useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { useProject } from "@/platform/project"; -import { useSettingsPages, type SettingsPageContribution } from "@/workbench/contributions"; +import { SettingsFormContext, useSettingsPages, type SettingsPageContribution } from "@/workbench/contributions"; import { Dialog, DialogButtons, @@ -22,7 +22,7 @@ import { type TreeHandle, T, } from "@/ui"; -import { PageContext } from "../components/context"; +import { PageContext, useSettingsFormFor } from "../components/context"; import { SearchLabel } from "../components/Form"; import { breadcrumb, buildPageTree, filterPageTree, flatten, groupKey, type PageNode } from "../model/pages"; import { @@ -102,60 +102,63 @@ function PageHost({ const hasProject = useProject((s) => !!s.project); const newProjects = useSettingsDialog((s) => s.newProjects); const spec = page ? builtinSpec(page.id) : undefined; - const projectScope = !!spec?.projectScope && hasProject && !newProjects; + const projectScope = !!(spec?.projectScope ?? page?.projectScope) && hasProject && !newProjects; const scopeKey = spec?.scopeGroup ?? pageId; const chosen = useSettingsDialog((s) => s.scopes[scopeKey]) ?? 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]); + const form = useSettingsFormFor(pageId, scope); if (!node) return ; const Body = page?.component; return ( -
-
- {crumbs.map((c, i) => ( - - {i > 0 && } - - {c} + +
+
+ {crumbs.map((c, i) => ( + + {i > 0 && } + + {c} + - - ))} + ))} +
+ {dirty && ( + + )} + {projectScope && !spec?.ownScopeSwitch && } + + + +
- {dirty && ( - + {scope === "project" && ( +
+ + Saved to .openidea/settings.toml in this project; overrides the global value. +
)} - {projectScope && !spec?.ownScopeSwitch && } - - - - -
- {scope === "project" && ( -
- - Saved to .openidea/settings.toml in this project; overrides the global value. + {globalNote && ( +
+ + Saved to the global settings: used by new projects and by projects without their own value. +
+ )} +
+ {Body ? : }
- )} - {globalNote && ( -
- - Saved to the global settings: used by new projects and by projects without their own value. -
- )} -
- {Body ? : } -
+ ); } diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index 34505b45a..af2904a17 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -5,12 +5,8 @@ * back to the default (mirrors `ide-core::settings` defaults). */ import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; -import { - validateFileTypeOverrides, - validateFileTypes, - type FileTypeAssociations, -} from "@/platform/fileTypes"; -import type { Settings } from "@/platform/ipc"; +import { validateFileTypeOverrides, validateFileTypes, type FileTypeAssociations } from "@/platform/fileTypes"; +import type { LanguageServerSettings, Settings } from "@/platform/ipc"; import { DEFAULT_LANGUAGES, DEFAULT_PROJECT_STRUCTURE, @@ -86,8 +82,15 @@ export interface ValidSettings extends WorkflowSettings { inspections: InspectionSettingsValid; /** Editor › Natural Languages › Spelling. */ spelling: SpellingValid; + /** Executables chosen for language servers / debug adapters, by command. */ + tools: Record; + /** Settings › Language Servers, by server name (as the backend sends them). */ + languageServers: Record; } +/** One `languageServers.` entry; every field optional, like in settings.toml. */ +export type LanguageServerEntry = Partial; + export interface SpellingValid { ideWords: string[]; projectWords: string[]; @@ -144,6 +147,8 @@ export const DEFAULT_SETTINGS: ValidSettings = { projectStructure: DEFAULT_PROJECT_STRUCTURE, inspections: DEFAULT_INSPECTIONS, spelling: { ideWords: [], projectWords: [], customDictionaries: [] }, + tools: {}, + languageServers: {}, ...DEFAULT_WORKFLOW, }; @@ -244,6 +249,15 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { projectStructure: validateProjectStructure(root.projectStructure), inspections: validateInspections(root.inspections), spelling: validateSpelling(root.spelling), + tools: Object.fromEntries( + Object.entries(obj(root.tools)).flatMap(([cmd, t]) => { + const path = obj(t).path; + return typeof path === "string" ? [[cmd, { path }]] : []; + }), + ), + languageServers: Object.fromEntries( + Object.entries(obj(root.languageServers)).map(([name, ls]) => [name, obj(ls) as LanguageServerEntry]), + ), ...validateWorkflow(root), }; } @@ -265,6 +279,16 @@ export function setAt(source: T, key: string, value: unknown): T { return { ...base, [head!]: rest.length ? setAt(base[head!], rest.join("."), value) : value } as T; } +/** Like `setAt`, but `null` / `undefined` removes the key (as a settings write of `null` does). */ +export function putAt(source: T, key: string, value: unknown): T { + const [head, ...rest] = key.split("."); + const base = { ...obj(source) }; + if (rest.length) base[head!] = putAt(base[head!], rest.join("."), value); + else if (value == null) delete base[head!]; + else base[head!] = value; + return base as T; +} + export function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true; if (typeof a !== typeof b || !a || !b || typeof a !== "object") return false; diff --git a/src/features/settings/model/staging.ts b/src/features/settings/model/staging.ts index 1591da3c5..891678d42 100644 --- a/src/features/settings/model/staging.ts +++ b/src/features/settings/model/staging.ts @@ -6,7 +6,7 @@ import { create } from "zustand"; import { call, commands, isTauri, type Settings } from "@/platform/ipc"; import { loadSettings, useProject } from "@/platform/project"; -import { deepEqual, getAt, setAt, validateSettings, type ValidSettings } from "./schema"; +import { deepEqual, getAt, putAt, setAt, validateSettings, type ValidSettings } from "./schema"; export type Scope = "global" | "project"; @@ -119,19 +119,48 @@ export function setPageScope(pageId: string, scope: Scope) { useSettingsDialog.setState((s) => ({ scopes: { ...s.scopes, [pageId]: scope } })); } -/** Current value of `key` as the page shows it: staged edit, else the effective value. */ +const isInside = (key: string, parent: string) => key.startsWith(`${parent}.`); + +/** + * Current value of `key` as the page shows it: staged edit, else the effective + * value. Edits staged for a parent (a whole map entry) or inside `key` (one + * field of it) show through too. + */ export function stagedValue(s: Bases & Pick, scope: Scope, key: string): unknown { const hit = s.staged[stageKey(scope, key)]; - return hit ? hit.value : getAt(baseFor(s, scope), key); + if (hit) return hit.value; + const related = Object.values(s.staged).filter( + (c) => c.scope === scope && (isInside(key, c.key) || isInside(c.key, key)), + ); + let value = getAt(baseFor(s, scope), key); + for (const c of related.sort((a, b) => a.key.length - b.key.length)) { + value = isInside(key, c.key) + ? (getAt(c.value, key.slice(c.key.length + 1)) ?? undefined) + : putAt(value, c.key.slice(key.length + 1), c.value); + } + return value; } +/** + * Stages `value` for `key`; `null` removes the key on Apply. An edit inside an + * entry staged as a whole (a map entry just added) goes into that entry; a new + * value for `key` drops the edits staged inside it. + */ export function stage(page: string, scope: Scope, key: string, value: unknown) { useSettingsDialog.setState((s) => { const staged = { ...s.staged }; + const parent = Object.values(staged).find((c) => c.scope === scope && isInside(key, c.key)); + if (parent) { + const merged = putAt(parent.value, key.slice(parent.key.length + 1), value); + staged[stageKey(scope, parent.key)] = { ...parent, value: merged }; + return { staged, error: null }; + } + for (const [id, c] of Object.entries(staged)) if (c.scope === scope && isInside(c.key, key)) delete staged[id]; const id = stageKey(scope, key); + const next = value === undefined ? null : value; // 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 }; + if (deepEqual(next ?? undefined, getAt(baseFor(s, scope), key))) delete staged[id]; + else staged[id] = { scope, key, value: next, page }; return { staged, error: null }; }); } @@ -147,7 +176,7 @@ export function resetPage(page: string) { /** Writes to perform, global layer first so a project value still wins afterwards. */ export function pendingChanges(s: Bases & Pick): StagedChange[] { return Object.values(s.staged) - .filter((c) => !deepEqual(c.value, getAt(baseFor(s, c.scope), c.key))) + .filter((c) => !deepEqual(c.value ?? undefined, getAt(baseFor(s, c.scope), c.key))) .sort((a, b) => (a.scope === b.scope ? a.key.localeCompare(b.key) : a.scope === "global" ? -1 : 1)); } @@ -217,7 +246,7 @@ export async function applySettings(deps?: PersistDeps): Promise { 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) + .reduce((g, c) => putAt(g, c.key, c.value), st.globalBase) : base, staged: Object.fromEntries(Object.entries(st.staged).filter(([k]) => !done.has(k))), })); diff --git a/src/features/tools/LanguageServersPage.module.css b/src/features/tools/LanguageServersPage.module.css index d5f3d8968..1c1eff93e 100644 --- a/src/features/tools/LanguageServersPage.module.css +++ b/src/features/tools/LanguageServersPage.module.css @@ -25,7 +25,7 @@ display: flex; flex: none; flex-direction: column; - width: 300px; + width: 264px; min-height: 0; border-right: 1px solid var(--border); } @@ -39,6 +39,13 @@ flex: 1; min-width: 0; } +.toolbarSep { + flex: none; + width: 1px; + height: 16px; + margin: 0 2px; + background: var(--border); +} .segmented { display: flex; height: var(--h-control-small); @@ -111,6 +118,10 @@ outline: none; background: var(--bg-hover); } +.rowOff .rowName, +.rowOff .rowLanguages { + color: var(--fg-muted); +} .rowName { flex: none; max-width: 60%; @@ -162,6 +173,7 @@ gap: 12px; } .headerText { + flex: 1; min-width: 0; } .title { @@ -178,60 +190,36 @@ } .actions { display: flex; + flex: none; align-items: center; gap: 4px; } -.fields { - display: grid; - grid-template-columns: max-content minmax(0, 1fr); - column-gap: 16px; - row-gap: 8px; - align-items: baseline; - margin-top: 4px; -} -.field { - display: contents; -} -.fieldLabel { - color: var(--fg-secondary); -} -.fieldValue { - min-width: 0; - color: var(--fg); -} -code.fieldValue { - font-family: var(--font-mono); +.linkButton { + padding: 0; + border: 0; + background: none; + color: var(--link); + font: inherit; font-size: var(--fs-small); + cursor: pointer; } -.breakAll { - overflow-wrap: anywhere; +.linkButton:hover { + color: var(--link-hover); + text-decoration: underline; } -.version { - font-variant-numeric: tabular-nums; -} -.pathRow { - display: flex; - align-items: center; - gap: 4px; -} -.pathInput { - flex: 1; - min-width: 0; +.linkButton:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; } .fieldHint { - margin-top: 2px; + margin-top: -4px; color: var(--fg-muted); font-size: var(--fs-small); } -.fieldError, .error { color: var(--danger); overflow-wrap: anywhere; } -.fieldError { - margin-top: 2px; - font-size: var(--fs-small); -} .notice { padding: 6px 8px; border-radius: var(--radius-small); @@ -265,7 +253,7 @@ code.fieldValue { font-size: var(--fs-small); } .log { - margin: 0; + margin: 4px 0 0; padding: 6px 8px; max-height: 240px; overflow: auto; diff --git a/src/features/tools/LanguageServersPage.tsx b/src/features/tools/LanguageServersPage.tsx index 549edcc66..b5407495f 100644 --- a/src/features/tools/LanguageServersPage.tsx +++ b/src/features/tools/LanguageServersPage.tsx @@ -1,18 +1,23 @@ /** * Settings › Languages & Frameworks › Language Servers, laid out like the - * Plugins page: language servers and debug adapters in a list on the left, - * the selected tool's location, custom path and install actions on the right. + * Plugins page: language servers and debug adapters in a list on the left + * (+ adds a server of one's own, − removes it), the selected tool's install + * actions and settings on the right. Settings are staged in the dialog and + * written on Apply / OK; installs run right away. */ -import { useEffect, useId, useMemo, useState, type KeyboardEvent } from "react"; +import { useEffect, useMemo, useState, type KeyboardEvent } from "react"; import clsx from "clsx"; import type { ToolInfo } from "@/bindings"; -import { Badge, Button, EmptyState, IconButton, Input, ProgressBar, SearchField, Spinner, T } from "@/ui"; -import { toolsBackend } from "./backend"; +import { Badge, Button, EmptyState, IconButton, ProgressBar, SearchField, Spinner, T } from "@/ui"; +import { onIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { useSettingsForm } from "@/workbench/contributions"; +import { languageNames, NewServerDialog, ServerSettings, ToolSettings, useLanguageChoices } from "./ServerSettings"; +import { serverKey, type ServerEntry } from "./serverConfig"; import { cancelInstall, installTool, refreshTools, - setCustomPath, STATUS_LABEL, uninstallTool, useTools, @@ -46,6 +51,29 @@ export function filterTools(tools: readonly ToolInfo[], query: string, filter: F }); } +/** A server added in this dialog, listed before Apply checks its executable. */ +function pendingTool(name: string, entry: ServerEntry, languages: string[]): ToolInfo { + return { + name, + displayName: name, + kind: "languageServer", + command: entry.command ?? "", + languages, + status: "missing", + path: null, + version: null, + installMethod: null, + canInstall: false, + unavailableReason: null, + canUninstall: false, + installHint: null, + homepage: null, + customError: null, + defaultArgs: [], + defaultInitializationOptions: null, + }; +} + function JobRow({ name, job }: { name: string; job: InstallJob }) { return (
@@ -62,140 +90,117 @@ function JobRow({ name, job }: { name: string; job: InstallJob }) { ); } -function ToolRow({ tool, selected, onSelect }: { tool: ToolInfo; selected: boolean; onSelect: () => void }) { +function ToolRow({ + tool, + selected, + off, + pending, + onSelect, +}: { + tool: ToolInfo; + selected: boolean; + off: boolean; + pending: boolean; + onSelect: () => void; +}) { return (
  • ); } -/** "Path" field: an executable to launch instead of the detected one, saved when committed. */ -function CustomPath({ tool }: { tool: ToolInfo }) { - const id = useId(); - const saved = tool.status === "custom" || tool.customError ? (tool.path ?? "") : ""; - const [draft, setDraft] = useState(null); - const value = draft ?? (tool.status === "custom" ? saved : ""); - const commit = async (next: string) => { - setDraft(null); - if (next.trim() !== saved) await setCustomPath(tool.command, next); - }; - return ( -
    - -
    - - setDraft(e.target.value)} - onBlur={() => draft !== null && void commit(draft)} - onKeyDown={(e: KeyboardEvent) => { - if (e.key === "Enter") void commit(e.currentTarget.value); - if (e.key === "Escape" && draft !== null) { - e.stopPropagation(); - setDraft(null); - } - }} - /> - { - const picked = await toolsBackend().chooseExecutable(`Select ${tool.command}`, value || undefined); - if (picked) await commit(picked); - }} - /> - {tool.status === "custom" && ( - void commit("")} /> - )} - - {tool.customError ? ( -
    - {tool.customError} -
    - ) : ( -
    - Leave empty to use the IDE-installed copy, else the one on PATH. -
    - )} -
    -
    - ); -} - -function Details({ tool }: { tool: ToolInfo }) { +function Details({ tool, pending }: { tool: ToolInfo; pending: boolean }) { const job = useTools((s) => s.jobs[tool.name]); const last = useTools((s) => s.logs[tool.name]); const [logOpen, setLogOpen] = useState(false); const log = job?.lines ?? last?.lines ?? []; const lastError = !job && last?.error !== "Cancelled" ? last?.error : null; const busy = !!job; - const manualHint = !tool.canInstall && tool.status === "missing"; + // Servers and adapters show the install hint next to their executable. + const manualHint = + !pending && + !tool.canInstall && + tool.status === "missing" && + (tool.unavailableReason ?? (tool.kind === "plugin" ? tool.installHint : null)); return (
    {tool.displayName} - {STATUS_LABEL[tool.status]} + {pending ? ( + + New + + ) : ( + {STATUS_LABEL[tool.status]} + )}
    {KIND_LABEL[tool.kind]} - {tool.languages.length > 0 && <> · {tool.languages.join(", ")}} + {tool.version && <> · {tool.version}} + {tool.homepage && ( + <> + {" · "} + + Homepage + + + )}
    -
    - -
    - {tool.canInstall && ( - - )} - {tool.canUninstall && ( - - )} - {log.length > 0 && ( - - )} +
    + {tool.canInstall && ( + + )} + {tool.canUninstall && ( + + )} +
    {job && } {lastError && ( @@ -205,58 +210,31 @@ function Details({ tool }: { tool: ToolInfo }) { )} {manualHint && (
    - {tool.unavailableReason ?? tool.installHint ?? "Install it manually and put it on PATH."} + {manualHint}
    )} - {tool.canInstall && tool.status === "missing" && tool.unavailableReason === null && tool.installMethod && ( -
    - Installs into the IDE's own tools folder via {tool.installMethod}. + {log.length > 0 && ( +
    + + {logOpen && ( +
    +              {log.join("\n")}
    +            
    + )}
    )} -
    -
    - - Command - - {tool.command} -
    - {tool.kind !== "plugin" && } - {tool.status !== "custom" && ( -
    - - Location - - {tool.path ?? "—"} -
    - )} -
    - - Version - - {tool.version ?? "—"} -
    - {tool.homepage && ( -
    - - Homepage - - - {tool.homepage} - -
    - )} -
    - - {logOpen && log.length > 0 && ( -
    -          {log.join("\n")}
    -        
    + {tool.kind === "languageServer" ? ( + + ) : ( + tool.kind === "debugger" && )}
    ); @@ -264,20 +242,44 @@ function Details({ tool }: { tool: ToolInfo }) { export function LanguageServersPage() { const { tools, loaded, loading, error } = useTools(); + const form = useSettingsForm(); const [query, setQuery] = useState(""); const [filter, setFilter] = useState("all"); const [selectedName, setSelectedName] = useState(null); + const [adding, setAdding] = useState(false); + const known = useLanguageChoices(); + const staged = form.useValue("languageServers") as Record | undefined; + const servers = useMemo(() => staged ?? {}, [staged]); + const applied = useProject((s) => s.settings?.languageServers); useEffect(() => { void refreshTools(); + // Applied paths and servers change what is found and listed. + return onIdeEvent("settingsChanged", (e) => { + if (!e.key || /^(tools|languageServers)(\.|$)/.test(e.key)) void refreshTools(); + }); }, []); - const shown = useMemo(() => filterTools(tools, query, filter), [tools, query, filter]); + // The backend's list, minus servers removed here and plus servers added here (until Apply). + const { all, pending } = useMemo(() => { + const isCustom = (e: ServerEntry | undefined) => (e?.languages?.length ?? 0) > 0; + const list = tools.filter((t) => !isCustom(applied?.[t.name]) || isCustom(servers[t.name])); + const added = new Set(); + for (const [name, entry] of Object.entries(servers)) { + if (!isCustom(entry) || list.some((t) => t.name === name)) continue; + list.push(pendingTool(name, entry, languageNames(entry.languages ?? [], known))); + added.add(name); + } + return { all: list, pending: added }; + }, [tools, servers, applied, known]); + + const shown = useMemo(() => filterTools(all, query, filter), [all, query, filter]); const ready = shown.filter((t) => t.status !== "missing"); const missing = shown.filter((t) => t.status === "missing"); const ordered = [...ready, ...missing]; const selected = ordered.find((t) => t.name === selectedName) ?? ordered[0] ?? null; - const missingCount = tools.filter((t) => t.status === "missing").length; + const missingCount = all.filter((t) => t.status === "missing").length; + const removable = !!selected && (servers[selected.name]?.languages?.length ?? 0) > 0; const onListKey = (e: KeyboardEvent) => { if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; @@ -296,7 +298,14 @@ export function LanguageServersPage() { {title} {list.length} {list.map((t) => ( - setSelectedName(t.name)} /> + setSelectedName(t.name)} + /> ))} ); @@ -315,13 +324,15 @@ export function LanguageServersPage() {
    - setAdding(true)} /> + selected && form.set(serverKey(selected.name), null)} /> + {loading ? ( ) : ( @@ -332,6 +343,13 @@ export function LanguageServersPage() { onClick={() => void refreshTools()} /> )} +
    {(["all", "missing", "installed"] as const).map((f) => ( @@ -343,7 +361,7 @@ export function LanguageServersPage() { className={clsx(styles.segment, filter === f && styles.segmentOn)} onClick={() => setFilter(f)} > - {f === "all" ? "All" : f === "missing" ? `Missing (${missingCount})` : "Available"} + {f === "all" ? tx("All") : f === "missing" ? tx`Missing (${missingCount})` : tx("Available")} ))}
    @@ -363,11 +381,23 @@ export function LanguageServersPage() {
    {selected ? ( -
    +
    ) : ( )}
    + {adding && ( + t.name)} + onClose={() => setAdding(false)} + onAdded={(name) => { + setAdding(false); + setFilter("all"); + setQuery(""); + setSelectedName(name); + }} + /> + )}
    ); } diff --git a/src/features/tools/ServerSettings.module.css b/src/features/tools/ServerSettings.module.css new file mode 100644 index 000000000..75c563cee --- /dev/null +++ b/src/features/tools/ServerSettings.module.css @@ -0,0 +1,188 @@ +.form { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +/* IDEA titled separator: "Startup ─────────". */ +.section { + min-width: 0; +} +.sectionTitle { + display: flex; + align-items: center; + gap: 8px; + height: 22px; + margin-bottom: 4px; + color: var(--fg-strong); +} +.sectionRule { + flex: 1; + height: 1px; + background: var(--border); +} +.sectionToggle { + width: 100%; + padding: 0; + border: 0; + background: none; + font: inherit; + text-align: left; + cursor: pointer; +} +.sectionToggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.sectionBody { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0 0 12px; +} + +/* Label above a full-width control: the details pane is narrow. */ +.row { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.label { + color: var(--fg); +} +.label::after { + content: ":"; +} +.control { + display: flex; + align-items: flex-start; + gap: 4px; + min-width: 0; +} +.hint { + color: var(--fg-subtle); + font-size: var(--fs-small); + line-height: 1.4; + overflow-wrap: anywhere; +} +.note { + color: var(--fg-subtle); + font-size: var(--fs-small); +} +.error { + color: var(--danger); +} +.code { + font-family: var(--font-mono); +} +.grow { + flex: 1; + min-width: 0; +} + +.area { + flex: 1; + min-width: 0; + padding: 4px 6px; + border: 0; + border-radius: var(--radius-small); + box-shadow: inset 0 0 0 1px var(--border-strong); + background: var(--bg-input); + color: var(--fg); + font-family: var(--font-mono); + font-size: var(--fs-small); + line-height: 1.45; + resize: vertical; +} +.area:focus { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent); +} +.area::placeholder { + color: var(--fg-muted); +} +.invalid, +.invalid:focus { + box-shadow: inset 0 0 0 2px var(--danger); +} + +.picker { + display: flex; + flex: 1; + align-items: center; + gap: 6px; + min-width: 0; + height: var(--h-control); + padding: 0 8px; + border: 0; + border-radius: var(--radius-small); + box-shadow: inset 0 0 0 1px var(--border-strong); + background: var(--bg-input); + color: var(--fg); + font: inherit; + text-align: left; +} +.picker:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--accent); +} +.pickerText { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.placeholder { + color: var(--fg-muted); +} +.pickerPopup { + display: flex; + flex-direction: column; + gap: 6px; + width: 260px; + padding: 8px; +} +.pickerList { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 240px; + overflow: auto; +} + +.link { + padding: 0; + border: 0; + background: none; + color: var(--link); + font: inherit; + cursor: pointer; +} +.link:hover { + color: var(--link-hover); + text-decoration: underline; +} +.link:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.dialogBody { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 16px 4px; +} +/* Dialogs are wide enough for "Label: [control]". */ +.dialogBody .row { + display: grid; + grid-template-columns: 90px minmax(0, 1fr); + align-items: center; + column-gap: 8px; +} +.dialogBody .control { + align-items: center; +} diff --git a/src/features/tools/ServerSettings.tsx b/src/features/tools/ServerSettings.tsx new file mode 100644 index 000000000..d16ff89e8 --- /dev/null +++ b/src/features/tools/ServerSettings.tsx @@ -0,0 +1,567 @@ +/** + * Settings › Language Servers, right-hand side: the selected tool's executable + * and, for a language server, its startup options and workspace configuration + * (`languageServers.`), plus the New Language Server dialog. Edits are + * staged in the Settings dialog and written on Apply / OK. + */ +import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import clsx from "clsx"; +import type { ToolInfo } from "@/bindings"; +import { Checkbox, Dialog, Icon, IconButton, Input, Popup, SearchField, T } from "@/ui"; +import { tx } from "@/platform/i18n"; +import { useSettingsForm } from "@/workbench/contributions"; +import { toolsBackend, type LanguageChoice } from "./backend"; +import { + formatArgs, + formatEnv, + formatJson, + formatList, + nameFromCommand, + parseArgs, + parseEnv, + parseJsonObject, + parseList, + SERVER_NAME, + serverKey, + toolPathKey, + type ServerEntry, + type ServerField, +} from "./serverConfig"; +import styles from "./ServerSettings.module.css"; + +let languageList: Promise | null = null; + +/** Languages a server can be used for, loaded once. */ +export function useLanguageChoices(): LanguageChoice[] { + const [langs, setLangs] = useState([]); + useEffect(() => { + let live = true; + languageList ??= toolsBackend() + .languages() + .catch(() => { + languageList = null; + return []; + }); + void languageList.then((l) => live && setLangs(l)); + return () => { + live = false; + }; + }, []); + return langs; +} + +/** Test hook: forget the loaded language list. */ +export function resetLanguageChoices() { + languageList = null; +} + +export function languageNames(ids: readonly string[], known: readonly LanguageChoice[]): string[] { + return ids.map((id) => known.find((l) => l.id === id)?.name ?? id); +} + +// --------------------------------------------------------------------------- +// Form building blocks (IDEA layout: "Label: [control]", titled separators) + +/** A titled separator; `collapsible` ones fold away like IDEA's hideable separators. */ +function Section({ + title, + note, + collapsible, + defaultOpen = true, + children, +}: { + title: string; + note?: string; + collapsible?: boolean; + defaultOpen?: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + const shown = !collapsible || open; + return ( +
    + {collapsible ? ( + + ) : ( +
    + {title} + +
    + )} + {shown && ( +
    + {note && ( +
    + {note} +
    + )} + {children} +
    + )} +
    + ); +} + +/** "Label:" above its control (beside it in dialogs), the hint below. */ +function Row({ + label, + htmlFor, + hint, + children, +}: { + label: string; + htmlFor?: string; + hint?: ReactNode; + children: ReactNode; +}) { + return ( +
    + +
    {children}
    + {hint &&
    {hint}
    } +
    + ); +} + +interface FieldProps { + label: string; + value: V | undefined; + format: (v: V | undefined) => string; + /** Text → value (null removes the setting); throws with a message when invalid. */ + parse: (text: string) => V | null; + onChange: (value: V | null) => void; + placeholder?: string; + hint?: ReactNode; + multiline?: boolean; + trailing?: ReactNode; +} + +/** + * A text field over a setting: valid text is staged as it is typed; invalid + * text stays in the field, marked, and isn't staged. + */ +function Field({ label, value, format, parse, onChange, placeholder, hint, multiline, trailing }: FieldProps) { + const id = useId(); + const [draft, setDraft] = useState(null); + const [error, setError] = useState(null); + const text = draft ?? format(value); + const change = (next: string) => { + setDraft(next); + try { + onChange(parse(next)); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }; + const common = { + id, + value: text, + placeholder, + "aria-invalid": error ? true : undefined, + // Valid text is reformatted from the staged value once the field is left. + onBlur: () => !error && setDraft(null), + }; + return ( + + {error} + + ) : ( + hint + ) + } + > + {multiline ? ( +