diff --git a/src/feedback/messages.rs b/src/feedback/messages.rs index b39d7e2..0d97641 100644 --- a/src/feedback/messages.rs +++ b/src/feedback/messages.rs @@ -271,7 +271,7 @@ impl RenderOnce for Callout { } } -/// One line beside what it is about, in the severity's color. +/// A line beside what it is about, in the severity's color; a long one wraps. #[derive(IntoElement)] pub struct InlineMessage { severity: Severity, @@ -302,7 +302,13 @@ impl RenderOnce for InlineMessage { .size(IconSize::Xs) .color(tone), ) - .child(self.text) + .child( + div() + .debug_selector(|| "inline-message-text".into()) + .flex_1() + .min_w_0() + .child(self.text), + ) } } diff --git a/src/feedback/tests/states.rs b/src/feedback/tests/states.rs index aeda963..7f4a77e 100644 --- a/src/feedback/tests/states.rs +++ b/src/feedback/tests/states.rs @@ -175,3 +175,28 @@ fn a_wrapped_body_pushes_the_actions_down(cx: &mut TestAppContext) { ); assert!(long > short + px(24.0), "{short:?} then {long:?}"); } + +/// A message too long for its box. +struct Worded; + +impl Render for Worded { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(200.0)) + .child(crate::feedback::InlineMessage::new( + crate::primitives::Severity::Danger, + "This file does not read as CSV: line 2: a quote inside a field it did not open.", + )) + } +} + +#[gpui::test] +fn a_long_inline_message_wraps_inside_its_box(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Worded); + cx.run_until_parked(); + let text = cx + .debug_bounds("inline-message-text") + .expect("the words draw"); + assert!(text.right() <= px(200.0), "{text:?}"); +} diff --git a/src/misc/csv.rs b/src/misc/csv.rs index c27a8c6..591e8e3 100644 --- a/src/misc/csv.rs +++ b/src/misc/csv.rs @@ -16,10 +16,11 @@ use crate::{ type OnRecords = Rc], &mut Window, &mut App)>; -/// Rows of fields from CSV text, as RFC 4180 has it: commas between fields, quotes around a field that holds a comma, a quote or a line break, a doubled quote for a quote inside, and CRLF or LF between rows. A last line break adds no row. Fails on a quote that never closes, text after a closing quote, or a row whose field count differs from the first's. +/// Rows of fields from CSV text, as RFC 4180 has it: commas between fields, quotes around a field that holds a comma, a quote or a line break, a doubled quote for a quote inside, and CRLF or LF between rows; a byte order mark before the first field is no part of it, and a last line break adds no row. Fails on a quote that never closes, naming the line it opened on; text after a closing quote; a quote inside a field it did not open; or a row whose field count differs from the first's. pub fn read_csv(text: &str) -> Result>, String> { let (mut rows, mut row, mut field) = (Vec::new(), Vec::new(), String::new()); - let (mut quoted, mut closed, mut line) = (false, false, 1); + let (mut quoted, mut closed, mut line, mut opened) = (false, false, 1, 1); + let text = text.strip_prefix('\u{feff}').unwrap_or(text); let mut chars = text.chars().peekable(); while let Some(ch) = chars.next() { match (quoted, ch) { @@ -32,7 +33,7 @@ pub fn read_csv(text: &str) -> Result>, String> { line += usize::from(ch == '\n'); field.push(ch); } - (false, '"') if field.is_empty() && !closed => quoted = true, + (false, '"') if field.is_empty() && !closed => (quoted, opened) = (true, line), (false, '"') => { return Err(format!( "line {line}: a quote inside a field it did not open" @@ -53,7 +54,7 @@ pub fn read_csv(text: &str) -> Result>, String> { } } if quoted { - return Err(format!("line {line}: a quote never closes")); + return Err(format!("line {opened}: a quote never closes")); } if !field.is_empty() || !row.is_empty() || closed { row.push(field); @@ -78,6 +79,18 @@ struct Reading { rows: Result>, String>, } +/// Reads the text once, a file of no rows being a fault too, and logs what does not read. +fn read(id: &ElementId, text: SharedString) -> Reading { + let rows = read_csv(&text).and_then(|rows| match rows.is_empty() { + true => Err("it holds no rows".to_string()), + false => Ok(rows), + }); + if let Err(why) = &rows { + log::warn!("csv importer {id:?}: {why}"); + } + Reading { text, rows } +} + /// CSV text into records: read as RFC 4180, its first row naming the columns unless the box says otherwise, then mapped to the owner's fields through an `ImportDialog`. Text that does not read as CSV, or holds no rows, says why, with only Cancel. Each way out answers once. Render it while open. #[derive(IntoElement)] pub struct CsvImporter { @@ -114,32 +127,19 @@ impl RenderOnce for CsvImporter { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; let reading = window.use_keyed_state((id.clone(), "reading"), cx, { - let text = self.text.clone(); - move |_, _| Reading { - rows: read_csv(&text), - text, - } + let (id, text) = (id.clone(), self.text.clone()); + move |_, _| read(&id, text) }); if reading.read(cx).text != self.text { - let rows = read_csv(&self.text); - reading.update(cx, |reading, _| { - *reading = Reading { - text: self.text.clone(), - rows, - } - }); + let fresh = read(&id, self.text.clone()); + reading.update(cx, |reading, _| *reading = fresh); } let headed = window.use_keyed_state((id.clone(), "headed"), cx, |_, _| true); - let rows = match &reading.read(cx).rows { - Ok(rows) if rows.is_empty() => Err("it holds no rows".to_string()), - Ok(rows) => Ok(rows.clone()), - Err(why) => Err(why.clone()), - }; + let rows = reading.read(cx).rows.clone(); let cancel = self.on_cancel; let rows = match rows { Ok(rows) => rows, Err(why) => { - log::warn!("csv importer {id:?}: {why}"); return Dialog::new(id.clone(), self.title, move |window, cx| cancel(window, cx)) .child( div() @@ -236,6 +236,27 @@ mod tests { assert_eq!(read_csv("").expect("nothing"), Vec::>::new()); } + #[test] + fn a_byte_order_mark_is_no_part_of_the_first_field() { + assert_eq!( + read_csv("\u{feff}Name\nAda").expect("valid CSV"), + [vec!["Name".to_string()], vec!["Ada".to_string()]] + ); + } + + #[test] + fn faults_name_their_lines() { + assert_eq!( + read_csv("a,b\nc,\"d\nd\ne"), + Err("line 2: a quote never closes".into()), + "the line the quote opened on" + ); + assert_eq!( + read_csv("a\nb\"c"), + Err("line 2: a quote inside a field it did not open".into()) + ); + } + #[test] fn csv_names_what_breaks_it() { assert_eq!( diff --git a/src/misc/tests/transfer.rs b/src/misc/tests/transfer.rs index 04abcee..0d91af1 100644 --- a/src/misc/tests/transfer.rs +++ b/src/misc/tests/transfer.rs @@ -1,9 +1,13 @@ use std::cell::{Cell, RefCell}; -use gpui::{AnyElement, IntoElement, TestAppContext, div}; +use gpui::{ + AnyElement, App, Context, FocusHandle, IntoElement, Modifiers, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, point, px, +}; -use super::{press, shown, stage}; +use super::{press, settle, shown, stage}; use crate::misc::{CsvImporter, ExportDialog, ExportFormat, ImportDialog, ImportField}; +use crate::{buttons::Button, primitives::FocusScope, theme::Theme}; thread_local! { static SAID: RefCell> = const { RefCell::new(Vec::new()) }; @@ -147,15 +151,15 @@ fn the_first_row_can_be_data(cx: &mut TestAppContext) { assert!(said().is_empty() && shown("import-missing-name", cx)); } -/// Text that does not read as CSV says why, and Escape cancels. +/// Text that does not read as CSV says why, and its Cancel cancels. #[gpui::test] fn broken_csv_says_why(cx: &mut TestAppContext) { TEXT.with(|text| text.replace("a,\"b")); let (_, cx) = stage(csv, cx); assert!(shown("csv-unread", cx)); press("tab", cx); - press("escape", cx); - assert_eq!(said(), ["cancelled"]); + press("enter", cx); + assert_eq!(said(), ["cancelled"], "its Cancel cancels"); } /// Text with no rows says so. @@ -165,3 +169,126 @@ fn empty_csv_says_why(cx: &mut TestAppContext) { let (_, cx) = stage(csv, cx); assert!(shown("csv-unread", cx)); } + +/// Shows the dialog again after an answer closed it. +fn reopen(cx: &mut VisualTestContext) { + OPEN.set(true); + cx.update(|window, _| window.refresh()); + settle(cx); +} + +/// Cancel and a press on the scrim cancel each dialog once; with nothing picked, Export hands over the first format. +#[gpui::test] +fn every_way_out_answers_once(cx: &mut TestAppContext) { + let (_, cx) = stage(export, cx); + for _ in 0..4 { + press("tab", cx); + } + press("enter", cx); + reopen(cx); + cx.simulate_click(point(px(3.0), px(3.0)), Modifiers::none()); + settle(cx); + reopen(cx); + for _ in 0..5 { + press("tab", cx); + } + press("enter", cx); + assert_eq!(said(), ["cancelled", "cancelled", "Pdf"]); +} + +/// The import dialog's Cancel and scrim cancel once each. +#[gpui::test] +fn import_cancels_once(cx: &mut TestAppContext) { + let (_, cx) = stage(import, cx); + for _ in 0..3 { + press("tab", cx); + } + press("enter", cx); + reopen(cx); + cx.simulate_click(point(px(3.0), px(3.0)), Modifiers::none()); + settle(cx); + assert_eq!(said(), ["cancelled", "cancelled"]); +} + +/// New text under an open importer reads again and imports, a field with no column coming empty. +#[gpui::test] +fn new_text_reads_again(cx: &mut TestAppContext) { + TEXT.with(|text| text.replace("a,\"b")); + let (_, cx) = stage(csv, cx); + assert!(shown("csv-unread", cx)); + TEXT.with(|text| text.replace("Name,Team\nAda,Engines\n")); + cx.update(|window, _| window.refresh()); + settle(cx); + for _ in 0..5 { + press("tab", cx); + } + press("enter", cx); + assert_eq!(said(), [r#"[["Ada", ""]]"#]); +} + +/// A page with a button that opens a dialog, and the dialog while it is open. +struct Opener { + root: FocusHandle, + button: FocusHandle, + part: fn() -> AnyElement, +} + +impl Render for Opener { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + FocusScope::new(&self.root) + .root() + .size_full() + .child( + Button::new("open", "Open") + .focus_handle(&self.button) + .on_click(move |_, _, cx| { + OPEN.set(true); + view.update(cx, |_, cx| cx.notify()); + }), + ) + .child(div().child((self.part)())) + } +} + +/// Opens `part` from a button, presses Tab `tabs` times and Enter, and says whether focus came back to the button. +fn answered_from_button(part: fn() -> AnyElement, tabs: usize, cx: &mut TestAppContext) -> bool { + OPEN.set(false); + cx.update(|cx: &mut App| { + Theme::init(cx); + crate::forms::bind_keys(cx); + cx.bind_keys([ + gpui::KeyBinding::new("tab", crate::primitives::FocusNext, None), + gpui::KeyBinding::new("shift-tab", crate::primitives::FocusPrev, None), + ]); + }); + let (view, cx) = cx.add_window_view(|_, cx| Opener { + root: cx.focus_handle(), + button: cx.focus_handle(), + part, + }); + let button = view.read_with(cx, |opener, _| opener.button.clone()); + cx.update(|window, _| window.focus(&button)); + settle(cx); + press("enter", cx); + for _ in 0..tabs { + press("tab", cx); + } + press("enter", cx); + cx.update(|window, _| button.is_focused(window)) +} + +/// Export hands focus back to the button that opened it. +#[gpui::test] +fn export_hands_focus_back(cx: &mut TestAppContext) { + assert!(answered_from_button(export, 5, cx)); + assert_eq!(said(), ["Pdf"]); +} + +/// Import hands focus back to the button that opened it. +#[gpui::test] +fn import_hands_focus_back(cx: &mut TestAppContext) { + TEXT.with(|text| text.replace("Name,Email\nAda,ada@x.io\n")); + assert!(answered_from_button(csv, 5, cx)); + assert_eq!(said(), [r#"[["Ada", "ada@x.io"]]"#]); +}