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