Files
zed/crates/csv_preview/src/csv_preview.rs
T
Oleksandr KholiavkoandAnthony Eid e652d967b8 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

303 lines
11 KiB
Rust

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