diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 84669f25a..579c89938 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -37,6 +37,23 @@ pub struct Settings { pub terminal: TerminalSettings, pub actions_on_save: ActionsOnSaveSettings, pub external_tools: Vec, + /// Language servers / debug adapters at a user-chosen location, by the + /// command the language definition launches (`elixir-ls`, `codelldb`). + pub tools: BTreeMap, +} + +#[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(), } } } diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 957635c31..1d76b48f9 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -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 { + 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, + ) -> IdeResult { 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") { diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 1bd753b63..695b8f908 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -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 { +pub fn setting_path(root: &Path, s: &str) -> Option { 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 = Vec::new(); let name = cfg.name.clone(); let name = name.as_str(); diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 9c7cce5f5..a090e96cc 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -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"); +} diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index c5f7dffe6..00279ba61 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -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, pub homepage: Option, + /// Why the executable chosen in settings cannot be used (status is then `Missing`). + pub custom_error: Option, } /// Streamed install progress. @@ -212,8 +216,18 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap { 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, } impl Tools { @@ -273,7 +289,13 @@ impl Tools { let system: Vec = 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) -> 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, Option) { diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index cbcf9c088..4162a1963 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -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()]); +} diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index df5641283..b485a9938 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -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(); diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index 7b2601b0f..6c93cd8e5 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -14,7 +14,16 @@ pub async fn debug_start( ) -> IdeResult { 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] diff --git a/src-tauri/src/commands/tools.rs b/src-tauri/src/commands/tools.rs index 0cf294337..729f6324e 100644 --- a/src-tauri/src/commands/tools.rs +++ b/src-tauri/src/commands/tools.rs @@ -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 { +/// The installer, reporting the executables chosen in the window's settings. +fn tools(app: &AppHandle, state: &AppState, window: &Window) -> IdeResult { 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> { - let tools = tools(&app)?; +pub async fn tools_list(app: AppHandle, window: Window, state: State<'_, AppState>) -> IdeResult> { + 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, ) -> IdeResult { - 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 { - let tools = tools(&app)?; +pub async fn tools_uninstall( + app: AppHandle, + window: Window, + state: State<'_, AppState>, + name: String, +) -> IdeResult { + 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)??; diff --git a/src/bindings.ts b/src/bindings.ts index 22f7dc893..6098ddb10 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -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 = { /** diff --git a/src/features/tools/LanguageServersPage.module.css b/src/features/tools/LanguageServersPage.module.css index b2bfaf429..6e510236c 100644 --- a/src/features/tools/LanguageServersPage.module.css +++ b/src/features/tools/LanguageServersPage.module.css @@ -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; -} diff --git a/src/features/tools/LanguageServersPage.tsx b/src/features/tools/LanguageServersPage.tsx index fb7f77330..a22cef798 100644 --- a/src/features/tools/LanguageServersPage.tsx +++ b/src/features/tools/LanguageServersPage.tsx @@ -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 = { installed: "success", onPath: "accent", + custom: "accent", missing: "warning", }; +const KIND_LABEL: Record = { + 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 ( -
- {tool.canInstall && ( - +
  • + +
  • + ); +} + +/** "Path" field: an executable to launch instead of the detected one, saved when committed. */ +function CustomPath({ tool }: { tool: ToolInfo }) { + const id = useId(); + const saved = tool.status === "custom" || tool.customError ? (tool.path ?? "") : ""; + const [draft, setDraft] = useState(null); + const value = draft ?? (tool.status === "custom" ? saved : ""); + const commit = async (next: string) => { + setDraft(null); + if (next.trim() !== saved) await setCustomPath(tool.command, next); + }; + return ( +
    + +
    + + setDraft(e.target.value)} + onBlur={() => draft !== null && void commit(draft)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") void commit(e.currentTarget.value); + if (e.key === "Escape" && draft !== null) { + e.stopPropagation(); + setDraft(null); + } + }} + /> + { + const picked = await toolsBackend().chooseExecutable(`Select ${tool.command}`, value || undefined); + if (picked) await commit(picked); + }} + /> + {tool.status === "custom" && ( + void commit("")} /> + )} + + {tool.customError ? ( +
    + {tool.customError} +
    + ) : ( +
    + Leave empty to use the IDE-installed copy, else the one on PATH. +
    + )} +
    +
    + ); +} + +function Details({ tool }: { tool: ToolInfo }) { + 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 ( +
    +
    +
    +
    + {tool.displayName} + {STATUS_LABEL[tool.status]} +
    +
    + {KIND_LABEL[tool.kind]} + {tool.languages.length > 0 && <> · {tool.languages.join(", ")}} +
    +
    +
    + +
    + {tool.canInstall && ( + + )} + {tool.canUninstall && ( + + )} + {log.length > 0 && ( + + )} +
    + {job && } + {lastError && ( +
    + {lastError} +
    )} - {tool.canUninstall && ( - + {manualHint && ( +
    + {tool.unavailableReason ?? tool.installHint ?? "Install it manually and put it on PATH."} +
    )} - {hasLog && ( - + {tool.canInstall && tool.status === "missing" && tool.unavailableReason === null && tool.installMethod && ( +
    + Installs into the IDE's own tools folder via {tool.installMethod}. +
    + )} + +
    +
    + + Command + + {tool.command} +
    + {tool.kind !== "plugin" && } + {tool.status !== "custom" && ( +
    + + Location + + {tool.path ?? "—"} +
    + )} +
    + + Version + + {tool.version ?? "—"} +
    + {tool.homepage && ( +
    + + Homepage + + + {tool.homepage} + +
    + )} +
    + + {logOpen && log.length > 0 && ( +
    +          {log.join("\n")}
    +        
    )}
    ); } 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("all"); - const [openLog, setOpenLog] = useState(null); + const [selectedName, setSelectedName] = useState(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) => { + 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(`[data-tool="${CSS.escape(next.name)}"]`)?.focus(); + }; + + const group = (title: string, list: ToolInfo[]) => + list.length > 0 && ( + <> +
  • + {title} {list.length} +
  • + {list.map((t) => ( + setSelectedName(t.name)} /> + ))} + + ); + return (

    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.

    -
    - -
    - {(["all", "missing", "installed"] as const).map((f) => ( - - ))} -
    - - {loading && } - void refreshTools()} - disabled={loading} - /> -
    - {error && (
    {error}
    )} - -
    - - - - - - - - - - - - {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 ( - - - - - - - - - {(job || lastError || logOpen || (!t.canInstall && t.status === "missing")) && ( - - - - )} - - ); - })} - -
    - Tool - - Languages - - Status - - Version - - - Actions - -
    -
    - {t.displayName} - {t.kind === "debugger" && ( - - Debugger - - )} - {t.kind === "plugin" && ( - - Plugin - - )} -
    - {(t.path ?? (t.command !== t.displayName ? t.command : null)) && ( -
    - {t.path ?? t.command} -
    - )} -
    {t.languages.join(", ") || "—"} - {STATUS_LABEL[t.status]} - {t.version ?? "—"} - 0} - logOpen={logOpen} - onToggleLog={() => setOpenLog(logOpen ? null : t.name)} - /> -
    - {job && } - {lastError && lastError !== "Cancelled" && ( -
    - {lastError} -
    - )} - {!t.canInstall && t.status === "missing" && ( -
    - {t.unavailableReason ?? t.installHint ?? "Install it manually and put it on PATH."} -
    - )} - {logOpen && log.length > 0 && ( -
    -                            {log.join("\n")}
    -                          
    - )} -
    - {loaded && shown.length === 0 && ( -
    - No tools match. +
    +
    +
    + + {loading ? ( + + ) : ( + void refreshTools()} + /> + )}
    - )} - {!loaded && !error && ( -
    - Checking installed tools… +
    + {(["all", "missing", "installed"] as const).map((f) => ( + + ))}
    +
      + {group("Available", ready)} + {group("Missing", missing)} + {loaded && shown.length === 0 && ( +
    • + No tools match. +
    • + )} + {!loaded && !error && ( +
    • + Checking installed tools… +
    • + )} +
    +
    + {selected ? ( +
    + ) : ( + )}
    diff --git a/src/features/tools/__tests__/tools.test.tsx b/src/features/tools/__tests__/tools.test.tsx index ffb645d67..480ec419d 100644 --- a/src/features/tools/__tests__/tools.test.tsx +++ b/src/features/tools/__tests__/tools.test.tsx @@ -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(`[data-tool="${name}"]`)!; + + it("lists tools and installs from the details pane", async () => { const user = userEvent.setup(); render(); - 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(); - 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(); + 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(); + 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(); @@ -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 () => { diff --git a/src/features/tools/backend.ts b/src/features/tools/backend.ts index 055f8cd3f..4083162da 100644 --- a/src/features/tools/backend.ts +++ b/src/features/tools/backend.ts @@ -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; @@ -13,6 +14,10 @@ export interface ToolsBackend { cancel(request: RequestId): void; /** Restarts running language servers named `name` (after an install). */ restartServers(name: string): Promise; + /** Launches `command` from `path` instead of the detected one (global setting); "" clears it. */ + setCustomPath(command: string, path: string): Promise; + /** File chooser for an executable; null when cancelled. */ + chooseExecutable(title: string, defaultPath?: string): Promise; } 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 & Pick): ToolInfo { @@ -51,6 +68,7 @@ function tool(partial: Partial & Pick) 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 = {}; + /** 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; diff --git a/src/features/tools/store.ts b/src/features/tools/store.ts index 73cb52b85..d9785097a 100644 --- a/src/features/tools/store.ts +++ b/src/features/tools/store.ts @@ -179,8 +179,26 @@ export async function uninstallTool(name: string): Promise { } } +/** Launches `command` from `path` (empty: auto-detect again), then re-checks the tools. */ +export async function setCustomPath(command: string, path: string): Promise { + 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 = { installed: "Installed", onPath: "On PATH", + custom: "Custom", missing: "Missing", }; diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 1f74a907a..d12057b40 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "自动检测" }