diff --git a/Cargo.lock b/Cargo.lock index 8c6c525480..10cb9751dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4580,8 +4580,10 @@ dependencies = [ "anyhow", "editor", "feature_flags", + "fuzzy", "gpui", "log", + "picker", "text", "ui", "workspace", diff --git a/crates/csv_preview/Cargo.toml b/crates/csv_preview/Cargo.toml index ff4c0a6124..1dbec1cc58 100644 --- a/crates/csv_preview/Cargo.toml +++ b/crates/csv_preview/Cargo.toml @@ -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 diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs index 444ee09099..bc96f23902 100644 --- a/crates/csv_preview/src/renderer/table_header.rs +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -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, +} + +enum ColumnFilterListEntry { + Header(SharedString), + Row { + row_index: usize, + positions: Vec, + }, +} + +struct ColumnFilterDelegate { + col: AnyColumn, + view: Entity, + /// 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, + /// Number of available (non-hidden) rows, shown in the search placeholder. + available_count: usize, + string_candidates: Arc>, + filtered: Vec, + selected_index: usize, + query: String, + foreground: ForegroundExecutor, + background: BackgroundExecutor, + cancel: Option>, +} + +impl ColumnFilterDelegate { + fn new( + col: AnyColumn, + view: Entity, + sort_order: FilterSortOrder, + column_filters: Arc>, + cx: &mut Context>, + ) -> 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 = 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::>(), + ); + + 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 => "".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)>, + ) -> Vec { + 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)> { + 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 = 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>) { + 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, (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>, + ) { + 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>) -> 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 { + format!("Search {} unique values…", self.available_count).into() + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> 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>) { + 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>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + 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 => "".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>, + ) -> Option { + 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 { + ) -> PopoverMenu> { 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, - col: AnyColumn, - column_filters: &[(FilterEntry, FilterEntryState)], - has_active_filters: bool, - sort_order: FilterSortOrder, - ) -> gpui::Entity { - 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 = 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!(" ({count})"), - } -}