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 <ozacod@users.noreply.github.com>
Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-07-02 13:49:41 +00:00
1 parent d9ebd07d9a
commit a378682f2f
5 files changed
+177 -25

No files matched your search

@@ -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",
+3
View File
@@ -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",
},
},
]
+19 -1
View File
@@ -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<Picker<Delegate>>,
@@ -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>) {
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>) {
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>) {
self.picker.update(cx, |picker, cx| {
picker.delegate.toggle_all_collapsed(cx);
});
}
fn open_in_split(
&mut self,
direction: workspace::SplitDirection,
+149 -24
View File
@@ -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<ProjectPath>,
}
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<Picker<Self>>,
) {
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<Picker<Self>>) {
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<Picker<Self>>) -> 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;
}
+3
View File
@@ -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())
}
}