From 8531de0d7eb66462d24a566455fe2ae09ad93487 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Fri, 10 Jul 2026 17:50:52 +0200 Subject: [PATCH 1/7] feat: Add support for TSV/PSV/SSV --- crates/csv_preview/src/csv_preview.rs | 14 +++-- crates/csv_preview/src/parser.rs | 91 ++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 3f8504ceae355f..2a25dbae96d72e 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -279,12 +279,14 @@ impl CsvPreviewView { .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")) + .and_then(|buffer| buffer.read(cx).file()) + .and_then(|file| { + file.path() + .extension() + .map(|ext_str| { + let lower = ext_str.to_lowercase(); + matches!(lower.as_str(), "csv" | "tsv" | "ssv" | "psv") + }) }) .unwrap_or(false) } diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index 116c8912a38684..2a4501c1ff455d 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -18,6 +18,15 @@ pub(crate) struct EditorState { pub _subscription: Subscription, } +fn delimiter_from_extension(ext: &str) -> char { + match ext.to_lowercase().as_str() { + "tsv" => '\t', + "psv" => '|', + "ssv" => ';', + _ => ',', + } +} + impl CsvPreviewView { pub(crate) fn parse_csv_from_active_editor( &mut self, @@ -57,13 +66,28 @@ impl CsvPreviewView { } } - let buffer_snapshot = view.update(cx, |_, cx| { - editor + let (buffer_snapshot, delimiter) = view.update(cx, |_, cx| { + let buffer_ref = editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .map(|b| b.read(cx).text_snapshot()); + + let delimiter = editor .read(cx) .buffer() .read(cx) .as_singleton() - .map(|b| b.read(cx).text_snapshot()) + .and_then(|buffer| buffer.read(cx).file()) + .and_then(|file| { + file.path() + .extension() + .map(|ext_str| delimiter_from_extension(ext_str)) + }) + .unwrap_or(','); + + (buffer_ref, delimiter) })?; let Some(buffer_snapshot) = buffer_snapshot else { @@ -72,7 +96,7 @@ impl CsvPreviewView { let instant = Instant::now(); let parsed_csv = cx - .background_spawn(async move { from_buffer(&buffer_snapshot) }) + .background_spawn(async move { from_buffer_with_delimiter(&buffer_snapshot, delimiter) }) .await; let parse_duration = instant.elapsed(); let parse_end_time: Instant = Instant::now(); @@ -96,14 +120,18 @@ impl CsvPreviewView { } } -pub fn from_buffer(buffer_snapshot: &BufferSnapshot) -> TableLikeContent { + +pub fn from_buffer_with_delimiter( + buffer_snapshot: &BufferSnapshot, + delimiter: char, +) -> 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); + let (parsed_cells_with_positions, line_numbers) = parse_csv_with_positions(&text, delimiter); if parsed_cells_with_positions.is_empty() { return TableLikeContent::default(); } @@ -136,6 +164,7 @@ pub fn from_buffer(buffer_snapshot: &BufferSnapshot) -> TableLikeContent { /// Parse CSV and track byte positions for each cell fn parse_csv_with_positions( text: &str, + delimiter: char, ) -> ( Vec)>>, Vec, @@ -175,7 +204,7 @@ fn parse_csv_with_positions( } } } - ',' if !in_quotes => { + c if c == delimiter && !in_quotes => { // Field separator let field_end_offset = current_offset; if current_field.is_empty() && !in_quotes { @@ -423,10 +452,52 @@ Jane,"Simple name""#; assert!(parsed.rows.is_empty()); } + #[test] + fn test_tsv_parsing() { + let tsv_data = "Name\tAge\tCity\nJohn\t30\tNew York\nJane\t25\tLos Angeles"; + let (parsed_cells, _) = parse_csv_with_positions(tsv_data, '\t'); + + assert_eq!(parsed_cells.len(), 3); + assert_eq!(parsed_cells[0].len(), 3); + assert_eq!(parsed_cells[0][0].0.as_ref(), "Name"); + assert_eq!(parsed_cells[0][1].0.as_ref(), "Age"); + assert_eq!(parsed_cells[0][2].0.as_ref(), "City"); + assert_eq!(parsed_cells[1][0].0.as_ref(), "John"); + assert_eq!(parsed_cells[1][1].0.as_ref(), "30"); + } + + #[test] + fn test_psv_parsing() { + let psv_data = "Name|Age|City\nJohn|30|New York\nJane|25|Los Angeles"; + let (parsed_cells, _) = parse_csv_with_positions(psv_data, '|'); + + assert_eq!(parsed_cells.len(), 3); + assert_eq!(parsed_cells[0].len(), 3); + assert_eq!(parsed_cells[0][0].0.as_ref(), "Name"); + assert_eq!(parsed_cells[0][1].0.as_ref(), "Age"); + assert_eq!(parsed_cells[0][2].0.as_ref(), "City"); + assert_eq!(parsed_cells[1][0].0.as_ref(), "John"); + assert_eq!(parsed_cells[1][1].0.as_ref(), "30"); + } + + #[test] + fn test_ssv_parsing() { + let ssv_data = "Name;Age;City\nJohn;30;New York\nJane;25;Los Angeles"; + let (parsed_cells, _) = parse_csv_with_positions(ssv_data, ';'); + + assert_eq!(parsed_cells.len(), 3); + assert_eq!(parsed_cells[0].len(), 3); + assert_eq!(parsed_cells[0][0].0.as_ref(), "Name"); + assert_eq!(parsed_cells[0][1].0.as_ref(), "Age"); + assert_eq!(parsed_cells[0][2].0.as_ref(), "City"); + assert_eq!(parsed_cells[1][0].0.as_ref(), "John"); + assert_eq!(parsed_cells[1][1].0.as_ref(), "30"); + } + #[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); + 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 @@ -452,7 +523,7 @@ Jane,"Simple name""#; 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); + let (parsed_cells, _) = parse_csv_with_positions(csv_data, ','); assert_eq!(parsed_cells.len(), 3); // header + 2 rows @@ -510,6 +581,6 @@ impl TableLikeContent { let buffer_id = BufferId::new(1).unwrap(); let buffer = Buffer::new(ReplicaId::LOCAL, buffer_id, text); let snapshot = buffer.snapshot(); - from_buffer(snapshot) + from_buffer_with_delimiter(&snapshot, ',') } } From 194f709177f9e6f112dcf376f5071a8bdbef243d Mon Sep 17 00:00:00 2001 From: HalavicH Date: Fri, 10 Jul 2026 17:59:40 +0200 Subject: [PATCH 2/7] theme: Add PSV and SSV file icons Reuse storage icon for pipe-separated and semicolon-separated value files. Maintains visual consistency with CSV and TSV format icons. --- crates/theme/src/icon_theme.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 5303f2952e8a53..406066c3d07272 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -230,8 +230,8 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ "storage", &[ "accdb", "csv", "dat", "db", "dbf", "dll", "fmp", "fp7", "frm", "gdb", "ib", "ldf", - "mdb", "mdf", "myd", "myi", "pdb", "RData", "rdata", "sav", "sdf", "sql", "sqlite", - "tsv", + "mdb", "mdf", "myd", "myi", "pdb", "psv", "RData", "rdata", "sav", "sdf", "sql", "sqlite", + "ssv", "tsv", ], ), ( From a51e68d62603f40c5b5d48343a6b2dde8fce36ed Mon Sep 17 00:00:00 2001 From: HalavicH Date: Fri, 10 Jul 2026 18:05:20 +0200 Subject: [PATCH 3/7] feat: Add Table icon for CSV preview - Create table.svg icon (3x3 grid design, 16x16 viewBox) - Add Table variant to IconName enum - Use Table icon for CSV preview tabs instead of FileDoc --- assets/icons/table.svg | 8 ++++++++ crates/csv_preview/src/csv_preview.rs | 2 +- crates/icons/src/icons.rs | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 assets/icons/table.svg diff --git a/assets/icons/table.svg b/assets/icons/table.svg new file mode 100644 index 00000000000000..a55be5624f8d7e --- /dev/null +++ b/assets/icons/table.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 2a25dbae96d72e..786f42c59ceaed 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -304,7 +304,7 @@ impl Item for CsvPreviewView { type Event = (); fn tab_icon(&self, _window: &Window, _cx: &App) -> Option { - Some(Icon::new(IconName::FileDoc)) + Some(Icon::new(IconName::Table)) } fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString { diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index d1005517d772fc..045a57bd84b29d 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -248,6 +248,7 @@ pub enum IconName { StarFilled, Stop, Tab, + Table, Terminal, TerminalAlt, TextSnippet, From ff34089df40f3cf6ece692cd078b1d17e44b33a7 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Fri, 10 Jul 2026 18:25:11 +0200 Subject: [PATCH 4/7] feat: Generalize naming `csv` -> `tabular_data` --- crates/csv_preview/src/csv_preview.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index 786f42c59ceaed..dfbeac88264441 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -23,7 +23,7 @@ mod settings; mod table_data_engine; mod types; -actions!(csv, [OpenPreview, OpenPreviewToTheSide]); +actions!(tabular_data, [OpenPreview, OpenPreviewToTheSide]); pub struct TabularDataPreviewFeatureFlag; @@ -281,12 +281,10 @@ impl CsvPreviewView { .as_singleton() .and_then(|buffer| buffer.read(cx).file()) .and_then(|file| { - file.path() - .extension() - .map(|ext_str| { - let lower = ext_str.to_lowercase(); - matches!(lower.as_str(), "csv" | "tsv" | "ssv" | "psv") - }) + file.path().extension().map(|ext_str| { + let lower = ext_str.to_lowercase(); + matches!(lower.as_str(), "csv" | "tsv" | "ssv" | "psv") + }) }) .unwrap_or(false) } From efb2d6366532c76999a21a39ba59e081a9389a50 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Thu, 13 Aug 2026 17:29:37 +0200 Subject: [PATCH 5/7] refactor: Parser --- crates/csv_preview/src/csv_preview.rs | 10 ++--- crates/csv_preview/src/parser.rs | 58 +++++++++++++++++++++------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/crates/csv_preview/src/csv_preview.rs b/crates/csv_preview/src/csv_preview.rs index dfbeac88264441..11525da35cb314 100644 --- a/crates/csv_preview/src/csv_preview.rs +++ b/crates/csv_preview/src/csv_preview.rs @@ -280,13 +280,9 @@ impl CsvPreviewView { .read(cx) .as_singleton() .and_then(|buffer| buffer.read(cx).file()) - .and_then(|file| { - file.path().extension().map(|ext_str| { - let lower = ext_str.to_lowercase(); - matches!(lower.as_str(), "csv" | "tsv" | "ssv" | "psv") - }) - }) - .unwrap_or(false) + .and_then(|file| file.path().extension()) + .and_then(parser::TabularFormat::from_extension) + .is_some() } } diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index 2a4501c1ff455d..05b94ddf526c89 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -18,12 +18,37 @@ pub(crate) struct EditorState { pub _subscription: Subscription, } -fn delimiter_from_extension(ext: &str) -> char { - match ext.to_lowercase().as_str() { - "tsv" => '\t', - "psv" => '|', - "ssv" => ';', - _ => ',', +#[derive(Clone, Copy)] +pub(crate) enum TabularFormat { + Csv, + Tsv, + Psv, + Ssv, +} + +const TABULAR_FORMATS: &[(&str, TabularFormat)] = &[ + ("csv", TabularFormat::Csv), + ("tsv", TabularFormat::Tsv), + ("psv", TabularFormat::Psv), + ("ssv", TabularFormat::Ssv), +]; + +impl TabularFormat { + pub(crate) fn from_extension(ext: &str) -> Option { + let lower = ext.to_lowercase(); + TABULAR_FORMATS + .iter() + .find(|(name, _)| *name == lower) + .map(|(_, format)| *format) + } + + fn delimiter(self) -> char { + match self { + TabularFormat::Csv => ',', + TabularFormat::Tsv => '\t', + TabularFormat::Psv => '|', + TabularFormat::Ssv => ';', + } } } @@ -74,18 +99,25 @@ impl CsvPreviewView { .as_singleton() .map(|b| b.read(cx).text_snapshot()); - let delimiter = editor + let extension = editor .read(cx) .buffer() .read(cx) .as_singleton() .and_then(|buffer| buffer.read(cx).file()) - .and_then(|file| { - file.path() - .extension() - .map(|ext_str| delimiter_from_extension(ext_str)) - }) - .unwrap_or(','); + .and_then(|file| file.path().extension().map(ToOwned::to_owned)); + + let delimiter = extension + .as_deref() + .and_then(TabularFormat::from_extension) + .map(TabularFormat::delimiter) + .unwrap_or_else(|| { + log::warn!( + "unrecognized tabular data extension {:?}, defaulting to comma delimiter", + extension + ); + ',' + }); (buffer_ref, delimiter) })?; From 558609bfb6e1a6521eda4a1dc53287a83c12dea6 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Fri, 14 Aug 2026 09:52:29 +0200 Subject: [PATCH 6/7] fix: Fmt --- crates/csv_preview/src/parser.rs | 1 - crates/theme/src/icon_theme.rs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/csv_preview/src/parser.rs b/crates/csv_preview/src/parser.rs index 05b94ddf526c89..8ce4c5ee13f9fc 100644 --- a/crates/csv_preview/src/parser.rs +++ b/crates/csv_preview/src/parser.rs @@ -152,7 +152,6 @@ impl CsvPreviewView { } } - pub fn from_buffer_with_delimiter( buffer_snapshot: &BufferSnapshot, delimiter: char, diff --git a/crates/theme/src/icon_theme.rs b/crates/theme/src/icon_theme.rs index 406066c3d07272..bdc3727a74fc6b 100644 --- a/crates/theme/src/icon_theme.rs +++ b/crates/theme/src/icon_theme.rs @@ -230,8 +230,8 @@ const FILE_SUFFIXES_BY_ICON_KEY: &[(&str, &[&str])] = &[ "storage", &[ "accdb", "csv", "dat", "db", "dbf", "dll", "fmp", "fp7", "frm", "gdb", "ib", "ldf", - "mdb", "mdf", "myd", "myi", "pdb", "psv", "RData", "rdata", "sav", "sdf", "sql", "sqlite", - "ssv", "tsv", + "mdb", "mdf", "myd", "myi", "pdb", "psv", "RData", "rdata", "sav", "sdf", "sql", + "sqlite", "ssv", "tsv", ], ), ( From 1efb1c749a917fe4a8a5d51310f7505ee633df99 Mon Sep 17 00:00:00 2001 From: HalavicH Date: Fri, 14 Aug 2026 09:55:16 +0200 Subject: [PATCH 7/7] fix: Test --- crates/zed/src/zed.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index be34bda080e81d..7a6d93b9621fb9 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5818,7 +5818,6 @@ mod tests { "context_server", "copilot", "copilot_edit_predictions", - "csv", "debug_panel", "debugger", "dev", @@ -5872,6 +5871,7 @@ mod tests { "svg", "syntax_tree_view", "tab_switcher", + "tabular_data", "task", "terminal", "terminal_panel",