Diagnostics style 2 (#3483)

Marshall Bowers created

[[PR Description]]

Merge past diagnostic multibuffer style work + some extras

Release Notes:

- N/A

Change summary

assets/icons/copy.svg                           |   1 
crates/diagnostics2/src/diagnostics.rs          |  72 +++++++---
crates/editor2/src/editor.rs                    |  44 +++++-
crates/editor2/src/element.rs                   | 130 +++++++++++++++---
crates/ui2/src/components/button/button.rs      |  14 +
crates/ui2/src/components/button/button_like.rs |  18 ++
crates/ui2/src/components/button/icon_button.rs |  14 +
crates/ui2/src/components/icon.rs               |   2 
crates/ui2/src/styled_ext.rs                    |  14 ++
9 files changed, 253 insertions(+), 56 deletions(-)

Detailed changes

assets/icons/copy.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>

crates/diagnostics2/src/diagnostics.rs 🔗

@@ -774,24 +774,39 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
     Arc::new(move |_| {
         h_stack()
             .id("diagnostic header")
-            .gap_3()
-            .bg(gpui::red())
-            .map(|stack| {
-                let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
-                    IconElement::new(Icon::XCircle).color(Color::Error)
-                } else {
-                    IconElement::new(Icon::ExclamationTriangle).color(Color::Warning)
-                };
-
-                stack.child(div().pl_8().child(icon))
-            })
-            .when_some(diagnostic.source.as_ref(), |stack, source| {
-                stack.child(Label::new(format!("{source}:")).color(Color::Accent))
-            })
-            .child(HighlightedLabel::new(message.clone(), highlights.clone()))
-            .when_some(diagnostic.code.as_ref(), |stack, code| {
-                stack.child(Label::new(code.clone()))
-            })
+            .py_2()
+            .pl_10()
+            .pr_5()
+            .w_full()
+            .justify_between()
+            .gap_2()
+            .child(
+                h_stack()
+                    .gap_3()
+                    .map(|stack| {
+                        let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
+                            IconElement::new(Icon::XCircle).color(Color::Error)
+                        } else {
+                            IconElement::new(Icon::ExclamationTriangle).color(Color::Warning)
+                        };
+                        stack.child(icon)
+                    })
+                    .child(
+                        h_stack()
+                            .gap_1()
+                            .child(HighlightedLabel::new(message.clone(), highlights.clone()))
+                            .when_some(diagnostic.code.as_ref(), |stack, code| {
+                                stack.child(Label::new(format!("({code})")).color(Color::Muted))
+                            }),
+                    ),
+            )
+            .child(
+                h_stack()
+                    .gap_1()
+                    .when_some(diagnostic.source.as_ref(), |stack, source| {
+                        stack.child(Label::new(format!("{source}")).color(Color::Muted))
+                    }),
+            )
             .into_any_element()
     })
 }
@@ -802,11 +817,22 @@ pub(crate) fn render_summary(summary: &DiagnosticSummary) -> AnyElement {
         label.into_any_element()
     } else {
         h_stack()
-            .bg(gpui::red())
-            .child(IconElement::new(Icon::XCircle))
-            .child(Label::new(summary.error_count.to_string()))
-            .child(IconElement::new(Icon::ExclamationTriangle))
-            .child(Label::new(summary.warning_count.to_string()))
+            .gap_1()
+            .when(summary.error_count > 0, |then| {
+                then.child(
+                    h_stack()
+                        .gap_1()
+                        .child(IconElement::new(Icon::XCircle).color(Color::Error))
+                        .child(Label::new(summary.error_count.to_string())),
+                )
+            })
+            .when(summary.warning_count > 0, |then| {
+                then.child(
+                    h_stack()
+                        .child(IconElement::new(Icon::ExclamationTriangle).color(Color::Warning))
+                        .child(Label::new(summary.warning_count.to_string())),
+                )
+            })
             .into_any_element()
     }
 }

crates/editor2/src/editor.rs 🔗

@@ -100,8 +100,10 @@ use text::{OffsetUtf16, Rope};
 use theme::{
     ActiveTheme, DiagnosticStyle, PlayerColor, SyntaxTheme, Theme, ThemeColors, ThemeSettings,
 };
-use ui::prelude::*;
-use ui::{h_stack, v_stack, HighlightedLabel, IconButton, Popover, Tooltip};
+use ui::{
+    h_stack, v_stack, ButtonSize, ButtonStyle, HighlightedLabel, Icon, IconButton, Popover, Tooltip,
+};
+use ui::{prelude::*, IconSize};
 use util::{post_inc, RangeExt, ResultExt, TryFutureExt};
 use workspace::{
     item::{ItemEvent, ItemHandle},
@@ -9689,20 +9691,44 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend
     let message = diagnostic.message;
     Arc::new(move |cx: &mut BlockContext| {
         let message = message.clone();
+        let copy_id: SharedString = format!("copy-{}", cx.block_id.clone()).to_string().into();
+        // TODO: `cx.write_to_clipboard` is not implemented in tests.
+        // let write_to_clipboard = cx.write_to_clipboard(ClipboardItem::new(message.clone()));
+
+        // TODO: Nate: We should tint the background of the block with the severity color
+        // We need to extend the theme before we can do this
         v_stack()
             .id(cx.block_id)
+            .relative()
             .size_full()
             .bg(gpui::red())
             .children(highlighted_lines.iter().map(|(line, highlights)| {
-                div()
+                let group_id = cx.block_id.to_string();
+
+                h_stack()
+                    .group(group_id.clone())
+                    .gap_2()
+                    .absolute()
+                    .left(cx.anchor_x)
+                    .px_1p5()
                     .child(HighlightedLabel::new(line.clone(), highlights.clone()))
-                    .ml(cx.anchor_x)
-            }))
-            .cursor_pointer()
-            .on_click(cx.listener(move |_, _, cx| {
-                cx.write_to_clipboard(ClipboardItem::new(message.clone()));
+                    .child(
+                        div()
+                            .border()
+                            .border_color(gpui::red())
+                            .invisible()
+                            .group_hover(group_id, |style| style.visible())
+                            .child(
+                                IconButton::new(copy_id.clone(), Icon::Copy)
+                                    .icon_color(Color::Muted)
+                                    .size(ButtonSize::Compact)
+                                    .style(ButtonStyle::Transparent)
+                                    // TODO: `cx.write_to_clipboard` is not implemented in tests.
+                                    // .on_click(cx.listener(move |_, _, cx| write_to_clipboard))
+                                    .tooltip(|cx| Tooltip::text("Copy diagnostic message", cx)),
+                            ),
+                    )
             }))
-            .tooltip(|cx| Tooltip::text("Copy diagnostic message", cx))
             .into_any_element()
     })
 }

crates/editor2/src/element.rs 🔗

@@ -51,8 +51,10 @@ use std::{
 };
 use sum_tree::Bias;
 use theme::{ActiveTheme, PlayerColor};
-use ui::prelude::*;
-use ui::{h_stack, IconButton, Tooltip};
+use ui::{
+    h_stack, ButtonLike, ButtonStyle, Disclosure, IconButton, IconElement, IconSize, Label, Tooltip,
+};
+use ui::{prelude::*, Icon};
 use util::ResultExt;
 use workspace::item::Item;
 
@@ -2223,7 +2225,8 @@ impl EditorElement {
                         .as_ref()
                         .map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
                         .unwrap_or_default();
-                    let jump_icon = project::File::from_dyn(buffer.file()).map(|file| {
+
+                    let jump_handler = project::File::from_dyn(buffer.file()).map(|file| {
                         let jump_path = ProjectPath {
                             worktree_id: file.worktree_id(cx),
                             path: file.path.clone(),
@@ -2234,11 +2237,11 @@ impl EditorElement {
                             .map_or(range.context.start, |primary| primary.start);
                         let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
 
-                        IconButton::new(block_id, ui::Icon::ArrowUpRight)
-                            .on_click(cx.listener_for(&self.editor, move |editor, e, cx| {
-                                editor.jump(jump_path.clone(), jump_position, jump_anchor, cx);
-                            }))
-                            .tooltip(|cx| Tooltip::for_action("Jump to Buffer", &OpenExcerpts, cx))
+                        let jump_handler = cx.listener_for(&self.editor, move |editor, e, cx| {
+                            editor.jump(jump_path.clone(), jump_position, jump_anchor, cx);
+                        });
+
+                        jump_handler
                     });
 
                     let element = if *starts_new_buffer {
@@ -2253,25 +2256,108 @@ impl EditorElement {
                                 .map(|p| SharedString::from(p.to_string_lossy().to_string() + "/"));
                         }
 
-                        h_stack()
-                            .id("path header block")
-                            .size_full()
-                            .bg(gpui::red())
-                            .child(
-                                filename
-                                    .map(SharedString::from)
-                                    .unwrap_or_else(|| "untitled".into()),
-                            )
-                            .children(parent_path)
-                            .children(jump_icon) // .p_x(gutter_padding)
+                        let is_open = true;
+
+                        div().id("path header container").size_full().p_1p5().child(
+                            h_stack()
+                                .id("path header block")
+                                .py_1p5()
+                                .pl_3()
+                                .pr_2()
+                                .rounded_lg()
+                                .shadow_md()
+                                .border()
+                                .border_color(cx.theme().colors().border)
+                                .bg(cx.theme().colors().editor_subheader_background)
+                                .justify_between()
+                                .cursor_pointer()
+                                .hover(|style| style.bg(cx.theme().colors().element_hover))
+                                .on_click(cx.listener(|_editor, _event, _cx| {
+                                    // TODO: Implement collapsing path headers
+                                    todo!("Clicking path header")
+                                }))
+                                .child(
+                                    h_stack()
+                                        .gap_3()
+                                        // TODO: Add open/close state and toggle action
+                                        .child(
+                                            div().border().border_color(gpui::red()).child(
+                                                ButtonLike::new("path-header-disclosure-control")
+                                                    .style(ButtonStyle::Subtle)
+                                                    .child(IconElement::new(match is_open {
+                                                        true => Icon::ChevronDown,
+                                                        false => Icon::ChevronRight,
+                                                    })),
+                                            ),
+                                        )
+                                        .child(
+                                            h_stack()
+                                                .gap_2()
+                                                .child(Label::new(
+                                                    filename
+                                                        .map(SharedString::from)
+                                                        .unwrap_or_else(|| "untitled".into()),
+                                                ))
+                                                .when_some(parent_path, |then, path| {
+                                                    then.child(Label::new(path).color(Color::Muted))
+                                                }),
+                                        ),
+                                )
+                                .children(jump_handler.map(|jump_handler| {
+                                    IconButton::new(block_id, Icon::ArrowUpRight)
+                                        .style(ButtonStyle::Subtle)
+                                        .on_click(jump_handler)
+                                        .tooltip(|cx| {
+                                            Tooltip::for_action("Jump to Buffer", &OpenExcerpts, cx)
+                                        })
+                                })), // .p_x(gutter_padding)
+                        )
                     } else {
                         let text_style = style.text.clone();
                         h_stack()
                             .id("collapsed context")
                             .size_full()
-                            .bg(gpui::red())
-                            .child("⋯")
-                            .children(jump_icon) // .p_x(gutter_padding)
+                            .gap(gutter_padding)
+                            .child(
+                                h_stack()
+                                    .justify_end()
+                                    .flex_none()
+                                    .w(gutter_width - gutter_padding)
+                                    .h_full()
+                                    .text_buffer(cx)
+                                    .text_color(cx.theme().colors().editor_line_number)
+                                    .child("..."),
+                            )
+                            .map(|this| {
+                                if let Some(jump_handler) = jump_handler {
+                                    this.child(
+                                        ButtonLike::new("jump to collapsed context")
+                                            .style(ButtonStyle::Transparent)
+                                            .full_width()
+                                            .on_click(jump_handler)
+                                            .tooltip(|cx| {
+                                                Tooltip::for_action(
+                                                    "Jump to Buffer",
+                                                    &OpenExcerpts,
+                                                    cx,
+                                                )
+                                            })
+                                            .child(
+                                                div()
+                                                    .h_px()
+                                                    .w_full()
+                                                    .bg(cx.theme().colors().border_variant)
+                                                    .group_hover("", |style| {
+                                                        style.bg(cx.theme().colors().border)
+                                                    }),
+                                            ),
+                                    )
+                                } else {
+                                    this.child(div().size_full().bg(gpui::green()))
+                                }
+                            })
+                        // .child("⋯")
+                        // .children(jump_icon) // .p_x(gutter_padding)
                     };
                     element.into_any()
                 }

crates/ui2/src/components/button/button.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::AnyView;
+use gpui::{AnyView, DefiniteLength};
 
 use crate::prelude::*;
 use crate::{
@@ -88,6 +88,18 @@ impl Clickable for Button {
     }
 }
 
+impl FixedWidth for Button {
+    fn width(mut self, width: DefiniteLength) -> Self {
+        self.base = self.base.width(width);
+        self
+    }
+
+    fn full_width(mut self) -> Self {
+        self.base = self.base.full_width();
+        self
+    }
+}
+
 impl ButtonCommon for Button {
     fn id(&self) -> &ElementId {
         self.base.id()

crates/ui2/src/components/button/button_like.rs 🔗

@@ -1,3 +1,4 @@
+use gpui::{relative, DefiniteLength};
 use gpui::{rems, transparent_black, AnyElement, AnyView, ClickEvent, Div, Hsla, Rems, Stateful};
 use smallvec::SmallVec;
 
@@ -246,6 +247,7 @@ pub struct ButtonLike {
     pub(super) style: ButtonStyle,
     pub(super) disabled: bool,
     pub(super) selected: bool,
+    pub(super) width: Option<DefiniteLength>,
     size: ButtonSize,
     tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView>>,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
@@ -259,6 +261,7 @@ impl ButtonLike {
             style: ButtonStyle::default(),
             disabled: false,
             selected: false,
+            width: None,
             size: ButtonSize::Default,
             tooltip: None,
             children: SmallVec::new(),
@@ -288,6 +291,18 @@ impl Clickable for ButtonLike {
     }
 }
 
+impl FixedWidth for ButtonLike {
+    fn width(mut self, width: DefiniteLength) -> Self {
+        self.width = Some(width);
+        self
+    }
+
+    fn full_width(mut self) -> Self {
+        self.width = Some(relative(1.));
+        self
+    }
+}
+
 impl ButtonCommon for ButtonLike {
     fn id(&self) -> &ElementId {
         &self.id
@@ -321,7 +336,10 @@ impl RenderOnce for ButtonLike {
     fn render(self, cx: &mut WindowContext) -> Self::Rendered {
         h_stack()
             .id(self.id.clone())
+            .group("")
+            .flex_none()
             .h(self.size.height())
+            .when_some(self.width, |this, width| this.w(width))
             .rounded_md()
             .gap_1()
             .px_1()

crates/ui2/src/components/button/icon_button.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::{Action, AnyView};
+use gpui::{Action, AnyView, DefiniteLength};
 
 use crate::prelude::*;
 use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize};
@@ -69,6 +69,18 @@ impl Clickable for IconButton {
     }
 }
 
+impl FixedWidth for IconButton {
+    fn width(mut self, width: DefiniteLength) -> Self {
+        self.base = self.base.width(width);
+        self
+    }
+
+    fn full_width(mut self) -> Self {
+        self.base = self.base.full_width();
+        self
+    }
+}
+
 impl ButtonCommon for IconButton {
     fn id(&self) -> &ElementId {
         self.base.id()

crates/ui2/src/components/icon.rs 🔗

@@ -27,6 +27,7 @@ pub enum Icon {
     Bolt,
     CaseSensitive,
     Check,
+    Copy,
     ChevronDown,
     ChevronLeft,
     ChevronRight,
@@ -100,6 +101,7 @@ impl Icon {
             Icon::Bolt => "icons/bolt.svg",
             Icon::CaseSensitive => "icons/case_insensitive.svg",
             Icon::Check => "icons/check.svg",
+            Icon::Copy => "icons/copy.svg",
             Icon::ChevronDown => "icons/chevron_down.svg",
             Icon::ChevronLeft => "icons/chevron_left.svg",
             Icon::ChevronRight => "icons/chevron_right.svg",

crates/ui2/src/styled_ext.rs 🔗

@@ -1,4 +1,6 @@
 use gpui::{px, Styled, WindowContext};
+use settings::Settings;
+use theme::ThemeSettings;
 
 use crate::prelude::*;
 use crate::{ElevationIndex, UITextSize};
@@ -60,6 +62,18 @@ pub trait StyledExt: Styled + Sized {
         self.text_size(size)
     }
 
+    /// The font size for buffer text.
+    ///
+    /// Retrieves the default font size, or the user's custom font size if set.
+    ///
+    /// This should only be used for text that is displayed in a buffer,
+    /// or other places that text needs to match the user's buffer font size.
+    fn text_buffer(self, cx: &mut WindowContext) -> Self {
+        let settings = ThemeSettings::get_global(cx);
+
+        self.text_size(settings.buffer_font_size)
+    }
+
     /// The [`Surface`](ui2::ElevationIndex::Surface) elevation level, located above the app background, is the standard level for all elements
     ///
     /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()`