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

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>
This commit is contained in:
Joseph T. Lyons
2025-12-18 16:59:21 -05:00
committed by GitHub
parent 0d74f982a5
commit 88f90c12ed
5 changed files with 93 additions and 28 deletions

View File

@@ -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()),
)
}),
));

View File

@@ -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(),

View File

@@ -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
}

View File

@@ -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(),

View File

@@ -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
})
.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);
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();
}))
})
.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()
}
}