Multibuffer breadcrumbs toolbar redesign (#45547)

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>
This commit is contained in:
KyleBarton 2026-01-06 04:42:00 -08:00 committed by GitHub
parent 9fcb36a87f
commit 216933f0b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 918 additions and 514 deletions

5
Cargo.lock generated
View file

@ -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",
]

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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,

View file

@ -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"] }

View file

@ -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(),
)
}

View file

@ -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"] }

View file

@ -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
}

View file

@ -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,

View file

@ -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 {

View file

@ -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>(

View file

@ -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());
.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());
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();
let full_path = match parent_path.as_deref() {
Some(parent) if !parent.is_empty() => {
format!("{}{}", parent, filename.as_str())
}
_ => filename.as_str().to_string(),
};
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(),
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() {

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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();
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),
],
);
let initial_location = search_bar.update_in(cx, |search_bar, window, cx| {
search_bar.set_active_pane_item(Some(&editor), 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();
editor.update_in(cx, |editor, window, cx| {
assert!(
display_points_of(editor.all_text_background_highlights(window, cx)).is_empty(),
);
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| {
editor.fold_all(&FoldAll, window, cx);
});
let is_collapsed = search_bar.read_with(cx, |search_bar, _| search_bar.is_collapsed);
assert!(is_collapsed);
editor.update_in(cx, |editor, window, cx| {
editor.unfold_all(&UnfoldAll, window, cx);
});
let is_collapsed = search_bar.read_with(cx, |search_bar, _| search_bar.is_collapsed);
assert!(!is_collapsed);
}
#[perf]

View file

@ -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]

View file

@ -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()
}

View file

@ -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)>>,

View file

@ -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)]

View file

@ -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())),
)