Regroup LSP context menu items by the worktree name (#34838)

Also 

* remove the feature gate
* open buffers with an error when no logs are present
* adjust the hover text to indicate that difference

<img width="480" height="380" alt="image"
src="https://github.com/user-attachments/assets/6b2350fc-5121-4b1e-bc22-503d964531a2"
/>

Release Notes:

- N/A
This commit is contained in:
Kirill Bulatov 2025-07-21 20:48:07 +03:00 committed by GitHub
parent b6cf398eab
commit 254c7a330a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 363 additions and 247 deletions

1
Cargo.lock generated
View file

@ -9165,7 +9165,6 @@ dependencies = [
"collections",
"copilot",
"editor",
"feature_flags",
"futures 0.3.31",
"gpui",
"itertools 0.14.0",

View file

@ -231,7 +231,6 @@ impl ActivityIndicator {
status,
} => {
let create_buffer = project.update(cx, |project, cx| project.create_buffer(cx));
let project = project.clone();
let status = status.clone();
let server_name = server_name.clone();
cx.spawn_in(window, async move |workspace, cx| {
@ -247,8 +246,7 @@ impl ActivityIndicator {
workspace.update_in(cx, |workspace, window, cx| {
workspace.add_item_to_active_pane(
Box::new(cx.new(|cx| {
let mut editor =
Editor::for_buffer(buffer, Some(project.clone()), window, cx);
let mut editor = Editor::for_buffer(buffer, None, window, cx);
editor.set_read_only(true);
editor
})),

View file

@ -18,7 +18,6 @@ client.workspace = true
collections.workspace = true
copilot.workspace = true
editor.workspace = true
feature_flags.workspace = true
futures.workspace = true
gpui.workspace = true
itertools.workspace = true

View file

@ -1,13 +1,17 @@
use std::{collections::hash_map, path::PathBuf, rc::Rc, time::Duration};
use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
rc::Rc,
time::Duration,
};
use client::proto;
use collections::{HashMap, HashSet};
use collections::HashSet;
use editor::{Editor, EditorEvent};
use feature_flags::FeatureFlagAppExt as _;
use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions};
use language::{BinaryStatus, BufferId, LocalFile, ServerHealth};
use language::{BinaryStatus, BufferId, ServerHealth};
use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector};
use project::{LspStore, LspStoreEvent, project_settings::ProjectSettings};
use project::{LspStore, LspStoreEvent, Worktree, project_settings::ProjectSettings};
use settings::{Settings as _, SettingsStore};
use ui::{
Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide,
@ -36,8 +40,7 @@ pub struct LspTool {
#[derive(Debug)]
struct LanguageServerState {
items: Vec<LspItem>,
other_servers_start_index: Option<usize>,
items: Vec<LspMenuItem>,
workspace: WeakEntity<Workspace>,
lsp_store: WeakEntity<LspStore>,
active_editor: Option<ActiveEditor>,
@ -63,8 +66,13 @@ impl std::fmt::Debug for ActiveEditor {
struct LanguageServers {
health_statuses: HashMap<LanguageServerId, LanguageServerHealthStatus>,
binary_statuses: HashMap<LanguageServerName, LanguageServerBinaryStatus>,
servers_per_buffer_abs_path:
HashMap<PathBuf, HashMap<LanguageServerId, Option<LanguageServerName>>>,
servers_per_buffer_abs_path: HashMap<PathBuf, ServersForPath>,
}
#[derive(Debug, Clone)]
struct ServersForPath {
servers: HashMap<LanguageServerId, Option<LanguageServerName>>,
worktree: Option<WeakEntity<Worktree>>,
}
#[derive(Debug, Clone)]
@ -120,8 +128,8 @@ impl LanguageServerState {
};
let mut first_button_encountered = false;
for (i, item) in self.items.iter().enumerate() {
if let LspItem::ToggleServersButton { restart } = item {
for item in &self.items {
if let LspMenuItem::ToggleServersButton { restart } = item {
let label = if *restart {
"Restart All Servers"
} else {
@ -140,22 +148,19 @@ impl LanguageServerState {
};
let project = workspace.read(cx).project().clone();
let buffer_store = project.read(cx).buffer_store().clone();
let worktree_store = project.read(cx).worktree_store();
let buffers = state
.read(cx)
.language_servers
.servers_per_buffer_abs_path
.keys()
.filter_map(|abs_path| {
worktree_store.read(cx).find_worktree(abs_path, cx)
})
.filter_map(|(worktree, relative_path)| {
let entry =
worktree.read(cx).entry_for_path(&relative_path)?;
project.read(cx).path_for_entry(entry.id, cx)
})
.filter_map(|project_path| {
.iter()
.filter_map(|(abs_path, servers)| {
let worktree =
servers.worktree.as_ref()?.upgrade()?.read(cx);
let relative_path =
abs_path.strip_prefix(&worktree.abs_path()).ok()?;
let entry = worktree.entry_for_path(&relative_path)?;
let project_path =
project.read(cx).path_for_entry(entry.id, cx)?;
buffer_store.read(cx).get_by_path(&project_path)
})
.collect();
@ -165,13 +170,16 @@ impl LanguageServerState {
.iter()
// Do not try to use IDs as we have stopped all servers already, when allowing to restart them all
.flat_map(|item| match item {
LspItem::ToggleServersButton { .. } => None,
LspItem::WithHealthCheck(_, status, ..) => Some(
LanguageServerSelector::Name(status.name.clone()),
),
LspItem::WithBinaryStatus(_, server_name, ..) => Some(
LanguageServerSelector::Name(server_name.clone()),
LspMenuItem::Header { .. } => None,
LspMenuItem::ToggleServersButton { .. } => None,
LspMenuItem::WithHealthCheck { health, .. } => Some(
LanguageServerSelector::Name(health.name.clone()),
),
LspMenuItem::WithBinaryStatus {
server_name, ..
} => Some(LanguageServerSelector::Name(
server_name.clone(),
)),
})
.collect();
lsp_store.restart_language_servers_for_buffers(
@ -190,13 +198,17 @@ impl LanguageServerState {
}
menu = menu.item(button);
continue;
};
} else if let LspMenuItem::Header { header, separator } = item {
menu = menu
.when(*separator, |menu| menu.separator())
.when_some(header.as_ref(), |menu, header| menu.header(header));
continue;
}
let Some(server_info) = item.server_info() else {
continue;
};
let workspace = self.workspace.clone();
let server_selector = server_info.server_selector();
// TODO currently, Zed remote does not work well with the LSP logs
// https://github.com/zed-industries/zed/issues/28557
@ -205,6 +217,7 @@ impl LanguageServerState {
let status_color = server_info
.binary_status
.as_ref()
.and_then(|binary_status| match binary_status.status {
BinaryStatus::None => None,
BinaryStatus::CheckingForUpdate
@ -223,17 +236,20 @@ impl LanguageServerState {
})
.unwrap_or(Color::Success);
if self
.other_servers_start_index
.is_some_and(|index| index == i)
{
menu = menu.separator().header("Other Buffers");
}
if i == 0 && self.other_servers_start_index.is_some() {
menu = menu.header("Current Buffer");
}
let message = server_info
.message
.as_ref()
.or_else(|| server_info.binary_status.as_ref()?.message.as_ref())
.cloned();
let hover_label = if has_logs {
Some("View Logs")
} else if message.is_some() {
Some("View Message")
} else {
None
};
let server_name = server_info.name.clone();
menu = menu.item(ContextMenuItem::custom_entry(
move |_, _| {
h_flex()
@ -245,42 +261,99 @@ impl LanguageServerState {
h_flex()
.gap_2()
.child(Indicator::dot().color(status_color))
.child(Label::new(server_info.name.0.clone())),
)
.child(
h_flex()
.visible_on_hover("menu_item")
.child(
Label::new("View Logs")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Icon::new(IconName::ChevronRight)
.size(IconSize::Small)
.color(Color::Muted),
),
.child(Label::new(server_name.0.clone())),
)
.when_some(hover_label, |div, hover_label| {
div.child(
h_flex()
.visible_on_hover("menu_item")
.child(
Label::new(hover_label)
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
Icon::new(IconName::ChevronRight)
.size(IconSize::Small)
.color(Color::Muted),
),
)
})
.into_any_element()
},
{
let lsp_logs = lsp_logs.clone();
let message = message.clone();
let server_selector = server_selector.clone();
let server_name = server_info.name.clone();
let workspace = self.workspace.clone();
move |window, cx| {
if !has_logs {
if has_logs {
lsp_logs.update(cx, |lsp_logs, cx| {
lsp_logs.open_server_trace(
workspace.clone(),
server_selector.clone(),
window,
cx,
);
});
} else if let Some(message) = &message {
let Some(create_buffer) = workspace
.update(cx, |workspace, cx| {
workspace
.project()
.update(cx, |project, cx| project.create_buffer(cx))
})
.ok()
else {
return;
};
let window = window.window_handle();
let workspace = workspace.clone();
let message = message.clone();
let server_name = server_name.clone();
cx.spawn(async move |cx| {
let buffer = create_buffer.await?;
buffer.update(cx, |buffer, cx| {
buffer.edit(
[(
0..0,
format!("Language server {server_name}:\n\n{message}"),
)],
None,
cx,
);
buffer.set_capability(language::Capability::ReadOnly, cx);
})?;
workspace.update(cx, |workspace, cx| {
window.update(cx, |_, window, cx| {
workspace.add_item_to_active_pane(
Box::new(cx.new(|cx| {
let mut editor =
Editor::for_buffer(buffer, None, window, cx);
editor.set_read_only(true);
editor
})),
None,
true,
window,
cx,
);
})
})??;
anyhow::Ok(())
})
.detach();
} else {
cx.propagate();
return;
}
lsp_logs.update(cx, |lsp_logs, cx| {
lsp_logs.open_server_trace(
workspace.clone(),
server_selector.clone(),
window,
cx,
);
});
}
},
server_info.message.map(|server_message| {
message.map(|server_message| {
DocumentationAside::new(
DocumentationSide::Right,
Rc::new(move |_| Label::new(server_message.clone()).into_any_element()),
@ -345,81 +418,95 @@ impl LanguageServers {
#[derive(Debug)]
enum ServerData<'a> {
WithHealthCheck(
LanguageServerId,
&'a LanguageServerHealthStatus,
Option<&'a LanguageServerBinaryStatus>,
),
WithBinaryStatus(
Option<LanguageServerId>,
&'a LanguageServerName,
&'a LanguageServerBinaryStatus,
),
}
#[derive(Debug)]
enum LspItem {
WithHealthCheck(
LanguageServerId,
LanguageServerHealthStatus,
Option<LanguageServerBinaryStatus>,
),
WithBinaryStatus(
Option<LanguageServerId>,
LanguageServerName,
LanguageServerBinaryStatus,
),
ToggleServersButton {
restart: bool,
WithHealthCheck {
server_id: LanguageServerId,
health: &'a LanguageServerHealthStatus,
binary_status: Option<&'a LanguageServerBinaryStatus>,
},
WithBinaryStatus {
server_id: Option<LanguageServerId>,
server_name: &'a LanguageServerName,
binary_status: &'a LanguageServerBinaryStatus,
},
}
impl LspItem {
#[derive(Debug)]
enum LspMenuItem {
WithHealthCheck {
server_id: LanguageServerId,
health: LanguageServerHealthStatus,
binary_status: Option<LanguageServerBinaryStatus>,
},
WithBinaryStatus {
server_id: Option<LanguageServerId>,
server_name: LanguageServerName,
binary_status: LanguageServerBinaryStatus,
},
ToggleServersButton {
restart: bool,
},
Header {
header: Option<SharedString>,
separator: bool,
},
}
impl LspMenuItem {
fn server_info(&self) -> Option<ServerInfo> {
match self {
LspItem::ToggleServersButton { .. } => None,
LspItem::WithHealthCheck(
language_server_id,
language_server_health_status,
language_server_binary_status,
) => Some(ServerInfo {
name: language_server_health_status.name.clone(),
id: Some(*language_server_id),
health: language_server_health_status.health(),
binary_status: language_server_binary_status.clone(),
message: language_server_health_status.message(),
}),
LspItem::WithBinaryStatus(
Self::Header { .. } => None,
Self::ToggleServersButton { .. } => None,
Self::WithHealthCheck {
server_id,
language_server_name,
language_server_binary_status,
) => Some(ServerInfo {
name: language_server_name.clone(),
health,
binary_status,
..
} => Some(ServerInfo {
name: health.name.clone(),
id: Some(*server_id),
health: health.health(),
binary_status: binary_status.clone(),
message: health.message(),
}),
Self::WithBinaryStatus {
server_id,
server_name,
binary_status,
..
} => Some(ServerInfo {
name: server_name.clone(),
id: *server_id,
health: None,
binary_status: Some(language_server_binary_status.clone()),
message: language_server_binary_status.message.clone(),
binary_status: Some(binary_status.clone()),
message: binary_status.message.clone(),
}),
}
}
}
impl ServerData<'_> {
fn name(&self) -> &LanguageServerName {
fn into_lsp_item(self) -> LspMenuItem {
match self {
Self::WithHealthCheck(_, state, _) => &state.name,
Self::WithBinaryStatus(_, name, ..) => name,
}
}
fn into_lsp_item(self) -> LspItem {
match self {
Self::WithHealthCheck(id, name, status) => {
LspItem::WithHealthCheck(id, name.clone(), status.cloned())
}
Self::WithBinaryStatus(server_id, name, status) => {
LspItem::WithBinaryStatus(server_id, name.clone(), status.clone())
}
Self::WithHealthCheck {
server_id,
health,
binary_status,
..
} => LspMenuItem::WithHealthCheck {
server_id,
health: health.clone(),
binary_status: binary_status.cloned(),
},
Self::WithBinaryStatus {
server_id,
server_name,
binary_status,
..
} => LspMenuItem::WithBinaryStatus {
server_id,
server_name: server_name.clone(),
binary_status: binary_status.clone(),
},
}
}
}
@ -452,7 +539,6 @@ impl LspTool {
let state = cx.new(|_| LanguageServerState {
workspace: workspace.weak_handle(),
items: Vec::new(),
other_servers_start_index: None,
lsp_store: lsp_store.downgrade(),
active_editor: None,
language_servers: LanguageServers::default(),
@ -542,13 +628,28 @@ impl LspTool {
message: proto::update_language_server::Variant::RegisteredForBuffer(update),
..
} => {
self.server_state.update(cx, |state, _| {
state
self.server_state.update(cx, |state, cx| {
let Ok(worktree) = state.workspace.update(cx, |workspace, cx| {
workspace
.project()
.read(cx)
.find_worktree(Path::new(&update.buffer_abs_path), cx)
.map(|(worktree, _)| worktree.downgrade())
}) else {
return;
};
let entry = state
.language_servers
.servers_per_buffer_abs_path
.entry(PathBuf::from(&update.buffer_abs_path))
.or_default()
.insert(*language_server_id, name.clone());
.or_insert_with(|| ServersForPath {
servers: HashMap::default(),
worktree: worktree.clone(),
});
entry.servers.insert(*language_server_id, name.clone());
if worktree.is_some() {
entry.worktree = worktree;
}
});
updated = true;
}
@ -562,94 +663,95 @@ impl LspTool {
fn regenerate_items(&mut self, cx: &mut App) {
self.server_state.update(cx, |state, cx| {
let editor_buffers = state
let active_worktrees = state
.active_editor
.as_ref()
.map(|active_editor| active_editor.editor_buffers.clone())
.unwrap_or_default();
let editor_buffer_paths = editor_buffers
.iter()
.filter_map(|buffer_id| {
let buffer_path = state
.lsp_store
.update(cx, |lsp_store, cx| {
Some(
project::File::from_dyn(
lsp_store
.buffer_store()
.read(cx)
.get(*buffer_id)?
.read(cx)
.file(),
)?
.abs_path(cx),
)
.into_iter()
.flat_map(|active_editor| {
active_editor
.editor
.upgrade()
.into_iter()
.flat_map(|active_editor| {
active_editor
.read(cx)
.buffer()
.read(cx)
.all_buffers()
.into_iter()
.filter_map(|buffer| {
project::File::from_dyn(buffer.read(cx).file())
})
.map(|buffer_file| buffer_file.worktree.clone())
})
.ok()??;
Some(buffer_path)
})
.collect::<Vec<_>>();
.collect::<HashSet<_>>();
let mut servers_with_health_checks = HashSet::default();
let mut server_ids_with_health_checks = HashSet::default();
let mut buffer_servers =
Vec::with_capacity(state.language_servers.health_statuses.len());
let mut other_servers =
Vec::with_capacity(state.language_servers.health_statuses.len());
let buffer_server_ids = editor_buffer_paths
.iter()
.filter_map(|buffer_path| {
state
.language_servers
.servers_per_buffer_abs_path
.get(buffer_path)
})
.flatten()
.fold(HashMap::default(), |mut acc, (server_id, name)| {
match acc.entry(*server_id) {
hash_map::Entry::Occupied(mut o) => {
let old_name: &mut Option<&LanguageServerName> = o.get_mut();
if old_name.is_none() {
*old_name = name.as_ref();
}
}
hash_map::Entry::Vacant(v) => {
v.insert(name.as_ref());
let mut server_ids_to_worktrees =
HashMap::<LanguageServerId, Entity<Worktree>>::default();
let mut server_names_to_worktrees = HashMap::<
LanguageServerName,
HashSet<(Entity<Worktree>, LanguageServerId)>,
>::default();
for servers_for_path in state.language_servers.servers_per_buffer_abs_path.values() {
if let Some(worktree) = servers_for_path
.worktree
.as_ref()
.and_then(|worktree| worktree.upgrade())
{
for (server_id, server_name) in &servers_for_path.servers {
server_ids_to_worktrees.insert(*server_id, worktree.clone());
if let Some(server_name) = server_name {
server_names_to_worktrees
.entry(server_name.clone())
.or_default()
.insert((worktree.clone(), *server_id));
}
}
acc
}
}
let mut servers_per_worktree = BTreeMap::<SharedString, Vec<ServerData>>::new();
let mut servers_without_worktree = Vec::<ServerData>::new();
let mut servers_with_health_checks = HashSet::default();
for (server_id, health) in &state.language_servers.health_statuses {
let worktree = server_ids_to_worktrees.get(server_id).or_else(|| {
let worktrees = server_names_to_worktrees.get(&health.name)?;
worktrees
.iter()
.find(|(worktree, _)| active_worktrees.contains(worktree))
.or_else(|| worktrees.iter().next())
.map(|(worktree, _)| worktree)
});
for (server_id, server_state) in &state.language_servers.health_statuses {
let binary_status = state
.language_servers
.binary_statuses
.get(&server_state.name);
servers_with_health_checks.insert(&server_state.name);
server_ids_with_health_checks.insert(*server_id);
if buffer_server_ids.contains_key(server_id) {
buffer_servers.push(ServerData::WithHealthCheck(
*server_id,
server_state,
binary_status,
));
} else {
other_servers.push(ServerData::WithHealthCheck(
*server_id,
server_state,
binary_status,
));
servers_with_health_checks.insert(&health.name);
let worktree_name =
worktree.map(|worktree| SharedString::new(worktree.read(cx).root_name()));
let binary_status = state.language_servers.binary_statuses.get(&health.name);
let server_data = ServerData::WithHealthCheck {
server_id: *server_id,
health,
binary_status,
};
match worktree_name {
Some(worktree_name) => servers_per_worktree
.entry(worktree_name.clone())
.or_default()
.push(server_data),
None => servers_without_worktree.push(server_data),
}
}
let mut can_stop_all = !state.language_servers.health_statuses.is_empty();
let mut can_restart_all = state.language_servers.health_statuses.is_empty();
for (server_name, status) in state
for (server_name, binary_status) in state
.language_servers
.binary_statuses
.iter()
.filter(|(name, _)| !servers_with_health_checks.contains(name))
{
match status.status {
match binary_status.status {
BinaryStatus::None => {
can_restart_all = false;
can_stop_all |= true;
@ -674,52 +776,73 @@ impl LspTool {
BinaryStatus::Failed { .. } => {}
}
let matching_server_id = state
.language_servers
.servers_per_buffer_abs_path
.iter()
.filter(|(path, _)| editor_buffer_paths.contains(path))
.flat_map(|(_, server_associations)| server_associations.iter())
.find_map(|(id, name)| {
if name.as_ref() == Some(server_name) {
Some(*id)
} else {
None
match server_names_to_worktrees.get(server_name) {
Some(worktrees_for_name) => {
match worktrees_for_name
.iter()
.find(|(worktree, _)| active_worktrees.contains(worktree))
.or_else(|| worktrees_for_name.iter().next())
{
Some((worktree, server_id)) => {
let worktree_name =
SharedString::new(worktree.read(cx).root_name());
servers_per_worktree
.entry(worktree_name.clone())
.or_default()
.push(ServerData::WithBinaryStatus {
server_name,
binary_status,
server_id: Some(*server_id),
});
}
None => servers_without_worktree.push(ServerData::WithBinaryStatus {
server_name,
binary_status,
server_id: None,
}),
}
});
if let Some(server_id) = matching_server_id {
buffer_servers.push(ServerData::WithBinaryStatus(
Some(server_id),
}
None => servers_without_worktree.push(ServerData::WithBinaryStatus {
server_name,
status,
));
} else {
other_servers.push(ServerData::WithBinaryStatus(None, server_name, status));
binary_status,
server_id: None,
}),
}
}
buffer_servers.sort_by_key(|data| data.name().clone());
other_servers.sort_by_key(|data| data.name().clone());
let mut other_servers_start_index = None;
let mut new_lsp_items =
Vec::with_capacity(buffer_servers.len() + other_servers.len() + 1);
new_lsp_items.extend(buffer_servers.into_iter().map(ServerData::into_lsp_item));
if !new_lsp_items.is_empty() {
other_servers_start_index = Some(new_lsp_items.len());
Vec::with_capacity(servers_per_worktree.len() + servers_without_worktree.len() + 2);
for (worktree_name, worktree_servers) in servers_per_worktree {
if worktree_servers.is_empty() {
continue;
}
new_lsp_items.push(LspMenuItem::Header {
header: Some(worktree_name),
separator: false,
});
new_lsp_items.extend(worktree_servers.into_iter().map(ServerData::into_lsp_item));
}
if !servers_without_worktree.is_empty() {
new_lsp_items.push(LspMenuItem::Header {
header: Some(SharedString::from("Unknown worktree")),
separator: false,
});
new_lsp_items.extend(
servers_without_worktree
.into_iter()
.map(ServerData::into_lsp_item),
);
}
new_lsp_items.extend(other_servers.into_iter().map(ServerData::into_lsp_item));
if !new_lsp_items.is_empty() {
if can_stop_all {
new_lsp_items.push(LspItem::ToggleServersButton { restart: true });
new_lsp_items.push(LspItem::ToggleServersButton { restart: false });
new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true });
new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: false });
} else if can_restart_all {
new_lsp_items.push(LspItem::ToggleServersButton { restart: true });
new_lsp_items.push(LspMenuItem::ToggleServersButton { restart: true });
}
}
state.items = new_lsp_items;
state.other_servers_start_index = other_servers_start_index;
});
}
@ -841,10 +964,7 @@ impl StatusItemView for LspTool {
impl Render for LspTool {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
if !cx.is_staff()
|| self.server_state.read(cx).language_servers.is_empty()
|| self.lsp_menu.is_none()
{
if self.server_state.read(cx).language_servers.is_empty() || self.lsp_menu.is_none() {
return div();
}
@ -852,12 +972,12 @@ impl Render for LspTool {
let mut has_warnings = false;
let mut has_other_notifications = false;
let state = self.server_state.read(cx);
for server in state.language_servers.health_statuses.values() {
if let Some(binary_status) = &state.language_servers.binary_statuses.get(&server.name) {
has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. });
has_other_notifications |= binary_status.message.is_some();
}
for binary_status in state.language_servers.binary_statuses.values() {
has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. });
has_other_notifications |= binary_status.message.is_some();
}
for server in state.language_servers.health_statuses.values() {
if let Some((message, health)) = &server.health {
has_other_notifications |= message.is_some();
match health {