дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
1572fc36ea
коміт
03a660354c
16 files changed
+761
-305
No files matched your search
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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'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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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": "自动检测"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача