fix: quote CSV fields only when required

This commit is contained in:
Valdemar Lorenzen
2026-10-21 10:35:43 +02:00
parent c533e71ecc
commit 138490eecd
4 changed files with 27 additions and 9 deletions
+2 -1
View File
@@ -8,4 +8,5 @@
- `yc` should copy to csv format - `yc` should copy to csv format
- should be able to copy without row names (`Y` keybinding) - should be able to copy without row names (`Y` keybinding)
- `Ym` should copy to markdown format - `Ym` should copy to markdown format
- `Yc` should copy to csv format - `Yc` should copy to csv format
- [ ] add ability to update values from the row view
+21 -6
View File
@@ -677,6 +677,10 @@ fn append_record<'a>(
if index > 0 { if index > 0 {
output.push(','); output.push(',');
} }
if !value.contains([',', '"', '\r', '\n']) {
output.push_str(value);
continue;
}
output.push('"'); output.push('"');
for character in value.chars() { for character in value.chars() {
if character == '"' { if character == '"' {
@@ -755,6 +759,17 @@ fn moved_index(selected: Option<usize>, count: usize, delta: isize) -> Option<us
mod tests { mod tests {
use super::moved_index; use super::moved_index;
#[test]
fn csv_quotes_only_fields_with_delimiters_quotes_or_line_breaks() {
let mut output = String::new();
super::append_record(
&mut output,
["42", "00123", "界", "", "a,b", "a\"b", "a\rb", "a\nb"].into_iter(),
false,
);
assert_eq!(output, "42,00123,界,,\"a,b\",\"a\"\"b\",\"a\rb\",\"a\nb\"\r\n");
}
#[test] #[test]
fn rectangular_selection_copies_full_cells_and_resets_on_data_changes() -> crate::Result<()> { fn rectangular_selection_copies_full_cells_and_resets_on_data_changes() -> crate::Result<()> {
use super::{App, Focus}; use super::{App, Focus};
@@ -799,14 +814,14 @@ mod tests {
); );
assert_eq!( assert_eq!(
app.selection_text(true, false).as_deref(), app.selection_text(true, false).as_deref(),
Some("\"b\",\"c\"\r\n\"a|b\",\"say \"\"hi\"\",界\"\r\n\"NULL\",\"<&>\\tail\"\r\n") Some("b,c\r\na|b,\"say \"\"hi\"\",界\"\r\nNULL,<&>\\tail\r\n")
); );
assert_eq!( assert_eq!(
app.selection_text(false, false).as_deref(), app.selection_text(false, false).as_deref(),
Some("\"a|b\",\"say \"\"hi\"\",界\"\r\n\"NULL\",\"<&>\\tail\"\r\n") Some("a|b,\"say \"\"hi\"\",界\"\r\nNULL,<&>\\tail\r\n")
); );
assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); assert!(app.handle_rows_command(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)));
assert_eq!(app.selection_text(false, false).as_deref(), Some("\"a|b\"\r\n")); assert_eq!(app.selection_text(false, false).as_deref(), Some("a|b\r\n"));
// A prefix cancels on focus changes rather than capturing a later key. // A prefix cancels on focus changes rather than capturing a later key.
app.handle_rows_command(key('y')); app.handle_rows_command(key('y'));
assert!(!app.handle_rows_command(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))); assert!(!app.handle_rows_command(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
@@ -814,7 +829,7 @@ mod tests {
app.handle_rows_command(key('v')); app.handle_rows_command(key('v'));
app.handle_key(key('n')); app.handle_key(key('n'));
assert!(app.selection_anchor.is_none()); assert!(app.selection_anchor.is_none());
assert_eq!(app.selection_text(false, false).as_deref(), Some("\"101\"\r\n")); assert_eq!(app.selection_text(false, false).as_deref(), Some("101\r\n"));
app.handle_rows_command(key('v')); app.handle_rows_command(key('v'));
app.handle_key(key('s')); app.handle_key(key('s'));
assert!(app.selection_anchor.is_none()); assert!(app.selection_anchor.is_none());
@@ -824,11 +839,11 @@ mod tests {
app.query.value = "SELECT 42 AS answer".into(); app.query.value = "SELECT 42 AS answer".into();
app.execute_query(); app.execute_query();
assert!(app.selection_anchor.is_none()); assert!(app.selection_anchor.is_none());
assert_eq!(app.selection_text(true, false).as_deref(), Some("\"answer\"\r\n\"42\"\r\n")); assert_eq!(app.selection_text(true, false).as_deref(), Some("answer\r\n42\r\n"));
app.handle_rows_command(key('v')); app.handle_rows_command(key('v'));
app.query.value = "SELECT missing FROM nowhere".into(); app.query.value = "SELECT missing FROM nowhere".into();
app.execute_query(); app.execute_query();
assert_eq!(app.selection_text(false, false).as_deref(), Some("\"42\"\r\n")); assert_eq!(app.selection_text(false, false).as_deref(), Some("42\r\n"));
assert!(app.selection_anchor.is_some()); assert!(app.selection_anchor.is_some());
app.open_relation(Some(0)); app.open_relation(Some(0));
assert!(app.selection_anchor.is_none()); assert!(app.selection_anchor.is_none());
+2 -1
View File
@@ -433,7 +433,8 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
With no block, copy uses the current cell. Blocks stay within the loaded page.\n\ With no block, copy uses the current cell. Blocks stay within the loaded page.\n\
Paging, refresh, schema/relation changes, and successful queries clear blocks.\n\ Paging, refresh, schema/relation changes, and successful queries clear blocks.\n\
Copy uses full displayed values; NULL/control escapes are preserved.\n\ Copy uses full displayed values; NULL/control escapes are preserved.\n\
Headerless Markdown has blank headings. CSV quotes fields with CRLF endings.\n\ Headerless Markdown has blank headings. CSV uses CRLF and quotes only fields\n\
containing commas, double quotes, or line breaks.\n\
Clipboard: macOS pbcopy; Windows PowerShell; Wayland wl-copy; X11 xclip.\n\n\ Clipboard: macOS pbcopy; Windows PowerShell; Wayland wl-copy; X11 xclip.\n\n\
Query editor\n\ Query editor\n\
i/a/I/A Enter Insert mode (at/after/start/end)\n\ i/a/I/A Enter Insert mode (at/after/start/end)\n\
+2 -1
View File
@@ -79,7 +79,8 @@ Navigation:
Blocks cover the current loaded page, including cells scrolled out of view. Blocks cover the current loaded page, including cells scrolled out of view.
Paging, refreshing, opening a relation, schema changes, and successful queries clear blocks. Paging, refreshing, opening a relation, schema changes, and successful queries clear blocks.
Copies use full displayed cell values (NULL and escaped control characters included). Copies use full displayed cell values (NULL and escaped control characters included).
Headerless Markdown uses blank headings; CSV quotes fields and uses CRLF line endings. Headerless Markdown uses blank headings; CSV quotes only fields containing commas,
double quotes, or line breaks, and uses CRLF line endings.
Clipboard tools: pbcopy on macOS, PowerShell on Windows, wl-copy on Wayland, Clipboard tools: pbcopy on macOS, PowerShell on Windows, wl-copy on Wayland,
or xclip on X11. A missing/failing tool reports an error without clearing the block. or xclip on X11. A missing/failing tool reports an error without clearing the block.
c Add a configured database in a floating window c Add a configured database in a floating window