csv_preview: Refine filter popover UI (#61138)

# Objective

- The column filter popover was a static `ContextMenu`: no search, and
it didn't scale to columns with many unique values.

## Solution

- Replaced it with a `Picker<ColumnFilterDelegate>` with fuzzy search
and match highlighting over the column's unique values.
- Row order is frozen at open time (available first, then values hidden
by other filters under a "Hidden by other filters" header) so toggling a
value doesn't reshuffle the list.
- Added a footer with `{selected} / {total} rows selected` and "Clear
all".
- Added `fuzzy` and `picker` deps to `csv_preview`.

## Testing

- Search narrows to fuzzy matches with highlighting.
- Toggling a value updates its checkmark and footer count without
closing or reordering the list.
- Values excluded by another column's filter show disabled under "Hidden
by other filters".
- "Clear all" clears the column's filters and keeps the popover open.

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [ ] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [ ] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable



## Showcase

| Before | After |
| --- | --- |
| <img width="660" height="351" alt="image"
src="https://github.com/user-attachments/assets/c78084a2-d327-4d48-9c05-f35383455713"
/> | <img width="660" height="354" alt="image"
src="https://github.com/user-attachments/assets/910d52ef-4530-4ac7-91ac-3db318cf6c19"
/> |

**+ search**
<img width="972" height="574" alt="image"
src="https://github.com/user-attachments/assets/1a26d0fc-c96c-4973-818d-7d053e8cce73"
/>

---

Release Notes:

- N/A
This commit is contained in:
Oleksandr Kholiavko authored and GitHub committed 2026-07-26 10:27:23 +00:00
1 parent 0fd17feb17
commit efc3ba73b5
3 files changed
+532 -161

No files matched your search

Generated
+2
View File
@@ -4580,8 +4580,10 @@ dependencies = [
"anyhow",
"editor",
"feature_flags",
"fuzzy",
"gpui",
"log",
"picker",
"text",
"ui",
"workspace",
+2
View File
@@ -10,8 +10,10 @@ path = "src/csv_preview.rs"
[dependencies]
anyhow.workspace = true
feature_flags.workspace = true
fuzzy.workspace = true
gpui.workspace = true
editor.workspace = true
picker.workspace = true
ui.workspace = true
workspace.workspace = true
log.workspace = true
+528 -161
View File
@@ -1,6 +1,19 @@
use gpui::ElementId;
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
use gpui::{
BackgroundExecutor, DismissEvent, ElementId, Entity, Focusable, ForegroundExecutor, Task,
};
use picker::{Picker, PickerDelegate};
use ui::{
ContextMenu, GradientFade, IconButton, IconName, IconSize, PopoverMenu, Tooltip, prelude::*,
Color, GradientFade, HighlightedLabel, Icon, IconButton, IconName, IconSize, Label, LabelSize,
ListItem, ListItemSpacing, PopoverMenu, Tooltip, prelude::*,
};
use crate::{
@@ -13,6 +26,494 @@ use crate::{
types::AnyColumn,
};
struct ColumnFilterRow {
entry: FilterEntry,
is_applied: bool,
/// Set when this value is hidden because another column's active filter
/// excludes every row containing it.
hidden_by: Option<AnyColumn>,
}
enum ColumnFilterListEntry {
Header(SharedString),
Row {
row_index: usize,
positions: Vec<usize>,
},
}
struct ColumnFilterDelegate {
col: AnyColumn,
view: Entity<CsvPreviewView>,
/// Row order frozen at open time (available entries sorted per the
/// column's `FilterSortOrder`, then entries hidden by other columns'
/// filters). Kept stable so toggling a value doesn't reshuffle the list
/// under the user's cursor; only `is_applied`/`hidden_by`/counts are
/// refreshed in place after a toggle.
rows: Vec<ColumnFilterRow>,
/// Number of available (non-hidden) rows, shown in the search placeholder.
available_count: usize,
string_candidates: Arc<Vec<StringMatchCandidate>>,
filtered: Vec<ColumnFilterListEntry>,
selected_index: usize,
query: String,
foreground: ForegroundExecutor,
background: BackgroundExecutor,
cancel: Option<Arc<AtomicBool>>,
}
impl ColumnFilterDelegate {
fn new(
col: AnyColumn,
view: Entity<CsvPreviewView>,
sort_order: FilterSortOrder,
column_filters: Arc<Vec<(FilterEntry, FilterEntryState)>>,
cx: &mut Context<Picker<Self>>,
) -> Self {
let mut available: Vec<(FilterEntry, bool)> = Vec::new();
let mut hidden: Vec<(FilterEntry, AnyColumn)> = Vec::new();
for (entry, state) in column_filters.iter() {
match state {
FilterEntryState::Available { is_applied } => {
available.push((entry.clone(), *is_applied))
}
FilterEntryState::Unavailable { blocked_by } => {
hidden.push((entry.clone(), *blocked_by))
}
}
}
match sort_order {
FilterSortOrder::AlphaThenCount => available.sort_by(|(a, a_app), (b, b_app)| {
b_app
.cmp(a_app)
.then_with(|| a.content.cmp(&b.content))
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
}),
FilterSortOrder::CountThenAlpha => available.sort_by(|(a, a_app), (b, b_app)| {
b_app
.cmp(a_app)
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
.then_with(|| a.content.cmp(&b.content))
}),
}
let available_count = available.len();
let rows: Vec<ColumnFilterRow> = available
.into_iter()
.map(|(entry, is_applied)| ColumnFilterRow {
entry,
is_applied,
hidden_by: None,
})
.chain(
hidden
.into_iter()
.map(|(entry, blocked_by)| ColumnFilterRow {
entry,
is_applied: false,
hidden_by: Some(blocked_by),
}),
)
.collect();
let string_candidates = Arc::new(
rows.iter()
.enumerate()
.map(|(index, row)| {
StringMatchCandidate::new(index, &Self::display_text(&row.entry))
})
.collect::<Vec<_>>(),
);
let filtered = Self::build_entries(
&rows,
rows.iter()
.enumerate()
.map(|(index, _)| (index, Vec::new()))
.collect(),
);
Self {
col,
view,
rows,
available_count,
string_candidates,
filtered,
selected_index: 0,
query: String::new(),
foreground: cx.foreground_executor().clone(),
background: cx.background_executor().clone(),
cancel: None,
}
}
fn display_text(entry: &FilterEntry) -> String {
match &entry.content {
Some(s) => s.as_ref().to_owned(),
None => "<null>".to_owned(),
}
}
/// Assembles the flat list shown to the user from matched
/// `(row_index, positions)` pairs (which must be sorted ascending by
/// `row_index`, matching `rows`' available-then-hidden order), inserting a
/// "Hidden by other filters" header before the first hidden row.
fn build_entries(
rows: &[ColumnFilterRow],
matches: Vec<(usize, Vec<usize>)>,
) -> Vec<ColumnFilterListEntry> {
let mut entries = Vec::with_capacity(matches.len() + 1);
let mut header_inserted = false;
for (row_index, positions) in matches {
if rows[row_index].hidden_by.is_some() && !header_inserted {
entries.push(ColumnFilterListEntry::Header(
"Hidden by other filters".into(),
));
header_inserted = true;
}
entries.push(ColumnFilterListEntry::Row {
row_index,
positions,
});
}
entries
}
fn first_selectable_index(&self) -> usize {
self.filtered
.iter()
.position(|entry| {
matches!(entry, ColumnFilterListEntry::Row { row_index, .. }
if self.rows[*row_index].hidden_by.is_none())
})
.unwrap_or(0)
}
fn matches_for_query(&self, query: &str) -> Vec<(usize, Vec<usize>)> {
if query.is_empty() {
return self
.rows
.iter()
.enumerate()
.map(|(index, _)| (index, Vec::new()))
.collect();
}
let cancel_flag = AtomicBool::new(false);
let mut matches: Vec<StringMatch> = self.foreground.block_on(match_strings(
self.string_candidates.as_ref(),
query,
false,
true,
usize::MAX,
&cancel_flag,
self.background.clone(),
));
matches.sort_by_key(|m| m.candidate_id);
matches
.into_iter()
.map(|m| (m.candidate_id, m.positions))
.collect()
}
/// Re-fetches this column's filter entries from the engine (e.g. after a
/// toggle changes counts/availability across the cascade) while keeping
/// `rows`' frozen order, then re-applies the current search query.
fn refresh_rows(&mut self, cx: &mut Context<Picker<Self>>) {
let column_filters = match self.view.read(cx).engine.get_filters_for_column(self.col) {
Ok(filters) => filters,
Err(err) => {
log::error!("Failed to get filters for column: {err}");
return;
}
};
let mut lookup: HashMap<Option<SharedString>, (FilterEntry, FilterEntryState)> =
column_filters
.iter()
.map(|(entry, state)| (entry.content.clone(), (entry.clone(), *state)))
.collect();
for row in &mut self.rows {
let Some((entry, state)) = lookup.remove(&row.entry.content) else {
continue;
};
row.entry = entry;
match state {
FilterEntryState::Available { is_applied } => {
row.is_applied = is_applied;
row.hidden_by = None;
}
FilterEntryState::Unavailable { blocked_by } => {
row.is_applied = false;
row.hidden_by = Some(blocked_by);
}
}
}
self.filtered = Self::build_entries(&self.rows, self.matches_for_query(&self.query));
self.selected_index = self
.selected_index
.min(self.filtered.len().saturating_sub(1));
}
}
impl PickerDelegate for ColumnFilterDelegate {
type ListItem = AnyElement;
fn name() -> &'static str {
"csv column filter"
}
fn match_count(&self) -> usize {
self.filtered.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
self.selected_index = ix.min(self.filtered.len().saturating_sub(1));
cx.notify();
}
fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
match self.filtered.get(ix) {
Some(ColumnFilterListEntry::Row { row_index, .. }) => {
self.rows[*row_index].hidden_by.is_none()
}
_ => false,
}
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
format!("Search {} unique values…", self.available_count).into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
self.query = query.clone();
if query.is_empty() {
self.filtered = Self::build_entries(&self.rows, self.matches_for_query(&query));
self.selected_index = self.first_selectable_index();
cx.notify();
return Task::ready(());
}
if let Some(prev) = &self.cancel {
prev.store(true, Ordering::Relaxed);
}
let cancel = Arc::new(AtomicBool::new(false));
self.cancel = Some(cancel.clone());
let string_candidates = self.string_candidates.clone();
let background = self.background.clone();
cx.spawn_in(window, async move |this, cx| {
let mut matches = match_strings(
string_candidates.as_ref(),
&query,
false,
true,
usize::MAX,
cancel.as_ref(),
background,
)
.await;
matches.sort_by_key(|m| m.candidate_id);
this.update_in(cx, |this, _, cx| {
if this.delegate.query != query {
return;
}
let rows = &this.delegate.rows;
let matches = matches
.into_iter()
.map(|m| (m.candidate_id, m.positions))
.collect();
this.delegate.filtered = ColumnFilterDelegate::build_entries(rows, matches);
this.delegate.selected_index = this.delegate.first_selectable_index();
cx.notify();
})
.ok();
})
}
fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
let Some(ColumnFilterListEntry::Row { row_index, .. }) =
self.filtered.get(self.selected_index)
else {
return;
};
let row_index = *row_index;
if self.rows[row_index].hidden_by.is_some() {
return;
}
let col = self.col;
let value = self.rows[row_index].entry.content.clone();
self.view
.update(cx, |view, cx| view.toggle_filter(col, value, cx));
self.refresh_rows(cx);
cx.notify();
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
match self.filtered.get(ix)? {
ColumnFilterListEntry::Header(label) => Some(
div()
.px_2()
.pt_2()
.pb_1()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.child(
Label::new(label.clone())
.size(LabelSize::XSmall)
.color(Color::Muted),
)
.into_any_element(),
),
ColumnFilterListEntry::Row {
row_index,
positions,
} => {
let row = &self.rows[*row_index];
let value_text: SharedString = match &row.entry.content {
Some(s) => s.clone(),
None => "<null>".into(),
};
let count_text = SharedString::from(row.entry.occurred_times().to_string());
let label = HighlightedLabel::new(value_text.clone(), positions.clone())
.size(LabelSize::Small)
.single_line()
.truncate();
if row.hidden_by.is_some() {
return Some(
ListItem::new(("csv-filter-hidden", ix))
.disabled(true)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.child(label.color(Color::Disabled))
.end_slot(
Label::new(count_text)
.size(LabelSize::Small)
.color(Color::Disabled),
)
.into_any_element(),
);
}
Some(
ListItem::new(("csv-filter-value", ix))
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.start_slot(
h_flex()
.flex_none()
.when(!row.is_applied, |el| el.invisible())
.child(
Icon::new(IconName::Check)
.size(IconSize::Small)
.color(Color::Accent),
),
)
.child(label)
.end_slot(
Label::new(count_text)
.size(LabelSize::Small)
.color(Color::Muted),
)
.tooltip(Tooltip::text(value_text))
.into_any_element(),
)
}
}
}
fn render_footer(
&self,
_window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<AnyElement> {
let selected_rows: usize = self
.rows
.iter()
.filter(|row| row.hidden_by.is_none() && row.is_applied)
.map(|row| row.entry.occurred_times())
.sum();
let total_rows: usize = self
.rows
.iter()
.filter(|row| row.hidden_by.is_none())
.map(|row| row.entry.occurred_times())
.sum();
if selected_rows == 0 {
return None;
}
let col = self.col;
Some(
h_flex()
.w_full()
.px_2()
.py_1()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.justify_between()
.items_center()
.child(
Label::new(format!("{selected_rows} / {total_rows} rows selected"))
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
div()
.id("csv-filter-clear-all")
.cursor_pointer()
.child(
Label::new("Clear all")
.size(LabelSize::Small)
.color(Color::Accent),
)
.on_click(cx.listener(move |picker, _, _, cx| {
picker
.delegate
.view
.update(cx, |view, cx| view.clear_filters(col, cx));
picker.delegate.refresh_rows(cx);
cx.notify();
cx.emit(DismissEvent);
})),
)
.into_any(),
)
}
}
impl CsvPreviewView {
/// Create header for data, which is orderable with text on the left and sort button on the right
pub(crate) fn create_header_element_with_sort_button(
@@ -83,14 +584,14 @@ impl CsvPreviewView {
cx: &mut Context<'_, CsvPreviewView>,
col_idx: AnyColumn,
) -> Button {
let sort_btn = Button::new(
Button::new(
ElementId::NamedInteger("sort-button".into(), col_idx.get() as u64),
match self.engine.applied_sorting {
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
SortDirection::Asc => "↓",
SortDirection::Desc => "↑",
},
_ => "↕", // Unsorted/available for sorting
_ => "↕",
},
)
.size(ButtonSize::Compact)
@@ -114,38 +615,31 @@ impl CsvPreviewView {
}))
.on_click(cx.listener(move |this, _event, _window, cx| {
let new_sorting = match this.engine.applied_sorting {
Some(ordering) if ordering.col_idx == col_idx => {
// Same column clicked - cycle through states
match ordering.direction {
SortDirection::Asc => Some(AppliedSorting {
col_idx,
direction: SortDirection::Desc,
}),
SortDirection::Desc => None, // Clear sorting
}
}
_ => {
// Different column or no sorting - start with ascending
Some(AppliedSorting {
Some(ordering) if ordering.col_idx == col_idx => match ordering.direction {
SortDirection::Asc => Some(AppliedSorting {
col_idx,
direction: SortDirection::Asc,
})
}
direction: SortDirection::Desc,
}),
SortDirection::Desc => None,
},
_ => Some(AppliedSorting {
col_idx,
direction: SortDirection::Asc,
}),
};
this.engine.applied_sorting = new_sorting;
this.apply_sort(cx);
cx.notify();
}));
sort_btn
}))
}
fn create_filter_button(
&self,
cx: &mut Context<'_, CsvPreviewView>,
col: AnyColumn,
) -> PopoverMenu<ContextMenu> {
) -> PopoverMenu<Picker<ColumnFilterDelegate>> {
let has_active_filters = self.engine.has_active_filters(col);
let sort_order = self.settings.filter_sort_order;
PopoverMenu::new(ElementId::NamedInteger(
"filter-menu".into(),
@@ -180,146 +674,19 @@ impl CsvPreviewView {
return None;
}
};
let filter_sort_order = view.settings.filter_sort_order;
let filter_menu = Self::create_filter_menu(
window,
cx,
view_entity.clone(),
col,
&column_filters,
has_active_filters,
filter_sort_order,
);
Some(filter_menu)
}
})
}
let view_entity = view_entity.clone();
fn create_filter_menu(
window: &mut ui::Window,
cx: &mut ui::App,
view_entity: gpui::Entity<CsvPreviewView>,
col: AnyColumn,
column_filters: &[(FilterEntry, FilterEntryState)],
has_active_filters: bool,
sort_order: FilterSortOrder,
) -> gpui::Entity<ContextMenu> {
let mut available: Vec<&FilterEntry> = column_filters
.iter()
.filter_map(|(entry, state)| {
matches!(state, FilterEntryState::Available { .. }).then_some(entry)
})
.collect();
match sort_order {
FilterSortOrder::AlphaThenCount => available.sort_by(|a, b| {
a.content
.cmp(&b.content)
.then_with(|| b.occurred_times().cmp(&a.occurred_times()))
}),
FilterSortOrder::CountThenAlpha => available.sort_by(|a, b| {
b.occurred_times()
.cmp(&a.occurred_times())
.then_with(|| a.content.cmp(&b.content))
}),
}
let unavailable: Vec<(&FilterEntry, AnyColumn)> = column_filters
.iter()
.filter_map(|(entry, state)| {
if let FilterEntryState::Unavailable { blocked_by } = state {
Some((entry, *blocked_by))
} else {
None
}
})
.collect();
// Pre-build applied-state lookup before moving into the closure
let applied_states: Vec<(FilterEntry, bool)> = column_filters
.iter()
.filter_map(|(entry, state)| {
if let FilterEntryState::Available { is_applied } = state {
Some((entry.clone(), *is_applied))
} else {
None
}
})
.collect();
let available_cloned: Vec<FilterEntry> = available.iter().map(|e| (*e).clone()).collect();
let unavailable_cloned: Vec<(FilterEntry, AnyColumn)> = unavailable
.into_iter()
.map(|(e, col)| (e.clone(), col))
.collect();
ContextMenu::build(window, cx, move |menu, _, _| {
let mut menu = menu;
if has_active_filters {
menu = menu
.toggleable_entry("Clear all", false, ui::IconPosition::Start, None, {
let view_entity = view_entity.clone();
move |_window, cx| {
view_entity.update(cx, |view, cx| {
view.clear_filters(col, cx);
cx.notify();
});
}
})
.separator();
}
for entry in &available_cloned {
let is_applied = applied_states
.iter()
.find(|(e, _)| e.content == entry.content)
.map_or(false, |(_, applied)| *applied);
let label: SharedString =
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
let entry_value = entry.content.clone();
menu = menu.toggleable_entry(&label, is_applied, ui::IconPosition::Start, None, {
let view_entity = view_entity.clone();
move |_window, cx| {
view_entity.update(cx, |view, cx| {
view.toggle_filter(col, entry_value.clone(), cx);
cx.notify();
});
}
let picker = cx.new(|cx| {
let delegate =
ColumnFilterDelegate::new(col, view_entity, sort_order, column_filters, cx);
Picker::list(delegate, window, cx)
.popover()
.initial_width(rems(18.75))
.show_scrollbar(true)
});
picker.focus_handle(cx).focus(window, cx);
Some(picker)
}
if !unavailable_cloned.is_empty() {
menu = menu.separator().header("Hidden by other filters");
for (entry, _blocked_by) in &unavailable_cloned {
let label: SharedString =
format_filter_label(entry.content.as_ref(), entry.occurred_times()).into();
menu = menu.custom_entry(
{
let label = label.clone();
move |_window, cx| {
div()
.px_2()
.text_color(cx.theme().colors().text_muted)
.child(label.clone())
.into_any_element()
}
},
|_, _| {},
);
}
}
menu
})
}
}
fn format_filter_label(content: Option<&SharedString>, count: usize) -> String {
match content {
Some(s) => format!("{s} ({count})"),
None => format!("<null> ({count})"),
}
}