diff --git a/apps/ely-doc/src/merge.rs b/apps/ely-doc/src/merge.rs index 7f79407..7feb167 100644 --- a/apps/ely-doc/src/merge.rs +++ b/apps/ely-doc/src/merge.rs @@ -36,6 +36,10 @@ pub fn menu(cx: &gpui::App) -> gpui::Menu { pub struct Merge { docs: WeakEntity, pub table: Option, + /// The template's tab, active when the data was chosen. + pub source: Option, + /// Tabs opened as previews; none is a template. + pub previews: std::collections::HashSet, /// 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.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) -> Option { - 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) { - 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( diff --git a/apps/ely-doc/src/merge/run.rs b/apps/ely-doc/src/merge/run.rs index 62c6f94..bbc9087 100644 --- a/apps/ely-doc/src/merge/run.rs +++ b/apps/ely-doc/src/merge/run.rs @@ -164,6 +164,7 @@ impl Docs { cx: &mut Context, ) { 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, diff --git a/apps/ely-doc/src/merge/tests.rs b/apps/ely-doc/src/merge/tests.rs index 9755017..2da6aea 100644 --- a/apps/ely-doc/src/merge/tests.rs +++ b/apps/ely-doc/src/merge/tests.rs @@ -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:?}"); +} diff --git a/apps/ely-doc/src/tests.rs b/apps/ely-doc/src/tests.rs index 808fc3d..ab0a07d 100644 --- a/apps/ely-doc/src/tests.rs +++ b/apps/ely-doc/src/tests.rs @@ -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) { + let active = self.core.active; + self.merge.update(cx, |merge, _| merge.source = active); + self.insert_field(header, cx); + } +} diff --git a/apps/ely-doc/src/tests/overlap.rs b/apps/ely-doc/src/tests/overlap.rs index 06ff694..97de2ea 100644 --- a/apps/ely-doc/src/tests/overlap.rs +++ b/apps/ely-doc/src/tests/overlap.rs @@ -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"); diff --git a/crates/ely-ui/src/i18n/tools.rs b/crates/ely-ui/src/i18n/tools.rs index b805b64..05c6be1 100644 --- a/crates/ely-ui/src/i18n/tools.rs +++ b/crates/ely-ui/src/i18n/tools.rs @@ -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", "记录"), diff --git a/tasks/BUGS.md b/tasks/BUGS.md index 8d2acb7..22f97e6 100644 --- a/tasks/BUGS.md +++ b/tasks/BUGS.md @@ -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 邮件合并某条记录失败时内核文档泄漏 diff --git a/tasks/reviews/D-02-r1.md b/tasks/reviews/D-02-r1.md new file mode 100644 index 0000000..a391a0d --- /dev/null +++ b/tasks/reviews/D-02-r1.md @@ -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 选择入口建立绑定的回归测试,预览后经生产模板解析路径生成至少两条不同记录,核对来源与输出内容,并让上述删除绑定的变异触发失败。 diff --git a/tasks/reviews/D-02-r2.md b/tasks/reviews/D-02-r2.md new file mode 100644 index 0000000..7ef22e9 --- /dev/null +++ b/tasks/reviews/D-02-r2.md @@ -0,0 +1 @@ +PASS