Multibuffer breadcrumbs toolbar redesign (#45547)

KyleBarton , Danilo Leal , and Zed Zippy created

Breadcrumb visual reorchestration which makes a couple of key changes:
- Breadcrumbs are now displayed in file headers for multibuffer views.
Singleton buffers are unchanged.
- Multibuffer views now have collapse/expand all buttons available in
the toolbar
- Search in multibuffer views now takes up less space, by occupying the
primary toolbar location erstwhile taken up by breadcrumbs

<img width="1721" height="823" alt="Screenshot 2025-12-22 at 4 15 41 PM"
src="https://github.com/user-attachments/assets/38e924f7-fe9e-4c83-84fb-2eee98137f43"
/>

<img width="1722" height="762" alt="Screenshot 2025-12-22 at 4 15 51 PM"
src="https://github.com/user-attachments/assets/ccc98ca3-75ce-4aca-bceb-890817b0f04d"
/>


Release Notes:

- Moved breadcrumbs to file headers in multibuffer views
- Added collapse/expand all options for multibuffer views
- Improved use of space for multibuffer search by taking the place of
the former breadcrumb toolbar location

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

Cargo.lock                                   |   5 
assets/keymaps/default-linux.json            |   5 
assets/keymaps/default-macos.json            |   5 
assets/keymaps/default-windows.json          |   3 
crates/agent_ui/src/agent_diff.rs            |  10 
crates/breadcrumbs/Cargo.toml                |   3 
crates/breadcrumbs/src/breadcrumbs.rs        | 171 -------
crates/diagnostics/Cargo.toml                |   4 
crates/diagnostics/src/buffer_diagnostics.rs |  18 
crates/diagnostics/src/diagnostics.rs        |  18 
crates/diagnostics/src/toolbar_controls.rs   |  52 +
crates/editor/src/editor.rs                  |  88 +++-
crates/editor/src/element.rs                 | 322 +++++++++++++--
crates/editor/src/items.rs                   |  53 --
crates/git_ui/src/commit_view.rs             |  10 
crates/git_ui/src/project_diff.rs            |  14 
crates/git_ui/src/text_diff_view.rs          |  12 
crates/search/src/buffer_search.rs           | 430 +++++++++++++++++++--
crates/search/src/project_search.rs          | 159 ++++---
crates/search/src/search_bar.rs              |  21 +
crates/workspace/src/item.rs                 |   1 
crates/workspace/src/searchable.rs           |   7 
crates/workspace/src/toolbar.rs              |  21 
23 files changed, 918 insertions(+), 514 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2334,12 +2334,9 @@ version = "0.1.0"
 dependencies = [
  "editor",
  "gpui",
- "itertools 0.14.0",
- "settings",
  "theme",
  "ui",
  "workspace",
- "zed_actions",
 ]
 
 [[package]]
@@ -4887,6 +4884,7 @@ dependencies = [
  "pretty_assertions",
  "project",
  "rand 0.9.2",
+ "search",
  "serde",
  "serde_json",
  "settings",
@@ -4896,6 +4894,7 @@ dependencies = [
  "unindent",
  "util",
  "workspace",
+ "zed_actions",
  "zlog",
 ]
 

assets/keymaps/default-linux.json 🔗

@@ -360,6 +360,7 @@
       "tab": "buffer_search::FocusEditor",
       "enter": "search::SelectNextMatch",
       "shift-enter": "search::SelectPreviousMatch",
+      "ctrl-shift-enter": "editor::ToggleFoldAll",
       "alt-enter": "search::SelectAllMatches",
       "find": "search::FocusSearch",
       "ctrl-f": "search::FocusSearch",
@@ -386,7 +387,7 @@
     "bindings": {
       "escape": "project_search::ToggleFocus",
       "shift-find": "search::FocusSearch",
-      "shift-enter": "project_search::ToggleAllSearchResults",
+      "ctrl-shift-enter": "project_search::ToggleAllSearchResults",
       "ctrl-shift-f": "search::FocusSearch",
       "ctrl-shift-h": "search::ToggleReplace",
       "alt-ctrl-g": "search::ToggleRegex",
@@ -459,7 +460,7 @@
       "alt-w": "search::ToggleWholeWord",
       "alt-find": "project_search::ToggleFilters",
       "alt-ctrl-f": "project_search::ToggleFilters",
-      "shift-enter": "project_search::ToggleAllSearchResults",
+      "ctrl-shift-enter": "project_search::ToggleAllSearchResults",
       "ctrl-alt-shift-r": "search::ToggleRegex",
       "ctrl-alt-shift-x": "search::ToggleRegex",
       "alt-r": "search::ToggleRegex",

assets/keymaps/default-macos.json 🔗

@@ -415,6 +415,7 @@
       "tab": "buffer_search::FocusEditor",
       "enter": "search::SelectNextMatch",
       "shift-enter": "search::SelectPreviousMatch",
+      "cmd-shift-enter": "editor::ToggleFoldAll",
       "alt-enter": "search::SelectAllMatches",
       "cmd-f": "search::FocusSearch",
       "cmd-alt-f": "search::ToggleReplace",
@@ -444,7 +445,7 @@
     "bindings": {
       "escape": "project_search::ToggleFocus",
       "cmd-shift-j": "project_search::ToggleFilters",
-      "shift-enter": "project_search::ToggleAllSearchResults",
+      "cmd-shift-enter": "project_search::ToggleAllSearchResults",
       "cmd-shift-f": "search::FocusSearch",
       "cmd-shift-h": "search::ToggleReplace",
       "alt-cmd-g": "search::ToggleRegex",
@@ -473,7 +474,7 @@
     "bindings": {
       "escape": "project_search::ToggleFocus",
       "cmd-shift-j": "project_search::ToggleFilters",
-      "shift-enter": "project_search::ToggleAllSearchResults",
+      "cmd-shift-enter": "project_search::ToggleAllSearchResults",
       "cmd-shift-h": "search::ToggleReplace",
       "alt-cmd-g": "search::ToggleRegex",
       "alt-cmd-x": "search::ToggleRegex",

assets/keymaps/default-windows.json 🔗

@@ -365,6 +365,7 @@
       "tab": "buffer_search::FocusEditor",
       "enter": "search::SelectNextMatch",
       "shift-enter": "search::SelectPreviousMatch",
+      "ctrl-shift-enter": "editor::ToggleFoldAll",
       "alt-enter": "search::SelectAllMatches",
       "ctrl-f": "search::FocusSearch",
       "ctrl-h": "search::ToggleReplace",
@@ -464,7 +465,7 @@
       "alt-c": "search::ToggleCaseSensitive",
       "alt-w": "search::ToggleWholeWord",
       "alt-f": "project_search::ToggleFilters",
-      "shift-enter": "project_search::ToggleAllSearchResults",
+      "ctrl-shift-enter": "project_search::ToggleAllSearchResults",
       "alt-r": "search::ToggleRegex",
       // "ctrl-shift-alt-x": "search::ToggleRegex",
       "ctrl-k shift-enter": "pane::TogglePinTab",

crates/agent_ui/src/agent_diff.rs 🔗

@@ -32,7 +32,7 @@ use util::ResultExt;
 use workspace::{
     Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
     Workspace,
-    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+    item::{ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 use zed_actions::assistant::ToggleFocus;
@@ -595,14 +595,6 @@ impl Item for AgentDiffPane {
         }
     }
 
-    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 added_to_workspace(
         &mut self,
         workspace: &mut Workspace,

crates/breadcrumbs/Cargo.toml 🔗

@@ -15,12 +15,9 @@ doctest = false
 [dependencies]
 editor.workspace = true
 gpui.workspace = true
-itertools.workspace = true
-settings.workspace = true
 theme.workspace = true
 ui.workspace = true
 workspace.workspace = true
-zed_actions.workspace = true
 
 [dev-dependencies]
 editor = { workspace = true, features = ["test-support"] }

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -1,16 +1,10 @@
-use editor::Editor;
-use gpui::{
-    Context, Element, EventEmitter, Focusable, FontWeight, IntoElement, ParentElement, Render,
-    StyledText, Subscription, Window,
-};
-use itertools::Itertools;
-use settings::Settings;
-use std::cmp;
+use editor::render_breadcrumb_text;
+use gpui::{Context, EventEmitter, IntoElement, Render, Subscription, Window};
 use theme::ActiveTheme;
-use ui::{ButtonLike, ButtonStyle, Label, Tooltip, prelude::*};
+use ui::prelude::*;
 use workspace::{
-    TabBarSettings, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
-    item::{BreadcrumbText, ItemEvent, ItemHandle},
+    ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
+    item::{ItemEvent, ItemHandle},
 };
 
 pub struct Breadcrumbs {
@@ -39,118 +33,32 @@ impl EventEmitter<ToolbarItemEvent> for Breadcrumbs {}
 
 impl Render for Breadcrumbs {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        const MAX_SEGMENTS: usize = 12;
-
         let element = h_flex()
             .id("breadcrumb-container")
             .flex_grow()
+            .h_8()
             .overflow_x_scroll()
             .text_ui(cx);
 
         let Some(active_item) = self.active_item.as_ref() else {
-            return element;
+            return element.into_any_element();
         };
 
-        let Some(mut segments) = active_item.breadcrumbs(cx.theme(), cx) else {
-            return element;
+        let Some(segments) = active_item.breadcrumbs(cx.theme(), cx) else {
+            return element.into_any_element();
         };
 
-        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);
-
-            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 prefix_element = active_item.breadcrumb_prefix(window, cx);
 
-        let breadcrumbs = if let Some(prefix) = prefix_element {
-            h_flex().gap_1p5().child(prefix).child(breadcrumbs_stack)
-        } else {
-            breadcrumbs_stack
-        };
-
-        match active_item
-            .downcast::<Editor>()
-            .map(|editor| editor.downgrade())
-        {
-            Some(editor) => 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,
-                            )
-                        }
-                    }),
-            ),
-            None => element
-                // Match the height and padding of the `ButtonLike` in the other arm.
-                .h(rems_from_px(22.))
-                .pl_1()
-                .child(breadcrumbs),
-        }
+        render_breadcrumb_text(
+            segments,
+            prefix_element,
+            active_item.as_ref(),
+            false,
+            window,
+            cx,
+        )
+        .into_any_element()
     }
 }
 
@@ -199,46 +107,3 @@ impl ToolbarItemView for Breadcrumbs {
         self.pane_focused = pane_focused;
     }
 }
-
-fn apply_dirty_filename_style(
-    segment: &BreadcrumbText,
-    text_style: &gpui::TextStyle,
-    cx: &mut Context<Breadcrumbs>,
-) -> Option<gpui::AnyElement> {
-    let text = segment.text.replace('\n', "⏎");
-
-    let filename_position = std::path::Path::new(&segment.text)
-        .file_name()
-        .and_then(|f| {
-            let filename_str = f.to_string_lossy();
-            segment.text.rfind(filename_str.as_ref())
-        })?;
-
-    let bold_weight = FontWeight::BOLD;
-    let default_color = Color::Default.color(cx);
-
-    if filename_position == 0 {
-        let mut filename_style = text_style.clone();
-        filename_style.font_weight = bold_weight;
-        filename_style.color = default_color;
-
-        return Some(
-            StyledText::new(text)
-                .with_default_highlights(&filename_style, [])
-                .into_any(),
-        );
-    }
-
-    let highlight_style = gpui::HighlightStyle {
-        font_weight: Some(bold_weight),
-        color: Some(default_color),
-        ..Default::default()
-    };
-
-    let highlight = vec![(filename_position..text.len(), highlight_style)];
-    Some(
-        StyledText::new(text)
-            .with_default_highlights(text_style, highlight)
-            .into_any(),
-    )
-}

crates/diagnostics/Cargo.toml 🔗

@@ -20,12 +20,14 @@ ctor.workspace = true
 editor.workspace = true
 gpui.workspace = true
 indoc.workspace = true
+itertools.workspace = true
 language.workspace = true
 log.workspace = true
 lsp.workspace = true
 markdown.workspace = true
 project.workspace = true
 rand.workspace = true
+search.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true
@@ -34,7 +36,7 @@ theme.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
-itertools.workspace = true
+zed_actions.workspace = true
 
 [dev-dependencies]
 client = { workspace = true, features = ["test-support"] }

crates/diagnostics/src/buffer_diagnostics.rs 🔗

@@ -6,7 +6,7 @@ use crate::{
 use anyhow::Result;
 use collections::HashMap;
 use editor::{
-    Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey,
+    Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
     display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
     multibuffer_context_lines,
 };
@@ -29,8 +29,8 @@ use std::{
 use text::{Anchor, BufferSnapshot, OffsetRangeExt};
 use ui::{Button, ButtonStyle, Icon, IconName, Label, Tooltip, h_flex, prelude::*};
 use workspace::{
-    ItemHandle, ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, Item, ItemEvent, TabContentParams},
+    ItemHandle, ItemNavHistory, Workspace,
+    item::{Item, ItemEvent, TabContentParams},
 };
 
 actions!(
@@ -701,18 +701,6 @@ impl Item for BufferDiagnosticsEditor {
         });
     }
 
-    fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
-        if EditorSettings::get_global(cx).toolbar.breadcrumbs {
-            ToolbarItemLocation::PrimaryLeft
-        } else {
-            ToolbarItemLocation::Hidden
-        }
-    }
-
-    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        self.editor.breadcrumbs(theme, cx)
-    }
-
     fn can_save(&self, _cx: &App) -> bool {
         true
     }

crates/diagnostics/src/diagnostics.rs 🔗

@@ -12,7 +12,7 @@ use buffer_diagnostics::BufferDiagnosticsEditor;
 use collections::{BTreeSet, HashMap, HashSet};
 use diagnostic_renderer::DiagnosticBlock;
 use editor::{
-    Editor, EditorEvent, EditorSettings, ExcerptRange, MultiBuffer, PathKey,
+    Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
     display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
     multibuffer_context_lines,
 };
@@ -45,8 +45,8 @@ pub use toolbar_controls::ToolbarControls;
 use ui::{Icon, IconName, Label, h_flex, prelude::*};
 use util::ResultExt;
 use workspace::{
-    ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
+    ItemNavHistory, Workspace,
+    item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -894,18 +894,6 @@ impl Item for ProjectDiagnosticsEditor {
         Some(Box::new(self.editor.clone()))
     }
 
-    fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
-        if EditorSettings::get_global(cx).toolbar.breadcrumbs {
-            ToolbarItemLocation::PrimaryLeft
-        } else {
-            ToolbarItemLocation::Hidden
-        }
-    }
-
-    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        self.editor.breadcrumbs(theme, cx)
-    }
-
     fn added_to_workspace(
         &mut self,
         workspace: &mut Workspace,

crates/diagnostics/src/toolbar_controls.rs 🔗

@@ -1,10 +1,11 @@
 use crate::{BufferDiagnosticsEditor, ProjectDiagnosticsEditor, ToggleDiagnosticsRefresh};
 use gpui::{Context, EventEmitter, ParentElement, Render, Window};
 use language::DiagnosticEntry;
+use search::buffer_search;
 use text::{Anchor, BufferId};
-use ui::prelude::*;
-use ui::{IconButton, IconButtonShape, IconName, Tooltip};
+use ui::{Tooltip, prelude::*};
 use workspace::{ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, item::ItemHandle};
+use zed_actions::assistant::InlineAssist;
 
 pub struct ToolbarControls {
     editor: Option<Box<dyn DiagnosticsToolbarEditor>>,
@@ -45,28 +46,44 @@ impl Render for ToolbarControls {
             None => {}
         }
 
-        let warning_tooltip = if include_warnings {
-            "Exclude Warnings"
+        let (warning_tooltip, warning_color) = if include_warnings {
+            ("Exclude Warnings", Color::Warning)
         } else {
-            "Include Warnings"
-        };
-
-        let warning_color = if include_warnings {
-            Color::Warning
-        } else {
-            Color::Muted
+            ("Include Warnings", Color::Disabled)
         };
 
         h_flex()
             .gap_1()
+            .child({
+                IconButton::new("toggle_search", IconName::MagnifyingGlass)
+                    .icon_size(IconSize::Small)
+                    .tooltip(Tooltip::for_action_title(
+                        "Buffer Search",
+                        &buffer_search::Deploy::find(),
+                    ))
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(Box::new(buffer_search::Deploy::find()), cx);
+                    })
+            })
+            .child({
+                IconButton::new("inline_assist", IconName::ZedAssistant)
+                    .icon_size(IconSize::Small)
+                    .tooltip(Tooltip::for_action_title(
+                        "Inline Assist",
+                        &InlineAssist::default(),
+                    ))
+                    .on_click(|_, window, cx| {
+                        window.dispatch_action(Box::new(InlineAssist::default()), cx);
+                    })
+            })
             .map(|div| {
                 if is_updating {
                     div.child(
                         IconButton::new("stop-updating", IconName::Stop)
-                            .icon_color(Color::Info)
-                            .shape(IconButtonShape::Square)
+                            .icon_color(Color::Error)
+                            .icon_size(IconSize::Small)
                             .tooltip(Tooltip::for_action_title(
-                                "Stop diagnostics update",
+                                "Stop Siagnostics Update",
                                 &ToggleDiagnosticsRefresh,
                             ))
                             .on_click(cx.listener(move |toolbar_controls, _, _, cx| {
@@ -79,10 +96,9 @@ impl Render for ToolbarControls {
                 } else {
                     div.child(
                         IconButton::new("refresh-diagnostics", IconName::ArrowCircle)
-                            .icon_color(Color::Info)
-                            .shape(IconButtonShape::Square)
+                            .icon_size(IconSize::Small)
                             .tooltip(Tooltip::for_action_title(
-                                "Refresh diagnostics",
+                                "Refresh Diagnostics",
                                 &ToggleDiagnosticsRefresh,
                             ))
                             .on_click(cx.listener({
@@ -98,7 +114,7 @@ impl Render for ToolbarControls {
             .child(
                 IconButton::new("toggle-warnings", IconName::Warning)
                     .icon_color(warning_color)
-                    .shape(IconButtonShape::Square)
+                    .icon_size(IconSize::Small)
                     .tooltip(Tooltip::text(warning_tooltip))
                     .on_click(cx.listener(|this, _, window, cx| {
                         if let Some(editor) = &this.editor {

crates/editor/src/editor.rs 🔗

@@ -58,6 +58,7 @@ pub use editor_settings::{
 };
 pub use element::{
     CursorLayout, EditorElement, HighlightedRange, HighlightedRangeLine, PointForPosition,
+    render_breadcrumb_text,
 };
 pub use git::blame::BlameRenderer;
 pub use hover_popover::hover_markdown_style;
@@ -204,9 +205,9 @@ 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,
+    searchable::{CollapseDirection, SearchEvent},
 };
 
 use crate::{
@@ -19257,37 +19258,25 @@ impl Editor {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        if self.buffer.read(cx).is_singleton() {
+        let has_folds = if self.buffer.read(cx).is_singleton() {
             let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
             let has_folds = display_map
                 .folds_in_range(MultiBufferOffset(0)..display_map.buffer_snapshot().len())
                 .next()
                 .is_some();
-
-            if has_folds {
-                self.unfold_all(&actions::UnfoldAll, window, cx);
-            } else {
-                self.fold_all(&actions::FoldAll, window, cx);
-            }
+            has_folds
         } else {
             let buffer_ids = self.buffer.read(cx).excerpt_buffer_ids();
-            let should_unfold = buffer_ids
+            let has_folds = buffer_ids
                 .iter()
                 .any(|buffer_id| self.is_buffer_folded(*buffer_id, cx));
+            has_folds
+        };
 
-            self.toggle_fold_multiple_buffers = cx.spawn_in(window, async move |editor, cx| {
-                editor
-                    .update_in(cx, |editor, _, cx| {
-                        for buffer_id in buffer_ids {
-                            if should_unfold {
-                                editor.unfold_buffer(buffer_id, cx);
-                            } else {
-                                editor.fold_buffer(buffer_id, cx);
-                            }
-                        }
-                    })
-                    .ok();
-            });
+        if has_folds {
+            self.unfold_all(&actions::UnfoldAll, window, cx);
+        } else {
+            self.fold_all(&actions::FoldAll, window, cx);
         }
     }
 
@@ -19452,6 +19441,9 @@ impl Editor {
                     .ok();
             });
         }
+        cx.emit(SearchEvent::ResultsCollapsedChanged(
+            CollapseDirection::Collapsed,
+        ));
     }
 
     pub fn fold_function_bodies(
@@ -19640,6 +19632,9 @@ impl Editor {
                     .ok();
             });
         }
+        cx.emit(SearchEvent::ResultsCollapsedChanged(
+            CollapseDirection::Expanded,
+        ));
     }
 
     pub fn fold_selected_ranges(
@@ -23330,6 +23325,53 @@ impl Editor {
             show_underlines: self.diagnostics_enabled(),
         }
     }
+    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 🔗

@@ -41,14 +41,14 @@ use git::{Oid, blame::BlameEntry, commit::ParsedCommitMessage, status::FileStatu
 use gpui::{
     Action, Along, AnyElement, App, AppContext, AvailableSpace, Axis as ScrollbarAxis, BorderStyle,
     Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle,
-    DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId,
+    DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, FontWeight,
     GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero,
     KeybindingKeystroke, Length, Modifiers, ModifiersChangedEvent, MouseButton, MouseClickEvent,
     MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, PaintQuad, ParentElement,
     Pixels, PressureStage, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString,
-    Size, StatefulInteractiveElement, Style, Styled, TextAlign, 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, TextAlign, 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};
@@ -94,8 +94,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,
 };
 
@@ -3878,6 +3879,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 editor_handle: &dyn ItemHandle = &self.editor;
+
+        let breadcrumbs = if is_selected {
+            editor.breadcrumbs_inner(cx.theme(), cx)
+        } else {
+            None
+        };
 
         let file_status = multi_buffer
             .all_diff_hunks_expanded()
@@ -4035,57 +4043,103 @@ impl EditorElement {
                             .id("path_header_block")
                             .min_w_0()
                             .size_full()
+                            .gap_1()
                             .justify_between()
                             .overflow_hidden()
-                            .child(h_flex().min_w_0().flex_1().gap_0p5().map(|path_header| {
-                                let filename = filename
-                                    .map(SharedString::from)
-                                    .unwrap_or_else(|| "untitled".into());
-
-                                path_header
-                                    .when(ItemSettings::get_global(cx).file_icons, |el| {
-                                        let path = path::Path::new(filename.as_str());
-                                        let icon =
-                                            FileIcons::get_icon(path, cx).unwrap_or_default();
-
-                                        el.child(Icon::from_path(icon).color(Color::Muted))
-                                    })
-                                    .child(
-                                        ButtonLike::new("filename-button")
-                                            .child(
-                                                Label::new(filename)
-                                                    .single_line()
-                                                    .color(file_status_label_color(file_status))
-                                                    .when(
-                                                        file_status.is_some_and(|s| s.is_deleted()),
-                                                        |label| label.strikethrough(),
+                            .child(h_flex().min_w_0().flex_1().gap_0p5().overflow_hidden().map(
+                                |path_header| {
+                                    let filename = filename
+                                        .map(SharedString::from)
+                                        .unwrap_or_else(|| "untitled".into());
+
+                                    let full_path = match parent_path.as_deref() {
+                                        Some(parent) if !parent.is_empty() => {
+                                            format!("{}{}", parent, filename.as_str())
+                                        }
+                                        _ => filename.as_str().to_string(),
+                                    };
+
+                                    path_header
+                                        .child(
+                                            ButtonLike::new("filename-button")
+                                                .when(
+                                                    ItemSettings::get_global(cx).file_icons,
+                                                    |this| {
+                                                        let path =
+                                                            path::Path::new(filename.as_str());
+                                                        let icon = FileIcons::get_icon(path, cx)
+                                                            .unwrap_or_default();
+
+                                                        this.child(
+                                                            Icon::from_path(icon)
+                                                                .color(Color::Muted),
+                                                        )
+                                                    },
+                                                )
+                                                .child(
+                                                    Label::new(filename)
+                                                        .single_line()
+                                                        .color(file_status_label_color(file_status))
+                                                        .buffer_font(cx)
+                                                        .when(
+                                                            file_status
+                                                                .is_some_and(|s| s.is_deleted()),
+                                                            |label| label.strikethrough(),
+                                                        ),
+                                                )
+                                                .tooltip(move |_, cx| {
+                                                    Tooltip::with_meta(
+                                                        "Open File",
+                                                        None,
+                                                        full_path.clone(),
+                                                        cx,
+                                                    )
+                                                })
+                                                .on_click(window.listener_for(&self.editor, {
+                                                    let jump_data = jump_data.clone();
+                                                    move |editor, e: &ClickEvent, window, cx| {
+                                                        editor.open_excerpts_common(
+                                                            Some(jump_data.clone()),
+                                                            e.modifiers().secondary(),
+                                                            window,
+                                                            cx,
+                                                        );
+                                                    }
+                                                })),
+                                        )
+                                        .when_some(parent_path, |then, path| {
+                                            then.child(
+                                                Label::new(path)
+                                                    .buffer_font(cx)
+                                                    .truncate_start()
+                                                    .color(
+                                                        if file_status
+                                                            .is_some_and(FileStatus::is_deleted)
+                                                        {
+                                                            Color::Custom(colors.text_disabled)
+                                                        } else {
+                                                            Color::Custom(colors.text_muted)
+                                                        },
                                                     ),
                                             )
-                                            .on_click(window.listener_for(&self.editor, {
-                                                let jump_data = jump_data.clone();
-                                                move |editor, e: &ClickEvent, window, cx| {
-                                                    editor.open_excerpts_common(
-                                                        Some(jump_data.clone()),
-                                                        e.modifiers().secondary(),
-                                                        window,
-                                                        cx,
-                                                    );
-                                                }
-                                            })),
-                                    )
-                                    .when(!for_excerpt.buffer.capability.editable(), |el| {
-                                        el.child(Icon::new(IconName::FileLock).color(Color::Muted))
-                                    })
-                                    .when_some(parent_path, |then, path| {
-                                        then.child(Label::new(path).truncate().color(
-                                            if file_status.is_some_and(FileStatus::is_deleted) {
-                                                Color::Custom(colors.text_disabled)
-                                            } else {
-                                                Color::Custom(colors.text_muted)
-                                            },
-                                        ))
-                                    })
-                            }))
+                                        })
+                                        .when(!for_excerpt.buffer.capability.editable(), |el| {
+                                            el.child(
+                                                Icon::new(IconName::FileLock).color(Color::Muted),
+                                            )
+                                        })
+                                        .when_some(breadcrumbs, |then, breadcrumbs| {
+                                            then.child(render_breadcrumb_text(
+                                                breadcrumbs,
+                                                None,
+                                                editor_handle,
+                                                true,
+                                                window,
+                                                cx,
+                                            ))
+                                        })
+                                },
+                            ))
                             .when(
                                 can_open_excerpts && is_selected && relative_path.is_some(),
                                 |el| {
@@ -7875,6 +7929,168 @@ impl EditorElement {
     }
 }
 
+pub fn render_breadcrumb_text(
+    mut segments: Vec<BreadcrumbText>,
+    prefix: Option<gpui::AnyElement>,
+    active_item: &dyn ItemHandle,
+    multibuffer_header: bool,
+    window: &mut Window,
+    cx: &App,
+) -> impl IntoElement {
+    const MAX_SEGMENTS: usize = 12;
+
+    let element = h_flex().flex_grow().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);
+
+        if index == 0
+            && !workspace::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()
+        .when(multibuffer_header, |this| {
+            this.pl_2()
+                .border_l_1()
+                .border_color(cx.theme().colors().border.opacity(0.6))
+        })
+        .children(breadcrumbs);
+
+    let breadcrumbs = if let Some(prefix) = prefix {
+        h_flex().gap_1p5().child(prefix).child(breadcrumbs_stack)
+    } else {
+        breadcrumbs_stack
+    };
+
+    let editor = active_item
+        .downcast::<Editor>()
+        .map(|editor| editor.downgrade());
+
+    match editor {
+        Some(editor) => element
+            .id("breadcrumb_container")
+            .when(!multibuffer_header, |this| this.overflow_x_scroll())
+            .child(
+                ButtonLike::new("toggle outline view")
+                    .child(breadcrumbs)
+                    .when(multibuffer_header, |this| {
+                        this.style(ButtonStyle::Transparent)
+                    })
+                    .when(!multibuffer_header, |this| {
+                        let focus_handle = editor.upgrade().unwrap().focus_handle(&cx);
+
+                        this.tooltip(move |_window, cx| {
+                            Tooltip::for_action_in(
+                                "Show Symbol Outline",
+                                &zed_actions::outline::ToggleOutline,
+                                &focus_handle,
+                                cx,
+                            )
+                        })
+                        .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);
+                                }
+                            }
+                        })
+                    }),
+            )
+            .into_any_element(),
+        None => element
+            // Match the height and padding of the `ButtonLike` in the other arm.
+            .h(rems_from_px(22.))
+            .pl_1()
+            .child(breadcrumbs)
+            .into_any_element(),
+    }
+}
+
+fn apply_dirty_filename_style(
+    segment: &BreadcrumbText,
+    text_style: &gpui::TextStyle,
+    cx: &App,
+) -> Option<gpui::AnyElement> {
+    let text = segment.text.replace('\n', "⏎");
+
+    let filename_position = std::path::Path::new(&segment.text)
+        .file_name()
+        .and_then(|f| {
+            let filename_str = f.to_string_lossy();
+            segment.text.rfind(filename_str.as_ref())
+        })?;
+
+    let bold_weight = FontWeight::BOLD;
+    let default_color = Color::Default.color(cx);
+
+    if filename_position == 0 {
+        let mut filename_style = text_style.clone();
+        filename_style.font_weight = bold_weight;
+        filename_style.color = default_color;
+
+        return Some(
+            StyledText::new(text)
+                .with_default_highlights(&filename_style, [])
+                .into_any(),
+        );
+    }
+
+    let highlight_style = gpui::HighlightStyle {
+        font_weight: Some(bold_weight),
+        color: Some(default_color),
+        ..Default::default()
+    };
+
+    let highlight = vec![(filename_position..text.len(), highlight_style)];
+    Some(
+        StyledText::new(text)
+            .with_default_highlights(text_style, highlight)
+            .into_any(),
+    )
+}
+
 fn file_status_label_color(file_status: Option<FileStatus>) -> Color {
     file_status.map_or(Color::Default, |status| {
         if status.is_conflicted() {

crates/editor/src/items.rs 🔗

@@ -38,7 +38,7 @@ use std::{
     sync::Arc,
 };
 use text::{BufferId, BufferSnapshot, Selection};
-use theme::{Theme, ThemeSettings};
+use theme::Theme;
 use ui::{IconDecorationKind, prelude::*};
 use util::{ResultExt, TryFutureExt, paths::PathExt};
 use workspace::{
@@ -963,56 +963,21 @@ impl Item for Editor {
         self.pixel_position_of_newest_cursor
     }
 
-    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
-        if self.show_breadcrumbs {
+    fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
+        if self.show_breadcrumbs && self.buffer().read(cx).is_singleton() {
             ToolbarItemLocation::PrimaryLeft
         } else {
             ToolbarItemLocation::Hidden
         }
     }
 
+    // In a non-singleton case, the breadcrumbs are actually shown on sticky file headers of the multibuffer.
     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 {
+            None
+        }
     }
 
     fn added_to_workspace(

crates/git_ui/src/commit_view.rs 🔗

@@ -30,7 +30,7 @@ use workspace::item::TabTooltipContent;
 use workspace::{
     Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
     Workspace,
-    item::{BreadcrumbText, ItemEvent, TabContentParams},
+    item::{ItemEvent, TabContentParams},
     notifications::NotifyTaskExt,
     pane::SaveIntent,
     searchable::SearchableItemHandle,
@@ -925,14 +925,6 @@ impl Item for CommitView {
             .update(cx, |editor, cx| editor.navigate(data, window, cx))
     }
 
-    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
-        ToolbarItemLocation::Hidden
-    }
-
-    fn breadcrumbs(&self, _theme: &theme::Theme, _cx: &App) -> Option<Vec<BreadcrumbText>> {
-        None
-    }
-
     fn added_to_workspace(
         &mut self,
         workspace: &mut Workspace,

crates/git_ui/src/project_diff.rs 🔗

@@ -41,7 +41,7 @@ use util::{ResultExt as _, rel_path::RelPath};
 use workspace::{
     CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
     ToolbarItemView, Workspace,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
+    item::{Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
     notifications::NotifyTaskExt,
     searchable::SearchableItemHandle,
 };
@@ -912,18 +912,6 @@ impl Item for ProjectDiff {
         }
     }
 
-    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
-        ToolbarItemLocation::PrimaryLeft
-    }
-
-    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        self.editor
-            .read(cx)
-            .last_selected_editor()
-            .read(cx)
-            .breadcrumbs(theme, cx)
-    }
-
     fn added_to_workspace(
         &mut self,
         workspace: &mut Workspace,

crates/git_ui/src/text_diff_view.rs 🔗

@@ -22,8 +22,8 @@ use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
 use util::paths::PathExt;
 
 use workspace::{
-    Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
+    Item, ItemHandle as _, ItemNavHistory, Workspace,
+    item::{ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -375,14 +375,6 @@ impl Item for TextDiffView {
             .update(cx, |editor, cx| editor.navigate(data, window, cx))
     }
 
-    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
-        ToolbarItemLocation::PrimaryLeft
-    }
-
-    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        self.diff_editor.breadcrumbs(theme, cx)
-    }
-
     fn added_to_workspace(
         &mut self,
         workspace: &mut Workspace,

crates/search/src/buffer_search.rs 🔗

@@ -5,13 +5,16 @@ use crate::{
     SearchOptions, SearchSource, SelectAllMatches, SelectNextMatch, SelectPreviousMatch,
     ToggleCaseSensitive, ToggleRegex, ToggleReplace, ToggleSelection, ToggleWholeWord,
     buffer_search::registrar::WithResultsOrExternalQuery,
-    search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
+    search_bar::{
+        ActionButtonState, alignment_element, filter_search_results_input, input_base_styles,
+        render_action_button, render_text_input,
+    },
 };
 use any_vec::AnyVec;
 use collections::HashMap;
 use editor::{
     DisplayPoint, Editor, EditorSettings, MultiBufferOffset,
-    actions::{Backtab, Tab},
+    actions::{Backtab, FoldAll, Tab, ToggleFoldAll, UnfoldAll},
 };
 use futures::channel::oneshot;
 use gpui::{
@@ -27,19 +30,17 @@ use project::{
 use schemars::JsonSchema;
 use serde::Deserialize;
 use settings::Settings;
-use std::sync::Arc;
+use std::{any::TypeId, sync::Arc};
 use zed_actions::{outline::ToggleOutline, workspace::CopyPath, workspace::CopyRelativePath};
 
-use ui::{
-    BASE_REM_SIZE_IN_PX, IconButton, IconButtonShape, IconName, Tooltip, h_flex, prelude::*,
-    utils::SearchInputWidth,
-};
+use ui::{BASE_REM_SIZE_IN_PX, IconButtonShape, Tooltip, prelude::*, utils::SearchInputWidth};
 use util::{ResultExt, paths::PathMatcher};
 use workspace::{
     ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
-    item::ItemHandle,
+    item::{ItemBufferKind, ItemHandle},
     searchable::{
-        Direction, FilteredSearchRange, SearchEvent, SearchableItemHandle, WeakSearchableItemHandle,
+        CollapseDirection, Direction, FilteredSearchRange, SearchEvent, SearchableItemHandle,
+        WeakSearchableItemHandle,
     },
 };
 
@@ -129,18 +130,72 @@ pub struct BufferSearchBar {
     editor_scroll_handle: ScrollHandle,
     editor_needed_width: Pixels,
     regex_language: Option<Arc<Language>>,
+    is_collapsed: bool,
 }
 
 impl EventEmitter<Event> for BufferSearchBar {}
 impl EventEmitter<workspace::ToolbarItemEvent> for BufferSearchBar {}
 impl Render for BufferSearchBar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        if self.dismissed {
-            return div().id("search_bar");
-        }
-
         let focus_handle = self.focus_handle(cx);
 
+        let collapse_expand_button = if self.needs_expand_collapse_option(cx) {
+            let query_editor_focus = self.query_editor.focus_handle(cx);
+
+            let (icon, label, tooltip_label) = if self.is_collapsed {
+                (
+                    IconName::ChevronUpDown,
+                    "Expand All",
+                    "Expand All Search Results",
+                )
+            } else {
+                (
+                    IconName::ChevronDownUp,
+                    "Collapse All",
+                    "Collapse All Search Results",
+                )
+            };
+
+            if self.dismissed {
+                let button = Button::new("multibuffer-collapse-expand-empty", label)
+                    .icon_position(IconPosition::Start)
+                    .icon(icon)
+                    .tooltip(move |_, cx| {
+                        Tooltip::for_action_in(
+                            tooltip_label,
+                            &ToggleFoldAll,
+                            &query_editor_focus.clone(),
+                            cx,
+                        )
+                    })
+                    .on_click(|_event, window, cx| {
+                        window.dispatch_action(ToggleFoldAll.boxed_clone(), cx)
+                    })
+                    .into_any_element();
+
+                return button;
+            }
+
+            Some(
+                IconButton::new("multibuffer-collapse-expand", icon)
+                    .shape(IconButtonShape::Square)
+                    .tooltip(move |_, cx| {
+                        Tooltip::for_action_in(
+                            tooltip_label,
+                            &ToggleFoldAll,
+                            &query_editor_focus,
+                            cx,
+                        )
+                    })
+                    .on_click(|_event, window, cx| {
+                        window.dispatch_action(ToggleFoldAll.boxed_clone(), cx)
+                    })
+                    .into_any_element(),
+            )
+        } else {
+            None
+        };
+
         let narrow_mode =
             self.scroll_handle.bounds().size.width / window.rem_size() < 340. / BASE_REM_SIZE_IN_PX;
         let hide_inline_icons = self.editor_needed_width
@@ -203,7 +258,13 @@ impl Render for BufferSearchBar {
         let input_base_styles =
             |border_color| input_base_styles(border_color, |div| div.w(input_width));
 
-        let query_column = input_base_styles(query_border)
+        let input_style = if find_in_results {
+            filter_search_results_input(query_border, |div| div.w(input_width), cx)
+        } else {
+            input_base_styles(query_border)
+        };
+
+        let query_column = input_style
             .id("editor-scroll")
             .track_scroll(&self.editor_scroll_handle)
             .child(render_text_input(&self.query_editor, color_override, cx))
@@ -336,11 +397,14 @@ impl Render for BufferSearchBar {
                 ))
             });
 
+        let has_collapse_button = collapse_expand_button.is_some();
+
         let search_line = h_flex()
             .w_full()
             .gap_2()
-            .when(find_in_results, |el| {
-                el.child(Label::new("Find in results").color(Color::Hint))
+            .when(find_in_results, |el| el.child(alignment_element()))
+            .when(!find_in_results && has_collapse_button, |el| {
+                el.pl_0p5().child(collapse_expand_button.expect("button"))
             })
             .child(query_column)
             .child(mode_column);
@@ -370,9 +434,11 @@ impl Render for BufferSearchBar {
                         &ReplaceAll,
                         focus_handle,
                     ));
+
                 h_flex()
                     .w_full()
                     .gap_2()
+                    .when(has_collapse_button, |this| this.child(alignment_element()))
                     .child(replace_column)
                     .child(replace_actions)
             });
@@ -395,26 +461,36 @@ impl Render for BufferSearchBar {
             h_flex()
                 .relative()
                 .child(search_line)
-                .when(!narrow_mode && !find_in_results, |div| {
-                    div.child(h_flex().absolute().right_0().child(render_action_button(
-                        "buffer-search",
-                        IconName::Close,
-                        Default::default(),
-                        "Close Search Bar",
-                        &Dismiss,
-                        focus_handle.clone(),
-                    )))
-                    .w_full()
+                .when(!narrow_mode && !find_in_results, |this| {
+                    this.child(
+                        h_flex()
+                            .absolute()
+                            .right_0()
+                            .when(has_collapse_button, |this| {
+                                this.pr_2()
+                                    .border_r_1()
+                                    .border_color(cx.theme().colors().border_variant)
+                            })
+                            .child(render_action_button(
+                                "buffer-search",
+                                IconName::Close,
+                                Default::default(),
+                                "Close Search Bar",
+                                &Dismiss,
+                                focus_handle.clone(),
+                            )),
+                    )
                 });
+
         v_flex()
             .id("buffer_search")
             .gap_2()
-            .py(px(1.0))
             .w_full()
             .track_scroll(&self.scroll_handle)
             .key_context(key_context)
             .capture_action(cx.listener(Self::tab))
             .capture_action(cx.listener(Self::backtab))
+            .capture_action(cx.listener(Self::toggle_fold_all))
             .on_action(cx.listener(Self::previous_history_query))
             .on_action(cx.listener(Self::next_history_query))
             .on_action(cx.listener(Self::dismiss))
@@ -455,6 +531,7 @@ impl Render for BufferSearchBar {
             .child(search_line)
             .children(query_error_line)
             .children(replace_line)
+            .into_any_element()
     }
 }
 
@@ -547,7 +624,9 @@ impl ToolbarItemView for BufferSearchBar {
             let is_project_search = searchable_item_handle.supported_options(cx).find_in_results;
             self.active_searchable_item = Some(searchable_item_handle);
             drop(self.update_matches(true, false, window, cx));
-            if !self.dismissed {
+            if self.needs_expand_collapse_option(cx) {
+                return ToolbarItemLocation::PrimaryLeft;
+            } else if !self.is_dismissed() {
                 if is_project_search {
                     self.dismiss(&Default::default(), window, cx);
                 } else {
@@ -737,6 +816,7 @@ impl BufferSearchBar {
             editor_scroll_handle: ScrollHandle::new(),
             editor_needed_width: px(0.),
             regex_language: None,
+            is_collapsed: false,
         }
     }
 
@@ -756,6 +836,9 @@ impl BufferSearchBar {
                 searchable_item.clear_matches(window, cx);
             }
         }
+
+        let needs_collapse_expand = self.needs_expand_collapse_option(cx);
+
         if let Some(active_editor) = self.active_searchable_item.as_mut() {
             self.selection_search_enabled = None;
             self.replace_enabled = false;
@@ -765,6 +848,14 @@ impl BufferSearchBar {
             self.focus(&handle, window, cx);
         }
 
+        if needs_collapse_expand {
+            cx.emit(Event::UpdateLocation);
+            cx.emit(ToolbarItemEvent::ChangeLocation(
+                ToolbarItemLocation::PrimaryLeft,
+            ));
+            cx.notify();
+            return;
+        }
         cx.emit(Event::UpdateLocation);
         cx.emit(ToolbarItemEvent::ChangeLocation(
             ToolbarItemLocation::Hidden,
@@ -845,7 +936,11 @@ impl BufferSearchBar {
         cx.notify();
         cx.emit(Event::UpdateLocation);
         cx.emit(ToolbarItemEvent::ChangeLocation(
-            ToolbarItemLocation::Secondary,
+            if self.needs_expand_collapse_option(cx) {
+                ToolbarItemLocation::PrimaryLeft
+            } else {
+                ToolbarItemLocation::Secondary
+            },
         ));
         true
     }
@@ -857,6 +952,45 @@ impl BufferSearchBar {
             .unwrap_or_default()
     }
 
+    // We provide an expand/collapse button if we are in a multibuffer
+    // and not doing a project search.
+    fn needs_expand_collapse_option(&self, cx: &App) -> bool {
+        if let Some(item) = &self.active_searchable_item {
+            let buffer_kind = item.buffer_kind(cx);
+
+            if buffer_kind == ItemBufferKind::Singleton {
+                return false;
+            }
+
+            let workspace::searchable::SearchOptions {
+                find_in_results, ..
+            } = item.supported_options(cx);
+            !find_in_results
+        } else {
+            false
+        }
+    }
+
+    fn toggle_fold_all(&mut self, _: &ToggleFoldAll, window: &mut Window, cx: &mut Context<Self>) {
+        self.toggle_fold_all_in_item(window, cx);
+    }
+
+    fn toggle_fold_all_in_item(&self, window: &mut Window, cx: &mut Context<Self>) {
+        let is_collapsed = self.is_collapsed;
+        if let Some(item) = &self.active_searchable_item {
+            if let Some(item) = item.act_as_type(TypeId::of::<Editor>(), cx) {
+                let editor = item.downcast::<Editor>().expect("Is an editor");
+                editor.update(cx, |editor, cx| {
+                    if is_collapsed {
+                        editor.unfold_all(&UnfoldAll, window, cx);
+                    } else {
+                        editor.fold_all(&FoldAll, window, cx);
+                    }
+                })
+            }
+        }
+    }
+
     pub fn search_suggested(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let search = self.query_suggestion(window, cx).map(|suggestion| {
             self.search(&suggestion, Some(self.default_options), true, window, cx)
@@ -1223,6 +1357,15 @@ impl BufferSearchBar {
                 drop(self.update_matches(false, false, window, cx));
             }
             SearchEvent::ActiveMatchChanged => self.update_match_index(window, cx),
+            SearchEvent::ResultsCollapsedChanged(collapse_direction) => {
+                if self.needs_expand_collapse_option(cx) {
+                    match collapse_direction {
+                        CollapseDirection::Collapsed => self.is_collapsed = true,
+                        CollapseDirection::Expanded => self.is_collapsed = false,
+                    }
+                }
+                cx.notify();
+            }
         }
     }
 
@@ -1659,7 +1802,7 @@ mod tests {
 
     use super::*;
     use editor::{
-        DisplayPoint, Editor, MultiBuffer, SearchSettings, SelectionEffects,
+        DisplayPoint, Editor, ExcerptRange, MultiBuffer, SearchSettings, SelectionEffects,
         display_map::DisplayRow, test::editor_test_context::EditorTestContext,
     };
     use gpui::{Hsla, TestAppContext, UpdateGlobal, VisualTestContext};
@@ -1680,6 +1823,79 @@ mod tests {
         });
     }
 
+    fn init_multibuffer_test(
+        cx: &mut TestAppContext,
+    ) -> (
+        Entity<Editor>,
+        Entity<BufferSearchBar>,
+        &mut VisualTestContext,
+    ) {
+        init_globals(cx);
+
+        let buffer1 = cx.new(|cx| {
+            Buffer::local(
+                            r#"
+                            A regular expression (shortened as regex or regexp;[1] also referred to as
+                            rational expression[2][3]) is a sequence of characters that specifies a search
+                            pattern in text. Usually such patterns are used by string-searching algorithms
+                            for "find" or "find and replace" operations on strings, or for input validation.
+                            "#
+                            .unindent(),
+                            cx,
+                        )
+        });
+
+        let buffer2 = cx.new(|cx| {
+            Buffer::local(
+                r#"
+                            Some Additional text with the term regular expression in it.
+                            There two lines.
+                            "#
+                .unindent(),
+                cx,
+            )
+        });
+
+        let multibuffer = cx.new(|cx| {
+            let mut buffer = MultiBuffer::new(language::Capability::ReadWrite);
+
+            //[ExcerptRange::new(Point::new(0, 0)..Point::new(2, 0))]
+            buffer.push_excerpts(
+                buffer1,
+                [ExcerptRange::new(Point::new(0, 0)..Point::new(3, 0))],
+                cx,
+            );
+            buffer.push_excerpts(
+                buffer2,
+                [ExcerptRange::new(Point::new(0, 0)..Point::new(1, 0))],
+                cx,
+            );
+
+            buffer
+        });
+        let mut editor = None;
+        let window = cx.add_window(|window, cx| {
+            let default_key_bindings = settings::KeymapFile::load_asset_allow_partial_failure(
+                "keymaps/default-macos.json",
+                cx,
+            )
+            .unwrap();
+            cx.bind_keys(default_key_bindings);
+            editor =
+                Some(cx.new(|cx| Editor::for_multibuffer(multibuffer.clone(), None, window, cx)));
+
+            let mut search_bar = BufferSearchBar::new(None, window, cx);
+            search_bar.set_active_pane_item(Some(&editor.clone().unwrap()), window, cx);
+            search_bar.show(window, cx);
+            search_bar
+        });
+        let search_bar = window.root(cx).unwrap();
+
+        let cx = VisualTestContext::from_window(*window, cx).into_mut();
+
+        (editor.unwrap(), search_bar, cx)
+    }
+
     fn init_test(
         cx: &mut TestAppContext,
     ) -> (
@@ -2978,38 +3194,140 @@ mod tests {
 
     #[perf]
     #[gpui::test]
-    async fn test_invalid_regexp_search_after_valid(cx: &mut TestAppContext) {
+    async fn test_hides_and_uses_secondary_when_in_singleton_buffer(cx: &mut TestAppContext) {
         let (editor, search_bar, cx) = init_test(cx);
-        // Search using valid regexp
-        search_bar
-            .update_in(cx, |search_bar, window, cx| {
-                search_bar.enable_search_option(SearchOptions::REGEX, window, cx);
-                search_bar.search("expression", None, true, window, cx)
-            })
-            .await
-            .unwrap();
+
+        let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.set_active_pane_item(Some(&editor), window, cx)
+        });
+
+        assert_eq!(initial_location, ToolbarItemLocation::Secondary);
+
+        let mut events = cx.events(&search_bar);
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.dismiss(&Dismiss, window, cx);
+        });
+
+        assert_eq!(
+            events.try_next().unwrap(),
+            Some(ToolbarItemEvent::ChangeLocation(
+                ToolbarItemLocation::Hidden
+            ))
+        );
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.show(window, cx);
+        });
+
+        assert_eq!(
+            events.try_next().unwrap(),
+            Some(ToolbarItemEvent::ChangeLocation(
+                ToolbarItemLocation::Secondary
+            ))
+        );
+    }
+
+    #[perf]
+    #[gpui::test]
+    async fn test_uses_primary_left_when_in_multi_buffer(cx: &mut TestAppContext) {
+        let (editor, search_bar, cx) = init_multibuffer_test(cx);
+
+        let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.set_active_pane_item(Some(&editor), window, cx)
+        });
+
+        assert_eq!(initial_location, ToolbarItemLocation::PrimaryLeft);
+
+        let mut events = cx.events(&search_bar);
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.dismiss(&Dismiss, window, cx);
+        });
+
+        assert_eq!(
+            events.try_next().unwrap(),
+            Some(ToolbarItemEvent::ChangeLocation(
+                ToolbarItemLocation::PrimaryLeft
+            ))
+        );
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.show(window, cx);
+        });
+
+        assert_eq!(
+            events.try_next().unwrap(),
+            Some(ToolbarItemEvent::ChangeLocation(
+                ToolbarItemLocation::PrimaryLeft
+            ))
+        );
+    }
+
+    #[perf]
+    #[gpui::test]
+    async fn test_hides_and_uses_secondary_when_part_of_project_search(cx: &mut TestAppContext) {
+        let (editor, search_bar, cx) = init_multibuffer_test(cx);
+
+        editor.update(cx, |editor, _| {
+            editor.set_in_project_search(true);
+        });
+
+        let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.set_active_pane_item(Some(&editor), window, cx)
+        });
+
+        assert_eq!(initial_location, ToolbarItemLocation::Hidden);
+
+        let mut events = cx.events(&search_bar);
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.dismiss(&Dismiss, window, cx);
+        });
+
+        assert_eq!(
+            events.try_next().unwrap(),
+            Some(ToolbarItemEvent::ChangeLocation(
+                ToolbarItemLocation::Hidden
+            ))
+        );
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.show(window, cx);
+        });
+
+        assert_eq!(
+            events.try_next().unwrap(),
+            Some(ToolbarItemEvent::ChangeLocation(
+                ToolbarItemLocation::Secondary
+            ))
+        );
+    }
+
+    #[perf]
+    #[gpui::test]
+    async fn test_sets_collapsed_when_editor_fold_events_emitted(cx: &mut TestAppContext) {
+        let (editor, search_bar, cx) = init_multibuffer_test(cx);
+
+        search_bar.update_in(cx, |search_bar, window, cx| {
+            search_bar.set_active_pane_item(Some(&editor), window, cx);
+        });
+
         editor.update_in(cx, |editor, window, cx| {
-            assert_eq!(
-                display_points_of(editor.all_text_background_highlights(window, cx)),
-                &[
-                    DisplayPoint::new(DisplayRow(0), 10)..DisplayPoint::new(DisplayRow(0), 20),
-                    DisplayPoint::new(DisplayRow(1), 9)..DisplayPoint::new(DisplayRow(1), 19),
-                ],
-            );
+            editor.fold_all(&FoldAll, window, cx);
         });
 
-        // Now, the expression is invalid
-        search_bar
-            .update_in(cx, |search_bar, window, cx| {
-                search_bar.search("expression (", None, true, window, cx)
-            })
-            .await
-            .unwrap_err();
+        let is_collapsed = search_bar.read_with(cx, |search_bar, _| search_bar.is_collapsed);
+
+        assert!(is_collapsed);
+
         editor.update_in(cx, |editor, window, cx| {
-            assert!(
-                display_points_of(editor.all_text_background_highlights(window, cx)).is_empty(),
-            );
+            editor.unfold_all(&UnfoldAll, window, cx);
         });
+
+        let is_collapsed = search_bar.read_with(cx, |search_bar, _| search_bar.is_collapsed);
+
+        assert!(!is_collapsed);
     }
 
     #[perf]

crates/search/src/project_search.rs 🔗

@@ -3,14 +3,17 @@ use crate::{
     SearchOption, SearchOptions, SearchSource, SelectNextMatch, SelectPreviousMatch,
     ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleReplace, ToggleWholeWord,
     buffer_search::Deploy,
-    search_bar::{ActionButtonState, input_base_styles, render_action_button, render_text_input},
+    search_bar::{
+        ActionButtonState, alignment_element, input_base_styles, render_action_button,
+        render_text_input,
+    },
 };
 use anyhow::Context as _;
 use collections::HashMap;
 use editor::{
     Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, PathKey,
     SelectionEffects,
-    actions::{Backtab, SelectAll, Tab},
+    actions::{Backtab, FoldAll, SelectAll, Tab, UnfoldAll},
     items::active_match_index,
     multibuffer_context_lines,
     scroll::Autoscroll,
@@ -42,8 +45,8 @@ use util::{ResultExt as _, paths::PathMatcher, rel_path::RelPath};
 use workspace::{
     DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
     ToolbarItemView, Workspace, WorkspaceId,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions},
-    searchable::{Direction, SearchableItem, SearchableItemHandle},
+    item::{Item, ItemEvent, ItemHandle, SaveOptions},
+    searchable::{CollapseDirection, Direction, SearchEvent, SearchableItem, SearchableItemHandle},
 };
 
 actions!(
@@ -660,56 +663,6 @@ impl Item for ProjectSearchView {
             _ => {}
         }
     }
-
-    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
-        if self.has_matches() {
-            ToolbarItemLocation::Secondary
-        } else {
-            ToolbarItemLocation::Hidden
-        }
-    }
-
-    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
-        self.results_editor.breadcrumbs(theme, cx)
-    }
-
-    fn breadcrumb_prefix(
-        &self,
-        _window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Option<gpui::AnyElement> {
-        if !self.has_matches() {
-            return None;
-        }
-
-        let is_collapsed = self.results_collapsed;
-
-        let (icon, tooltip_label) = if is_collapsed {
-            (IconName::ChevronUpDown, "Expand All Search Results")
-        } else {
-            (IconName::ChevronDownUp, "Collapse All Search Results")
-        };
-
-        let focus_handle = self.query_editor.focus_handle(cx);
-
-        Some(
-            IconButton::new("project-search-collapse-expand", icon)
-                .shape(IconButtonShape::Square)
-                .icon_size(IconSize::Small)
-                .tooltip(move |_, cx| {
-                    Tooltip::for_action_in(
-                        tooltip_label,
-                        &ToggleAllSearchResults,
-                        &focus_handle,
-                        cx,
-                    )
-                })
-                .on_click(cx.listener(|this, _, window, cx| {
-                    this.toggle_all_search_results(&ToggleAllSearchResults, window, cx);
-                }))
-                .into_any_element(),
-        )
-    }
 }
 
 impl ProjectSearchView {
@@ -815,26 +768,18 @@ impl ProjectSearchView {
     fn toggle_all_search_results(
         &mut self,
         _: &ToggleAllSearchResults,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        self.results_collapsed = !self.results_collapsed;
-        self.update_results_visibility(cx);
+        self.update_results_visibility(window, cx);
     }
 
-    fn update_results_visibility(&mut self, cx: &mut Context<Self>) {
+    fn update_results_visibility(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         self.results_editor.update(cx, |editor, cx| {
-            let multibuffer = editor.buffer().read(cx);
-            let buffer_ids = multibuffer.excerpt_buffer_ids();
-
             if self.results_collapsed {
-                for buffer_id in buffer_ids {
-                    editor.fold_buffer(buffer_id, cx);
-                }
+                editor.unfold_all(&UnfoldAll, window, cx);
             } else {
-                for buffer_id in buffer_ids {
-                    editor.unfold_buffer(buffer_id, cx);
-                }
+                editor.fold_all(&FoldAll, window, cx);
             }
         });
         cx.notify();
@@ -924,6 +869,21 @@ impl ProjectSearchView {
                 cx.emit(ViewEvent::EditorEvent(event.clone()));
             }),
         );
+        subscriptions.push(cx.subscribe(
+            &results_editor,
+            |this, _editor, event: &SearchEvent, cx| {
+                match event {
+                    SearchEvent::ResultsCollapsedChanged(collapsed_direction) => {
+                        match collapsed_direction {
+                            CollapseDirection::Collapsed => this.results_collapsed = true,
+                            CollapseDirection::Expanded => this.results_collapsed = false,
+                        }
+                    }
+                    _ => (),
+                };
+                cx.notify();
+            },
+        ));
 
         let included_files_editor = cx.new(|cx| {
             let mut editor = Editor::single_line(window, cx);
@@ -2036,7 +1996,7 @@ impl ProjectSearchBar {
 impl Render for ProjectSearchBar {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let Some(search) = self.active_project_search.clone() else {
-            return div();
+            return div().into_any_element();
         };
         let search = search.read(cx);
         let focus_handle = search.focus_handle(cx);
@@ -2138,7 +2098,7 @@ impl Render for ProjectSearchBar {
                     .then_some(ActionButtonState::Disabled),
                 "Select Next Match",
                 &SelectNextMatch,
-                query_focus,
+                query_focus.clone(),
             ))
             .child(
                 div()
@@ -2202,9 +2162,37 @@ impl Render for ProjectSearchBar {
             ))
             .child(matches_column);
 
+        let is_collapsed = search.results_collapsed;
+
+        let (icon, tooltip_label) = if is_collapsed {
+            (IconName::ChevronUpDown, "Expand All Search Results")
+        } else {
+            (IconName::ChevronDownUp, "Collapse All Search Results")
+        };
+
+        let expand_button = IconButton::new("project-search-collapse-expand", icon)
+            .shape(IconButtonShape::Square)
+            .tooltip(move |_, cx| {
+                Tooltip::for_action_in(
+                    tooltip_label,
+                    &ToggleAllSearchResults,
+                    &query_focus.clone(),
+                    cx,
+                )
+            })
+            .on_click(cx.listener(|this, _, window, cx| {
+                if let Some(active_view) = &this.active_project_search {
+                    active_view.update(cx, |active_view, cx| {
+                        active_view.toggle_all_search_results(&ToggleAllSearchResults, window, cx);
+                    })
+                }
+            }));
+
         let search_line = h_flex()
+            .pl_0p5()
             .w_full()
             .gap_2()
+            .child(expand_button)
             .child(query_column)
             .child(mode_column);
 
@@ -2237,6 +2225,7 @@ impl Render for ProjectSearchBar {
             h_flex()
                 .w_full()
                 .gap_2()
+                .child(alignment_element())
                 .child(replace_column)
                 .child(replace_actions)
         });
@@ -2273,8 +2262,9 @@ impl Render for ProjectSearchBar {
                 .child(SearchOption::IncludeIgnored.as_button(
                     search.search_options,
                     SearchSource::Project(cx),
-                    focus_handle.clone(),
+                    focus_handle,
                 ));
+
             h_flex()
                 .w_full()
                 .gap_2()
@@ -2282,6 +2272,7 @@ impl Render for ProjectSearchBar {
                     h_flex()
                         .gap_2()
                         .w(input_width)
+                        .child(alignment_element())
                         .child(include)
                         .child(exclude),
                 )
@@ -2323,7 +2314,6 @@ impl Render for ProjectSearchBar {
 
         v_flex()
             .gap_2()
-            .py(px(1.0))
             .w_full()
             .key_context(key_context)
             .on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
@@ -2370,6 +2360,7 @@ impl Render for ProjectSearchBar {
             .children(replace_line)
             .children(filter_line)
             .children(filter_error_line)
+            .into_any_element()
     }
 }
 
@@ -2720,6 +2711,32 @@ pub mod tests {
                 (dp(5, 6)..dp(5, 9), "match"),
             ],
         );
+        search_view
+            .update(cx, |search_view, window, cx| {
+                search_view.results_editor.update(cx, |editor, cx| {
+                    editor.fold_all(&FoldAll, window, cx);
+                })
+            })
+            .expect("Should fold fine");
+
+        let results_collapsed = search_view
+            .read_with(cx, |search_view, _| search_view.results_collapsed)
+            .expect("got results_collapsed");
+
+        assert!(results_collapsed);
+        search_view
+            .update(cx, |search_view, window, cx| {
+                search_view.results_editor.update(cx, |editor, cx| {
+                    editor.unfold_all(&UnfoldAll, window, cx);
+                })
+            })
+            .expect("Should unfold fine");
+
+        let results_collapsed = search_view
+            .read_with(cx, |search_view, _| search_view.results_collapsed)
+            .expect("got results_collapsed");
+
+        assert!(!results_collapsed);
     }
 
     #[perf]

crates/search/src/search_bar.rs 🔗

@@ -50,6 +50,22 @@ pub(crate) fn input_base_styles(border_color: Hsla, map: impl FnOnce(Div) -> Div
         .border_color(border_color)
         .rounded_md()
 }
+pub(crate) fn filter_search_results_input(
+    border_color: Hsla,
+    map: impl FnOnce(Div) -> Div,
+    cx: &App,
+) -> Div {
+    input_base_styles(border_color, map).pl_0().child(
+        h_flex()
+            .mr_2()
+            .px_2()
+            .h_full()
+            .border_r_1()
+            .border_color(cx.theme().colors().border)
+            .bg(cx.theme().colors().text_accent.opacity(0.05))
+            .child(Label::new("Find in Results").color(Color::Muted)),
+    )
+}
 
 pub(crate) fn render_text_input(
     editor: &Entity<Editor>,
@@ -89,3 +105,8 @@ pub(crate) fn render_text_input(
 
     EditorElement::new(editor, editor_style)
 }
+
+/// This element makes all search inputs align as if they were in the same column
+pub(crate) fn alignment_element() -> Div {
+    div().size_5().flex_none().ml_0p5()
+}

crates/workspace/src/item.rs 🔗

@@ -125,6 +125,7 @@ pub enum ItemEvent {
 }
 
 // TODO: Combine this with existing HighlightedText struct?
+#[derive(Debug)]
 pub struct BreadcrumbText {
     pub text: String,
     pub highlights: Option<Vec<(Range<usize>, HighlightStyle)>>,

crates/workspace/src/searchable.rs 🔗

@@ -12,10 +12,17 @@ use crate::{
     item::{Item, WeakItemHandle},
 };
 
+#[derive(Clone, Debug)]
+pub enum CollapseDirection {
+    Collapsed,
+    Expanded,
+}
+
 #[derive(Clone, Debug)]
 pub enum SearchEvent {
     MatchesInvalidated,
     ActiveMatchChanged,
+    ResultsCollapsedChanged(CollapseDirection),
 }
 
 #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]

crates/workspace/src/toolbar.rs 🔗

@@ -6,6 +6,7 @@ use gpui::{
 use ui::prelude::*;
 use ui::{h_flex, v_flex};
 
+#[derive(Copy, Clone, Debug, PartialEq)]
 pub enum ToolbarItemEvent {
     ChangeLocation(ToolbarItemLocation),
 }
@@ -109,9 +110,10 @@ impl Render for Toolbar {
         v_flex()
             .group("toolbar")
             .relative()
-            .p(DynamicSpacing::Base08.rems(cx))
+            .py(DynamicSpacing::Base06.rems(cx))
+            .px(DynamicSpacing::Base08.rems(cx))
             .when(has_left_items || has_right_items, |this| {
-                this.gap(DynamicSpacing::Base08.rems(cx))
+                this.gap(DynamicSpacing::Base06.rems(cx))
             })
             .border_b_1()
             .border_color(cx.theme().colors().border_variant)
@@ -119,12 +121,13 @@ impl Render for Toolbar {
             .when(has_left_items || has_right_items, |this| {
                 this.child(
                     h_flex()
-                        .min_h_6()
+                        .items_start()
                         .justify_between()
                         .gap(DynamicSpacing::Base08.rems(cx))
                         .when(has_left_items, |this| {
                             this.child(
                                 h_flex()
+                                    .min_h_8()
                                     .flex_auto()
                                     .justify_start()
                                     .overflow_x_hidden()
@@ -134,17 +137,9 @@ impl Render for Toolbar {
                         .when(has_right_items, |this| {
                             this.child(
                                 h_flex()
-                                    .h_full()
+                                    .h_8()
                                     .flex_row_reverse()
-                                    .map(|el| {
-                                        if has_left_items {
-                                            // We're using `flex_none` here to prevent some flickering that can occur when the
-                                            // size of the left items container changes.
-                                            el.flex_none()
-                                        } else {
-                                            el.flex_auto()
-                                        }
-                                    })
+                                    .when(has_left_items, |this| this.flex_none())
                                     .justify_end()
                                     .children(self.right_items().map(|item| item.to_any())),
                             )