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}