feat(tools): Custom language server paths and a list/details Language Servers page

Servers and debug adapters could only be found on PATH or in the IDE's
tools folder; the only way to pick another executable (a version that
matches the project's Elixir/OTP, a local build) was an undocumented
languages/*.toml override. `tools.<command>.path` now names the
executable to launch: language servers (configure_server, restarted on
change), debug sessions and the tool list honour it, the latter
reporting a Custom status with its version or why the path is unusable.

The Language Servers page is laid out like Plugins: a compact list
grouped by availability on the left, the selected tool's command, path,
location, version, install actions and hints on the right.

A debugger named like its language server (ElixirLS's elixir-ls /
elixir-ls-debugger) was folded into the server's row and never listed;
it now gets a row of its own.

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-24 11:26:31 +08:00
1 джерело 1572fc36ea
коміт 03a660354c
16 files changed
+761 -305

No files matched your search

+18
Переглянути файл
@@ -37,6 +37,23 @@ pub struct Settings {
pub terminal: TerminalSettings,
pub actions_on_save: ActionsOnSaveSettings,
pub external_tools: Vec<ExternalTool>,
/// Language servers / debug adapters at a user-chosen location, by the
/// command the language definition launches (`elixir-ls`, `codelldb`).
pub tools: BTreeMap<String, ToolSettings>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ToolSettings {
/// Executable to launch instead of the managed / `PATH` one; empty = auto-detect.
pub path: String,
}
impl Settings {
/// The configured executable for `command`, if any (`~/` not yet expanded).
pub fn tool_path(&self, command: &str) -> Option<&str> {
self.tools.get(command).map(|t| t.path.trim()).filter(|p| !p.is_empty())
}
}
impl Default for Settings {
@@ -59,6 +76,7 @@ impl Default for Settings {
terminal: TerminalSettings::default(),
actions_on_save: ActionsOnSaveSettings::default(),
external_tools: Vec::new(),
tools: BTreeMap::new(),
}
}
}
+14
Переглянути файл
@@ -522,6 +522,17 @@ impl DebugManager {
/// waits for `initialized`, sends breakpoints, then `configurationDone`.
/// Events of the session (including cargo build output) go to `owner` only.
pub async fn start(&self, config: LaunchConfig, owner: SessionOwner) -> IdeResult<DebugSessionId> {
self.start_with(config, owner, |_| None).await
}
/// [`start`](Self::start) with the adapter executable chosen by `custom_command`
/// (the configured command → a user-chosen executable, from settings).
pub async fn start_with(
&self,
config: LaunchConfig,
owner: SessionOwner,
custom_command: impl FnOnce(&str) -> Option<String>,
) -> IdeResult<DebugSessionId> {
let mut arguments = launch_arguments(&config)?;
let cwd = config
.cwd
@@ -531,6 +542,9 @@ impl DebugManager {
let dbg = adapter::find_config(&self.languages, &config.adapter)
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
let mut spec = adapter::resolve(&dbg, cwd.clone());
if let Some(command) = custom_command(&spec.command) {
spec.command = command;
}
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
if config.adapter == "debugpy" {
let python = match arguments.get("python") {
+4 -1
Переглянути файл
@@ -16,7 +16,7 @@ fn non_empty(s: &str) -> Option<&str> {
}
/// Resolves `~/` and project-relative paths of a setting.
fn setting_path(root: &Path, s: &str) -> Option<PathBuf> {
pub fn setting_path(root: &Path, s: &str) -> Option<PathBuf> {
let s = non_empty(s)?;
let p = match s.strip_prefix("~/") {
Some(rest) => crate::home_dir()?.join(rest),
@@ -153,6 +153,9 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
/// server settings (`python.pythonPath`, rust-analyzer `check.command`, `typescript.tsdk`).
/// `root` is the project root, `server_root` the server's workspace root.
pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root: &Path, s: &Settings) {
if let Some(custom) = s.tool_path(&cfg.command).and_then(|p| setting_path(root, p)) {
cfg.command = custom.to_string_lossy().into_owned();
}
let mut path_dirs: Vec<PathBuf> = Vec::new();
let name = cfg.name.clone();
let name = name.as_str();
+15
Переглянути файл
@@ -255,3 +255,18 @@ fn detect_on_this_machine() {
}
}
}
#[test]
fn custom_tool_path_replaces_the_command() {
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
s.tools.insert("elixir-ls".into(), ide_core::settings::ToolSettings { path: "tools/ls/language_server.sh".into() });
s.tools.insert("zls".into(), ide_core::settings::ToolSettings { path: " ".into() });
let mut cfg = server("elixir-ls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(Path::new(&cfg.command), tmp.path().join("tools/ls/language_server.sh"));
// A blank path keeps auto-detection.
let mut cfg = server("zls");
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
assert_eq!(cfg.command, "zls");
}
+54 -3
Переглянути файл
@@ -39,6 +39,8 @@ pub enum ToolStatus {
Installed,
/// Found on the system `PATH`.
OnPath,
/// The executable chosen in settings.
Custom,
Missing,
}
@@ -66,6 +68,8 @@ pub struct ToolInfo {
/// Manual install instructions from the language definition.
pub install_hint: Option<String>,
pub homepage: Option<String>,
/// Why the executable chosen in settings cannot be used (status is then `Missing`).
pub custom_error: Option<String>,
}
/// Streamed install progress.
@@ -212,8 +216,18 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
for s in &l.language_servers {
add(&s.name, &s.command, ToolKind::LanguageServer, s.install_hint.as_ref(), &l.name);
}
}
let servers: HashSet<&str> = langs.iter().flat_map(|l| &l.language_servers).map(|s| s.name.as_str()).collect();
for l in langs {
if let Some(d) = &l.debugger {
add(&d.adapter, &d.command, ToolKind::Debugger, d.install_hint.as_ref(), &l.name);
// An adapter named like a server but launched differently (ElixirLS's
// `elixir-ls-debugger`) is a tool of its own, listed by its command.
let name = if servers.contains(d.adapter.as_str()) && !TOOLS.iter().any(|t| t.name == d.adapter) {
&d.command
} else {
&d.adapter
};
add(name, &d.command, ToolKind::Debugger, d.install_hint.as_ref(), &l.name);
}
}
for (plugin, host) in registry::PLUGIN_HOSTS {
@@ -260,6 +274,8 @@ pub struct Tools {
/// `PATH` handed to installer child processes.
child_path: OsString,
platform: Platform,
/// Executables chosen in settings, by command.
custom: BTreeMap<String, PathBuf>,
}
impl Tools {
@@ -273,7 +289,13 @@ impl Tools {
let system: Vec<PathBuf> = std::env::split_paths(&search_path).filter(|d| !ours.contains(d)).collect();
let system_path = std::env::join_paths(&system).unwrap_or_default();
let child_path = std::env::join_paths(ours.iter().chain(system.iter())).unwrap_or_default();
Self { root, system_path, child_path, platform }
Self { root, system_path, child_path, platform, custom: BTreeMap::new() }
}
/// Reports the executables chosen in settings (command → path) as the tools' location.
pub fn with_custom_paths(mut self, custom: BTreeMap<String, PathBuf>) -> Self {
self.custom = custom;
self
}
pub fn root(&self) -> &Path {
@@ -394,7 +416,9 @@ impl Tools {
can_uninstall: false,
install_hint,
homepage: None,
});
custom_error: None,
})
.map(|info| self.apply_custom(info, None));
};
let (status, path, version) = self.detect(spec);
let unavailable_reason = self.unavailable_reason(spec);
@@ -414,7 +438,34 @@ impl Tools {
can_uninstall,
install_hint,
homepage: Some(spec.homepage.to_string()),
custom_error: None,
})
.map(|info| self.apply_custom(info, Some(spec)))
}
/// Overrides the detected location with the executable chosen in settings.
fn apply_custom(&self, mut info: ToolInfo, spec: Option<&ToolSpec>) -> ToolInfo {
let Some(path) = self.custom.get(&info.command) else { return info };
if !path.is_file() {
info.status = ToolStatus::Missing;
info.custom_error = Some(format!("{} does not exist", path.display()));
info.path = None;
info.version = None;
return info;
}
info.version = spec.and_then(|spec| {
let (cmd, args) = spec.version?;
// The version command is the tool itself, whatever the chosen file is called.
let program =
if cmd == spec.commands[0] { path.clone() } else { path.parent().and_then(|d| self.exe_in(d, cmd))? };
let mut c = self.command(program);
c.args(args);
let (ok, out) = run_quiet(c, Duration::from_secs(4))?;
if ok { parse_version(&out) } else { None }
});
info.status = ToolStatus::Custom;
info.path = Some(path.clone().into());
info
}
fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option<PathBuf>, Option<String>) {
+31
Переглянути файл
@@ -286,3 +286,34 @@ fn installed_bundle_is_detected_and_resolvable() {
assert_eq!(managed_bundle(app_data, "java-debug"), Some(bin.join("com.microsoft.java.debug.plugin.jar")));
assert_eq!(managed_bundle(app_data, "jdtls"), None, "not a bundle");
}
#[cfg(unix)]
#[test]
fn custom_paths_win_and_report_their_version() {
let env = Env::new();
env.script(&env.sys, "gopls", "echo 'golang.org/x/tools/gopls v0.16.1'");
let elsewhere = env.sys.parent().unwrap().join("opt/go/bin");
env.script(&elsewhere, "my-gopls", "echo 'golang.org/x/tools/gopls v0.17.0'");
let custom =
BTreeMap::from([("gopls".to_string(), elsewhere.join("my-gopls")), ("zls".to_string(), elsewhere.join("zls"))]);
let list = env.tools().with_custom_paths(custom).list(&langs());
let gopls = find(&list, "gopls");
assert_eq!(gopls.status, ToolStatus::Custom);
assert_eq!(gopls.path.as_deref(), Some(elsewhere.join("my-gopls").as_path()));
assert_eq!(gopls.version.as_deref(), Some("0.17.0"));
assert_eq!(gopls.custom_error, None);
// A path that does not exist is reported, not silently replaced by detection.
let zls = find(&list, "zls");
assert_eq!(zls.status, ToolStatus::Missing);
assert!(zls.custom_error.as_deref().unwrap().contains("does not exist"));
}
#[test]
fn a_debugger_named_like_its_server_is_listed_by_its_command() {
let list = Env::new().tools().list(&langs());
let server = find(&list, "elixir-ls");
assert_eq!((server.kind, server.command.as_str()), (ToolKind::LanguageServer, "elixir-ls"));
let debugger = find(&list, "elixir-ls-debugger");
assert_eq!((debugger.kind, debugger.command.as_str()), (ToolKind::Debugger, "elixir-ls-debugger"));
assert_eq!(debugger.languages, vec!["Elixir".to_string()]);
}
+2 -2
Переглянути файл
@@ -148,8 +148,8 @@ pub fn settings_set(
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
}
}
// A new interpreter / toolchain restarts the language servers it affects.
if key == "languages" || key.starts_with("languages.") {
// 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}."))) {
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
for project in affected {
let lsp = project.lsp.clone();
+10 -1
Переглянути файл
@@ -14,7 +14,16 @@ pub async fn debug_start(
) -> IdeResult<DebugSessionId> {
let label = window.label();
let owner = SessionOwner::new(AppState::owner_window(label), state.window_sink(label));
state.debugger.start(config, owner).await
let settings = match state.project(label) {
Ok(project) => project.settings.effective(),
Err(_) => state.global_settings.effective(),
};
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
let custom = |command: &str| {
let path = ide_sdk::apply::setting_path(&root, settings.tool_path(command)?)?;
Some(path.to_string_lossy().into_owned())
};
state.debugger.start_with(config, owner, custom).await
}
#[tauri::command]
+24 -8
Переглянути файл
@@ -4,11 +4,21 @@ use crate::state::AppState;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_tools::{InstallEvent, ToolInfo, Tools};
use tauri::ipc::Channel;
use tauri::{AppHandle, Manager, State};
use tauri::{AppHandle, Manager, State, Window};
fn tools(app: &AppHandle) -> IdeResult<Tools> {
/// The installer, reporting the executables chosen in the window's settings.
fn tools(app: &AppHandle, state: &AppState, window: &Window) -> IdeResult<Tools> {
let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?;
Ok(Tools::new(&data))
let (settings, root) = match state.project(window.label()) {
Ok(project) => (project.settings.effective(), project.root.clone()),
Err(_) => (state.global_settings.effective(), Default::default()),
};
let custom = settings
.tools
.iter()
.filter_map(|(command, t)| Some((command.clone(), ide_sdk::apply::setting_path(&root, &t.path)?)))
.collect();
Ok(Tools::new(&data).with_custom_paths(custom))
}
fn join_err(e: impl std::fmt::Display) -> IdeError {
@@ -18,8 +28,8 @@ fn join_err(e: impl std::fmt::Display) -> IdeError {
/// Status of every known language server / debug adapter and the languages using it.
#[tauri::command]
#[specta::specta]
pub async fn tools_list(app: AppHandle, state: State<'_, AppState>) -> IdeResult<Vec<ToolInfo>> {
let tools = tools(&app)?;
pub async fn tools_list(app: AppHandle, window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ToolInfo>> {
let tools = tools(&app, &state, &window)?;
let langs = state.languages.all();
tauri::async_runtime::spawn_blocking(move || tools.list(&langs)).await.map_err(join_err)
}
@@ -30,12 +40,13 @@ pub async fn tools_list(app: AppHandle, state: State<'_, AppState>) -> IdeResult
#[specta::specta]
pub async fn tools_install(
app: AppHandle,
window: Window,
state: State<'_, AppState>,
name: String,
request: RequestId,
on_event: Channel<InstallEvent>,
) -> IdeResult<ToolInfo> {
let tools = tools(&app)?;
let tools = tools(&app, &state, &window)?;
let langs = state.languages.all();
let cancel = state.begin_request(request);
let result = tauri::async_runtime::spawn_blocking(move || {
@@ -56,8 +67,13 @@ pub async fn tools_install(
/// Removes a tool the IDE installed.
#[tauri::command]
#[specta::specta]
pub async fn tools_uninstall(app: AppHandle, state: State<'_, AppState>, name: String) -> IdeResult<ToolInfo> {
let tools = tools(&app)?;
pub async fn tools_uninstall(
app: AppHandle,
window: Window,
state: State<'_, AppState>,
name: String,
) -> IdeResult<ToolInfo> {
let tools = tools(&app, &state, &window)?;
let langs = state.languages.all();
let info =
tauri::async_runtime::spawn_blocking(move || tools.uninstall(&name, &langs)).await.map_err(join_err)??;
+20 -1
Переглянути файл
@@ -3421,6 +3421,11 @@ export type Settings_Deserialize = {
terminal?: TerminalSettings,
actionsOnSave?: ActionsOnSaveSettings,
externalTools?: ExternalTool[],
/**
* Language servers / debug adapters at a user-chosen location, by the
* command the language definition launches (`elixir-ls`, `codelldb`).
*/
tools?: { [key in string]: ToolSettings },
};
export type Settings_Serialize = {
@@ -3454,6 +3459,11 @@ export type Settings_Serialize = {
terminal: TerminalSettings,
actionsOnSave: ActionsOnSaveSettings,
externalTools: ExternalTool[],
/**
* Language servers / debug adapters at a user-chosen location, by the
* command the language definition launches (`elixir-ls`, `codelldb`).
*/
tools: { [key in string]: ToolSettings },
};
export type Severity = "error" | "warning" | "info" | "hint";
@@ -3860,17 +3870,26 @@ export type ToolInfo = {
/** Manual install instructions from the language definition. */
installHint: string | null,
homepage: string | null,
/** Why the executable chosen in settings cannot be used (status is then `Missing`). */
customError: string | null,
};
export type ToolKind = "languageServer" | "debugger" |
/** Loaded into another tool's process, e.g. a jdtls agent. */
"plugin";
export type ToolSettings = {
/** Executable to launch instead of the managed / `PATH` one; empty = auto-detect. */
path?: string,
};
export type ToolStatus =
/** Installed by the IDE (managed tools dir or a toolchain component it manages). */
"installed" |
/** Found on the system `PATH`. */
"onPath" | "missing";
"onPath" |
/** The executable chosen in settings. */
"custom" | "missing";
export type TypeScriptSettings = {
/**
+175 -93
Переглянути файл
@@ -3,36 +3,58 @@
flex-direction: column;
gap: 8px;
min-width: 0;
height: 100%;
min-height: 420px;
}
.intro {
margin: 0 0 4px;
margin: 0;
max-width: 680px;
color: var(--fg-secondary);
}
.body {
display: flex;
flex: 1;
min-height: 0;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
}
/* list */
.side {
display: flex;
flex: none;
flex-direction: column;
width: 300px;
min-height: 0;
border-right: 1px solid var(--border);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
gap: 4px;
margin: 8px 8px 6px;
}
.search {
width: 240px;
}
.spacer {
flex: 1;
min-width: 0;
}
.segmented {
display: inline-flex;
display: flex;
height: var(--h-control-small);
margin: 0 8px 6px;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border-strong);
overflow: hidden;
}
.segment {
padding: 0 10px;
flex: 1;
padding: 0 6px;
border: 0;
background: transparent;
color: var(--fg-secondary);
font: inherit;
white-space: nowrap;
}
.segment:hover {
background: var(--bg-hover);
@@ -45,67 +67,70 @@
background: var(--bg-selected);
color: var(--fg-strong);
}
.error,
.rowError {
color: var(--danger);
}
.error {
padding: 6px 0;
}
.tableWrap {
min-width: 0;
.list {
flex: 1;
min-height: 0;
margin: 0;
padding: 0 0 4px;
list-style: none;
overflow: auto;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border);
}
.table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
.groupHeader {
display: flex;
align-items: center;
gap: 6px;
height: var(--h-row);
padding: 0 12px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.table th {
height: var(--h-header);
padding: 0 8px;
text-align: left;
font-weight: normal;
color: var(--fg-secondary);
background: var(--bg-island);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
white-space: nowrap;
}
.table td {
padding: 4px 8px;
vertical-align: middle;
}
/* IDEA tables: no row rules, selection-like hover. */
.row td {
border-top: 0;
}
.table th + th {
border-left: 1px solid var(--border);
}
.row:first-child td {
border-top: 0;
.groupCount {
font-variant-numeric: tabular-nums;
}
.row:hover {
background: var(--bg-hover);
}
.detailRow td {
padding-top: 0;
padding-bottom: 6px;
.rowSelected,
.rowSelected:hover {
background: var(--bg-selected);
}
.toolName {
.rowMain {
display: flex;
align-items: center;
gap: 6px;
min-height: var(--h-row);
gap: 8px;
width: 100%;
height: var(--h-row);
padding: 0 12px;
border: 0;
background: transparent;
color: var(--fg);
font: inherit;
text-align: left;
}
.display {
.rowMain:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.rowName {
flex: none;
max-width: 60%;
overflow: hidden;
color: var(--fg-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.rowLanguages {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--fg-muted);
font-size: var(--fs-small);
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.kindTag {
flex: none;
padding: 0 5px;
border-radius: var(--radius-small);
background: var(--bg-control);
@@ -113,35 +138,108 @@
font-size: var(--fs-tiny);
line-height: 16px;
}
.sub {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.listEmpty {
padding: 8px 12px;
color: var(--fg-muted);
font-family: var(--font-mono);
font-size: var(--fs-small);
}
.languages {
max-width: 200px;
/* details */
.details {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
min-width: 0;
padding: 12px 16px 16px;
overflow: auto;
}
.detailsEmpty {
flex: 1;
}
.header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.headerText {
min-width: 0;
}
.title {
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-strong);
font-size: var(--fs-input-large);
font-weight: 600;
}
.subtitle {
margin-top: 2px;
color: var(--fg-secondary);
}
.version {
font-variant-numeric: tabular-nums;
color: var(--fg-secondary);
white-space: nowrap;
}
/* Status, Version and actions take their content's width; Tool and Languages share the rest. */
.fitHead,
.actionsHead {
width: 1%;
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.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);
font-size: var(--fs-small);
}
.breakAll {
overflow-wrap: anywhere;
}
.version {
font-variant-numeric: tabular-nums;
}
.pathRow {
display: flex;
align-items: center;
gap: 4px;
}
.pathInput {
flex: 1;
min-width: 0;
}
.fieldHint {
margin-top: 2px;
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);
background: var(--bg-control);
color: var(--fg-secondary);
overflow-wrap: anywhere;
-webkit-user-select: text;
user-select: text;
}
.job {
display: flex;
@@ -166,14 +264,10 @@
font-family: var(--font-mono);
font-size: var(--fs-small);
}
.hint {
color: var(--fg-muted);
font-size: var(--fs-small);
}
.log {
margin: 4px 0 0;
margin: 0;
padding: 6px 8px;
max-height: 200px;
max-height: 240px;
overflow: auto;
background: var(--bg-app);
color: var(--fg-secondary);
@@ -184,15 +278,3 @@
-webkit-user-select: text;
user-select: text;
}
.empty {
padding: 12px;
color: var(--fg-muted);
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
+286 -182
Переглянути файл
@@ -1,12 +1,18 @@
/** Settings › Languages & Frameworks › Language Servers: status + install/uninstall of LSP servers and debug adapters. */
import { Fragment, useEffect, useMemo, useState } from "react";
/**
* 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.
*/
import { useEffect, useId, useMemo, useState, type KeyboardEvent } from "react";
import clsx from "clsx";
import type { ToolInfo } from "@/bindings";
import { Badge, Button, IconButton, ProgressBar, SearchField, Spinner, T } from "@/ui";
import { Badge, Button, EmptyState, IconButton, Input, ProgressBar, SearchField, Spinner, T } from "@/ui";
import { toolsBackend } from "./backend";
import {
cancelInstall,
installTool,
refreshTools,
setCustomPath,
STATUS_LABEL,
uninstallTool,
useTools,
@@ -19,9 +25,16 @@ type Filter = "all" | "missing" | "installed";
const STATUS_TONE: Record<ToolInfo["status"], "success" | "accent" | "warning"> = {
installed: "success",
onPath: "accent",
custom: "accent",
missing: "warning",
};
const KIND_LABEL: Record<ToolInfo["kind"], string> = {
languageServer: "Language server",
debugger: "Debugger",
plugin: "Plugin",
};
export function filterTools(tools: readonly ToolInfo[], query: string, filter: Filter): ToolInfo[] {
const q = query.trim().toLowerCase();
return tools.filter((t) => {
@@ -48,217 +61,308 @@ function JobRow({ name, job }: { name: string; job: InstallJob }) {
);
}
function Actions({
tool,
busy,
onToggleLog,
hasLog,
logOpen,
}: {
tool: ToolInfo;
busy: boolean;
hasLog: boolean;
logOpen: boolean;
onToggleLog: () => void;
}) {
const label = tool.displayName;
function ToolRow({ tool, selected, onSelect }: { tool: ToolInfo; selected: boolean; onSelect: () => void }) {
return (
<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 === "missing" ? "Install" : tool.status === "installed" ? "Reinstall" : "Install managed copy of"} ${label}`}
>
{tool.status === "missing" ? "Install" : tool.status === "installed" ? "Reinstall" : "Install"}
</Button>
<li className={clsx(styles.row, selected && styles.rowSelected)}>
<button
type="button"
className={styles.rowMain}
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>
<span className={styles.rowName}>{tool.displayName}</span>
{tool.kind !== "languageServer" && (
<span className={styles.kindTag}>
<T>{KIND_LABEL[tool.kind]}</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 }) {
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";
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>
</div>
<div className={styles.subtitle}>
<T>{KIND_LABEL[tool.kind]}</T>
{tool.languages.length > 0 && <> · {tool.languages.join(", ")}</>}
</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" ? "Reinstall" : "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>
{job && <JobRow name={tool.displayName} job={job} />}
{lastError && (
<div className={styles.error} role="alert">
{lastError}
</div>
)}
{tool.canUninstall && (
<Button
size="small"
variant="ghost"
disabled={busy}
onClick={() => void uninstallTool(tool.name)}
aria-label={`Uninstall ${label}`}
>
<T>Uninstall</T>
</Button>
{manualHint && (
<div className={styles.notice} role="note">
{tool.unavailableReason ?? tool.installHint ?? "Install it manually and put it on PATH."}
</div>
)}
{hasLog && (
<IconButton
icon="terminal"
size="small"
title={logOpen ? "Hide install log" : "Show install log"}
pressed={logOpen}
onClick={onToggleLog}
/>
{tool.canInstall && tool.status === "missing" && tool.unavailableReason === null && tool.installMethod && (
<div className={styles.fieldHint}>
<T>Installs into the IDE&apos;s own tools folder via</T> {tool.installMethod}.
</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>
)}
</div>
);
}
export function LanguageServersPage() {
const { tools, loaded, loading, error, jobs, logs } = useTools();
const { tools, loaded, loading, error } = useTools();
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [openLog, setOpenLog] = useState<string | null>(null);
const [selectedName, setSelectedName] = useState<string | null>(null);
useEffect(() => {
void refreshTools();
}, []);
const shown = useMemo(() => filterTools(tools, query, filter), [tools, 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 onListKey = (e: KeyboardEvent<HTMLUListElement>) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault();
const i = selected ? ordered.indexOf(selected) : -1;
const next = ordered[Math.min(ordered.length - 1, Math.max(0, i + (e.key === "ArrowDown" ? 1 : -1)))];
if (!next) return;
setSelectedName(next.name);
e.currentTarget.querySelector<HTMLElement>(`[data-tool="${CSS.escape(next.name)}"]`)?.focus();
};
const group = (title: string, list: ToolInfo[]) =>
list.length > 0 && (
<>
<li className={styles.groupHeader} role="presentation">
<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)} />
))}
</>
);
return (
<div className={styles.page}>
<p className={styles.intro}>
Language servers and debug adapters used by the IDE. Missing tools can be installed into the IDE&apos;s own
tools folder; nothing is downloaded until you click Install.
</p>
<div className={styles.toolbar}>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder="Filter by tool or language"
aria-label="Filter language servers"
/>
<div className={styles.segmented} role="radiogroup" aria-label="Show">
{(["all", "missing", "installed"] as const).map((f) => (
<button
key={f}
type="button"
role="radio"
aria-checked={filter === f}
className={clsx(styles.segment, filter === f && styles.segmentOn)}
onClick={() => setFilter(f)}
>
{f === "all" ? "All" : f === "missing" ? `Missing (${missingCount})` : "Available"}
</button>
))}
</div>
<span className={styles.spacer} />
{loading && <Spinner size={14} label="Checking tools" />}
<IconButton
icon="refresh"
title="Re-check installed tools"
onClick={() => void refreshTools()}
disabled={loading}
/>
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.tableWrap}>
<table className={styles.table} aria-label="Language servers and debug adapters">
<thead>
<tr>
<th scope="col">
<T>Tool</T>
</th>
<th scope="col">
<T>Languages</T>
</th>
<th scope="col" className={styles.fitHead}>
<T>Status</T>
</th>
<th scope="col" className={styles.fitHead}>
<T>Version</T>
</th>
<th scope="col" className={styles.actionsHead}>
<span className={styles.visuallyHidden}>
<T>Actions</T>
</span>
</th>
</tr>
</thead>
<tbody>
{shown.map((t) => {
const job = jobs[t.name];
const log = job?.lines ?? logs[t.name]?.lines ?? [];
const lastError = !job ? logs[t.name]?.error : null;
const logOpen = openLog === t.name;
return (
<Fragment key={t.name}>
<tr className={styles.row} data-tool={t.name}>
<td>
<div className={styles.toolName}>
<span className={styles.display}>{t.displayName}</span>
{t.kind === "debugger" && (
<span className={styles.kindTag}>
<T>Debugger</T>
</span>
)}
{t.kind === "plugin" && (
<span className={styles.kindTag}>
<T>Plugin</T>
</span>
)}
</div>
{(t.path ?? (t.command !== t.displayName ? t.command : null)) && (
<div className={styles.sub} title={t.path ?? undefined}>
{t.path ?? t.command}
</div>
)}
</td>
<td className={styles.languages}>{t.languages.join(", ") || "—"}</td>
<td>
<Badge tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Badge>
</td>
<td className={styles.version}>{t.version ?? "—"}</td>
<td>
<Actions
tool={t}
busy={!!job}
hasLog={log.length > 0}
logOpen={logOpen}
onToggleLog={() => setOpenLog(logOpen ? null : t.name)}
/>
</td>
</tr>
{(job || lastError || logOpen || (!t.canInstall && t.status === "missing")) && (
<tr className={styles.detailRow}>
<td colSpan={5}>
{job && <JobRow name={t.displayName} job={job} />}
{lastError && lastError !== "Cancelled" && (
<div className={styles.rowError} role="alert">
{lastError}
</div>
)}
{!t.canInstall && t.status === "missing" && (
<div className={styles.hint}>
{t.unavailableReason ?? t.installHint ?? "Install it manually and put it on PATH."}
</div>
)}
{logOpen && log.length > 0 && (
<pre className={styles.log} aria-label={`${t.displayName} install log`}>
{log.join("\n")}
</pre>
)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
{loaded && shown.length === 0 && (
<div className={styles.empty}>
<T>No tools match.</T>
<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"
/>
{loading ? (
<Spinner size={14} label="Checking tools" />
) : (
<IconButton
icon="refresh"
size="small"
title="Re-check installed tools"
onClick={() => void refreshTools()}
/>
)}
</div>
)}
{!loaded && !error && (
<div className={styles.empty}>
<T>Checking installed tools…</T>
<div className={styles.segmented} role="radiogroup" aria-label="Show">
{(["all", "missing", "installed"] as const).map((f) => (
<button
key={f}
type="button"
role="radio"
aria-checked={filter === f}
className={clsx(styles.segment, filter === f && styles.segmentOn)}
onClick={() => setFilter(f)}
>
{f === "all" ? "All" : f === "missing" ? `Missing (${missingCount})` : "Available"}
</button>
))}
</div>
<ul className={styles.list} aria-label="Language servers and debug adapters" onKeyDown={onListKey}>
{group("Available", ready)}
{group("Missing", missing)}
{loaded && shown.length === 0 && (
<li className={styles.listEmpty}>
<T>No tools match.</T>
</li>
)}
{!loaded && !error && (
<li className={styles.listEmpty}>
<T>Checking installed tools…</T>
</li>
)}
</ul>
</div>
{selected ? (
<Details key={selected.name} tool={selected} />
) : (
<EmptyState className={styles.detailsEmpty} title={loaded ? "No tool selected" : "Loading…"} />
)}
</div>
</div>
+43 -13
Переглянути файл
@@ -126,29 +126,59 @@ describe("missing language server prompt", () => {
});
describe("LanguageServersPage", () => {
it("lists tools and installs from the table", async () => {
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 />);
const table = await screen.findByRole("table", { name: /language servers/i });
const row = await within(table).findByText("gopls");
const tr = row.closest("tr")!;
expect(within(tr).getByText("Missing")).toBeInTheDocument();
await user.click(within(tr).getByRole("button", { name: "Install gopls" }));
await waitFor(() => expect(within(table).getByText("gopls").closest("tr")!).toHaveTextContent("Installed"));
expect(within(table).getByText("gopls").closest("tr")!).toHaveTextContent("1.0.0");
await screen.findByText("gopls");
await user.click(listItem("gopls"));
const details = screen.getByLabelText("gopls details");
expect(within(details).getByText("Missing")).toBeInTheDocument();
await user.click(within(details).getByRole("button", { name: "Install gopls" }));
await waitFor(() => expect(screen.getByLabelText("gopls details")).toHaveTextContent("Installed"));
expect(screen.getByLabelText("gopls details")).toHaveTextContent("1.0.0");
// Installed tools move to the Available group, still selected.
expect(listItem("gopls")).toHaveAttribute("aria-current", "true");
// The install log can be shown afterwards.
await user.click(
within(within(table).getByText("gopls").closest("tr")!).getByRole("button", { name: "Show install log" }),
);
await user.click(screen.getByRole("button", { name: "Show install log" }));
expect(screen.getByLabelText("gopls install log")).toHaveTextContent("Installing gopls");
});
it("explains why a tool cannot be installed", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
expect(await screen.findByText("gem install solargraph")).toBeInTheDocument();
await screen.findByText("solargraph");
await user.click(listItem("solargraph"));
expect(screen.getByText("gem install solargraph")).toBeInTheDocument();
await user.click(listItem("zls"));
expect(screen.getByText("curl is required to download tools")).toBeInTheDocument();
});
it("sets and clears a custom path", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
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");
});
it("moves the selection with the arrow keys", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
await screen.findByText("gopls");
await user.click(listItem("rust-analyzer"));
await user.keyboard("{ArrowDown}");
expect(screen.getByLabelText("typescript-language-server details")).toBeInTheDocument();
});
it("filters to missing tools", async () => {
const user = userEvent.setup();
render(<LanguageServersPage />);
@@ -158,7 +188,7 @@ describe("LanguageServersPage", () => {
expect(screen.getByText("gopls")).toBeInTheDocument();
await user.type(screen.getByRole("textbox", { name: /filter language servers/i }), "zig");
expect(screen.queryByText("gopls")).not.toBeInTheDocument();
expect(screen.getByText("ZLS (Zig)")).toBeInTheDocument();
expect(listItem("zls")).toHaveTextContent("ZLS (Zig)");
});
it("shows backend errors", async () => {
+35
Переглянути файл
@@ -5,6 +5,7 @@
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 ToolsBackend {
list(): Promise<ToolInfo[]>;
@@ -13,6 +14,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>;
}
export const tauriToolsBackend: ToolsBackend = {
@@ -35,6 +40,18 @@ 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;
},
};
function tool(partial: Partial<ToolInfo> & Pick<ToolInfo, "name" | "languages">): ToolInfo {
@@ -51,6 +68,7 @@ function tool(partial: Partial<ToolInfo> & Pick<ToolInfo, "name" | "languages">)
canUninstall: false,
installHint: null,
homepage: null,
customError: null,
...partial,
};
}
@@ -183,6 +201,23 @@ export class MemoryToolsBackend implements ToolsBackend {
async restartServers(name: string) {
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;
}
}
let backend: ToolsBackend | null = null;
+18
Переглянути файл
@@ -179,8 +179,26 @@ 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",
custom: "Custom",
missing: "Missing",
};
+12 -1
Переглянути файл
@@ -2313,5 +2313,16 @@
"Group By": "分组依据",
"The search hit its limit; only usages found so far are replaced.": "搜索已达上限,只替换目前找到的用法。",
"Review": "审查",
"Commit and Push…": "提交并推送…"
"Commit and Push…": "提交并推送…",
"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 自己的工具目录,方式:",
"No tool selected": "未选择工具",
"Use auto-detected": "使用自动检测",
"Custom": "自定义",
"On PATH": "PATH 中",
"Reinstall": "重新安装",
"Auto-detect": "自动检测"
}