From a378682f2fca195d9ef4076bfe5eb023fd42cb82 Mon Sep 17 00:00:00 2001 From: ozacod <47009516+ozacod@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:49:41 +0300 Subject: [PATCH] text_finder: Add collapsible file groups (#60193) ## Summary Add a chevron (`Disclosure`) to each file group header in Text Finder, letting you fold/unfold that file's matches. Fold state is cleared whenever a new search starts. ## Test plan - [x] Open Text Finder, run a search with matches in multiple files - [x] Click a header's chevron, confirm its matches hide/show and selection lands on a visible row - [x] Start a new search, confirm folded state doesn't carry over Release Notes: - Added the ability to collapse per-file match groups in Text Finder --------- Co-authored-by: ozacod Co-authored-by: Danilo Leal --- assets/keymaps/specific-overrides-macos.json | 3 + assets/keymaps/specific-overrides.json | 3 + crates/search/src/text_finder.rs | 20 ++- crates/search/src/text_finder/delegate.rs | 173 ++++++++++++++++--- crates/search/src/text_finder/render.rs | 3 + 5 files changed, 177 insertions(+), 25 deletions(-) diff --git a/assets/keymaps/specific-overrides-macos.json b/assets/keymaps/specific-overrides-macos.json index 3fd1d6c87f..e24dfb2051 100644 --- a/assets/keymaps/specific-overrides-macos.json +++ b/assets/keymaps/specific-overrides-macos.json @@ -39,6 +39,9 @@ "use_key_equivalents": true, "bindings": { "alt-cmd-f": "text_finder::ToProjectSearch", + "alt-cmd-[": "text_finder::Fold", + "alt-cmd-]": "text_finder::Unfold", + "cmd-shift-enter": "text_finder::ToggleFoldAll", "cmd-j": "pane::SplitDown", "cmd-k": "pane::SplitUp", "cmd-h": "pane::SplitLeft", diff --git a/assets/keymaps/specific-overrides.json b/assets/keymaps/specific-overrides.json index 663153a843..2c4191089a 100644 --- a/assets/keymaps/specific-overrides.json +++ b/assets/keymaps/specific-overrides.json @@ -39,6 +39,9 @@ "ctrl-k": "pane::SplitUp", "ctrl-h": "pane::SplitLeft", "ctrl-l": "pane::SplitRight", + "ctrl-{": "text_finder::Fold", + "ctrl-}": "text_finder::Unfold", + "ctrl-shift-enter": "text_finder::ToggleFoldAll", }, }, ] diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 707587658c..500f8e4fe9 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -29,7 +29,7 @@ use util::ResultExt as _; use crate::{ProjectSearchView, SearchOptions, text_finder::delegate::PopulateProjectSearch}; -actions!(text_finder, [ToProjectSearch,]); +actions!(text_finder, [ToProjectSearch, Fold, Unfold, ToggleFoldAll]); pub struct TextFinder { picker: Entity>, @@ -259,6 +259,24 @@ impl TextFinder { self.open_in_split(workspace::SplitDirection::Down, window, cx); } + fn fold(&mut self, _: &Fold, _window: &mut Window, cx: &mut Context) { + self.picker.update(cx, |picker, cx| { + picker.delegate.set_selected_group_collapsed(true, cx); + }); + } + + fn unfold(&mut self, _: &Unfold, _window: &mut Window, cx: &mut Context) { + self.picker.update(cx, |picker, cx| { + picker.delegate.set_selected_group_collapsed(false, cx); + }); + } + + fn toggle_fold_all(&mut self, _: &ToggleFoldAll, _window: &mut Window, cx: &mut Context) { + self.picker.update(cx, |picker, cx| { + picker.delegate.toggle_all_collapsed(cx); + }); + } + fn open_in_split( &mut self, direction: workspace::SplitDirection, diff --git a/crates/search/src/text_finder/delegate.rs b/crates/search/src/text_finder/delegate.rs index 0b69e49d04..4d4b071747 100644 --- a/crates/search/src/text_finder/delegate.rs +++ b/crates/search/src/text_finder/delegate.rs @@ -32,8 +32,8 @@ use editor::{MultiBufferSnapshot, PathKey, multibuffer_context_lines}; use file_icons::FileIcons; use futures::StreamExt; use gpui::{ - AnyElement, AppContext, AsyncApp, DismissEvent, EntityId, HighlightStyle, StyledText, Task, - TextStyle, prelude::*, + AnyElement, AppContext, AsyncApp, ClickEvent, DismissEvent, EntityId, HighlightStyle, + Modifiers, StyledText, Task, TextStyle, prelude::*, }; use gpui::{Entity, FocusHandle}; use language::{Buffer, LanguageAwareStyling}; @@ -45,15 +45,15 @@ use smol::future::yield_now; use text::Anchor; use theme_settings::ThemeSettings; use ui::{ - Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable, Tooltip, - prelude::*, + Disclosure, Divider, FluentBuilder, IconButtonShape, ListItem, ListItemSpacing, Toggleable, + Tooltip, prelude::*, text_for_keystroke, }; use util::ResultExt; use workspace::SplitDirection; use workspace::Workspace; use workspace::item::ItemSettings; -use super::SearchMatch; +use super::{Fold, SearchMatch, Unfold}; use crate::project_search::{ActiveSettings, ProjectSearch}; use crate::{ProjectSearchView, SearchOption, SearchOptions}; @@ -85,6 +85,7 @@ pub struct Delegate { /// column so every row's number right-aligns to the widest one. Recomputed in /// [`Delegate::rebuild_entries`]. pub(crate) max_line_number: u32, + pub(crate) collapsed_paths: HashSet, } pub(crate) enum Entry { @@ -316,6 +317,7 @@ impl Delegate { in_progress_search, unique_files: HashSet::default(), max_line_number: 0, + collapsed_paths: HashSet::default(), }); this @@ -368,7 +370,9 @@ impl Delegate { entries.push(Entry::Header(search_match.path.clone())); last_path = Some(&search_match.path); } - entries.push(Entry::Match(match_index)); + if !self.collapsed_paths.contains(&search_match.path) { + entries.push(Entry::Match(match_index)); + } } self.entries = entries; self.max_line_number = self @@ -394,6 +398,65 @@ impl Delegate { .position(|entry| matches!(entry, Entry::Match(_))) } + pub(crate) fn toggle_group_collapsed(&mut self, path: &ProjectPath) { + if !self.collapsed_paths.remove(path) { + self.collapsed_paths.insert(path.clone()); + } + self.rebuild_entries(); + } + + pub(crate) fn set_selected_group_collapsed( + &mut self, + collapsed: bool, + cx: &mut Context>, + ) { + let path = match self.entries.get(self.selected_index) { + Some(Entry::Match(match_index)) => self + .matches + .get(*match_index) + .map(|search_match| search_match.path.clone()), + Some(Entry::Header(path)) => Some(path.clone()), + Some(Entry::Separator) | None => None, + }; + let Some(path) = path else { + return; + }; + if collapsed == self.collapsed_paths.contains(&path) { + return; + } + + self.toggle_group_collapsed(&path); + + if let Some(index) = self.entries.iter().position(|entry| match entry { + Entry::Header(header_path) => collapsed && *header_path == path, + Entry::Match(match_index) => { + !collapsed + && self + .matches + .get(*match_index) + .is_some_and(|search_match| search_match.path == path) + } + Entry::Separator => false, + }) { + self.selected_index = index; + } + cx.notify(); + } + + pub(crate) fn toggle_all_collapsed(&mut self, cx: &mut Context>) { + if self.collapsed_paths.is_empty() { + self.collapsed_paths = self + .matches + .iter() + .map(|search_match| search_match.path.clone()) + .collect(); + } else { + self.collapsed_paths.clear(); + } + self.rebuild_entries(); + cx.notify(); + } + fn selected_search_match(&self) -> Option<&SearchMatch> { match self.entries.get(self.selected_index)? { Entry::Match(match_index) => self.matches.get(*match_index), @@ -626,7 +689,11 @@ impl PickerDelegate for Delegate { } fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context>) -> bool { - matches!(self.entries.get(ix), Some(Entry::Match(_))) + match self.entries.get(ix) { + Some(Entry::Match(_)) => true, + Some(Entry::Header(path)) => self.collapsed_paths.contains(path), + Some(Entry::Separator) | None => false, + } } fn selected_index(&self) -> usize { @@ -674,6 +741,7 @@ impl PickerDelegate for Delegate { self.matches.clear(); self.entries.clear(); self.unique_files.clear(); + self.collapsed_paths.clear(); self.selected_index = 0; self.active_query = None; cx.notify(); @@ -841,27 +909,83 @@ impl PickerDelegate for Delegate { .color(Color::Muted) .size(IconSize::Small) }); + let is_collapsed = self.collapsed_paths.contains(path); + let toggle_path = path.clone(); + let tooltip_focus_handle = self.focus_handle.clone(); Some( - h_flex() - .w_full() - .min_w_0() - .px(DynamicSpacing::Base06.rems(cx)) - .py_1() - .gap_1p5() - .children(file_icon) + div() + .px_1() .child( h_flex() - .gap_1() - .child(Label::new(file_name).size(LabelSize::Small)) - .when(!directory.is_empty(), |this| { - this.child( - Label::new(directory) - .size(LabelSize::Small) - .color(Color::Muted) - .truncate_start(), - ) - }), + .w_full() + .min_w_0() + .p_1() + .gap_1p5() + .rounded_sm() + .when(selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) + .child( + h_flex() + .gap_1() + .child( + Disclosure::new( + ("text-finder-fold", ix), + !is_collapsed, + ) + .tooltip(move |_window, cx| { + let (label, action): (_, &dyn gpui::Action) = + if is_collapsed { + ("Unfold", &Unfold) + } else { + ("Fold", &Fold) + }; + Tooltip::with_meta_in( + label, + Some(action), + format!( + "{} to toggle all", + text_for_keystroke( + &Modifiers::alt(), + "click", + cx + ) + ), + &tooltip_focus_handle, + cx, + ) + }) + .on_click( + cx.listener( + move |this, event: &ClickEvent, _window, cx| { + if event.modifiers().alt { + this.delegate.toggle_all_collapsed(cx); + } else { + this.delegate.toggle_group_collapsed( + &toggle_path, + ); + cx.notify(); + } + }, + ), + ), + ) + .children(file_icon), + ) + .child( + h_flex() + .gap_1() + .child(Label::new(file_name).size(LabelSize::Small)) + .when(!directory.is_empty(), |this| { + this.child( + Label::new(directory) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start(), + ) + }), + ), ) .into_any_element(), ) @@ -958,6 +1082,7 @@ async fn stream_results_to_picker( delegate.matches.clear(); delegate.entries.clear(); delegate.unique_files.clear(); + delegate.collapsed_paths.clear(); delegate.selected_index = 0; clear_existing = false; } diff --git a/crates/search/src/text_finder/render.rs b/crates/search/src/text_finder/render.rs index d36587ce46..1f120f0e32 100644 --- a/crates/search/src/text_finder/render.rs +++ b/crates/search/src/text_finder/render.rs @@ -15,6 +15,9 @@ impl Render for TextFinder { .on_action(cx.listener(Self::split_right)) .on_action(cx.listener(Self::split_up)) .on_action(cx.listener(Self::split_down)) + .on_action(cx.listener(Self::fold)) + .on_action(cx.listener(Self::unfold)) + .on_action(cx.listener(Self::toggle_fold_all)) .child(self.picker.clone()) } }