Add an experimental, WIP diagnostics grouping panel (#14515)

Kirill Bulatov created

Provide a current, broken state as an experimental way to browse
diagnostics.
The diagnostics are grouped by lines and reduced into a block that, in
case of multiple diagnostics per line, could be toggled back and forth
to show more diagnostics on the line.
Use `grouped_diagnostics::Deploy` to show the panel.

Issues remaining:
* panic on warnings toggle due to incorrect excerpt manipulation
* badly styled blocks
* no key bindings to navigate between blocks and toggle them
* overall odd usability gains for certain groups of people

Due to all above, the thing is feature-gated and not exposed to regular
people.


Release Notes:

- N/A

Change summary

Cargo.lock                                    |    2 
crates/assistant/src/assistant_panel.rs       |   11 
crates/diagnostics/Cargo.toml                 |    2 
crates/diagnostics/src/diagnostics.rs         |   11 
crates/diagnostics/src/diagnostics_tests.rs   |    6 
crates/diagnostics/src/grouped_diagnostics.rs | 1419 +++++++++++++++++++++
crates/editor/src/display_map.rs              |    1 
crates/editor/src/display_map/block_map.rs    |   73 +
crates/editor/src/editor.rs                   |   94 
crates/editor/src/element.rs                  |  105 
crates/feature_flags/src/feature_flags.rs     |    5 
crates/multi_buffer/src/multi_buffer.rs       |   11 
crates/project/src/project.rs                 |    2 
13 files changed, 1,647 insertions(+), 95 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3402,11 +3402,13 @@ dependencies = [
  "ctor",
  "editor",
  "env_logger",
+ "feature_flags",
  "futures 0.3.28",
  "gpui",
  "language",
  "log",
  "lsp",
+ "multi_buffer",
  "pretty_assertions",
  "project",
  "rand 0.8.5",

crates/assistant/src/assistant_panel.rs 🔗

@@ -2549,10 +2549,13 @@ fn render_slash_command_output_toggle(
     fold: ToggleFold,
     _cx: &mut WindowContext,
 ) -> AnyElement {
-    Disclosure::new(("slash-command-output-fold-indicator", row.0), !is_folded)
-        .selected(is_folded)
-        .on_click(move |_e, cx| fold(!is_folded, cx))
-        .into_any_element()
+    Disclosure::new(
+        ("slash-command-output-fold-indicator", row.0 as u64),
+        !is_folded,
+    )
+    .selected(is_folded)
+    .on_click(move |_e, cx| fold(!is_folded, cx))
+    .into_any_element()
 }
 
 fn render_pending_slash_command_gutter_decoration(

crates/diagnostics/Cargo.toml 🔗

@@ -18,11 +18,13 @@ collections.workspace = true
 ctor.workspace = true
 editor.workspace = true
 env_logger.workspace = true
+feature_flags.workspace = true
 futures.workspace = true
 gpui.workspace = true
 language.workspace = true
 log.workspace = true
 lsp.workspace = true
+multi_buffer.workspace = true
 project.workspace = true
 rand.workspace = true
 schemars.workspace = true

crates/diagnostics/src/diagnostics.rs 🔗

@@ -4,6 +4,7 @@ mod toolbar_controls;
 
 #[cfg(test)]
 mod diagnostics_tests;
+mod grouped_diagnostics;
 
 use anyhow::Result;
 use collections::{BTreeSet, HashSet};
@@ -14,6 +15,7 @@ use editor::{
     scroll::Autoscroll,
     Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset,
 };
+use feature_flags::FeatureFlagAppExt;
 use futures::{
     channel::mpsc::{self, UnboundedSender},
     StreamExt as _,
@@ -52,6 +54,9 @@ pub fn init(cx: &mut AppContext) {
     ProjectDiagnosticsSettings::register(cx);
     cx.observe_new_views(ProjectDiagnosticsEditor::register)
         .detach();
+    if !cx.has_flag::<feature_flags::GroupedDiagnostics>() {
+        grouped_diagnostics::init(cx);
+    }
 }
 
 struct ProjectDiagnosticsEditor {
@@ -466,7 +471,9 @@ impl ProjectDiagnosticsEditor {
                                         position: (excerpt_id, entry.range.start),
                                         height: diagnostic.message.matches('\n').count() as u8 + 1,
                                         style: BlockStyle::Fixed,
-                                        render: diagnostic_block_renderer(diagnostic, true),
+                                        render: diagnostic_block_renderer(
+                                            diagnostic, None, true, true,
+                                        ),
                                         disposition: BlockDisposition::Below,
                                     });
                                 }
@@ -798,7 +805,7 @@ impl Item for ProjectDiagnosticsEditor {
 const DIAGNOSTIC_HEADER: &'static str = "diagnostic header";
 
 fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
-    let (message, code_ranges) = highlight_diagnostic_message(&diagnostic);
+    let (message, code_ranges) = highlight_diagnostic_message(&diagnostic, None);
     let message: SharedString = message;
     Box::new(move |cx| {
         let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into();

crates/diagnostics/src/diagnostics_tests.rs 🔗

@@ -973,8 +973,8 @@ fn editor_blocks(
             blocks.extend(
                 snapshot
                     .blocks_in_range(DisplayRow(0)..snapshot.max_point().row())
-                    .enumerate()
-                    .filter_map(|(ix, (row, block))| {
+                    .filter_map(|(row, block)| {
+                        let transform_block_id = block.id();
                         let name: SharedString = match block {
                             TransformBlock::Custom(block) => {
                                 let mut element = block.render(&mut BlockContext {
@@ -984,7 +984,7 @@ fn editor_blocks(
                                     line_height: px(0.),
                                     em_width: px(0.),
                                     max_width: px(0.),
-                                    block_id: ix,
+                                    transform_block_id,
                                     editor_style: &editor::EditorStyle::default(),
                                 });
                                 let element = element.downcast_mut::<Stateful<Div>>().unwrap();

crates/diagnostics/src/grouped_diagnostics.rs 🔗

@@ -0,0 +1,1419 @@
+use anyhow::Result;
+use collections::{BTreeMap, BTreeSet, HashMap, HashSet};
+use editor::{
+    diagnostic_block_renderer,
+    display_map::{
+        BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock,
+        TransformBlockId,
+    },
+    scroll::Autoscroll,
+    Bias, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToPoint,
+};
+use futures::{
+    channel::mpsc::{self, UnboundedSender},
+    StreamExt as _,
+};
+use gpui::{
+    actions, div, AnyElement, AnyView, AppContext, Context, EventEmitter, FocusHandle,
+    FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Render, SharedString,
+    Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
+};
+use language::{
+    Buffer, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, OffsetRangeExt, ToOffset,
+    ToPoint as _,
+};
+use lsp::LanguageServerId;
+use multi_buffer::{build_excerpt_ranges, ExpandExcerptDirection, MultiBufferRow};
+use project::{DiagnosticSummary, Project, ProjectPath};
+use settings::Settings;
+use std::{
+    any::{Any, TypeId},
+    cmp::Ordering,
+    ops::Range,
+    sync::{
+        atomic::{self, AtomicBool},
+        Arc,
+    },
+};
+use theme::ActiveTheme;
+use ui::{h_flex, prelude::*, Icon, IconName, Label};
+use util::{debug_panic, ResultExt};
+use workspace::{
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
+    ItemNavHistory, Pane, ToolbarItemLocation, Workspace,
+};
+
+use crate::project_diagnostics_settings::ProjectDiagnosticsSettings;
+actions!(grouped_diagnostics, [Deploy, ToggleWarnings]);
+
+pub fn init(cx: &mut AppContext) {
+    cx.observe_new_views(GroupedDiagnosticsEditor::register)
+        .detach();
+}
+
+struct GroupedDiagnosticsEditor {
+    project: Model<Project>,
+    workspace: WeakView<Workspace>,
+    focus_handle: FocusHandle,
+    editor: View<Editor>,
+    summary: DiagnosticSummary,
+    excerpts: Model<MultiBuffer>,
+    path_states: Vec<PathState>,
+    paths_to_update: BTreeSet<(ProjectPath, LanguageServerId)>,
+    include_warnings: bool,
+    context: u32,
+    update_paths_tx: UnboundedSender<(ProjectPath, Option<LanguageServerId>)>,
+    _update_excerpts_task: Task<Result<()>>,
+    _subscription: Subscription,
+}
+
+struct PathState {
+    path: ProjectPath,
+    first_excerpt_id: Option<ExcerptId>,
+    last_excerpt_id: Option<ExcerptId>,
+    diagnostics: Vec<(DiagnosticData, BlockId)>,
+}
+
+#[derive(Debug, Clone)]
+struct DiagnosticData {
+    language_server_id: LanguageServerId,
+    is_primary: bool,
+    entry: DiagnosticEntry<language::Anchor>,
+}
+
+impl DiagnosticData {
+    fn diagnostic_entries_equal(&self, other: &DiagnosticData) -> bool {
+        self.language_server_id == other.language_server_id
+            && self.is_primary == other.is_primary
+            && self.entry.range == other.entry.range
+            && equal_without_group_ids(&self.entry.diagnostic, &other.entry.diagnostic)
+    }
+}
+
+// `group_id` can differ between LSP server diagnostics output,
+// hence ignore it when checking diagnostics for updates.
+fn equal_without_group_ids(a: &language::Diagnostic, b: &language::Diagnostic) -> bool {
+    a.source == b.source
+        && a.code == b.code
+        && a.severity == b.severity
+        && a.message == b.message
+        && a.is_primary == b.is_primary
+        && a.is_disk_based == b.is_disk_based
+        && a.is_unnecessary == b.is_unnecessary
+}
+
+impl EventEmitter<EditorEvent> for GroupedDiagnosticsEditor {}
+
+impl Render for GroupedDiagnosticsEditor {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        let child = if self.path_states.is_empty() {
+            div()
+                .bg(cx.theme().colors().editor_background)
+                .flex()
+                .items_center()
+                .justify_center()
+                .size_full()
+                .child(Label::new("No problems in workspace"))
+        } else {
+            div().size_full().child(self.editor.clone())
+        };
+
+        div()
+            .track_focus(&self.focus_handle)
+            .when(self.path_states.is_empty(), |el| {
+                el.key_context("EmptyPane")
+            })
+            .size_full()
+            .on_action(cx.listener(Self::toggle_warnings))
+            .child(child)
+    }
+}
+
+impl GroupedDiagnosticsEditor {
+    fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+        workspace.register_action(Self::deploy);
+    }
+
+    fn new_with_context(
+        context: u32,
+        project_handle: Model<Project>,
+        workspace: WeakView<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let project_event_subscription =
+            cx.subscribe(&project_handle, |this, project, event, cx| match event {
+                project::Event::DiskBasedDiagnosticsStarted { .. } => {
+                    cx.notify();
+                }
+                project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
+                    log::debug!("disk based diagnostics finished for server {language_server_id}");
+                    this.enqueue_update_stale_excerpts(Some(*language_server_id));
+                }
+                project::Event::DiagnosticsUpdated {
+                    language_server_id,
+                    path,
+                } => {
+                    this.paths_to_update
+                        .insert((path.clone(), *language_server_id));
+                    this.summary = project.read(cx).diagnostic_summary(false, cx);
+                    cx.emit(EditorEvent::TitleChanged);
+
+                    if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) {
+                        log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change");
+                    } else {
+                        log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts");
+                        this.enqueue_update_stale_excerpts(Some(*language_server_id));
+                    }
+                }
+                _ => {}
+            });
+
+        let focus_handle = cx.focus_handle();
+        cx.on_focus_in(&focus_handle, |this, cx| this.focus_in(cx))
+            .detach();
+        cx.on_focus_out(&focus_handle, |this, _event, cx| this.focus_out(cx))
+            .detach();
+
+        let excerpts = cx.new_model(|cx| {
+            MultiBuffer::new(
+                project_handle.read(cx).replica_id(),
+                project_handle.read(cx).capability(),
+            )
+        });
+        let editor = cx.new_view(|cx| {
+            let mut editor =
+                Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), false, cx);
+            editor.set_vertical_scroll_margin(5, cx);
+            editor
+        });
+        cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| {
+            cx.emit(event.clone());
+            match event {
+                EditorEvent::Focused => {
+                    if this.path_states.is_empty() {
+                        cx.focus(&this.focus_handle);
+                    }
+                }
+                EditorEvent::Blurred => this.enqueue_update_stale_excerpts(None),
+                _ => {}
+            }
+        })
+        .detach();
+
+        let (update_excerpts_tx, mut update_excerpts_rx) = mpsc::unbounded();
+
+        let project = project_handle.read(cx);
+        let mut this = Self {
+            project: project_handle.clone(),
+            context,
+            summary: project.diagnostic_summary(false, cx),
+            workspace,
+            excerpts,
+            focus_handle,
+            editor,
+            path_states: Vec::new(),
+            paths_to_update: BTreeSet::new(),
+            include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings,
+            update_paths_tx: update_excerpts_tx,
+            _update_excerpts_task: cx.spawn(move |this, mut cx| async move {
+                while let Some((path, language_server_id)) = update_excerpts_rx.next().await {
+                    if let Some(buffer) = project_handle
+                        .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))?
+                        .await
+                        .log_err()
+                    {
+                        this.update(&mut cx, |this, cx| {
+                            this.update_excerpts(path, language_server_id, buffer, cx);
+                        })?;
+                    }
+                }
+                anyhow::Ok(())
+            }),
+            _subscription: project_event_subscription,
+        };
+        this.enqueue_update_all_excerpts(cx);
+        this
+    }
+
+    fn new(
+        project_handle: Model<Project>,
+        workspace: WeakView<Workspace>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        Self::new_with_context(
+            editor::DEFAULT_MULTIBUFFER_CONTEXT,
+            project_handle,
+            workspace,
+            cx,
+        )
+    }
+
+    fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
+        if let Some(existing) = workspace.item_of_type::<GroupedDiagnosticsEditor>(cx) {
+            workspace.activate_item(&existing, cx);
+        } else {
+            let workspace_handle = cx.view().downgrade();
+            let diagnostics = cx.new_view(|cx| {
+                GroupedDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx)
+            });
+            workspace.add_item_to_active_pane(Box::new(diagnostics), None, cx);
+        }
+    }
+
+    fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext<Self>) {
+        self.include_warnings = !self.include_warnings;
+        self.enqueue_update_all_excerpts(cx);
+        cx.notify();
+    }
+
+    fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
+        if self.focus_handle.is_focused(cx) && !self.path_states.is_empty() {
+            self.editor.focus_handle(cx).focus(cx)
+        }
+    }
+
+    fn focus_out(&mut self, cx: &mut ViewContext<Self>) {
+        if !self.focus_handle.is_focused(cx) && !self.editor.focus_handle(cx).is_focused(cx) {
+            self.enqueue_update_stale_excerpts(None);
+        }
+    }
+
+    /// Enqueue an update of all excerpts. Updates all paths that either
+    /// currently have diagnostics or are currently present in this view.
+    fn enqueue_update_all_excerpts(&mut self, cx: &mut ViewContext<Self>) {
+        self.project.update(cx, |project, cx| {
+            let mut paths = project
+                .diagnostic_summaries(false, cx)
+                .map(|(path, _, _)| path)
+                .collect::<BTreeSet<_>>();
+            paths.extend(self.path_states.iter().map(|state| state.path.clone()));
+            for path in paths {
+                self.update_paths_tx.unbounded_send((path, None)).unwrap();
+            }
+        });
+    }
+
+    /// Enqueue an update of the excerpts for any path whose diagnostics are known
+    /// to have changed. If a language server id is passed, then only the excerpts for
+    /// that language server's diagnostics will be updated. Otherwise, all stale excerpts
+    /// will be refreshed.
+    fn enqueue_update_stale_excerpts(&mut self, language_server_id: Option<LanguageServerId>) {
+        for (path, server_id) in &self.paths_to_update {
+            if language_server_id.map_or(true, |id| id == *server_id) {
+                self.update_paths_tx
+                    .unbounded_send((path.clone(), Some(*server_id)))
+                    .unwrap();
+            }
+        }
+    }
+
+    fn update_excerpts(
+        &mut self,
+        path_to_update: ProjectPath,
+        server_to_update: Option<LanguageServerId>,
+        buffer: Model<Buffer>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.paths_to_update.retain(|(path, server_id)| {
+            *path != path_to_update
+                || server_to_update.map_or(false, |to_update| *server_id != to_update)
+        });
+
+        // TODO kb change selections as in the old panel, to the next primary diagnostics
+        // TODO kb make [shift-]f8 to work, jump to the next block group
+        let _was_empty = self.path_states.is_empty();
+        let path_ix = match self.path_states.binary_search_by(|probe| {
+            project::compare_paths((&probe.path.path, true), (&path_to_update.path, true))
+        }) {
+            Ok(ix) => ix,
+            Err(ix) => {
+                self.path_states.insert(
+                    ix,
+                    PathState {
+                        path: path_to_update.clone(),
+                        diagnostics: Vec::new(),
+                        last_excerpt_id: None,
+                        first_excerpt_id: None,
+                    },
+                );
+                ix
+            }
+        };
+
+        // TODO kb when warnings are turned off, there's a lot of refresh for many paths happening, why?
+        let max_severity = if self.include_warnings {
+            DiagnosticSeverity::WARNING
+        } else {
+            DiagnosticSeverity::ERROR
+        };
+
+        let excerpt_borders = self.excerpt_borders_for_path(path_ix);
+        let path_state = &mut self.path_states[path_ix];
+        let buffer_snapshot = buffer.read(cx).snapshot();
+
+        let mut path_update = PathUpdate::new(
+            excerpt_borders,
+            &buffer_snapshot,
+            server_to_update,
+            max_severity,
+            path_state,
+        );
+        path_update.prepare_excerpt_data(
+            self.context,
+            self.excerpts.read(cx).snapshot(cx),
+            buffer.read(cx).snapshot(),
+            path_state.diagnostics.iter(),
+        );
+        self.excerpts.update(cx, |multi_buffer, cx| {
+            path_update.apply_excerpt_changes(
+                path_state,
+                self.context,
+                buffer_snapshot,
+                multi_buffer,
+                buffer,
+                cx,
+            );
+        });
+
+        let new_multi_buffer_snapshot = self.excerpts.read(cx).snapshot(cx);
+        let blocks_to_insert =
+            path_update.prepare_blocks_to_insert(self.editor.clone(), new_multi_buffer_snapshot);
+
+        let new_block_ids = self.editor.update(cx, |editor, cx| {
+            editor.remove_blocks(std::mem::take(&mut path_update.blocks_to_remove), None, cx);
+            editor.insert_blocks(blocks_to_insert, Some(Autoscroll::fit()), cx)
+        });
+        path_state.diagnostics = path_update.new_blocks(new_block_ids);
+
+        if self.path_states.is_empty() {
+            if self.editor.focus_handle(cx).is_focused(cx) {
+                cx.focus(&self.focus_handle);
+            }
+        } else if self.focus_handle.is_focused(cx) {
+            let focus_handle = self.editor.focus_handle(cx);
+            cx.focus(&focus_handle);
+        }
+
+        #[cfg(test)]
+        self.check_invariants(cx);
+
+        cx.notify();
+    }
+
+    fn excerpt_borders_for_path(&self, path_ix: usize) -> (Option<ExcerptId>, Option<ExcerptId>) {
+        let previous_path_state_ix =
+            Some(path_ix.saturating_sub(1)).filter(|&previous_path_ix| previous_path_ix != path_ix);
+        let next_path_state_ix = path_ix + 1;
+        let start = previous_path_state_ix.and_then(|i| {
+            self.path_states[..=i]
+                .iter()
+                .rev()
+                .find_map(|state| state.last_excerpt_id)
+        });
+        let end = self.path_states[next_path_state_ix..]
+            .iter()
+            .find_map(|state| state.first_excerpt_id);
+        (start, end)
+    }
+
+    #[cfg(test)]
+    fn check_invariants(&self, cx: &mut ViewContext<Self>) {
+        let mut excerpts = Vec::new();
+        for (id, buffer, _) in self.excerpts.read(cx).snapshot(cx).excerpts() {
+            if let Some(file) = buffer.file() {
+                excerpts.push((id, file.path().clone()));
+            }
+        }
+
+        let mut prev_path = None;
+        for (_, path) in &excerpts {
+            if let Some(prev_path) = prev_path {
+                if path < prev_path {
+                    panic!("excerpts are not sorted by path {:?}", excerpts);
+                }
+            }
+            prev_path = Some(path);
+        }
+    }
+}
+
+impl FocusableView for GroupedDiagnosticsEditor {
+    fn focus_handle(&self, _: &AppContext) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl Item for GroupedDiagnosticsEditor {
+    type Event = EditorEvent;
+
+    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+
+    fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
+        self.editor.update(cx, |editor, cx| editor.deactivated(cx));
+    }
+
+    fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
+        self.editor
+            .update(cx, |editor, cx| editor.navigate(data, cx))
+    }
+
+    fn tab_tooltip_text(&self, _: &AppContext) -> Option<SharedString> {
+        Some("Project Diagnostics".into())
+    }
+
+    fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement {
+        if self.summary.error_count == 0 && self.summary.warning_count == 0 {
+            Label::new("No problems")
+                .color(if params.selected {
+                    Color::Default
+                } else {
+                    Color::Muted
+                })
+                .into_any_element()
+        } else {
+            h_flex()
+                .gap_1()
+                .when(self.summary.error_count > 0, |then| {
+                    then.child(
+                        h_flex()
+                            .gap_1()
+                            .child(Icon::new(IconName::XCircle).color(Color::Error))
+                            .child(Label::new(self.summary.error_count.to_string()).color(
+                                if params.selected {
+                                    Color::Default
+                                } else {
+                                    Color::Muted
+                                },
+                            )),
+                    )
+                })
+                .when(self.summary.warning_count > 0, |then| {
+                    then.child(
+                        h_flex()
+                            .gap_1()
+                            .child(Icon::new(IconName::ExclamationTriangle).color(Color::Warning))
+                            .child(Label::new(self.summary.warning_count.to_string()).color(
+                                if params.selected {
+                                    Color::Default
+                                } else {
+                                    Color::Muted
+                                },
+                            )),
+                    )
+                })
+                .into_any_element()
+        }
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("project diagnostics")
+    }
+
+    fn for_each_project_item(
+        &self,
+        cx: &AppContext,
+        f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item),
+    ) {
+        self.editor.for_each_project_item(cx, f)
+    }
+
+    fn is_singleton(&self, _: &AppContext) -> bool {
+        false
+    }
+
+    fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
+        self.editor.update(cx, |editor, _| {
+            editor.set_nav_history(Some(nav_history));
+        });
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option<workspace::WorkspaceId>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<View<Self>>
+    where
+        Self: Sized,
+    {
+        Some(cx.new_view(|cx| {
+            GroupedDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx)
+        }))
+    }
+
+    fn is_dirty(&self, cx: &AppContext) -> bool {
+        self.excerpts.read(cx).is_dirty(cx)
+    }
+
+    fn has_conflict(&self, cx: &AppContext) -> bool {
+        self.excerpts.read(cx).has_conflict(cx)
+    }
+
+    fn can_save(&self, _: &AppContext) -> bool {
+        true
+    }
+
+    fn save(
+        &mut self,
+        format: bool,
+        project: Model<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.save(format, project, cx)
+    }
+
+    fn save_as(
+        &mut self,
+        _: Model<Project>,
+        _: ProjectPath,
+        _: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        unreachable!()
+    }
+
+    fn reload(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
+        self.editor.reload(project, cx)
+    }
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: TypeId,
+        self_handle: &'a View<Self>,
+        _: &'a AppContext,
+    ) -> Option<AnyView> {
+        if type_id == TypeId::of::<Self>() {
+            Some(self_handle.to_any())
+        } else if type_id == TypeId::of::<Editor>() {
+            Some(self.editor.to_any())
+        } else {
+            None
+        }
+    }
+
+    fn breadcrumb_location(&self) -> ToolbarItemLocation {
+        ToolbarItemLocation::PrimaryLeft
+    }
+
+    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
+        self.editor.breadcrumbs(theme, cx)
+    }
+
+    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
+        self.editor
+            .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
+    }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        Some("diagnostics")
+    }
+
+    fn deserialize(
+        project: Model<Project>,
+        workspace: WeakView<Workspace>,
+        _workspace_id: workspace::WorkspaceId,
+        _item_id: workspace::ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<Result<View<Self>>> {
+        Task::ready(Ok(cx.new_view(|cx| Self::new(project, workspace, cx))))
+    }
+}
+
+fn compare_data_locations(
+    old: &DiagnosticData,
+    new: &DiagnosticData,
+    snapshot: &BufferSnapshot,
+) -> Ordering {
+    compare_diagnostics(&old.entry, &new.entry, snapshot)
+        .then_with(|| old.language_server_id.cmp(&new.language_server_id))
+}
+
+fn compare_diagnostics(
+    old: &DiagnosticEntry<language::Anchor>,
+    new: &DiagnosticEntry<language::Anchor>,
+    snapshot: &BufferSnapshot,
+) -> Ordering {
+    compare_diagnostic_ranges(&old.range, &new.range, snapshot)
+        .then_with(|| old.diagnostic.message.cmp(&new.diagnostic.message))
+}
+
+fn compare_diagnostic_ranges(
+    old: &Range<language::Anchor>,
+    new: &Range<language::Anchor>,
+    snapshot: &BufferSnapshot,
+) -> Ordering {
+    // The diagnostics may point to a previously open Buffer for this file.
+    if !old.start.is_valid(snapshot) || !new.start.is_valid(snapshot) {
+        return Ordering::Greater;
+    }
+
+    old.start
+        .to_offset(snapshot)
+        .cmp(&new.start.to_offset(snapshot))
+        .then_with(|| {
+            old.end
+                .to_offset(snapshot)
+                .cmp(&new.end.to_offset(snapshot))
+        })
+}
+
+// TODO kb wrong? What to do here instead?
+fn compare_diagnostic_range_edges(
+    old: &Range<language::Anchor>,
+    new: &Range<language::Anchor>,
+    snapshot: &BufferSnapshot,
+) -> (Ordering, Ordering) {
+    // The diagnostics may point to a previously open Buffer for this file.
+    let start_cmp = match (old.start.is_valid(snapshot), new.start.is_valid(snapshot)) {
+        (false, false) => old.start.offset.cmp(&new.start.offset),
+        (false, true) => Ordering::Greater,
+        (true, false) => Ordering::Less,
+        (true, true) => old.start.cmp(&new.start, snapshot),
+    };
+
+    let end_cmp = old
+        .end
+        .to_offset(snapshot)
+        .cmp(&new.end.to_offset(snapshot));
+    (start_cmp, end_cmp)
+}
+
+#[derive(Debug)]
+struct PathUpdate {
+    path_excerpts_borders: (Option<ExcerptId>, Option<ExcerptId>),
+    latest_excerpt_id: ExcerptId,
+    new_diagnostics: Vec<(DiagnosticData, Option<BlockId>)>,
+    diagnostics_by_row_label: BTreeMap<MultiBufferRow, (editor::Anchor, Vec<usize>)>,
+    blocks_to_remove: HashSet<BlockId>,
+    unchanged_blocks: HashMap<usize, BlockId>,
+    excerpts_with_new_diagnostics: HashSet<ExcerptId>,
+    excerpts_to_remove: Vec<ExcerptId>,
+    excerpt_expands: HashMap<(ExpandExcerptDirection, u32), Vec<ExcerptId>>,
+    excerpts_to_add: HashMap<ExcerptId, Vec<Range<language::Anchor>>>,
+    first_excerpt_id: Option<ExcerptId>,
+    last_excerpt_id: Option<ExcerptId>,
+}
+
+impl PathUpdate {
+    fn new(
+        path_excerpts_borders: (Option<ExcerptId>, Option<ExcerptId>),
+        buffer_snapshot: &BufferSnapshot,
+        server_to_update: Option<LanguageServerId>,
+        max_severity: DiagnosticSeverity,
+        path_state: &PathState,
+    ) -> Self {
+        let mut blocks_to_remove = HashSet::default();
+        let mut removed_groups = HashSet::default();
+        let mut new_diagnostics = path_state
+            .diagnostics
+            .iter()
+            .filter(|(diagnostic_data, _)| {
+                server_to_update.map_or(true, |server_id| {
+                    diagnostic_data.language_server_id != server_id
+                })
+            })
+            .filter(|(diagnostic_data, block_id)| {
+                let diagnostic = &diagnostic_data.entry.diagnostic;
+                let retain = !diagnostic.is_primary || diagnostic.severity <= max_severity;
+                if !retain {
+                    removed_groups.insert(diagnostic.group_id);
+                    blocks_to_remove.insert(*block_id);
+                }
+                retain
+            })
+            .map(|(diagnostic, block_id)| (diagnostic.clone(), Some(*block_id)))
+            .collect::<Vec<_>>();
+        new_diagnostics.retain(|(diagnostic_data, block_id)| {
+            let retain = !removed_groups.contains(&diagnostic_data.entry.diagnostic.group_id);
+            if !retain {
+                if let Some(block_id) = block_id {
+                    blocks_to_remove.insert(*block_id);
+                }
+            }
+            retain
+        });
+        for (server_id, group) in buffer_snapshot
+            .diagnostic_groups(server_to_update)
+            .into_iter()
+            .filter(|(_, group)| {
+                group.entries[group.primary_ix].diagnostic.severity <= max_severity
+            })
+        {
+            for (diagnostic_index, diagnostic) in group.entries.iter().enumerate() {
+                let new_data = DiagnosticData {
+                    language_server_id: server_id,
+                    is_primary: diagnostic_index == group.primary_ix,
+                    entry: diagnostic.clone(),
+                };
+                let (Ok(i) | Err(i)) = new_diagnostics.binary_search_by(|probe| {
+                    compare_data_locations(&probe.0, &new_data, &buffer_snapshot)
+                });
+                new_diagnostics.insert(i, (new_data, None));
+            }
+        }
+
+        let latest_excerpt_id = path_excerpts_borders.0.unwrap_or_else(|| ExcerptId::min());
+        Self {
+            latest_excerpt_id,
+            path_excerpts_borders,
+            new_diagnostics,
+            blocks_to_remove,
+            diagnostics_by_row_label: BTreeMap::new(),
+            excerpts_to_remove: Vec::new(),
+            excerpts_with_new_diagnostics: HashSet::default(),
+            unchanged_blocks: HashMap::default(),
+            excerpts_to_add: HashMap::default(),
+            excerpt_expands: HashMap::default(),
+            first_excerpt_id: None,
+            last_excerpt_id: None,
+        }
+    }
+
+    fn prepare_excerpt_data<'a>(
+        &'a mut self,
+        context: u32,
+        multi_buffer_snapshot: MultiBufferSnapshot,
+        buffer_snapshot: BufferSnapshot,
+        current_diagnostics: impl Iterator<Item = &'a (DiagnosticData, BlockId)> + 'a,
+    ) {
+        let mut current_diagnostics = current_diagnostics.fuse().peekable();
+        let mut excerpts_to_expand =
+            HashMap::<ExcerptId, HashMap<ExpandExcerptDirection, u32>>::default();
+        let mut current_excerpts = path_state_excerpts(
+            self.path_excerpts_borders.0,
+            self.path_excerpts_borders.1,
+            &multi_buffer_snapshot,
+        )
+        .fuse()
+        .peekable();
+
+        for (diagnostic_index, (new_diagnostic, existing_block)) in
+            self.new_diagnostics.iter().enumerate()
+        {
+            if let Some(existing_block) = existing_block {
+                self.unchanged_blocks
+                    .insert(diagnostic_index, *existing_block);
+            }
+
+            loop {
+                match current_excerpts.peek() {
+                    None => {
+                        let excerpt_ranges = self
+                            .excerpts_to_add
+                            .entry(self.latest_excerpt_id)
+                            .or_default();
+                        let new_range = new_diagnostic.entry.range.clone();
+                        let (Ok(i) | Err(i)) = excerpt_ranges.binary_search_by(|probe| {
+                            compare_diagnostic_ranges(probe, &new_range, &buffer_snapshot)
+                        });
+                        excerpt_ranges.insert(i, new_range);
+                        break;
+                    }
+                    Some((current_excerpt_id, _, current_excerpt_range)) => {
+                        match compare_diagnostic_range_edges(
+                            &current_excerpt_range.context,
+                            &new_diagnostic.entry.range,
+                            &buffer_snapshot,
+                        ) {
+                            /*
+                                  new_s new_e
+                            ----[---->><<----]--
+                             cur_s         cur_e
+                            */
+                            (
+                                Ordering::Less | Ordering::Equal,
+                                Ordering::Greater | Ordering::Equal,
+                            ) => {
+                                self.excerpts_with_new_diagnostics
+                                    .insert(*current_excerpt_id);
+                                if self.first_excerpt_id.is_none() {
+                                    self.first_excerpt_id = Some(*current_excerpt_id);
+                                }
+                                self.last_excerpt_id = Some(*current_excerpt_id);
+                                break;
+                            }
+                            /*
+                                  cur_s cur_e
+                            ---->>>>>[--]<<<<<--
+                             new_s         new_e
+                            */
+                            (
+                                Ordering::Greater | Ordering::Equal,
+                                Ordering::Less | Ordering::Equal,
+                            ) => {
+                                let expand_up = current_excerpt_range
+                                    .context
+                                    .start
+                                    .to_point(&buffer_snapshot)
+                                    .row
+                                    .saturating_sub(
+                                        new_diagnostic
+                                            .entry
+                                            .range
+                                            .start
+                                            .to_point(&buffer_snapshot)
+                                            .row,
+                                    );
+                                let expand_down = new_diagnostic
+                                    .entry
+                                    .range
+                                    .end
+                                    .to_point(&buffer_snapshot)
+                                    .row
+                                    .saturating_sub(
+                                        current_excerpt_range
+                                            .context
+                                            .end
+                                            .to_point(&buffer_snapshot)
+                                            .row,
+                                    );
+                                let expand_value = excerpts_to_expand
+                                    .entry(*current_excerpt_id)
+                                    .or_default()
+                                    .entry(ExpandExcerptDirection::UpAndDown)
+                                    .or_default();
+                                *expand_value = (*expand_value).max(expand_up).max(expand_down);
+                                self.excerpts_with_new_diagnostics
+                                    .insert(*current_excerpt_id);
+                                if self.first_excerpt_id.is_none() {
+                                    self.first_excerpt_id = Some(*current_excerpt_id);
+                                }
+                                self.last_excerpt_id = Some(*current_excerpt_id);
+                                break;
+                            }
+                            /*
+                                    new_s   new_e
+                                     >       <
+                            ----[---->>>]<<<<<--
+                             cur_s    cur_e
+
+                            or
+                                      new_s new_e
+                                        >    <
+                            ----[----]-->>><<<--
+                             cur_s cur_e
+                            */
+                            (Ordering::Less, Ordering::Less) => {
+                                if current_excerpt_range
+                                    .context
+                                    .end
+                                    .cmp(&new_diagnostic.entry.range.start, &buffer_snapshot)
+                                    .is_ge()
+                                {
+                                    let expand_down = new_diagnostic
+                                        .entry
+                                        .range
+                                        .end
+                                        .to_point(&buffer_snapshot)
+                                        .row
+                                        .saturating_sub(
+                                            current_excerpt_range
+                                                .context
+                                                .end
+                                                .to_point(&buffer_snapshot)
+                                                .row,
+                                        );
+                                    let expand_value = excerpts_to_expand
+                                        .entry(*current_excerpt_id)
+                                        .or_default()
+                                        .entry(ExpandExcerptDirection::Down)
+                                        .or_default();
+                                    *expand_value = (*expand_value).max(expand_down);
+                                    self.excerpts_with_new_diagnostics
+                                        .insert(*current_excerpt_id);
+                                    if self.first_excerpt_id.is_none() {
+                                        self.first_excerpt_id = Some(*current_excerpt_id);
+                                    }
+                                    self.last_excerpt_id = Some(*current_excerpt_id);
+                                    break;
+                                } else if !self
+                                    .excerpts_with_new_diagnostics
+                                    .contains(current_excerpt_id)
+                                {
+                                    self.excerpts_to_remove.push(*current_excerpt_id);
+                                }
+                            }
+                            /*
+                                  cur_s      cur_e
+                            ---->>>>>[<<<<----]--
+                                >        <
+                               new_s    new_e
+
+                            or
+                                      cur_s cur_e
+                            ---->>><<<--[----]--
+                                >    <
+                               new_s new_e
+                            */
+                            (Ordering::Greater, Ordering::Greater) => {
+                                if current_excerpt_range
+                                    .context
+                                    .start
+                                    .cmp(&new_diagnostic.entry.range.end, &buffer_snapshot)
+                                    .is_le()
+                                {
+                                    let expand_up = current_excerpt_range
+                                        .context
+                                        .start
+                                        .to_point(&buffer_snapshot)
+                                        .row
+                                        .saturating_sub(
+                                            new_diagnostic
+                                                .entry
+                                                .range
+                                                .start
+                                                .to_point(&buffer_snapshot)
+                                                .row,
+                                        );
+                                    let expand_value = excerpts_to_expand
+                                        .entry(*current_excerpt_id)
+                                        .or_default()
+                                        .entry(ExpandExcerptDirection::Up)
+                                        .or_default();
+                                    *expand_value = (*expand_value).max(expand_up);
+                                    self.excerpts_with_new_diagnostics
+                                        .insert(*current_excerpt_id);
+                                    if self.first_excerpt_id.is_none() {
+                                        self.first_excerpt_id = Some(*current_excerpt_id);
+                                    }
+                                    self.last_excerpt_id = Some(*current_excerpt_id);
+                                    break;
+                                } else {
+                                    let excerpt_ranges = self
+                                        .excerpts_to_add
+                                        .entry(self.latest_excerpt_id)
+                                        .or_default();
+                                    let new_range = new_diagnostic.entry.range.clone();
+                                    let (Ok(i) | Err(i)) =
+                                        excerpt_ranges.binary_search_by(|probe| {
+                                            compare_diagnostic_ranges(
+                                                probe,
+                                                &new_range,
+                                                &buffer_snapshot,
+                                            )
+                                        });
+                                    excerpt_ranges.insert(i, new_range);
+                                    break;
+                                }
+                            }
+                        }
+                        if let Some((next_id, ..)) = current_excerpts.next() {
+                            self.latest_excerpt_id = next_id;
+                        }
+                    }
+                }
+            }
+
+            loop {
+                match current_diagnostics.peek() {
+                    None => break,
+                    Some((current_diagnostic, current_block)) => {
+                        match compare_data_locations(
+                            current_diagnostic,
+                            new_diagnostic,
+                            &buffer_snapshot,
+                        ) {
+                            Ordering::Less => {
+                                self.blocks_to_remove.insert(*current_block);
+                            }
+                            Ordering::Equal => {
+                                if current_diagnostic.diagnostic_entries_equal(&new_diagnostic) {
+                                    self.unchanged_blocks
+                                        .insert(diagnostic_index, *current_block);
+                                } else {
+                                    self.blocks_to_remove.insert(*current_block);
+                                }
+                                let _ = current_diagnostics.next();
+                                break;
+                            }
+                            Ordering::Greater => break,
+                        }
+                        let _ = current_diagnostics.next();
+                    }
+                }
+            }
+        }
+
+        self.excerpts_to_remove.retain(|excerpt_id| {
+            !self.excerpts_with_new_diagnostics.contains(excerpt_id)
+                && !excerpts_to_expand.contains_key(excerpt_id)
+        });
+        self.excerpts_to_remove.extend(
+            current_excerpts
+                .filter(|(excerpt_id, ..)| {
+                    !self.excerpts_with_new_diagnostics.contains(excerpt_id)
+                        && !excerpts_to_expand.contains_key(excerpt_id)
+                })
+                .map(|(excerpt_id, ..)| excerpt_id),
+        );
+        let mut excerpt_expands = HashMap::default();
+        for (excerpt_id, directions) in excerpts_to_expand {
+            let excerpt_expand = if directions.len() > 1 {
+                Some((
+                    ExpandExcerptDirection::UpAndDown,
+                    directions
+                        .values()
+                        .max()
+                        .copied()
+                        .unwrap_or_default()
+                        .max(context),
+                ))
+            } else {
+                directions
+                    .into_iter()
+                    .next()
+                    .map(|(direction, expand)| (direction, expand.max(context)))
+            };
+            if let Some(expand) = excerpt_expand {
+                excerpt_expands
+                    .entry(expand)
+                    .or_insert_with(|| Vec::new())
+                    .push(excerpt_id);
+            }
+        }
+        self.blocks_to_remove
+            .extend(current_diagnostics.map(|(_, block_id)| block_id));
+    }
+
+    fn apply_excerpt_changes(
+        &mut self,
+        path_state: &mut PathState,
+        context: u32,
+        buffer_snapshot: BufferSnapshot,
+        multi_buffer: &mut MultiBuffer,
+        buffer: Model<Buffer>,
+        cx: &mut gpui::ModelContext<MultiBuffer>,
+    ) {
+        let max_point = buffer_snapshot.max_point();
+        for (after_excerpt_id, ranges) in std::mem::take(&mut self.excerpts_to_add) {
+            let ranges = ranges
+                .into_iter()
+                .map(|range| {
+                    let mut extended_point_range = range.to_point(&buffer_snapshot);
+                    extended_point_range.start.row =
+                        extended_point_range.start.row.saturating_sub(context);
+                    extended_point_range.start.column = 0;
+                    extended_point_range.end.row =
+                        (extended_point_range.end.row + context).min(max_point.row);
+                    extended_point_range.end.column = u32::MAX;
+                    let extended_start =
+                        buffer_snapshot.clip_point(extended_point_range.start, Bias::Left);
+                    let extended_end =
+                        buffer_snapshot.clip_point(extended_point_range.end, Bias::Right);
+                    extended_start..extended_end
+                })
+                .collect::<Vec<_>>();
+            let (joined_ranges, _) = build_excerpt_ranges(&buffer_snapshot, &ranges, context);
+            let excerpts = multi_buffer.insert_excerpts_after(
+                after_excerpt_id,
+                buffer.clone(),
+                joined_ranges,
+                cx,
+            );
+            if self.first_excerpt_id.is_none() {
+                self.first_excerpt_id = excerpts.first().copied();
+            }
+            self.last_excerpt_id = excerpts.last().copied();
+        }
+        for ((direction, line_count), excerpts) in std::mem::take(&mut self.excerpt_expands) {
+            multi_buffer.expand_excerpts(excerpts, line_count, direction, cx);
+        }
+        multi_buffer.remove_excerpts(std::mem::take(&mut self.excerpts_to_remove), cx);
+        path_state.first_excerpt_id = self.first_excerpt_id;
+        path_state.last_excerpt_id = self.last_excerpt_id;
+    }
+
+    fn prepare_blocks_to_insert(
+        &mut self,
+        editor: View<Editor>,
+        multi_buffer_snapshot: MultiBufferSnapshot,
+    ) -> Vec<BlockProperties<editor::Anchor>> {
+        let mut updated_excerpts = path_state_excerpts(
+            self.path_excerpts_borders.0,
+            self.path_excerpts_borders.1,
+            &multi_buffer_snapshot,
+        )
+        .fuse()
+        .peekable();
+        let mut used_labels = BTreeMap::new();
+        self.diagnostics_by_row_label = self.new_diagnostics.iter().enumerate().fold(
+            BTreeMap::new(),
+            |mut diagnostics_by_row_label, (diagnostic_index, (diagnostic, existing_block))| {
+                let new_diagnostic = &diagnostic.entry;
+                let block_position = new_diagnostic.range.start;
+                let excerpt_id = loop {
+                    match updated_excerpts.peek() {
+                        None => break None,
+                        Some((excerpt_id, excerpt_buffer_snapshot, excerpt_range)) => {
+                            let excerpt_range = &excerpt_range.context;
+                            match block_position.cmp(&excerpt_range.start, excerpt_buffer_snapshot)
+                            {
+                                Ordering::Less => break None,
+                                Ordering::Equal | Ordering::Greater => match block_position
+                                    .cmp(&excerpt_range.end, excerpt_buffer_snapshot)
+                                {
+                                    Ordering::Equal | Ordering::Less => break Some(*excerpt_id),
+                                    Ordering::Greater => {
+                                        let _ = updated_excerpts.next();
+                                    }
+                                },
+                            }
+                        }
+                    }
+                };
+
+                let Some(position_in_multi_buffer) = excerpt_id.and_then(|excerpt_id| {
+                    multi_buffer_snapshot.anchor_in_excerpt(excerpt_id, block_position)
+                }) else {
+                    return diagnostics_by_row_label;
+                };
+
+                let multi_buffer_row = MultiBufferRow(
+                    position_in_multi_buffer
+                        .to_point(&multi_buffer_snapshot)
+                        .row,
+                );
+
+                let grouped_diagnostics = &mut diagnostics_by_row_label
+                    .entry(multi_buffer_row)
+                    .or_insert_with(|| (position_in_multi_buffer, Vec::new()))
+                    .1;
+                let new_label = used_labels
+                    .entry(multi_buffer_row)
+                    .or_insert_with(|| HashSet::default())
+                    .insert((
+                        new_diagnostic.diagnostic.source.as_deref(),
+                        new_diagnostic.diagnostic.message.as_str(),
+                    ));
+
+                if !new_label || !grouped_diagnostics.is_empty() {
+                    if let Some(existing_block) = existing_block {
+                        self.blocks_to_remove.insert(*existing_block);
+                    }
+                    if let Some(block_id) = self.unchanged_blocks.remove(&diagnostic_index) {
+                        self.blocks_to_remove.insert(block_id);
+                    }
+                }
+                if new_label {
+                    let (Ok(i) | Err(i)) = grouped_diagnostics.binary_search_by(|&probe| {
+                        let a = &self.new_diagnostics[probe].0.entry.diagnostic;
+                        let b = &self.new_diagnostics[diagnostic_index].0.entry.diagnostic;
+                        a.group_id
+                            .cmp(&b.group_id)
+                            .then_with(|| a.is_primary.cmp(&b.is_primary).reverse())
+                            .then_with(|| a.severity.cmp(&b.severity))
+                    });
+                    grouped_diagnostics.insert(i, diagnostic_index);
+                }
+
+                diagnostics_by_row_label
+            },
+        );
+
+        self.diagnostics_by_row_label
+            .values()
+            .filter_map(|(earliest_in_row_position, diagnostics_at_line)| {
+                let earliest_in_row_position = *earliest_in_row_position;
+                match diagnostics_at_line.len() {
+                    0 => None,
+                    len => {
+                        if len == 1 {
+                            let i = diagnostics_at_line.first().copied()?;
+                            if self.unchanged_blocks.contains_key(&i) {
+                                return None;
+                            }
+                        }
+                        let lines_in_first_message = diagnostic_text_lines(
+                            &self
+                                .new_diagnostics
+                                .get(diagnostics_at_line.first().copied()?)?
+                                .0
+                                .entry
+                                .diagnostic,
+                        );
+                        let folded_block_height = lines_in_first_message.clamp(1, 2);
+                        let diagnostics_to_render = Arc::new(
+                            diagnostics_at_line
+                                .iter()
+                                .filter_map(|&index| self.new_diagnostics.get(index))
+                                .map(|(diagnostic_data, _)| {
+                                    diagnostic_data.entry.diagnostic.clone()
+                                })
+                                .collect::<Vec<_>>(),
+                        );
+                        Some(BlockProperties {
+                            position: earliest_in_row_position,
+                            height: folded_block_height,
+                            style: BlockStyle::Sticky,
+                            render: render_same_line_diagnostics(
+                                Arc::new(AtomicBool::new(false)),
+                                diagnostics_to_render,
+                                editor.clone(),
+                                folded_block_height,
+                            ),
+                            disposition: BlockDisposition::Above,
+                        })
+                    }
+                }
+            })
+            .collect()
+    }
+
+    fn new_blocks(mut self, new_block_ids: Vec<BlockId>) -> Vec<(DiagnosticData, BlockId)> {
+        let mut new_block_ids = new_block_ids.into_iter().fuse();
+        for (_, (_, grouped_diagnostics)) in self.diagnostics_by_row_label {
+            let mut created_block_id = None;
+            match grouped_diagnostics.len() {
+                0 => {
+                    debug_panic!("Unexpected empty diagnostics group");
+                    continue;
+                }
+                1 => {
+                    let index = grouped_diagnostics[0];
+                    if let Some(&block_id) = self.unchanged_blocks.get(&index) {
+                        self.new_diagnostics[index].1 = Some(block_id);
+                    } else {
+                        let Some(block_id) =
+                            created_block_id.get_or_insert_with(|| new_block_ids.next())
+                        else {
+                            debug_panic!("Expected a new block for each new diagnostic");
+                            continue;
+                        };
+                        self.new_diagnostics[index].1 = Some(*block_id);
+                    }
+                }
+                _ => {
+                    let Some(block_id) =
+                        created_block_id.get_or_insert_with(|| new_block_ids.next())
+                    else {
+                        debug_panic!("Expected a new block for each new diagnostic group");
+                        continue;
+                    };
+                    for i in grouped_diagnostics {
+                        self.new_diagnostics[i].1 = Some(*block_id);
+                    }
+                }
+            }
+        }
+
+        self.new_diagnostics
+            .into_iter()
+            .filter_map(|(diagnostic, block_id)| Some((diagnostic, block_id?)))
+            .collect()
+    }
+}
+
+fn render_same_line_diagnostics(
+    expanded: Arc<AtomicBool>,
+    diagnostics: Arc<Vec<language::Diagnostic>>,
+    editor_handle: View<Editor>,
+    folded_block_height: u8,
+) -> RenderBlock {
+    Box::new(move |cx: &mut BlockContext| {
+        let block_id = match cx.transform_block_id {
+            TransformBlockId::Block(block_id) => block_id,
+            _ => {
+                debug_panic!("Expected a block id for the diagnostics block");
+                return div().into_any_element();
+            }
+        };
+        let Some(first_diagnostic) = diagnostics.first() else {
+            debug_panic!("Expected at least one diagnostic");
+            return div().into_any_element();
+        };
+        let button_expanded = expanded.clone();
+        let expanded = expanded.load(atomic::Ordering::Acquire);
+        let expand_label = if expanded { '-' } else { '+' };
+        let first_diagnostics_height = diagnostic_text_lines(first_diagnostic);
+        let extra_diagnostics = diagnostics.len() - 1;
+        let toggle_expand_label =
+            if folded_block_height == first_diagnostics_height && extra_diagnostics == 0 {
+                None
+            } else if extra_diagnostics > 0 {
+                Some(format!("{expand_label}{extra_diagnostics}"))
+            } else {
+                Some(expand_label.to_string())
+            };
+
+        let expanded_block_height = diagnostics
+            .iter()
+            .map(|diagnostic| diagnostic_text_lines(diagnostic))
+            .sum::<u8>();
+        let editor_handle = editor_handle.clone();
+        let mut parent = v_flex();
+        let mut diagnostics_iter = diagnostics.iter().fuse();
+        if let Some(first_diagnostic) = diagnostics_iter.next() {
+            let mut renderer = diagnostic_block_renderer(
+                first_diagnostic.clone(),
+                Some(folded_block_height),
+                false,
+                true,
+            );
+            parent = parent.child(
+                h_flex()
+                    .when_some(toggle_expand_label, |parent, label| {
+                        parent.child(Button::new(cx.transform_block_id, label).on_click({
+                            let diagnostics = Arc::clone(&diagnostics);
+                            move |_, cx| {
+                                let new_expanded = !expanded;
+                                button_expanded.store(new_expanded, atomic::Ordering::Release);
+                                let new_size = if new_expanded {
+                                    expanded_block_height
+                                } else {
+                                    folded_block_height
+                                };
+                                editor_handle.update(cx, |editor, cx| {
+                                    editor.replace_blocks(
+                                        HashMap::from_iter(Some((
+                                            block_id,
+                                            (
+                                                Some(new_size),
+                                                render_same_line_diagnostics(
+                                                    Arc::clone(&button_expanded),
+                                                    Arc::clone(&diagnostics),
+                                                    editor_handle.clone(),
+                                                    folded_block_height,
+                                                ),
+                                            ),
+                                        ))),
+                                        None,
+                                        cx,
+                                    )
+                                });
+                            }
+                        }))
+                    })
+                    .child(renderer(cx)),
+            );
+        }
+        if expanded {
+            for diagnostic in diagnostics_iter {
+                let mut renderer = diagnostic_block_renderer(diagnostic.clone(), None, false, true);
+                parent = parent.child(renderer(cx));
+            }
+        }
+        parent.into_any_element()
+    })
+}
+
+fn diagnostic_text_lines(diagnostic: &language::Diagnostic) -> u8 {
+    diagnostic.message.matches('\n').count() as u8 + 1
+}
+
+fn path_state_excerpts(
+    after_excerpt_id: Option<ExcerptId>,
+    before_excerpt_id: Option<ExcerptId>,
+    multi_buffer_snapshot: &editor::MultiBufferSnapshot,
+) -> impl Iterator<Item = (ExcerptId, &BufferSnapshot, ExcerptRange<language::Anchor>)> {
+    multi_buffer_snapshot
+        .excerpts()
+        .skip_while(move |&(excerpt_id, ..)| match after_excerpt_id {
+            Some(after_excerpt_id) => after_excerpt_id != excerpt_id,
+            None => false,
+        })
+        .filter(move |&(excerpt_id, ..)| after_excerpt_id != Some(excerpt_id))
+        .take_while(move |&(excerpt_id, ..)| match before_excerpt_id {
+            Some(before_excerpt_id) => before_excerpt_id != excerpt_id,
+            None => true,
+        })
+}

crates/editor/src/display_map.rs 🔗

@@ -30,6 +30,7 @@ use crate::{
 pub use block_map::{
     BlockBufferRows, BlockChunks as DisplayChunks, BlockContext, BlockDisposition, BlockId,
     BlockMap, BlockPoint, BlockProperties, BlockStyle, RenderBlock, TransformBlock,
+    TransformBlockId,
 };
 use block_map::{BlockRow, BlockSnapshot};
 use collections::{HashMap, HashSet};

crates/editor/src/display_map/block_map.rs 🔗

@@ -4,7 +4,7 @@ use super::{
 };
 use crate::{EditorStyle, GutterDimensions};
 use collections::{Bound, HashMap, HashSet};
-use gpui::{AnyElement, Pixels, WindowContext};
+use gpui::{AnyElement, EntityId, Pixels, WindowContext};
 use language::{BufferSnapshot, Chunk, Patch, Point};
 use multi_buffer::{Anchor, ExcerptId, ExcerptRange, MultiBufferRow, ToPoint as _};
 use parking_lot::Mutex;
@@ -20,6 +20,7 @@ use std::{
 };
 use sum_tree::{Bias, SumTree};
 use text::Edit;
+use ui::ElementId;
 
 const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize];
 
@@ -53,6 +54,12 @@ pub struct BlockSnapshot {
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct BlockId(usize);
 
+impl Into<ElementId> for BlockId {
+    fn into(self) -> ElementId {
+        ElementId::Integer(self.0)
+    }
+}
+
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
 pub struct BlockPoint(pub Point);
 
@@ -62,7 +69,7 @@ pub struct BlockRow(pub(super) u32);
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
 struct WrapRow(u32);
 
-pub type RenderBlock = Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>;
+pub type RenderBlock = Box<dyn Send + FnMut(&mut BlockContext) -> AnyElement>;
 
 pub struct Block {
     id: BlockId,
@@ -77,11 +84,22 @@ pub struct BlockProperties<P> {
     pub position: P,
     pub height: u8,
     pub style: BlockStyle,
-    pub render: Box<dyn Send + Fn(&mut BlockContext) -> AnyElement>,
+    pub render: RenderBlock,
     pub disposition: BlockDisposition,
 }
 
-#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
+impl<P: Debug> Debug for BlockProperties<P> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("BlockProperties")
+            .field("position", &self.position)
+            .field("height", &self.height)
+            .field("style", &self.style)
+            .field("disposition", &self.disposition)
+            .finish()
+    }
+}
+
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)]
 pub enum BlockStyle {
     Fixed,
     Flex,
@@ -95,10 +113,47 @@ pub struct BlockContext<'a, 'b> {
     pub gutter_dimensions: &'b GutterDimensions,
     pub em_width: Pixels,
     pub line_height: Pixels,
-    pub block_id: usize,
+    pub transform_block_id: TransformBlockId,
     pub editor_style: &'b EditorStyle,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum TransformBlockId {
+    Block(BlockId),
+    ExcerptHeader(ExcerptId),
+    ExcerptFooter(ExcerptId),
+}
+
+impl From<TransformBlockId> for EntityId {
+    fn from(value: TransformBlockId) -> Self {
+        match value {
+            TransformBlockId::Block(BlockId(id)) => EntityId::from(id as u64),
+            TransformBlockId::ExcerptHeader(id) => id.into(),
+            TransformBlockId::ExcerptFooter(id) => id.into(),
+        }
+    }
+}
+
+impl Into<ElementId> for TransformBlockId {
+    fn into(self) -> ElementId {
+        match self {
+            Self::Block(BlockId(id)) => ("Block", id).into(),
+            Self::ExcerptHeader(id) => ("ExcerptHeader", EntityId::from(id)).into(),
+            Self::ExcerptFooter(id) => ("ExcerptFooter", EntityId::from(id)).into(),
+        }
+    }
+}
+
+impl std::fmt::Display for TransformBlockId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Block(id) => write!(f, "Block({id:?})"),
+            Self::ExcerptHeader(id) => write!(f, "ExcerptHeader({id:?})"),
+            Self::ExcerptFooter(id) => write!(f, "ExcerptFooter({id:?})"),
+        }
+    }
+}
+
 /// Whether the block should be considered above or below the anchor line
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
 pub enum BlockDisposition {
@@ -157,6 +212,14 @@ impl BlockLike for TransformBlock {
 }
 
 impl TransformBlock {
+    pub fn id(&self) -> TransformBlockId {
+        match self {
+            TransformBlock::Custom(block) => TransformBlockId::Block(block.id),
+            TransformBlock::ExcerptHeader { id, .. } => TransformBlockId::ExcerptHeader(*id),
+            TransformBlock::ExcerptFooter { id, .. } => TransformBlockId::ExcerptFooter(*id),
+        }
+    }
+
     fn disposition(&self) -> BlockDisposition {
         match self {
             TransformBlock::Custom(block) => block.disposition,

crates/editor/src/editor.rs 🔗

@@ -68,12 +68,12 @@ use git::diff_hunk_to_display;
 use gpui::{
     div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement,
     AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem,
-    Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusOutEvent, FocusableView,
-    FontId, FontStyle, FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext,
-    ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString,
-    Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle, UnderlineStyle,
-    UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext, WeakFocusHandle,
-    WeakView, WhiteSpace, WindowContext,
+    Context, DispatchPhase, ElementId, EntityId, EventEmitter, FocusHandle, FocusOutEvent,
+    FocusableView, FontId, FontStyle, FontWeight, HighlightStyle, Hsla, InteractiveText,
+    KeyContext, ListSizingBehavior, Model, MouseButton, PaintQuad, ParentElement, Pixels, Render,
+    SharedString, Size, StrikethroughStyle, Styled, StyledText, Subscription, Task, TextStyle,
+    UnderlineStyle, UniformListScrollHandle, View, ViewContext, ViewInputHandler, VisualContext,
+    WeakFocusHandle, WeakView, WhiteSpace, WindowContext,
 };
 use highlight_matching_bracket::refresh_matching_bracket_highlights;
 use hover_popover::{hide_hover, HoverState};
@@ -9762,7 +9762,7 @@ impl Editor {
                         *block_id,
                         (
                             None,
-                            diagnostic_block_renderer(diagnostic.clone(), is_valid),
+                            diagnostic_block_renderer(diagnostic.clone(), None, true, is_valid),
                         ),
                     );
                 }
@@ -9815,7 +9815,7 @@ impl Editor {
                             style: BlockStyle::Fixed,
                             position: buffer.anchor_after(entry.range.start),
                             height: message_height,
-                            render: diagnostic_block_renderer(diagnostic, true),
+                            render: diagnostic_block_renderer(diagnostic, None, true, true),
                             disposition: BlockDisposition::Below,
                         }
                     }),
@@ -12684,11 +12684,17 @@ impl InvalidationRegion for SnippetState {
     }
 }
 
-pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> RenderBlock {
-    let (text_without_backticks, code_ranges) = highlight_diagnostic_message(&diagnostic);
+pub fn diagnostic_block_renderer(
+    diagnostic: Diagnostic,
+    max_message_rows: Option<u8>,
+    allow_closing: bool,
+    _is_valid: bool,
+) -> RenderBlock {
+    let (text_without_backticks, code_ranges) =
+        highlight_diagnostic_message(&diagnostic, max_message_rows);
 
     Box::new(move |cx: &mut BlockContext| {
-        let group_id: SharedString = cx.block_id.to_string().into();
+        let group_id: SharedString = cx.transform_block_id.to_string().into();
 
         let mut text_style = cx.text_style().clone();
         text_style.color = diagnostic_style(diagnostic.severity, cx.theme().status());
@@ -12700,23 +12706,25 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren
 
         let multi_line_diagnostic = diagnostic.message.contains('\n');
 
-        let buttons = |diagnostic: &Diagnostic, block_id: usize| {
+        let buttons = |diagnostic: &Diagnostic, block_id: TransformBlockId| {
             if multi_line_diagnostic {
                 v_flex()
             } else {
                 h_flex()
             }
-            .children(diagnostic.is_primary.then(|| {
-                IconButton::new(("close-block", block_id), IconName::XCircle)
-                    .icon_color(Color::Muted)
-                    .size(ButtonSize::Compact)
-                    .style(ButtonStyle::Transparent)
-                    .visible_on_hover(group_id.clone())
-                    .on_click(move |_click, cx| cx.dispatch_action(Box::new(Cancel)))
-                    .tooltip(|cx| Tooltip::for_action("Close Diagnostics", &Cancel, cx))
-            }))
+            .when(allow_closing, |div| {
+                div.children(diagnostic.is_primary.then(|| {
+                    IconButton::new(("close-block", EntityId::from(block_id)), IconName::XCircle)
+                        .icon_color(Color::Muted)
+                        .size(ButtonSize::Compact)
+                        .style(ButtonStyle::Transparent)
+                        .visible_on_hover(group_id.clone())
+                        .on_click(move |_click, cx| cx.dispatch_action(Box::new(Cancel)))
+                        .tooltip(|cx| Tooltip::for_action("Close Diagnostics", &Cancel, cx))
+                }))
+            })
             .child(
-                IconButton::new(("copy-block", block_id), IconName::Copy)
+                IconButton::new(("copy-block", EntityId::from(block_id)), IconName::Copy)
                     .icon_color(Color::Muted)
                     .size(ButtonSize::Compact)
                     .style(ButtonStyle::Transparent)
@@ -12729,12 +12737,12 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren
             )
         };
 
-        let icon_size = buttons(&diagnostic, cx.block_id)
+        let icon_size = buttons(&diagnostic, cx.transform_block_id)
             .into_any_element()
             .layout_as_root(AvailableSpace::min_size(), cx);
 
         h_flex()
-            .id(cx.block_id)
+            .id(cx.transform_block_id)
             .group(group_id.clone())
             .relative()
             .size_full()
@@ -12746,7 +12754,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren
                     .w(cx.anchor_x - cx.gutter_dimensions.width - icon_size.width)
                     .flex_shrink(),
             )
-            .child(buttons(&diagnostic, cx.block_id))
+            .child(buttons(&diagnostic, cx.transform_block_id))
             .child(div().flex().flex_shrink_0().child(
                 StyledText::new(text_without_backticks.clone()).with_highlights(
                     &text_style,
@@ -12765,7 +12773,10 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren
     })
 }
 
-pub fn highlight_diagnostic_message(diagnostic: &Diagnostic) -> (SharedString, Vec<Range<usize>>) {
+pub fn highlight_diagnostic_message(
+    diagnostic: &Diagnostic,
+    mut max_message_rows: Option<u8>,
+) -> (SharedString, Vec<Range<usize>>) {
     let mut text_without_backticks = String::new();
     let mut code_ranges = Vec::new();
 
@@ -12777,18 +12788,45 @@ pub fn highlight_diagnostic_message(diagnostic: &Diagnostic) -> (SharedString, V
 
     let mut prev_offset = 0;
     let mut in_code_block = false;
+    let mut newline_indices = diagnostic
+        .message
+        .match_indices('\n')
+        .map(|(ix, _)| ix)
+        .fuse()
+        .peekable();
     for (ix, _) in diagnostic
         .message
         .match_indices('`')
         .chain([(diagnostic.message.len(), "")])
     {
+        let mut trimmed_ix = ix;
+        while let Some(newline_index) = newline_indices.peek() {
+            if *newline_index < ix {
+                if let Some(rows_left) = &mut max_message_rows {
+                    if *rows_left == 0 {
+                        trimmed_ix = newline_index.saturating_sub(1);
+                        break;
+                    } else {
+                        *rows_left -= 1;
+                    }
+                }
+                let _ = newline_indices.next();
+            } else {
+                break;
+            }
+        }
         let prev_len = text_without_backticks.len();
-        text_without_backticks.push_str(&diagnostic.message[prev_offset..ix]);
-        prev_offset = ix + 1;
+        let new_text = &diagnostic.message[prev_offset..trimmed_ix];
+        text_without_backticks.push_str(new_text);
         if in_code_block {
             code_ranges.push(prev_len..text_without_backticks.len());
         }
+        prev_offset = trimmed_ix + 1;
         in_code_block = !in_code_block;
+        if trimmed_ix != ix {
+            text_without_backticks.push_str("...");
+            break;
+        }
     }
 
     (text_without_backticks.into(), code_ranges)

crates/editor/src/element.rs 🔗

@@ -1,4 +1,5 @@
 use crate::editor_settings::ScrollBeyondLastLine;
+use crate::TransformBlockId;
 use crate::{
     blame_entry_tooltip::{blame_entry_relative_timestamp, BlameEntryTooltip},
     display_map::{
@@ -31,7 +32,7 @@ use gpui::{
     anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
     transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
     ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity,
-    FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
+    EntityId, FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
     ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
     ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
     StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
@@ -1939,7 +1940,6 @@ impl EditorElement {
         line_layouts: &[LineWithInvisibles],
         cx: &mut WindowContext,
     ) -> Vec<BlockLayout> {
-        let mut block_id = 0;
         let (fixed_blocks, non_fixed_blocks) = snapshot
             .blocks_in_range(rows.clone())
             .partition::<Vec<_>, _>(|(_, block)| match block {
@@ -1950,7 +1950,7 @@ impl EditorElement {
 
         let render_block = |block: &TransformBlock,
                             available_space: Size<AvailableSpace>,
-                            block_id: usize,
+                            block_id: TransformBlockId,
                             block_row_start: DisplayRow,
                             cx: &mut WindowContext| {
             let mut element = match block {
@@ -1974,7 +1974,7 @@ impl EditorElement {
                         gutter_dimensions,
                         line_height,
                         em_width,
-                        block_id,
+                        transform_block_id: block_id,
                         max_width: text_hitbox.size.width.max(*scroll_width),
                         editor_style: &self.style,
                     })
@@ -2058,7 +2058,7 @@ impl EditorElement {
                         let header_padding = px(6.0);
 
                         v_flex()
-                            .id(("path excerpt header", block_id))
+                            .id(("path excerpt header", EntityId::from(block_id)))
                             .size_full()
                             .p(header_padding)
                             .child(
@@ -2166,7 +2166,7 @@ impl EditorElement {
                             }))
                     } else {
                         v_flex()
-                            .id(("excerpt header", block_id))
+                            .id(("excerpt header", EntityId::from(block_id)))
                             .size_full()
                             .child(
                                 div()
@@ -2314,49 +2314,54 @@ impl EditorElement {
                 }
 
                 TransformBlock::ExcerptFooter { id, .. } => {
-                    let element = v_flex().id(("excerpt footer", block_id)).size_full().child(
-                        h_flex()
-                            .justify_end()
-                            .flex_none()
-                            .w(gutter_dimensions.width
-                                - (gutter_dimensions.left_padding + gutter_dimensions.margin))
-                            .h_full()
-                            .child(
-                                ButtonLike::new("expand-icon")
-                                    .style(ButtonStyle::Transparent)
-                                    .child(
-                                        svg()
-                                            .path(IconName::ArrowDownFromLine.path())
-                                            .size(IconSize::XSmall.rems())
-                                            .text_color(cx.theme().colors().editor_line_number)
-                                            .group("")
-                                            .hover(|style| {
-                                                style.text_color(
-                                                    cx.theme().colors().editor_active_line_number,
+                    let element = v_flex()
+                        .id(("excerpt footer", EntityId::from(block_id)))
+                        .size_full()
+                        .child(
+                            h_flex()
+                                .justify_end()
+                                .flex_none()
+                                .w(gutter_dimensions.width
+                                    - (gutter_dimensions.left_padding + gutter_dimensions.margin))
+                                .h_full()
+                                .child(
+                                    ButtonLike::new("expand-icon")
+                                        .style(ButtonStyle::Transparent)
+                                        .child(
+                                            svg()
+                                                .path(IconName::ArrowDownFromLine.path())
+                                                .size(IconSize::XSmall.rems())
+                                                .text_color(cx.theme().colors().editor_line_number)
+                                                .group("")
+                                                .hover(|style| {
+                                                    style.text_color(
+                                                        cx.theme()
+                                                            .colors()
+                                                            .editor_active_line_number,
+                                                    )
+                                                }),
+                                        )
+                                        .on_click(cx.listener_for(&self.editor, {
+                                            let id = *id;
+                                            move |editor, _, cx| {
+                                                editor.expand_excerpt(
+                                                    id,
+                                                    multi_buffer::ExpandExcerptDirection::Down,
+                                                    cx,
+                                                );
+                                            }
+                                        }))
+                                        .tooltip({
+                                            move |cx| {
+                                                Tooltip::for_action(
+                                                    "Expand Excerpt",
+                                                    &ExpandExcerpts { lines: 0 },
+                                                    cx,
                                                 )
-                                            }),
-                                    )
-                                    .on_click(cx.listener_for(&self.editor, {
-                                        let id = *id;
-                                        move |editor, _, cx| {
-                                            editor.expand_excerpt(
-                                                id,
-                                                multi_buffer::ExpandExcerptDirection::Down,
-                                                cx,
-                                            );
-                                        }
-                                    }))
-                                    .tooltip({
-                                        move |cx| {
-                                            Tooltip::for_action(
-                                                "Expand Excerpt",
-                                                &ExpandExcerpts { lines: 0 },
-                                                cx,
-                                            )
-                                        }
-                                    }),
-                            ),
-                    );
+                                            }
+                                        }),
+                                ),
+                        );
                     element.into_any()
                 }
             };
@@ -2372,8 +2377,8 @@ impl EditorElement {
                 AvailableSpace::MinContent,
                 AvailableSpace::Definite(block.height() as f32 * line_height),
             );
+            let block_id = block.id();
             let (element, element_size) = render_block(block, available_space, block_id, row, cx);
-            block_id += 1;
             fixed_block_max_width = fixed_block_max_width.max(element_size.width + em_width);
             blocks.push(BlockLayout {
                 row,
@@ -2401,8 +2406,8 @@ impl EditorElement {
                 AvailableSpace::Definite(width),
                 AvailableSpace::Definite(block.height() as f32 * line_height),
             );
+            let block_id = block.id();
             let (element, _) = render_block(block, available_space, block_id, row, cx);
-            block_id += 1;
             blocks.push(BlockLayout {
                 row,
                 element,

crates/feature_flags/src/feature_flags.rs 🔗

@@ -34,6 +34,11 @@ impl FeatureFlag for TerminalInlineAssist {
     const NAME: &'static str = "terminal-inline-assist";
 }
 
+pub struct GroupedDiagnostics {}
+impl FeatureFlag for GroupedDiagnostics {
+    const NAME: &'static str = "grouped-diagnostics";
+}
+
 pub trait FeatureFlagViewExt<V: 'static> {
     fn observe_flag<T: FeatureFlag, F>(&mut self, callback: F) -> Subscription
     where

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -6,7 +6,7 @@ use clock::ReplicaId;
 use collections::{BTreeMap, Bound, HashMap, HashSet};
 use futures::{channel::mpsc, SinkExt};
 use git::diff::DiffHunk;
-use gpui::{AppContext, EventEmitter, Model, ModelContext};
+use gpui::{AppContext, EntityId, EventEmitter, Model, ModelContext};
 use itertools::Itertools;
 use language::{
     char_kind,
@@ -49,6 +49,12 @@ const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize];
 #[derive(Debug, Default, Clone, Copy, Hash, PartialEq, Eq, PartialOrd, Ord)]
 pub struct ExcerptId(usize);
 
+impl From<ExcerptId> for EntityId {
+    fn from(id: ExcerptId) -> Self {
+        EntityId::from(id.0 as u64)
+    }
+}
+
 /// One or more [`Buffers`](Buffer) being edited in a single view.
 ///
 /// See <https://zed.dev/features#multi-buffers>
@@ -302,6 +308,7 @@ struct ExcerptBytes<'a> {
     reversed: bool,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
 pub enum ExpandExcerptDirection {
     Up,
     Down,
@@ -4679,7 +4686,7 @@ impl ToPointUtf16 for PointUtf16 {
     }
 }
 
-fn build_excerpt_ranges<T>(
+pub fn build_excerpt_ranges<T>(
     buffer: &BufferSnapshot,
     ranges: &[Range<T>],
     context_line_count: u32,

crates/project/src/project.rs 🔗

@@ -11720,7 +11720,7 @@ fn sort_search_matches(search_matches: &mut Vec<SearchMatchCandidate>, cx: &AppC
     });
 }
 
-fn compare_paths(
+pub fn compare_paths(
     (path_a, a_is_file): (&Path, bool),
     (path_b, b_is_file): (&Path, bool),
 ) -> cmp::Ordering {