search: Fix collapse/expand all button sync (#48773)

Derive collapsed state from `Editor.has_any_buffer_folded` instead of
tracking it separately, removing redundant `ResultsCollapsedChanged` event
and stale `is_collapsed`/`results_collapsed` fields.

Closes #48734 

Release Notes:

- Fixed collapse/expand all button in buffer search and project search
not syncing correctly when toggling individual file sections

---------

Signed-off-by: Benjamin <5719034+bnjjj@users.noreply.github.com>
Co-authored-by: dino <dinojoaocosta@gmail.com>
This commit is contained in:
Coenen Benjaminanddino authored and GitHub committed 2026-02-16 12:55:16 +00:00
1 parent 24dbd216d7
commit b6c48b60ab
4 files changed
+242 -55

No files matched your search

+8 -7
View File
@@ -217,7 +217,7 @@ use workspace::{
TabBarSettings, Toast, ViewId, Workspace, WorkspaceId, WorkspaceSettings,
item::{BreadcrumbText, ItemBufferKind, ItemHandle, PreviewTabsSettings, SaveOptions},
notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
searchable::{CollapseDirection, SearchEvent},
searchable::SearchEvent,
};
use zed_actions::editor::{MoveDown, MoveUp};
@@ -20046,9 +20046,6 @@ impl Editor {
.ok();
});
}
cx.emit(SearchEvent::ResultsCollapsedChanged(
CollapseDirection::Collapsed,
));
}
pub fn fold_function_bodies(
@@ -20237,9 +20234,6 @@ impl Editor {
.ok();
});
}
cx.emit(SearchEvent::ResultsCollapsedChanged(
CollapseDirection::Expanded,
));
}
pub fn fold_selected_ranges(
@@ -20350,6 +20344,13 @@ impl Editor {
self.display_map.read(cx).is_buffer_folded(buffer)
}
pub fn has_any_buffer_folded(&self, cx: &App) -> bool {
if self.buffer().read(cx).is_singleton() {
return false;
}
!self.folded_buffers(cx).is_empty()
}
pub fn folded_buffers<'a>(&self, cx: &'a App) -> &'a HashSet<BufferId> {
self.display_map.read(cx).folded_buffers()
}
+76 -19
View File
@@ -42,8 +42,8 @@ use workspace::{
ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
item::{ItemBufferKind, ItemHandle},
searchable::{
CollapseDirection, Direction, FilteredSearchRange, SearchEvent, SearchToken,
SearchableItemHandle, WeakSearchableItemHandle,
Direction, FilteredSearchRange, SearchEvent, SearchToken, SearchableItemHandle,
WeakSearchableItemHandle,
},
};
@@ -94,7 +94,6 @@ pub struct BufferSearchBar {
editor_scroll_handle: ScrollHandle,
editor_needed_width: Pixels,
regex_language: Option<Arc<Language>>,
is_collapsed: bool,
splittable_editor: Option<WeakEntity<SplittableEditor>>,
_splittable_editor_subscription: Option<Subscription>,
}
@@ -212,7 +211,14 @@ impl Render for BufferSearchBar {
let collapse_expand_button = if self.needs_expand_collapse_option(cx) {
let query_editor_focus = self.query_editor.focus_handle(cx);
let (icon, tooltip_label) = if self.is_collapsed {
let is_collapsed = self
.active_searchable_item
.as_ref()
.and_then(|item| item.act_as_type(TypeId::of::<Editor>(), cx))
.and_then(|item| item.downcast::<Editor>().ok())
.map(|editor: Entity<Editor>| editor.read(cx).has_any_buffer_folded(cx))
.unwrap_or_default();
let (icon, tooltip_label) = if is_collapsed {
(IconName::ChevronUpDown, "Expand All Files")
} else {
(IconName::ChevronDownUp, "Collapse All Files")
@@ -887,7 +893,6 @@ impl BufferSearchBar {
editor_scroll_handle: ScrollHandle::new(),
editor_needed_width: px(0.),
regex_language: None,
is_collapsed: false,
splittable_editor: None,
_splittable_editor_subscription: None,
}
@@ -1053,11 +1058,11 @@ impl BufferSearchBar {
}
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| {
let is_collapsed = editor.has_any_buffer_folded(cx);
if is_collapsed {
editor.unfold_all(&UnfoldAll, window, cx);
} else {
@@ -1434,15 +1439,6 @@ 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();
}
}
}
@@ -3395,20 +3391,81 @@ mod tests {
editor.update_in(cx, |editor, window, cx| {
editor.fold_all(&FoldAll, window, cx);
});
cx.run_until_parked();
let is_collapsed = search_bar.read_with(cx, |search_bar, _| search_bar.is_collapsed);
let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
assert!(is_collapsed);
editor.update_in(cx, |editor, window, cx| {
editor.unfold_all(&UnfoldAll, window, cx);
});
cx.run_until_parked();
let is_collapsed = search_bar.read_with(cx, |search_bar, _| search_bar.is_collapsed);
let is_collapsed = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
assert!(!is_collapsed);
}
#[perf]
#[gpui::test]
async fn test_collapse_state_syncs_after_manual_buffer_fold(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);
});
// Fold all buffers via fold_all
editor.update_in(cx, |editor, window, cx| {
editor.fold_all(&FoldAll, window, cx);
});
cx.run_until_parked();
let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
assert!(
has_any_folded,
"All buffers should be folded after fold_all"
);
// Manually unfold one buffer (simulating a chevron click)
let first_buffer_id = editor.read_with(cx, |editor, cx| {
editor.buffer().read(cx).excerpt_buffer_ids()[0]
});
editor.update_in(cx, |editor, _window, cx| {
editor.unfold_buffer(first_buffer_id, cx);
});
let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
assert!(
has_any_folded,
"Should still report folds when only one buffer is unfolded"
);
// Manually unfold the second buffer too
let second_buffer_id = editor.read_with(cx, |editor, cx| {
editor.buffer().read(cx).excerpt_buffer_ids()[1]
});
editor.update_in(cx, |editor, _window, cx| {
editor.unfold_buffer(second_buffer_id, cx);
});
let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
assert!(
!has_any_folded,
"No folds should remain after unfolding all buffers individually"
);
// Manually fold one buffer back
editor.update_in(cx, |editor, _window, cx| {
editor.fold_buffer(first_buffer_id, cx);
});
let has_any_folded = editor.read_with(cx, |editor, cx| editor.has_any_buffer_folded(cx));
assert!(
has_any_folded,
"Should report folds after manually folding one buffer"
);
}
#[perf]
#[gpui::test]
async fn test_search_options_changes(cx: &mut TestAppContext) {
+158 -22
View File
@@ -49,10 +49,7 @@ use workspace::{
DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
ToolbarItemView, Workspace, WorkspaceId,
item::{Item, ItemEvent, ItemHandle, SaveOptions},
searchable::{
CollapseDirection, Direction, SearchEvent, SearchToken, SearchableItem,
SearchableItemHandle,
},
searchable::{Direction, SearchEvent, SearchToken, SearchableItem, SearchableItemHandle},
};
actions!(
@@ -269,7 +266,6 @@ pub struct ProjectSearchView {
replace_enabled: bool,
included_opened_only: bool,
regex_language: Option<Arc<Language>>,
results_collapsed: bool,
_subscriptions: Vec<Subscription>,
}
@@ -814,8 +810,9 @@ impl ProjectSearchView {
}
fn update_results_visibility(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let has_any_folded = self.results_editor.read(cx).has_any_buffer_folded(cx);
self.results_editor.update(cx, |editor, cx| {
if self.results_collapsed {
if has_any_folded {
editor.unfold_all(&UnfoldAll, window, cx);
} else {
editor.fold_all(&FoldAll, window, cx);
@@ -910,18 +907,7 @@ impl ProjectSearchView {
);
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();
},
|_this, _editor, _event: &SearchEvent, cx| cx.notify(),
));
let included_files_editor = cx.new(|cx| {
@@ -997,7 +983,6 @@ impl ProjectSearchView {
replace_enabled: false,
included_opened_only: false,
regex_language: None,
results_collapsed: false,
_subscriptions: subscriptions,
};
@@ -2224,7 +2209,7 @@ impl Render for ProjectSearchBar {
))
.child(matches_column);
let is_collapsed = search.results_collapsed;
let is_collapsed = search.results_editor.read(cx).has_any_buffer_folded(cx);
let (icon, tooltip_label) = if is_collapsed {
(IconName::ChevronUpDown, "Expand All Search Results")
@@ -2804,9 +2789,15 @@ pub mod tests {
})
})
.expect("Should fold fine");
cx.run_until_parked();
let results_collapsed = search_view
.read_with(cx, |search_view, _| search_view.results_collapsed)
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.has_any_buffer_folded(cx)
})
.expect("got results_collapsed");
assert!(results_collapsed);
@@ -2817,14 +2808,159 @@ pub mod tests {
})
})
.expect("Should unfold fine");
cx.run_until_parked();
let results_collapsed = search_view
.read_with(cx, |search_view, _| search_view.results_collapsed)
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.has_any_buffer_folded(cx)
})
.expect("got results_collapsed");
assert!(!results_collapsed);
}
#[perf]
#[gpui::test]
async fn test_collapse_state_syncs_after_manual_buffer_fold(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/dir"),
json!({
"one.rs": "const ONE: usize = 1;",
"two.rs": "const TWO: usize = one::ONE + one::ONE;",
"three.rs": "const THREE: usize = one::ONE + two::TWO;",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx));
let search_view = cx.add_window(|window, cx| {
ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None)
});
// Search for "ONE" which appears in all 3 files
perform_search(search_view, "ONE", cx);
// Verify initial state: no folds
let has_any_folded = search_view
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.has_any_buffer_folded(cx)
})
.expect("should read state");
assert!(!has_any_folded, "No buffers should be folded initially");
// Fold all via fold_all
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");
cx.run_until_parked();
let has_any_folded = search_view
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.has_any_buffer_folded(cx)
})
.expect("should read state");
assert!(
has_any_folded,
"All buffers should be folded after fold_all"
);
// Manually unfold one buffer (simulating a chevron click)
let first_buffer_id = search_view
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.buffer()
.read(cx)
.excerpt_buffer_ids()[0]
})
.expect("should read buffer ids");
search_view
.update(cx, |search_view, _window, cx| {
search_view.results_editor.update(cx, |editor, cx| {
editor.unfold_buffer(first_buffer_id, cx);
})
})
.expect("Should unfold one buffer");
let has_any_folded = search_view
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.has_any_buffer_folded(cx)
})
.expect("should read state");
assert!(
has_any_folded,
"Should still report folds when only one buffer is unfolded"
);
// Unfold all via unfold_all
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");
cx.run_until_parked();
let has_any_folded = search_view
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.has_any_buffer_folded(cx)
})
.expect("should read state");
assert!(!has_any_folded, "No folds should remain after unfold_all");
// Manually fold one buffer back (simulating a chevron click)
search_view
.update(cx, |search_view, _window, cx| {
search_view.results_editor.update(cx, |editor, cx| {
editor.fold_buffer(first_buffer_id, cx);
})
})
.expect("Should fold one buffer");
let has_any_folded = search_view
.read_with(cx, |search_view, cx| {
search_view
.results_editor
.read(cx)
.has_any_buffer_folded(cx)
})
.expect("should read state");
assert!(
has_any_folded,
"Should report folds after manually folding one buffer"
);
}
#[perf]
#[gpui::test]
async fn test_deploy_project_search_focus(cx: &mut TestAppContext) {
-7
View File
@@ -25,17 +25,10 @@ impl SearchToken {
}
}
#[derive(Clone, Debug)]
pub enum CollapseDirection {
Collapsed,
Expanded,
}
#[derive(Debug, Clone)]
pub enum SearchEvent {
MatchesInvalidated,
ActiveMatchChanged,
ResultsCollapsedChanged(CollapseDirection),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]