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