From 9a4d818b788c2a0d7c920fd4ddb9a8d4393181eb Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:56:02 +0800 Subject: [PATCH] refactor(editor): drop the LSP client entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a `.rs` file in the code panel silently spawned rust-analyzer, which then indexed the whole workspace — hundreds of megabytes of RAM and a busy core — with no setting to turn it off. A terminal emulator should not do that to its user on a click, and rather than add a flag to disable something nobody asked for, the integration goes. Removed: the JSON-RPC client and reader thread (`ui::lsp`), the per-server registry, the completion / hover / definition providers installed on the buffer, document sync (didOpen/didChange/didSave/didClose), diagnostics, Go to Definition (F12), Find References (⇧F12) and its drawer, and the status bar's server indicator. With them go the `lsp-types`, `ropey` and `url` dependencies — all three were used only by this code (they remain in the lock file as transitive deps of gpui-component and gpui, which is expected). Kept, and deliberately so: - **Syntax highlighting**, which is tree-sitter, not LSP: gpui-component's `tree-sitter-languages` feature, `InputState::code_editor(language)` and `language_for_path` are all untouched. It is static, in-process, and costs nothing beyond parsing the open buffer. - ⌘S save, dirty tracking, the external-change watcher and its conflict banner, markdown preview, soft wrap, and open-from-the-file-tree. The module header now records *why* there is no language server, so the next person to reach for one finds the reasoning instead of a gap. Net −975 lines. --- Cargo.lock | 3 - Cargo.toml | 13 +- src/core/actions.rs | 4 - src/ui/app.rs | 10 +- src/ui/code_editor.rs | 349 ++--------------------- src/ui/keymap.rs | 5 - src/ui/lsp.rs | 642 ------------------------------------------ src/ui/mod.rs | 1 - 8 files changed, 26 insertions(+), 1001 deletions(-) delete mode 100644 src/ui/lsp.rs diff --git a/Cargo.lock b/Cargo.lock index 026be7fd..c1e9e8ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9560,7 +9560,6 @@ dependencies = [ "libc", "libgssapi", "log", - "lsp-types", "memchr", "notify 8.2.0", "notify-rust", @@ -9572,7 +9571,6 @@ dependencies = [ "regex", "reqwest_client", "resvg", - "ropey", "russh", "russh-sftp", "serde", @@ -9583,7 +9581,6 @@ dependencies = [ "smol", "tokio", "tray-icon", - "url", "uuid", "windows-sys 0.59.0", "winresource", diff --git a/Cargo.toml b/Cargo.toml index 34cd6409..a61fa4ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,16 +80,11 @@ notify-rust = "4" # editor, `ui::code`) reuses it to refresh the tree and detect external edits. notify = "8" -# Code panel (file tree + editor, `ui::code`). `ropey` and `lsp-types` are pinned -# to the exact versions gpui-component uses so its `InputState` rope / LSP -# provider types unify with ours; `ignore` supplies the gitignore matcher chain -# the file tree uses to dim ignored entries (same crate ripgrep uses). -ropey = "=2.0.0-beta.1" -lsp-types = { version = "0.97.0", features = ["proposed"] } +# Code panel (file tree + editor, `ui::code`): `ignore` supplies the gitignore +# matcher chain the file tree uses to dim ignored entries (same crate ripgrep +# uses). The editor itself needs nothing else — text storage and syntax +# highlighting both live inside gpui-component's `InputState`. ignore = "0.4" -# `url` does the file-path ↔ `file://` URI conversion for the LSP client -# (percent-encoding, Windows drive letters); already in the tree via gpui. -url = "2" # Cross-platform PTY for the daemon: a Unix pty on Unix, ConPTY on Windows, behind # one blocking `Read`/`Write`/`resize` API. This is what lets `daemon::pane` share diff --git a/src/core/actions.rs b/src/core/actions.rs index 724a42ab..f5eac7e7 100644 --- a/src/core/actions.rs +++ b/src/core/actions.rs @@ -80,10 +80,6 @@ actions!( ToggleCodePanel, // Save the editor panel's active file (⌘S). EditorSave, - // LSP jump to the definition of the symbol at the editor cursor (F12). - EditorGotoDefinition, - // LSP list references to the symbol at the editor cursor (⇧F12). - EditorFindReferences, // Open the SSH profile manager/editor full-window page (WS6, FR-P1). OpenSshProfiles, // Reconnect a dead native-SSH pane in place (WS6, FR-E4). diff --git a/src/ui/app.rs b/src/ui/app.rs index 6e7212c6..cb7466ca 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -144,8 +144,8 @@ pub struct Tab { /// roots/expansion, and visibility. Same per-tab contract as /// `diff_overlay` — switching away hides it, switching back restores it, /// closing the tab drops it. Shared caches (directory listings, gitignore - /// matchers, language servers, watchers) live on [`Tty7App`]. `None` until - /// the panel is first opened in this tab. + /// matchers, watchers) live on [`Tty7App`]. `None` until the panel is + /// first opened in this tab. pub(crate) code: Option>, /// The sidebar group this tab last *definitively* belonged to: the /// repository home of its first pane's cwd — the main checkout's root, so @@ -4864,12 +4864,6 @@ impl Render for Tty7App { .on_action( cx.listener(|this, _: &EditorSave, window, cx| this.editor_save_active(window, cx)), ) - .on_action(cx.listener(|this, _: &EditorGotoDefinition, window, cx| { - this.editor_goto_definition(window, cx) - })) - .on_action(cx.listener(|this, _: &EditorFindReferences, window, cx| { - this.editor_find_references(window, cx) - })) // Quit lives on the same element-tree action path as every other Cmd // shortcut above, so a focused terminal routes `cmd-q` here rather // than relying solely on the global handler (which the keystroke diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index f7ab91ed..7e431837 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -11,6 +11,14 @@ //! unsaved-close confirmation, and the overlay chrome itself (the file-tree //! column comes from `ui::file_tree`). //! +//! Deliberately *not* an IDE: there is no language-server integration, and +//! adding one is not a wanted feature. Opening a `.rs` file silently spawning +//! rust-analyzer — a background process indexing the whole workspace for +//! hundreds of megabytes of RAM — is not something a terminal emulator should +//! do to its user. Highlighting comes from tree-sitter grammars compiled into +//! gpui-component (see [`language_for_path`]), which is static, in-process, +//! and costs nothing beyond parsing the open buffer. +//! //! Layout: overlaying the body (like Settings and the diff overlay) rather //! than docking a side column means toggling never resizes the terminal — no //! PTY resize, no reflow — and the editor gets the full body width. The tab @@ -23,8 +31,8 @@ use std::time::SystemTime; use gpui::prelude::*; use gpui::{ - AnyElement, Context, Entity, Focusable as _, MouseButton, PromptLevel, SharedString, - Subscription, Window, div, px, + AnyElement, Context, Entity, Focusable as _, PromptLevel, SharedString, Subscription, Window, + div, px, }; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::input::{Input, InputEvent, InputState, TabSize}; @@ -60,13 +68,6 @@ pub(crate) struct OpenFile { pub(crate) preview: bool, /// Soft-wrap state (mirrored here — the input's own flag isn't readable). pub(crate) wrap: bool, - /// The language server serving this file (spawned per workspace root), - /// with the LSP `languageId` used for document sync. `None` when no - /// server is configured/available for the language. - lsp: Option<(std::rc::Rc, &'static str)>, - /// Debounced full-document `didChange`; replaced (cancelling the old - /// timer) on every keystroke. - change_task: Option>, _sub: Subscription, /// Repaints the app when the input notifies (cursor moves, scrolls…) so /// the status bar's Ln/Col stays live. @@ -87,17 +88,15 @@ impl OpenFile { /// Per-tab code-panel state, hung on [`Tab::code`](crate::ui::app::Tab) with /// the same lifecycle contract as the diff overlay: only the active tab's /// panel renders, switching away hides it, closing the tab drops it. The -/// shared caches (directory listings, gitignore matchers, language servers, -/// filesystem watchers) live on [`Tty7App`] — this holds only what is truly -/// this tab's: its open files and its tree view state. +/// shared caches (directory listings, gitignore matchers, filesystem +/// watchers) live on [`Tty7App`] — this holds only what is truly this tab's: +/// its open files and its tree view state. pub(crate) struct TabCode { /// Whether the overlay is currently shown for this tab. The open-file set /// survives hiding (Esc) — only closing the tab drops it. pub(crate) visible: bool, pub(crate) files: Vec, pub(crate) active: usize, - /// Find-references results, shown as a drawer under the editor. - pub(crate) references: Option>, /// File-tree roots: this tab's pane cwds resolved to repo roots. pub(crate) roots: Vec, pub(crate) expanded: std::collections::HashSet, @@ -116,7 +115,6 @@ impl TabCode { visible: false, files: Vec::new(), active: 0, - references: None, roots: Vec::new(), expanded: std::collections::HashSet::new(), selected: None, @@ -137,18 +135,6 @@ pub(crate) struct EditorPanelState { /// Feeds changed paths from the watcher thread into the UI-side reload /// loop spawned in [`EditorPanelState::new`]. events_tx: smol::channel::Sender, - /// Language-server registry (one client per server × workspace root). - pub(crate) lsp: crate::ui::lsp::LspRegistry, -} - -/// One row in the find-references drawer. -pub(crate) struct ReferenceItem { - pub path: PathBuf, - /// 0-based target position. - pub line: u32, - pub character: u32, - /// The referenced line's text, for the row preview. - pub preview: SharedString, } impl EditorPanelState { @@ -177,7 +163,6 @@ impl EditorPanelState { Self { watcher: None, events_tx: tx, - lsp: crate::ui::lsp::LspRegistry::new(window, cx), } } } @@ -409,36 +394,15 @@ impl Tty7App { .replaceable(true) .folding(true) .soft_wrap(false) - .default_value(text.clone()) + .default_value(text) }); - // Language server: spawn (or reuse) the server for this language at - // the file's workspace root, open the document, and install the - // completion / hover / definition providers on the input. - let root = crate::ui::file_tree::repo_root_for(&path) - .or_else(|| path.parent().map(Path::to_path_buf)) - .unwrap_or_else(|| PathBuf::from("/")); - let lsp = self.editor.lsp.client_for(language, &root); - if let Some((client, language_id)) = &lsp { - client.did_open(&path, language_id, &text); - let provider = std::rc::Rc::new(crate::ui::lsp::FileLsp { - client: client.clone(), - path: path.clone(), - }); - input.update(cx, |st, _| { - st.lsp.completion_provider = Some(provider.clone()); - st.lsp.hover_provider = Some(provider.clone()); - st.lsp.definition_provider = Some(provider); - }); - } // Dirty tracking: `set_value` suppresses events, so every Change here - // is a real user edit. Each edit also (re)arms the debounced LSP - // didChange sync. Files may be open in any tab, not just the active - // one, so the lookup scans all tabs. + // is a real user edit. Files may be open in any tab, not just the + // active one, so the lookup scans all tabs. let sub = cx.subscribe_in(&input, window, { let path = path.clone(); - move |this: &mut Tty7App, _input, ev, window, cx| { + move |this: &mut Tty7App, _input, ev, _window, cx| { if matches!(ev, InputEvent::Change) { - let path = path.clone(); let Some(f) = this .tabs .iter_mut() @@ -451,16 +415,6 @@ impl Tty7App { if !f.dirty { f.dirty = true; } - if f.lsp.is_some() { - f.change_task = Some(cx.spawn_in(window, async move |app, cx| { - cx.background_executor() - .timer(std::time::Duration::from_millis(150)) - .await; - let _ = app.update(cx, |app, cx| { - app.editor_sync_lsp_document(&path, cx); - }); - })); - } cx.notify(); } } @@ -482,8 +436,6 @@ impl Tty7App { conflict: false, preview: false, wrap: false, - lsp, - change_task: None, _sub: sub, _observe: observe, }, @@ -581,13 +533,6 @@ impl Tty7App { f.dirty = false; f.conflict = false; f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok(); - if let Some((client, _)) = &f.lsp { - // Make sure the server saw the final text before the save - // notification (the debounced didChange may still be - // pending), so on-save diagnostics match the disk state. - client.did_change(&f.path, &text); - client.did_save(&f.path); - } cx.notify(); } Err(e) => { @@ -677,10 +622,7 @@ impl Tty7App { if ix >= code.files.len() { return; } - let f = code.files.remove(ix); - if let Some((client, _)) = &f.lsp { - client.did_close(&f.path); - } + code.files.remove(ix); if code.active >= ix && code.active > 0 { code.active -= 1; } @@ -688,160 +630,6 @@ impl Tty7App { cx.notify(); } - /// Every open buffer for `path`, across all tabs (a file can be open in - /// more than one tab's panel; each has its own buffer). - fn editor_files_for_path<'a>(&'a self, path: &'a Path) -> impl Iterator { - self.tabs - .iter() - .filter_map(|t| t.code.as_deref()) - .flat_map(|c| c.files.iter()) - .filter(move |f| f.path == *path) - } - - /// Push the buffer's current text to the language server (the debounced - /// tail of a typing burst). - pub(crate) fn editor_sync_lsp_document(&mut self, path: &Path, cx: &mut Context) { - for f in self.editor_files_for_path(path) { - if let Some((client, _)) = &f.lsp { - client.did_change(&f.path, &f.input.read(cx).text().to_string()); - break; // one didChange per path — the server sees one document - } - } - } - - /// Apply `publishDiagnostics` for `path` to its open buffers (any tab). - pub(crate) fn editor_apply_diagnostics( - &mut self, - path: &Path, - diags: Vec, - _window: &mut Window, - cx: &mut Context, - ) { - let inputs: Vec> = self - .editor_files_for_path(path) - .map(|f| f.input.clone()) - .collect(); - for input in inputs { - input.update(cx, |st, cx| { - let text = st.text().clone(); - if let Some(set) = st.diagnostics_mut() { - set.reset(&text); - set.extend(diags.iter().cloned()); - cx.notify(); - } - }); - } - } - - /// `EditorGotoDefinition` (F12): resolve the definition at the cursor and - /// jump — opening the target file first when it lives elsewhere (the - /// in-buffer ⌘-click path can't cross files; this one can). - pub(crate) fn editor_goto_definition(&mut self, window: &mut Window, cx: &mut Context) { - let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { - return; - }; - let Some((client, _)) = &f.lsp else { return }; - let st = f.input.read(cx); - let (text, offset) = (st.text().clone(), st.cursor()); - let Some(params) = crate::ui::lsp::LspClient::position_params(&f.path, &text, offset) - else { - return; - }; - let rx = client.request("textDocument/definition", params); - cx.spawn_in(window, async move |app, cx| { - let Ok(v) = rx.recv().await else { return }; - let links = crate::ui::lsp::normalize_definitions(v); - let Some(link) = links.first() else { return }; - let Some(target) = crate::ui::lsp::path_for_uri(link.target_uri.as_str()) else { - return; - }; - let pos = link.target_selection_range.start; - let _ = app.update_in(cx, |app, window, cx| { - app.editor_jump_to(&target, pos, window, cx); - }); - }) - .detach(); - } - - /// `EditorFindReferences` (⇧F12): list every reference to the symbol at - /// the cursor in a drawer under the editor. - pub(crate) fn editor_find_references(&mut self, window: &mut Window, cx: &mut Context) { - let Some(f) = self.tab_code().and_then(|c| c.active_file()) else { - return; - }; - let Some((client, _)) = &f.lsp else { return }; - let st = f.input.read(cx); - let (text, offset) = (st.text().clone(), st.cursor()); - let Some(mut params) = crate::ui::lsp::LspClient::position_params(&f.path, &text, offset) - else { - return; - }; - params["context"] = serde_json::json!({ "includeDeclaration": true }); - let rx = client.request("textDocument/references", params); - cx.spawn_in(window, async move |app, cx| { - let Ok(v) = rx.recv().await else { return }; - let locations: Vec = serde_json::from_value(v).unwrap_or_default(); - // Read each referenced line once per file for the row previews. On - // the background executor: `spawn_in` runs on the *main* thread, and - // a couple of hundred `read_to_string`s there would stall a frame. - let items: Vec = cx - .background_executor() - .spawn(async move { - let mut items: Vec = Vec::new(); - let mut file_lines: std::collections::HashMap> = - std::collections::HashMap::new(); - for loc in locations.into_iter().take(200) { - let Some(path) = crate::ui::lsp::path_for_uri(loc.uri.as_str()) else { - continue; - }; - let lines = file_lines.entry(path.clone()).or_insert_with(|| { - std::fs::read_to_string(&path) - .map(|t| t.lines().map(|l| l.to_string()).collect()) - .unwrap_or_default() - }); - let line = loc.range.start.line; - let preview = lines - .get(line as usize) - .map(|l| l.trim().to_string()) - .unwrap_or_default(); - items.push(ReferenceItem { - path, - line, - character: loc.range.start.character, - preview: preview.into(), - }); - } - items - }) - .await; - let _ = app.update(cx, |app, cx| { - if let Some(code) = app.tab_code_mut() { - code.references = Some(items); - } - cx.notify(); - }); - }) - .detach(); - } - - /// Open `path` (if needed) and place the cursor at an LSP position. - pub(crate) fn editor_jump_to( - &mut self, - path: &Path, - pos: lsp_types::Position, - window: &mut Window, - cx: &mut Context, - ) { - self.open_file_in_editor(path, window, cx); - if let Some(f) = self.tab_code().and_then(|c| c.active_file()) - && f.path == *path - { - f.input.clone().update(cx, |st, cx| { - st.set_cursor_position(pos, window, cx); - }); - } - } - /// A watched file changed on disk. Clean buffers reload silently; dirty /// ones raise the conflict banner and let the user pick a side. The file /// may be open in several tabs — each buffer is handled on its own. @@ -909,9 +697,6 @@ impl Tty7App { f.disk_mtime = std::fs::metadata(&f.path).and_then(|m| m.modified()).ok(); f.dirty = false; f.conflict = false; - if let Some((client, _)) = &f.lsp { - client.did_change(&f.path, &text); - } let input = f.input.clone(); input.update(cx, |input, cx| input.set_value(text, window, cx)); cx.notify(); @@ -968,7 +753,6 @@ impl Tty7App { .and_then(|c| c.active_file()) .filter(|f| f.conflict) .map(|_| self.render_editor_conflict_banner(cx)); - let references = self.render_editor_references(cx); let editor_col = v_flex() .flex_1() @@ -976,8 +760,7 @@ impl Tty7App { .h_full() .child(self.render_editor_header(cx)) .when_some(conflict_banner, |this, b| this.child(b)) - .child(div().flex_1().min_h_0().child(body)) - .when_some(references, |this, drawer| this.child(drawer)); + .child(div().flex_1().min_h_0().child(body)); Some( v_flex() @@ -1082,8 +865,7 @@ impl Tty7App { } /// The Zed-style status bar along the panel bottom: repo-relative path on - /// the left; preview/wrap toggles, cursor position, and the language - /// server's presence on the right. + /// the left; preview/wrap toggles and the cursor position on the right. fn render_code_status_bar(&self, _window: &Window, cx: &mut Context) -> gpui::Div { let code = self.tab_code(); let muted = cx.theme().muted_foreground; @@ -1116,9 +898,6 @@ impl Tty7App { let wrap: Option = active.map(|f| f.wrap); let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown"); let preview = active.is_some_and(|f| f.preview); - let lsp_name: Option = active - .and_then(|f| f.lsp.as_ref()) - .map(|(client, _)| format!("{} ✓", client.name()).into()); h_flex() .flex_none() @@ -1174,7 +953,6 @@ impl Tty7App { ) }) .when_some(cursor, |this, t| this.child(div().child(t))) - .when_some(lsp_name, |this, t| this.child(div().child(t))) } /// Empty state: the panel is open with nothing loaded. @@ -1197,93 +975,6 @@ impl Tty7App { ) } - /// The find-references drawer (⇧F12 results) under the editor body. - fn render_editor_references(&self, cx: &mut Context) -> Option { - let refs = self.tab_code()?.references.as_ref()?; - let muted = cx.theme().muted_foreground; - let rows = refs.iter().enumerate().map(|(ix, r)| { - let name = r - .path - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_default(); - let (path, line, character) = (r.path.clone(), r.line, r.character); - h_flex() - .id(("editor-ref", ix)) - .items_center() - .gap_2() - .px_2() - .py_0p5() - .text_sm() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().accent.opacity(0.5))) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, window, cx| { - this.editor_jump_to( - &path, - lsp_types::Position::new(line, character), - window, - cx, - ); - }), - ) - .child( - div() - .flex_none() - .text_color(muted) - .child(format!("{name}:{}", r.line + 1)), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_ellipsis() - .child(r.preview.clone()), - ) - }); - Some( - v_flex() - .flex_none() - .max_h(gpui::relative(0.4)) - .border_t_1() - .border_color(cx.theme().border) - .child( - h_flex() - .items_center() - .px_2() - .py_1() - .text_sm() - .child(div().flex_1().child(format!("{} references", refs.len()))) - .child( - crate::ui::tab_strip::chrome_tile( - Button::new("editor-refs-close").icon(IconName::Close), - false, - cx, - ) - .xsmall() - .on_click(cx.listener( - |this, _, _w, cx| { - if let Some(code) = this.tab_code_mut() { - code.references = None; - } - cx.notify(); - }, - )), - ), - ) - .child( - v_flex() - .id("editor-refs-list") - .flex_1() - .min_h_0() - .overflow_y_scroll() - .children(rows), - ) - .into_any_element(), - ) - } - /// Banner shown when the file changed on disk while the buffer is dirty. fn render_editor_conflict_banner(&self, cx: &mut Context) -> AnyElement { let tab_ix = self.active; diff --git a/src/ui/keymap.rs b/src/ui/keymap.rs index 33a84c2f..c7ceab8d 100644 --- a/src/ui/keymap.rs +++ b/src/ui/keymap.rs @@ -211,9 +211,6 @@ pub(crate) fn default_bindings() -> Vec<(&'static str, &'static str)> { ("ToggleCodePanel", "secondary-shift-e"), // Save the editor's active file. ⌘S is free — the terminal has no save. ("EditorSave", "secondary-s"), - // LSP navigation in the editor panel, on the VS Code chords. - ("EditorGotoDefinition", "f12"), - ("EditorFindReferences", "shift-f12"), // No default chord — reachable from the command palette ("SSH: Manage // Profiles…") and bindable in Settings. ("OpenSshProfiles", ""), @@ -515,8 +512,6 @@ fn make_binding(action: &str, keystroke: &str) -> Option { "ToggleSftp" => KeyBinding::new(keystroke, ToggleSftp, None), "ToggleCodePanel" => KeyBinding::new(keystroke, ToggleCodePanel, None), "EditorSave" => KeyBinding::new(keystroke, EditorSave, None), - "EditorGotoDefinition" => KeyBinding::new(keystroke, EditorGotoDefinition, None), - "EditorFindReferences" => KeyBinding::new(keystroke, EditorFindReferences, None), "OpenSshProfiles" => KeyBinding::new(keystroke, OpenSshProfiles, None), "RestartSshSession" => KeyBinding::new(keystroke, RestartSshSession, None), "Quit" => KeyBinding::new(keystroke, Quit, None), diff --git a/src/ui/lsp.rs b/src/ui/lsp.rs deleted file mode 100644 index 439c599a..00000000 --- a/src/ui/lsp.rs +++ /dev/null @@ -1,642 +0,0 @@ -//! Minimal LSP client for the code-editor panel. -//! -//! One `LspClient` per (server, workspace root): a spawned server process -//! speaking JSON-RPC over stdio. A std reader thread parses `Content-Length` -//! frames and routes responses to per-request channels, `publishDiagnostics` -//! notifications to a UI-side loop, and answers the handful of server→client -//! requests (`workspace/configuration` &co.) with benign defaults so servers -//! like rust-analyzer don't stall waiting on us. -//! -//! The editor integrates through gpui-component's provider traits: completion -//! (popup menu), hover (popover) and definition (⌘-hover underline + ⌘-click) -//! are handled by [`FileLsp`], one per open file. The component's ⌘-click jump -//! only works within the current buffer, so `definitions` filters to same-file -//! links; the app-level Go to Definition action (F12, `code_editor.rs`) does -//! the full cross-file open + jump itself. -//! -//! Servers are discovered on PATH per language (rust-analyzer, gopls, pyright, -//! typescript-language-server, clangd); a missing binary just means no LSP for -//! that language — the editor works fine without it. - -use std::collections::HashMap; -use std::io::{BufRead, BufReader, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Child, ChildStdin, Command, Stdio}; -use std::rc::Rc; -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; -use std::sync::{Arc, Mutex}; - -use anyhow::{Context as _, Result, anyhow}; -use gpui::{App, Context, Task, Window}; -use gpui_component::input::InputState; -use gpui_component::input::{CompletionProvider, DefinitionProvider, HoverProvider, RopeExt as _}; -use ropey::Rope; -use serde_json::{Value, json}; - -use crate::ui::app::Tty7App; - -/// How long a single request may wait on the server before the provider gives -/// up (a hung server must not wedge hover/completion forever). -const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8); - -/// The command line and LSP `languageId` for a tree-sitter language name. -/// `None` → no server configured for that language. -pub(crate) fn server_for_language(lang: &str) -> Option<(&'static [&'static str], &'static str)> { - Some(match lang { - "rust" => (&["rust-analyzer"], "rust"), - "go" => (&["gopls"], "go"), - "python" => (&["pyright-langserver", "--stdio"], "python"), - "typescript" => (&["typescript-language-server", "--stdio"], "typescript"), - "tsx" => ( - &["typescript-language-server", "--stdio"], - "typescriptreact", - ), - "javascript" => (&["typescript-language-server", "--stdio"], "javascript"), - "c" => (&["clangd"], "c"), - "cpp" => (&["clangd"], "cpp"), - _ => return None, - }) -} - -/// `file://` URI for a local path (percent-encoded via the `url` crate). -pub(crate) fn uri_for_path(path: &Path) -> Option { - url::Url::from_file_path(path).ok().map(|u| u.to_string()) -} - -/// Local path for a `file://` URI string; `None` for non-file schemes. -pub(crate) fn path_for_uri(uri: &str) -> Option { - url::Url::parse(uri).ok()?.to_file_path().ok() -} - -// --------------------------------------------------------------------------- -// Client. -// --------------------------------------------------------------------------- - -/// Thread-shared client internals (UI thread + reader thread). -struct Inner { - name: String, - stdin: Mutex, - /// In-flight requests by id; the reader thread resolves them. - pending: Mutex>>, - /// Flips true when the `initialize` response lands; frames sent before - /// that wait in `queued`. - ready: AtomicBool, - queued: Mutex>, - next_id: AtomicI64, -} - -impl Inner { - fn write_frame(stdin: &mut ChildStdin, body: &str) { - let _ = write!(stdin, "Content-Length: {}\r\n\r\n{body}", body.len()); - let _ = stdin.flush(); - } - - /// Send a frame now, or park it until the server finished initializing. - /// - /// The `ready` check happens under the `queued` lock, and `flush_queued` - /// takes the same lock — otherwise a frame could read `ready == false`, - /// lose the race to the reader thread flipping it and draining the queue, - /// and then park itself behind a handshake that already finished, where - /// nothing would ever send it (a `didOpen` lost that way costs the file its - /// diagnostics for the whole session). - fn send(&self, body: String) { - let mut queued = self.queued.lock().unwrap(); - if self.ready.load(Ordering::SeqCst) { - drop(queued); - let mut stdin = self.stdin.lock().unwrap(); - Self::write_frame(&mut stdin, &body); - } else { - queued.push(body); - } - } - - /// Initialize finished: mark ready and flush everything parked behind the - /// handshake. `ready` flips under the `queued` lock, so no sender can slip a - /// frame out ahead of the ones already parked (a `didChange` overtaking its - /// `didOpen` desynchronizes the server for the rest of the session). - fn mark_ready_and_flush(&self) { - let mut queued = self.queued.lock().unwrap(); - self.ready.store(true, Ordering::SeqCst); - let parked: Vec = std::mem::take(&mut *queued); - let mut stdin = self.stdin.lock().unwrap(); - for body in parked { - Self::write_frame(&mut stdin, &body); - } - } -} - -/// A running language server bound to one workspace root. -pub(crate) struct LspClient { - inner: Arc, - child: std::cell::RefCell, - #[allow(dead_code)] // identifies the client in future workspace-level requests - pub(crate) root: PathBuf, - /// didOpen version counter per document. - versions: std::cell::RefCell>, -} - -impl Drop for LspClient { - fn drop(&mut self) { - // No graceful shutdown round-trip — the app is closing the panel or - // exiting; killing the child reaps it without waiting on a wedged one. - let _ = self.child.borrow_mut().kill(); - } -} - -impl LspClient { - /// Spawn `cmd` rooted at `root` and start the handshake + reader thread. - /// Diagnostics flow out through `diag_tx` as `(path, diagnostics)`. - pub(crate) fn spawn( - cmd: &[&str], - root: &Path, - diag_tx: smol::channel::Sender<(PathBuf, Vec)>, - ) -> Result { - let mut child = Command::new(cmd[0]) - .args(&cmd[1..]) - .current_dir(root) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .with_context(|| format!("spawning {}", cmd[0]))?; - let stdin = child.stdin.take().ok_or_else(|| anyhow!("no stdin"))?; - let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; - - let inner = Arc::new(Inner { - name: cmd[0].to_string(), - stdin: Mutex::new(stdin), - pending: Mutex::new(HashMap::new()), - ready: AtomicBool::new(false), - queued: Mutex::new(Vec::new()), - next_id: AtomicI64::new(2), // 1 is reserved for `initialize` - }); - - // The initialize request goes out immediately (bypassing the queue). - let root_uri = uri_for_path(root).unwrap_or_else(|| "file:///".into()); - let init = json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "processId": std::process::id(), - "rootUri": root_uri, - "workspaceFolders": [{ - "uri": root_uri, - "name": root.file_name().map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "root".into()), - }], - "capabilities": { - "textDocument": { - "synchronization": { "didSave": true }, - "publishDiagnostics": { "relatedInformation": false }, - "hover": { "contentFormat": ["markdown", "plaintext"] }, - "completion": { - "completionItem": { - "snippetSupport": false, - "documentationFormat": ["markdown", "plaintext"], - } - }, - "definition": { "linkSupport": true }, - "references": {}, - }, - "workspace": { "configuration": true, "workspaceFolders": true }, - "window": { "workDoneProgress": true }, - }, - }, - }); - { - let mut stdin = inner.stdin.lock().unwrap(); - Inner::write_frame(&mut stdin, &init.to_string()); - } - - // Reader thread: frame parser + dispatcher. - let reader_inner = inner.clone(); - std::thread::Builder::new() - .name(format!("lsp-{}", cmd[0])) - .spawn(move || reader_loop(stdout, reader_inner, diag_tx)) - .context("spawning lsp reader thread")?; - - Ok(Self { - inner, - child: std::cell::RefCell::new(child), - root: root.to_path_buf(), - versions: std::cell::RefCell::new(HashMap::new()), - }) - } - - /// The server binary's name, for the status bar. - pub(crate) fn name(&self) -> &str { - &self.inner.name - } - - fn notify(&self, method: &str, params: Value) { - let body = json!({ "jsonrpc": "2.0", "method": method, "params": params }).to_string(); - self.inner.send(body); - } - - /// Fire a request; the returned channel yields the `result` value (or - /// `Null` on a server-side error). Await it on a background executor. - pub(crate) fn request(&self, method: &str, params: Value) -> smol::channel::Receiver { - let id = self.inner.next_id.fetch_add(1, Ordering::SeqCst); - let (tx, rx) = smol::channel::bounded(1); - self.inner.pending.lock().unwrap().insert(id, tx); - let body = - json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }).to_string(); - self.inner.send(body); - rx - } - - pub(crate) fn did_open(&self, path: &Path, language_id: &str, text: &str) { - let Some(uri) = uri_for_path(path) else { - return; - }; - self.versions.borrow_mut().insert(path.to_path_buf(), 1); - self.notify( - "textDocument/didOpen", - json!({ "textDocument": { - "uri": uri, "languageId": language_id, "version": 1, "text": text, - }}), - ); - } - - /// Full-document sync (the simplest correct thing at this scale). - pub(crate) fn did_change(&self, path: &Path, text: &str) { - let Some(uri) = uri_for_path(path) else { - return; - }; - let mut versions = self.versions.borrow_mut(); - let v = versions.entry(path.to_path_buf()).or_insert(1); - *v += 1; - self.notify( - "textDocument/didChange", - json!({ - "textDocument": { "uri": uri, "version": *v }, - "contentChanges": [{ "text": text }], - }), - ); - } - - pub(crate) fn did_save(&self, path: &Path) { - let Some(uri) = uri_for_path(path) else { - return; - }; - self.notify( - "textDocument/didSave", - json!({ "textDocument": { "uri": uri } }), - ); - } - - pub(crate) fn did_close(&self, path: &Path) { - let Some(uri) = uri_for_path(path) else { - return; - }; - self.versions.borrow_mut().remove(path); - self.notify( - "textDocument/didClose", - json!({ "textDocument": { "uri": uri } }), - ); - } - - /// Standard text-document position params for a rope offset. - pub(crate) fn position_params(path: &Path, text: &Rope, offset: usize) -> Option { - let uri = uri_for_path(path)?; - let pos = text.offset_to_position(offset); - Some(json!({ - "textDocument": { "uri": uri }, - "position": { "line": pos.line, "character": pos.character }, - })) - } -} - -/// Parse `Content-Length`-framed JSON-RPC from the server and dispatch. -fn reader_loop( - stdout: std::process::ChildStdout, - inner: Arc, - diag_tx: smol::channel::Sender<(PathBuf, Vec)>, -) { - let mut reader = BufReader::new(stdout); - loop { - // Headers. - let mut content_length: Option = None; - loop { - let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) => return, // EOF: server exited - Ok(_) => {} - Err(_) => return, - } - let line = line.trim_end(); - if line.is_empty() { - break; - } - if let Some(rest) = line.strip_prefix("Content-Length:") { - content_length = rest.trim().parse().ok(); - } - } - let Some(len) = content_length else { continue }; - let mut buf = vec![0u8; len]; - if reader.read_exact(&mut buf).is_err() { - return; - } - let Ok(msg) = serde_json::from_slice::(&buf) else { - continue; - }; - - let id = msg.get("id").and_then(|v| v.as_i64()); - let method = msg.get("method").and_then(|v| v.as_str()); - match (id, method) { - // Server → client request: answer with a benign default so the - // server never blocks on us. - (Some(id), Some(method)) => { - let result = match method { - "workspace/configuration" => { - let n = msg - .pointer("/params/items") - .and_then(|v| v.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - Value::Array(vec![Value::Null; n]) - } - _ => Value::Null, - }; - let body = json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string(); - let mut stdin = inner.stdin.lock().unwrap(); - Inner::write_frame(&mut stdin, &body); - } - // Response. - (Some(id), None) => { - if id == 1 { - // The initialize response: complete the handshake, then - // release everything parked behind it. - let initialized = - json!({ "jsonrpc": "2.0", "method": "initialized", "params": {} }) - .to_string(); - { - let mut stdin = inner.stdin.lock().unwrap(); - Inner::write_frame(&mut stdin, &initialized); - } - inner.mark_ready_and_flush(); - log::info!("lsp: {} initialized", inner.name); - continue; - } - if let Some(tx) = inner.pending.lock().unwrap().remove(&id) { - let result = msg.get("result").cloned().unwrap_or(Value::Null); - let _ = tx.try_send(result); - } - } - // Notification. - (None, Some("textDocument/publishDiagnostics")) => { - let uri = msg - .pointer("/params/uri") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - let Some(path) = path_for_uri(uri) else { - continue; - }; - let diags: Vec = msg - .pointer("/params/diagnostics") - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - let _ = diag_tx.try_send((path, diags)); - } - _ => {} - } - } -} - -// --------------------------------------------------------------------------- -// Registry. -// --------------------------------------------------------------------------- - -/// Lazily-spawned clients keyed by (server binary, workspace root). A spawn -/// failure is cached as `None` so a missing binary logs once, not per file. -pub(crate) struct LspRegistry { - clients: HashMap<(String, PathBuf), Option>>, - diag_tx: smol::channel::Sender<(PathBuf, Vec)>, -} - -impl LspRegistry { - pub(crate) fn new(window: &mut Window, cx: &mut Context) -> Self { - // Diagnostics loop: reader threads push (path, diags); this applies - // them to the matching open editor on the UI thread. - let (tx, rx) = smol::channel::unbounded::<(PathBuf, Vec)>(); - cx.spawn_in(window, async move |app, cx| { - while let Ok((path, diags)) = rx.recv().await { - let ok = app.update_in(cx, |app, window, cx| { - app.editor_apply_diagnostics(&path, diags, window, cx); - }); - if ok.is_err() { - break; - } - } - }) - .detach(); - Self { - clients: HashMap::new(), - diag_tx: tx, - } - } - - /// The client for a language at a workspace root, spawning on first use. - /// Returns the client plus the LSP `languageId` for didOpen. - pub(crate) fn client_for( - &mut self, - language: &str, - root: &Path, - ) -> Option<(Rc, &'static str)> { - let (cmd, language_id) = server_for_language(language)?; - let key = (cmd[0].to_string(), root.to_path_buf()); - let slot = self.clients.entry(key).or_insert_with(|| { - match LspClient::spawn(cmd, root, self.diag_tx.clone()) { - Ok(client) => Some(Rc::new(client)), - Err(e) => { - log::info!("lsp: {} unavailable: {e:#}", cmd[0]); - None - } - } - }); - slot.clone().map(|c| (c, language_id)) - } -} - -// --------------------------------------------------------------------------- -// Per-file provider bridging to gpui-component's LSP traits. -// --------------------------------------------------------------------------- - -/// The provider object installed on an open file's `InputState`. -pub(crate) struct FileLsp { - pub(crate) client: Rc, - pub(crate) path: PathBuf, -} - -/// Await one LSP response with a timeout, off the UI thread. -async fn recv_with_timeout(rx: smol::channel::Receiver) -> Result { - let timeout = async { - smol::Timer::after(REQUEST_TIMEOUT).await; - Err(anyhow!("lsp request timed out")) - }; - let recv = async { rx.recv().await.map_err(|_| anyhow!("lsp server gone")) }; - smol::future::or(recv, timeout).await -} - -impl CompletionProvider for FileLsp { - fn completions( - &self, - text: &Rope, - offset: usize, - trigger: lsp_types::CompletionContext, - _window: &mut Window, - cx: &mut Context, - ) -> Task> { - let Some(mut params) = LspClient::position_params(&self.path, text, offset) else { - return Task::ready(Err(anyhow!("bad path"))); - }; - params["context"] = serde_json::to_value(&trigger).unwrap_or(Value::Null); - let rx = self.client.request("textDocument/completion", params); - cx.background_executor().spawn(async move { - let v = recv_with_timeout(rx).await?; - if v.is_null() { - return Ok(lsp_types::CompletionResponse::Array(vec![])); - } - Ok(serde_json::from_value(v)?) - }) - } - - fn is_completion_trigger( - &self, - _offset: usize, - new_text: &str, - _cx: &mut Context, - ) -> bool { - new_text - .chars() - .last() - .is_some_and(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | ':')) - } -} - -impl HoverProvider for FileLsp { - fn hover( - &self, - text: &Rope, - offset: usize, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - let Some(params) = LspClient::position_params(&self.path, text, offset) else { - return Task::ready(Ok(None)); - }; - let rx = self.client.request("textDocument/hover", params); - cx.background_executor().spawn(async move { - let v = recv_with_timeout(rx).await?; - if v.is_null() { - return Ok(None); - } - Ok(serde_json::from_value(v).ok()) - }) - } -} - -impl DefinitionProvider for FileLsp { - fn definitions( - &self, - text: &Rope, - offset: usize, - _window: &mut Window, - cx: &mut App, - ) -> Task>> { - let Some(params) = LspClient::position_params(&self.path, text, offset) else { - return Task::ready(Ok(vec![])); - }; - let Some(this_uri) = uri_for_path(&self.path) else { - return Task::ready(Ok(vec![])); - }; - let rx = self.client.request("textDocument/definition", params); - cx.background_executor().spawn(async move { - let v = recv_with_timeout(rx).await?; - let links = normalize_definitions(v); - // The component's ⌘-click jump applies target offsets to the - // *current* buffer, so only same-file links are safe to hand it; - // cross-file jumps go through the F12 action instead. - Ok(links - .into_iter() - .filter(|l| l.target_uri.as_str() == this_uri) - .collect()) - }) - } -} - -/// `textDocument/definition` may answer `Location`, `Location[]` or -/// `LocationLink[]`; normalize all three to links. -pub(crate) fn normalize_definitions(v: Value) -> Vec { - if v.is_null() { - return vec![]; - } - if let Ok(links) = serde_json::from_value::>(v.clone()) { - return links; - } - let to_link = |loc: lsp_types::Location| lsp_types::LocationLink { - origin_selection_range: None, - target_uri: loc.uri, - target_range: loc.range, - target_selection_range: loc.range, - }; - if let Ok(locs) = serde_json::from_value::>(v.clone()) { - return locs.into_iter().map(to_link).collect(); - } - if let Ok(loc) = serde_json::from_value::(v) { - return vec![to_link(loc)]; - } - vec![] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn uri_round_trips_paths_with_spaces() { - // `Url::from_file_path` requires an *absolute* path in the host's own - // shape, so a POSIX literal here would simply fail to convert on - // Windows rather than exercise the percent-encoding under test. - let p = if cfg!(windows) { - Path::new(r"C:\tmp\a dir\file.rs") - } else { - Path::new("/tmp/a dir/file.rs") - }; - let uri = uri_for_path(p).unwrap(); - assert!(uri.starts_with("file:///")); - assert!(uri.contains("a%20dir")); - assert_eq!(path_for_uri(&uri), Some(p.to_path_buf())); - } - - #[test] - fn definition_responses_normalize_all_three_shapes() { - let loc = json!({ "uri": "file:///a.rs", "range": { - "start": { "line": 1, "character": 2 }, - "end": { "line": 1, "character": 5 } } }); - // Single Location. - assert_eq!(normalize_definitions(loc.clone()).len(), 1); - // Location[]. - assert_eq!( - normalize_definitions(json!([loc.clone(), loc.clone()])).len(), - 2 - ); - // LocationLink[]. - let link = json!([{ "targetUri": "file:///b.rs", - "targetRange": loc["range"], "targetSelectionRange": loc["range"] }]); - let links = normalize_definitions(link); - assert_eq!(links.len(), 1); - assert_eq!(links[0].target_uri.as_str(), "file:///b.rs"); - // Null. - assert!(normalize_definitions(Value::Null).is_empty()); - } - - #[test] - fn server_map_covers_the_big_five() { - for lang in ["rust", "go", "python", "typescript", "cpp"] { - assert!(server_for_language(lang).is_some(), "{lang}"); - } - assert!(server_for_language("markdown").is_none()); - } -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 5d5757fd..9356807a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -14,7 +14,6 @@ pub mod forwards; pub mod hints; pub mod home; pub mod keymap; -pub mod lsp; pub mod palette; pub mod pane; pub mod perf;