Initial pass at clearing up the toolbar in the multibuffer case. Breadcrumbs to sticky headers.

KyleBarton created

Change summary

crates/breadcrumbs/src/breadcrumbs.rs |   6 
crates/editor/src/editor.rs           |  52 +++++++++++
crates/editor/src/element.rs          | 129 +++++++++++++++++++++++++++-
crates/editor/src/items.rs            |  97 +++++++++++---------
crates/git_ui/src/project_diff.rs     |  47 ++++++++++
crates/search/src/project_search.rs   |  24 +++-
6 files changed, 295 insertions(+), 60 deletions(-)

Detailed changes

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -5,12 +5,12 @@ use gpui::{
 };
 use itertools::Itertools;
 use settings::Settings;
-use std::cmp;
+use std::{any::Any, cmp};
 use theme::ActiveTheme;
 use ui::{ButtonLike, ButtonStyle, Label, Tooltip, prelude::*};
 use workspace::{
     TabBarSettings, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
-    item::{BreadcrumbText, ItemEvent, ItemHandle},
+    item::{BreadcrumbText, ItemBufferKind, ItemEvent, ItemHandle},
 };
 
 pub struct Breadcrumbs {
@@ -51,6 +51,7 @@ impl Render for Breadcrumbs {
             return element;
         };
 
+        // Begin - logic we should copy/move
         let Some(mut segments) = active_item.breadcrumbs(cx.theme(), cx) else {
             return element;
         };
@@ -151,6 +152,7 @@ impl Render for Breadcrumbs {
                 .pl_1()
                 .child(breadcrumbs),
         }
+        // End
     }
 }
 

crates/editor/src/editor.rs 🔗

@@ -207,7 +207,7 @@ use workspace::{
     CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal,
     RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast,
     ViewId, Workspace, WorkspaceId, WorkspaceSettings,
-    item::{ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
+    item::{BreadcrumbText, ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
     searchable::SearchEvent,
 };
@@ -1216,6 +1216,7 @@ pub struct Editor {
     accent_data: Option<AccentData>,
     fetched_tree_sitter_chunks: HashMap<ExcerptId, HashSet<Range<BufferRow>>>,
     use_base_text_line_numbers: bool,
+    is_multibuffer_collapsed: bool,
 }
 
 #[derive(Debug, PartialEq)]
@@ -2417,6 +2418,7 @@ impl Editor {
             accent_data: None,
             fetched_tree_sitter_chunks: HashMap::default(),
             use_base_text_line_numbers: false,
+            is_multibuffer_collapsed: false,
         };
 
         if is_minimap {
@@ -23248,6 +23250,54 @@ impl Editor {
             .map(|vim_mode| vim_mode.0)
             .unwrap_or(false)
     }
+
+    fn breadcrumbs_inner(&self, variant: &Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
+        let cursor = self.selections.newest_anchor().head();
+        let multibuffer = self.buffer().read(cx);
+        let is_singleton = multibuffer.is_singleton();
+        let (buffer_id, symbols) = multibuffer
+            .read(cx)
+            .symbols_containing(cursor, Some(variant.syntax()))?;
+        let buffer = multibuffer.buffer(buffer_id)?;
+
+        let buffer = buffer.read(cx);
+        let settings = ThemeSettings::get_global(cx);
+        // In a multi-buffer layout, we don't want to include the filename in the breadcrumbs
+        let mut breadcrumbs = if is_singleton {
+            let text = self.breadcrumb_header.clone().unwrap_or_else(|| {
+                buffer
+                    .snapshot()
+                    .resolve_file_path(
+                        self.project
+                            .as_ref()
+                            .map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
+                            .unwrap_or_default(),
+                        cx,
+                    )
+                    .unwrap_or_else(|| {
+                        if multibuffer.is_singleton() {
+                            multibuffer.title(cx).to_string()
+                        } else {
+                            "untitled".to_string()
+                        }
+                    })
+            });
+            vec![BreadcrumbText {
+                text,
+                highlights: None,
+                font: Some(settings.buffer_font.clone()),
+            }]
+        } else {
+            vec![]
+        };
+
+        breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText {
+            text: symbol.text,
+            highlights: Some(symbol.highlight_ranges),
+            font: Some(settings.buffer_font.clone()),
+        }));
+        Some(breadcrumbs)
+    }
 }
 
 fn edit_for_markdown_paste<'a>(

crates/editor/src/element.rs 🔗

@@ -50,9 +50,9 @@ use gpui::{
     KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
     MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
     Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
-    Size, StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, WeakEntity,
-    Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px,
-    quad, relative, size, solid_background, transparent_black,
+    Size, StatefulInteractiveElement, Style, Styled, StyledText, TextRun, TextStyleRefinement,
+    WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline,
+    point, px, quad, relative, size, solid_background, transparent_black,
 };
 use itertools::Itertools;
 use language::{IndentGuideSettings, language_settings::ShowWhitespaceSetting};
@@ -98,8 +98,9 @@ use unicode_segmentation::UnicodeSegmentation;
 use util::post_inc;
 use util::{RangeExt, ResultExt, debug_panic};
 use workspace::{
-    CollaboratorId, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace,
-    item::{Item, ItemBufferKind},
+    CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel,
+    Workspace,
+    item::{BreadcrumbText, Item, ItemBufferKind},
     notifications::NotifyTaskExt,
 };
 
@@ -3956,6 +3957,13 @@ impl EditorElement {
         let editor = self.editor.read(cx);
         let multi_buffer = editor.buffer.read(cx);
         let is_read_only = self.editor.read(cx).read_only(cx);
+        let weak_editor = self.editor.downgrade();
+
+        let breadcrumbs = if is_selected {
+            editor.breadcrumbs_inner(cx.theme(), cx)
+        } else {
+            None
+        };
 
         let file_status = multi_buffer
             .all_diff_hunks_expanded()
@@ -4160,6 +4168,15 @@ impl EditorElement {
                                             },
                                         ))
                                     })
+                                    .when_some(breadcrumbs, |then, breadcrumbs| {
+                                        then.child(self.render_breadcrumb_text(
+                                            breadcrumbs,
+                                            None, // TODO gotta figure this out somehow
+                                            weak_editor,
+                                            window,
+                                            cx,
+                                        ))
+                                    })
                             }))
                             .when(
                                 can_open_excerpts && is_selected && relative_path.is_some(),
@@ -4313,6 +4330,108 @@ impl EditorElement {
             })
     }
 
+    fn render_breadcrumb_text(
+        &self,
+        mut segments: Vec<BreadcrumbText>,
+        prefix: Option<gpui::AnyElement>,
+        editor: WeakEntity<Editor>,
+        window: &mut Window,
+        cx: &App,
+    ) -> impl IntoElement {
+        const MAX_SEGMENTS: usize = 12;
+
+        let element = h_flex()
+            .id("breadcrumb-container")
+            .flex_grow()
+            .overflow_x_scroll()
+            .text_ui(cx);
+
+        let prefix_end_ix = cmp::min(segments.len(), MAX_SEGMENTS / 2);
+        let suffix_start_ix = cmp::max(
+            prefix_end_ix,
+            segments.len().saturating_sub(MAX_SEGMENTS / 2),
+        );
+
+        if suffix_start_ix > prefix_end_ix {
+            segments.splice(
+                prefix_end_ix..suffix_start_ix,
+                Some(BreadcrumbText {
+                    text: "⋯".into(),
+                    highlights: None,
+                    font: None,
+                }),
+            );
+        }
+
+        let highlighted_segments = segments.into_iter().enumerate().map(|(_index, segment)| {
+            let mut text_style = window.text_style();
+            if let Some(ref font) = segment.font {
+                text_style.font_family = font.family.clone();
+                text_style.font_features = font.features.clone();
+                text_style.font_style = font.style;
+                text_style.font_weight = font.weight;
+            }
+            text_style.color = Color::Muted.color(cx);
+
+            // TODO this shouldn't apply here, but will in the formal breadcrumb (e.g. singleton buffer). Need to resolve the difference.
+            // if index == 0
+            //     && !TabBarSettings::get_global(cx).show
+            //     && active_item.is_dirty(cx)
+            //     && let Some(styled_element) = apply_dirty_filename_style(&segment, &text_style, cx)
+            // {
+            //     return styled_element;
+            // }
+
+            StyledText::new(segment.text.replace('\n', "⏎"))
+                .with_default_highlights(&text_style, segment.highlights.unwrap_or_default())
+                .into_any()
+        });
+        let breadcrumbs = Itertools::intersperse_with(highlighted_segments, || {
+            Label::new("›").color(Color::Placeholder).into_any_element()
+        });
+
+        let breadcrumbs_stack = h_flex().gap_1().children(breadcrumbs);
+
+        let breadcrumbs = if let Some(prefix) = prefix {
+            h_flex().gap_1p5().child(prefix).child(breadcrumbs_stack)
+        } else {
+            breadcrumbs_stack
+        };
+        element.child(
+            ButtonLike::new("toggle outline view")
+                .child(breadcrumbs)
+                .style(ButtonStyle::Transparent)
+                .on_click({
+                    let editor = editor.clone();
+                    move |_, window, cx| {
+                        if let Some((editor, callback)) = editor
+                            .upgrade()
+                            .zip(zed_actions::outline::TOGGLE_OUTLINE.get())
+                        {
+                            callback(editor.to_any_view(), window, cx);
+                        }
+                    }
+                })
+                .tooltip(move |_window, cx| {
+                    if let Some(editor) = editor.upgrade() {
+                        let focus_handle = editor.read(cx).focus_handle(cx);
+                        Tooltip::for_action_in(
+                            "Show Symbol Outline",
+                            &zed_actions::outline::ToggleOutline,
+                            &focus_handle,
+                            cx,
+                        )
+                    } else {
+                        Tooltip::for_action(
+                            "Show Symbol Outline",
+                            &zed_actions::outline::ToggleOutline,
+                            cx,
+                        )
+                    }
+                }),
+        )
+    }
+
     fn render_blocks(
         &self,
         rows: Range<DisplayRow>,

crates/editor/src/items.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     Anchor, Autoscroll, BufferSerialization, Editor, EditorEvent, EditorSettings, ExcerptId,
     ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot, NavigationData,
-    ReportEditorEvent, SearchWithinRange, SelectionEffects, ToPoint as _,
+    ReportEditorEvent, SearchWithinRange, SelectionEffects, ToPoint as _, ToggleFoldAll,
     display_map::HighlightKey,
     editor_settings::SeedQuerySetting,
     persistence::{DB, SerializedEditor},
@@ -39,7 +39,7 @@ use std::{
 };
 use text::{BufferId, BufferSnapshot, Selection};
 use theme::{Theme, ThemeSettings};
-use ui::{IconDecorationKind, prelude::*};
+use ui::{IconButtonShape, IconDecorationKind, Tooltip, prelude::*};
 use util::{ResultExt, TryFutureExt, paths::PathExt};
 use workspace::{
     CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
@@ -940,7 +940,50 @@ impl Item for Editor {
         self.pixel_position_of_newest_cursor
     }
 
-    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
+    fn breadcrumb_prefix(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<gpui::AnyElement> {
+        if self.buffer().read(cx).is_singleton() {
+            return None;
+        }
+
+        let is_collapsed = self.is_multibuffer_collapsed;
+
+        let (icon, label, tooltip_label) = if is_collapsed {
+            (
+                IconName::ChevronUpDown,
+                "Expand All",
+                "Expand All Search Results",
+            )
+        } else {
+            (
+                IconName::ChevronDownUp,
+                "Collapse All",
+                "Collapse All Search Results",
+            )
+        };
+
+        let focus_handle = self.focus_handle.clone();
+
+        Some(
+            Button::new("multibuffer-collapse-expand", label)
+                .icon(icon)
+                .icon_position(IconPosition::Start)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action_in(tooltip_label, &ToggleFoldAll, &focus_handle, cx)
+                })
+                .on_click(cx.listener(|this, _, window, cx| {
+                    this.is_multibuffer_collapsed = !this.is_multibuffer_collapsed;
+                    this.toggle_fold_all(&ToggleFoldAll, window, cx);
+                }))
+                .into_any_element(),
+        )
+    }
+
+    fn breadcrumb_location(&self, _cx: &App) -> ToolbarItemLocation {
         if self.show_breadcrumbs {
             ToolbarItemLocation::PrimaryLeft
         } else {
@@ -948,48 +991,14 @@ impl Item for Editor {
         }
     }
 
+    // In a non-singleton case, the breadcrumbs are actually shown on sticky file headers of the multibuffer.
+    // In those cases, the toolbar breadcrumbs should be an empty vector.
     fn breadcrumbs(&self, variant: &Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        let cursor = self.selections.newest_anchor().head();
-        let multibuffer = self.buffer().read(cx);
-        let (buffer_id, symbols) = multibuffer
-            .read(cx)
-            .symbols_containing(cursor, Some(variant.syntax()))?;
-        let buffer = multibuffer.buffer(buffer_id)?;
-
-        let buffer = buffer.read(cx);
-        let text = self.breadcrumb_header.clone().unwrap_or_else(|| {
-            buffer
-                .snapshot()
-                .resolve_file_path(
-                    self.project
-                        .as_ref()
-                        .map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
-                        .unwrap_or_default(),
-                    cx,
-                )
-                .unwrap_or_else(|| {
-                    if multibuffer.is_singleton() {
-                        multibuffer.title(cx).to_string()
-                    } else {
-                        "untitled".to_string()
-                    }
-                })
-        });
-
-        let settings = ThemeSettings::get_global(cx);
-
-        let mut breadcrumbs = vec![BreadcrumbText {
-            text,
-            highlights: None,
-            font: Some(settings.buffer_font.clone()),
-        }];
-
-        breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText {
-            text: symbol.text,
-            highlights: Some(symbol.highlight_ranges),
-            font: Some(settings.buffer_font.clone()),
-        }));
-        Some(breadcrumbs)
+        if self.buffer.read(cx).is_singleton() {
+            self.breadcrumbs_inner(variant, cx)
+        } else {
+            Some(vec![])
+        }
     }
 
     fn added_to_workspace(

crates/git_ui/src/project_diff.rs 🔗

@@ -9,7 +9,7 @@ use buffer_diff::{BufferDiff, DiffHunkSecondaryStatus};
 use collections::{HashMap, HashSet};
 use editor::{
     Addon, Editor, EditorEvent, SelectionEffects, SplittableEditor,
-    actions::{GoToHunk, GoToPreviousHunk},
+    actions::{GoToHunk, GoToPreviousHunk, ToggleFoldAll},
     multibuffer_context_lines,
     scroll::Autoscroll,
 };
@@ -70,6 +70,7 @@ pub struct ProjectDiff {
     workspace: WeakEntity<Workspace>,
     focus_handle: FocusHandle,
     pending_scroll: Option<PathKey>,
+    is_collapsed: bool,
     _task: Task<Result<()>>,
     _subscription: Subscription,
 }
@@ -329,6 +330,7 @@ impl ProjectDiff {
             focus_handle,
             editor,
             multibuffer,
+            is_collapsed: false,
             buffer_diff_subscriptions: Default::default(),
             pending_scroll: None,
             _task: task,
@@ -910,6 +912,49 @@ impl Item for ProjectDiff {
         }
     }
 
+    fn breadcrumb_prefix(
+        &self,
+        _window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<gpui::AnyElement> {
+        let is_collapsed = self.is_collapsed;
+
+        let (icon, label, tooltip_label) = if is_collapsed {
+            (
+                IconName::ChevronUpDown,
+                "Expand All",
+                "Expand All Search Results",
+            )
+        } else {
+            (
+                IconName::ChevronDownUp,
+                "Collapse All",
+                "Collapse All Search Results",
+            )
+        };
+
+        let focus_handle = self.editor.focus_handle(cx);
+
+        Some(
+            Button::new("multibuffer-collapse-expand", label)
+                .icon(icon)
+                .icon_position(IconPosition::Start)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::for_action_in(tooltip_label, &ToggleFoldAll, &focus_handle, cx)
+                })
+                .on_click(cx.listener(|this, _, window, cx| {
+                    this.is_collapsed = !this.is_collapsed;
+                    this.editor.update(cx, |splittable, cx| {
+                        splittable.last_selected_editor().update(cx, |editor, cx| {
+                            editor.toggle_fold_all(&ToggleFoldAll, window, cx);
+                        })
+                    })
+                }))
+                .into_any_element(),
+        )
+    }
+
     fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
         ToolbarItemLocation::PrimaryLeft
     }

crates/search/src/project_search.rs 🔗

@@ -667,8 +667,9 @@ impl Item for ProjectSearchView {
         }
     }
 
-    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        self.results_editor.breadcrumbs(theme, cx)
+    fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option<Vec<BreadcrumbText>> {
+        // This should only show the prefix - hence it cannot be none right now
+        Some(vec![])
     }
 
     fn breadcrumb_prefix(
@@ -682,17 +683,26 @@ impl Item for ProjectSearchView {
 
         let is_collapsed = self.results_collapsed;
 
-        let (icon, tooltip_label) = if is_collapsed {
-            (IconName::ChevronUpDown, "Expand All Search Results")
+        let (icon, label, tooltip_label) = if is_collapsed {
+            (
+                IconName::ChevronUpDown,
+                "Expand All",
+                "Expand All Search Results",
+            )
         } else {
-            (IconName::ChevronDownUp, "Collapse All Search Results")
+            (
+                IconName::ChevronDownUp,
+                "Collapse All",
+                "Collapse All Search Results",
+            )
         };
 
         let focus_handle = self.query_editor.focus_handle(cx);
 
         Some(
-            IconButton::new("project-search-collapse-expand", icon)
-                .shape(IconButtonShape::Square)
+            Button::new("project-search-collapse-expand", label)
+                .icon(icon)
+                .icon_position(IconPosition::Start)
                 .icon_size(IconSize::Small)
                 .tooltip(move |_, cx| {
                     Tooltip::for_action_in(