Detailed changes
@@ -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",
@@ -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" }
@@ -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
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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<TableInteractionState>,
+ pub(crate) column_widths: ColumnWidths,
+ pub(crate) parsing_task: Option<Task<anyhow::Result<()>>>,
+ 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<std::time::Instant>,
+}
+
+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::<TabularDataPreviewFeatureFlag>(), |div| {
+ div.on_action(cx.listener(|workspace, _: &OpenPreview, window, cx| {
+ if let Some(editor) = workspace
+ .active_item(cx)
+ .and_then(|item| item.act_as::<Editor>(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::<CsvPreviewView>()
+ .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::<Editor>(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::<CsvPreviewView>().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<Editor>, cx: &mut Context<Workspace>) -> Entity<Self> {
+ let contents = TableLikeContent::default();
+ let table_interaction_state = cx.new(|cx| {
+ TableInteractionState::new(cx)
+ .with_custom_scrollbar(ui::Scrollbars::for_settings::<editor::EditorSettings>())
+ });
+
+ 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<Workspace>,
+ ) -> Option<Entity<Editor>> {
+ let editor = workspace
+ .active_item(cx)
+ .and_then(|item| item.act_as::<Editor>(cx))?;
+ Self::is_csv_file(&editor, cx).then_some(editor)
+ }
+
+ fn is_csv_file(editor: &Entity<Editor>, 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<Icon> {
+ 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<usize>,
+}
+impl PerformanceMetrics {
+ pub fn record<F, R>(&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::<Vec<_>>();
+ 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::<Vec<_>>()
+ .join("\n")
+ }
+
+ /// Get timing for a specific metric
+ pub fn get_timing(&self, name: &str) -> Option<Duration> {
+ 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<TableColumnWidths>,
+}
+
+impl ColumnWidths {
+ pub(crate) fn new(cx: &mut Context<CsvPreviewView>, 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<CsvPreviewView>, cols: usize) {
+ self.widths
+ .update(cx, |entity, cx| *entity = TableColumnWidths::new(cols, cx));
+ }
+}
@@ -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<Editor>,
+ pub _subscription: Subscription,
+}
+
+impl CsvPreviewView {
+ pub(crate) fn parse_csv_from_active_editor(
+ &mut self,
+ wait_for_debounce: bool,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Editor>,
+ cx: &mut Context<Self>,
+ ) -> Task<anyhow::Result<()>> {
+ 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<(SharedString, std::ops::Range<usize>)>>,
+ Vec<LineNumber>,
+) {
+ let mut rows = Vec::new();
+ let mut line_numbers = Vec::new();
+ let mut current_row: Vec<(SharedString, std::ops::Range<usize>)> = 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<usize>)>,
+) -> TableRow<TableCell> {
+ let mut raw_row = row
+ .into_iter()
+ .map(|(content, range)| {
+ TableCell::from_buffer_position(content, range.start, range.end, &buffer_snapshot)
+ })
+ .collect::<Vec<_>>();
+
+ 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)
+ }
+}
@@ -0,0 +1,5 @@
+mod preview_view;
+mod render_table;
+mod row_identifiers;
+mod table_cell;
+mod table_header;
@@ -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<Self>) -> 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)
+ }
+}
@@ -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<TableColumnWidths>,
+ cx: &mut Context<Self>,
+ ) -> 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<DefiniteLength>,
+ resize_behaviors: UncheckedTableRow<TableResizeBehavior>,
+ current_widths: &Entity<TableColumnWidths>,
+ cx: &mut Context<Self>,
+ ) -> 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<usize>, _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<CsvPreviewView>,
+ ) -> Option<UncheckedTableRow<AnyElement>> {
+ // 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 `<null>` 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)
+ }
+}
@@ -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<AnyElement> {
+ 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)
+ }
+}
@@ -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<CsvPreviewView>,
+ ) -> 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> {
+ 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))
+}
@@ -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
+ }
+}
@@ -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,
+}
@@ -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<AppliedSorting>,
+ 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<DataRow>,
+ /// Filtered and sorted rows. Computed cheaply from `sorted_mapping` and `filtered_out_rows`
+ pub mapping: Arc<HashMap<DisplayRow, DataRow>>,
+}
+
+impl DisplayToDataMapping {
+ /// Get the data row for a given display row
+ pub fn get_data_row(&self, display_row: DisplayRow) -> Option<DataRow> {
+ 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<AppliedSorting>, rows: &[TableRow<TableCell>]) {
+ let data_rows: Vec<DataRow> = (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(),
+ );
+ }
+}
@@ -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<TableCell>],
+ mut data_row_ids: Vec<DataRow>,
+ sorting: AppliedSorting,
+) -> Vec<DataRow> {
+ 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
+}
@@ -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),
+}
@@ -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<usize> for DisplayRow {
+ fn from(row: usize) -> Self {
+ DisplayRow::new(row)
+ }
+}
+
+impl From<usize> 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<usize> for AnyColumn {
+ fn from(col: usize) -> Self {
+ AnyColumn::new(col)
+ }
+}
+
+impl From<AnyColumn> 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<DisplayRow>, col: impl Into<AnyColumn>) -> Self {
+ Self {
+ row: row.into(),
+ col: col.into(),
+ }
+ }
+
+ /// Returns (row, column)
+ pub fn to_raw(&self) -> (usize, usize) {
+ (self.row.0, self.col.0)
+ }
+}
@@ -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,
+ }
+ }
+}
@@ -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<TableCell>,
+ pub rows: Vec<TableRow<TableCell>>,
+ /// Follows the same indices as `rows`
+ pub line_numbers: Vec<LineNumber>,
+}
+
+impl Default for TableLikeContent {
+ fn default() -> Self {
+ Self {
+ number_of_cols: 0,
+ headers: TableRow::<TableCell>::from_vec(vec![], 0),
+ rows: vec![],
+ line_numbers: vec![],
+ }
+ }
+}
+
+impl TableLikeContent {
+ pub(crate) fn get_row(&self, data_row: DataRow) -> Option<&TableRow<TableCell>> {
+ self.rows.get(*data_row)
+ }
+}
@@ -36,6 +36,13 @@ pub mod table_row {
pub struct TableRow<T>(Vec<T>);
impl<T> TableRow<T> {
+ 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<T>`, 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<usize>) -> &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<usize>) -> Option<&T> {
+ self.0.get(col.into())
}
pub fn as_slice(&self) -> &[T] {
@@ -735,6 +743,7 @@ pub struct Table {
empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> 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<TableRow<Length>>,
pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &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,
}
}
}
@@ -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
@@ -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);
@@ -4809,6 +4809,7 @@ mod tests {
"console",
"context_server",
"copilot",
+ "csv",
"debug_panel",
"debugger",
"dev",
@@ -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::<TabularDataPreviewFeatureFlag>()
+ && 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<dyn gpui::Action>,
&svg_preview::OpenPreview as &dyn gpui::Action,
),
+ PreviewType::Csv => (
+ "toggle-csv-preview",
+ "Preview CSV",
+ Box::new(CsvOpenPreview) as Box<dyn gpui::Action>,
+ Box::new(CsvOpenPreviewToTheSide) as Box<dyn gpui::Action>,
+ &csv_preview::OpenPreview as &dyn gpui::Action,
+ ),
};
let alt_click = gpui::Keystroke {