Add language server version in a tooltip on language server hover (#45302)

Joseph T. Lyons and factory-droid[bot] created

I wanted a way to make it easy to figure out which version of a language
server Zed is running. Now, you get a tooltip when hovering on a
language server in the Language Servers popover.

<img width="498" height="168" alt="SCR-20251218-ovln"
src="https://github.com/user-attachments/assets/1ced4214-b868-4405-8881-eb7c0b75a53e"
/>

This PR also fixes a bug. We had existing code to open a tooltip on
these language server entrees and display the language server message,
which was never fully wired up for `CustomEntry`s. Now, in this PR, we
will show show either version, message, or both, in the documentation
aside, depending on what the server has given us.

Mostly done with Droid (using GPT-5.2), with manual review and multiple
follow ups to guide it into using existing patterns in the codebase,
when it did something abnormal.

Release Notes:

- Added language server version in a tooltip on language server hover

---------

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

Change summary

crates/language_tools/src/lsp_button.rs   | 32 ++++++++++-
crates/language_tools/src/lsp_log_view.rs |  9 +++
crates/lsp/src/lsp.rs                     |  8 ++
crates/project/src/lsp_store.rs           |  4 +
crates/ui/src/components/context_menu.rs  | 68 +++++++++++++++---------
5 files changed, 93 insertions(+), 28 deletions(-)

Detailed changes

crates/language_tools/src/lsp_button.rs 🔗

@@ -127,6 +127,16 @@ impl LanguageServerState {
             return menu;
         };
 
+        let server_versions = self
+            .lsp_store
+            .update(cx, |lsp_store, _| {
+                lsp_store
+                    .language_server_statuses()
+                    .map(|(server_id, status)| (server_id, status.server_version.clone()))
+                    .collect::<HashMap<_, _>>()
+            })
+            .unwrap_or_default();
+
         let mut first_button_encountered = false;
         for item in &self.items {
             if let LspMenuItem::ToggleServersButton { restart } = item {
@@ -254,6 +264,22 @@ impl LanguageServerState {
             };
 
             let server_name = server_info.name.clone();
+            let server_version = server_versions
+                .get(&server_info.id)
+                .and_then(|version| version.clone());
+
+            let tooltip_text = match (&server_version, &message) {
+                (None, None) => None,
+                (Some(version), None) => {
+                    Some(SharedString::from(format!("Version: {}", version.as_ref())))
+                }
+                (None, Some(message)) => Some(message.clone()),
+                (Some(version), Some(message)) => Some(SharedString::from(format!(
+                    "Version: {}\n\n{}",
+                    version.as_ref(),
+                    message.as_ref()
+                ))),
+            };
             menu = menu.item(ContextMenuItem::custom_entry(
                 move |_, _| {
                     h_flex()
@@ -355,11 +381,11 @@ impl LanguageServerState {
                         }
                     }
                 },
-                message.map(|server_message| {
+                tooltip_text.map(|tooltip_text| {
                     DocumentationAside::new(
                         DocumentationSide::Right,
-                        DocumentationEdge::Bottom,
-                        Rc::new(move |_| Label::new(server_message.clone()).into_any_element()),
+                        DocumentationEdge::Top,
+                        Rc::new(move |_| Label::new(tooltip_text.clone()).into_any_element()),
                     )
                 }),
             ));

crates/language_tools/src/lsp_log_view.rs 🔗

@@ -330,6 +330,8 @@ impl LspLogView {
         let server_info = format!(
             "* Server: {NAME} (id {ID})
 
+* Version: {VERSION}
+
 * Binary: {BINARY}
 
 * Registered workspace folders:
@@ -340,6 +342,12 @@ impl LspLogView {
 * Configuration: {CONFIGURATION}",
             NAME = info.status.name,
             ID = info.id,
+            VERSION = info
+                .status
+                .server_version
+                .as_ref()
+                .map(|version| version.as_ref())
+                .unwrap_or("Unknown"),
             BINARY = info
                 .status
                 .binary
@@ -1334,6 +1342,7 @@ impl ServerInfo {
             capabilities: server.capabilities(),
             status: LanguageServerStatus {
                 name: server.name(),
+                server_version: server.version(),
                 pending_work: Default::default(),
                 has_pending_diagnostic_updates: false,
                 progress_tokens: Default::default(),

crates/lsp/src/lsp.rs 🔗

@@ -89,6 +89,7 @@ pub struct LanguageServer {
     outbound_tx: channel::Sender<String>,
     notification_tx: channel::Sender<NotificationSerializer>,
     name: LanguageServerName,
+    version: Option<SharedString>,
     process_name: Arc<str>,
     binary: LanguageServerBinary,
     capabilities: RwLock<ServerCapabilities>,
@@ -501,6 +502,7 @@ impl LanguageServer {
             response_handlers,
             io_handlers,
             name: server_name,
+            version: None,
             process_name: binary
                 .path
                 .file_name()
@@ -925,6 +927,7 @@ impl LanguageServer {
                     )
                 })?;
             if let Some(info) = response.server_info {
+                self.version = info.version.map(SharedString::from);
                 self.process_name = info.name.into();
             }
             self.capabilities = RwLock::new(response.capabilities);
@@ -1155,6 +1158,11 @@ impl LanguageServer {
         self.name.clone()
     }
 
+    /// Get the version of the running language server.
+    pub fn version(&self) -> Option<SharedString> {
+        self.version.clone()
+    }
+
     pub fn process_name(&self) -> &str {
         &self.process_name
     }

crates/project/src/lsp_store.rs 🔗

@@ -3864,6 +3864,7 @@ pub enum LspStoreEvent {
 #[derive(Clone, Debug, Serialize)]
 pub struct LanguageServerStatus {
     pub name: LanguageServerName,
+    pub server_version: Option<SharedString>,
     pub pending_work: BTreeMap<ProgressToken, LanguageServerProgress>,
     pub has_pending_diagnostic_updates: bool,
     pub progress_tokens: HashSet<ProgressToken>,
@@ -8354,6 +8355,7 @@ impl LspStore {
                     server_id,
                     LanguageServerStatus {
                         name,
+                        server_version: None,
                         pending_work: Default::default(),
                         has_pending_diagnostic_updates: false,
                         progress_tokens: Default::default(),
@@ -9389,6 +9391,7 @@ impl LspStore {
                 server_id,
                 LanguageServerStatus {
                     name: server_name.clone(),
+                    server_version: None,
                     pending_work: Default::default(),
                     has_pending_diagnostic_updates: false,
                     progress_tokens: Default::default(),
@@ -11419,6 +11422,7 @@ impl LspStore {
             server_id,
             LanguageServerStatus {
                 name: language_server.name(),
+                server_version: language_server.version(),
                 pending_work: Default::default(),
                 has_pending_diagnostic_updates: false,
                 progress_tokens: Default::default(),

crates/ui/src/components/context_menu.rs 🔗

@@ -893,39 +893,57 @@ impl ContextMenu {
                 entry_render,
                 handler,
                 selectable,
+                documentation_aside,
                 ..
             } => {
                 let handler = handler.clone();
                 let menu = cx.entity().downgrade();
                 let selectable = *selectable;
-                ListItem::new(ix)
-                    .inset(true)
-                    .toggle_state(if selectable {
-                        Some(ix) == self.selected_index
-                    } else {
-                        false
+
+                div()
+                    .id(("context-menu-child", ix))
+                    .when_some(documentation_aside.clone(), |this, documentation_aside| {
+                        this.occlude()
+                            .on_hover(cx.listener(move |menu, hovered, _, cx| {
+                            if *hovered {
+                                menu.documentation_aside = Some((ix, documentation_aside.clone()));
+                            } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix)
+                            {
+                                menu.documentation_aside = None;
+                            }
+                            cx.notify();
+                        }))
                     })
-                    .selectable(selectable)
-                    .when(selectable, |item| {
-                        item.on_click({
-                            let context = self.action_context.clone();
-                            let keep_open_on_confirm = self.keep_open_on_confirm;
-                            move |_, window, cx| {
-                                handler(context.as_ref(), window, cx);
-                                menu.update(cx, |menu, cx| {
-                                    menu.clicked = true;
-
-                                    if keep_open_on_confirm {
-                                        menu.rebuild(window, cx);
-                                    } else {
-                                        cx.emit(DismissEvent);
+                    .child(
+                        ListItem::new(ix)
+                            .inset(true)
+                            .toggle_state(if selectable {
+                                Some(ix) == self.selected_index
+                            } else {
+                                false
+                            })
+                            .selectable(selectable)
+                            .when(selectable, |item| {
+                                item.on_click({
+                                    let context = self.action_context.clone();
+                                    let keep_open_on_confirm = self.keep_open_on_confirm;
+                                    move |_, window, cx| {
+                                        handler(context.as_ref(), window, cx);
+                                        menu.update(cx, |menu, cx| {
+                                            menu.clicked = true;
+
+                                            if keep_open_on_confirm {
+                                                menu.rebuild(window, cx);
+                                            } else {
+                                                cx.emit(DismissEvent);
+                                            }
+                                        })
+                                        .ok();
                                     }
                                 })
-                                .ok();
-                            }
-                        })
-                    })
-                    .child(entry_render(window, cx))
+                            })
+                            .child(entry_render(window, cx)),
+                    )
                     .into_any_element()
             }
         }