ElyDoc: mail merge keeps the template chosen with the data; previews are never templates (D-02)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-09 12:27:13 -04:00
1 parent 1f4807c81c
commit 139e8f6250
9 files changed
+102 -17

No files matched your search

+29 -9
View File
@@ -36,6 +36,10 @@ pub fn menu(cx: &gpui::App) -> gpui::Menu {
pub struct Merge {
docs: WeakEntity<Docs>,
pub table: Option<Table>,
/// The template's tab, active when the data was chosen.
pub source: Option<u64>,
/// Tabs opened as previews; none is a template.
pub previews: std::collections::HashSet<u64>,
/// The record to preview, from 1.
record: usize,
pdf: bool,
@@ -46,6 +50,8 @@ impl Merge {
Merge {
docs,
table: None,
source: None,
previews: std::collections::HashSet::new(),
record: 1,
pdf: false,
}
@@ -62,11 +68,20 @@ impl Docs {
}
pub(crate) fn choose_csv(&mut self, cx: &mut Context<Self>) {
self.pick(false, cx, |docs, path, cx| match read_csv(&path) {
let Some(source) = self.core.active else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
};
if self.merge.read(cx).previews.contains(&source) {
let text = Docs::text(cx, "doc.merge.from_preview", &[]);
return self.toast(text, Severity::Warning, cx);
}
self.pick(false, cx, move |docs, path, cx| match read_csv(&path) {
Ok(table) => {
log::info!("doc: merge data has {} rows", table.rows.len());
docs.merge.update(cx, |merge, cx| {
merge.table = Some(table);
merge.source = Some(source);
merge.record = 1;
cx.notify();
});
@@ -84,20 +99,20 @@ impl Docs {
});
}
/// The tab a merge starts from: the active one.
/// The template's tab, while it is open.
fn source(&self, cx: &mut Context<Self>) -> Option<u64> {
if self.core.active.is_none() {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
let source = self.merge.read(cx).source.filter(|source| self.tab(*source).is_some());
if source.is_none() {
let text = Docs::text(cx, "doc.merge.gone", &[]);
self.toast(text, Severity::Warning, cx);
}
self.core.active
source
}
/// Types a field's marker at the cursor.
/// Types a field's marker at the template's cursor.
pub(crate) fn insert_field(&mut self, header: &str, cx: &mut Context<Self>) {
let Some(tab) = self.active_tab() else {
let text = Docs::text(cx, "command.blocked.no_document", &[]);
return self.toast(text, Severity::Warning, cx);
let Some(tab) = self.source(cx).and_then(|source| self.tab(source)) else {
return;
};
tab.view.update(cx, |view, _| view.insert(&marker(header)));
}
@@ -194,7 +209,12 @@ impl Render for Merge {
})
};
let row = || div().flex().flex_wrap().items_center().gap_1();
let template = self.source.and_then(|source| {
let docs = self.docs.upgrade()?;
Some(docs.read(cx).tab(source)?.name.clone())
});
let group = PropertyGroup::new(table.name.clone())
.row(text("doc.merge.template"), div().child(template.unwrap_or_else(|| text("doc.merge.gone"))))
.row(text("doc.merge.data"), choose)
.row(text("doc.merge.fields"), row().children(fields))
.row(
+1
View File
@@ -164,6 +164,7 @@ impl Docs {
cx: &mut Context<Self>,
) {
log::info!("doc: previewing merge record {}", at + 1);
self.merge.update(cx, |merge, _| merge.previews.insert(into));
self.load_into(into, Source::Copy(copy), cx);
self.core.waiting.push((
into,
+49 -4
View File
@@ -22,9 +22,9 @@ fn records_fill_previews_and_files() {
let csv = dir.path().join("people.csv");
std::fs::write(&csv, "Name,City\nAda,London\n«City»,上海\n").expect("the csv writes");
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
type_into(&mut cx, &docs, " of ");
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("City", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("City", cx)));
let source = cx.update(|app| docs.read(app).core.tabs[0].name.to_string());
cx.update(|app| {
docs.update(app, |docs, cx| {
@@ -171,7 +171,7 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
let table = read_csv(&csv).expect("the csv reads");
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.insert_field("Name", cx);
docs.field_here("Name", cx);
docs.preview_record(source, &table, 0, cx);
docs.core.active = Some(other);
})
@@ -191,6 +191,14 @@ fn a_merge_keeps_its_source_when_the_tab_changes() {
});
let preview = dir.path().join("preview.odt");
let id = cx.update(|app| docs.read(app).core.tabs[2].id);
// The preview is active now; the template stays.
cx.update(|app| {
docs.update(app, |docs, cx| {
docs.core.active = Some(id);
assert_eq!(docs.source(cx), Some(source), "the template stays after a preview");
assert!(docs.merge.read(cx).previews.contains(&id), "the preview is known");
})
});
with_window(&mut cx, &docs, |docs, window, cx| {
docs.write_as(id, preview.clone(), None, window, cx)
});
@@ -227,7 +235,7 @@ fn two_generations_keep_their_own_templates() {
for text in ["SourceA ", "SourceB "] {
sources.push(blank(&mut cx, &docs));
type_into(&mut cx, &docs, text);
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
cx.update(|app| docs.update(app, |docs, cx| docs.field_here("Name", cx)));
settled(&cx, &docs);
}
let csv = dir.path().join("one.csv");
@@ -265,3 +273,40 @@ fn two_generations_keep_their_own_templates() {
);
}
}
#[test]
fn the_template_chosen_with_the_data_stays_through_a_preview() {
let dir = tempfile::tempdir().expect("a temp dir");
let mut cx = TestAppContext::single();
let Some(docs) = docs(&mut cx, dir.path()) else {
eprintln!("SKIP: no LOK build");
return;
};
let template = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "Dear ");
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");
cx.update(|app| docs.update(app, |docs, cx| docs.choose_csv(cx)));
cx.simulate_path_prompt_response(move |_| Some(vec![csv]));
settle(&cx, |app| docs.read(app).merge.read(app).table.is_some());
cx.update(|app| docs.update(app, |docs, cx| docs.insert_field("Name", cx)));
let table = cx.update(|app| docs.read(app).merge.read(app).table.clone().expect("the data"));
// As the panel's buttons do: preview, then generate.
cx.update(|app| docs.update(app, |docs, cx| {
let source = docs.source(cx).expect("the template");
docs.preview_record(source, &table, 0, cx);
}));
settle(&cx, |app| tabs(app, &docs) == 2 && docs.read(app).core.active != Some(template));
let out = dir.path().join("out");
std::fs::create_dir(&out).expect("the folder");
cx.update(|app| docs.update(app, |docs, cx| {
let source = docs.source(cx).expect("the template, not the preview");
assert_eq!(source, template);
docs.generate(source, table.clone(), false, out.clone(), cx);
}));
let stem = cx.update(|app| docs.read(app).tab(template).expect("a tab").name.to_string());
let target = |n: usize| out.join(format!("{stem} {n}.docx"));
settle(&cx, |_| target(2).exists());
let (first, second) = (text_of(&mut cx, &docs, &target(1)), text_of(&mut cx, &docs, &target(2)));
assert!(first.contains("Dear Ada") && second.contains("Dear Grace"), "{first:?} {second:?}");
}
+9
View File
@@ -463,3 +463,12 @@ fn an_outside_change_asks_and_keep_mine_overwrites() {
mod find;
mod more;
mod overlap;
impl Docs {
/// Chooses the active tab as template, then types a field.
pub(crate) fn field_here(&mut self, header: &str, cx: &mut gpui::Context<Self>) {
let active = self.core.active;
self.merge.update(cx, |merge, _| merge.source = active);
self.insert_field(header, cx);
}
}
+2 -2
View File
@@ -86,10 +86,10 @@ fn interleaved_previews_keep_their_sources() {
};
let alpha = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceAlpha ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
let beta = blank(&mut cx, &docs);
type_into(&mut cx, &docs, "SourceBeta ");
update_doc(&mut cx, &docs, |docs, cx| docs.insert_field("Name", cx));
update_doc(&mut cx, &docs, |docs, cx| docs.field_here("Name", cx));
settled(&cx, &docs);
let csv = dir.path().join("two.csv");
std::fs::write(&csv, "Name\nAda\nGrace\n").expect("the csv writes");