feat(lsp): Configure language servers and add servers of one's own (#108)

Language servers can be configured from settings.toml (global or project) and Settings › Language Servers: arguments, environment, initialization options and workspace settings per server, enable/disable, and any stdio LSP server can be added for a language. Settings-only changes are pushed with workspace/didChangeConfiguration; launch changes restart the server.

Also: re-enabling a server now detaches and stops the fallback that stood in for it, and a server added from the dialog is written as its own [languageServers.<name>] table.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-28 03:40:18 +08:00
1 джерело 025e0019d2
коміт 48ac2094fc
39 files changed
+2236 -353

No files matched your search

Створено
+2
Переглянути файл
@@ -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",
+1
Переглянути файл
@@ -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"] }
+1
Переглянути файл
@@ -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
+56
Переглянути файл
@@ -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<String, ToolSettings>,
/// 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<String, LanguageServerSettings>,
/// 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<Vec<String>>,
/// Added to the server's environment.
pub env: BTreeMap<String, String>,
/// Merged over the definition's `initializationOptions` (sent once at startup).
#[serde(skip_serializing_if = "Option::is_none")]
#[specta(optional, type = Option<specta_typescript::Unknown>)]
pub initialization_options: Option<serde_json::Value>,
/// 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<specta_typescript::Unknown>)]
pub settings: Option<serde_json::Value>,
/// Language ids a server defined here serves (`python`); empty for the servers
/// of the language definitions.
pub languages: Vec<String>,
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
pub root_markers: Vec<String>,
}
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(),
+1 -1
Переглянути файл
@@ -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 });
+14 -3
Переглянути файл
@@ -26,6 +26,17 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
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<ide_language::LanguageConfig> {
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<Vec<ToolInfo>> {
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<InstallEvent>,
) -> IdeResult<ToolInfo> {
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<ToolInfo> {
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)
+8
Переглянути файл
@@ -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<PathBuf> =
@@ -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())
}));
}
{
+1
Переглянути файл
@@ -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
+4
Переглянути файл
@@ -72,6 +72,10 @@ pub struct LanguageServerConfig {
/// JSON passed as `initializationOptions`.
#[serde(default)]
pub initialization_options: Option<toml::Value>,
/// Answers `workspace/configuration`; without it the sections are looked up
/// in `initialization_options`.
#[serde(default)]
pub settings: Option<serde_json::Value>,
/// Human hint shown when the binary is missing.
#[serde(default)]
pub install_hint: Option<String>,
+27 -7
Переглянути файл
@@ -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,
}
}
+1 -2
Переглянути файл
@@ -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);
+136 -4
Переглянути файл
@@ -68,6 +68,9 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> 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<ConfigFn>;
/// 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<LanguageServerConfig> + Send + Sync;
struct Attached {
server: Arc<Server>,
@@ -146,6 +149,7 @@ pub struct LspManager {
docs: Mutex<HashMap<DocId, DocState>>,
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
config_hook: RwLock<Option<ConfigHook>>,
servers_hook: RwLock<Option<Arc<ServersFn>>>,
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
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<Arc<Server>> {
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<LanguageServerConfig> {
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<ServersFn>) {
*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<Arc<Server>> = 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(&current)
{
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<ide_core::ServerId>)> = 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<ide_core::ServerId> = 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<Arc<Server>> = 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<Attached>, Vec<Attached>) =
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<Server>| 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<Arc<Server>> = self.servers.lock().clone();
let unlisted: Vec<Arc<Server>> = 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<Server>| 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<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>) {
+83
Переглянути файл
@@ -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<Mutex<Option<Value>>> = Default::default();
let disabled: Arc<Mutex<Vec<String>>> = 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::<Vec<_>>(), ["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<Mutex<Vec<String>>> = 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::<Vec<_>>(), ["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::<Vec<_>>(), ["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 });
+3
Переглянути файл
@@ -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<serde_json::Valu
env: Default::default(),
root_markers: vec!["package.json".into()],
initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()),
settings: None,
install_hint: None,
connect: None,
fallback: false,
+73 -2
Переглянути файл
@@ -5,8 +5,8 @@ use crate::discover::{project_venv_python, workspace_tsdk};
use crate::exec::{path_with, search_path};
use crate::{bin_dir, exe, venv_of};
use crate::{godot, vue};
use ide_core::settings::Settings;
use ide_language::LanguageServerConfig;
use ide_core::settings::{LanguageServerSettings, Settings};
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -302,6 +302,77 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
}
}
/// The servers `language` uses after Settings › Language Servers: the
/// definition's ones that aren't disabled, then the ones defined in settings for it.
pub fn language_servers(language: &str, defined: &[LanguageServerConfig], s: &Settings) -> Vec<LanguageServerConfig> {
let enabled = |name: &str| s.language_servers.get(name).is_none_or(|ls| ls.enabled);
let mut out: Vec<LanguageServerConfig> = 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.
+54
Переглянути файл
@@ -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<LanguageServerConfig>| v.into_iter().map(|c| c.name).collect::<Vec<_>>();
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) {
+18
Переглянути файл
@@ -72,6 +72,10 @@ pub struct ToolInfo {
pub homepage: Option<String>,
/// Why the executable chosen in settings cannot be used (status is then `Missing`).
pub custom_error: Option<String>,
/// A language server's arguments from its language definition.
pub default_args: Vec<String>,
/// A language server's `initializationOptions` from its language definition, as JSON text.
pub default_initialization_options: Option<String>,
}
/// Streamed install progress.
@@ -209,6 +213,8 @@ struct Usage {
command: Option<String>,
kind: Option<ToolKind>,
install_hint: Option<String>,
args: Vec<String>,
initialization_options: Option<String>,
}
fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
@@ -243,6 +249,14 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
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)))
}
+72 -8
Переглянути файл
@@ -106,20 +106,33 @@ fn json_to_toml(value: &Json) -> IdeResult<toml_edit::Value> {
})
}
/// Sets (or removes, for `None`) a dotted key in a TOML document.
fn set_in_document(doc: &mut DocumentMut, segments: &[&str], value: Option<toml_edit::Value>) {
/// 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<toml_edit::Value>, 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();
+1 -1
Переглянути файл
@@ -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
+72
Переглянути файл
@@ -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" |
+27
Переглянути файл
@@ -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 });
+21 -1
Переглянути файл
@@ -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<T>(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);
}
+45 -42
Переглянути файл
@@ -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 <EmptyState title="Nothing found" icon="search" />;
const Body = page?.component;
return (
<PageContext.Provider value={ctx}>
<div className={styles.pageHeader}>
<div className={styles.crumbs} aria-label={tx("Breadcrumb")}>
{crumbs.map((c, i) => (
<span key={i} className={styles.crumb}>
{i > 0 && <Icon name="chevron-right" size={12} className={styles.crumbSep} />}
<span>
<T>{c}</T>
<SettingsFormContext.Provider value={form}>
<div className={styles.pageHeader}>
<div className={styles.crumbs} aria-label={tx("Breadcrumb")}>
{crumbs.map((c, i) => (
<span key={i} className={styles.crumb}>
{i > 0 && <Icon name="chevron-right" size={12} className={styles.crumbSep} />}
<span>
<T>{c}</T>
</span>
</span>
</span>
))}
))}
</div>
{dirty && (
<button type="button" className={styles.reset} onClick={onReset}>
<T>Reset</T>
</button>
)}
{projectScope && !spec?.ownScopeSwitch && <ScopeSwitch pageId={scopeKey} scope={scope} />}
<span className={styles.history}>
<IconButton icon="arrow-left" title="Back" size="small" disabled={!history.back} onClick={history.back} />
<IconButton
icon="arrow-right"
title="Forward"
size="small"
disabled={!history.forward}
onClick={history.forward}
/>
</span>
</div>
{dirty && (
<button type="button" className={styles.reset} onClick={onReset}>
<T>Reset</T>
</button>
{scope === "project" && (
<div className={styles.scopeNote}>
<Icon name="info" size={14} />
<T>Saved to .openidea/settings.toml in this project; overrides the global value.</T>
</div>
)}
{projectScope && !spec?.ownScopeSwitch && <ScopeSwitch pageId={scopeKey} scope={scope} />}
<span className={styles.history}>
<IconButton icon="arrow-left" title="Back" size="small" disabled={!history.back} onClick={history.back} />
<IconButton
icon="arrow-right"
title="Forward"
size="small"
disabled={!history.forward}
onClick={history.forward}
/>
</span>
</div>
{scope === "project" && (
<div className={styles.scopeNote}>
<Icon name="info" size={14} />
<T>Saved to .openidea/settings.toml in this project; overrides the global value.</T>
{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>
)}
{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>
</SettingsFormContext.Provider>
</PageContext.Provider>
);
}
+30 -6
Переглянути файл
@@ -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<string, { path: string }>;
/** Settings › Language Servers, by server name (as the backend sends them). */
languageServers: Record<string, LanguageServerEntry>;
}
/** One `languageServers.<name>` entry; every field optional, like in settings.toml. */
export type LanguageServerEntry = Partial<LanguageServerSettings>;
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<T>(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<T>(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;
+36 -7
Переглянути файл
@@ -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<SettingsDialogState, "staged">, 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<SettingsDialogState, "staged">): 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<boolean> {
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))),
}));
+29 -41
Переглянути файл
@@ -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;
+189 -159
Переглянути файл
@@ -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 (
<div className={styles.job}>
@@ -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 (
<li className={clsx(styles.row, selected && styles.rowSelected)}>
<button
type="button"
className={styles.rowMain}
className={clsx(styles.rowMain, off && styles.rowOff)}
aria-current={selected || undefined}
data-tool={tool.name}
title={`${tool.displayName} — ${tool.languages.join(", ")}`}
onClick={onSelect}
>
<Badge dot tone={STATUS_TONE[tool.status]}>
{STATUS_LABEL[tool.status]}
<Badge dot tone={pending ? "accent" : STATUS_TONE[tool.status]}>
{pending ? tx("New") : STATUS_LABEL[tool.status]}
</Badge>
<span className={styles.rowName}>{tool.displayName}</span>
{pending && (
<span className={styles.kindTag}>
<T>New</T>
</span>
)}
{tool.kind !== "languageServer" && (
<span className={styles.kindTag}>
<T>{KIND_LABEL[tool.kind]}</T>
</span>
)}
{off && (
<span className={styles.kindTag}>
<T>Disabled</T>
</span>
)}
<span className={styles.rowLanguages}>{tool.languages.join(", ")}</span>
</button>
</li>
);
}
/** "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<string | null>(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 (
<div className={styles.field}>
<label className={styles.fieldLabel} htmlFor={id}>
<T>Path</T>
</label>
<div className={styles.fieldValue}>
<span className={styles.pathRow}>
<Input
id={id}
mono
className={styles.pathInput}
value={value}
invalid={!!tool.customError}
placeholder={tool.status !== "custom" && tool.path ? tool.path : "Auto-detect"}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => draft !== null && void commit(draft)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") void commit(e.currentTarget.value);
if (e.key === "Escape" && draft !== null) {
e.stopPropagation();
setDraft(null);
}
}}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const picked = await toolsBackend().chooseExecutable(`Select ${tool.command}`, value || undefined);
if (picked) await commit(picked);
}}
/>
{tool.status === "custom" && (
<IconButton icon="close" size="small" title="Use auto-detected" onClick={() => void commit("")} />
)}
</span>
{tool.customError ? (
<div className={styles.fieldError} role="alert">
{tool.customError}
</div>
) : (
<div className={styles.fieldHint}>
<T>Leave empty to use the IDE-installed copy, else the one on PATH.</T>
</div>
)}
</div>
</div>
);
}
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 (
<div className={styles.details} aria-label={`${tool.displayName} details`}>
<div className={styles.header}>
<div className={styles.headerText}>
<div className={styles.title}>
<span>{tool.displayName}</span>
<Badge tone={STATUS_TONE[tool.status]}>{STATUS_LABEL[tool.status]}</Badge>
{pending ? (
<Badge tone="accent">
<T>New</T>
</Badge>
) : (
<Badge tone={STATUS_TONE[tool.status]}>{STATUS_LABEL[tool.status]}</Badge>
)}
</div>
<div className={styles.subtitle}>
<T>{KIND_LABEL[tool.kind]}</T>
{tool.languages.length > 0 && <> · {tool.languages.join(", ")}</>}
{tool.version && <> · {tool.version}</>}
{tool.homepage && (
<>
{" · "}
<a href={tool.homepage} target="_blank" rel="noreferrer">
<T>Homepage</T>
</a>
</>
)}
</div>
</div>
</div>
<div className={styles.actions}>
{tool.canInstall && (
<Button
size="small"
variant={tool.status === "missing" ? "primary" : "secondary"}
disabled={busy}
onClick={() => void installTool(tool.name)}
aria-label={
tool.status === "installed" ? tx`Reinstall ${tool.displayName}` : tx`Install ${tool.displayName}`
}
>
{tool.status === "installed" ? "Reinstall" : "Install"}
</Button>
)}
{tool.canUninstall && (
<Button size="small" variant="ghost" disabled={busy} onClick={() => void uninstallTool(tool.name)}>
<T>Uninstall</T>
</Button>
)}
{log.length > 0 && (
<Button size="small" variant="ghost" aria-expanded={logOpen} onClick={() => setLogOpen(!logOpen)}>
{logOpen ? "Hide install log" : "Show install log"}
</Button>
)}
<div className={styles.actions}>
{tool.canInstall && (
<Button
size="small"
variant={tool.status === "missing" ? "primary" : "secondary"}
disabled={busy}
title={
tool.installMethod ? tx`Installs into the IDE's own tools folder via ${tool.installMethod}` : undefined
}
onClick={() => void installTool(tool.name)}
aria-label={
tool.status === "installed" ? tx`Reinstall ${tool.displayName}` : tx`Install ${tool.displayName}`
}
>
{tool.status === "installed" ? "Reinstall" : "Install"}
</Button>
)}
{tool.canUninstall && (
<Button size="small" variant="ghost" disabled={busy} onClick={() => void uninstallTool(tool.name)}>
<T>Uninstall</T>
</Button>
)}
</div>
</div>
{job && <JobRow name={tool.displayName} job={job} />}
{lastError && (
@@ -205,58 +210,31 @@ function Details({ tool }: { tool: ToolInfo }) {
)}
{manualHint && (
<div className={styles.notice} role="note">
{tool.unavailableReason ?? tool.installHint ?? "Install it manually and put it on PATH."}
{manualHint}
</div>
)}
{tool.canInstall && tool.status === "missing" && tool.unavailableReason === null && tool.installMethod && (
<div className={styles.fieldHint}>
<T>Installs into the IDE&apos;s own tools folder via</T> {tool.installMethod}.
{log.length > 0 && (
<div>
<button
type="button"
className={styles.linkButton}
aria-expanded={logOpen}
onClick={() => setLogOpen(!logOpen)}
>
{logOpen ? tx("Hide install log") : tx("Show install log")}
</button>
{logOpen && (
<pre className={styles.log} aria-label={`${tool.displayName} install log`}>
{log.join("\n")}
</pre>
)}
</div>
)}
<div className={styles.fields}>
<div className={styles.field}>
<span className={styles.fieldLabel}>
<T>Command</T>
</span>
<code className={styles.fieldValue}>{tool.command}</code>
</div>
{tool.kind !== "plugin" && <CustomPath key={`${tool.name}:${tool.path ?? ""}`} tool={tool} />}
{tool.status !== "custom" && (
<div className={styles.field}>
<span className={styles.fieldLabel}>
<T>Location</T>
</span>
<code className={clsx(styles.fieldValue, styles.breakAll)}>{tool.path ?? "—"}</code>
</div>
)}
<div className={styles.field}>
<span className={styles.fieldLabel}>
<T>Version</T>
</span>
<span className={clsx(styles.fieldValue, styles.version)}>{tool.version ?? "—"}</span>
</div>
{tool.homepage && (
<div className={styles.field}>
<span className={styles.fieldLabel}>
<T>Homepage</T>
</span>
<a
className={clsx(styles.fieldValue, styles.breakAll)}
href={tool.homepage}
target="_blank"
rel="noreferrer"
>
{tool.homepage}
</a>
</div>
)}
</div>
{logOpen && log.length > 0 && (
<pre className={styles.log} aria-label={`${tool.displayName} install log`}>
{log.join("\n")}
</pre>
{tool.kind === "languageServer" ? (
<ServerSettings tool={tool} pending={pending} />
) : (
tool.kind === "debugger" && <ToolSettings tool={tool} />
)}
</div>
);
@@ -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<Filter>("all");
const [selectedName, setSelectedName] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const known = useLanguageChoices();
const staged = form.useValue("languageServers") as Record<string, ServerEntry> | 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<string>();
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<HTMLUListElement>) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
@@ -296,7 +298,14 @@ export function LanguageServersPage() {
<T>{title}</T> <span className={styles.groupCount}>{list.length}</span>
</li>
{list.map((t) => (
<ToolRow key={t.name} tool={t} selected={t === selected} onSelect={() => setSelectedName(t.name)} />
<ToolRow
key={t.name}
tool={t}
selected={t === selected}
off={servers[t.name]?.enabled === false}
pending={pending.has(t.name)}
onSelect={() => setSelectedName(t.name)}
/>
))}
</>
);
@@ -315,13 +324,15 @@ export function LanguageServersPage() {
<div className={styles.body}>
<div className={styles.side}>
<div className={styles.toolbar}>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder="Filter by tool or language"
aria-label="Filter language servers"
<IconButton icon="plus" size="small" title="Add Language Server…" onClick={() => setAdding(true)} />
<IconButton
icon="minus"
size="small"
title="Remove Language Server"
disabled={!removable}
onClick={() => selected && form.set(serverKey(selected.name), null)}
/>
<span className={styles.toolbarSep} aria-hidden />
{loading ? (
<Spinner size={14} label="Checking tools" />
) : (
@@ -332,6 +343,13 @@ export function LanguageServersPage() {
onClick={() => void refreshTools()}
/>
)}
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder="Filter by tool or language"
aria-label="Filter language servers"
/>
</div>
<div className={styles.segmented} role="radiogroup" aria-label="Show">
{(["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")}
</button>
))}
</div>
@@ -363,11 +381,23 @@ export function LanguageServersPage() {
</ul>
</div>
{selected ? (
<Details key={selected.name} tool={selected} />
<Details key={selected.name} tool={selected} pending={pending.has(selected.name)} />
) : (
<EmptyState className={styles.detailsEmpty} title={loaded ? "No tool selected" : "Loading…"} />
)}
</div>
{adding && (
<NewServerDialog
existing={all.map((t) => t.name)}
onClose={() => setAdding(false)}
onAdded={(name) => {
setAdding(false);
setFilter("all");
setQuery("");
setSelectedName(name);
}}
/>
)}
</div>
);
}
+188
Переглянути файл
@@ -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;
}
+567
Переглянути файл
@@ -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.<name>`), 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<LanguageChoice[]> | null = null;
/** Languages a server can be used for, loaded once. */
export function useLanguageChoices(): LanguageChoice[] {
const [langs, setLangs] = useState<LanguageChoice[]>([]);
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 (
<section className={styles.section} aria-label={tx(title)}>
{collapsible ? (
<button
type="button"
className={clsx(styles.sectionTitle, styles.sectionToggle)}
aria-expanded={open}
onClick={() => setOpen(!open)}
>
<Icon name={open ? "chevron-down" : "chevron-right"} size={12} />
<T>{title}</T>
<span className={styles.sectionRule} aria-hidden />
</button>
) : (
<div className={styles.sectionTitle}>
<T>{title}</T>
<span className={styles.sectionRule} aria-hidden />
</div>
)}
{shown && (
<div className={styles.sectionBody}>
{note && (
<div className={styles.note}>
<T>{note}</T>
</div>
)}
{children}
</div>
)}
</section>
);
}
/** "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 (
<div className={styles.row}>
<label className={styles.label} htmlFor={htmlFor}>
<T>{label}</T>
</label>
<div className={styles.control}>{children}</div>
{hint && <div className={styles.hint}>{hint}</div>}
</div>
);
}
interface FieldProps<V> {
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<V>({ label, value, format, parse, onChange, placeholder, hint, multiline, trailing }: FieldProps<V>) {
const id = useId();
const [draft, setDraft] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<Row
label={label}
htmlFor={id}
hint={
error ? (
<span className={styles.error} role="alert">
{error}
</span>
) : (
hint
)
}
>
{multiline ? (
<textarea
{...common}
spellCheck={false}
rows={Math.min(14, Math.max(3, text.split("\n").length + 1, (placeholder ?? "").split("\n").length))}
className={clsx(styles.area, error && styles.invalid)}
onChange={(e) => change(e.target.value)}
/>
) : (
<Input {...common} mono invalid={!!error} className={styles.grow} onChange={(e) => change(e.target.value)} />
)}
{trailing}
</Row>
);
}
/** "Python, TypeScript ▾": checkboxes in a popup, with a filter. */
export function LanguagePicker({
id,
value,
onChange,
invalid,
}: {
id?: string;
value: readonly string[];
onChange: (ids: string[]) => void;
invalid?: boolean;
}) {
const known = useLanguageChoices();
const anchor = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? known.filter((l) => l.name.toLowerCase().includes(q) || l.id.includes(q)) : known;
}, [known, query]);
const label = languageNames(value, known).join(", ");
return (
<>
<button
id={id}
ref={anchor}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-invalid={invalid || undefined}
className={clsx(styles.picker, invalid && styles.invalid)}
onClick={() => setOpen(!open)}
>
<span className={clsx(styles.pickerText, !label && styles.placeholder)}>
{label || tx("Choose languages…")}
</span>
<Icon name="chevron-down" size={12} />
</button>
<Popup
open={open}
onClose={() => setOpen(false)}
anchor={anchor}
placement="bottom-start"
role="dialog"
aria-label={tx("Languages")}
className={styles.pickerPopup}
>
<SearchField
value={query}
onChange={setQuery}
placeholder="Filter languages"
aria-label="Filter languages"
data-autofocus
/>
<div className={styles.pickerList}>
{shown.map((l) => (
<Checkbox
key={l.id}
checked={value.includes(l.id)}
label={l.name}
onChange={(on) => onChange(on ? [...value, l.id] : value.filter((v) => v !== l.id))}
/>
))}
{shown.length === 0 && (
<div className={styles.note}>
<T>No languages match.</T>
</div>
)}
</div>
</Popup>
</>
);
}
// ---------------------------------------------------------------------------
// The tool's settings
function useServerEntry(name: string): ServerEntry {
const form = useSettingsForm();
return (form.useValue(serverKey(name)) ?? {}) as ServerEntry;
}
/** Where the executable was found, or what is wrong with it. */
function executableHint(tool: ToolInfo, pending: boolean): ReactNode {
if (pending) return <T>Checked when the settings are applied.</T>;
if (tool.customError) return <span className={styles.error}>{tool.customError}</span>;
if (tool.path) {
return (
<>
<T>Using</T> <code className={styles.code}>{tool.path}</code>
{tool.version && <> ({tool.version})</>}
</>
);
}
return tool.installHint ?? <T>Not found on PATH.</T>;
}
function ExecutableRow({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
const form = useSettingsForm();
const entry = useServerEntry(tool.name);
const custom = (entry.languages?.length ?? 0) > 0;
// A server defined in settings names its executable itself; others may point elsewhere.
const key = custom ? serverKey(tool.name, "command") : toolPathKey(tool.command);
const tools = form.useValue("tools") as Record<string, { path: string }> | undefined;
const value = custom ? entry.command : tools?.[tool.command]?.path;
const set = (path: string | null) => {
if (custom || !tool.command.includes(".")) form.set(key, path);
else {
// A command with dots can't be a key segment: replace the map.
const next = { ...tools };
if (path) next[tool.command] = { path };
else delete next[tool.command];
form.set("tools", next);
}
};
return (
<Field<string>
key={key}
label={custom ? "Command" : "Path"}
value={value}
format={(v) => v ?? ""}
parse={(t) => t.trim() || null}
onChange={set}
placeholder={custom ? tx("Executable name or path") : tx("Auto-detect")}
hint={executableHint(tool, pending)}
trailing={
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const picked = await toolsBackend().chooseExecutable(tx`Select ${tool.command || tool.name}`, value);
if (picked) set(picked);
}}
/>
}
/>
);
}
/** Debug adapters: only where the executable is. */
export function ToolSettings({ tool }: { tool: ToolInfo }) {
return (
<Section title="Executable">
<ExecutableRow tool={tool} pending={false} />
</Section>
);
}
/** A language server: on/off, executable, startup options, workspace configuration. */
export function ServerSettings({ tool, pending }: { tool: ToolInfo; pending: boolean }) {
const form = useSettingsForm();
const entry = useServerEntry(tool.name);
const custom = (entry.languages?.length ?? 0) > 0;
const set = (field: ServerField) => (value: unknown) => form.set(serverKey(tool.name, field), value);
const known = useLanguageChoices();
const languagesId = useId();
const languages = custom ? languageNames(entry.languages ?? [], known) : tool.languages;
const defaultOptions = useMemo(() => {
try {
return tool.defaultInitializationOptions ? JSON.parse(tool.defaultInitializationOptions) : null;
} catch {
return null;
}
}, [tool.defaultInitializationOptions]);
const overridden = !custom && Object.keys(entry).length > 0;
return (
<div className={styles.form}>
<Checkbox
checked={entry.enabled !== false}
label={
languages.length ? tx`Use ${tool.displayName} for ${languages.join(", ")} files` : tx`Use ${tool.displayName}`
}
// Enabled is the default; a project says so explicitly to win over a global "off".
onChange={(on) => set("enabled")(on ? (form.scope === "project" ? true : null) : false)}
/>
<Section title="Executable">
<ExecutableRow tool={tool} pending={pending} />
{custom && (
<>
<Row label="Languages" htmlFor={languagesId}>
<LanguagePicker
id={languagesId}
value={entry.languages ?? []}
invalid={!entry.languages?.length}
onChange={(ids) => set("languages")(ids.length ? ids : null)}
/>
</Row>
<Field<string[]>
label="Root markers"
value={entry.rootMarkers}
format={formatList}
parse={parseList}
onChange={set("rootMarkers")}
placeholder="pyproject.toml, .git"
hint={<T>Files marking the workspace root.</T>}
/>
</>
)}
</Section>
<Section
title="Startup"
note="Changing these restarts the server."
collapsible
defaultOpen={!!(entry.args || entry.env || entry.initializationOptions)}
>
<Field<string[]>
label="Arguments"
value={entry.args ?? undefined}
format={formatArgs}
parse={parseArgs}
onChange={set("args")}
placeholder={formatArgs(tool.defaultArgs) || tx("None")}
/>
<Field<Record<string, string>>
label="Environment variables"
value={entry.env}
format={formatEnv}
parse={parseEnv}
onChange={set("env")}
placeholder="NAME=value; OTHER=value"
/>
<Field<Record<string, unknown>>
label="Initialization options"
multiline
value={entry.initializationOptions as Record<string, unknown> | undefined}
format={formatJson}
parse={parseJsonObject}
onChange={set("initializationOptions")}
placeholder={defaultOptions ? formatJson(defaultOptions) : '{ "key": "value" }'}
hint={defaultOptions ? <T>Merged over the defaults shown when empty.</T> : undefined}
/>
</Section>
<Section
title="Workspace Configuration"
note="Sent to the running server; no restart needed."
collapsible
defaultOpen={!!entry.settings}
>
<Field<Record<string, unknown>>
label="Settings"
multiline
value={entry.settings as Record<string, unknown> | undefined}
format={formatJson}
parse={parseJsonObject}
onChange={set("settings")}
placeholder={'{ "section": { "key": "value" } }'}
/>
</Section>
{overridden && (
<div>
<button type="button" className={styles.link} onClick={() => form.set(serverKey(tool.name), null)}>
<T>Restore Defaults</T>
</button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// New Language Server
/** Asks for a name, command and languages; stages the new server on OK. */
export function NewServerDialog({
existing,
onClose,
onAdded,
}: {
existing: readonly string[];
onClose: () => void;
onAdded: (name: string) => void;
}) {
const form = useSettingsForm();
const ids = { name: useId(), command: useId(), languages: useId() };
const [commandLine, setCommandLine] = useState("");
const [name, setName] = useState<string | null>(null);
const [languages, setLanguages] = useState<string[]>([]);
// "ruff server": the executable, then its arguments.
let words: string[] = [];
let quoteError: string | null = null;
try {
words = parseArgs(commandLine) ?? [];
} catch (e) {
quoteError = e instanceof Error ? e.message : String(e);
}
const [command = "", ...args] = words;
const effectiveName = (name ?? nameFromCommand(command)).trim();
const problem =
quoteError ??
(!command
? tx("Enter the command that starts the server.")
: !SERVER_NAME.test(effectiveName)
? tx("Name: letters, digits, - _ + only.")
: existing.includes(effectiveName)
? tx`${effectiveName} already exists.`
: !languages.length
? tx("Choose the languages it serves.")
: null);
const add = () => {
if (problem) return;
const entry: ServerEntry = { command, languages };
if (args.length) entry.args = args;
form.set(serverKey(effectiveName), entry);
onAdded(effectiveName);
};
return (
<Dialog
open
onClose={onClose}
title={tx("New Language Server")}
width={480}
buttons={[
{ label: "OK", role: "default", disabled: !!problem, title: problem ?? undefined, onClick: add },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.dialogBody}>
<Row label="Command" htmlFor={ids.command}>
<Input
id={ids.command}
mono
autoFocus
className={styles.grow}
value={commandLine}
placeholder="ruff server"
onChange={(e) => setCommandLine(e.target.value)}
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const picked = await toolsBackend().chooseExecutable(tx("Select the server executable"), command);
if (picked) setCommandLine(formatArgs([picked, ...args]));
}}
/>
</Row>
<Row label="Name" htmlFor={ids.name}>
<Input
id={ids.name}
mono
className={styles.grow}
value={name ?? nameFromCommand(command)}
onChange={(e) => setName(e.target.value)}
/>
</Row>
<Row label="Languages" htmlFor={ids.languages}>
<LanguagePicker id={ids.languages} value={languages} onChange={setLanguages} />
</Row>
<div className={styles.note}>
{problem ?? <T>Any language server that talks LSP over stdio, with its arguments.</T>}
</div>
</div>
</Dialog>
);
}
+169
Переглянути файл
@@ -0,0 +1,169 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useProject } from "@/platform/project";
import { provideNotificationService } from "@/workbench/contributions";
import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend";
import { LanguageServersPage } from "../LanguageServersPage";
import { resetLanguageChoices } from "../ServerSettings";
import { useTools } from "../store";
import { formatArgs, nameFromCommand, parseArgs, parseEnv, parseJsonObject, parseList } from "../serverConfig";
import { resetTestForm, stagedFor, TestSettingsForm, useTestForm } from "./settingsForm";
describe("server settings text fields", () => {
it("splits arguments like a shell and formats them back", () => {
expect(parseArgs(`--stdio --log "a b" 'c"d' e\\ f`)).toEqual(["--stdio", "--log", "a b", 'c"d', "e f"]);
expect(parseArgs(" ")).toBeNull();
expect(parseArgs('""')).toEqual([""]);
expect(() => parseArgs('"open')).toThrow(/Unclosed/);
const args = ["--x", "a b", 'q"t', ""];
expect(parseArgs(formatArgs(args))).toEqual(args);
});
it("parses environment variables separated by semicolons", () => {
expect(parseEnv("A=1; B_2 = x=y;\n C=")).toEqual({ A: "1", B_2: "x=y", C: "" });
expect(parseEnv(" ; ")).toBeNull();
expect(() => parseEnv("A=1; nope")).toThrow("Expected NAME=value: nope");
});
it("accepts JSON objects without nulls", () => {
expect(parseJsonObject('{ "a": { "b": [1] } }')).toEqual({ a: { b: [1] } });
expect(parseJsonObject(" ")).toBeNull();
expect(() => parseJsonObject("[1]")).toThrow("Expected a JSON object");
expect(() => parseJsonObject('{ "a": { "b": null } }')).toThrow("at a.b");
expect(() => parseJsonObject("{")).toThrow();
});
it("splits lists and names servers after their executable", () => {
expect(parseList("python, typescript python")).toEqual(["python", "typescript"]);
expect(parseList(",")).toBeNull();
expect(nameFromCommand("/opt/bin/ruff")).toBe("ruff");
expect(nameFromCommand("C:\\tools\\my server.exe")).toBe("my-server");
});
});
describe("Language Servers page settings", () => {
let backend: MemoryToolsBackend;
const listItem = (name: string) =>
screen.getByRole("list", { name: /language servers/i }).querySelector<HTMLElement>(`[data-tool="${name}"]`);
const details = (name: string) => screen.getByLabelText(`${name} details`);
const renderPage = () =>
render(
<TestSettingsForm>
<LanguageServersPage />
</TestSettingsForm>,
);
beforeEach(() => {
provideNotificationService({ notify: () => {} });
backend = new MemoryToolsBackend(
sampleTools().map((t) =>
t.name === "rust-analyzer"
? { ...t, defaultArgs: ["--stdio"], defaultInitializationOptions: '{"checkOnSave":true}' }
: t,
),
1,
);
setToolsBackend(backend);
resetLanguageChoices();
resetTestForm();
useTools.setState({ tools: [], loaded: false, loading: false, error: null, jobs: {}, logs: {} });
useProject.setState({ project: null, settings: null });
});
afterEach(() => setToolsBackend(null));
it("stages startup options, showing the defaults they replace", async () => {
const user = userEvent.setup();
renderPage();
const pane = await screen.findByLabelText("rust-analyzer details");
// Folded while nothing is set there.
expect(within(pane).queryByRole("textbox", { name: "Arguments" })).toBeNull();
await user.click(within(pane).getByRole("button", { name: "Startup" }));
const args = within(pane).getByRole("textbox", { name: "Arguments" });
expect(args).toHaveAttribute("placeholder", "--stdio");
expect(within(pane).getByRole("textbox", { name: "Initialization options" })).toHaveAttribute(
"placeholder",
JSON.stringify({ checkOnSave: true }, null, 2),
);
await user.type(args, '--log "a b"');
expect(stagedFor("languageServers.rust-analyzer.args")).toEqual(["--log", "a b"]);
await user.type(within(pane).getByRole("textbox", { name: "Environment variables" }), "RA_LOG=info");
expect(stagedFor("languageServers.rust-analyzer.env")).toEqual({ RA_LOG: "info" });
// Invalid JSON is marked and not staged; valid JSON is.
const init = within(pane).getByRole("textbox", { name: "Initialization options" });
await user.click(init);
await user.paste('{ "check": null }');
expect(within(pane).getByRole("alert")).toHaveTextContent("null is not supported");
expect(stagedFor("languageServers.rust-analyzer.initializationOptions")).toBeUndefined();
await user.clear(init);
await user.paste('{ "check": { "command": "clippy" } }');
expect(stagedFor("languageServers.rust-analyzer.initializationOptions")).toEqual({ check: { command: "clippy" } });
// Turned off: marked in the list.
await user.click(within(pane).getByRole("checkbox", { name: /Use rust-analyzer for Rust files/ }));
expect(stagedFor("languageServers.rust-analyzer.enabled")).toBe(false);
expect(listItem("rust-analyzer")).toHaveTextContent("Disabled");
await user.click(within(pane).getByRole("button", { name: "Restore Defaults" }));
expect(stagedFor("languageServers.rust-analyzer")).toBeNull();
expect(listItem("rust-analyzer")).not.toHaveTextContent("Disabled");
expect(args).toHaveValue("");
});
it("adds a server of one's own from its command line, then removes it", async () => {
const user = userEvent.setup();
renderPage();
await screen.findByLabelText("rust-analyzer details");
await user.click(screen.getByRole("button", { name: "Add Language Server…" }));
const dialog = screen.getByRole("dialog", { name: "New Language Server" });
const ok = within(dialog).getByRole("button", { name: "OK" });
await user.type(within(dialog).getByRole("textbox", { name: "Command" }), "/opt/bin/ruff server");
expect(within(dialog).getByRole("textbox", { name: "Name" })).toHaveValue("ruff");
expect(ok).toBeDisabled();
expect(dialog).toHaveTextContent("Choose the languages it serves.");
const picker = within(dialog).getByRole("button", { name: "Languages" });
expect(picker).toHaveTextContent("Choose languages…");
await user.click(picker);
await user.click(await screen.findByRole("checkbox", { name: "Python" }));
await user.keyboard("{Escape}");
expect(picker).toHaveTextContent("Python");
await user.click(ok);
expect(stagedFor("languageServers.ruff")).toEqual({
command: "/opt/bin/ruff",
args: ["server"],
languages: ["python"],
});
// Listed and selected before Apply, as a new server.
await waitFor(() => expect(listItem("ruff")).toHaveAttribute("aria-current", "true"));
expect(listItem("ruff")).toHaveTextContent("New");
const pane = details("ruff");
expect(within(pane).getByRole("textbox", { name: "Command" })).toHaveValue("/opt/bin/ruff");
expect(within(pane).getByRole("textbox", { name: "Arguments" })).toHaveValue("server");
expect(pane).toHaveTextContent("Checked when the settings are applied.");
// Edits go into the staged entry.
await user.type(within(pane).getByRole("textbox", { name: "Root markers" }), "pyproject.toml");
expect(useTestForm.getState().values).toMatchObject({
languageServers: { ruff: { rootMarkers: ["pyproject.toml"] } },
});
await user.click(screen.getByRole("button", { name: "Remove Language Server" }));
expect(stagedFor("languageServers.ruff")).toBeNull();
expect(listItem("ruff")).toBeNull();
});
it("keeps the remove button for servers of one's own", async () => {
const user = userEvent.setup();
resetTestForm({ languageServers: { ruff: { command: "ruff", languages: ["python"] } } });
renderPage();
await screen.findByLabelText("rust-analyzer details");
expect(screen.getByRole("button", { name: "Remove Language Server" })).toBeDisabled();
await user.click(listItem("ruff")!);
expect(screen.getByRole("button", { name: "Remove Language Server" })).toBeEnabled();
});
});
+48
Переглянути файл
@@ -0,0 +1,48 @@
/** A Settings dialog stand-in: a page's form over plain values, staging into `staged`. */
import { useMemo, type ReactNode } from "react";
import { create } from "zustand";
import { SettingsFormContext, type SettingsForm } from "@/workbench/contributions";
interface FormState {
values: Record<string, unknown>;
/** Staged writes in order, as `settingsSet` would get them on Apply. */
staged: Array<[string, unknown]>;
}
export const useTestForm = create<FormState>(() => ({ values: {}, staged: [] }));
const getAt = (v: unknown, key: string) =>
key
.split(".")
.reduce<unknown>((cur, k) => (cur && typeof cur === "object" ? (cur as Record<string, unknown>)[k] : undefined), v);
function putAt(source: unknown, key: string, value: unknown): Record<string, unknown> {
const [head, ...rest] = key.split(".");
const base = { ...(source as Record<string, unknown> | undefined) };
if (rest.length) base[head!] = putAt(base[head!], rest.join("."), value);
else if (value == null) delete base[head!];
else base[head!] = value;
return base;
}
export function resetTestForm(values: Record<string, unknown> = {}) {
useTestForm.setState({ values, staged: [] });
}
/** The last value staged for `key`. */
export function stagedFor(key: string): unknown {
return useTestForm.getState().staged.findLast(([k]) => k === key)?.[1];
}
export function TestSettingsForm({ children }: { children: ReactNode }) {
const form = useMemo<SettingsForm>(
() => ({
scope: "global",
useValue: (key) => useTestForm((s) => getAt(s.values, key)),
set: (key, value) =>
useTestForm.setState((s) => ({ values: putAt(s.values, key, value), staged: [...s.staged, [key, value]] })),
}),
[],
);
return <SettingsFormContext.Provider value={form}>{children}</SettingsFormContext.Provider>;
}
+24 -13
Переглянути файл
@@ -8,6 +8,7 @@ import { MemoryToolsBackend, sampleTools, setToolsBackend } from "../backend";
import { cancelInstall, installTool, refreshTools, uninstallTool, useTools } from "../store";
import { filterTools, LanguageServersPage } from "../LanguageServersPage";
import { installMissingServerPrompts, missingKey } from "../missing";
import { resetTestForm, stagedFor, TestSettingsForm } from "./settingsForm";
type Note = Parameters<NotificationService["notify"]>[0];
let notes: Note[];
@@ -126,12 +127,19 @@ describe("missing language server prompt", () => {
});
describe("LanguageServersPage", () => {
beforeEach(() => resetTestForm());
const renderPage = () =>
render(
<TestSettingsForm>
<LanguageServersPage />
</TestSettingsForm>,
);
const listItem = (name: string) =>
screen.getByRole("list", { name: /language servers/i }).querySelector<HTMLElement>(`[data-tool="${name}"]`)!;
it("lists tools and installs from the details pane", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
renderPage();
await screen.findByText("gopls");
await user.click(listItem("gopls"));
const details = screen.getByLabelText("gopls details");
@@ -148,7 +156,7 @@ describe("LanguageServersPage", () => {
it("explains why a tool cannot be installed", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
renderPage();
await screen.findByText("solargraph");
await user.click(listItem("solargraph"));
expect(screen.getByText("gem install solargraph")).toBeInTheDocument();
@@ -156,23 +164,26 @@ describe("LanguageServersPage", () => {
expect(screen.getByText("curl is required to download tools")).toBeInTheDocument();
});
it("sets and clears a custom path", async () => {
it("stages and clears a custom path", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
renderPage();
await screen.findByText("gopls");
await user.click(listItem("gopls"));
const path = within(screen.getByLabelText("gopls details")).getByRole("textbox", { name: "Path" });
await user.type(path, "/opt/go/bin/gopls{Enter}");
await waitFor(() => expect(screen.getByLabelText("gopls details")).toHaveTextContent("Custom"));
expect(backend.customPaths).toEqual({ gopls: "/opt/go/bin/gopls" });
await user.click(screen.getByRole("button", { name: "Use auto-detected" }));
await waitFor(() => expect(backend.customPaths).toEqual({}));
expect(screen.getByLabelText("gopls details")).toHaveTextContent("Missing");
await user.type(path, "/opt/go/bin/gopls");
expect(stagedFor("tools.gopls.path")).toBe("/opt/go/bin/gopls");
await user.clear(path);
expect(stagedFor("tools.gopls.path")).toBeNull();
// Picked with the file chooser.
backend.chosen = "/usr/local/bin/gopls";
await user.click(within(screen.getByLabelText("gopls details")).getByRole("button", { name: "Browse…" }));
expect(stagedFor("tools.gopls.path")).toBe("/usr/local/bin/gopls");
expect(path).toHaveValue("/usr/local/bin/gopls");
});
it("moves the selection with the arrow keys", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
renderPage();
await screen.findByText("gopls");
await user.click(listItem("rust-analyzer"));
await user.keyboard("{ArrowDown}");
@@ -181,7 +192,7 @@ describe("LanguageServersPage", () => {
it("filters to missing tools", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
renderPage();
await screen.findByText("gopls");
await user.click(screen.getByRole("radio", { name: /missing/i }));
expect(screen.queryByText("rust-analyzer")).not.toBeInTheDocument();
@@ -193,7 +204,7 @@ describe("LanguageServersPage", () => {
it("shows backend errors", async () => {
vi.spyOn(backend, "list").mockRejectedValueOnce(new Error("boom"));
render(<LanguageServersPage />);
renderPage();
expect(await screen.findByRole("alert")).toHaveTextContent("boom");
});
});
+20 -20
Переглянути файл
@@ -5,7 +5,11 @@
import { Channel } from "@tauri-apps/api/core";
import type { InstallEvent, RequestId, ToolInfo } from "@/bindings";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
import { useProject } from "@/platform/project";
export interface LanguageChoice {
id: string;
name: string;
}
export interface ToolsBackend {
list(): Promise<ToolInfo[]>;
@@ -14,10 +18,10 @@ export interface ToolsBackend {
cancel(request: RequestId): void;
/** Restarts running language servers named `name` (after an install). */
restartServers(name: string): Promise<void>;
/** Launches `command` from `path` instead of the detected one (global setting); "" clears it. */
setCustomPath(command: string, path: string): Promise<void>;
/** File chooser for an executable; null when cancelled. */
chooseExecutable(title: string, defaultPath?: string): Promise<string | null>;
/** Languages a server defined in settings can serve. */
languages(): Promise<LanguageChoice[]>;
}
export const tauriToolsBackend: ToolsBackend = {
@@ -40,18 +44,13 @@ export const tauriToolsBackend: ToolsBackend = {
servers.filter((s) => s.name === name).map((s) => call(commands.lspRestart(s.id)).catch(() => {})),
);
},
setCustomPath: async (command, path) => {
const tools = { ...(await commands.settingsGet()).tools };
if (path) tools[command] = { path };
else delete tools[command];
const settings = await call(commands.settingsSet("global", "tools", JSON.stringify(tools)));
useProject.setState({ settings });
},
chooseExecutable: async (title, defaultPath) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: false, multiple: false, title, defaultPath: defaultPath || undefined });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
languages: async () =>
(await commands.languages()).map((l) => ({ id: l.id, name: l.name })).sort((a, b) => a.name.localeCompare(b.name)),
};
function tool(partial: Partial<ToolInfo> & Pick<ToolInfo, "name" | "languages">): ToolInfo {
@@ -69,6 +68,8 @@ function tool(partial: Partial<ToolInfo> & Pick<ToolInfo, "name" | "languages">)
installHint: null,
homepage: null,
customError: null,
defaultArgs: [],
defaultInitializationOptions: null,
...partial,
};
}
@@ -202,22 +203,21 @@ export class MemoryToolsBackend implements ToolsBackend {
this.restarted.push(name);
}
/** Paths set through `setCustomPath`, by command. */
customPaths: Record<string, string> = {};
/** What `chooseExecutable` answers. */
chosen: string | null = null;
async setCustomPath(command: string, path: string) {
if (path) this.customPaths[command] = path;
else delete this.customPaths[command];
for (const t of this.tools.filter((x) => x.command === command)) {
this.replace(path ? { ...t, status: "custom", path, version: null } : { ...t, status: "missing", path: null });
}
}
async chooseExecutable() {
return this.chosen;
}
async languages() {
return [
{ id: "go", name: "Go" },
{ id: "python", name: "Python" },
{ id: "rust", name: "Rust" },
{ id: "typescript", name: "TypeScript" },
];
}
}
let backend: ToolsBackend | null = null;
+13 -1
Переглянути файл
@@ -19,7 +19,19 @@ export function activateSettingsPages(): () => void {
title: "Language Servers",
group: "Languages & Frameworks",
order: 400,
keywords: ["lsp", "language server", "debugger", "adapter", "dap", "install", "rust-analyzer", "gopls", "pyright"],
keywords: [
"lsp",
"language server",
"debugger",
"adapter",
"dap",
"install",
"initialization options",
"rust-analyzer",
"gopls",
"pyright",
],
projectScope: true,
component: lazyComponent(() => import("./LanguageServersPage").then((m) => m.LanguageServersPage)),
});
}
+119
Переглянути файл
@@ -0,0 +1,119 @@
/**
* Settings › Language Servers entries (`languageServers.<name>` in settings.toml):
* text ↔ value conversions for the editor fields. Parsers return `null` for an
* empty field (the setting is removed) and throw `Error` with a user-facing message.
*/
import type { LanguageServerSettings } from "@/bindings";
/** What the settings set for a server (any field may be absent). */
export type ServerEntry = Partial<LanguageServerSettings>;
export type ServerField = keyof LanguageServerSettings;
/** Settings keys of a server's entry and of a tool's executable. */
export const serverKey = (name: string, field?: ServerField) =>
field ? `languageServers.${name}.${field}` : `languageServers.${name}`;
export const toolPathKey = (command: string) => `tools.${command}.path`;
/** The server's name proposed for an executable (`/opt/bin/ruff` → `ruff`). */
export function nameFromCommand(command: string): string {
const base = command.trim().split(/[\\/]/).pop() ?? "";
return base.replace(/\.(exe|cmd|bat)$/i, "").replace(/[^A-Za-z0-9_+-]/g, "-");
}
/** Names become settings keys: no dots or spaces. */
export const SERVER_NAME = /^[A-Za-z0-9][A-Za-z0-9_+-]*$/;
/** Shell-like split: whitespace separates, quotes group, backslash escapes. */
export function parseArgs(text: string): string[] | null {
const out: string[] = [];
let cur = "";
let started = false;
let quote: '"' | "'" | null = null;
for (let i = 0; i < text.length; i++) {
const c = text[i]!;
if (quote) {
if (c === quote) quote = null;
else if (c === "\\" && quote === '"' && i + 1 < text.length) cur += text[++i];
else cur += c;
} else if (c === '"' || c === "'") {
quote = c;
started = true;
} else if (c === "\\" && i + 1 < text.length) {
cur += text[++i];
started = true;
} else if (/\s/.test(c)) {
if (started) out.push(cur);
cur = "";
started = false;
} else {
cur += c;
started = true;
}
}
if (quote) throw new Error(`Unclosed ${quote} quote`);
if (started) out.push(cur);
return out.length ? out : null;
}
export function formatArgs(args: readonly string[] | undefined | null): string {
return (args ?? []).map((a) => (a === "" || /[\s"'\\]/.test(a) ? `"${a.replace(/["\\]/g, "\\$&")}"` : a)).join(" ");
}
/** `NAME=value` pairs separated by `;` (or new lines), as in run configurations. */
export function parseEnv(text: string): Record<string, string> | null {
const env: Record<string, string> = {};
for (const raw of text.split(/[;\n]/)) {
const pair = raw.trim();
if (!pair) continue;
const eq = pair.indexOf("=");
const key = eq < 0 ? "" : pair.slice(0, eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error(`Expected NAME=value: ${pair}`);
env[key] = pair.slice(eq + 1).trim();
}
return Object.keys(env).length ? env : null;
}
export function formatEnv(env: Record<string, string> | undefined | null): string {
return Object.entries(env ?? {})
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}
/** A JSON object (settings.toml has no `null`, so neither may the object). */
export function parseJsonObject(text: string): Record<string, unknown> | null {
if (!text.trim()) return null;
let value: unknown;
try {
value = JSON.parse(text);
} catch (e) {
throw new Error(e instanceof Error ? e.message : String(e), { cause: e });
}
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Expected a JSON object");
const nullAt = findNull(value, "");
if (nullAt !== null) throw new Error(`null is not supported (at ${nullAt || "top level"})`);
return value as Record<string, unknown>;
}
function findNull(v: unknown, path: string): string | null {
if (v === null) return path;
if (typeof v !== "object") return null;
for (const [k, child] of Object.entries(v as Record<string, unknown>)) {
const hit = findNull(child, Array.isArray(v) ? `${path}[${k}]` : path ? `${path}.${k}` : k);
if (hit !== null) return hit;
}
return null;
}
export function formatJson(value: unknown): string {
return value == null ? "" : JSON.stringify(value, null, 2);
}
/** Comma / whitespace separated names. */
export function parseList(text: string): string[] | null {
const items = text.split(/[\s,]+/).filter(Boolean);
return items.length ? [...new Set(items)] : null;
}
export function formatList(items: readonly string[] | undefined | null): string {
return (items ?? []).join(", ");
}
-17
Переглянути файл
@@ -180,23 +180,6 @@ export async function uninstallTool(name: string): Promise<boolean> {
}
}
/** Launches `command` from `path` (empty: auto-detect again), then re-checks the tools. */
export async function setCustomPath(command: string, path: string): Promise<boolean> {
try {
await toolsBackend().setCustomPath(command, path.trim());
await refreshTools();
return true;
} catch (e) {
notifications().notify({
group: "tools",
level: "error",
title: `Failed to set the path of ${command}`,
body: errorText(e),
});
return false;
}
}
export const STATUS_LABEL: Record<ToolInfo["status"], string> = {
installed: "Installed",
onPath: "On PATH",
+31 -5
Переглянути файл
@@ -847,6 +847,7 @@
"Hide Library Frames": "隐藏库帧",
"Hide Replace": "隐藏替换",
"Hide debug window on process termination": "进程终止时隐藏调试窗口",
"Auto-detect": "自动检测",
"Hide install log": "隐藏安装日志",
"Hierarchies": "层次结构",
"Hierarchy": "层次结构",
@@ -2384,14 +2385,11 @@
"Language server": "语言服务器",
"Path": "路径",
"Missing": "缺失",
"Leave empty to use the IDE-installed copy, else the one on PATH.": "留空则使用 IDE 安装的副本,否则使用 PATH 上的版本。",
"Installs into the IDE's own tools folder via": "安装到 IDE 自己的工具目录,方式:",
"Installs into the IDE's own tools folder via {installMethod}": "通过 {installMethod} 安装到 IDE 自己的工具文件夹",
"No tool selected": "未选择工具",
"Use auto-detected": "使用自动检测",
"Custom": "自定义",
"On PATH": "PATH 中",
"Reinstall": "重新安装",
"Auto-detect": "自动检测",
"Move focus to the Editor with Escape": "按 Escape 将焦点移到编辑器",
"Otherwise Escape goes to the shell.": "否则 Escape 发送给 shell。",
"Configure terminal keybindings": "配置终端快捷键",
@@ -3596,7 +3594,6 @@
"Failed to download JDK {version}": "下载 JDK {version} 失败",
"Java Settings…": "Java 设置…",
"{name} needs JDK {version} to run": "{name} 需要 JDK {version} 才能运行",
"Failed to set the path of {command}": "设置 {command} 的路径失败",
"Search for branches, actions, repositories": "搜索分支、操作、仓库",
"Git Settings": "Git 设置",
"Repositories and branches": "仓库和分支",
@@ -3976,6 +3973,35 @@
"Delete the layout '{name}'?": "是否删除布局 '{name}'?",
"In {left}, not in {right}": "在 {left} 中,不在 {right} 中",
"Local branch '{local}' has commits that do not exist in '{remote}'. Rebase '{local}' onto '{remote}', or drop local commits?": "本地分支 '{local}' 有 '{remote}' 中不存在的提交。是将 '{local}' 变基到 '{remote}',还是丢弃本地提交?",
"Add Language Server…": "添加语言服务器…",
"Use {displayName}": "使用 {displayName}",
"Select {name}": "选择 {name}",
"Root markers": "根标记",
"Initialization options": "初始化选项",
"Global": "全局",
"This project": "此项目",
"Executable name or path": "可执行文件名或路径",
"Select the server executable": "选择服务器可执行文件",
"Remove Language Server": "移除语言服务器",
"Missing ({missingCount})": "缺失 ({missingCount})",
"Choose languages…": "选择语言…",
"Filter languages": "筛选语言",
"No languages match.": "没有匹配的语言。",
"Checked when the settings are applied.": "应用设置后检查。",
"Using": "使用",
"Not found on PATH.": "在 PATH 中未找到。",
"Use {displayName} for {join} files": "对 {join} 文件使用 {displayName}",
"Files marking the workspace root.": "标记工作区根目录的文件。",
"Merged over the defaults shown when empty.": "与默认值(为空时显示)合并。",
"Workspace Configuration": "工作区配置",
"Enter the command that starts the server.": "请输入启动服务器的命令。",
"Name: letters, digits, - _ + only.": "名称:只能包含字母、数字、- _ +。",
"{effectiveName} already exists.": "{effectiveName} 已存在。",
"Choose the languages it serves.": "请选择它服务的语言。",
"New Language Server": "新建语言服务器",
"Any language server that talks LSP over stdio, with its arguments.": "任何通过 stdio 使用 LSP 的语言服务器,可带参数。",
"Changing these restarts the server.": "修改这些设置会重启服务器。",
"Sent to the running server; no restart needed.": "发送给运行中的服务器,无需重启。",
"SSH Configurations": "SSH 配置",
"User name": "用户名",
"Authentication type": "身份验证类型",
+4
Переглянути файл
@@ -182,9 +182,13 @@ const UNTRANSLATED = new Set<string>([
"kebab-case",
"KEY=value; OTHER=value",
"NAME",
"NAME=value; OTHER=value",
"node_modules/typescript",
"PascalCase",
"pyproject.toml, .git",
"ruff server",
"snake_case",
'{ "section": { "key": "value" } }',
// Identifiers that only look like text: icon names, error codes, categories, console output
"app",
"cancelled",
+48 -13
Переглянути файл
@@ -8,7 +8,15 @@
* The workbench renders whatever is registered; the action
* `toolwindow.<id>` toggles it (registered automatically).
*/
import { useEffect, useRef, type ComponentType, type ReactNode, type RefObject } from "react";
import {
createContext,
useContext,
useEffect,
useRef,
type ComponentType,
type ReactNode,
type RefObject,
} from "react";
import type { RunConfig } from "@/bindings";
import type { MenuItem } from "@/ui/Menu";
import { create } from "zustand";
@@ -453,7 +461,10 @@ export interface EmbeddedTerminalOptions {
"aria-label"?: string;
}
export type EmbeddedTerminalFactory = (host: HTMLElement, options: EmbeddedTerminalOptions) => Promise<EmbeddedTerminal>;
export type EmbeddedTerminalFactory = (
host: HTMLElement,
options: EmbeddedTerminalOptions,
) => Promise<EmbeddedTerminal>;
const services: {
editor?: EditorService;
@@ -587,6 +598,29 @@ export interface SettingsPageContribution {
/** Extra words matched by the settings search. */
keywords?: string[];
component: ComponentType;
/** The page edits settings that a project can override (Global / This project switch). */
projectScope?: boolean;
}
/**
* What a settings page edits through: values of the page's scope with the
* edits not applied yet. Edits are written on Apply / OK and dropped on Cancel.
*/
export interface SettingsForm {
scope: "global" | "project";
/** Value of a dotted key (map entries too, e.g. `languageServers.ruff.args`). */
useValue(key: string): unknown;
/** Stages a value; `null` removes the key on Apply. */
set(key: string, value: unknown): void;
}
export const SettingsFormContext = createContext<SettingsForm | null>(null);
/** The form of the settings page being shown (provided by the Settings dialog). */
export function useSettingsForm(): SettingsForm {
const form = useContext(SettingsFormContext);
if (!form) throw new Error("useSettingsForm outside a settings page");
return form;
}
export const useSettingsPages = create<{ pages: Map<string, SettingsPageContribution> }>(() => ({ pages: new Map() }));
@@ -684,14 +718,7 @@ export function projectViewRoots(map: ReadonlyMap<string, ProjectViewRootContrib
// in `paths[0]`, the target directory (features/templates: file templates, http-client: HTTP Request).
export type ContextMenuId =
| "editor"
| "projectView"
| "editorTab"
| "editorGutter"
| "openIn"
| "stopProcess"
| "selectIn"
| "new";
"editor" | "projectView" | "editorTab" | "editorGutter" | "openIn" | "stopProcess" | "selectIn" | "new";
export interface MenuGroupContext {
/** Files / directories the menu is for (absolute paths; a virtual URI for virtual documents). */
@@ -883,7 +910,9 @@ export interface RunConfigTypeContribution {
debugAdapter?: string;
}
export const useRunConfigTypes = create<{ types: Map<string, RunConfigTypeContribution> }>(() => ({ types: new Map() }));
export const useRunConfigTypes = create<{ types: Map<string, RunConfigTypeContribution> }>(() => ({
types: new Map(),
}));
export function registerRunConfigType(type: RunConfigTypeContribution): () => void {
useRunConfigTypes.setState((s) => ({ types: new Map(s.types).set(type.id, type) }));
@@ -1019,7 +1048,11 @@ export function setLocalChanges(provider: string, lists: LocalChangelist[]): ()
}
/** Paths of the changed files, of one list (`listId`) or all; `existing` leaves out deleted files. */
export function localChangedPaths(lists: readonly LocalChangelist[], listId?: string | null, existing = false): string[] {
export function localChangedPaths(
lists: readonly LocalChangelist[],
listId?: string | null,
existing = false,
): string[] {
const out: string[] = [];
for (const l of lists) {
if (listId && l.id !== listId) continue;
@@ -1067,7 +1100,9 @@ export function registerRunAnythingProvider(p: RunAnythingProvider): () => void
/** Registered providers in order. */
export function runAnythingProviderList(): RunAnythingProvider[] {
return [...runAnythingProviders.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id));
return [...runAnythingProviders.values()].sort(
(a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id),
);
}
// ---------------------------------------------------------------------------