Close prompt: a failed Save says why and keeps the document open (#1445) (#1465)

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Dmitrii Khlapov
2026-10-14 13:20:11 -04:00
committed by GitHub
co-authored by Dmitrii Khlapov Brandon Thomas
parent 17ec132c7c
commit f2a96a21f9
4 changed files with 195 additions and 20 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: table row margins and splitting; previously trivial: progress log: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: full screen, #980; previously trivial: progress log: a failed save from the close prompt says so, #1445; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-13 | A paragraph that ends with a page break keeps its space after on the page before, as in Word: the next page no longer starts up to a line too low, and a page break before right after it no longer leaves a blank page |
| 2026-10-13 | Tables break across pages like Word's: a cell without its own margins lines up with its neighbours, exact row heights include the bottom margin, and a row whose margin doesn't fit at a page bottom is split with widow control instead of moving to the next page whole |
| 2026-10-13 | Equation parsers are hardened (#1456): deeply chained input (`x^1^1…`, `a/a/…`, `\sum\sum…`) no longer builds a tree thousands of levels deep that overflows small stacks, `\text{…}` is escaped in LaTeX output so it reads back unchanged, and runs merge inside every structure. |
| 2026-10-13 | Closing with unsaved changes → Save that can't write the file (no permission, locked by another app, folder gone) now says so plainly instead of looking like Cancel: the prompt comes back with the reason in it ("Couldn't save … so it is still open and nothing was lost") for Save again, Don't Save or Cancel, the status bar says it too, and `ui.saveChanges` returns that error; the same for a Save As from the prompt; the document stays open with its edits (#1445) |
| 2026-10-12 | RTF import now uses the RTF spec's defaults where a file names none (#902): no space before or after paragraphs, single line spacing, and the `\deff` default font, instead of WordCraft's Normal style; RTF files that relied on the old defaults open tighter, like in Word |
| 2026-10-11 | Right-clicking a table cell offers the table's commands, and as in Word they follow the selection: with the caret in a cell Insert, Delete Cells, Split Cells, Text Direction and Table Properties…; with several cells Merge Cells instead of Split; with whole columns or rows Delete Columns or Rows, Merge Cells and Distribute Rows and Columns; with the whole table Delete Table, Distribute, AutoFit and Insert Caption…. A right-click beside selected cells moves the caret there instead of keeping them |
| 2026-10-11 | Drags, Shift+arrows and Shift+click over table cells select whole cells, as in Word: from one cell into another the rectangle between them (bold, alignment and Delete leave the other cells alone), into or out of a table whole rows; extending again goes on from where the selection started |
@@ -311,6 +312,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-12 | trivial | Progress log: list label follows paragraph alignment (#1159) |
| 2026-10-13 | trivial | Progress log: space after a page break |
| 2026-10-13 | trivial | Progress log: table row margins and splitting |
| 2026-10-13 | trivial | Progress log: a failed save from the close prompt says so (#1445) |
| 2026-10-12 | trivial | Progress log: Accept All and Reject All keep the right paragraph marks (#864) |
| 2026-10-13 | trivial | Progress log: equation pictures keep the theme colours on Windows (#1002) |
| 2026-10-11 | trivial | Progress log: Ctrl, Alt, left-edge and selected-cells table border drags (#711) |
+10 -2
View File
@@ -355,7 +355,8 @@ pub enum Dialog {
/// "Do you want to save changes?" before a user's New, Open, Close, Envelopes, Labels or
/// Finish & Merge replaces the document (see [`WordApp::run`]); `then` runs once it is
/// answered (`ui.saveChanges`), and only while the document it asked about
/// (`Session::document_id`) is still the one open.
/// (`Session::document_id`) is still the one open. `message`: why the last Save failed, shown
/// in the prompt when it comes back after that failure (#1445); empty otherwise.
SaveChanges {
name: String,
then: String,
@@ -363,6 +364,8 @@ pub enum Dialog {
params: Value,
#[serde(skip)]
document: u64,
#[serde(default)]
message: String,
},
/// Mailings › Select Recipients / Edit Recipient List: a table of recipients to type or
/// edit (field names on top), or a file to load instead (#240).
@@ -2345,9 +2348,14 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
let (ok, cancel) = buttons(ui, tl!("Close"));
ok || cancel
}
Dialog::SaveChanges { name, .. } => {
Dialog::SaveChanges { name, message, .. } => {
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Do you want to save changes to {name}?"), &[("name", name)])).font(semibold(15.0)));
ui.label(tl!("Your changes will be lost if you don't save them."));
// Why the last Save failed (#1445): the prompt came back so the user can't miss it.
if !message.is_empty() {
ui.add_space(4.0);
ui.label(egui::RichText::new(message.as_str()).color(ui.visuals().error_fg_color));
}
ui.add_space(8.0);
let buttons = ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+24 -4
View File
@@ -238,10 +238,9 @@ impl WordApp {
r
}
AfterPick::SaveAs(after) => {
let saved = picked.is_some_and(|path| {
self.run("file.save", json!({"path": path}))
.is_ok_and(|v| v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some())
});
// The save's error, kept for the Save Changes prompt to explain (#1445).
let result = picked.map(|path| self.run("file.save", json!({"path": path})));
let saved = matches!(&result, Some(Ok(v)) if v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some());
if saved {
self.ui.backstage = false;
self.canvas.want_focus = true;
@@ -255,6 +254,9 @@ impl WordApp {
Ok(json!({"saved": saved}))
}
AfterSave::Continue { then, params, document } => {
if let Some(Err(error)) = result {
return Err(self.save_failed(&error, then, params, document));
}
if !self.safely_saved(saved) || document != self.session.document_id() {
return Ok(json!({"done": false}));
}
@@ -442,6 +444,24 @@ mod tests {
assert_eq!(shown.borrow()[0].0, FileDialogRequest::Open { purpose: "document".into() });
}
/// #1445: "Save changes?" → Save on a new document while another file dialog is still open:
/// nothing can be asked, so nothing happens, and the status bar says why.
#[test]
fn save_changes_while_a_file_dialog_is_open_says_so() {
let (mut a, shown) = async_app();
typed(&mut a);
a.open_dialog();
assert_eq!(shown.borrow().len(), 1);
a.run("file.close", json!({})).unwrap();
a.status_msg = None;
let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(r["done"], false);
assert!(!a.quit_requested);
assert!(a.session.dirty && body_text(&a).contains(UNSAVED));
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();
assert!(msg.contains("file dialog is already open"), "the status bar says why: {msg:?}");
}
/// "Save changes?" → Save on a new document: the held-back Close runs only once the later
/// Save As has actually saved; a cancelled Save As cancels the held-back New.
#[test]
+158 -13
View File
@@ -651,9 +651,14 @@ impl WordApp {
return Ok(json!({"pending": name}));
}
if self.session.dirty && discards_document(id, &params) {
let name =
self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem());
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then: id.to_string(), params, document: self.session.document_id() });
let name = self.prompt_name();
self.dialog = Some(dialogs::Dialog::SaveChanges {
name,
then: id.to_string(),
params,
document: self.session.document_id(),
message: String::new(),
});
return Ok(json!({"pending": "saveChanges"}));
}
// The ribbon and context-menu Paste commands send no text: read the system clipboard's, as
@@ -843,7 +848,10 @@ impl WordApp {
file_dialogs::Asked::Busy => Ok(json!({"done": false})),
};
}
SaveChoice::Save => self.save_for_prompt(),
SaveChoice::Save => match self.save_for_prompt() {
Ok(saved) => saved,
Err(error) => return Err(self.save_failed(&error, then, params, document)),
},
};
if !go {
return Ok(json!({"done": false}));
@@ -870,13 +878,33 @@ impl WordApp {
/// Save before New/Open/Close (and the mailings that replace the document): true once the
/// document is safely written. A save in a format that doesn't keep everything (a page
/// image, plain text) writes a copy and leaves the document unsaved, so it doesn't count:
/// the command is cancelled and the document stays.
fn save_for_prompt(&mut self) -> bool {
let saved = match self.execute("file.save", json!({})) {
Ok(v) => v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some(),
Err(_) => false,
};
self.safely_saved(saved)
/// the command is cancelled and the document stays. A save that fails (no permission, the
/// file locked by another app, a missing folder) is the save's error ([`Self::save_failed`]
/// tells the user).
fn save_for_prompt(&mut self) -> Result<bool, String> {
let v = self.execute("file.save", json!({}))?;
let saved = v.get("saved").and_then(Value::as_bool) == Some(true) || v.get("downloaded").is_some();
Ok(self.safely_saved(saved))
}
/// The document's name as the Save Changes prompt shows it: its file's name, else its title.
fn prompt_name(&self) -> String {
self.session.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| self.title_stem())
}
/// Save (or Save As) from the Save Changes prompt failed (#1445): say so plainly on the status
/// bar and in the prompt, which comes back for Save again, Don't Save or Cancel, with the
/// held-back command (`then`) still waiting. Nothing closes and nothing is discarded. Returns
/// the message, the prompt answer's error.
pub(crate) fn save_failed(&mut self, error: &str, then: String, params: Value, document: u64) -> String {
let name = self.prompt_name();
let message = i18n::fmt(tl!("Couldn't save {name}, so it is still open and nothing was lost: {error}"), &[("name", &name), ("error", error)]);
log::error!("save from the Save Changes prompt failed: {error}");
self.status(message.clone());
if document == self.session.document_id() {
self.dialog = Some(dialogs::Dialog::SaveChanges { name, then, params, document, message: message.clone() });
}
message
}
/// Whether a save before New/Open/Close counts: it happened, and kept everything.
@@ -2948,6 +2976,14 @@ mod tests {
a.session.doc.plain_text(wordcraft_doc::StoryRef::Body)
}
/// The reason the Save Changes prompt shows (a failed save), if that prompt is open.
fn prompt_message(a: &WordApp) -> Option<String> {
match &a.dialog {
Some(dialogs::Dialog::SaveChanges { message, .. }) => Some(message.clone()),
_ => None,
}
}
fn prompt(a: &WordApp) -> Option<&'static str> {
a.dialog.as_ref().map(|d| d.name())
}
@@ -3091,6 +3127,114 @@ mod tests {
assert!(body_text(&a).contains(UNSAVED), "nothing was replaced on the way out");
}
/// #1445: the window's X on an edited document with a file → Save writes the file and lets
/// the window close.
#[test]
fn window_close_save_writes_the_file_and_quits() {
let dir = scratch("close-save");
let path = docx_on_disk(&dir, "Draft");
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Revised "})).unwrap();
assert!(!a.close_requested());
assert_eq!(prompt(&a), Some("saveChanges"));
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
let on_disk = wordcraft_engine::io::open_path(&path).unwrap().plain_text(wordcraft_doc::StoryRef::Body);
assert!(on_disk.contains("Revised Draft"), "the edit is on disk: {on_disk:?}");
assert!(a.quit_requested, "the app may close");
assert!(a.close_requested(), "the close that follows goes ahead");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1445: when Save in the close prompt can't write the file, the prompt's answer is an error
/// that says so plainly, the status bar shows it, and the document stays open, still edited.
#[test]
fn window_close_save_that_fails_says_so_and_keeps_the_document() {
let dir = scratch("close-save-fails");
let folder = dir.join("folder");
std::fs::create_dir_all(&folder).unwrap();
let path = docx_on_disk(&folder, "Draft");
let mut a = app();
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("text.insert", json!({"text": "Revised "})).unwrap();
// The document's folder is gone and a file has its name: nothing can be written there.
std::fs::remove_dir_all(&folder).unwrap();
std::fs::write(&folder, b"not a folder").unwrap();
assert!(!a.close_requested());
a.status_msg = None;
let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err();
assert!(err.contains("Couldn't save contract.docx") && err.contains("still open"), "{err}");
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| *m == err), "the status bar says why: {:?}", a.status_msg);
assert!(!a.quit_requested, "nothing closes");
assert!(a.session.dirty && body_text(&a).contains("Revised Draft"), "nothing was discarded");
assert_eq!(a.session.path.as_deref(), Some(path.as_path()));
// The prompt comes back with the reason in it: Save again, Don't Save or Cancel.
assert_eq!(prompt(&a), Some("saveChanges"), "the prompt is open again");
assert_eq!(prompt_message(&a).as_deref(), Some(err.as_str()), "the prompt says why");
assert!(a.run("ui.saveChanges", json!({"answer": "save"})).is_err(), "Save again fails again");
assert_eq!(prompt(&a), Some("saveChanges"));
a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap();
assert_eq!(prompt(&a), None);
assert!(!a.close_requested(), "the next close asks again");
assert_eq!(prompt_message(&a).as_deref(), Some(""), "a fresh prompt, no old reason");
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert!(a.quit_requested, "Don't Save still closes");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1445: a new document → close → Save → Save As to a place that can't be written: the
/// same plain error, the prompt back with the reason, nothing closed or discarded.
#[test]
fn window_close_save_as_that_fails_says_so_and_keeps_the_document() {
let dir = scratch("close-save-as-fails");
let blocker = dir.join("folder");
std::fs::write(&blocker, b"not a folder").unwrap();
let picked = blocker.join("novel.docx").to_string_lossy().to_string();
let mut a = typed();
a.services.pick_save = Some(Box::new(move |_| Some(picked.clone())));
assert!(!a.close_requested());
a.status_msg = None;
let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err();
assert!(err.contains("Couldn't save") && err.contains("still open"), "{err}");
assert!(a.status_msg.as_ref().is_some_and(|(m, _)| *m == err), "the status bar says why: {:?}", a.status_msg);
assert!(!a.quit_requested, "nothing closes");
assert!(a.session.dirty && body_text(&a).contains(UNSAVED), "nothing was discarded");
assert_eq!(a.session.path, None);
assert_eq!(prompt(&a), Some("saveChanges"), "the prompt is open again");
assert_eq!(prompt_message(&a).as_deref(), Some(err.as_str()), "the prompt says why");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1445: the prompt that comes back after a failed save shows the reason on screen.
#[test]
fn save_prompt_shows_why_the_save_failed() {
use egui_kittest::kittest::Queryable;
let mut a = typed();
a.run("file.close", json!({})).unwrap();
let reason = "Couldn't save Draft, so it is still open and nothing was lost: disk full";
if let Some(dialogs::Dialog::SaveChanges { message, .. }) = &mut a.dialog {
*message = reason.into();
}
let mut fonts = false;
let mut h = egui_kittest::Harness::builder().build_ui_state(
move |ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
if fonts {
dialogs::show(app, &ctx);
} else {
theme::install_fonts_for(&ctx, false);
fonts = true;
}
},
a,
);
for _ in 0..3 {
h.step();
}
assert!(h.query_by_label(reason).is_some(), "the reason is in the prompt");
assert!(h.query_by_label("Save").is_some(), "Save again is offered");
}
/// Enter activates the focused prompt button; it means Save only when no button has focus.
/// (Enter on a focused Cancel or Don't Save used to be overridden by a blanket Enter → Save.)
#[test]
@@ -3799,8 +3943,9 @@ mod tests {
a.run("file.new", json!({})).unwrap();
a.status_msg = None;
let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(r["done"], false, "the New was cancelled");
// A failed save is an error, not a quiet Cancel (#1445); the New doesn't run.
let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err();
assert!(err.contains("still open") && err.contains("blocked by the browser"), "{err}");
assert!(body_text(&a).contains(UNSAVED));
assert!(a.session.dirty);
let msg = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default();