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::{ Color, GradientFade, HighlightedLabel, Icon, IconButton, IconName, IconSize, Label, LabelSize, ListItem, ListItemSpacing, PopoverMenu, Tooltip, prelude::*,}; use crate::{ CsvPreviewView, settings::FilterSortOrder, table_data_engine::{ filtering_by_column::{FilterEntry, FilterEntryState}, sorting_by_column::{AppliedSorting, SortDirection}, }, 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),Lines 1–500 of 692. Load more
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::{ Color, GradientFade, HighlightedLabel, Icon, IconButton, IconName, IconSize, Label, LabelSize, ListItem, ListItemSpacing, PopoverMenu, Tooltip, prelude::*, }; use crate::{ CsvPreviewView, settings::FilterSortOrder, table_data_engine::{ filtering_by_column::{FilterEntry, FilterEntryState}, sorting_by_column::{AppliedSorting, SortDirection}, }, 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),