Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions assets/icons/table.svg

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where did you get the sag from? We typically use lucide.dev icons because their licensing lets us.

Maybe we want this: https://lucide.dev/icons/table instead

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did it in Inkscape :)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's fine then!

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 6 additions & 10 deletions crates/csv_preview/src/csv_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ mod settings;
mod table_data_engine;
mod types;

actions!(csv, [OpenPreview, OpenPreviewToTheSide]);
actions!(tabular_data, [OpenPreview, OpenPreviewToTheSide]);

pub struct TabularDataPreviewFeatureFlag;

Expand Down Expand Up @@ -279,14 +279,10 @@ 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"))
})
.unwrap_or(false)
.and_then(|buffer| buffer.read(cx).file())
.and_then(|file| file.path().extension())
.and_then(parser::TabularFormat::from_extension)
.is_some()
}
}

Expand All @@ -302,7 +298,7 @@ impl Item for CsvPreviewView {
type Event = ();

fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
Some(Icon::new(IconName::FileDoc))
Some(Icon::new(IconName::Table))
}

fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
Expand Down
122 changes: 112 additions & 10 deletions crates/csv_preview/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,40 @@ pub(crate) struct EditorState {
pub _subscription: Subscription,
}

#[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<Self> {
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 => ';',
}
}
}

impl CsvPreviewView {
pub(crate) fn parse_csv_from_active_editor(
&mut self,
Expand Down Expand Up @@ -57,13 +91,35 @@ 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())
.map(|b| b.read(cx).text_snapshot());

let extension = editor
.read(cx)
.buffer()
.read(cx)
.as_singleton()
.and_then(|buffer| buffer.read(cx).file())
.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)
})?;

let Some(buffer_snapshot) = buffer_snapshot else {
Expand All @@ -72,7 +128,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();
Expand All @@ -96,14 +152,17 @@ 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();
}
Expand Down Expand Up @@ -136,6 +195,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<(SharedString, std::ops::Range<usize>)>>,
Vec<LineNumber>,
Expand Down Expand Up @@ -175,7 +235,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 {
Expand Down Expand Up @@ -423,10 +483,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
Expand All @@ -452,7 +554,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

Expand Down Expand Up @@ -510,6 +612,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, ',')
}
}
1 change: 1 addition & 0 deletions crates/icons/src/icons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ pub enum IconName {
StarFilled,
Stop,
Tab,
Table,
Terminal,
TerminalAlt,
TextSnippet,
Expand Down
4 changes: 2 additions & 2 deletions crates/theme/src/icon_theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
),
(
Expand Down
2 changes: 1 addition & 1 deletion crates/zed/src/zed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5818,7 +5818,6 @@ mod tests {
"context_server",
"copilot",
"copilot_edit_predictions",
"csv",
"debug_panel",
"debugger",
"dev",
Expand Down Expand Up @@ -5872,6 +5871,7 @@ mod tests {
"svg",
"syntax_tree_view",
"tab_switcher",
"tabular_data",
"task",
"terminal",
"terminal_panel",
Expand Down
Loading