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