Misc · Export and import: reasons wrap; every way out tested

feedback::InlineMessage lays its words in a block that wraps, so the
importer's reason for unread text no longer runs past the dialog. The
reader skips a leading byte order mark, names the line an unclosed
quote opened on, and logs once per reading. Tests press each Cancel,
the scrim, Export with the first format, see focus return to the
opener, read new text under an open importer, and see a field with no
column come empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-28 05:50:28 -04:00
1 parent 9912638fa7
commit cddbd150e6
4 files changed
+208 -29

No files matched your search

+8 -2
View File
@@ -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),
)
}
}
+25
View File
@@ -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<Self>) -> 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:?}");
}
+43 -22
View File
@@ -16,10 +16,11 @@ use crate::{
type OnRecords = Rc<dyn Fn(&[Vec<SharedString>], &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<Vec<Vec<String>>, 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<Vec<Vec<String>>, 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<Vec<Vec<String>>, 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<Vec<Vec<String>>, 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::<Vec<String>>::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!(
+132 -5
View File
@@ -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<Vec<String>> = 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<Self>) -> 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"]]"#]);
}