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");
+6
View File
@@ -45,6 +45,9 @@ pub(super) const EN: &[(&str, &str)] = &[
("doc.merge.open", "Mail Merge…"),
("doc.merge.title", "Mail Merge"),
("doc.merge.data", "Data"),
("doc.merge.template", "Template"),
("doc.merge.gone", "The template is closed. Choose the data again."),
("doc.merge.from_preview", "A preview cannot be a template. Switch to the original document."),
("doc.merge.choose", "Choose CSV…"),
("doc.merge.fields", "Fields"),
("doc.merge.record", "Record"),
@@ -104,6 +107,9 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
("doc.merge.open", "邮件合并…"),
("doc.merge.title", "邮件合并"),
("doc.merge.data", "数据"),
("doc.merge.template", "模板"),
("doc.merge.gone", "模板已关闭,请重新选择数据。"),
("doc.merge.from_preview", "预览不能作模板,请切回原文档。"),
("doc.merge.choose", "选择 CSV…"),
("doc.merge.fields", "字段"),
("doc.merge.record", "记录"),
+2 -2
View File
@@ -4,7 +4,7 @@
## 0.1.2 先做(用户 2026-10-09 定:先修致命与高,加自动更新,发布 0.1.2;其余随后按清单次序)
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
次序:S-03、P-01、X-01、D-01、D-02、P-02、P-03、P-04、M-01、X-02、X-03、K-01(Linux、Windows,本机不能运行)、UPD-1、REL-1、REL-2。K-01 不进 0.1.2:只影响 Linux 与 Windows 的 worker,本机不能运行验证,0.1.2 只发 macOS;留在下面的清单里,未勾。T-05 已改一半(审查查出缩放后尺寸不重测),暂放,补丁存在草稿区。
- [ ] UPD-1 自动更新:应用内检查新版本、下载、校验,经用户同意后替换并重启;发布时生成更新清单,不再要点 GitHub 链接
@@ -48,7 +48,7 @@
- [ ] P-13 优化导出的“之前”大小取磁盘文件而非当前文档
- [ ] P-14 文字块含控制字符时 JSON 回复不可解析
- [x] D-01 导出或打印对话框开着时关闭其标签即崩溃 — Docs 实现 tab_closed,关标签即撤下其导出对话框;R1 PASS
- [ ] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录
- [x] D-02 邮件合并以当前标签为模板,预览后生成的每份都是同一条记录 — 选数据时记下模板标签,预览副本不作模板;R1 测试绕过选 CSV 入口;R2 PASS
- [ ] D-03 界面线程同步持 worker 锁读内核(样式、字体、书签、公式、分节名)
- [ ] D-04 一条批注的时间读不出,全部批注与修订都不显示
- [ ] D-05 邮件合并某条记录失败时内核文档泄漏
+3
View File
@@ -0,0 +1,3 @@
FAIL
apps/ely-doc/src/merge/tests.rs:174(相关:apps/ely-doc/src/tests.rs:471):模板绑定的回归测试绕过了生产入口 `choose_csv`。`field_here` 直接写入 `Merge.source`,随后测试直接向 `preview_record`、`generate` 传入模板编号;新增的 `source()` 断言验证的是辅助函数预写入的状态。具体复现:在隔离副本中仅移除 merge.rs:84 的 `merge.source = Some(source)`,现有 5 项 `merge::` 测试仍全部通过。此时新会话选择 CSV 后 `Merge.source` 保持 `None`,预览与生成都会误报“模板已关闭”。通过 `choose_csv` 和 `simulate_path_prompt_response` 驱动的额外探针在原代码通过,在该变异下明确失败:实际 `None`,预期 `Some(0)`。证据见 `/tmp/ely-d02-mutation-existing.log`、`/tmp/ely-d02-mutation-probe.log`、`/tmp/ely-d02-probe-baseline.log`;原代码的 ElyDoc 全部 47 项测试、5 项 i18n 测试及应用构建通过。修复方向:增加通过 CSV 选择入口建立绑定的回归测试,预览后经生产模板解析路径生成至少两条不同记录,核对来源与输出内容,并让上述删除绑定的变异触发失败。
+1
View File
@@ -0,0 +1 @@
PASS