Add CSV preview with live table view and interactive features (#48207)

Oleksandr Kholiavko and Anthony Eid created

## **Description:**

**Context:**
This PR introduces an initial CSV preview feature for Zed, building upon
two previously merged infrastructure PRs:
- [#46341](https://github.com/zed-industries/zed/pull/46341) - Data
table dynamic column support (removed const generics)
- [#46190](https://github.com/zed-industries/zed/pull/46190) - Variable
row height mode for data tables

This implementation is based on the [original draft PR
#44344](https://github.com/zed-industries/zed/pull/44344), which has
been carefully decomposed into smaller, reviewable pieces.

---

#### **Features Included:**

**Core Infrastructure:**
- Live CSV parsing with smart debouncing (200ms cooldown)
- Performance monitoring with built-in timing metrics (not displayed in
UI yet)
- Automatic file change detection and re-parsing
- Support for quoted fields, multiline cells, and escaped characters

**Table Display:**
- Variable row height rendering with fallback to uniform mode
(switchable via settings)
- Draggable column resizing (reusing existing data table infrastructure)
- Row identifiers supporting both source line numbers and sequential row
numbers
- Configurable font rendering (UI font vs monospace)
- Tooltips showing full cell content on hover

**Interactive Features:**
- Column sorting (ascending/descending) with visual indicators

**Settings Panel:**
- Toggle between variable/uniform row rendering
- Font type selection (UI/monospace)
- Row identifier type configuration
- Debug information display
- Multiline cell rendering options

---

#### **Features Intentionally Removed for This PR:**

To reduce complexity and review scope, the following features were
temporarily reverted and will be reintroduced in subsequent PRs:

- ❌ Settings pannel with performance metrics overlay
- ❌ Cell selection (single, multiple, and range selections)
- ❌ Keyboard navigation with arrow keys and selection extension
- ❌ Copy functionality supporting CSV, TSV, and Markdown table formats
- ❌ Inline cell editing with file persistence
- ❌ Viewport following for large datasets
- ❌ Column filtering and search capabilities

These removals were done via "time-machine" commits that cleanly nuked
vertical slices of functionality from the complete implementation.

---

**Technical Implementation:**

The feature is organized into a dedicated `csv_preview` crate with the
following structure:

```
crates/csv_preview/
├── src/
│   ├── csv_preview.rs          # Main view and coordination logic
│   ├── parser.rs               # CSV parsing and editor integration
│   ├── settings.rs             # Configuration types and defaults
│   ├── table_data_engine.rs    # Data transformation logic
│   ├── renderer/               # UI rendering modules
│   │   ├── preview_view.rs     # Main render implementation
│   │   ├── render_table.rs     # Table component assembly
│   │   ├── table_cell.rs       # Individual cell rendering
│   │   ├── table_header.rs     # Header with sorting controls
│   │   └── row_identifiers.rs  # Line number column
│   └── types/                  # Core data structures
│       ├── table_like_content.rs
│       ├── coordinates.rs      # Display vs data coordinate systems
│       └── table_cell.rs
```

**Key architectural decisions:**
- **Dual coordinate system**: Separates data indices from display
indices to support sorting/filtering
- **Component reuse**: Leverages existing `data_table` infrastructure
from the keymap editor

---

**Integration:**
- Registers `csv::OpenPreview` action (currently without default
keybindings)
- Follows the same workspace integration pattern as `markdown_preview`
and `svg_preview`
- Automatically detects `.csv` file extensions
- Tab integration with appropriate icons and naming

---

**Code Structure Note:**
Some code structures, types, and documentation may appear redundant or
over-engineered in this initial implementation. This is intentional -
the feature was developed as a complete system and then decomposed by
functionality rather than being built incrementally. The "extra"
infrastructure supports features that were removed for this PR but will
be reintroduced in subsequent ones.

This approach was chosen over extensive refactoring because:
1. The complete feature took 200+ commits to develop with significant
rewrites
2. Clean extraction of vertical slices was more feasible than rebuilding
incrementally
3. The end state will utilize all these components, making current
"redundancy" temporary

I apologize for any inconvenience this may cause during review, but the
alternative would have required significant refactoring effort just to
make intermediate states "prettier," which seemed counterproductive.

---

**Future Work:**
This lays the groundwork for upcoming PRs that will reintroduce the
removed features:
- Cell selection and keyboard navigation
- Copy functionality with multiple output formats
- Inline editing capabilities with undo/redo
- Column filtering and search
- TSV and other delimiter support
- Improved horizontal scrolling behavior
- Settings persistence

**Testing:**
Includes test fixtures demonstrating multiline cell handling, various
column counts, and edge cases.

---

**Release Notes:**

- N/A This is feature flagged

---------

Co-authored-by: Anthony Eid <anthony@zed.dev>

Change summary

Cargo.lock                                                    |  15 
Cargo.toml                                                    |   2 
crates/csv_preview/Cargo.toml                                 |  21 
crates/csv_preview/LICENSE-GPL                                |   1 
crates/csv_preview/src/csv_preview.rs                         | 302 ++
crates/csv_preview/src/parser.rs                              | 513 +++++
crates/csv_preview/src/renderer.rs                            |   5 
crates/csv_preview/src/renderer/preview_view.rs               |  50 
crates/csv_preview/src/renderer/render_table.rs               | 193 +
crates/csv_preview/src/renderer/row_identifiers.rs            | 189 +
crates/csv_preview/src/renderer/table_cell.rs                 |  72 
crates/csv_preview/src/renderer/table_header.rs               |  94 
crates/csv_preview/src/settings.rs                            |  46 
crates/csv_preview/src/table_data_engine.rs                   |  90 
crates/csv_preview/src/table_data_engine/sorting_by_column.rs |  49 
crates/csv_preview/src/types.rs                               |  17 
crates/csv_preview/src/types/coordinates.rs                   | 127 +
crates/csv_preview/src/types/table_cell.rs                    |  54 
crates/csv_preview/src/types/table_like_content.rs            |  32 
crates/ui/src/components/data_table.rs                        |  43 
crates/zed/Cargo.toml                                         |   1 
crates/zed/src/main.rs                                        |   1 
crates/zed/src/zed.rs                                         |   1 
crates/zed/src/zed/quick_action_bar/preview.rs                |  17 
24 files changed, 1,928 insertions(+), 7 deletions(-)

Detailed changes

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",

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" }

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

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<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));
+    }
+}

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<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)
+    }
+}

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<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)
+    }
+}

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<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)
+    }
+}

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<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)
+    }
+}

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<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))
+}

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
+    }
+}

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,
+}

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<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(),
+        );
+    }
+}

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<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
+}

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),
+}

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<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)
+    }
+}

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,
+        }
+    }
+}

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<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)
+    }
+}

crates/ui/src/components/data_table.rs 🔗

@@ -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,
         }
     }
 }

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

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);

crates/zed/src/zed.rs 🔗

@@ -4809,6 +4809,7 @@ mod tests {
                 "console",
                 "context_server",
                 "copilot",
+                "csv",
                 "debug_panel",
                 "debugger",
                 "dev",

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::<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 {