Persist LSP logs after log view is closed

Max Brunsfeld created

Change summary

crates/lsp_log/src/lsp_log.rs | 367 +++++++++++++++++++++++++-----------
crates/theme/src/ui.rs        |  34 ++-
crates/welcome/src/welcome.rs |  10 
3 files changed, 282 insertions(+), 129 deletions(-)

Detailed changes

crates/lsp_log/src/lsp_log.rs 🔗

@@ -1,4 +1,4 @@
-use collections::HashMap;
+use collections::{hash_map, HashMap};
 use editor::Editor;
 use futures::{channel::mpsc, StreamExt};
 use gpui::{
@@ -7,15 +7,15 @@ use gpui::{
         AnchorCorner, ChildView, Empty, Flex, Label, MouseEventHandler, Overlay, OverlayFitMode,
         ParentElement, Stack,
     },
-    platform::MouseButton,
-    AnyElement, AppContext, Element, Entity, ModelHandle, View, ViewContext, ViewHandle,
+    platform::{CursorStyle, MouseButton},
+    AnyElement, AppContext, Element, Entity, ModelContext, ModelHandle, View, ViewContext,
+    ViewHandle, WeakModelHandle,
 };
 use language::{Buffer, LanguageServerId, LanguageServerName};
 use project::{Project, WorktreeId};
 use settings::Settings;
 use std::{borrow::Cow, sync::Arc};
-use theme::Theme;
-use util::ResultExt;
+use theme::{ui, Theme};
 use workspace::{
     item::{Item, ItemHandle},
     ToolbarItemLocation, ToolbarItemView, Workspace,
@@ -24,11 +24,27 @@ use workspace::{
 const SEND_LINE: &str = "// Send:\n";
 const RECEIVE_LINE: &str = "// Receive:\n";
 
+struct LogStore {
+    projects: HashMap<WeakModelHandle<Project>, LogStoreProject>,
+    io_tx: mpsc::UnboundedSender<(WeakModelHandle<Project>, LanguageServerId, bool, String)>,
+}
+
+struct LogStoreProject {
+    servers: HashMap<LanguageServerId, LogStoreLanguageServer>,
+    _subscription: gpui::Subscription,
+}
+
+struct LogStoreLanguageServer {
+    buffer: ModelHandle<Buffer>,
+    last_message_kind: Option<MessageKind>,
+    _subscription: lsp::Subscription,
+}
+
 pub struct LspLogView {
-    enabled_logs: HashMap<LanguageServerId, LogState>,
+    log_store: ModelHandle<LogStore>,
     current_server_id: Option<LanguageServerId>,
+    editor: Option<ViewHandle<Editor>>,
     project: ModelHandle<Project>,
-    io_tx: mpsc::UnboundedSender<(LanguageServerId, bool, String)>,
 }
 
 pub struct LspLogToolbarItemView {
@@ -37,13 +53,6 @@ pub struct LspLogToolbarItemView {
     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,
@@ -53,26 +62,37 @@ enum MessageKind {
 actions!(log, [OpenLanguageServerLogs]);
 
 pub fn init(cx: &mut AppContext) {
-    cx.add_action(LspLogView::open);
+    let log_set = cx.add_model(|cx| LogStore::new(cx));
+
+    cx.add_action(
+        move |workspace: &mut Workspace, _: &OpenLanguageServerLogs, cx: _| {
+            let project = workspace.project().read(cx);
+            if project.is_local() {
+                workspace.add_item(
+                    Box::new(cx.add_view(|cx| {
+                        LspLogView::new(workspace.project().clone(), log_set.clone(), cx)
+                    })),
+                    cx,
+                );
+            }
+        },
+    );
 }
 
-impl LspLogView {
-    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
+impl LogStore {
+    fn new(cx: &mut ModelContext<Self>) -> Self {
         let (io_tx, mut io_rx) = mpsc::unbounded();
         let this = Self {
-            enabled_logs: HashMap::default(),
-            current_server_id: None,
+            projects: HashMap::default(),
             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 {
+            while let Some((project, 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);
-                    })
-                    .log_err();
+                        this.on_io(project, server_id, is_output, &message, cx);
+                    });
                 }
             }
             anyhow::Ok(())
@@ -81,95 +101,155 @@ impl LspLogView {
         this
     }
 
-    fn open(
-        workspace: &mut Workspace,
-        _: &OpenLanguageServerLogs,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        let project = workspace.project().read(cx);
-        if project.is_remote() {
-            return;
-        }
+    pub fn has_enabled_logs_for_language_server(
+        &self,
+        project: &ModelHandle<Project>,
+        server_id: LanguageServerId,
+    ) -> bool {
+        self.projects
+            .get(&project.downgrade())
+            .map_or(false, |store| store.servers.contains_key(&server_id))
+    }
+
+    pub fn enable_logs_for_language_server(
+        &mut self,
+        project: &ModelHandle<Project>,
+        server_id: LanguageServerId,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<ModelHandle<Buffer>> {
+        let server = project.read(cx).language_server_for_id(server_id)?;
+        let weak_project = project.downgrade();
+        let project_logs = match self.projects.entry(weak_project) {
+            hash_map::Entry::Occupied(entry) => entry.into_mut(),
+            hash_map::Entry::Vacant(entry) => entry.insert(LogStoreProject {
+                servers: HashMap::default(),
+                _subscription: cx.observe_release(&project, move |this, _, cx| {
+                    this.projects.remove(&weak_project);
+                }),
+            }),
+        };
+        let server_log_state = project_logs.servers.entry(server_id).or_insert_with(|| {
+            let io_tx = self.io_tx.clone();
+            let language = project.read(cx).languages().language_for_name("JSON");
+            let buffer = cx.add_model(|cx| Buffer::new(0, "", cx));
+            cx.spawn_weak({
+                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 log_view = cx.add_view(|cx| Self::new(workspace.project().clone(), cx));
-        workspace.add_item(Box::new(log_view), cx);
+            let project = project.downgrade();
+            LogStoreLanguageServer {
+                buffer,
+                last_message_kind: None,
+                _subscription: server.on_io(move |is_received, json| {
+                    io_tx
+                        .unbounded_send((project, server_id, is_received, json.to_string()))
+                        .ok();
+                }),
+            }
+        });
+        Some(server_log_state.buffer.clone())
     }
 
-    fn activate_log(&mut self, server_id: LanguageServerId, cx: &mut ViewContext<Self>) {
-        self.enable_logs_for_language_server(server_id, cx);
-        self.current_server_id = Some(server_id);
-        cx.notify();
+    pub fn disable_logs_for_language_server(
+        &mut self,
+        project: &ModelHandle<Project>,
+        server_id: LanguageServerId,
+        cx: &mut ModelContext<Self>,
+    ) {
+        let project = project.downgrade();
+        if let Some(store) = self.projects.get_mut(&project) {
+            store.servers.remove(&server_id);
+            if store.servers.is_empty() {
+                self.projects.remove(&project);
+            }
+        }
     }
 
     fn on_io(
         &mut self,
+        project: WeakModelHandle<Project>,
         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);
-                }
+        cx: &mut AppContext,
+    ) -> Option<()> {
+        let state = self
+            .projects
+            .get_mut(&project)?
+            .servers
+            .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();
-                buffer.edit([(len..len, message)], None, cx);
-            });
+                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);
+        });
+        Some(())
+    }
+}
+
+impl LspLogView {
+    fn new(
+        project: ModelHandle<Project>,
+        log_set: ModelHandle<LogStore>,
+        _: &mut ViewContext<Self>,
+    ) -> Self {
+        Self {
+            project,
+            log_store: log_set,
+            editor: None,
+            current_server_id: None,
         }
     }
 
-    pub fn enable_logs_for_language_server(
+    fn show_logs_for_server(&mut self, server_id: LanguageServerId, cx: &mut ViewContext<Self>) {
+        let buffer = self.log_store.update(cx, |log_set, cx| {
+            log_set.enable_logs_for_language_server(&self.project, server_id, cx)
+        });
+        if let Some(buffer) = buffer {
+            self.current_server_id = Some(server_id);
+            self.editor = Some(cx.add_view(|cx| {
+                let mut editor = Editor::for_buffer(buffer, Some(self.project.clone()), cx);
+                editor.set_read_only(true);
+                editor.move_to_end(&Default::default(), cx);
+                editor
+            }));
+            cx.notify();
+        }
+    }
+
+    fn toggle_logging_for_server(
         &mut self,
         server_id: LanguageServerId,
+        enabled: bool,
         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();
-                    }),
-                }
-            });
-        }
+        self.log_store.update(cx, |log_store, cx| {
+            if enabled {
+                log_store.enable_logs_for_language_server(&self.project, server_id, cx);
+            } else {
+                log_store.disable_logs_for_language_server(&self.project, server_id, cx);
+            }
+        });
     }
 }
 
@@ -179,12 +259,11 @@ impl View for LspLogView {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        if let Some(id) = self.current_server_id {
-            if let Some(log) = self.enabled_logs.get_mut(&id) {
-                return ChildView::new(&log.editor, cx).into_any();
-            }
+        if let Some(editor) = &self.editor {
+            ChildView::new(&editor, cx).into_any()
+        } else {
+            Empty::new().into_any()
         }
-        Empty::new().into_any()
     }
 }
 
@@ -195,7 +274,7 @@ impl Item for LspLogView {
         style: &theme::Tab,
         _: &AppContext,
     ) -> AnyElement<V> {
-        Label::new("Logs", style.label.clone()).into_any()
+        Label::new("LSP Logs", style.label.clone()).into_any()
     }
 }
 
@@ -228,10 +307,24 @@ impl View for LspLogToolbarItemView {
         let theme = cx.global::<Settings>().theme.clone();
         let Some(log_view) = self.log_view.as_ref() else { return Empty::new().into_any() };
         let project = self.project.read(cx);
-        let mut language_servers = project.language_servers().collect::<Vec<_>>();
-        language_servers.sort_by_key(|a| a.0);
+        let log_view = log_view.read(cx);
+        let log_store = log_view.log_store.read(cx);
+
+        let mut language_servers = project
+            .language_servers()
+            .map(|(id, name, worktree)| {
+                (
+                    id,
+                    name,
+                    worktree,
+                    log_store.has_enabled_logs_for_language_server(&self.project, id),
+                )
+            })
+            .collect::<Vec<_>>();
+        language_servers.sort_by_key(|a| (a.0, a.2));
+        language_servers.dedup_by_key(|a| a.0);
 
-        let current_server_id = log_view.read(cx).current_server_id;
+        let current_server_id = log_view.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())
@@ -252,11 +345,13 @@ impl View for LspLogToolbarItemView {
                     Overlay::new(
                         Flex::column()
                             .with_children(language_servers.into_iter().filter_map(
-                                |(id, name, worktree_id)| {
+                                |(id, name, worktree_id, logging_enabled)| {
                                     Self::render_language_server_menu_item(
                                         id,
                                         name,
                                         worktree_id,
+                                        logging_enabled,
+                                        Some(id) == current_server_id,
                                         &self.project,
                                         &theme,
                                         cx,
@@ -266,19 +361,22 @@ impl View for LspLogToolbarItemView {
                             .contained()
                             .with_style(theme.contacts_popover.container)
                             .constrained()
-                            .with_width(200.)
+                            .with_width(400.)
                             .with_height(400.),
                     )
                     .with_fit_mode(OverlayFitMode::SwitchAnchor)
-                    .with_anchor_corner(AnchorCorner::TopRight)
+                    .with_anchor_corner(AnchorCorner::TopLeft)
                     .with_z_index(999)
                     .aligned()
                     .bottom()
-                    .right(),
+                    .left(),
                 )
             } else {
                 None
             })
+            .aligned()
+            .left()
+            .clipped()
             .into_any()
     }
 }
@@ -297,10 +395,29 @@ impl LspLogToolbarItemView {
         cx.notify();
     }
 
-    fn activate_log_for_server(&mut self, id: LanguageServerId, cx: &mut ViewContext<Self>) {
+    fn toggle_logging_for_server(
+        &mut self,
+        id: LanguageServerId,
+        enabled: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
         if let Some(log_view) = &self.log_view {
             log_view.update(cx, |log_view, cx| {
-                log_view.activate_log(id, cx);
+                log_view.toggle_logging_for_server(id, enabled, cx);
+                if !enabled && Some(id) == log_view.current_server_id {
+                    log_view.current_server_id = None;
+                    cx.notify();
+                }
+            });
+            self.menu_open = false;
+        }
+        cx.notify();
+    }
+
+    fn show_logs_for_server(&mut self, id: LanguageServerId, cx: &mut ViewContext<Self>) {
+        if let Some(log_view) = &self.log_view {
+            log_view.update(cx, |log_view, cx| {
+                log_view.show_logs_for_server(id, cx);
             });
             self.menu_open = false;
         }
@@ -308,7 +425,7 @@ impl LspLogToolbarItemView {
     }
 
     fn render_language_server_menu_header(
-        current_server: Option<(LanguageServerId, LanguageServerName, WorktreeId)>,
+        current_server: Option<(LanguageServerId, LanguageServerName, WorktreeId, bool)>,
         project: &ModelHandle<Project>,
         theme: &Arc<Theme>,
         cx: &mut ViewContext<Self>,
@@ -317,7 +434,7 @@ impl LspLogToolbarItemView {
         MouseEventHandler::<ToggleMenu, Self>::new(0, cx, move |state, cx| {
             let project = project.read(cx);
             let label: Cow<str> = current_server
-                .and_then(|(_, server_name, worktree_id)| {
+                .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())
@@ -325,6 +442,7 @@ impl LspLogToolbarItemView {
                 .unwrap_or_else(|| "No server selected".into());
             Label::new(label, theme.context_menu.item.default.label.clone())
         })
+        .with_cursor_style(CursorStyle::PointingHand)
         .on_click(MouseButton::Left, move |_, view, cx| {
             view.toggle_menu(cx);
         })
@@ -334,6 +452,8 @@ impl LspLogToolbarItemView {
         id: LanguageServerId,
         name: LanguageServerName,
         worktree_id: WorktreeId,
+        logging_enabled: bool,
+        is_selected: bool,
         project: &ModelHandle<Project>,
         theme: &Arc<Theme>,
         cx: &mut ViewContext<Self>,
@@ -349,15 +469,34 @@ impl LspLogToolbarItemView {
 
         Some(
             MouseEventHandler::<ActivateLog, _>::new(id.0, cx, move |state, cx| {
-                Label::new(label, theme.context_menu.item.default.label.clone())
+                let item_style = theme.context_menu.item.style_for(state, is_selected);
+                Flex::row()
+                    .with_child(ui::checkbox_with_label::<Self, _, Self, _>(
+                        Empty::new(),
+                        &theme.welcome.checkbox,
+                        logging_enabled,
+                        id.0,
+                        cx,
+                        move |this, enabled, cx| {
+                            this.toggle_logging_for_server(id, enabled, cx);
+                        },
+                    ))
+                    .with_child(Label::new(label, item_style.label.clone()))
+                    .contained()
+                    .with_style(item_style.container)
             })
+            .with_cursor_style(CursorStyle::PointingHand)
             .on_click(MouseButton::Left, move |_, view, cx| {
-                view.activate_log_for_server(id, cx);
+                view.show_logs_for_server(id, cx);
             }),
         )
     }
 }
 
+impl Entity for LogStore {
+    type Event = ();
+}
+
 impl Entity for LspLogView {
     type Event = ();
 }

crates/theme/src/ui.rs 🔗

@@ -27,28 +27,40 @@ pub struct CheckboxStyle {
     pub hovered_and_checked: ContainerStyle,
 }
 
-pub fn checkbox<Tag: 'static, V: View>(
+pub fn checkbox<Tag, V, F>(
     label: &'static str,
     style: &CheckboxStyle,
     checked: bool,
+    id: usize,
     cx: &mut ViewContext<V>,
-    change: fn(checked: bool, cx: &mut EventContext<V>) -> (),
-) -> MouseEventHandler<Tag, V> {
+    change: F,
+) -> MouseEventHandler<Tag, V>
+where
+    Tag: 'static,
+    V: View,
+    F: 'static + Fn(&mut V, bool, &mut EventContext<V>),
+{
     let label = Label::new(label, style.label.text.clone())
         .contained()
         .with_style(style.label.container);
-
-    checkbox_with_label(label, style, checked, cx, change)
+    checkbox_with_label(label, style, checked, id, cx, change)
 }
 
-pub fn checkbox_with_label<Tag: 'static, D: Element<V>, V: View>(
+pub fn checkbox_with_label<Tag, D, V, F>(
     label: D,
     style: &CheckboxStyle,
     checked: bool,
+    id: usize,
     cx: &mut ViewContext<V>,
-    change: fn(checked: bool, cx: &mut EventContext<V>) -> (),
-) -> MouseEventHandler<Tag, V> {
-    MouseEventHandler::new(0, cx, |state, _| {
+    change: F,
+) -> MouseEventHandler<Tag, V>
+where
+    Tag: 'static,
+    D: Element<V>,
+    V: View,
+    F: 'static + Fn(&mut V, bool, &mut EventContext<V>),
+{
+    MouseEventHandler::new(id, cx, |state, _| {
         let indicator = if checked {
             svg(&style.icon)
         } else {
@@ -75,8 +87,8 @@ pub fn checkbox_with_label<Tag: 'static, D: Element<V>, V: View>(
             .with_child(label)
             .align_children_center()
     })
-    .on_click(platform::MouseButton::Left, move |_, _, cx| {
-        change(!checked, cx)
+    .on_click(platform::MouseButton::Left, move |_, view, cx| {
+        change(view, !checked, cx)
     })
     .with_cursor_style(platform::CursorStyle::PointingHand)
 }

crates/welcome/src/welcome.rs 🔗

@@ -126,7 +126,7 @@ impl View for WelcomePage {
                 .with_child(
                     Flex::column()
                         .with_child(
-                            theme::ui::checkbox_with_label::<Metrics, _, Self>(
+                            theme::ui::checkbox_with_label::<Metrics, _, Self, _>(
                                 Flex::column()
                                     .with_child(
                                         Label::new(
@@ -146,8 +146,9 @@ impl View for WelcomePage {
                                     ),
                                 &theme.welcome.checkbox,
                                 metrics,
+                                0,
                                 cx,
-                                |checked, cx| {
+                                |_, checked, cx| {
                                     SettingsFile::update(cx, move |file| {
                                         file.telemetry.set_metrics(checked)
                                     })
@@ -157,12 +158,13 @@ impl View for WelcomePage {
                             .with_style(theme.welcome.checkbox_container),
                         )
                         .with_child(
-                            theme::ui::checkbox::<Diagnostics, Self>(
+                            theme::ui::checkbox::<Diagnostics, Self, _>(
                                 "Send crash reports",
                                 &theme.welcome.checkbox,
                                 diagnostics,
+                                0,
                                 cx,
-                                |checked, cx| {
+                                |_, checked, cx| {
                                     SettingsFile::update(cx, move |file| {
                                         file.telemetry.set_diagnostics(checked)
                                     })