diff --git a/Cargo.lock b/Cargo.lock index c4dcfa054efa372259880c3a813a5d203e9c1be7..99347bd08f0d5b3ae13ab352612e3876a3cf6a11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4340,6 +4340,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "csv_preview" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "feature_flags", + "gpui", + "log", + "text", + "ui", + "workspace", +] + [[package]] name = "ctor" version = "0.4.3" @@ -21727,6 +21741,7 @@ dependencies = [ "copilot_chat", "copilot_ui", "crashes", + "csv_preview", "dap", "dap_adapters", "db", diff --git a/Cargo.toml b/Cargo.toml index 98fccfaeb21bc6107323378605c8299d5bd5838f..8e1312f032e19b2c2c189677f144f04dd7f4589c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ members = [ "crates/copilot_chat", "crates/crashes", "crates/credentials_provider", + "crates/csv_preview", "crates/dap", "crates/dap_adapters", "crates/db", @@ -298,6 +299,7 @@ copilot_ui = { path = "crates/copilot_ui" } crashes = { path = "crates/crashes" } credentials_provider = { path = "crates/credentials_provider" } crossbeam = "0.8.4" +csv_preview = { path = "crates/csv_preview"} dap = { path = "crates/dap" } dap_adapters = { path = "crates/dap_adapters" } db = { path = "crates/db" } diff --git a/crates/csv_preview/Cargo.toml b/crates/csv_preview/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7e9ce2c4d515cfce9586a0686475a8dfed0ddc95 --- /dev/null +++ b/crates/csv_preview/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "csv_preview" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[lib] +path = "src/csv_preview.rs" + +[dependencies] +anyhow.workspace = true +feature_flags.workspace = true +gpui.workspace = true +editor.workspace = true +ui.workspace = true +workspace.workspace = true +log.workspace = true +text.workspace = true + +[lints] +workspace = true diff --git a/crates/csv_preview/LICENSE-GPL b/crates/csv_preview/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/csv_preview/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs new file mode 100644 index 0000000000000000000000000000000000000000..f056f5a12225b000527b9087760e3d683bda1b5b --- /dev/null +++ b/crates/csv_preview/src/csv_preview.rs @@ -0,0 +1,302 @@ +use editor::{Editor, EditorEvent}; +use feature_flags::{FeatureFlag, FeatureFlagAppExt as _}; +use gpui::{ + AppContext, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, Task, actions, +}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; + +use crate::table_data_engine::TableDataEngine; +use ui::{SharedString, TableColumnWidths, TableInteractionState, prelude::*}; +use workspace::{Item, SplitDirection, Workspace}; + +use crate::{parser::EditorState, settings::CsvPreviewSettings, types::TableLikeContent}; + +mod parser; +mod renderer; +mod settings; +mod table_data_engine; +mod types; + +actions!(csv, [OpenPreview, OpenPreviewToTheSide]); + +pub struct TabularDataPreviewFeatureFlag; + +impl FeatureFlag for TabularDataPreviewFeatureFlag { + const NAME: &'static str = "tabular-data-preview"; +} + +pub struct CsvPreviewView { + pub(crate) engine: TableDataEngine, + + pub(crate) focus_handle: FocusHandle, + active_editor_state: EditorState, + pub(crate) table_interaction_state: Entity, + pub(crate) column_widths: ColumnWidths, + pub(crate) parsing_task: Option>>, + pub(crate) settings: CsvPreviewSettings, + /// Performance metrics for debugging and monitoring CSV operations. + pub(crate) performance_metrics: PerformanceMetrics, + pub(crate) list_state: gpui::ListState, + /// Time when the last parsing operation ended, used for smart debouncing + pub(crate) last_parse_end_time: Option, +} + +pub fn init(cx: &mut App) { + cx.observe_new(|workspace: &mut Workspace, _, _| { + CsvPreviewView::register(workspace); + }) + .detach() +} + +impl CsvPreviewView { + pub fn register(workspace: &mut Workspace) { + workspace.register_action_renderer(|div, _, _, cx| { + div.when(cx.has_flag::(), |div| { + div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| { + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .filter(|editor| Self::is_csv_file(editor, cx)) + { + let csv_preview = Self::new(&editor, cx); + workspace.active_pane().update(cx, |pane, cx| { + let existing = pane + .items_of_type::() + .find(|view| view.read(cx).active_editor_state.editor == editor); + if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { + pane.activate_item(idx, true, true, window, cx); + } else { + pane.add_item(Box::new(csv_preview), true, true, None, window, cx); + } + }); + cx.notify(); + } + })) + .on_action(cx.listener( + |workspace, _: &OpenPreviewToTheSide, window, cx| { + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .filter(|editor| Self::is_csv_file(editor, cx)) + { + let csv_preview = Self::new(&editor, cx); + let pane = workspace + .find_pane_in_direction(SplitDirection::Right, cx) + .unwrap_or_else(|| { + workspace.split_pane( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ) + }); + pane.update(cx, |pane, cx| { + let existing = + pane.items_of_type::().find(|view| { + view.read(cx).active_editor_state.editor == editor + }); + if let Some(idx) = existing.and_then(|e| pane.index_for_item(&e)) { + pane.activate_item(idx, true, true, window, cx); + } else { + pane.add_item( + Box::new(csv_preview), + false, + false, + None, + window, + cx, + ); + } + }); + cx.notify(); + } + }, + )) + }) + }); + } + + fn new(editor: &Entity, cx: &mut Context) -> Entity { + let contents = TableLikeContent::default(); + let table_interaction_state = cx.new(|cx| { + TableInteractionState::new(cx) + .with_custom_scrollbar(ui::Scrollbars::for_settings::()) + }); + + cx.new(|cx| { + let subscription = cx.subscribe( + editor, + |this: &mut CsvPreviewView, _editor, event: &EditorEvent, cx| { + match event { + EditorEvent::Edited { .. } + | EditorEvent::DirtyChanged + | EditorEvent::ExcerptsEdited { .. } => { + this.parse_csv_from_active_editor(true, cx); + } + _ => {} + }; + }, + ); + + let mut view = CsvPreviewView { + focus_handle: cx.focus_handle(), + active_editor_state: EditorState { + editor: editor.clone(), + _subscription: subscription, + }, + table_interaction_state, + column_widths: ColumnWidths::new(cx, 1), + parsing_task: None, + performance_metrics: PerformanceMetrics::default(), + list_state: gpui::ListState::new(contents.rows.len(), ListAlignment::Top, px(1.)), + settings: CsvPreviewSettings::default(), + last_parse_end_time: None, + engine: TableDataEngine::default(), + }; + + view.parse_csv_from_active_editor(false, cx); + view + }) + } + + pub(crate) fn editor_state(&self) -> &EditorState { + &self.active_editor_state + } + pub(crate) fn apply_sort(&mut self) { + self.performance_metrics.record("Sort", || { + self.engine.apply_sort(); + }); + } + + /// Update ordered indices when ordering or content changes + pub(crate) fn apply_filter_sort(&mut self) { + self.performance_metrics.record("Filter&sort", || { + self.engine.calculate_d2d_mapping(); + }); + + // Update list state with filtered row count + let visible_rows = self.engine.d2d_mapping().visible_row_count(); + self.list_state = gpui::ListState::new(visible_rows, ListAlignment::Top, px(1.)); + } + + pub fn resolve_active_item_as_csv_editor( + workspace: &Workspace, + cx: &mut Context, + ) -> Option> { + let editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx))?; + Self::is_csv_file(&editor, cx).then_some(editor) + } + + fn is_csv_file(editor: &Entity, cx: &App) -> bool { + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .and_then(|buffer| { + buffer + .read(cx) + .file() + .and_then(|file| file.path().extension()) + .map(|ext| ext.eq_ignore_ascii_case("csv")) + }) + .unwrap_or(false) + } +} + +impl Focusable for CsvPreviewView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter<()> for CsvPreviewView {} + +impl Item for CsvPreviewView { + type Event = (); + + fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { + Some(Icon::new(IconName::FileDoc)) + } + + fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { + self.editor_state() + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .and_then(|b| { + let file = b.read(cx).file()?; + let local_file = file.as_local()?; + local_file + .abs_path(cx) + .file_name() + .map(|name| format!("Preview {}", name.to_string_lossy()).into()) + }) + .unwrap_or_else(|| SharedString::from("CSV Preview")) + } +} + +#[derive(Debug, Default)] +pub struct PerformanceMetrics { + /// Map of timing metrics with their duration and measurement time. + pub timings: HashMap<&'static str, (Duration, Instant)>, + /// List of display indices that were rendered in the current frame. + pub rendered_indices: Vec, +} +impl PerformanceMetrics { + pub fn record(&mut self, name: &'static str, mut f: F) -> R + where + F: FnMut() -> R, + { + let start_time = Instant::now(); + let ret = f(); + let duration = start_time.elapsed(); + self.timings.insert(name, (duration, Instant::now())); + ret + } + + /// Displays all metrics sorted A-Z in format: `{name}: {took}ms {ago}s ago` + pub fn display(&self) -> String { + let mut metrics = self.timings.iter().collect::>(); + metrics.sort_by_key(|&(name, _)| *name); + metrics + .iter() + .map(|(name, (duration, time))| { + let took = duration.as_secs_f32() * 1000.; + let ago = time.elapsed().as_secs(); + format!("{name}: {took:.2}ms {ago}s ago") + }) + .collect::>() + .join("\n") + } + + /// Get timing for a specific metric + pub fn get_timing(&self, name: &str) -> Option { + self.timings.get(name).map(|(duration, _)| *duration) + } +} + +/// Holds state of column widths for a table component in CSV preview. +pub(crate) struct ColumnWidths { + pub widths: Entity, +} + +impl ColumnWidths { + pub(crate) fn new(cx: &mut Context, cols: usize) -> Self { + Self { + widths: cx.new(|cx| TableColumnWidths::new(cols, cx)), + } + } + /// Replace the current `TableColumnWidths` entity with a new one for the given column count. + pub(crate) fn replace(&self, cx: &mut Context, cols: usize) { + self.widths + .update(cx, |entity, cx| *entity = TableColumnWidths::new(cols, cx)); + } +} diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs new file mode 100644 index 0000000000000000000000000000000000000000..b087404e0ebbd13cdaf20cab692f5470ea6ce292 --- /dev/null +++ b/crates/csv_preview/src/parser.rs @@ -0,0 +1,513 @@ +use crate::{ + CsvPreviewView, + types::TableLikeContent, + types::{LineNumber, TableCell}, +}; +use editor::Editor; +use gpui::{AppContext, Context, Entity, Subscription, Task}; +use std::time::{Duration, Instant}; +use text::BufferSnapshot; +use ui::{SharedString, table_row::TableRow}; + +pub(crate) const REPARSE_DEBOUNCE: Duration = Duration::from_millis(200); + +pub(crate) struct EditorState { + pub editor: Entity, + pub _subscription: Subscription, +} + +impl CsvPreviewView { + pub(crate) fn parse_csv_from_active_editor( + &mut self, + wait_for_debounce: bool, + cx: &mut Context, + ) { + let editor = self.active_editor_state.editor.clone(); + self.parsing_task = Some(self.parse_csv_in_background(wait_for_debounce, editor, cx)); + } + + fn parse_csv_in_background( + &mut self, + wait_for_debounce: bool, + editor: Entity, + cx: &mut Context, + ) -> Task> { + cx.spawn(async move |view, cx| { + if wait_for_debounce { + // Smart debouncing: check if cooldown period has already passed + let now = Instant::now(); + let should_wait = view.update(cx, |view, _| { + if let Some(last_end) = view.last_parse_end_time { + let cooldown_until = last_end + REPARSE_DEBOUNCE; + if now < cooldown_until { + Some(cooldown_until - now) + } else { + None // Cooldown already passed, parse immediately + } + } else { + None // First parse, no debounce + } + })?; + + if let Some(wait_duration) = should_wait { + cx.background_executor().timer(wait_duration).await; + } + } + + let buffer_snapshot = view.update(cx, |_, cx| { + editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .map(|b| b.read(cx).text_snapshot()) + })?; + + let Some(buffer_snapshot) = buffer_snapshot else { + return Ok(()); + }; + + let instant = Instant::now(); + let parsed_csv = cx + .background_spawn(async move { from_buffer(&buffer_snapshot) }) + .await; + let parse_duration = instant.elapsed(); + let parse_end_time: Instant = Instant::now(); + log::debug!("Parsed CSV in {}ms", parse_duration.as_millis()); + view.update(cx, move |view, cx| { + view.performance_metrics + .timings + .insert("Parsing", (parse_duration, Instant::now())); + + log::debug!("Parsed {} rows", parsed_csv.rows.len()); + // Update table width so it can be rendered properly + let cols = parsed_csv.headers.cols(); + view.column_widths.replace(cx, cols + 1); // Add 1 for the line number column + + view.engine.contents = parsed_csv; + view.last_parse_end_time = Some(parse_end_time); + + view.apply_filter_sort(); + cx.notify(); + }) + }) + } +} + +pub fn from_buffer(buffer_snapshot: &BufferSnapshot) -> TableLikeContent { + let text = buffer_snapshot.text(); + + if text.trim().is_empty() { + return TableLikeContent::default(); + } + + let (parsed_cells_with_positions, line_numbers) = parse_csv_with_positions(&text); + if parsed_cells_with_positions.is_empty() { + return TableLikeContent::default(); + } + let raw_headers = parsed_cells_with_positions[0].clone(); + + // Calculating the longest row, as CSV might have less headers than max row width + let Some(max_number_of_cols) = parsed_cells_with_positions.iter().map(|r| r.len()).max() else { + return TableLikeContent::default(); + }; + + // Convert to TableCell objects with buffer positions + let headers = create_table_row(&buffer_snapshot, max_number_of_cols, raw_headers); + + let rows = parsed_cells_with_positions + .into_iter() + .skip(1) + .map(|row| create_table_row(&buffer_snapshot, max_number_of_cols, row)) + .collect(); + + let row_line_numbers = line_numbers.into_iter().skip(1).collect(); + + TableLikeContent { + headers, + rows, + line_numbers: row_line_numbers, + number_of_cols: max_number_of_cols, + } +} + +/// Parse CSV and track byte positions for each cell +fn parse_csv_with_positions( + text: &str, +) -> ( + Vec)>>, + Vec, +) { + let mut rows = Vec::new(); + let mut line_numbers = Vec::new(); + let mut current_row: Vec<(SharedString, std::ops::Range)> = Vec::new(); + let mut current_field = String::new(); + let mut field_start_offset = 0; + let mut current_offset = 0; + let mut in_quotes = false; + let mut current_line = 1; // 1-based line numbering + let mut row_start_line = 1; + let mut chars = text.chars().peekable(); + + while let Some(ch) = chars.next() { + let char_byte_len = ch.len_utf8(); + + match ch { + '"' => { + if in_quotes { + if chars.peek() == Some(&'"') { + // Escaped quote + chars.next(); + current_field.push('"'); + current_offset += 1; // Skip the second quote + } else { + // End of quoted field + in_quotes = false; + } + } else { + // Start of quoted field + in_quotes = true; + if current_field.is_empty() { + // Include the opening quote in the range + field_start_offset = current_offset; + } + } + } + ',' if !in_quotes => { + // Field separator + let field_end_offset = current_offset; + if current_field.is_empty() && !in_quotes { + field_start_offset = current_offset; + } + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + current_field.clear(); + field_start_offset = current_offset + char_byte_len; + } + '\n' => { + current_line += 1; + if !in_quotes { + // Row separator (only when not inside quotes) + let field_end_offset = current_offset; + if current_field.is_empty() && current_row.is_empty() { + field_start_offset = 0; + } + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + current_field.clear(); + + // Only add non-empty rows + if !current_row.is_empty() + && !current_row.iter().all(|(field, _)| field.trim().is_empty()) + { + rows.push(current_row); + // Add line number info for this row + let line_info = if row_start_line == current_line - 1 { + LineNumber::Line(row_start_line) + } else { + LineNumber::LineRange(row_start_line, current_line - 1) + }; + line_numbers.push(line_info); + } + current_row = Vec::new(); + row_start_line = current_line; + field_start_offset = current_offset + char_byte_len; + } else { + // Newline inside quotes - preserve it + current_field.push(ch); + } + } + '\r' => { + if chars.peek() == Some(&'\n') { + // Handle Windows line endings (\r\n): account for \r byte, let \n be handled next + current_offset += char_byte_len; + continue; + } else { + // Standalone \r + current_line += 1; + if !in_quotes { + // Row separator (only when not inside quotes) + let field_end_offset = current_offset; + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + current_field.clear(); + + // Only add non-empty rows + if !current_row.is_empty() + && !current_row.iter().all(|(field, _)| field.trim().is_empty()) + { + rows.push(current_row); + // Add line number info for this row + let line_info = if row_start_line == current_line - 1 { + LineNumber::Line(row_start_line) + } else { + LineNumber::LineRange(row_start_line, current_line - 1) + }; + line_numbers.push(line_info); + } + current_row = Vec::new(); + row_start_line = current_line; + field_start_offset = current_offset + char_byte_len; + } else { + // \r inside quotes - preserve it + current_field.push(ch); + } + } + } + _ => { + if current_field.is_empty() && !in_quotes { + field_start_offset = current_offset; + } + current_field.push(ch); + } + } + + current_offset += char_byte_len; + } + + // Add the last field and row if not empty + if !current_field.is_empty() || !current_row.is_empty() { + let field_end_offset = current_offset; + current_row.push(( + current_field.clone().into(), + field_start_offset..field_end_offset, + )); + } + if !current_row.is_empty() && !current_row.iter().all(|(field, _)| field.trim().is_empty()) { + rows.push(current_row); + // Add line number info for the last row + let line_info = if row_start_line == current_line { + LineNumber::Line(row_start_line) + } else { + LineNumber::LineRange(row_start_line, current_line) + }; + line_numbers.push(line_info); + } + + (rows, line_numbers) +} + +fn create_table_row( + buffer_snapshot: &BufferSnapshot, + max_number_of_cols: usize, + row: Vec<(SharedString, std::ops::Range)>, +) -> TableRow { + let mut raw_row = row + .into_iter() + .map(|(content, range)| { + TableCell::from_buffer_position(content, range.start, range.end, &buffer_snapshot) + }) + .collect::>(); + + let append_elements = max_number_of_cols - raw_row.len(); + if append_elements > 0 { + for _ in 0..append_elements { + raw_row.push(TableCell::Virtual); + } + } + + TableRow::from_vec(raw_row, max_number_of_cols) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_csv_parsing_basic() { + let csv_data = "Name,Age,City\nJohn,30,New York\nJane,25,Los Angeles"; + let parsed = TableLikeContent::from_str(csv_data.to_string()); + + assert_eq!(parsed.headers.cols(), 3); + assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name"); + assert_eq!(parsed.headers[1].display_value().unwrap().as_ref(), "Age"); + assert_eq!(parsed.headers[2].display_value().unwrap().as_ref(), "City"); + + assert_eq!(parsed.rows.len(), 2); + assert_eq!(parsed.rows[0][0].display_value().unwrap().as_ref(), "John"); + assert_eq!(parsed.rows[0][1].display_value().unwrap().as_ref(), "30"); + assert_eq!( + parsed.rows[0][2].display_value().unwrap().as_ref(), + "New York" + ); + } + + #[test] + fn test_csv_parsing_with_quotes() { + let csv_data = r#"Name,Description +"John Doe","A person with ""special"" characters" +Jane,"Simple name""#; + let parsed = TableLikeContent::from_str(csv_data.to_string()); + + assert_eq!(parsed.headers.cols(), 2); + assert_eq!(parsed.rows.len(), 2); + assert_eq!( + parsed.rows[0][1].display_value().unwrap().as_ref(), + r#"A person with "special" characters"# + ); + } + + #[test] + fn test_csv_parsing_with_newlines_in_quotes() { + let csv_data = "Name,Description,Status\n\"John\nDoe\",\"A person with\nmultiple lines\",Active\n\"Jane Smith\",\"Simple\",\"Also\nActive\""; + let parsed = TableLikeContent::from_str(csv_data.to_string()); + + assert_eq!(parsed.headers.cols(), 3); + assert_eq!(parsed.headers[0].display_value().unwrap().as_ref(), "Name"); + assert_eq!( + parsed.headers[1].display_value().unwrap().as_ref(), + "Description" + ); + assert_eq!( + parsed.headers[2].display_value().unwrap().as_ref(), + "Status" + ); + + assert_eq!(parsed.rows.len(), 2); + assert_eq!( + parsed.rows[0][0].display_value().unwrap().as_ref(), + "John\nDoe" + ); + assert_eq!( + parsed.rows[0][1].display_value().unwrap().as_ref(), + "A person with\nmultiple lines" + ); + assert_eq!( + parsed.rows[0][2].display_value().unwrap().as_ref(), + "Active" + ); + + assert_eq!( + parsed.rows[1][0].display_value().unwrap().as_ref(), + "Jane Smith" + ); + assert_eq!( + parsed.rows[1][1].display_value().unwrap().as_ref(), + "Simple" + ); + assert_eq!( + parsed.rows[1][2].display_value().unwrap().as_ref(), + "Also\nActive" + ); + + // Check line numbers + assert_eq!(parsed.line_numbers.len(), 2); + match &parsed.line_numbers[0] { + LineNumber::LineRange(start, end) => { + assert_eq!(start, &2); + assert_eq!(end, &4); + } + _ => panic!("Expected LineRange for multiline row"), + } + match &parsed.line_numbers[1] { + LineNumber::LineRange(start, end) => { + assert_eq!(start, &5); + assert_eq!(end, &6); + } + _ => panic!("Expected LineRange for second multiline row"), + } + } + + #[test] + fn test_empty_csv() { + let parsed = TableLikeContent::from_str("".to_string()); + assert_eq!(parsed.headers.cols(), 0); + assert!(parsed.rows.is_empty()); + } + + #[test] + fn test_csv_parsing_quote_offset_handling() { + let csv_data = r#"first,"se,cond",third"#; + let (parsed_cells, _) = parse_csv_with_positions(csv_data); + + assert_eq!(parsed_cells.len(), 1); // One row + assert_eq!(parsed_cells[0].len(), 3); // Three cells + + // first: 0..5 (no quotes) + let (content1, range1) = &parsed_cells[0][0]; + assert_eq!(content1.as_ref(), "first"); + assert_eq!(*range1, 0..5); + + // "se,cond": 6..15 (includes quotes in range, content without quotes) + let (content2, range2) = &parsed_cells[0][1]; + assert_eq!(content2.as_ref(), "se,cond"); + assert_eq!(*range2, 6..15); + + // third: 16..21 (no quotes) + let (content3, range3) = &parsed_cells[0][2]; + assert_eq!(content3.as_ref(), "third"); + assert_eq!(*range3, 16..21); + } + + #[test] + fn test_csv_parsing_complex_quotes() { + let csv_data = r#"id,"name with spaces","description, with commas",status +1,"John Doe","A person with ""quotes"" and, commas",active +2,"Jane Smith","Simple description",inactive"#; + let (parsed_cells, _) = parse_csv_with_positions(csv_data); + + assert_eq!(parsed_cells.len(), 3); // header + 2 rows + + // Check header row + let header_row = &parsed_cells[0]; + assert_eq!(header_row.len(), 4); + + // id: 0..2 + assert_eq!(header_row[0].0.as_ref(), "id"); + assert_eq!(header_row[0].1, 0..2); + + // "name with spaces": 3..21 (includes quotes) + assert_eq!(header_row[1].0.as_ref(), "name with spaces"); + assert_eq!(header_row[1].1, 3..21); + + // "description, with commas": 22..48 (includes quotes) + assert_eq!(header_row[2].0.as_ref(), "description, with commas"); + assert_eq!(header_row[2].1, 22..48); + + // status: 49..55 + assert_eq!(header_row[3].0.as_ref(), "status"); + assert_eq!(header_row[3].1, 49..55); + + // Check first data row + let first_row = &parsed_cells[1]; + assert_eq!(first_row.len(), 4); + + // 1: 56..57 + assert_eq!(first_row[0].0.as_ref(), "1"); + assert_eq!(first_row[0].1, 56..57); + + // "John Doe": 58..68 (includes quotes) + assert_eq!(first_row[1].0.as_ref(), "John Doe"); + assert_eq!(first_row[1].1, 58..68); + + // Content should be stripped of quotes but include escaped quotes + assert_eq!( + first_row[2].0.as_ref(), + r#"A person with "quotes" and, commas"# + ); + // The range should include the outer quotes: 69..107 + assert_eq!(first_row[2].1, 69..107); + + // active: 108..114 + assert_eq!(first_row[3].0.as_ref(), "active"); + assert_eq!(first_row[3].1, 108..114); + } +} + +impl TableLikeContent { + #[cfg(test)] + pub fn from_str(text: String) -> Self { + use text::{Buffer, BufferId, ReplicaId}; + + let buffer_id = BufferId::new(1).unwrap(); + let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, text); + let snapshot = buffer.snapshot(); + from_buffer(snapshot) + } +} diff --git a/crates/csv_preview/src/renderer.rs b/crates/csv_preview/src/renderer.rs new file mode 100644 index 0000000000000000000000000000000000000000..42ae05936c7ebd3fb9c619793376998b6d33e2c1 --- /dev/null +++ b/crates/csv_preview/src/renderer.rs @@ -0,0 +1,5 @@ +mod preview_view; +mod render_table; +mod row_identifiers; +mod table_cell; +mod table_header; diff --git a/crates/csv_preview/src/renderer/preview_view.rs b/crates/csv_preview/src/renderer/preview_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..55e62d03806b578f59c2542cf997f90ec22a1f8f --- /dev/null +++ b/crates/csv_preview/src/renderer/preview_view.rs @@ -0,0 +1,50 @@ +use std::time::Instant; + +use ui::{div, prelude::*}; + +use crate::{CsvPreviewView, settings::FontType}; + +impl Render for CsvPreviewView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + + self.performance_metrics.rendered_indices.clear(); + let render_prep_start = Instant::now(); + let table_with_settings = v_flex() + .size_full() + .p_4() + .bg(theme.colors().editor_background) + .track_focus(&self.focus_handle) + .child({ + if self.engine.contents.number_of_cols == 0 { + div() + .flex() + .items_center() + .justify_center() + .h_32() + .text_ui(cx) + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .text_color(cx.theme().colors().text_muted) + .child("No CSV content to display") + .into_any_element() + } else { + self.create_table(&self.column_widths.widths, cx) + } + }); + + let render_prep_duration = render_prep_start.elapsed(); + self.performance_metrics.timings.insert( + "render_prep", + (render_prep_duration, std::time::Instant::now()), + ); + + div() + .relative() + .w_full() + .h_full() + .child(table_with_settings) + } +} diff --git a/crates/csv_preview/src/renderer/render_table.rs b/crates/csv_preview/src/renderer/render_table.rs new file mode 100644 index 0000000000000000000000000000000000000000..0cc3bc3c46fb24570b3c99c9121dff3860c6b820 --- /dev/null +++ b/crates/csv_preview/src/renderer/render_table.rs @@ -0,0 +1,193 @@ +use crate::types::TableCell; +use gpui::{AnyElement, Entity}; +use std::ops::Range; +use ui::Table; +use ui::TableColumnWidths; +use ui::TableResizeBehavior; +use ui::UncheckedTableRow; +use ui::{DefiniteLength, div, prelude::*}; + +use crate::{ + CsvPreviewView, + settings::RowRenderMechanism, + types::{AnyColumn, DisplayCellId, DisplayRow}, +}; + +impl CsvPreviewView { + /// Creates a new table. + /// Column number is derived from the `TableColumnWidths` entity. + pub(crate) fn create_table( + &self, + current_widths: &Entity, + cx: &mut Context, + ) -> AnyElement { + let cols = current_widths.read(cx).cols(); + let remaining_col_number = cols - 1; + let fraction = if remaining_col_number > 0 { + 1. / remaining_col_number as f32 + } else { + 1. // only column with line numbers is present. Put 100%, but it will be overwritten anyways :D + }; + let mut widths = vec![DefiniteLength::Fraction(fraction); cols]; + let line_number_width = self.calculate_row_identifier_column_width(); + widths[0] = DefiniteLength::Absolute(AbsoluteLength::Pixels(line_number_width.into())); + + let mut resize_behaviors = vec![TableResizeBehavior::Resizable; cols]; + resize_behaviors[0] = TableResizeBehavior::None; + + self.create_table_inner( + self.engine.contents.rows.len(), + widths, + resize_behaviors, + current_widths, + cx, + ) + } + + fn create_table_inner( + &self, + row_count: usize, + widths: UncheckedTableRow, + resize_behaviors: UncheckedTableRow, + current_widths: &Entity, + cx: &mut Context, + ) -> AnyElement { + let cols = widths.len(); + // Create headers array with interactive elements + let mut headers = Vec::with_capacity(cols); + + headers.push(self.create_row_identifier_header(cx)); + + // Add the actual CSV headers with sort buttons + for i in 0..(cols - 1) { + let header_text = self + .engine + .contents + .headers + .get(AnyColumn(i)) + .and_then(|h| h.display_value().cloned()) + .unwrap_or_else(|| format!("Col {}", i + 1).into()); + + headers.push(self.create_header_element_with_sort_button( + header_text, + cx, + AnyColumn::from(i), + )); + } + + Table::new(cols) + .interactable(&self.table_interaction_state) + .striped() + .column_widths(widths) + .resizable_columns(resize_behaviors, current_widths, cx) + .header(headers) + .disable_base_style() + .map(|table| { + let row_identifier_text_color = cx.theme().colors().editor_line_number; + match self.settings.rendering_with { + RowRenderMechanism::VariableList => { + table.variable_row_height_list(row_count, self.list_state.clone(), { + cx.processor(move |this, display_row: usize, _window, cx| { + this.performance_metrics.rendered_indices.push(display_row); + + let display_row = DisplayRow(display_row); + Self::render_single_table_row( + this, + cols, + display_row, + row_identifier_text_color, + cx, + ) + .unwrap_or_else(|| panic!("Expected to render a table row")) + }) + }) + } + RowRenderMechanism::UniformList => { + table.uniform_list("csv-table", row_count, { + cx.processor(move |this, range: Range, _window, cx| { + // Record all display indices in the range for performance metrics + this.performance_metrics + .rendered_indices + .extend(range.clone()); + + range + .filter_map(|display_index| { + Self::render_single_table_row( + this, + cols, + DisplayRow(display_index), + row_identifier_text_color, + cx, + ) + }) + .collect() + }) + }) + } + } + }) + .into_any_element() + } + + /// Render a single table row + /// + /// Used both by UniformList and VariableRowHeightList + fn render_single_table_row( + this: &CsvPreviewView, + cols: usize, + display_row: DisplayRow, + row_identifier_text_color: gpui::Hsla, + cx: &Context, + ) -> Option> { + // Get the actual row index from our sorted indices + let data_row = this.engine.d2d_mapping().get_data_row(display_row)?; + let row = this.engine.contents.get_row(data_row)?; + + let mut elements = Vec::with_capacity(cols); + elements.push(this.create_row_identifier_cell(display_row, data_row, cx)?); + + // Remaining columns: actual CSV data + for col in (0..this.engine.contents.number_of_cols).map(AnyColumn) { + let table_cell = row.expect_get(col); + + // TODO: Introduce `` cell type + let cell_content = table_cell.display_value().cloned().unwrap_or_default(); + + let display_cell_id = DisplayCellId::new(display_row, col); + + let cell = div().size_full().whitespace_nowrap().text_ellipsis().child( + CsvPreviewView::create_selectable_cell( + display_cell_id, + cell_content, + this.settings.vertical_alignment, + this.settings.font_type, + cx, + ), + ); + + elements.push( + div() + .size_full() + .when(this.settings.show_debug_info, |parent| { + parent.child(div().text_color(row_identifier_text_color).child( + match table_cell { + TableCell::Real { position: pos, .. } => { + let slv = pos.start.timestamp().value; + let so = pos.start.offset; + let elv = pos.end.timestamp().value; + let eo = pos.end.offset; + format!("Pos {so}(L{slv})-{eo}(L{elv})") + } + TableCell::Virtual => "Virtual cell".into(), + }, + )) + }) + .text_ui(cx) + .child(cell) + .into_any_element(), + ); + } + + Some(elements) + } +} diff --git a/crates/csv_preview/src/renderer/row_identifiers.rs b/crates/csv_preview/src/renderer/row_identifiers.rs new file mode 100644 index 0000000000000000000000000000000000000000..a122aa9bf3d803b9deb9c6211e117ba4aa593d93 --- /dev/null +++ b/crates/csv_preview/src/renderer/row_identifiers.rs @@ -0,0 +1,189 @@ +use ui::{ + ActiveTheme as _, AnyElement, Button, ButtonCommon as _, ButtonSize, ButtonStyle, + Clickable as _, Context, ElementId, FluentBuilder as _, IntoElement as _, ParentElement as _, + SharedString, Styled as _, StyledTypography as _, Tooltip, div, +}; + +use crate::{ + CsvPreviewView, + settings::{FontType, RowIdentifiers}, + types::{DataRow, DisplayRow, LineNumber}, +}; + +pub enum RowIdentDisplayMode { + /// E.g + /// ```text + /// 1 + /// ... + /// 5 + /// ``` + Vertical, + /// E.g. + /// ```text + /// 1-5 + /// ``` + Horizontal, +} + +impl LineNumber { + pub fn display_string(&self, mode: RowIdentDisplayMode) -> String { + match *self { + LineNumber::Line(line) => line.to_string(), + LineNumber::LineRange(start, end) => match mode { + RowIdentDisplayMode::Vertical => { + if start + 1 == end { + format!("{start}\n{end}") + } else { + format!("{start}\n...\n{end}") + } + } + RowIdentDisplayMode::Horizontal => { + format!("{start}-{end}") + } + }, + } + } +} + +impl CsvPreviewView { + /// Calculate the optimal width for the row identifier column (line numbers or row numbers). + /// + /// This ensures the column is wide enough to display the largest identifier comfortably, + /// but not wastefully wide for small files. + pub(crate) fn calculate_row_identifier_column_width(&self) -> f32 { + match self.settings.numbering_type { + RowIdentifiers::SrcLines => self.calculate_line_number_width(), + RowIdentifiers::RowNum => self.calculate_row_number_width(), + } + } + + /// Calculate width needed for line numbers (can be multi-line) + fn calculate_line_number_width(&self) -> f32 { + // Find the maximum line number that could be displayed + let max_line_number = self + .engine + .contents + .line_numbers + .iter() + .map(|ln| match ln { + LineNumber::Line(n) => *n, + LineNumber::LineRange(_, end) => *end, + }) + .max() + .unwrap_or_default(); + + let digit_count = if max_line_number == 0 { + 1 + } else { + (max_line_number as f32).log10().floor() as usize + 1 + }; + + // if !self.settings.multiline_cells_enabled { + // // Uses horizontal line numbers layout like `123-456`. Needs twice the size + // digit_count *= 2; + // } + + let char_width_px = 9.0; // TODO: get real width of the characters + let base_width = (digit_count as f32) * char_width_px; + let padding = 20.0; + let min_width = 60.0; + (base_width + padding).max(min_width) + } + + /// Calculate width needed for sequential row numbers + fn calculate_row_number_width(&self) -> f32 { + let max_row_number = self.engine.contents.rows.len(); + + let digit_count = if max_row_number == 0 { + 1 + } else { + (max_row_number as f32).log10().floor() as usize + 1 + }; + + let char_width_px = 9.0; // TODO: get real width of the characters + let base_width = (digit_count as f32) * char_width_px; + let padding = 20.0; + let min_width = 60.0; + (base_width + padding).max(min_width) + } + + pub(crate) fn create_row_identifier_header( + &self, + cx: &mut Context<'_, CsvPreviewView>, + ) -> AnyElement { + // First column: row identifier (clickable to toggle between Lines and Rows) + let row_identifier_text = match self.settings.numbering_type { + RowIdentifiers::SrcLines => "Lines", + RowIdentifiers::RowNum => "Rows", + }; + + let view = cx.entity(); + let value = div() + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .child( + Button::new( + ElementId::Name("row-identifier-toggle".into()), + row_identifier_text, + ) + .style(ButtonStyle::Subtle) + .size(ButtonSize::Compact) + .tooltip(Tooltip::text( + "Toggle between: file line numbers or sequential row numbers", + )) + .on_click(move |_event, _window, cx| { + view.update(cx, |this, cx| { + this.settings.numbering_type = match this.settings.numbering_type { + RowIdentifiers::SrcLines => RowIdentifiers::RowNum, + RowIdentifiers::RowNum => RowIdentifiers::SrcLines, + }; + cx.notify(); + }); + }), + ) + .into_any_element(); + value + } + + pub(crate) fn create_row_identifier_cell( + &self, + display_row: DisplayRow, + data_row: DataRow, + cx: &Context<'_, CsvPreviewView>, + ) -> Option { + let row_identifier: SharedString = match self.settings.numbering_type { + RowIdentifiers::SrcLines => self + .engine + .contents + .line_numbers + .get(*data_row)? + .display_string(if self.settings.multiline_cells_enabled { + RowIdentDisplayMode::Vertical + } else { + RowIdentDisplayMode::Horizontal + }) + .into(), + RowIdentifiers::RowNum => (*display_row + 1).to_string().into(), + }; + + let value = div() + .flex() + .px_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .h_full() + .text_ui(cx) + // Row identifiers are always centered + .items_center() + .justify_end() + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .child(row_identifier) + .into_any_element(); + Some(value) + } +} diff --git a/crates/csv_preview/src/renderer/table_cell.rs b/crates/csv_preview/src/renderer/table_cell.rs new file mode 100644 index 0000000000000000000000000000000000000000..32900ab77708936e218e9af10a4de5fba796e6a7 --- /dev/null +++ b/crates/csv_preview/src/renderer/table_cell.rs @@ -0,0 +1,72 @@ +//! Table Cell Rendering + +use gpui::{AnyElement, ElementId}; +use ui::{SharedString, Tooltip, div, prelude::*}; + +use crate::{ + CsvPreviewView, + settings::{FontType, VerticalAlignment}, + types::DisplayCellId, +}; + +impl CsvPreviewView { + /// Create selectable table cell with mouse event handlers. + pub fn create_selectable_cell( + display_cell_id: DisplayCellId, + cell_content: SharedString, + vertical_alignment: VerticalAlignment, + font_type: FontType, + cx: &Context, + ) -> AnyElement { + create_table_cell( + display_cell_id, + cell_content, + vertical_alignment, + font_type, + cx, + ) + // Mouse events handlers will be here + .into_any_element() + } +} + +/// Create styled table cell div element. +fn create_table_cell( + display_cell_id: DisplayCellId, + cell_content: SharedString, + vertical_alignment: VerticalAlignment, + font_type: FontType, + cx: &Context<'_, CsvPreviewView>, +) -> gpui::Stateful
{ + div() + .id(ElementId::NamedInteger( + format!( + "csv-display-cell-{}-{}", + *display_cell_id.row, *display_cell_id.col + ) + .into(), + 0, + )) + .cursor_pointer() + .flex() + .h_full() + .px_1() + .bg(cx.theme().colors().editor_background) + .border_b_1() + .border_r_1() + .border_color(cx.theme().colors().border_variant) + .map(|div| match vertical_alignment { + VerticalAlignment::Top => div.items_start(), + VerticalAlignment::Center => div.items_center(), + }) + .map(|div| match vertical_alignment { + VerticalAlignment::Top => div.content_start(), + VerticalAlignment::Center => div.content_center(), + }) + .map(|div| match font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .tooltip(Tooltip::text(cell_content.clone())) + .child(div().child(cell_content)) +} diff --git a/crates/csv_preview/src/renderer/table_header.rs b/crates/csv_preview/src/renderer/table_header.rs new file mode 100644 index 0000000000000000000000000000000000000000..52a16be9fc81ef1c3f001513b652a33c3b06dc82 --- /dev/null +++ b/crates/csv_preview/src/renderer/table_header.rs @@ -0,0 +1,94 @@ +use gpui::ElementId; +use ui::{Tooltip, prelude::*}; + +use crate::{ + CsvPreviewView, + settings::FontType, + table_data_engine::sorting_by_column::{AppliedSorting, SortDirection}, + types::AnyColumn, +}; + +impl CsvPreviewView { + /// Create header for data, which is orderable with text on the left and sort button on the right + pub(crate) fn create_header_element_with_sort_button( + &self, + header_text: SharedString, + cx: &mut Context<'_, CsvPreviewView>, + col_idx: AnyColumn, + ) -> AnyElement { + // CSV data columns: text + filter/sort buttons + h_flex() + .justify_between() + .items_center() + .w_full() + .map(|div| match self.settings.font_type { + FontType::Ui => div.font_ui(cx), + FontType::Monospace => div.font_buffer(cx), + }) + .child(div().child(header_text)) + .child(h_flex().gap_1().child(self.create_sort_button(cx, col_idx))) + .into_any_element() + } + + fn create_sort_button( + &self, + cx: &mut Context<'_, CsvPreviewView>, + col_idx: AnyColumn, + ) -> Button { + let sort_btn = Button::new( + ElementId::NamedInteger("sort-button".into(), col_idx.get() as u64), + match self.engine.applied_sorting { + Some(ordering) if ordering.col_idx == col_idx => match ordering.direction { + SortDirection::Asc => "↓", + SortDirection::Desc => "↑", + }, + _ => "↕", // Unsorted/available for sorting + }, + ) + .size(ButtonSize::Compact) + .style( + if self + .engine + .applied_sorting + .is_some_and(|o| o.col_idx == col_idx) + { + ButtonStyle::Filled + } else { + ButtonStyle::Subtle + }, + ) + .tooltip(Tooltip::text(match self.engine.applied_sorting { + Some(ordering) if ordering.col_idx == col_idx => match ordering.direction { + SortDirection::Asc => "Sorted A-Z. Click to sort Z-A", + SortDirection::Desc => "Sorted Z-A. Click to disable sorting", + }, + _ => "Not sorted. Click to sort A-Z", + })) + .on_click(cx.listener(move |this, _event, _window, cx| { + let new_sorting = match this.engine.applied_sorting { + Some(ordering) if ordering.col_idx == col_idx => { + // Same column clicked - cycle through states + match ordering.direction { + SortDirection::Asc => Some(AppliedSorting { + col_idx, + direction: SortDirection::Desc, + }), + SortDirection::Desc => None, // Clear sorting + } + } + _ => { + // Different column or no sorting - start with ascending + Some(AppliedSorting { + col_idx, + direction: SortDirection::Asc, + }) + } + }; + + this.engine.applied_sorting = new_sorting; + this.apply_sort(); + cx.notify(); + })); + sort_btn + } +} diff --git a/crates/csv_preview/src/settings.rs b/crates/csv_preview/src/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..e627b3cc994a84f54268a05ba17534789f631fe0 --- /dev/null +++ b/crates/csv_preview/src/settings.rs @@ -0,0 +1,46 @@ +#[derive(Default, Clone, Copy)] +pub enum RowRenderMechanism { + /// Default behaviour + #[default] + VariableList, + /// More performance oriented, but all rows are same height + #[allow(dead_code)] // Will be used when settings ui is added + UniformList, +} + +#[derive(Default, Clone, Copy)] +pub enum VerticalAlignment { + /// Align text to the top of cells + #[default] + Top, + /// Center text vertically in cells + Center, +} + +#[derive(Default, Clone, Copy)] +pub enum FontType { + /// Use the default UI font + #[default] + Ui, + /// Use monospace font (same as buffer/editor font) + Monospace, +} + +#[derive(Default, Clone, Copy)] +pub enum RowIdentifiers { + /// Show original line numbers from CSV file + #[default] + SrcLines, + /// Show sequential row numbers starting from 1 + RowNum, +} + +#[derive(Clone, Default)] +pub(crate) struct CsvPreviewSettings { + pub(crate) rendering_with: RowRenderMechanism, + pub(crate) vertical_alignment: VerticalAlignment, + pub(crate) font_type: FontType, + pub(crate) numbering_type: RowIdentifiers, + pub(crate) show_debug_info: bool, + pub(crate) multiline_cells_enabled: bool, +} diff --git a/crates/csv_preview/src/table_data_engine.rs b/crates/csv_preview/src/table_data_engine.rs new file mode 100644 index 0000000000000000000000000000000000000000..382b41a28507213dcc5993adb49a1fddc5e7b64c --- /dev/null +++ b/crates/csv_preview/src/table_data_engine.rs @@ -0,0 +1,90 @@ +//! This module defines core operations and config of tabular data view (CSV table) +//! It operates in 2 coordinate systems: +//! - `DataCellId` - indices of src data cells +//! - `DisplayCellId` - indices of data after applied transformations like sorting/filtering, which is used to render cell on the screen +//! +//! It's designed to contain core logic of operations without relying on `CsvPreviewView`, context or window handles. + +use std::{collections::HashMap, sync::Arc}; + +use ui::table_row::TableRow; + +use crate::{ + table_data_engine::sorting_by_column::{AppliedSorting, sort_data_rows}, + types::{DataRow, DisplayRow, TableCell, TableLikeContent}, +}; + +pub mod sorting_by_column; + +#[derive(Default)] +pub(crate) struct TableDataEngine { + pub applied_sorting: Option, + d2d_mapping: DisplayToDataMapping, + pub contents: TableLikeContent, +} + +impl TableDataEngine { + pub(crate) fn d2d_mapping(&self) -> &DisplayToDataMapping { + &self.d2d_mapping + } + + pub(crate) fn apply_sort(&mut self) { + self.d2d_mapping + .apply_sorting(self.applied_sorting, &self.contents.rows); + self.d2d_mapping.merge_mappings(); + } + + /// Applies sorting and filtering to the data and produces display to data mapping + pub(crate) fn calculate_d2d_mapping(&mut self) { + self.d2d_mapping + .apply_sorting(self.applied_sorting, &self.contents.rows); + self.d2d_mapping.merge_mappings(); + } +} + +/// Relation of Display (rendered) rows to Data (src) rows with applied transformations +/// Transformations applied: +/// - sorting by column +#[derive(Debug, Default)] +pub struct DisplayToDataMapping { + /// All rows sorted, regardless of applied filtering. Applied every time sorting changes + pub sorted_rows: Vec, + /// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows` + pub mapping: Arc>, +} + +impl DisplayToDataMapping { + /// Get the data row for a given display row + pub fn get_data_row(&self, display_row: DisplayRow) -> Option { + self.mapping.get(&display_row).copied() + } + + /// Get the number of filtered rows + pub fn visible_row_count(&self) -> usize { + self.mapping.len() + } + + /// Computes sorting + fn apply_sorting(&mut self, sorting: Option, rows: &[TableRow]) { + let data_rows: Vec = (0..rows.len()).map(DataRow).collect(); + + let sorted_rows = if let Some(sorting) = sorting { + sort_data_rows(&rows, data_rows, sorting) + } else { + data_rows + }; + + self.sorted_rows = sorted_rows; + } + + /// Take pre-computed sorting and filtering results, and apply them to the mapping + fn merge_mappings(&mut self) { + self.mapping = Arc::new( + self.sorted_rows + .iter() + .enumerate() + .map(|(display, data)| (DisplayRow(display), *data)) + .collect(), + ); + } +} diff --git a/crates/csv_preview/src/table_data_engine/sorting_by_column.rs b/crates/csv_preview/src/table_data_engine/sorting_by_column.rs new file mode 100644 index 0000000000000000000000000000000000000000..52d61351a3d4a8fad0cec60d8c6c594fec05c545 --- /dev/null +++ b/crates/csv_preview/src/table_data_engine/sorting_by_column.rs @@ -0,0 +1,49 @@ +use ui::table_row::TableRow; + +use crate::types::{AnyColumn, DataRow, TableCell}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum SortDirection { + Asc, + Desc, +} + +/// Config or currently active sorting +#[derive(Debug, Clone, Copy)] +pub struct AppliedSorting { + /// 0-based column index + pub col_idx: AnyColumn, + /// Direction of sorting (asc/desc) + pub direction: SortDirection, +} + +pub fn sort_data_rows( + content_rows: &[TableRow], + mut data_row_ids: Vec, + sorting: AppliedSorting, +) -> Vec { + data_row_ids.sort_by(|&a, &b| { + let row_a = &content_rows[*a]; + let row_b = &content_rows[*b]; + + // TODO: Decide how to handle nulls (on top or on bottom) + let val_a = row_a + .get(sorting.col_idx) + .and_then(|tc| tc.display_value()) + .map(|tc| tc.as_str()) + .unwrap_or(""); + let val_b = row_b + .get(sorting.col_idx) + .and_then(|tc| tc.display_value()) + .map(|tc| tc.as_str()) + .unwrap_or(""); + + let cmp = val_a.cmp(val_b); + match sorting.direction { + SortDirection::Asc => cmp, + SortDirection::Desc => cmp.reverse(), + } + }); + + data_row_ids +} diff --git a/crates/csv_preview/src/types.rs b/crates/csv_preview/src/types.rs new file mode 100644 index 0000000000000000000000000000000000000000..87fc513f53e61db996d39dcb05409c765fd0c6dc --- /dev/null +++ b/crates/csv_preview/src/types.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +pub use coordinates::*; +mod coordinates; +pub use table_cell::*; +mod table_cell; +pub use table_like_content::*; +mod table_like_content; + +/// Line number information for CSV rows +#[derive(Debug, Clone, Copy)] +pub enum LineNumber { + /// Single line row + Line(usize), + /// Multi-line row spanning from start to end line. Incluisive + LineRange(usize, usize), +} diff --git a/crates/csv_preview/src/types/coordinates.rs b/crates/csv_preview/src/types/coordinates.rs new file mode 100644 index 0000000000000000000000000000000000000000..d800bef6ce0dd54d5ae65301163f79013e447ce3 --- /dev/null +++ b/crates/csv_preview/src/types/coordinates.rs @@ -0,0 +1,127 @@ +//! Type definitions for CSV table coordinates and cell identifiers. +//! +//! Provides newtypes for self-documenting coordinate systems: +//! - Display coordinates: Visual positions in rendered table +//! - Data coordinates: Original CSV data positions + +use std::ops::Deref; + +///// Rows ///// +/// Visual row position in rendered table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DisplayRow(pub usize); + +impl DisplayRow { + /// Create a new display row + pub fn new(row: usize) -> Self { + Self(row) + } + + /// Get the inner row value + pub fn get(self) -> usize { + self.0 + } +} + +impl Deref for DisplayRow { + type Target = usize; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// Original CSV row position. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct DataRow(pub usize); + +impl DataRow { + /// Create a new data row + pub fn new(row: usize) -> Self { + Self(row) + } +} + +impl Deref for DataRow { + type Target = usize; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for DisplayRow { + fn from(row: usize) -> Self { + DisplayRow::new(row) + } +} + +impl From for DataRow { + fn from(row: usize) -> Self { + DataRow::new(row) + } +} + +///// Columns ///// +/// Data column position in CSV table. 0-based +/// +/// Currently represents both display and data coordinate systems since +/// column reordering is not yet implemented. When column reordering is added, +/// this will need to be split into `DisplayColumn` and `DataColumn` types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AnyColumn(pub usize); + +impl AnyColumn { + /// Create a new column ID + pub fn new(col: usize) -> Self { + Self(col) + } + + /// Get the inner column value + pub fn get(self) -> usize { + self.0 + } +} + +impl Deref for AnyColumn { + type Target = usize; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for AnyColumn { + fn from(col: usize) -> Self { + AnyColumn::new(col) + } +} + +impl From for usize { + fn from(value: AnyColumn) -> Self { + *value + } +} + +///// Cells ///// +/// Visual cell position in rendered table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DisplayCellId { + pub row: DisplayRow, + pub col: AnyColumn, +} + +impl DisplayCellId { + /// Create a new display cell ID + pub fn new(row: impl Into, col: impl Into) -> Self { + Self { + row: row.into(), + col: col.into(), + } + } + + /// Returns (row, column) + pub fn to_raw(&self) -> (usize, usize) { + (self.row.0, self.col.0) + } +} diff --git a/crates/csv_preview/src/types/table_cell.rs b/crates/csv_preview/src/types/table_cell.rs new file mode 100644 index 0000000000000000000000000000000000000000..b6f9adb3fe82b0d468d1ffc8404e707a762e94ea --- /dev/null +++ b/crates/csv_preview/src/types/table_cell.rs @@ -0,0 +1,54 @@ +use text::Anchor; +use ui::SharedString; + +/// Position of a cell within the source CSV buffer +#[derive(Clone, Debug)] +pub struct CellContentSpan { + /// Start anchor of the cell content in the source buffer + pub start: Anchor, + /// End anchor of the cell content in the source buffer + pub end: Anchor, +} + +/// A table cell with its content and position in the source buffer +#[derive(Clone, Debug)] +pub enum TableCell { + /// Cell existing in the CSV + Real { + /// Position of this cell in the source buffer + position: CellContentSpan, + /// Cached display value (for performance) + cached_value: SharedString, + }, + /// Virtual cell, created to pad malformed row + Virtual, +} + +impl TableCell { + /// Create a TableCell with buffer position tracking + pub fn from_buffer_position( + content: SharedString, + start_offset: usize, + end_offset: usize, + buffer_snapshot: &text::BufferSnapshot, + ) -> Self { + let start_anchor = buffer_snapshot.anchor_before(start_offset); + let end_anchor = buffer_snapshot.anchor_after(end_offset); + + Self::Real { + position: CellContentSpan { + start: start_anchor, + end: end_anchor, + }, + cached_value: content, + } + } + + /// Get the display value for this cell + pub fn display_value(&self) -> Option<&SharedString> { + match self { + TableCell::Real { cached_value, .. } => Some(cached_value), + TableCell::Virtual => None, + } + } +} diff --git a/crates/csv_preview/src/types/table_like_content.rs b/crates/csv_preview/src/types/table_like_content.rs new file mode 100644 index 0000000000000000000000000000000000000000..7bf205af812c24d70f33157f8ab7acc454c3b0d5 --- /dev/null +++ b/crates/csv_preview/src/types/table_like_content.rs @@ -0,0 +1,32 @@ +use ui::table_row::TableRow; + +use crate::types::{DataRow, LineNumber, TableCell}; + +/// Generic container struct of table-like data (CSV, TSV, etc) +#[derive(Clone)] +pub struct TableLikeContent { + /// Number of data columns. + /// Defines table width used to validate `TableRow` on creation + pub number_of_cols: usize, + pub headers: TableRow, + pub rows: Vec>, + /// Follows the same indices as `rows` + pub line_numbers: Vec, +} + +impl Default for TableLikeContent { + fn default() -> Self { + Self { + number_of_cols: 0, + headers: TableRow::::from_vec(vec![], 0), + rows: vec![], + line_numbers: vec![], + } + } +} + +impl TableLikeContent { + pub(crate) fn get_row(&self, data_row: DataRow) -> Option<&TableRow> { + self.rows.get(*data_row) + } +} diff --git a/crates/ui/src/components/data_table.rs b/crates/ui/src/components/data_table.rs index 8a40c246ca44ea9dbb25e61bb611882343ba7f94..76ed64850c92e274bd8aeca483dd197cfbccbf52 100644 --- a/crates/ui/src/components/data_table.rs +++ b/crates/ui/src/components/data_table.rs @@ -36,6 +36,13 @@ pub mod table_row { pub struct TableRow(Vec); impl TableRow { + pub fn from_element(element: T, length: usize) -> Self + where + T: Clone, + { + Self::from_vec(vec![element; length], length) + } + /// Constructs a `TableRow` from a `Vec`, panicking if the length does not match `expected_length`. /// /// Use this when you want to ensure at construction time that the row has the correct number of columns. @@ -70,7 +77,8 @@ pub mod table_row { /// /// # Panics /// Panics if `col` is out of bounds (i.e., `col >= self.cols()`). - pub fn expect_get(&self, col: usize) -> &T { + pub fn expect_get(&self, col: impl Into) -> &T { + let col = col.into(); self.0.get(col).unwrap_or_else(|| { panic!( "Expected table row of `{}` to have {col:?}", @@ -79,8 +87,8 @@ pub mod table_row { }) } - pub fn get(&self, col: usize) -> Option<&T> { - self.0.get(col) + pub fn get(&self, col: impl Into) -> Option<&T> { + self.0.get(col.into()) } pub fn as_slice(&self) -> &[T] { @@ -735,6 +743,7 @@ pub struct Table { empty_table_callback: Option AnyElement>>, /// The number of columns in the table. Used to assert column numbers in `TableRow` collections cols: usize, + disable_base_cell_style: bool, } impl Table { @@ -753,9 +762,19 @@ impl Table { use_ui_font: true, empty_table_callback: None, col_widths: None, + disable_base_cell_style: false, } } + /// Disables based styling of row cell (paddings, text ellipsis, nowrap, etc), keeping width settings + /// + /// Doesn't affect base style of header cell. + /// Doesn't remove overflow-hidden + pub fn disable_base_style(mut self) -> Self { + self.disable_base_cell_style = true; + self + } + /// Enables uniform list rendering. /// The provided function will be passed directly to the `uniform_list` element. /// Therefore, if this method is called, any calls to [`Table::row`] before or after @@ -973,10 +992,18 @@ pub fn render_table_row( .into_iter() .zip(column_widths.into_vec()) .map(|(cell, width)| { - base_cell_style_text(width, table_context.use_ui_font, cx) - .px_1() - .py_0p5() - .child(cell) + if table_context.disable_base_cell_style { + div() + .when_some(width, |this, width| this.w(width)) + .when(width.is_none(), |this| this.flex_1()) + .overflow_hidden() + .child(cell) + } else { + base_cell_style_text(width, table_context.use_ui_font, cx) + .px_1() + .py_0p5() + .child(cell) + } }), ); @@ -1071,6 +1098,7 @@ pub struct TableRenderContext { pub column_widths: Option>, pub map_row: Option), &mut Window, &mut App) -> AnyElement>>, pub use_ui_font: bool, + pub disable_base_cell_style: bool, } impl TableRenderContext { @@ -1083,6 +1111,7 @@ impl TableRenderContext { column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)), map_row: table.map_row.clone(), use_ui_font: table.use_ui_font, + disable_base_cell_style: table.disable_base_cell_style, } } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index cf8df08c010bfe643b93b5628cf520ee2ec1dd8b..c04e10636f9088cf5f12dbda526a4e933a5e37e3 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -94,6 +94,7 @@ copilot.workspace = true copilot_chat.workspace = true copilot_ui.workspace = true crashes.workspace = true +csv_preview.workspace = true dap_adapters.workspace = true db.workspace = true debug_adapter_extension.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e93bd92d041a18e927e1560379bcdb2886605874..38238d8af519c0506ab451bccaa1abe3a893e4c9 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -715,6 +715,7 @@ fn main() { git_graph::init(cx); feedback::init(cx); markdown_preview::init(cx); + csv_preview::init(cx); svg_preview::init(cx); onboarding::init(cx); settings_ui::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 55f185aae13e49c6b90610a50ad197ee47ee8a98..a0a6e424d46790ad49c860377c5d1e711aae6b61 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4809,6 +4809,7 @@ mod tests { "console", "context_server", "copilot", + "csv", "debug_panel", "debugger", "dev", diff --git a/crates/zed/src/zed/quick_action_bar/preview.rs b/crates/zed/src/zed/quick_action_bar/preview.rs index 5d43e79542357977b06fbbd884472f94ad3595c8..01e2d164d7d7a8a81e64ab77ad646111e4baacd7 100644 --- a/crates/zed/src/zed/quick_action_bar/preview.rs +++ b/crates/zed/src/zed/quick_action_bar/preview.rs @@ -1,3 +1,8 @@ +use csv_preview::{ + CsvPreviewView, OpenPreview as CsvOpenPreview, OpenPreviewToTheSide as CsvOpenPreviewToTheSide, + TabularDataPreviewFeatureFlag, +}; +use feature_flags::FeatureFlagAppExt as _; use gpui::{AnyElement, Modifiers, WeakEntity}; use markdown_preview::{ OpenPreview as MarkdownOpenPreview, OpenPreviewToTheSide as MarkdownOpenPreviewToTheSide, @@ -16,6 +21,7 @@ use super::QuickActionBar; enum PreviewType { Markdown, Svg, + Csv, } impl QuickActionBar { @@ -35,6 +41,10 @@ impl QuickActionBar { } else if SvgPreviewView::resolve_active_item_as_svg_buffer(workspace, cx).is_some() { preview_type = Some(PreviewType::Svg); + } else if cx.has_flag::() + && CsvPreviewView::resolve_active_item_as_csv_editor(workspace, cx).is_some() + { + preview_type = Some(PreviewType::Csv); } }); } @@ -57,6 +67,13 @@ impl QuickActionBar { Box::new(SvgOpenPreviewToTheSide) as Box, &svg_preview::OpenPreview as &dyn gpui::Action, ), + PreviewType::Csv => ( + "toggle-csv-preview", + "Preview CSV", + Box::new(CsvOpenPreview) as Box, + Box::new(CsvOpenPreviewToTheSide) as Box, + &csv_preview::OpenPreview as &dyn gpui::Action, + ), }; let alt_click = gpui::Keystroke {