Start work on a language server log view

Max Brunsfeld created

Change summary

Cargo.lock                    |  21 ++
Cargo.toml                    |   1 
crates/editor/src/editor.rs   |  21 ++
crates/lsp_log/Cargo.toml     |  29 ++
crates/lsp_log/src/lsp_log.rs | 374 +++++++++++++++++++++++++++++++++++++
crates/project/src/project.rs |  36 ++-
crates/zed/Cargo.toml         |   1 
crates/zed/src/zed.rs         |   7 
8 files changed, 477 insertions(+), 13 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3612,6 +3612,26 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "lsp_log"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "editor",
+ "futures 0.3.25",
+ "gpui",
+ "language",
+ "lsp",
+ "project",
+ "serde",
+ "settings",
+ "theme",
+ "unindent",
+ "util",
+ "workspace",
+]
+
 [[package]]
 name = "mach"
 version = "0.3.2"
@@ -8571,6 +8591,7 @@ dependencies = [
  "libc",
  "log",
  "lsp",
+ "lsp_log",
  "node_runtime",
  "num_cpus",
  "outline",

Cargo.toml 🔗

@@ -35,6 +35,7 @@ members = [
     "crates/live_kit_client",
     "crates/live_kit_server",
     "crates/lsp",
+    "crates/lsp_log",
     "crates/media",
     "crates/menu",
     "crates/node_runtime",

crates/editor/src/editor.rs 🔗

@@ -511,6 +511,7 @@ pub struct Editor {
     workspace_id: Option<WorkspaceId>,
     keymap_context_layers: BTreeMap<TypeId, KeymapContext>,
     input_enabled: bool,
+    read_only: bool,
     leader_replica_id: Option<u16>,
     remote_id: Option<ViewId>,
     hover_state: HoverState,
@@ -1283,6 +1284,7 @@ impl Editor {
             workspace_id: None,
             keymap_context_layers: Default::default(),
             input_enabled: true,
+            read_only: false,
             leader_replica_id: None,
             remote_id: None,
             hover_state: Default::default(),
@@ -1425,6 +1427,10 @@ impl Editor {
         self.input_enabled = input_enabled;
     }
 
+    pub fn set_read_only(&mut self, read_only: bool) {
+        self.read_only = read_only;
+    }
+
     fn selections_did_change(
         &mut self,
         local: bool,
@@ -1533,6 +1539,10 @@ impl Editor {
         S: ToOffset,
         T: Into<Arc<str>>,
     {
+        if self.read_only {
+            return;
+        }
+
         self.buffer
             .update(cx, |buffer, cx| buffer.edit(edits, None, cx));
     }
@@ -1543,6 +1553,10 @@ impl Editor {
         S: ToOffset,
         T: Into<Arc<str>>,
     {
+        if self.read_only {
+            return;
+        }
+
         self.buffer.update(cx, |buffer, cx| {
             buffer.edit(edits, Some(AutoindentMode::EachLine), cx)
         });
@@ -1897,6 +1911,9 @@ impl Editor {
     pub fn handle_input(&mut self, text: &str, cx: &mut ViewContext<Self>) {
         let text: Arc<str> = text.into();
 
+        if self.read_only {
+            return;
+        }
         if !self.input_enabled {
             cx.emit(Event::InputIgnored { text });
             return;
@@ -2282,6 +2299,10 @@ impl Editor {
         autoindent_mode: Option<AutoindentMode>,
         cx: &mut ViewContext<Self>,
     ) {
+        if self.read_only {
+            return;
+        }
+
         let text: Arc<str> = text.into();
         self.transact(cx, |this, cx| {
             let old_selections = this.selections.all_adjusted(cx);

crates/lsp_log/Cargo.toml 🔗

@@ -0,0 +1,29 @@
+[package]
+name = "lsp_log"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/lsp_log.rs"
+doctest = false
+
+[dependencies]
+collections = { path = "../collections" }
+editor = { path = "../editor" }
+settings = { path = "../settings" }
+theme = { path = "../theme" }
+language = { path = "../language" }
+project = { path = "../project" }
+workspace = { path = "../workspace" }
+gpui = { path = "../gpui" }
+util = { path = "../util" }
+lsp = { path = "../lsp" }
+futures = { workspace = true }
+serde = { workspace = true }
+anyhow = "1.0"
+
+[dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }
+unindent = "0.1.7"

crates/lsp_log/src/lsp_log.rs 🔗

@@ -0,0 +1,374 @@
+use collections::HashMap;
+use editor::Editor;
+use futures::{channel::mpsc, StreamExt};
+use gpui::{
+    actions,
+    elements::{
+        AnchorCorner, ChildView, Empty, Flex, Label, MouseEventHandler, Overlay, OverlayFitMode,
+        ParentElement, Stack,
+    },
+    impl_internal_actions,
+    platform::MouseButton,
+    AppContext, Element, ElementBox, Entity, ModelHandle, RenderContext, View, ViewContext,
+    ViewHandle,
+};
+use language::{Buffer, LanguageServerId, LanguageServerName};
+use project::{Project, WorktreeId};
+use settings::Settings;
+use std::{borrow::Cow, sync::Arc};
+use theme::Theme;
+use workspace::{
+    item::{Item, ItemHandle},
+    ToolbarItemLocation, ToolbarItemView, Workspace,
+};
+
+const SEND_LINE: &str = "// Send:\n";
+const RECEIVE_LINE: &str = "// Receive:\n";
+
+pub struct LspLogView {
+    enabled_logs: HashMap<LanguageServerId, LogState>,
+    current_server_id: Option<LanguageServerId>,
+    project: ModelHandle<Project>,
+    io_tx: mpsc::UnboundedSender<(LanguageServerId, bool, String)>,
+}
+
+pub struct LspLogToolbarItemView {
+    log_view: Option<ViewHandle<LspLogView>>,
+    menu_open: bool,
+    project: ModelHandle<Project>,
+}
+
+struct LogState {
+    buffer: ModelHandle<Buffer>,
+    editor: ViewHandle<Editor>,
+    last_message_kind: Option<MessageKind>,
+    _subscription: lsp::Subscription,
+}
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+enum MessageKind {
+    Send,
+    Receive,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+struct ActivateLog {
+    server_id: LanguageServerId,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+struct ToggleMenu;
+
+impl_internal_actions!(log, [ActivateLog, ToggleMenu]);
+actions!(log, [OpenLanguageServerLogs]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.add_action(LspLogView::deploy);
+    cx.add_action(LspLogToolbarItemView::toggle_menu);
+    cx.add_action(LspLogToolbarItemView::activate_log_for_server);
+}
+
+impl LspLogView {
+    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
+        let (io_tx, mut io_rx) = mpsc::unbounded();
+        let this = Self {
+            enabled_logs: HashMap::default(),
+            current_server_id: None,
+            io_tx,
+            project,
+        };
+        cx.spawn_weak(|this, mut cx| async move {
+            while let Some((language_server_id, is_output, mut message)) = io_rx.next().await {
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| {
+                        message.push('\n');
+                        this.on_io(language_server_id, is_output, &message, cx);
+                    })
+                }
+            }
+        })
+        .detach();
+        this
+    }
+
+    fn deploy(
+        workspace: &mut Workspace,
+        _: &OpenLanguageServerLogs,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let project = workspace.project().read(cx);
+        if project.is_remote() {
+            return;
+        }
+
+        let log_view = cx.add_view(|cx| Self::new(workspace.project().clone(), cx));
+        workspace.add_item(Box::new(log_view), cx);
+    }
+
+    fn activate_log(&mut self, action: &ActivateLog, cx: &mut ViewContext<Self>) {
+        self.enable_logs_for_language_server(action.server_id, cx);
+        self.current_server_id = Some(action.server_id);
+        cx.notify();
+    }
+
+    fn on_io(
+        &mut self,
+        language_server_id: LanguageServerId,
+        is_received: bool,
+        message: &str,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let Some(state) = self.enabled_logs.get_mut(&language_server_id) {
+            state.buffer.update(cx, |buffer, cx| {
+                let kind = if is_received {
+                    MessageKind::Receive
+                } else {
+                    MessageKind::Send
+                };
+                if state.last_message_kind != Some(kind) {
+                    let len = buffer.len();
+                    let line = match kind {
+                        MessageKind::Send => SEND_LINE,
+                        MessageKind::Receive => RECEIVE_LINE,
+                    };
+                    buffer.edit([(len..len, line)], None, cx);
+                    state.last_message_kind = Some(kind);
+                }
+                let len = buffer.len();
+                buffer.edit([(len..len, message)], None, cx);
+            });
+        }
+    }
+
+    pub fn enable_logs_for_language_server(
+        &mut self,
+        server_id: LanguageServerId,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let Some(server) = self.project.read(cx).language_server_for_id(server_id) {
+            self.enabled_logs.entry(server_id).or_insert_with(|| {
+                let project = self.project.read(cx);
+                let io_tx = self.io_tx.clone();
+                let language = project.languages().language_for_name("JSON");
+                let buffer = cx.add_model(|cx| Buffer::new(0, "", cx));
+                cx.spawn({
+                    let buffer = buffer.clone();
+                    |_, mut cx| async move {
+                        let language = language.await.ok();
+                        buffer.update(&mut cx, |buffer, cx| {
+                            buffer.set_language(language, cx);
+                        });
+                    }
+                })
+                .detach();
+                let editor = cx.add_view(|cx| {
+                    let mut editor =
+                        Editor::for_buffer(buffer.clone(), Some(self.project.clone()), cx);
+                    editor.set_read_only(true);
+                    editor
+                });
+
+                LogState {
+                    buffer,
+                    editor,
+                    last_message_kind: None,
+                    _subscription: server.on_io(move |is_received, json| {
+                        io_tx
+                            .unbounded_send((server_id, is_received, json.to_string()))
+                            .ok();
+                    }),
+                }
+            });
+        }
+    }
+}
+
+impl View for LspLogView {
+    fn ui_name() -> &'static str {
+        "LspLogView"
+    }
+
+    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
+        if let Some(id) = self.current_server_id {
+            if let Some(log) = self.enabled_logs.get_mut(&id) {
+                return ChildView::new(&log.editor, cx).boxed();
+            }
+        }
+        Empty::new().boxed()
+    }
+}
+
+impl Item for LspLogView {
+    fn tab_content(&self, _: Option<usize>, style: &theme::Tab, _: &AppContext) -> ElementBox {
+        Label::new("Logs", style.label.clone()).boxed()
+    }
+}
+
+impl ToolbarItemView for LspLogToolbarItemView {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn ItemHandle>,
+        cx: &mut ViewContext<Self>,
+    ) -> workspace::ToolbarItemLocation {
+        self.menu_open = false;
+        if let Some(item) = active_pane_item {
+            if let Some(log_view) = item.downcast::<LspLogView>() {
+                self.log_view = Some(log_view.clone());
+                return ToolbarItemLocation::PrimaryLeft {
+                    flex: Some((1., false)),
+                };
+            }
+        }
+        self.log_view = None;
+        ToolbarItemLocation::Hidden
+    }
+}
+
+impl View for LspLogToolbarItemView {
+    fn ui_name() -> &'static str {
+        "LspLogView"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<'_, Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+        let Some(log_view) = self.log_view.as_ref() else { return Empty::new().boxed() };
+        let project = self.project.read(cx);
+        let mut language_servers = project.language_servers().collect::<Vec<_>>();
+        language_servers.sort_by_key(|a| a.0);
+
+        let current_server_id = log_view.read(cx).current_server_id;
+        let current_server = current_server_id.and_then(|current_server_id| {
+            if let Ok(ix) = language_servers.binary_search_by_key(&current_server_id, |e| e.0) {
+                Some(language_servers[ix].clone())
+            } else {
+                None
+            }
+        });
+
+        Stack::new()
+            .with_child(Self::render_language_server_menu_header(
+                current_server,
+                &self.project,
+                &theme,
+                cx,
+            ))
+            .with_children(if self.menu_open {
+                Some(
+                    Overlay::new(
+                        Flex::column()
+                            .with_children(language_servers.into_iter().filter_map(
+                                |(id, name, worktree_id)| {
+                                    Self::render_language_server_menu_item(
+                                        id,
+                                        name,
+                                        worktree_id,
+                                        &self.project,
+                                        &theme,
+                                        cx,
+                                    )
+                                },
+                            ))
+                            .contained()
+                            .with_style(theme.contacts_popover.container)
+                            .constrained()
+                            .with_width(200.)
+                            .with_height(400.)
+                            .boxed(),
+                    )
+                    .with_fit_mode(OverlayFitMode::SwitchAnchor)
+                    .with_anchor_corner(AnchorCorner::TopRight)
+                    .with_z_index(999)
+                    .aligned()
+                    .bottom()
+                    .right()
+                    .boxed(),
+                )
+            } else {
+                None
+            })
+            .boxed()
+    }
+}
+
+impl LspLogToolbarItemView {
+    pub fn new(project: ModelHandle<Project>) -> Self {
+        Self {
+            menu_open: false,
+            log_view: None,
+            project,
+        }
+    }
+
+    fn toggle_menu(&mut self, _: &ToggleMenu, cx: &mut ViewContext<Self>) {
+        self.menu_open = !self.menu_open;
+        cx.notify();
+    }
+
+    fn activate_log_for_server(&mut self, action: &ActivateLog, cx: &mut ViewContext<Self>) {
+        if let Some(log_view) = &self.log_view {
+            log_view.update(cx, |log_view, cx| {
+                log_view.activate_log(action, cx);
+            });
+            self.menu_open = false;
+        }
+        cx.notify();
+    }
+
+    fn render_language_server_menu_header(
+        current_server: Option<(LanguageServerId, LanguageServerName, WorktreeId)>,
+        project: &ModelHandle<Project>,
+        theme: &Arc<Theme>,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        MouseEventHandler::<ToggleMenu>::new(0, cx, move |state, cx| {
+            let project = project.read(cx);
+            let label: Cow<str> = current_server
+                .and_then(|(_, server_name, worktree_id)| {
+                    let worktree = project.worktree_for_id(worktree_id, cx)?;
+                    let worktree = &worktree.read(cx);
+                    Some(format!("{} - ({})", server_name.0, worktree.root_name()).into())
+                })
+                .unwrap_or_else(|| "No server selected".into());
+            Label::new(label, theme.context_menu.item.default.label.clone()).boxed()
+        })
+        .on_click(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(ToggleMenu);
+        })
+        .boxed()
+    }
+
+    fn render_language_server_menu_item(
+        id: LanguageServerId,
+        name: LanguageServerName,
+        worktree_id: WorktreeId,
+        project: &ModelHandle<Project>,
+        theme: &Arc<Theme>,
+        cx: &mut RenderContext<Self>,
+    ) -> Option<ElementBox> {
+        let project = project.read(cx);
+        let worktree = project.worktree_for_id(worktree_id, cx)?;
+        let worktree = &worktree.read(cx);
+        if !worktree.is_visible() {
+            return None;
+        }
+        let label = format!("{} - ({})", name.0, worktree.root_name());
+
+        Some(
+            MouseEventHandler::<ActivateLog>::new(id.0, cx, move |state, cx| {
+                Label::new(label, theme.context_menu.item.default.label.clone()).boxed()
+            })
+            .on_click(MouseButton::Left, move |_, cx| {
+                cx.dispatch_action(ActivateLog { server_id: id })
+            })
+            .boxed(),
+        )
+    }
+}
+
+impl Entity for LspLogView {
+    type Event = ();
+}
+
+impl Entity for LspLogToolbarItemView {
+    type Event = ();
+}

crates/project/src/project.rs 🔗

@@ -185,6 +185,8 @@ pub struct Collaborator {
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Event {
+    LanguageServerAdded(LanguageServerId),
+    LanguageServerRemoved(LanguageServerId),
     ActiveEntryChanged(Option<ProjectEntryId>),
     WorktreeAdded,
     WorktreeRemoved(WorktreeId),
@@ -1869,7 +1871,7 @@ impl Project {
                 let next_snapshot = buffer.text_snapshot();
 
                 let language_servers: Vec<_> = self
-                    .language_servers_iter_for_buffer(buffer, cx)
+                    .language_servers_for_buffer(buffer, cx)
                     .map(|i| i.1.clone())
                     .collect();
 
@@ -6279,7 +6281,25 @@ impl Project {
         }
     }
 
-    pub fn language_servers_iter_for_buffer(
+    pub fn language_servers(
+        &self,
+    ) -> impl '_ + Iterator<Item = (LanguageServerId, LanguageServerName, WorktreeId)> {
+        self.language_server_ids
+            .iter()
+            .map(|((worktree_id, server_name), server_id)| {
+                (*server_id, server_name.clone(), *worktree_id)
+            })
+    }
+
+    pub fn language_server_for_id(&self, id: LanguageServerId) -> Option<Arc<LanguageServer>> {
+        if let LanguageServerState::Running { server, .. } = self.language_servers.get(&id)? {
+            Some(server.clone())
+        } else {
+            None
+        }
+    }
+
+    pub fn language_servers_for_buffer(
         &self,
         buffer: &Buffer,
         cx: &AppContext,
@@ -6299,20 +6319,12 @@ impl Project {
             })
     }
 
-    fn language_servers_for_buffer(
-        &self,
-        buffer: &Buffer,
-        cx: &AppContext,
-    ) -> Vec<(&Arc<CachedLspAdapter>, &Arc<LanguageServer>)> {
-        self.language_servers_iter_for_buffer(buffer, cx).collect()
-    }
-
     fn primary_language_servers_for_buffer(
         &self,
         buffer: &Buffer,
         cx: &AppContext,
     ) -> Option<(&Arc<CachedLspAdapter>, &Arc<LanguageServer>)> {
-        self.language_servers_iter_for_buffer(buffer, cx).next()
+        self.language_servers_for_buffer(buffer, cx).next()
     }
 
     fn language_server_for_buffer(
@@ -6321,7 +6333,7 @@ impl Project {
         server_id: LanguageServerId,
         cx: &AppContext,
     ) -> Option<(&Arc<CachedLspAdapter>, &Arc<LanguageServer>)> {
-        self.language_servers_iter_for_buffer(buffer, cx)
+        self.language_servers_for_buffer(buffer, cx)
             .find(|(_, s)| s.server_id() == server_id)
     }
 

crates/zed/Cargo.toml 🔗

@@ -46,6 +46,7 @@ journal = { path = "../journal" }
 language = { path = "../language" }
 language_selector = { path = "../language_selector" }
 lsp = { path = "../lsp" }
+lsp_log = { path = "../lsp_log" }
 node_runtime = { path = "../node_runtime" }
 outline = { path = "../outline" }
 plugin_runtime = { path = "../plugin_runtime" }

crates/zed/src/zed.rs 🔗

@@ -262,6 +262,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
     );
     activity_indicator::init(cx);
     copilot_button::init(cx);
+    lsp_log::init(cx);
     call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
     settings::KeymapFileContent::load_defaults(cx);
 }
@@ -273,7 +274,7 @@ pub fn initialize_workspace(
 ) {
     let workspace_handle = cx.handle();
     cx.subscribe(&workspace_handle, {
-        move |_, _, event, cx| {
+        move |workspace, _, event, cx| {
             if let workspace::Event::PaneAdded(pane) = event {
                 pane.update(cx, |pane, cx| {
                     pane.toolbar().update(cx, |toolbar, cx| {
@@ -287,6 +288,10 @@ pub fn initialize_workspace(
                         toolbar.add_item(submit_feedback_button, cx);
                         let feedback_info_text = cx.add_view(|_| FeedbackInfoText::new());
                         toolbar.add_item(feedback_info_text, cx);
+                        let lsp_log_item = cx.add_view(|_| {
+                            lsp_log::LspLogToolbarItemView::new(workspace.project().clone())
+                        });
+                        toolbar.add_item(lsp_log_item, cx);
                     })
                 });
             }