diagnostics: Current file diagnostics view (#34430)

Dino and Conrad Irwin created

These changes introduce a new command to the Diagnostics panel,
`diagnostics: deploy current file`, which allows the user to view the
diagnostics only for the currently opened file.

Here's a screen recording showing these changes in action 🔽 

[diagnostics: deploy current
file](https://github.com/user-attachments/assets/b0e87eea-3b3a-4888-95f8-9e21aff8ea97)

Closes #4739 

Release Notes:

- Added new `diagnostics: deploy current file` command to view
diagnostics for the currently open file

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/diagnostics/src/buffer_diagnostics.rs  | 982 +++++++++++++++++++++
crates/diagnostics/src/diagnostic_renderer.rs |  29 
crates/diagnostics/src/diagnostics.rs         |  98 +
crates/diagnostics/src/diagnostics_tests.rs   | 434 +++++++++
crates/diagnostics/src/toolbar_controls.rs    |  95 +
crates/editor/src/editor.rs                   |   2 
crates/project/src/lsp_store.rs               |  31 
crates/project/src/project.rs                 |   7 
8 files changed, 1,613 insertions(+), 65 deletions(-)

Detailed changes

crates/diagnostics/src/buffer_diagnostics.rs 🔗

@@ -0,0 +1,982 @@
+use crate::{
+    DIAGNOSTICS_UPDATE_DELAY, IncludeWarnings, ToggleWarnings, context_range_for_entry,
+    diagnostic_renderer::{DiagnosticBlock, DiagnosticRenderer},
+    toolbar_controls::DiagnosticsToolbarEditor,
+};
+use anyhow::Result;
+use collections::HashMap;
+use editor::{
+    Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
+    display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
+    multibuffer_context_lines,
+};
+use gpui::{
+    AnyElement, App, AppContext, Context, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
+    InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
+    Task, WeakEntity, Window, actions, div,
+};
+use language::{Buffer, DiagnosticEntry, Point};
+use project::{
+    DiagnosticSummary, Event, Project, ProjectItem, ProjectPath,
+    project_settings::{DiagnosticSeverity, ProjectSettings},
+};
+use settings::Settings;
+use std::{
+    any::{Any, TypeId},
+    cmp::Ordering,
+    sync::Arc,
+};
+use text::{Anchor, BufferSnapshot, OffsetRangeExt};
+use ui::{Button, ButtonStyle, Icon, IconName, Label, Tooltip, h_flex, prelude::*};
+use util::paths::PathExt;
+use workspace::{
+    ItemHandle, ItemNavHistory, ToolbarItemLocation, Workspace,
+    item::{BreadcrumbText, Item, ItemEvent, TabContentParams},
+};
+
+actions!(
+    diagnostics,
+    [
+        /// Opens the project diagnostics view for the currently focused file.
+        DeployCurrentFile,
+    ]
+);
+
+/// The `BufferDiagnosticsEditor` is meant to be used when dealing specifically
+/// with diagnostics for a single buffer, as only the excerpts of the buffer
+/// where diagnostics are available are displayed.
+pub(crate) struct BufferDiagnosticsEditor {
+    pub project: Entity<Project>,
+    focus_handle: FocusHandle,
+    editor: Entity<Editor>,
+    /// The current diagnostic entries in the `BufferDiagnosticsEditor`. Used to
+    /// allow quick comparison of updated diagnostics, to confirm if anything
+    /// has changed.
+    pub(crate) diagnostics: Vec<DiagnosticEntry<Anchor>>,
+    /// The blocks used to display the diagnostics' content in the editor, next
+    /// to the excerpts where the diagnostic originated.
+    blocks: Vec<CustomBlockId>,
+    /// Multibuffer to contain all excerpts that contain diagnostics, which are
+    /// to be rendered in the editor.
+    multibuffer: Entity<MultiBuffer>,
+    /// The buffer for which the editor is displaying diagnostics and excerpts
+    /// for.
+    buffer: Option<Entity<Buffer>>,
+    /// The path for which the editor is displaying diagnostics for.
+    project_path: ProjectPath,
+    /// Summary of the number of warnings and errors for the path. Used to
+    /// display the number of warnings and errors in the tab's content.
+    summary: DiagnosticSummary,
+    /// Whether to include warnings in the list of diagnostics shown in the
+    /// editor.
+    pub(crate) include_warnings: bool,
+    /// Keeps track of whether there's a background task already running to
+    /// update the excerpts, in order to avoid firing multiple tasks for this purpose.
+    pub(crate) update_excerpts_task: Option<Task<Result<()>>>,
+    /// The project's subscription, responsible for processing events related to
+    /// diagnostics.
+    _subscription: Subscription,
+}
+
+impl BufferDiagnosticsEditor {
+    /// Creates new instance of the `BufferDiagnosticsEditor` which can then be
+    /// displayed by adding it to a pane.
+    pub fn new(
+        project_path: ProjectPath,
+        project_handle: Entity<Project>,
+        buffer: Option<Entity<Buffer>>,
+        include_warnings: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        // Subscribe to project events related to diagnostics so the
+        // `BufferDiagnosticsEditor` can update its state accordingly.
+        let project_event_subscription = cx.subscribe_in(
+            &project_handle,
+            window,
+            |buffer_diagnostics_editor, _project, event, window, cx| match event {
+                Event::DiskBasedDiagnosticsStarted { .. } => {
+                    cx.notify();
+                }
+                Event::DiskBasedDiagnosticsFinished { .. } => {
+                    buffer_diagnostics_editor.update_all_excerpts(window, cx);
+                }
+                Event::DiagnosticsUpdated {
+                    paths,
+                    language_server_id,
+                } => {
+                    // When diagnostics have been updated, the
+                    // `BufferDiagnosticsEditor` should update its state only if
+                    // one of the paths matches its `project_path`, otherwise
+                    // the event should be ignored.
+                    if paths.contains(&buffer_diagnostics_editor.project_path) {
+                        buffer_diagnostics_editor.update_diagnostic_summary(cx);
+
+                        if buffer_diagnostics_editor.editor.focus_handle(cx).contains_focused(window, cx) || buffer_diagnostics_editor.focus_handle.contains_focused(window, cx) {
+                            log::debug!("diagnostics updated for server {language_server_id}. recording change");
+                        } else {
+                            log::debug!("diagnostics updated for server {language_server_id}. updating excerpts");
+                            buffer_diagnostics_editor.update_all_excerpts(window, cx);
+                        }
+                    }
+                }
+                _ => {}
+            },
+        );
+
+        let focus_handle = cx.focus_handle();
+
+        cx.on_focus_in(
+            &focus_handle,
+            window,
+            |buffer_diagnostics_editor, window, cx| buffer_diagnostics_editor.focus_in(window, cx),
+        )
+        .detach();
+
+        cx.on_focus_out(
+            &focus_handle,
+            window,
+            |buffer_diagnostics_editor, _event, window, cx| {
+                buffer_diagnostics_editor.focus_out(window, cx)
+            },
+        )
+        .detach();
+
+        let summary = project_handle
+            .read(cx)
+            .diagnostic_summary_for_path(&project_path, cx);
+
+        let multibuffer = cx.new(|cx| MultiBuffer::new(project_handle.read(cx).capability()));
+        let max_severity = Self::max_diagnostics_severity(include_warnings);
+        let editor = cx.new(|cx| {
+            let mut editor = Editor::for_multibuffer(
+                multibuffer.clone(),
+                Some(project_handle.clone()),
+                window,
+                cx,
+            );
+            editor.set_vertical_scroll_margin(5, cx);
+            editor.disable_inline_diagnostics();
+            editor.set_max_diagnostics_severity(max_severity, cx);
+            editor.set_all_diagnostics_active(cx);
+            editor
+        });
+
+        // Subscribe to events triggered by the editor in order to correctly
+        // update the buffer's excerpts.
+        cx.subscribe_in(
+            &editor,
+            window,
+            |buffer_diagnostics_editor, _editor, event: &EditorEvent, window, cx| {
+                cx.emit(event.clone());
+
+                match event {
+                    // If the user tries to focus on the editor but there's actually
+                    // no excerpts for the buffer, focus back on the
+                    // `BufferDiagnosticsEditor` instance.
+                    EditorEvent::Focused => {
+                        if buffer_diagnostics_editor.multibuffer.read(cx).is_empty() {
+                            window.focus(&buffer_diagnostics_editor.focus_handle);
+                        }
+                    }
+                    EditorEvent::Blurred => {
+                        buffer_diagnostics_editor.update_all_excerpts(window, cx)
+                    }
+                    _ => {}
+                }
+            },
+        )
+        .detach();
+
+        let diagnostics = vec![];
+        let update_excerpts_task = None;
+        let mut buffer_diagnostics_editor = Self {
+            project: project_handle,
+            focus_handle,
+            editor,
+            diagnostics,
+            blocks: Default::default(),
+            multibuffer,
+            buffer,
+            project_path,
+            summary,
+            include_warnings,
+            update_excerpts_task,
+            _subscription: project_event_subscription,
+        };
+
+        buffer_diagnostics_editor.update_all_diagnostics(window, cx);
+        buffer_diagnostics_editor
+    }
+
+    fn deploy(
+        workspace: &mut Workspace,
+        _: &DeployCurrentFile,
+        window: &mut Window,
+        cx: &mut Context<Workspace>,
+    ) {
+        // Determine the currently opened path by finding the active editor and
+        // finding the project path for the buffer.
+        // If there's no active editor with a project path, avoiding deploying
+        // the buffer diagnostics view.
+        if let Some(editor) = workspace.active_item_as::<Editor>(cx)
+            && let Some(project_path) = editor.project_path(cx)
+        {
+            // Check if there's already a `BufferDiagnosticsEditor` tab for this
+            // same path, and if so, focus on that one instead of creating a new
+            // one.
+            let existing_editor = workspace
+                .items_of_type::<BufferDiagnosticsEditor>(cx)
+                .find(|editor| editor.read(cx).project_path == project_path);
+
+            if let Some(editor) = existing_editor {
+                workspace.activate_item(&editor, true, true, window, cx);
+            } else {
+                let include_warnings = match cx.try_global::<IncludeWarnings>() {
+                    Some(include_warnings) => include_warnings.0,
+                    None => ProjectSettings::get_global(cx).diagnostics.include_warnings,
+                };
+
+                let item = cx.new(|cx| {
+                    Self::new(
+                        project_path,
+                        workspace.project().clone(),
+                        editor.read(cx).buffer().read(cx).as_singleton(),
+                        include_warnings,
+                        window,
+                        cx,
+                    )
+                });
+
+                workspace.add_item_to_active_pane(Box::new(item), None, true, window, cx);
+            }
+        }
+    }
+
+    pub fn register(
+        workspace: &mut Workspace,
+        _window: Option<&mut Window>,
+        _: &mut Context<Workspace>,
+    ) {
+        workspace.register_action(Self::deploy);
+    }
+
+    fn update_all_diagnostics(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.update_all_excerpts(window, cx);
+    }
+
+    fn update_diagnostic_summary(&mut self, cx: &mut Context<Self>) {
+        let project = self.project.read(cx);
+
+        self.summary = project.diagnostic_summary_for_path(&self.project_path, cx);
+    }
+
+    /// Enqueue an update to the excerpts and diagnostic blocks being shown in
+    /// the editor.
+    pub(crate) fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        // If there's already a task updating the excerpts, early return and let
+        // the other task finish.
+        if self.update_excerpts_task.is_some() {
+            return;
+        }
+
+        let buffer = self.buffer.clone();
+
+        self.update_excerpts_task = Some(cx.spawn_in(window, async move |editor, cx| {
+            cx.background_executor()
+                .timer(DIAGNOSTICS_UPDATE_DELAY)
+                .await;
+
+            if let Some(buffer) = buffer {
+                editor
+                    .update_in(cx, |editor, window, cx| {
+                        editor.update_excerpts(buffer, window, cx)
+                    })?
+                    .await?;
+            };
+
+            let _ = editor.update(cx, |editor, cx| {
+                editor.update_excerpts_task = None;
+                cx.notify();
+            });
+
+            Ok(())
+        }));
+    }
+
+    /// Updates the excerpts in the `BufferDiagnosticsEditor` for a single
+    /// buffer.
+    fn update_excerpts(
+        &mut self,
+        buffer: Entity<Buffer>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        let was_empty = self.multibuffer.read(cx).is_empty();
+        let multibuffer_context = multibuffer_context_lines(cx);
+        let buffer_snapshot = buffer.read(cx).snapshot();
+        let buffer_snapshot_max = buffer_snapshot.max_point();
+        let max_severity = Self::max_diagnostics_severity(self.include_warnings)
+            .into_lsp()
+            .unwrap_or(lsp::DiagnosticSeverity::WARNING);
+
+        cx.spawn_in(window, async move |buffer_diagnostics_editor, mut cx| {
+            // Fetch the diagnostics for the whole of the buffer
+            // (`Point::zero()..buffer_snapshot.max_point()`) so we can confirm
+            // if the diagnostics changed, if it didn't, early return as there's
+            // nothing to update.
+            let diagnostics = buffer_snapshot
+                .diagnostics_in_range::<_, Anchor>(Point::zero()..buffer_snapshot_max, false)
+                .collect::<Vec<_>>();
+
+            let unchanged =
+                buffer_diagnostics_editor.update(cx, |buffer_diagnostics_editor, _cx| {
+                    if buffer_diagnostics_editor
+                        .diagnostics_are_unchanged(&diagnostics, &buffer_snapshot)
+                    {
+                        return true;
+                    }
+
+                    buffer_diagnostics_editor.set_diagnostics(&diagnostics);
+                    return false;
+                })?;
+
+            if unchanged {
+                return Ok(());
+            }
+
+            // Mapping between the Group ID and a vector of DiagnosticEntry.
+            let mut grouped: HashMap<usize, Vec<_>> = HashMap::default();
+            for entry in diagnostics {
+                grouped
+                    .entry(entry.diagnostic.group_id)
+                    .or_default()
+                    .push(DiagnosticEntry {
+                        range: entry.range.to_point(&buffer_snapshot),
+                        diagnostic: entry.diagnostic,
+                    })
+            }
+
+            let mut blocks: Vec<DiagnosticBlock> = Vec::new();
+            for (_, group) in grouped {
+                // If the minimum severity of the group is higher than the
+                // maximum severity, or it doesn't even have severity, skip this
+                // group.
+                if group
+                    .iter()
+                    .map(|d| d.diagnostic.severity)
+                    .min()
+                    .is_none_or(|severity| severity > max_severity)
+                {
+                    continue;
+                }
+
+                let diagnostic_blocks = cx.update(|_window, cx| {
+                    DiagnosticRenderer::diagnostic_blocks_for_group(
+                        group,
+                        buffer_snapshot.remote_id(),
+                        Some(Arc::new(buffer_diagnostics_editor.clone())),
+                        cx,
+                    )
+                })?;
+
+                // For each of the diagnostic blocks to be displayed in the
+                // editor, figure out its index in the list of blocks.
+                //
+                // The following rules are used to determine the order:
+                // 1. Blocks with a lower start position should come first.
+                // 2. If two blocks have the same start position, the one with
+                // the higher end position should come first.
+                for diagnostic_block in diagnostic_blocks {
+                    let index = blocks.partition_point(|probe| {
+                        match probe
+                            .initial_range
+                            .start
+                            .cmp(&diagnostic_block.initial_range.start)
+                        {
+                            Ordering::Less => true,
+                            Ordering::Greater => false,
+                            Ordering::Equal => {
+                                probe.initial_range.end > diagnostic_block.initial_range.end
+                            }
+                        }
+                    });
+
+                    blocks.insert(index, diagnostic_block);
+                }
+            }
+
+            // Build the excerpt ranges for this specific buffer's diagnostics,
+            // so those excerpts can later be used to update the excerpts shown
+            // in the editor.
+            // This is done by iterating over the list of diagnostic blocks and
+            // determine what range does the diagnostic block span.
+            let mut excerpt_ranges: Vec<ExcerptRange<Point>> = Vec::new();
+
+            for diagnostic_block in blocks.iter() {
+                let excerpt_range = context_range_for_entry(
+                    diagnostic_block.initial_range.clone(),
+                    multibuffer_context,
+                    buffer_snapshot.clone(),
+                    &mut cx,
+                )
+                .await;
+
+                let index = excerpt_ranges
+                    .binary_search_by(|probe| {
+                        probe
+                            .context
+                            .start
+                            .cmp(&excerpt_range.start)
+                            .then(probe.context.end.cmp(&excerpt_range.end))
+                            .then(
+                                probe
+                                    .primary
+                                    .start
+                                    .cmp(&diagnostic_block.initial_range.start),
+                            )
+                            .then(probe.primary.end.cmp(&diagnostic_block.initial_range.end))
+                            .then(Ordering::Greater)
+                    })
+                    .unwrap_or_else(|index| index);
+
+                excerpt_ranges.insert(
+                    index,
+                    ExcerptRange {
+                        context: excerpt_range,
+                        primary: diagnostic_block.initial_range.clone(),
+                    },
+                )
+            }
+
+            // Finally, update the editor's content with the new excerpt ranges
+            // for this editor, as well as the diagnostic blocks.
+            buffer_diagnostics_editor.update_in(cx, |buffer_diagnostics_editor, window, cx| {
+                // Remove the list of `CustomBlockId` from the editor's display
+                // map, ensuring that if any diagnostics have been solved, the
+                // associated block stops being shown.
+                let block_ids = buffer_diagnostics_editor.blocks.clone();
+
+                buffer_diagnostics_editor.editor.update(cx, |editor, cx| {
+                    editor.display_map.update(cx, |display_map, cx| {
+                        display_map.remove_blocks(block_ids.into_iter().collect(), cx);
+                    })
+                });
+
+                let (anchor_ranges, _) =
+                    buffer_diagnostics_editor
+                        .multibuffer
+                        .update(cx, |multibuffer, cx| {
+                            multibuffer.set_excerpt_ranges_for_path(
+                                PathKey::for_buffer(&buffer, cx),
+                                buffer.clone(),
+                                &buffer_snapshot,
+                                excerpt_ranges,
+                                cx,
+                            )
+                        });
+
+                if was_empty {
+                    if let Some(anchor_range) = anchor_ranges.first() {
+                        let range_to_select = anchor_range.start..anchor_range.start;
+
+                        buffer_diagnostics_editor.editor.update(cx, |editor, cx| {
+                            editor.change_selections(Default::default(), window, cx, |selection| {
+                                selection.select_anchor_ranges([range_to_select])
+                            })
+                        });
+
+                        // If the `BufferDiagnosticsEditor` is currently
+                        // focused, move focus to its editor.
+                        if buffer_diagnostics_editor.focus_handle.is_focused(window) {
+                            buffer_diagnostics_editor
+                                .editor
+                                .read(cx)
+                                .focus_handle(cx)
+                                .focus(window);
+                        }
+                    }
+                }
+
+                // Cloning the blocks before moving ownership so these can later
+                // be used to set the block contents for testing purposes.
+                #[cfg(test)]
+                let cloned_blocks = blocks.clone();
+
+                // Build new diagnostic blocks to be added to the editor's
+                // display map for the new diagnostics. Update the `blocks`
+                // property before finishing, to ensure the blocks are removed
+                // on the next execution.
+                let editor_blocks =
+                    anchor_ranges
+                        .into_iter()
+                        .zip(blocks.into_iter())
+                        .map(|(anchor, block)| {
+                            let editor = buffer_diagnostics_editor.editor.downgrade();
+
+                            BlockProperties {
+                                placement: BlockPlacement::Near(anchor.start),
+                                height: Some(1),
+                                style: BlockStyle::Flex,
+                                render: Arc::new(move |block_context| {
+                                    block.render_block(editor.clone(), block_context)
+                                }),
+                                priority: 1,
+                            }
+                        });
+
+                let block_ids = buffer_diagnostics_editor.editor.update(cx, |editor, cx| {
+                    editor.display_map.update(cx, |display_map, cx| {
+                        display_map.insert_blocks(editor_blocks, cx)
+                    })
+                });
+
+                // In order to be able to verify which diagnostic blocks are
+                // rendered in the editor, the `set_block_content_for_tests`
+                // function must be used, so that the
+                // `editor::test::editor_content_with_blocks` function can then
+                // be called to fetch these blocks.
+                #[cfg(test)]
+                {
+                    for (block_id, block) in block_ids.iter().zip(cloned_blocks.iter()) {
+                        let markdown = block.markdown.clone();
+                        editor::test::set_block_content_for_tests(
+                            &buffer_diagnostics_editor.editor,
+                            *block_id,
+                            cx,
+                            move |cx| {
+                                markdown::MarkdownElement::rendered_text(
+                                    markdown.clone(),
+                                    cx,
+                                    editor::hover_popover::diagnostics_markdown_style,
+                                )
+                            },
+                        );
+                    }
+                }
+
+                buffer_diagnostics_editor.blocks = block_ids;
+                cx.notify()
+            })
+        })
+    }
+
+    fn set_diagnostics(&mut self, diagnostics: &Vec<DiagnosticEntry<Anchor>>) {
+        self.diagnostics = diagnostics.clone();
+    }
+
+    fn diagnostics_are_unchanged(
+        &self,
+        diagnostics: &Vec<DiagnosticEntry<Anchor>>,
+        snapshot: &BufferSnapshot,
+    ) -> bool {
+        if self.diagnostics.len() != diagnostics.len() {
+            return false;
+        }
+
+        self.diagnostics
+            .iter()
+            .zip(diagnostics.iter())
+            .all(|(existing, new)| {
+                existing.diagnostic.message == new.diagnostic.message
+                    && existing.diagnostic.severity == new.diagnostic.severity
+                    && existing.diagnostic.is_primary == new.diagnostic.is_primary
+                    && existing.range.to_offset(snapshot) == new.range.to_offset(snapshot)
+            })
+    }
+
+    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        // If the `BufferDiagnosticsEditor` is focused and the multibuffer is
+        // not empty, focus on the editor instead, which will allow the user to
+        // start interacting and editing the buffer's contents.
+        if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
+            self.editor.focus_handle(cx).focus(window)
+        }
+    }
+
+    fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window)
+        {
+            self.update_all_excerpts(window, cx);
+        }
+    }
+
+    pub fn toggle_warnings(
+        &mut self,
+        _: &ToggleWarnings,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let include_warnings = !self.include_warnings;
+        let max_severity = Self::max_diagnostics_severity(include_warnings);
+
+        self.editor.update(cx, |editor, cx| {
+            editor.set_max_diagnostics_severity(max_severity, cx);
+        });
+
+        self.include_warnings = include_warnings;
+        self.diagnostics.clear();
+        self.update_all_diagnostics(window, cx);
+    }
+
+    fn max_diagnostics_severity(include_warnings: bool) -> DiagnosticSeverity {
+        match include_warnings {
+            true => DiagnosticSeverity::Warning,
+            false => DiagnosticSeverity::Error,
+        }
+    }
+
+    #[cfg(test)]
+    pub fn editor(&self) -> &Entity<Editor> {
+        &self.editor
+    }
+
+    #[cfg(test)]
+    pub fn summary(&self) -> &DiagnosticSummary {
+        &self.summary
+    }
+}
+
+impl Focusable for BufferDiagnosticsEditor {
+    fn focus_handle(&self, _: &App) -> FocusHandle {
+        self.focus_handle.clone()
+    }
+}
+
+impl EventEmitter<EditorEvent> for BufferDiagnosticsEditor {}
+
+impl Item for BufferDiagnosticsEditor {
+    type Event = EditorEvent;
+
+    fn act_as_type<'a>(
+        &'a self,
+        type_id: std::any::TypeId,
+        self_handle: &'a Entity<Self>,
+        _: &'a App,
+    ) -> Option<gpui::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 added_to_workspace(
+        &mut self,
+        workspace: &mut Workspace,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            editor.added_to_workspace(workspace, window, cx)
+        });
+    }
+
+    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+        ToolbarItemLocation::PrimaryLeft
+    }
+
+    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+        self.editor.breadcrumbs(theme, cx)
+    }
+
+    fn can_save(&self, _cx: &App) -> bool {
+        true
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: Option<workspace::WorkspaceId>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<Entity<Self>>
+    where
+        Self: Sized,
+    {
+        Some(cx.new(|cx| {
+            BufferDiagnosticsEditor::new(
+                self.project_path.clone(),
+                self.project.clone(),
+                self.buffer.clone(),
+                self.include_warnings,
+                window,
+                cx,
+            )
+        }))
+    }
+
+    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.editor
+            .update(cx, |editor, cx| editor.deactivated(window, cx));
+    }
+
+    fn for_each_project_item(&self, cx: &App, f: &mut dyn FnMut(EntityId, &dyn ProjectItem)) {
+        self.editor.for_each_project_item(cx, f);
+    }
+
+    fn has_conflict(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).has_conflict(cx)
+    }
+
+    fn has_deleted_file(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).has_deleted_file(cx)
+    }
+
+    fn is_dirty(&self, cx: &App) -> bool {
+        self.multibuffer.read(cx).is_dirty(cx)
+    }
+
+    fn is_singleton(&self, _cx: &App) -> bool {
+        false
+    }
+
+    fn navigate(
+        &mut self,
+        data: Box<dyn Any>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> bool {
+        self.editor
+            .update(cx, |editor, cx| editor.navigate(data, window, cx))
+    }
+
+    fn reload(
+        &mut self,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.reload(project, window, cx)
+    }
+
+    fn save(
+        &mut self,
+        options: workspace::item::SaveOptions,
+        project: Entity<Project>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.save(options, project, window, cx)
+    }
+
+    fn save_as(
+        &mut self,
+        _project: Entity<Project>,
+        _path: ProjectPath,
+        _window: &mut Window,
+        _cx: &mut Context<Self>,
+    ) -> Task<Result<()>> {
+        unreachable!()
+    }
+
+    fn set_nav_history(
+        &mut self,
+        nav_history: ItemNavHistory,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, _| {
+            editor.set_nav_history(Some(nav_history));
+        })
+    }
+
+    // Builds the content to be displayed in the tab.
+    fn tab_content(&self, params: TabContentParams, _window: &Window, _cx: &App) -> AnyElement {
+        let error_count = self.summary.error_count;
+        let warning_count = self.summary.warning_count;
+        let label = Label::new(
+            self.project_path
+                .path
+                .file_name()
+                .map(|f| f.to_sanitized_string())
+                .unwrap_or_else(|| self.project_path.path.to_sanitized_string()),
+        );
+
+        h_flex()
+            .gap_1()
+            .child(label)
+            .when(error_count == 0 && warning_count == 0, |parent| {
+                parent.child(
+                    h_flex()
+                        .gap_1()
+                        .child(Icon::new(IconName::Check).color(Color::Success)),
+                )
+            })
+            .when(error_count > 0, |parent| {
+                parent.child(
+                    h_flex()
+                        .gap_1()
+                        .child(Icon::new(IconName::XCircle).color(Color::Error))
+                        .child(Label::new(error_count.to_string()).color(params.text_color())),
+                )
+            })
+            .when(warning_count > 0, |parent| {
+                parent.child(
+                    h_flex()
+                        .gap_1()
+                        .child(Icon::new(IconName::Warning).color(Color::Warning))
+                        .child(Label::new(warning_count.to_string()).color(params.text_color())),
+                )
+            })
+            .into_any_element()
+    }
+
+    fn tab_content_text(&self, _detail: usize, _app: &App) -> SharedString {
+        "Buffer Diagnostics".into()
+    }
+
+    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
+        Some(
+            format!(
+                "Buffer Diagnostics - {}",
+                self.project_path.path.to_sanitized_string()
+            )
+            .into(),
+        )
+    }
+
+    fn telemetry_event_text(&self) -> Option<&'static str> {
+        Some("Buffer Diagnostics Opened")
+    }
+
+    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
+        Editor::to_item_events(event, f)
+    }
+}
+
+impl Render for BufferDiagnosticsEditor {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let filename = self.project_path.path.to_sanitized_string();
+        let error_count = self.summary.error_count;
+        let warning_count = match self.include_warnings {
+            true => self.summary.warning_count,
+            false => 0,
+        };
+
+        let child = if error_count + warning_count == 0 {
+            let label = match warning_count {
+                0 => "No problems in",
+                _ => "No errors in",
+            };
+
+            v_flex()
+                .key_context("EmptyPane")
+                .size_full()
+                .gap_1()
+                .justify_center()
+                .items_center()
+                .text_center()
+                .bg(cx.theme().colors().editor_background)
+                .child(
+                    div()
+                        .h_flex()
+                        .child(Label::new(label).color(Color::Muted))
+                        .child(
+                            Button::new("open-file", filename)
+                                .style(ButtonStyle::Transparent)
+                                .tooltip(Tooltip::text("Open File"))
+                                .on_click(cx.listener(|buffer_diagnostics, _, window, cx| {
+                                    if let Some(workspace) = window.root::<Workspace>().flatten() {
+                                        workspace.update(cx, |workspace, cx| {
+                                            workspace
+                                                .open_path(
+                                                    buffer_diagnostics.project_path.clone(),
+                                                    None,
+                                                    true,
+                                                    window,
+                                                    cx,
+                                                )
+                                                .detach_and_log_err(cx);
+                                        })
+                                    }
+                                })),
+                        ),
+                )
+                .when(self.summary.warning_count > 0, |div| {
+                    let label = match self.summary.warning_count {
+                        1 => "Show 1 warning".into(),
+                        warning_count => format!("Show {} warnings", warning_count),
+                    };
+
+                    div.child(
+                        Button::new("diagnostics-show-warning-label", label).on_click(cx.listener(
+                            |buffer_diagnostics_editor, _, window, cx| {
+                                buffer_diagnostics_editor.toggle_warnings(
+                                    &Default::default(),
+                                    window,
+                                    cx,
+                                );
+                                cx.notify();
+                            },
+                        )),
+                    )
+                })
+        } else {
+            div().size_full().child(self.editor.clone())
+        };
+
+        div()
+            .key_context("Diagnostics")
+            .track_focus(&self.focus_handle(cx))
+            .size_full()
+            .child(child)
+    }
+}
+
+impl DiagnosticsToolbarEditor for WeakEntity<BufferDiagnosticsEditor> {
+    fn include_warnings(&self, cx: &App) -> bool {
+        self.read_with(cx, |buffer_diagnostics_editor, _cx| {
+            buffer_diagnostics_editor.include_warnings
+        })
+        .unwrap_or(false)
+    }
+
+    fn has_stale_excerpts(&self, _cx: &App) -> bool {
+        false
+    }
+
+    fn is_updating(&self, cx: &App) -> bool {
+        self.read_with(cx, |buffer_diagnostics_editor, cx| {
+            buffer_diagnostics_editor.update_excerpts_task.is_some()
+                || buffer_diagnostics_editor
+                    .project
+                    .read(cx)
+                    .language_servers_running_disk_based_diagnostics(cx)
+                    .next()
+                    .is_some()
+        })
+        .unwrap_or(false)
+    }
+
+    fn stop_updating(&self, cx: &mut App) {
+        let _ = self.update(cx, |buffer_diagnostics_editor, cx| {
+            buffer_diagnostics_editor.update_excerpts_task = None;
+            cx.notify();
+        });
+    }
+
+    fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App) {
+        let _ = self.update(cx, |buffer_diagnostics_editor, cx| {
+            buffer_diagnostics_editor.update_all_excerpts(window, cx);
+        });
+    }
+
+    fn toggle_warnings(&self, window: &mut Window, cx: &mut App) {
+        let _ = self.update(cx, |buffer_diagnostics_editor, cx| {
+            buffer_diagnostics_editor.toggle_warnings(&Default::default(), window, cx);
+        });
+    }
+
+    fn get_diagnostics_for_buffer(
+        &self,
+        _buffer_id: text::BufferId,
+        cx: &App,
+    ) -> Vec<language::DiagnosticEntry<text::Anchor>> {
+        self.read_with(cx, |buffer_diagnostics_editor, _cx| {
+            buffer_diagnostics_editor.diagnostics.clone()
+        })
+        .unwrap_or_default()
+    }
+}

crates/diagnostics/src/diagnostic_renderer.rs 🔗

@@ -18,7 +18,7 @@ use ui::{
 };
 use util::maybe;
 
-use crate::ProjectDiagnosticsEditor;
+use crate::toolbar_controls::DiagnosticsToolbarEditor;
 
 pub struct DiagnosticRenderer;
 
@@ -26,7 +26,7 @@ impl DiagnosticRenderer {
     pub fn diagnostic_blocks_for_group(
         diagnostic_group: Vec<DiagnosticEntry<Point>>,
         buffer_id: BufferId,
-        diagnostics_editor: Option<WeakEntity<ProjectDiagnosticsEditor>>,
+        diagnostics_editor: Option<Arc<dyn DiagnosticsToolbarEditor>>,
         cx: &mut App,
     ) -> Vec<DiagnosticBlock> {
         let Some(primary_ix) = diagnostic_group
@@ -130,6 +130,7 @@ impl editor::DiagnosticRenderer for DiagnosticRenderer {
         cx: &mut App,
     ) -> Vec<BlockProperties<Anchor>> {
         let blocks = Self::diagnostic_blocks_for_group(diagnostic_group, buffer_id, None, cx);
+
         blocks
             .into_iter()
             .map(|block| {
@@ -182,7 +183,7 @@ pub(crate) struct DiagnosticBlock {
     pub(crate) initial_range: Range<Point>,
     pub(crate) severity: DiagnosticSeverity,
     pub(crate) markdown: Entity<Markdown>,
-    pub(crate) diagnostics_editor: Option<WeakEntity<ProjectDiagnosticsEditor>>,
+    pub(crate) diagnostics_editor: Option<Arc<dyn DiagnosticsToolbarEditor>>,
 }
 
 impl DiagnosticBlock {
@@ -233,7 +234,7 @@ impl DiagnosticBlock {
 
     pub fn open_link(
         editor: &mut Editor,
-        diagnostics_editor: &Option<WeakEntity<ProjectDiagnosticsEditor>>,
+        diagnostics_editor: &Option<Arc<dyn DiagnosticsToolbarEditor>>,
         link: SharedString,
         window: &mut Window,
         cx: &mut Context<Editor>,
@@ -254,18 +255,10 @@ impl DiagnosticBlock {
 
         if let Some(diagnostics_editor) = diagnostics_editor {
             if let Some(diagnostic) = diagnostics_editor
-                .read_with(cx, |diagnostics, _| {
-                    diagnostics
-                        .diagnostics
-                        .get(&buffer_id)
-                        .cloned()
-                        .unwrap_or_default()
-                        .into_iter()
-                        .filter(|d| d.diagnostic.group_id == group_id)
-                        .nth(ix)
-                })
-                .ok()
-                .flatten()
+                .get_diagnostics_for_buffer(buffer_id, cx)
+                .into_iter()
+                .filter(|d| d.diagnostic.group_id == group_id)
+                .nth(ix)
             {
                 let multibuffer = editor.buffer().read(cx);
                 let Some(snapshot) = multibuffer
@@ -297,9 +290,9 @@ impl DiagnosticBlock {
         };
     }
 
-    fn jump_to<T: ToOffset>(
+    fn jump_to<I: ToOffset>(
         editor: &mut Editor,
-        range: Range<T>,
+        range: Range<I>,
         window: &mut Window,
         cx: &mut Context<Editor>,
     ) {

crates/diagnostics/src/diagnostics.rs 🔗

@@ -1,12 +1,14 @@
 pub mod items;
 mod toolbar_controls;
 
+mod buffer_diagnostics;
 mod diagnostic_renderer;
 
 #[cfg(test)]
 mod diagnostics_tests;
 
 use anyhow::Result;
+use buffer_diagnostics::BufferDiagnosticsEditor;
 use collections::{BTreeSet, HashMap};
 use diagnostic_renderer::DiagnosticBlock;
 use editor::{
@@ -36,6 +38,7 @@ use std::{
 };
 use text::{BufferId, OffsetRangeExt};
 use theme::ActiveTheme;
+use toolbar_controls::DiagnosticsToolbarEditor;
 pub use toolbar_controls::ToolbarControls;
 use ui::{Icon, IconName, Label, h_flex, prelude::*};
 use util::ResultExt;
@@ -64,6 +67,7 @@ impl Global for IncludeWarnings {}
 pub fn init(cx: &mut App) {
     editor::set_diagnostic_renderer(diagnostic_renderer::DiagnosticRenderer {}, cx);
     cx.observe_new(ProjectDiagnosticsEditor::register).detach();
+    cx.observe_new(BufferDiagnosticsEditor::register).detach();
 }
 
 pub(crate) struct ProjectDiagnosticsEditor {
@@ -85,6 +89,7 @@ pub(crate) struct ProjectDiagnosticsEditor {
 impl EventEmitter<EditorEvent> for ProjectDiagnosticsEditor {}
 
 const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50);
+const DIAGNOSTICS_SUMMARY_UPDATE_DELAY: Duration = Duration::from_millis(30);
 
 impl Render for ProjectDiagnosticsEditor {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
@@ -144,7 +149,7 @@ impl Render for ProjectDiagnosticsEditor {
 }
 
 impl ProjectDiagnosticsEditor {
-    fn register(
+    pub fn register(
         workspace: &mut Workspace,
         _window: Option<&mut Window>,
         _: &mut Context<Workspace>,
@@ -160,7 +165,7 @@ impl ProjectDiagnosticsEditor {
         cx: &mut Context<Self>,
     ) -> Self {
         let project_event_subscription =
-            cx.subscribe_in(&project_handle, window, |this, project, event, window, cx| match event {
+            cx.subscribe_in(&project_handle, window, |this, _project, event, window, cx| match event {
                 project::Event::DiskBasedDiagnosticsStarted { .. } => {
                     cx.notify();
                 }
@@ -173,13 +178,12 @@ impl ProjectDiagnosticsEditor {
                     paths,
                 } => {
                     this.paths_to_update.extend(paths.clone());
-                    let project = project.clone();
                     this.diagnostic_summary_update = cx.spawn(async move |this, cx| {
                         cx.background_executor()
-                            .timer(Duration::from_millis(30))
+                            .timer(DIAGNOSTICS_SUMMARY_UPDATE_DELAY)
                             .await;
                         this.update(cx, |this, cx| {
-                            this.summary = project.read(cx).diagnostic_summary(false, cx);
+                            this.update_diagnostic_summary(cx);
                         })
                         .log_err();
                     });
@@ -326,6 +330,7 @@ impl ProjectDiagnosticsEditor {
             let is_active = workspace
                 .active_item(cx)
                 .is_some_and(|item| item.item_id() == existing.item_id());
+
             workspace.activate_item(&existing, true, !is_active, window, cx);
         } else {
             let workspace_handle = cx.entity().downgrade();
@@ -383,22 +388,25 @@ impl ProjectDiagnosticsEditor {
     /// currently have diagnostics or are currently present in this view.
     fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.project.update(cx, |project, cx| {
-            let mut paths = project
+            let mut project_paths = project
                 .diagnostic_summaries(false, cx)
-                .map(|(path, _, _)| path)
+                .map(|(project_path, _, _)| project_path)
                 .collect::<BTreeSet<_>>();
+
             self.multibuffer.update(cx, |multibuffer, cx| {
                 for buffer in multibuffer.all_buffers() {
                     if let Some(file) = buffer.read(cx).file() {
-                        paths.insert(ProjectPath {
+                        project_paths.insert(ProjectPath {
                             path: file.path().clone(),
                             worktree_id: file.worktree_id(cx),
                         });
                     }
                 }
             });
-            self.paths_to_update = paths;
+
+            self.paths_to_update = project_paths;
         });
+
         self.update_stale_excerpts(window, cx);
     }
 
@@ -428,6 +436,7 @@ impl ProjectDiagnosticsEditor {
         let was_empty = self.multibuffer.read(cx).is_empty();
         let buffer_snapshot = buffer.read(cx).snapshot();
         let buffer_id = buffer_snapshot.remote_id();
+
         let max_severity = if self.include_warnings {
             lsp::DiagnosticSeverity::WARNING
         } else {
@@ -441,6 +450,7 @@ impl ProjectDiagnosticsEditor {
                     false,
                 )
                 .collect::<Vec<_>>();
+
             let unchanged = this.update(cx, |this, _| {
                 if this.diagnostics.get(&buffer_id).is_some_and(|existing| {
                     this.diagnostics_are_unchanged(existing, &diagnostics, &buffer_snapshot)
@@ -475,7 +485,7 @@ impl ProjectDiagnosticsEditor {
                     crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group(
                         group,
                         buffer_snapshot.remote_id(),
-                        Some(this.clone()),
+                        Some(Arc::new(this.clone())),
                         cx,
                     )
                 })?;
@@ -505,6 +515,7 @@ impl ProjectDiagnosticsEditor {
                     cx,
                 )
                 .await;
+
                 let i = excerpt_ranges
                     .binary_search_by(|probe| {
                         probe
@@ -574,6 +585,7 @@ impl ProjectDiagnosticsEditor {
                                 priority: 1,
                             }
                         });
+
                 let block_ids = this.editor.update(cx, |editor, cx| {
                     editor.display_map.update(cx, |display_map, cx| {
                         display_map.insert_blocks(editor_blocks, cx)
@@ -604,6 +616,10 @@ impl ProjectDiagnosticsEditor {
             })
         })
     }
+
+    fn update_diagnostic_summary(&mut self, cx: &mut Context<Self>) {
+        self.summary = self.project.read(cx).diagnostic_summary(false, cx);
+    }
 }
 
 impl Focusable for ProjectDiagnosticsEditor {
@@ -812,6 +828,68 @@ impl Item for ProjectDiagnosticsEditor {
     }
 }
 
+impl DiagnosticsToolbarEditor for WeakEntity<ProjectDiagnosticsEditor> {
+    fn include_warnings(&self, cx: &App) -> bool {
+        self.read_with(cx, |project_diagnostics_editor, _cx| {
+            project_diagnostics_editor.include_warnings
+        })
+        .unwrap_or(false)
+    }
+
+    fn has_stale_excerpts(&self, cx: &App) -> bool {
+        self.read_with(cx, |project_diagnostics_editor, _cx| {
+            !project_diagnostics_editor.paths_to_update.is_empty()
+        })
+        .unwrap_or(false)
+    }
+
+    fn is_updating(&self, cx: &App) -> bool {
+        self.read_with(cx, |project_diagnostics_editor, cx| {
+            project_diagnostics_editor.update_excerpts_task.is_some()
+                || project_diagnostics_editor
+                    .project
+                    .read(cx)
+                    .language_servers_running_disk_based_diagnostics(cx)
+                    .next()
+                    .is_some()
+        })
+        .unwrap_or(false)
+    }
+
+    fn stop_updating(&self, cx: &mut App) {
+        let _ = self.update(cx, |project_diagnostics_editor, cx| {
+            project_diagnostics_editor.update_excerpts_task = None;
+            cx.notify();
+        });
+    }
+
+    fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App) {
+        let _ = self.update(cx, |project_diagnostics_editor, cx| {
+            project_diagnostics_editor.update_all_excerpts(window, cx);
+        });
+    }
+
+    fn toggle_warnings(&self, window: &mut Window, cx: &mut App) {
+        let _ = self.update(cx, |project_diagnostics_editor, cx| {
+            project_diagnostics_editor.toggle_warnings(&Default::default(), window, cx);
+        });
+    }
+
+    fn get_diagnostics_for_buffer(
+        &self,
+        buffer_id: text::BufferId,
+        cx: &App,
+    ) -> Vec<language::DiagnosticEntry<text::Anchor>> {
+        self.read_with(cx, |project_diagnostics_editor, _cx| {
+            project_diagnostics_editor
+                .diagnostics
+                .get(&buffer_id)
+                .cloned()
+                .unwrap_or_default()
+        })
+        .unwrap_or_default()
+    }
+}
 const DIAGNOSTIC_EXPANSION_ROW_LIMIT: u32 = 32;
 
 async fn context_range_for_entry(

crates/diagnostics/src/diagnostics_tests.rs 🔗

@@ -1567,6 +1567,440 @@ async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) {
     cx.assert_editor_state(indoc! {"error ˇwarning info hint"});
 }
 
+#[gpui::test]
+async fn test_buffer_diagnostics(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    // We'll be creating two different files, both with diagnostics, so we can
+    // later verify that, since the `BufferDiagnosticsEditor` only shows
+    // diagnostics for the provided path, the diagnostics for the other file
+    // will not be shown, contrary to what happens with
+    // `ProjectDiagnosticsEditor`.
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/test"),
+        json!({
+            "main.rs": "
+                fn main() {
+                    let x = vec![];
+                    let y = vec![];
+                    a(x);
+                    b(y);
+                    c(y);
+                    d(x);
+                }
+            "
+            .unindent(),
+            "other.rs": "
+                fn other() {
+                    let unused = 42;
+                    undefined_function();
+                }
+            "
+            .unindent(),
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*window, cx);
+    let project_path = project::ProjectPath {
+        worktree_id: project.read_with(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        }),
+        path: Arc::from(Path::new("main.rs")),
+    };
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_buffer(project_path.clone(), cx)
+        })
+        .await
+        .ok();
+
+    // Create the diagnostics for `main.rs`.
+    let language_server_id = LanguageServerId(0);
+    let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap();
+    let lsp_store = project.read_with(cx, |project, _| project.lsp_store());
+
+    lsp_store.update(cx, |lsp_store, cx| {
+        lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams {
+            uri: uri.clone(),
+            diagnostics: vec![
+                lsp::Diagnostic{
+                    range: lsp::Range::new(lsp::Position::new(5, 6), lsp::Position::new(5, 7)),
+                    severity: Some(lsp::DiagnosticSeverity::WARNING),
+                    message: "use of moved value\nvalue used here after move".to_string(),
+                    related_information: Some(vec![
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 9))),
+                            message: "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait".to_string()
+                        },
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(4, 6), lsp::Position::new(4, 7))),
+                            message: "value moved here".to_string()
+                        },
+                    ]),
+                    ..Default::default()
+                },
+                lsp::Diagnostic{
+                    range: lsp::Range::new(lsp::Position::new(6, 6), lsp::Position::new(6, 7)),
+                    severity: Some(lsp::DiagnosticSeverity::ERROR),
+                    message: "use of moved value\nvalue used here after move".to_string(),
+                    related_information: Some(vec![
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9))),
+                            message: "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait".to_string()
+                        },
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(3, 6), lsp::Position::new(3, 7))),
+                            message: "value moved here".to_string()
+                        },
+                    ]),
+                    ..Default::default()
+                }
+            ],
+            version: None
+        }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap();
+
+        // Create diagnostics for other.rs to ensure that the file and
+        // diagnostics are not included in `BufferDiagnosticsEditor` when it is
+        // deployed for main.rs.
+        lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams {
+            uri: lsp::Uri::from_file_path(path!("/test/other.rs")).unwrap(),
+            diagnostics: vec![
+                lsp::Diagnostic{
+                    range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 14)),
+                    severity: Some(lsp::DiagnosticSeverity::WARNING),
+                    message: "unused variable: `unused`".to_string(),
+                    ..Default::default()
+                },
+                lsp::Diagnostic{
+                    range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 22)),
+                    severity: Some(lsp::DiagnosticSeverity::ERROR),
+                    message: "cannot find function `undefined_function` in this scope".to_string(),
+                    ..Default::default()
+                }
+            ],
+            version: None
+        }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap();
+    });
+
+    let buffer_diagnostics = window.build_entity(cx, |window, cx| {
+        BufferDiagnosticsEditor::new(
+            project_path.clone(),
+            project.clone(),
+            buffer,
+            true,
+            window,
+            cx,
+        )
+    });
+    let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _| {
+        buffer_diagnostics.editor().clone()
+    });
+
+    // Since the excerpt updates is handled by a background task, we need to
+    // wait a little bit to ensure that the buffer diagnostic's editor content
+    // is rendered.
+    cx.executor()
+        .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10));
+
+    pretty_assertions::assert_eq!(
+        editor_content_with_blocks(&editor, cx),
+        indoc::indoc! {
+            "§ main.rs
+             § -----
+             fn main() {
+                 let x = vec![];
+             § move occurs because `x` has type `Vec<char>`, which does not implement
+             § the `Copy` trait (back)
+                 let y = vec![];
+             § move occurs because `y` has type `Vec<char>`, which does not implement
+             § the `Copy` trait
+                 a(x); § value moved here
+                 b(y); § value moved here
+                 c(y);
+             § use of moved value
+             § value used here after move
+                 d(x);
+             § use of moved value
+             § value used here after move
+             § hint: move occurs because `x` has type `Vec<char>`, which does not
+             § implement the `Copy` trait
+             }"
+        }
+    );
+}
+
+#[gpui::test]
+async fn test_buffer_diagnostics_without_warnings(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/test"),
+        json!({
+            "main.rs": "
+                fn main() {
+                    let x = vec![];
+                    let y = vec![];
+                    a(x);
+                    b(y);
+                    c(y);
+                    d(x);
+                }
+            "
+            .unindent(),
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*window, cx);
+    let project_path = project::ProjectPath {
+        worktree_id: project.read_with(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        }),
+        path: Arc::from(Path::new("main.rs")),
+    };
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_buffer(project_path.clone(), cx)
+        })
+        .await
+        .ok();
+
+    let language_server_id = LanguageServerId(0);
+    let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap();
+    let lsp_store = project.read_with(cx, |project, _| project.lsp_store());
+
+    lsp_store.update(cx, |lsp_store, cx| {
+        lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams {
+            uri: uri.clone(),
+            diagnostics: vec![
+                lsp::Diagnostic{
+                    range: lsp::Range::new(lsp::Position::new(5, 6), lsp::Position::new(5, 7)),
+                    severity: Some(lsp::DiagnosticSeverity::WARNING),
+                    message: "use of moved value\nvalue used here after move".to_string(),
+                    related_information: Some(vec![
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 9))),
+                            message: "move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait".to_string()
+                        },
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(4, 6), lsp::Position::new(4, 7))),
+                            message: "value moved here".to_string()
+                        },
+                    ]),
+                    ..Default::default()
+                },
+                lsp::Diagnostic{
+                    range: lsp::Range::new(lsp::Position::new(6, 6), lsp::Position::new(6, 7)),
+                    severity: Some(lsp::DiagnosticSeverity::ERROR),
+                    message: "use of moved value\nvalue used here after move".to_string(),
+                    related_information: Some(vec![
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9))),
+                            message: "move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait".to_string()
+                        },
+                        lsp::DiagnosticRelatedInformation {
+                            location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(3, 6), lsp::Position::new(3, 7))),
+                            message: "value moved here".to_string()
+                        },
+                    ]),
+                    ..Default::default()
+                }
+            ],
+            version: None
+        }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap();
+    });
+
+    let include_warnings = false;
+    let buffer_diagnostics = window.build_entity(cx, |window, cx| {
+        BufferDiagnosticsEditor::new(
+            project_path.clone(),
+            project.clone(),
+            buffer,
+            include_warnings,
+            window,
+            cx,
+        )
+    });
+
+    let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _cx| {
+        buffer_diagnostics.editor().clone()
+    });
+
+    // Since the excerpt updates is handled by a background task, we need to
+    // wait a little bit to ensure that the buffer diagnostic's editor content
+    // is rendered.
+    cx.executor()
+        .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10));
+
+    pretty_assertions::assert_eq!(
+        editor_content_with_blocks(&editor, cx),
+        indoc::indoc! {
+            "§ main.rs
+             § -----
+             fn main() {
+                 let x = vec![];
+             § move occurs because `x` has type `Vec<char>`, which does not implement
+             § the `Copy` trait (back)
+                 let y = vec![];
+                 a(x); § value moved here
+                 b(y);
+                 c(y);
+                 d(x);
+             § use of moved value
+             § value used here after move
+             § hint: move occurs because `x` has type `Vec<char>`, which does not
+             § implement the `Copy` trait
+             }"
+        }
+    );
+}
+
+#[gpui::test]
+async fn test_buffer_diagnostics_multiple_servers(cx: &mut TestAppContext) {
+    init_test(cx);
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/test"),
+        json!({
+            "main.rs": "
+                fn main() {
+                    let x = vec![];
+                    let y = vec![];
+                    a(x);
+                    b(y);
+                    c(y);
+                    d(x);
+                }
+            "
+            .unindent(),
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await;
+    let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*window, cx);
+    let project_path = project::ProjectPath {
+        worktree_id: project.read_with(cx, |project, cx| {
+            project.worktrees(cx).next().unwrap().read(cx).id()
+        }),
+        path: Arc::from(Path::new("main.rs")),
+    };
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_buffer(project_path.clone(), cx)
+        })
+        .await
+        .ok();
+
+    // Create the diagnostics for `main.rs`.
+    // Two warnings are being created, one for each language server, in order to
+    // assert that both warnings are rendered in the editor.
+    let language_server_id_a = LanguageServerId(0);
+    let language_server_id_b = LanguageServerId(1);
+    let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap();
+    let lsp_store = project.read_with(cx, |project, _| project.lsp_store());
+
+    lsp_store.update(cx, |lsp_store, cx| {
+        lsp_store
+            .update_diagnostics(
+                language_server_id_a,
+                lsp::PublishDiagnosticsParams {
+                    uri: uri.clone(),
+                    diagnostics: vec![lsp::Diagnostic {
+                        range: lsp::Range::new(lsp::Position::new(5, 6), lsp::Position::new(5, 7)),
+                        severity: Some(lsp::DiagnosticSeverity::WARNING),
+                        message: "use of moved value\nvalue used here after move".to_string(),
+                        related_information: None,
+                        ..Default::default()
+                    }],
+                    version: None,
+                },
+                None,
+                DiagnosticSourceKind::Pushed,
+                &[],
+                cx,
+            )
+            .unwrap();
+
+        lsp_store
+            .update_diagnostics(
+                language_server_id_b,
+                lsp::PublishDiagnosticsParams {
+                    uri: uri.clone(),
+                    diagnostics: vec![lsp::Diagnostic {
+                        range: lsp::Range::new(lsp::Position::new(6, 6), lsp::Position::new(6, 7)),
+                        severity: Some(lsp::DiagnosticSeverity::WARNING),
+                        message: "use of moved value\nvalue used here after move".to_string(),
+                        related_information: None,
+                        ..Default::default()
+                    }],
+                    version: None,
+                },
+                None,
+                DiagnosticSourceKind::Pushed,
+                &[],
+                cx,
+            )
+            .unwrap();
+    });
+
+    let buffer_diagnostics = window.build_entity(cx, |window, cx| {
+        BufferDiagnosticsEditor::new(
+            project_path.clone(),
+            project.clone(),
+            buffer,
+            true,
+            window,
+            cx,
+        )
+    });
+    let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _| {
+        buffer_diagnostics.editor().clone()
+    });
+
+    // Since the excerpt updates is handled by a background task, we need to
+    // wait a little bit to ensure that the buffer diagnostic's editor content
+    // is rendered.
+    cx.executor()
+        .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10));
+
+    pretty_assertions::assert_eq!(
+        editor_content_with_blocks(&editor, cx),
+        indoc::indoc! {
+            "§ main.rs
+             § -----
+                 a(x);
+                 b(y);
+                 c(y);
+             § use of moved value
+             § value used here after move
+                 d(x);
+             § use of moved value
+             § value used here after move
+             }"
+        }
+    );
+
+    buffer_diagnostics.update(cx, |buffer_diagnostics, _cx| {
+        assert_eq!(
+            *buffer_diagnostics.summary(),
+            DiagnosticSummary {
+                warning_count: 2,
+                error_count: 0
+            }
+        );
+    })
+}
+
 fn init_test(cx: &mut TestAppContext) {
     cx.update(|cx| {
         zlog::init_test();

crates/diagnostics/src/toolbar_controls.rs 🔗

@@ -1,33 +1,56 @@
-use crate::{ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh};
-use gpui::{Context, Entity, EventEmitter, ParentElement, Render, WeakEntity, Window};
+use crate::{BufferDiagnosticsEditor, ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh};
+use gpui::{Context, EventEmitter, ParentElement, Render, Window};
+use language::DiagnosticEntry;
+use text::{Anchor, BufferId};
 use ui::prelude::*;
 use ui::{IconButton, IconButtonShape, IconName, Tooltip};
 use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, item::ItemHandle};
 
 pub struct ToolbarControls {
-    editor: Option<WeakEntity<ProjectDiagnosticsEditor>>,
+    editor: Option<Box<dyn DiagnosticsToolbarEditor>>,
+}
+
+pub(crate) trait DiagnosticsToolbarEditor: Send + Sync {
+    /// Informs the toolbar whether warnings are included in the diagnostics.
+    fn include_warnings(&self, cx: &App) -> bool;
+    /// Toggles whether warning diagnostics should be displayed by the
+    /// diagnostics editor.
+    fn toggle_warnings(&self, window: &mut Window, cx: &mut App);
+    /// Indicates whether any of the excerpts displayed by the diagnostics
+    /// editor are stale.
+    fn has_stale_excerpts(&self, cx: &App) -> bool;
+    /// Indicates whether the diagnostics editor is currently updating the
+    /// diagnostics.
+    fn is_updating(&self, cx: &App) -> bool;
+    /// Requests that the diagnostics editor stop updating the diagnostics.
+    fn stop_updating(&self, cx: &mut App);
+    /// Requests that the diagnostics editor updates the displayed diagnostics
+    /// with the latest information.
+    fn refresh_diagnostics(&self, window: &mut Window, cx: &mut App);
+    /// Returns a list of diagnostics for the provided buffer id.
+    fn get_diagnostics_for_buffer(
+        &self,
+        buffer_id: BufferId,
+        cx: &App,
+    ) -> Vec<DiagnosticEntry<Anchor>>;
 }
 
 impl Render for ToolbarControls {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let mut include_warnings = false;
         let mut has_stale_excerpts = false;
+        let mut include_warnings = false;
         let mut is_updating = false;
 
-        if let Some(editor) = self.diagnostics() {
-            let diagnostics = editor.read(cx);
-            include_warnings = diagnostics.include_warnings;
-            has_stale_excerpts = !diagnostics.paths_to_update.is_empty();
-            is_updating = diagnostics.update_excerpts_task.is_some()
-                || diagnostics
-                    .project
-                    .read(cx)
-                    .language_servers_running_disk_based_diagnostics(cx)
-                    .next()
-                    .is_some();
+        match &self.editor {
+            Some(editor) => {
+                include_warnings = editor.include_warnings(cx);
+                has_stale_excerpts = editor.has_stale_excerpts(cx);
+                is_updating = editor.is_updating(cx);
+            }
+            None => {}
         }
 
-        let tooltip = if include_warnings {
+        let warning_tooltip = if include_warnings {
             "Exclude Warnings"
         } else {
             "Include Warnings"
@@ -52,11 +75,12 @@ impl Render for ToolbarControls {
                                 &ToggleDiagnosticsRefresh,
                             ))
                             .on_click(cx.listener(move |toolbar_controls, _, _, cx| {
-                                if let Some(diagnostics) = toolbar_controls.diagnostics() {
-                                    diagnostics.update(cx, |diagnostics, cx| {
-                                        diagnostics.update_excerpts_task = None;
+                                match toolbar_controls.editor() {
+                                    Some(editor) => {
+                                        editor.stop_updating(cx);
                                         cx.notify();
-                                    });
+                                    }
+                                    None => {}
                                 }
                             })),
                     )
@@ -71,12 +95,11 @@ impl Render for ToolbarControls {
                                 &ToggleDiagnosticsRefresh,
                             ))
                             .on_click(cx.listener({
-                                move |toolbar_controls, _, window, cx| {
-                                    if let Some(diagnostics) = toolbar_controls.diagnostics() {
-                                        diagnostics.update(cx, move |diagnostics, cx| {
-                                            diagnostics.update_all_excerpts(window, cx);
-                                        });
-                                    }
+                                move |toolbar_controls, _, window, cx| match toolbar_controls
+                                    .editor()
+                                {
+                                    Some(editor) => editor.refresh_diagnostics(window, cx),
+                                    None => {}
                                 }
                             })),
                     )
@@ -86,13 +109,10 @@ impl Render for ToolbarControls {
                 IconButton::new("toggle-warnings", IconName::Warning)
                     .icon_color(warning_color)
                     .shape(IconButtonShape::Square)
-                    .tooltip(Tooltip::text(tooltip))
-                    .on_click(cx.listener(|this, _, window, cx| {
-                        if let Some(editor) = this.diagnostics() {
-                            editor.update(cx, |editor, cx| {
-                                editor.toggle_warnings(&Default::default(), window, cx);
-                            });
-                        }
+                    .tooltip(Tooltip::text(warning_tooltip))
+                    .on_click(cx.listener(|this, _, window, cx| match &this.editor {
+                        Some(editor) => editor.toggle_warnings(window, cx),
+                        None => {}
                     })),
             )
     }
@@ -109,7 +129,10 @@ impl ToolbarItemView for ToolbarControls {
     ) -> ToolbarItemLocation {
         if let Some(pane_item) = active_pane_item.as_ref() {
             if let Some(editor) = pane_item.downcast::<ProjectDiagnosticsEditor>() {
-                self.editor = Some(editor.downgrade());
+                self.editor = Some(Box::new(editor.downgrade()));
+                ToolbarItemLocation::PrimaryRight
+            } else if let Some(editor) = pane_item.downcast::<BufferDiagnosticsEditor>() {
+                self.editor = Some(Box::new(editor.downgrade()));
                 ToolbarItemLocation::PrimaryRight
             } else {
                 ToolbarItemLocation::Hidden
@@ -131,7 +154,7 @@ impl ToolbarControls {
         ToolbarControls { editor: None }
     }
 
-    fn diagnostics(&self) -> Option<Entity<ProjectDiagnosticsEditor>> {
-        self.editor.as_ref()?.upgrade()
+    fn editor(&self) -> Option<&dyn DiagnosticsToolbarEditor> {
+        self.editor.as_deref()
     }
 }

crates/editor/src/editor.rs 🔗

@@ -18998,6 +18998,8 @@ impl Editor {
         }
     }
 
+    /// Returns the project path for the editor's buffer, if any buffer is
+    /// opened in the editor.
     pub fn project_path(&self, cx: &App) -> Option<ProjectPath> {
         if let Some(buffer) = self.buffer.read(cx).as_singleton() {
             buffer.read(cx).project_path(cx)

crates/project/src/lsp_store.rs 🔗

@@ -86,7 +86,6 @@ use node_runtime::read_package_installed_version;
 use parking_lot::Mutex;
 use postage::{mpsc, sink::Sink, stream::Stream, watch};
 use rand::prelude::*;
-
 use rpc::{
     AnyProtoClient,
     proto::{FromProto, LspRequestId, LspRequestMessage as _, ToProto},
@@ -7124,6 +7123,36 @@ impl LspStore {
         summary
     }
 
+    /// Returns the diagnostic summary for a specific project path.
+    pub fn diagnostic_summary_for_path(
+        &self,
+        project_path: &ProjectPath,
+        _: &App,
+    ) -> DiagnosticSummary {
+        if let Some(summaries) = self
+            .diagnostic_summaries
+            .get(&project_path.worktree_id)
+            .and_then(|map| map.get(&project_path.path))
+        {
+            let (error_count, warning_count) = summaries.iter().fold(
+                (0, 0),
+                |(error_count, warning_count), (_language_server_id, summary)| {
+                    (
+                        error_count + summary.error_count,
+                        warning_count + summary.warning_count,
+                    )
+                },
+            );
+
+            DiagnosticSummary {
+                error_count,
+                warning_count,
+            }
+        } else {
+            DiagnosticSummary::default()
+        }
+    }
+
     pub fn diagnostic_summaries<'a>(
         &'a self,
         include_ignored: bool,

crates/project/src/project.rs 🔗

@@ -4400,6 +4400,13 @@ impl Project {
             .diagnostic_summary(include_ignored, cx)
     }
 
+    /// Returns a summary of the diagnostics for the provided project path only.
+    pub fn diagnostic_summary_for_path(&self, path: &ProjectPath, cx: &App) -> DiagnosticSummary {
+        self.lsp_store
+            .read(cx)
+            .diagnostic_summary_for_path(path, cx)
+    }
+
     pub fn diagnostic_summaries<'a>(
         &'a self,
         include_ignored: bool,