Files
zed/crates/csv_preview/src/parser.rs
T
Oleksandr KholiavkoandAnthony Eid d4634441e2 Add CSV preview with live table view and interactive features (#48207)
## **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>
2026-03-03 15:49:40 +01:00

514 lines
18 KiB
Rust

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