Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:32:28.713Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

csv_preview.rs

385 lines · 13.9 KB · rust
1use editor::{Editor, EditorEvent};
2use feature_flags::{FeatureFlag, FeatureFlagAppExt as _, PresenceFlag, register_feature_flag};
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::{DisplayToDataMapping, TableDataEngine};
12use ui::{
13    AbsoluteLength, ResizableColumnsState, SharedString, TableInteractionState,
14    TableResizeBehavior, prelude::*,
15};
16use workspace::{Item, Pane, 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    type Value = PresenceFlag;
33}
34register_feature_flag!(TabularDataPreviewFeatureFlag);
35
36pub struct CsvPreviewView {
37    pub(crate) engine: TableDataEngine,
38
39    pub(crate) focus_handle: FocusHandle,
40    active_editor_state: EditorState,
41    pub(crate) table_interaction_state: Entity<TableInteractionState>,
42    pub(crate) column_widths: ColumnWidths,
43    pub(crate) parsing_task: Option<Task<anyhow::Result<()>>>,
44    pub(crate) is_parsing: bool,
45    /// Background task computing the display-to-data mapping after a filter/sort change.
46    /// Stored here so that a new change cancels the previous in-flight computation.
47    pub(crate) filter_sort_task: Option<Task<()>>,
48    pub(crate) settings: CsvPreviewSettings,
49    /// Performance metrics for debugging and monitoring CSV operations.
50    pub(crate) performance_metrics: PerformanceMetrics,
51    pub(crate) list_state: gpui::ListState,
52    /// Cached row height, refreshed from the actual text line height on every render.
53    /// Used to size not-yet-rendered rows for the scrollbar without a full `.measure_all()`
54    /// pass, so it tracks the real row height instead of a hardcoded guess.
55    pub(crate) row_height: Pixels,
56    /// Time when the last parsing operation ended, used for smart debouncing
57    pub(crate) last_parse_end_time: Option<std::time::Instant>,
58}
59
60pub fn init(cx: &mut App) {
61    cx.observe_new(|workspace: &mut Workspace, _, _| {
62        CsvPreviewView::register(workspace);
63    })
64    .detach()
65}
66
67impl CsvPreviewView {
68    pub(crate) fn sync_column_widths(&self, cx: &mut Context<Self>) {
69        // plus 1 for the row identifier column
70        let cols = self.engine.contents.headers.cols() + 1;
71        let line_number_width = self.calculate_row_identifier_column_width();
72
73        let mut widths: Vec<AbsoluteLength> = vec![AbsoluteLength::Pixels(px(150.)); cols];
74        widths[0] = AbsoluteLength::Pixels(px(line_number_width));
75
76        let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols];
77        resize_behaviors[0] = TableResizeBehavior::None;
78
79        self.column_widths.widths.update(cx, |state, _cx| {
80            if state.cols() != cols {
81                *state = ResizableColumnsState::new(cols, widths, resize_behaviors);
82            } else {
83                state.set_column_configuration(
84                    0,
85                    AbsoluteLength::Pixels(px(line_number_width)),
86                    TableResizeBehavior::None,
87                );
88            }
89        });
90    }
91
92    pub fn register(workspace: &mut Workspace) {
93        workspace.register_action_renderer(|div, _, _, cx| {
94            div.when(cx.has_flag::<TabularDataPreviewFeatureFlag>(), |div| {
95                div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| {
96                    if let Some(editor) = Self::resolve_active_item_as_csv_editor(workspace, cx) {
97                        let pane = workspace.active_pane().clone();
98                        Self::open_preview_in_pane(editor, pane, window, cx);
99                    }
100                }))
101                .on_action(cx.listener(
102                    |workspace, _: &OpenPreviewToTheSide, window, cx| {
103                        if let Some(editor) = Self::resolve_active_item_as_csv_editor(workspace, cx)
104                        {
105                            let pane = workspace.active_pane().clone();
106                            Self::open_preview_to_the_side_of_pane(
107                                workspace, editor, pane, window, cx,
108                            );
109                        }
110                    },
111                ))
112            })
113        });
114    }
115
116    pub fn open_preview_in_pane(
117        editor: Entity<Editor>,
118        pane: Entity<Pane>,
119        window: &mut Window,
120        cx: &mut Context<Workspace>,
121    ) {
122        Self::activate_or_add_preview(editor, pane, true, window, cx);
123    }
124
125    pub fn open_preview_to_the_side_of_pane(
126        workspace: &mut Workspace,
127        editor: Entity<Editor>,
128        origin_pane: Entity<Pane>,
129        window: &mut Window,
130        cx: &mut Context<Workspace>,
131    ) {
132        let target_pane = workspace.adjacent_pane_of(&origin_pane, window, cx);
133        Self::activate_or_add_preview(editor, target_pane, false, window, cx);
134    }
135
136    fn activate_or_add_preview(
137        editor: Entity<Editor>,
138        pane: Entity<Pane>,
139        focus: bool,
140        window: &mut Window,
141        cx: &mut Context<Workspace>,
142    ) {
143        let existing_view_idx = Self::find_existing_preview_item_idx(pane.read(cx), &editor, cx);
144        if let Some(existing_view_idx) = existing_view_idx {
145            pane.update(cx, |pane, cx| {
146                pane.activate_item(existing_view_idx, focus, focus, window, cx);
147            });
148        } else {
149            let csv_preview = Self::new(&editor, window, cx);
150            pane.update(cx, |pane, cx| {
151                pane.add_item(Box::new(csv_preview), focus, focus, None, window, cx);
152            });
153        }
154        cx.notify();
155    }
156
157    fn find_existing_preview_item_idx(
158        pane: &Pane,
159        editor: &Entity<Editor>,
160        cx: &App,
161    ) -> Option<usize> {
162        pane.items_of_type::<CsvPreviewView>()
163            .find(|view| &view.read(cx).active_editor_state.editor == editor)
164            .and_then(|view| pane.index_for_item(&view))
165    }
166
167    fn new(editor: &Entity<Editor>, window: &Window, cx: &mut Context<Workspace>) -> Entity<Self> {
168        let contents = TableLikeContent::default();
169        let table_interaction_state = cx.new(|cx| {
170            TableInteractionState::new(cx).with_custom_scrollbar(ui::Scrollbars::for_settings::<
171                editor::EditorSettingsScrollbarProxy,
172            >())
173        });
174
175        cx.new(|cx| {
176            let subscription = cx.subscribe(
177                editor,
178                |this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| {
179                    match event {
180                        EditorEvent::Edited { .. } | EditorEvent::DirtyChanged => {
181                            this.parse_csv_from_active_editor(true, cx);
182                        }
183                        _ => {}
184                    };
185                },
186            );
187
188            let row_height = window.pixel_snap(window.line_height());
189            let mut view = CsvPreviewView {
190                focus_handle: cx.focus_handle(),
191                active_editor_state: EditorState {
192                    editor: editor.clone(),
193                    _subscription: subscription,
194                },
195                table_interaction_state,
196                column_widths: ColumnWidths::new(cx, 1),
197                parsing_task: None,
198                is_parsing: false,
199                filter_sort_task: None,
200                performance_metrics: PerformanceMetrics::default(),
201                list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.))
202                    .with_uniform_item_height(row_height),
203                row_height,
204                settings: CsvPreviewSettings::default(),
205                last_parse_end_time: None,
206                engine: TableDataEngine::default(),
207            };
208
209            view.parse_csv_from_active_editor(false, cx);
210            view
211        })
212    }
213
214    pub(crate) fn editor_state(&self) -> &EditorState {
215        &self.active_editor_state
216    }
217    pub(crate) fn apply_sort(&mut self, cx: &mut Context<Self>) {
218        self.apply_filter_sort(cx);
219    }
220
221    pub fn clear_filters(&mut self, col: types::AnyColumn, cx: &mut Context<Self>) {
222        self.engine.clear_filters_for_col(col);
223        self.apply_filter_sort(cx);
224    }
225
226    pub fn toggle_filter(
227        &mut self,
228        col: types::AnyColumn,
229        value: Option<SharedString>,
230        cx: &mut Context<Self>,
231    ) {
232        if let Err(err) = self.engine.toggle_filter(col, value) {
233            log::error!("Failed to toggle filter: {err}");
234            return;
235        }
236        self.apply_filter_sort(cx);
237    }
238
239    /// Spawns a background task to recompute the display-to-data mapping after a filter or sort
240    /// change. Storing the task cancels any previous in-flight computation automatically.
241    pub(crate) fn apply_filter_sort(&mut self, cx: &mut Context<Self>) {
242        let contents = self.engine.contents.clone();
243        let filter_stack = self.engine.filter_stack.clone();
244        let sorting = self.engine.applied_sorting;
245
246        self.filter_sort_task = Some(cx.spawn(async move |this, cx| {
247            let mapping = cx
248                .background_spawn(async move {
249                    DisplayToDataMapping::compute(&contents, &filter_stack, sorting)
250                })
251                .await;
252
253            this.update(cx, |view, cx| {
254                view.engine.set_d2d_mapping(mapping);
255                let visible_rows = view.engine.d2d_mapping().visible_row_count();
256                // Uses the row height measured on the last render. Cheaper than a full
257                // `.measure_all()` pass; exact row heights are re-measured on scrolling.
258                view.list_state
259                    .reset_with_uniform_height(visible_rows, view.row_height);
260                cx.notify();
261            })
262            .ok();
263        }));
264    }
265
266    pub fn resolve_active_item_as_csv_editor(
267        workspace: &Workspace,
268        cx: &mut Context<Workspace>,
269    ) -> Option<Entity<Editor>> {
270        let editor = workspace
271            .active_item(cx)
272            .and_then(|item| item.act_as::<Editor>(cx))?;
273        Self::is_csv_file(&editor, cx).then_some(editor)
274    }
275
276    pub fn is_csv_file(editor: &Entity<Editor>, cx: &App) -> bool {
277        editor
278            .read(cx)
279            .buffer()
280            .read(cx)
281            .as_singleton()
282            .and_then(|buffer| {
283                buffer
284                    .read(cx)
285                    .file()
286                    .and_then(|file| file.path().extension())
287                    .map(|ext| ext.eq_ignore_ascii_case("csv"))
288            })
289            .unwrap_or(false)
290    }
291}
292
293impl Focusable for CsvPreviewView {
294    fn focus_handle(&self, _cx: &App) -> FocusHandle {
295        self.focus_handle.clone()
296    }
297}
298
299impl EventEmitter<()> for CsvPreviewView {}
300
301impl Item for CsvPreviewView {
302    type Event = ();
303
304    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
305        Some(Icon::new(IconName::FileDoc))
306    }
307
308    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
309        self.editor_state()
310            .editor
311            .read(cx)
312            .buffer()
313            .read(cx)
314            .as_singleton()
315            .and_then(|b| {
316                let file = b.read(cx).file()?;
317                let local_file = file.as_local()?;
318                local_file
319                    .abs_path(cx)
320                    .file_name()
321                    .map(|name| format!("Preview {}", name.to_string_lossy()).into())
322            })
323            .unwrap_or_else(|| SharedString::from("CSV Preview"))
324    }
325}
326
327#[derive(Debug, Default)]
328pub struct PerformanceMetrics {
329    /// Map of timing metrics with their duration and measurement time.
330    pub timings: HashMap<&'static str, (Duration, Instant)>,
331    /// List of display indices that were rendered in the current frame.
332    pub rendered_indices: Vec<usize>,
333}
334impl PerformanceMetrics {
335    pub fn record<F, R>(&mut self, name: &'static str, mut f: F) -> R
336    where
337        F: FnMut() -> R,
338    {
339        let start_time = Instant::now();
340        let ret = f();
341        let duration = start_time.elapsed();
342        self.timings.insert(name, (duration, Instant::now()));
343        ret
344    }
345
346    /// Displays all metrics sorted A-Z in format: `{name}: {took}ms {ago}s ago`
347    pub fn display(&self) -> String {
348        let mut metrics = self.timings.iter().collect::<Vec<_>>();
349        metrics.sort_by_key(|&(name, _)| *name);
350        metrics
351            .iter()
352            .map(|(name, (duration, time))| {
353                let took = duration.as_secs_f32() * 1000.;
354                let ago = time.elapsed().as_secs();
355                format!("{name}: {took:.3}ms {ago}s ago")
356            })
357            .collect::<Vec<_>>()
358            .join("\n")
359    }
360
361    /// Get timing for a specific metric
362    pub fn get_timing(&self, name: &str) -> Option<Duration> {
363        self.timings.get(name).map(|(duration, _)| *duration)
364    }
365}
366
367/// Holds state of column widths for a table component in CSV preview.
368pub(crate) struct ColumnWidths {
369    pub widths: Entity<ResizableColumnsState>,
370}
371
372impl ColumnWidths {
373    pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
374        Self {
375            widths: cx.new(|_cx| {
376                ResizableColumnsState::new(
377                    cols,
378                    vec![AbsoluteLength::Pixels(px(150.)); cols],
379                    vec![ui::TableResizeBehavior::Resizable; cols],
380                )
381            }),
382        }
383    }
384}
385
Served at tenant.openagents/omega Member data and write actions are omitted.