дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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
Цей коміт міститься в:
1 джерело
025e0019d2
коміт
48ac2094fc
39 files changed
+2236
-353
No files matched your search
Створено
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}));
|
||||
}
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(¤t)
|
||||
{
|
||||
s.set_effective_config(next);
|
||||
let params = json!({ "settings": handler::all_settings(&s.effective_config()) });
|
||||
if let Err(e) = run.peer.notify("workspace/didChangeConfiguration", params) {
|
||||
tracing::debug!("{}: didChangeConfiguration: {e}", s.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match s.status() {
|
||||
@@ -1815,6 +1857,96 @@ impl LspManager {
|
||||
ide_core::event::ServerStatus::Stopped => {}
|
||||
}
|
||||
}
|
||||
self.attach_fallbacks();
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
}
|
||||
|
||||
/// A server enabled again shadows the fallbacks attached in its place: they
|
||||
/// leave the open documents, and stop once no document uses them.
|
||||
async fn detach_shadowed_fallbacks(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
let mut shadowed: Vec<(DocId, Vec<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>) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" |
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))),
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(", ");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "身份验证类型",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Заблокувати користувача