Skip to repository content385 lines · 13.9 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:27:55.011Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
csv_preview.rs
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