csv_preview.rs

  1use editor::{Editor, EditorEvent};
  2use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
  3use gpui::{
  4    AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions,
  5};
  6use std::{
  7    collections::HashMap,
  8    time::{Duration, Instant},
  9};
 10
 11use crate::table_data_engine::TableDataEngine;
 12use ui::{
 13    AbsoluteLength, DefiniteLength, RedistributableColumnsState, SharedString,
 14    TableInteractionState, TableResizeBehavior, prelude::*,
 15};
 16use workspace::{Item, SplitDirection, Workspace};
 17
 18use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent};
 19
 20mod parser;
 21mod renderer;
 22mod settings;
 23mod table_data_engine;
 24mod types;
 25
 26actions!(csv, [OpenPreview, OpenPreviewToTheSide]);
 27
 28pub struct TabularDataPreviewFeatureFlag;
 29
 30impl FeatureFlag for TabularDataPreviewFeatureFlag {
 31    const NAME: &'static str = "tabular-data-preview";
 32}
 33
 34pub struct CsvPreviewView {
 35    pub(crate) engine: TableDataEngine,
 36
 37    pub(crate) focus_handle: FocusHandle,
 38    active_editor_state: EditorState,
 39    pub(crate) table_interaction_state: Entity<TableInteractionState>,
 40    pub(crate) column_widths: ColumnWidths,
 41    pub(crate) parsing_task: Option<Task<anyhow::Result<()>>>,
 42    pub(crate) settings: CsvPreviewSettings,
 43    /// Performance metrics for debugging and monitoring CSV operations.
 44    pub(crate) performance_metrics: PerformanceMetrics,
 45    pub(crate) list_state: gpui::ListState,
 46    /// Time when the last parsing operation ended, used for smart debouncing
 47    pub(crate) last_parse_end_time: Option<std::time::Instant>,
 48}
 49
 50pub fn init(cx: &mut App) {
 51    cx.observe_new(|workspace: &mut Workspace, _, _| {
 52        CsvPreviewView::register(workspace);
 53    })
 54    .detach()
 55}
 56
 57impl CsvPreviewView {
 58    pub(crate) fn sync_column_widths(&self, cx: &mut Context<Self>) {
 59        // plus 1 for the rows column
 60        let cols = self.engine.contents.headers.cols() + 1;
 61        let remaining_col_number = cols.saturating_sub(1);
 62        let fraction = if remaining_col_number > 0 {
 63            1. / remaining_col_number as f32
 64        } else {
 65            1.
 66        };
 67        let mut widths = vec![DefiniteLength::Fraction(fraction); cols];
 68        let line_number_width = self.calculate_row_identifier_column_width();
 69        widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into()));
 70
 71        let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols];
 72        resize_behaviors[0] = TableResizeBehavior::None;
 73
 74        self.column_widths.widths.update(cx, |state, _cx| {
 75            if state.cols() != cols
 76                || state.initial_widths().as_slice() != widths.as_slice()
 77                || state.resize_behavior().as_slice() != resize_behaviors.as_slice()
 78            {
 79                *state = RedistributableColumnsState::new(cols, widths, resize_behaviors);
 80            }
 81        });
 82    }
 83
 84    pub fn register(workspace: &mut Workspace) {
 85        workspace.register_action_renderer(|div, _, _, cx| {
 86            div.when(cx.has_flag::<TabularDataPreviewFeatureFlag>(), |div| {
 87                div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| {
 88                    if let Some(editor) = workspace
 89                        .active_item(cx)
 90                        .and_then(|item| item.act_as::<Editor>(cx))
 91                        .filter(|editor| Self::is_csv_file(editor, cx))
 92                    {
 93                        let csv_preview = Self::new(&editor, cx);
 94                        workspace.active_pane().update(cx, |pane, cx| {
 95                            let existing = pane
 96                                .items_of_type::<CsvPreviewView>()
 97                                .find(|view| view.read(cx).active_editor_state.editor == editor);
 98                            if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) {
 99                                pane.activate_item(idx, true, true, window, cx);
100                            } else {
101                                pane.add_item(Box::new(csv_preview), true, true, None, window, cx);
102                            }
103                        });
104                        cx.notify();
105                    }
106                }))
107                .on_action(cx.listener(
108                    |workspace, _: &OpenPreviewToTheSide, window, cx| {
109                        if let Some(editor) = workspace
110                            .active_item(cx)
111                            .and_then(|item| item.act_as::<Editor>(cx))
112                            .filter(|editor| Self::is_csv_file(editor, cx))
113                        {
114                            let csv_preview = Self::new(&editor, cx);
115                            let pane = workspace
116                                .find_pane_in_direction(SplitDirection::Right, cx)
117                                .unwrap_or_else(|| {
118                                    workspace.split_pane(
119                                        workspace.active_pane().clone(),
120                                        SplitDirection::Right,
121                                        window,
122                                        cx,
123                                    )
124                                });
125                            pane.update(cx, |pane, cx| {
126                                let existing =
127                                    pane.items_of_type::<CsvPreviewView>().find(|view| {
128                                        view.read(cx).active_editor_state.editor == editor
129                                    });
130                                if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) {
131                                    pane.activate_item(idx, true, true, window, cx);
132                                } else {
133                                    pane.add_item(
134                                        Box::new(csv_preview),
135                                        false,
136                                        false,
137                                        None,
138                                        window,
139                                        cx,
140                                    );
141                                }
142                            });
143                            cx.notify();
144                        }
145                    },
146                ))
147            })
148        });
149    }
150
151    fn new(editor: &Entity<Editor>, cx: &mut Context<Workspace>) -> Entity<Self> {
152        let contents = TableLikeContent::default();
153        let table_interaction_state = cx.new(|cx| {
154            TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::<
155                editor::EditorSettingsScrollbarProxy,
156            >())
157        });
158
159        cx.new(|cx| {
160            let subscription = cx.subscribe(
161                editor,
162                |this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| {
163                    match event {
164                        EditorEvent::Edited { .. }
165                        | EditorEvent::DirtyChanged
166                        | EditorEvent::ExcerptsEdited { .. } => {
167                            this.parse_csv_from_active_editor(true, cx);
168                        }
169                        _ => {}
170                    };
171                },
172            );
173
174            let mut view = CsvPreviewView {
175                focus_handle: cx.focus_handle(),
176                active_editor_state: EditorState {
177                    editor: editor.clone(),
178                    _subscription: subscription,
179                },
180                table_interaction_state,
181                column_widths: ColumnWidths::new(cx, 1),
182                parsing_task: None,
183                performance_metrics: PerformanceMetrics::default(),
184                list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)),
185                settings: CsvPreviewSettings::default(),
186                last_parse_end_time: None,
187                engine: TableDataEngine::default(),
188            };
189
190            view.parse_csv_from_active_editor(false, cx);
191            view
192        })
193    }
194
195    pub(crate) fn editor_state(&self) -> &EditorState {
196        &self.active_editor_state
197    }
198    pub(crate) fn apply_sort(&mut self) {
199        self.performance_metrics.record("Sort", || {
200            self.engine.apply_sort();
201        });
202    }
203
204    /// Update ordered indices when ordering or content changes
205    pub(crate) fn apply_filter_sort(&mut self) {
206        self.performance_metrics.record("Filter&sort", || {
207            self.engine.calculate_d2d_mapping();
208        });
209
210        // Update list state with filtered row count
211        let visible_rows = self.engine.d2d_mapping().visible_row_count();
212        self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.));
213    }
214
215    pub fn resolve_active_item_as_csv_editor(
216        workspace: &Workspace,
217        cx: &mut Context<Workspace>,
218    ) -> Option<Entity<Editor>> {
219        let editor = workspace
220            .active_item(cx)
221            .and_then(|item| item.act_as::<Editor>(cx))?;
222        Self::is_csv_file(&editor, cx).then_some(editor)
223    }
224
225    fn is_csv_file(editor: &Entity<Editor>, cx: &App) -> bool {
226        editor
227            .read(cx)
228            .buffer()
229            .read(cx)
230            .as_singleton()
231            .and_then(|buffer| {
232                buffer
233                    .read(cx)
234                    .file()
235                    .and_then(|file| file.path().extension())
236                    .map(|ext| ext.eq_ignore_ascii_case("csv"))
237            })
238            .unwrap_or(false)
239    }
240}
241
242impl Focusable for CsvPreviewView {
243    fn focus_handle(&self, _cx: &App) -> FocusHandle {
244        self.focus_handle.clone()
245    }
246}
247
248impl EventEmitter<()> for CsvPreviewView {}
249
250impl Item for CsvPreviewView {
251    type Event = ();
252
253    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
254        Some(Icon::new(IconName::FileDoc))
255    }
256
257    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
258        self.editor_state()
259            .editor
260            .read(cx)
261            .buffer()
262            .read(cx)
263            .as_singleton()
264            .and_then(|b| {
265                let file = b.read(cx).file()?;
266                let local_file = file.as_local()?;
267                local_file
268                    .abs_path(cx)
269                    .file_name()
270                    .map(|name| format!("Preview {}", name.to_string_lossy()).into())
271            })
272            .unwrap_or_else(|| SharedString::from("CSV Preview"))
273    }
274}
275
276#[derive(Debug, Default)]
277pub struct PerformanceMetrics {
278    /// Map of timing metrics with their duration and measurement time.
279    pub timings: HashMap<&'static str, (Duration, Instant)>,
280    /// List of display indices that were rendered in the current frame.
281    pub rendered_indices: Vec<usize>,
282}
283impl PerformanceMetrics {
284    pub fn record<F, R>(&mut self, name: &'static str, mut f: F) -> R
285    where
286        F: FnMut() -> R,
287    {
288        let start_time = Instant::now();
289        let ret = f();
290        let duration = start_time.elapsed();
291        self.timings.insert(name, (duration, Instant::now()));
292        ret
293    }
294
295    /// Displays all metrics sorted A-Z in format: `{name}: {took}ms {ago}s ago`
296    pub fn display(&self) -> String {
297        let mut metrics = self.timings.iter().collect::<Vec<_>>();
298        metrics.sort_by_key(|&(name, _)| *name);
299        metrics
300            .iter()
301            .map(|(name, (duration, time))| {
302                let took = duration.as_secs_f32() * 1000.;
303                let ago = time.elapsed().as_secs();
304                format!("{name}: {took:.2}ms {ago}s ago")
305            })
306            .collect::<Vec<_>>()
307            .join("\n")
308    }
309
310    /// Get timing for a specific metric
311    pub fn get_timing(&self, name: &str) -> Option<Duration> {
312        self.timings.get(name).map(|(duration, _)| *duration)
313    }
314}
315
316/// Holds state of column widths for a table component in CSV preview.
317pub(crate) struct ColumnWidths {
318    pub widths: Entity<RedistributableColumnsState>,
319}
320
321impl ColumnWidths {
322    pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
323        Self {
324            widths: cx.new(|_cx| {
325                RedistributableColumnsState::new(
326                    cols,
327                    vec![ui::DefiniteLength::Fraction(1.0 / cols as f32); cols],
328                    vec![ui::TableResizeBehavior::Resizable; cols],
329                )
330            }),
331        }
332    }
333}