tab_switcher: Add support for diagnostics (#34547)

Support to show diagnostics on the tab switcher in the same way they are
displayed on the tab bar. This follows the setting
`tabs.show_diagnostics`.

This will improve user experience when disabling the tab bar and still
being able to see the diagnostics when switching tabs

Preview:

<img width="768" height="523" alt="Screenshot From 2025-07-16 11-02-42"
src="https://github.com/user-attachments/assets/308873ba-0458-485d-ae05-0de7c1cdfb28"
/>


Release Notes:

- Added diagnostics indicators to the tab switcher

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Alvaro Parker 2025-08-25 16:18:03 -04:00 committed by GitHub
parent 823a0018e5
commit 99cee8778c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 108 additions and 49 deletions

View file

@ -1569,11 +1569,21 @@ impl Buffer {
self.send_operation(op, true, cx); self.send_operation(op, true, cx);
} }
pub fn get_diagnostics(&self, server_id: LanguageServerId) -> Option<&DiagnosticSet> { pub fn buffer_diagnostics(
let Ok(idx) = self.diagnostics.binary_search_by_key(&server_id, |v| v.0) else { &self,
return None; for_server: Option<LanguageServerId>,
}; ) -> Vec<&DiagnosticEntry<Anchor>> {
Some(&self.diagnostics[idx].1) match for_server {
Some(server_id) => match self.diagnostics.binary_search_by_key(&server_id, |v| v.0) {
Ok(idx) => self.diagnostics[idx].1.iter().collect(),
Err(_) => Vec::new(),
},
None => self
.diagnostics
.iter()
.flat_map(|(_, diagnostic_set)| diagnostic_set.iter())
.collect(),
}
} }
fn request_autoindent(&mut self, cx: &mut Context<Self>) { fn request_autoindent(&mut self, cx: &mut Context<Self>) {

View file

@ -7588,19 +7588,16 @@ impl LspStore {
let snapshot = buffer_handle.read(cx).snapshot(); let snapshot = buffer_handle.read(cx).snapshot();
let buffer = buffer_handle.read(cx); let buffer = buffer_handle.read(cx);
let reused_diagnostics = buffer let reused_diagnostics = buffer
.get_diagnostics(server_id) .buffer_diagnostics(Some(server_id))
.into_iter() .iter()
.flat_map(|diag| { .filter(|v| merge(buffer, &v.diagnostic, cx))
diag.iter() .map(|v| {
.filter(|v| merge(buffer, &v.diagnostic, cx)) let start = Unclipped(v.range.start.to_point_utf16(&snapshot));
.map(|v| { let end = Unclipped(v.range.end.to_point_utf16(&snapshot));
let start = Unclipped(v.range.start.to_point_utf16(&snapshot)); DiagnosticEntry {
let end = Unclipped(v.range.end.to_point_utf16(&snapshot)); range: start..end,
DiagnosticEntry { diagnostic: v.diagnostic.clone(),
range: start..end, }
diagnostic: v.diagnostic.clone(),
}
})
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();

View file

@ -2,12 +2,14 @@
mod tab_switcher_tests; mod tab_switcher_tests;
use collections::HashMap; use collections::HashMap;
use editor::items::entry_git_aware_label_color; use editor::items::{
entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color,
};
use fuzzy::StringMatchCandidate; use fuzzy::StringMatchCandidate;
use gpui::{ use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle,
Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Render, Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Point,
Styled, Task, WeakEntity, Window, actions, rems, Render, Styled, Task, WeakEntity, Window, actions, rems,
}; };
use picker::{Picker, PickerDelegate}; use picker::{Picker, PickerDelegate};
use project::Project; use project::Project;
@ -15,11 +17,14 @@ use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
use settings::Settings; use settings::Settings;
use std::{cmp::Reverse, sync::Arc}; use std::{cmp::Reverse, sync::Arc};
use ui::{ListItem, ListItemSpacing, Tooltip, prelude::*}; use ui::{
DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip,
prelude::*,
};
use util::ResultExt; use util::ResultExt;
use workspace::{ use workspace::{
ModalView, Pane, SaveIntent, Workspace, ModalView, Pane, SaveIntent, Workspace,
item::{ItemHandle, ItemSettings, TabContentParams}, item::{ItemHandle, ItemSettings, ShowDiagnostics, TabContentParams},
pane::{Event as PaneEvent, render_item_indicator, tab_details}, pane::{Event as PaneEvent, render_item_indicator, tab_details},
}; };
@ -233,6 +238,77 @@ pub struct TabSwitcherDelegate {
restored_items: bool, restored_items: bool,
} }
impl TabMatch {
fn icon(
&self,
project: &Entity<Project>,
selected: bool,
window: &Window,
cx: &App,
) -> Option<DecoratedIcon> {
let icon = self.item.tab_icon(window, cx)?;
let item_settings = ItemSettings::get_global(cx);
let show_diagnostics = item_settings.show_diagnostics;
let git_status_color = item_settings
.git_status
.then(|| {
let path = self.item.project_path(cx)?;
let project = project.read(cx);
let entry = project.entry_for_path(&path, cx)?;
let git_status = project
.project_path_git_status(&path, cx)
.map(|status| status.summary())
.unwrap_or_default();
Some(entry_git_aware_label_color(
git_status,
entry.is_ignored,
selected,
))
})
.flatten();
let colored_icon = icon.color(git_status_color.unwrap_or_default());
let most_sever_diagostic_level = if show_diagnostics == ShowDiagnostics::Off {
None
} else {
let buffer_store = project.read(cx).buffer_store().read(cx);
let buffer = self
.item
.project_path(cx)
.and_then(|path| buffer_store.get_by_path(&path))
.map(|buffer| buffer.read(cx));
buffer.and_then(|buffer| {
buffer
.buffer_diagnostics(None)
.iter()
.map(|diagnostic_entry| diagnostic_entry.diagnostic.severity)
.min()
})
};
let decorations =
entry_diagnostic_aware_icon_decoration_and_color(most_sever_diagostic_level)
.filter(|(d, _)| {
*d != IconDecorationKind::Triangle
|| show_diagnostics != ShowDiagnostics::Errors
})
.map(|(icon, color)| {
let knockout_item_color = if selected {
cx.theme().colors().element_selected
} else {
cx.theme().colors().element_background
};
IconDecoration::new(icon, knockout_item_color, cx)
.color(color.color(cx))
.position(Point {
x: px(-2.),
y: px(-2.),
})
});
Some(DecoratedIcon::new(colored_icon, decorations))
}
}
impl TabSwitcherDelegate { impl TabSwitcherDelegate {
#[allow(clippy::complexity)] #[allow(clippy::complexity)]
fn new( fn new(
@ -574,31 +650,7 @@ impl PickerDelegate for TabSwitcherDelegate {
}; };
let label = tab_match.item.tab_content(params, window, cx); let label = tab_match.item.tab_content(params, window, cx);
let icon = tab_match.item.tab_icon(window, cx).map(|icon| { let icon = tab_match.icon(&self.project, selected, window, cx);
let git_status_color = ItemSettings::get_global(cx)
.git_status
.then(|| {
tab_match
.item
.project_path(cx)
.as_ref()
.and_then(|path| {
let project = self.project.read(cx);
let entry = project.entry_for_path(path, cx)?;
let git_status = project
.project_path_git_status(path, cx)
.map(|status| status.summary())
.unwrap_or_default();
Some((entry, git_status))
})
.map(|(entry, git_status)| {
entry_git_aware_label_color(git_status, entry.is_ignored, selected)
})
})
.flatten();
icon.color(git_status_color.unwrap_or_default())
});
let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx); let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx);
let indicator_color = if let Some(ref indicator) = indicator { let indicator_color = if let Some(ref indicator) = indicator {
@ -640,7 +692,7 @@ impl PickerDelegate for TabSwitcherDelegate {
.inset(true) .inset(true)
.toggle_state(selected) .toggle_state(selected)
.child(h_flex().w_full().child(label)) .child(h_flex().w_full().child(label))
.start_slot::<Icon>(icon) .start_slot::<DecoratedIcon>(icon)
.map(|el| { .map(|el| {
if self.selected_index == ix { if self.selected_index == ix {
el.end_slot::<AnyElement>(close_button) el.end_slot::<AnyElement>(close_button)