mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 16:02:24 +00:00
Merge pull request #159 from l0ng-ai/refactor/drop-lsp
refactor(editor): drop the LSP client entirely
This commit is contained in:
Generated
-3
@@ -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",
|
||||
|
||||
+4
-9
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
+2
-8
@@ -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<Box<crate::ui::code_editor::TabCode>>,
|
||||
/// 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
|
||||
|
||||
+20
-329
@@ -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<crate::ui::lsp::LspClient>, &'static str)>,
|
||||
/// Debounced full-document `didChange`; replaced (cancelling the old
|
||||
/// timer) on every keystroke.
|
||||
change_task: Option<gpui::Task<()>>,
|
||||
_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<OpenFile>,
|
||||
pub(crate) active: usize,
|
||||
/// Find-references results, shown as a drawer under the editor.
|
||||
pub(crate) references: Option<Vec<ReferenceItem>>,
|
||||
/// File-tree roots: this tab's pane cwds resolved to repo roots.
|
||||
pub(crate) roots: Vec<PathBuf>,
|
||||
pub(crate) expanded: std::collections::HashSet<PathBuf>,
|
||||
@@ -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<PathBuf>,
|
||||
/// 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<Item = &'a OpenFile> {
|
||||
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<Self>) {
|
||||
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<lsp_types::Diagnostic>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let inputs: Vec<Entity<InputState>> = 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<Self>) {
|
||||
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<Self>) {
|
||||
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<lsp_types::Location> = 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<ReferenceItem> = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let mut items: Vec<ReferenceItem> = Vec::new();
|
||||
let mut file_lines: std::collections::HashMap<PathBuf, Vec<String>> =
|
||||
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>,
|
||||
) {
|
||||
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<Self>) -> gpui::Div {
|
||||
let code = self.tab_code();
|
||||
let muted = cx.theme().muted_foreground;
|
||||
@@ -1116,9 +898,6 @@ impl Tty7App {
|
||||
let wrap: Option<bool> = 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<SharedString> = 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<Self>) -> Option<AnyElement> {
|
||||
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<Self>) -> AnyElement {
|
||||
let tab_ix = self.active;
|
||||
|
||||
@@ -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<KeyBinding> {
|
||||
"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),
|
||||
|
||||
-642
@@ -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<String> {
|
||||
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<PathBuf> {
|
||||
url::Url::parse(uri).ok()?.to_file_path().ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Client.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Thread-shared client internals (UI thread + reader thread).
|
||||
struct Inner {
|
||||
name: String,
|
||||
stdin: Mutex<ChildStdin>,
|
||||
/// In-flight requests by id; the reader thread resolves them.
|
||||
pending: Mutex<HashMap<i64, smol::channel::Sender<Value>>>,
|
||||
/// Flips true when the `initialize` response lands; frames sent before
|
||||
/// that wait in `queued`.
|
||||
ready: AtomicBool,
|
||||
queued: Mutex<Vec<String>>,
|
||||
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<String> = 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<Inner>,
|
||||
child: std::cell::RefCell<Child>,
|
||||
#[allow(dead_code)] // identifies the client in future workspace-level requests
|
||||
pub(crate) root: PathBuf,
|
||||
/// didOpen version counter per document.
|
||||
versions: std::cell::RefCell<HashMap<PathBuf, i64>>,
|
||||
}
|
||||
|
||||
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<lsp_types::Diagnostic>)>,
|
||||
) -> Result<Self> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Inner>,
|
||||
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
|
||||
) {
|
||||
let mut reader = BufReader::new(stdout);
|
||||
loop {
|
||||
// Headers.
|
||||
let mut content_length: Option<usize> = 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::<Value>(&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<lsp_types::Diagnostic> = 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<Rc<LspClient>>>,
|
||||
diag_tx: smol::channel::Sender<(PathBuf, Vec<lsp_types::Diagnostic>)>,
|
||||
}
|
||||
|
||||
impl LspRegistry {
|
||||
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> 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<lsp_types::Diagnostic>)>();
|
||||
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<LspClient>, &'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<LspClient>,
|
||||
pub(crate) path: PathBuf,
|
||||
}
|
||||
|
||||
/// Await one LSP response with a timeout, off the UI thread.
|
||||
async fn recv_with_timeout(rx: smol::channel::Receiver<Value>) -> Result<Value> {
|
||||
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<InputState>,
|
||||
) -> Task<Result<lsp_types::CompletionResponse>> {
|
||||
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<InputState>,
|
||||
) -> 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<Result<Option<lsp_types::Hover>>> {
|
||||
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<Result<Vec<lsp_types::LocationLink>>> {
|
||||
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<lsp_types::LocationLink> {
|
||||
if v.is_null() {
|
||||
return vec![];
|
||||
}
|
||||
if let Ok(links) = serde_json::from_value::<Vec<lsp_types::LocationLink>>(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::<Vec<lsp_types::Location>>(v.clone()) {
|
||||
return locs.into_iter().map(to_link).collect();
|
||||
}
|
||||
if let Ok(loc) = serde_json::from_value::<lsp_types::Location>(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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user