Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4f33f8a
feat: Complete implementation during MVP
HalavicH Dec 6, 2025
552282f
time-machine: Revert cell editor
HalavicH Feb 2, 2026
b950bf0
time-machine: Revert copy selected
HalavicH Feb 2, 2026
1493bf0
time-machine: Revert commented table width mode
HalavicH Feb 2, 2026
37aff09
time-machine: Remove cell selection feature
HalavicH Feb 2, 2026
2ae77f1
time-machine: Remove filters
HalavicH Feb 2, 2026
ea96548
time-machine: Revert commented table width mode
HalavicH Feb 2, 2026
90bf2d7
time-machine: Remove progress
HalavicH Feb 2, 2026
87ad2a4
time-machine: Remove csv fixtures for quick testing
HalavicH Feb 2, 2026
bb82845
time-machine: Remove settings pane
HalavicH Feb 2, 2026
49204b7
fix: Remove dependencies needed for table fork
HalavicH Feb 2, 2026
021654b
fix: Cell editor remove flag
HalavicH Feb 2, 2026
75e8a2a
fix: Remove useless code & update comments
HalavicH Feb 3, 2026
2e34b56
fix: Update new crate position in Cargo.toml
HalavicH Feb 3, 2026
e906319
fix: Remove unused import
HalavicH Feb 3, 2026
4d6bd05
fix: Update docs
HalavicH Feb 3, 2026
676f1bd
Fix clippy and cfg(test) build errors
Anthony-Eid Feb 24, 2026
d937070
Merge remote-tracking branch 'origin' into feat/csv-preview/initial-i…
Anthony-Eid Feb 24, 2026
4f78d0a
Fix build errors
Anthony-Eid Feb 24, 2026
69ef92d
fix: Address review comments
HalavicH Feb 24, 2026
674c812
feat: Add open preview quick action
HalavicH Feb 24, 2026
4a27d61
feat: Add feature flag for tabular data
HalavicH Feb 24, 2026
81c6f0c
Make cvs file check case insenitive
Anthony-Eid Mar 3, 2026
b2c788c
Fix license sym link and don't trim cvs cell content
Anthony-Eid Mar 3, 2026
95e6503
Fix failing tests
Anthony-Eid Mar 3, 2026
9cc163c
Fix feature flag race condition
Anthony-Eid Mar 3, 2026
4711c57
Fix doc tests
Anthony-Eid Mar 3, 2026
faa9661
Fix some minor nits
Anthony-Eid Mar 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ members = [
"crates/copilot_chat",
"crates/crashes",
"crates/credentials_provider",
"crates/csv_preview",
"crates/dap",
"crates/dap_adapters",
"crates/db",
Expand Down Expand Up @@ -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" }
Expand Down
21 changes: 21 additions & 0 deletions crates/csv_preview/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions crates/csv_preview/LICENSE-GPL
302 changes: 302 additions & 0 deletions crates/csv_preview/src/csv_preview.rs
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading