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)
126 .with_custom_scrollbar(ui::Scrollbars::for_settings::<editor::EditorSettings>())
127 });
128
129 cx.new(|cx| {
130 let subscription = cx.subscribe(
131 editor,
132 |this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| {
133 match event {
134 EditorEvent::Edited { .. }
135 | EditorEvent::DirtyChanged
136 | EditorEvent::ExcerptsEdited { .. } => {
137 this.parse_csv_from_active_editor(true, cx);
138 }
139 _ => {}
140 };
141 },
142 );
143
144 let mut view = CsvPreviewView {
145 focus_handle: cx.focus_handle(),
146 active_editor_state: EditorState {
147 editor: editor.clone(),
148 _subscription: subscription,
149 },
150 table_interaction_state,
151 column_widths: ColumnWidths::new(cx, 1),
152 parsing_task: None,
153 performance_metrics: PerformanceMetrics::default(),
154 list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)),
155 settings: CsvPreviewSettings::default(),
156 last_parse_end_time: None,
157 engine: TableDataEngine::default(),
158 };
159
160 view.parse_csv_from_active_editor(false, cx);
161 view
162 })
163 }
164
165 pub(crate) fn editor_state(&self) -> &EditorState {
166 &self.active_editor_state
167 }
168 pub(crate) fn apply_sort(&mut self) {
169 self.performance_metrics.record("Sort", || {
170 self.engine.apply_sort();
171 });
172 }
173
174 /// Update ordered indices when ordering or content changes
175 pub(crate) fn apply_filter_sort(&mut self) {
176 self.performance_metrics.record("Filter&sort", || {
177 self.engine.calculate_d2d_mapping();
178 });
179
180 // Update list state with filtered row count
181 let visible_rows = self.engine.d2d_mapping().visible_row_count();
182 self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.));
183 }
184
185 pub fn resolve_active_item_as_csv_editor(
186 workspace: &Workspace,
187 cx: &mut Context<Workspace>,
188 ) -> Option<Entity<Editor>> {
189 let editor = workspace
190 .active_item(cx)
191 .and_then(|item| item.act_as::<Editor>(cx))?;
192 Self::is_csv_file(&editor, cx).then_some(editor)
193 }
194
195 fn is_csv_file(editor: &Entity<Editor>, cx: &App) -> bool {
196 editor
197 .read(cx)
198 .buffer()
199 .read(cx)
200 .as_singleton()
201 .and_then(|buffer| {
202 buffer
203 .read(cx)
204 .file()
205 .and_then(|file| file.path().extension())
206 .map(|ext| ext.eq_ignore_ascii_case("csv"))
207 })
208 .unwrap_or(false)
209 }
210}
211
212impl Focusable for CsvPreviewView {
213 fn focus_handle(&self, _cx: &App) -> FocusHandle {
214 self.focus_handle.clone()
215 }
216}
217
218impl EventEmitter<()> for CsvPreviewView {}
219
220impl Item for CsvPreviewView {
221 type Event = ();
222
223 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
224 Some(Icon::new(IconName::FileDoc))
225 }
226
227 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
228 self.editor_state()
229 .editor
230 .read(cx)
231 .buffer()
232 .read(cx)
233 .as_singleton()
234 .and_then(|b| {
235 let file = b.read(cx).file()?;
236 let local_file = file.as_local()?;
237 local_file
238 .abs_path(cx)
239 .file_name()
240 .map(|name| format!("Preview {}", name.to_string_lossy()).into())
241 })
242 .unwrap_or_else(|| SharedString::from("CSV Preview"))
243 }
244}
245
246#[derive(Debug, Default)]
247pub struct PerformanceMetrics {
248 /// Map of timing metrics with their duration and measurement time.
249 pub timings: HashMap<&'static str, (Duration, Instant)>,
250 /// List of display indices that were rendered in the current frame.
251 pub rendered_indices: Vec<usize>,
252}
253impl PerformanceMetrics {
254 pub fn record<F, R>(&mut self, name: &'static str, mut f: F) -> R
255 where
256 F: FnMut() -> R,
257 {
258 let start_time = Instant::now();
259 let ret = f();
260 let duration = start_time.elapsed();
261 self.timings.insert(name, (duration, Instant::now()));
262 ret
263 }
264
265 /// Displays all metrics sorted A-Z in format: `{name}: {took}ms {ago}s ago`
266 pub fn display(&self) -> String {
267 let mut metrics = self.timings.iter().collect::<Vec<_>>();
268 metrics.sort_by_key(|&(name, _)| *name);
269 metrics
270 .iter()
271 .map(|(name, (duration, time))| {
272 let took = duration.as_secs_f32() * 1000.;
273 let ago = time.elapsed().as_secs();
274 format!("{name}: {took:.2}ms {ago}s ago")
275 })
276 .collect::<Vec<_>>()
277 .join("\n")
278 }
279
280 /// Get timing for a specific metric
281 pub fn get_timing(&self, name: &str) -> Option<Duration> {
282 self.timings.get(name).map(|(duration, _)| *duration)
283 }
284}
285
286/// Holds state of column widths for a table component in CSV preview.
287pub(crate) struct ColumnWidths {
288 pub widths: Entity<TableColumnWidths>,
289}
290
291impl ColumnWidths {
292 pub(crate) fn new(cx: &mut Context<CsvPreviewView>, cols: usize) -> Self {
293 Self {
294 widths: cx.new(|cx| TableColumnWidths::new(cols, cx)),
295 }
296 }
297 /// Replace the current `TableColumnWidths` entity with a new one for the given column count.
298 pub(crate) fn replace(&self, cx: &mut Context<CsvPreviewView>, cols: usize) {
299 self.widths
300 .update(cx, |entity, cx| *entity = TableColumnWidths::new(cols, cx));
301 }
302}