From b6c48b60abbec8ce3ea95be63a054482977694d3 Mon Sep 17 00:00:00 2001 From: Coenen Benjamin Date: Mon, 16 Feb 2026 13:55:16 +0100 Subject: [PATCH] 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 --- crates/editor/src/editor.rs | 15 +-- crates/search/src/buffer_search.rs | 95 ++++++++++++--- crates/search/src/project_search.rs | 180 ++++++++++++++++++++++++---- crates/workspace/src/searchable.rs | 7 -- 4 files changed, 242 insertions(+), 55 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3e41228e505ed81cb2ec7572ec96bbc0edacc78c..6f3bab3133d0192ce6b5af0591ad98a38eafe1a9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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 { self.display_map.read(cx).folded_buffers() } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 25cbb4ef4cdb65128adee25b93d33409b9df57f9..7112fad656485eb45244aecc4dc685f93273cb6e 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -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>, - is_collapsed: bool, splittable_editor: Option>, _splittable_editor_subscription: Option, } @@ -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::(), cx)) + .and_then(|item| item.downcast::().ok()) + .map(|editor: Entity| 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) { - let is_collapsed = self.is_collapsed; if let Some(item) = &self.active_searchable_item { if let Some(item) = item.act_as_type(TypeId::of::(), cx) { let editor = item.downcast::().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) { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 7c85077c488371e611fdadf0baf7ee94f49fe511..66e63afaccfba90a72a2d7bc81f785d16b83d632 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -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>, - results_collapsed: bool, _subscriptions: Vec, } @@ -814,8 +810,9 @@ impl ProjectSearchView { } fn update_results_visibility(&mut self, window: &mut Window, cx: &mut Context) { + 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) { diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 6c9d8edeb487da17314ef033da37fd7caa51d650..93d809d7a522d11e4b4bd78e71899b89aa4d0508 100644 --- a/crates/workspace/src/searchable.rs +++ b/crates/workspace/src/searchable.rs @@ -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)]