Save prompt on quit: "Save changes to <name>?" for every unsaved design

The title bar's ×, File ▸ Quit, the OS closing the window (Alt+F4, Cmd+Q, the window manager:
close_requested is cancelled until the designs are answered) and app.quit / ui.window close all
go through documents::request_quit, which asks Save / Don't Save / Cancel once per design with
unsaved changes. Save saves (Save As for a never-saved design) and quits only if it saved; Don't
Save discards (no recovery copy is kept); Cancel or Esc stays open. Closing a tab asks the same
question (now titled "Save changes to <name>?"); New and Open never replace an unsaved design
(they open a tab). The web app asks through beforeunload while a design is unsaved. Control:
ui.documents {action: quit, answer: save|dont_save|cancel}, app.quit {force}. Tests: quit with
Save then Don't Save across two tabs, Cancel, a Save that saves nothing, Save As; scenario
s43_quit_asks_to_save.

Fixes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 16:03:32 +00:00
1 parent d2bc8e9847
commit 4562c8e5ba
10 files changed
+240 -30

No files matched your search

+1
View File
@@ -29,4 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
] }
+23 -1
View File
@@ -27,6 +27,25 @@ mod web {
/// Seconds between autosaves of unsaved work.
const AUTOSAVE_S: f64 = 30.0;
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
e.prevent_default();
e.set_return_value("unsaved");
}
});
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_unload.forget();
}
struct Shell {
app: SolveApp,
files: Rc<Files>,
@@ -291,6 +310,7 @@ mod web {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
files::take_drops(&self.files, raw);
@@ -317,7 +337,9 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let Some(document) = window.document() else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
+6
View File
@@ -21,6 +21,12 @@ struct App(SolveApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.0);
}
self.0.logic(ctx);
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+12 -3
View File
@@ -111,6 +111,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
"documents": app.docs.count(),
"quit_requested": app.quit_requested,
"quitting": app.docs.quitting.is_some(),
})),
"ui.documents" => ok(crate::documents::control(app, p)),
"ui.home" => {
@@ -459,9 +461,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
}
"app.quit" => {
app.quit_requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
// `force`: quit without asking about unsaved designs.
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
app.quit_requested = true;
} else {
crate::documents::request_quit(app);
}
if app.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
ok(json!({"quitting": app.quit_requested, "asking": app.docs.closing.is_some()}))
}
other => err(format!("unknown method `{other}`")),
}
+168 -20
View File
@@ -1,7 +1,8 @@
//! Several open designs, as tabs in the application bar. The active design lives in the app as
//! always (`app.session`, `app.cam`…); the others wait here with their own session (undo,
//! selection), camera and per-design view state, and are swapped in when their tab is picked.
//! Closing a design with unsaved changes asks first.
//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?"
//! per design with unsaved changes ([`request_quit`]).
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
@@ -48,6 +49,8 @@ pub struct Documents {
pub active: usize,
/// A tab waiting for the answer to "save changes?".
pub closing: Option<usize>,
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
pub quitting: Option<Vec<usize>>,
}
impl Documents {
@@ -214,6 +217,44 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
}
}
/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
/// Cancel, or a Save that didn't save, stays open.
pub fn request_quit(app: &mut SolveApp) {
if app.docs.quitting.is_none() {
app.docs.quitting = Some(Vec::new());
}
quit_step(app);
}
/// Ask about the next design with unsaved changes, or quit when none is left.
fn quit_step(app: &mut SolveApp) {
let Some(skip) = app.docs.quitting.clone() else { return };
if app.docs.closing.is_some() {
return;
}
match (0..app.docs.count()).find(|i| !skip.contains(i) && dirty(app, *i)) {
Some(i) => {
switch(app, i);
app.docs.closing = Some(i);
}
None => {
// Don't Save means discard: no recovery copy is kept for the next launch either.
for i in skip {
switch(app, i);
app.session.mark_saved();
}
app.docs.quitting = None;
app.quit_requested = true;
}
}
}
/// Is any open design unsaved (the web page asks before it is left)?
pub fn any_dirty(app: &SolveApp) -> bool {
(0..app.docs.count()).any(|i| dirty(app, i))
}
fn dirty(app: &SolveApp, i: usize) -> bool {
if i == app.docs.active || app.docs.slots.is_empty() {
return app.session.is_dirty();
@@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
.collect()
}
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
/// closes without asking).
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
match p.get("action").and_then(Value::as_str) {
Some("new") => new_design(app),
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)),
Some("quit") => request_quit(app),
_ => {}
}
let answer = match p.get("answer").and_then(Value::as_str) {
Some("save") => Some(Answer::Save),
Some("dont_save") => Some(Answer::DontSave),
Some("cancel") => Some(Answer::Cancel),
_ => None,
};
if let Some(a) = answer {
answer_prompt(app, a);
}
let tabs: Vec<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing})
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some()})
}
/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close,
@@ -375,50 +426,81 @@ pub fn layout(n: usize, active: usize, room: f32) -> (usize, usize, f32) {
(first, fit, MIN_TAB)
}
/// The "save changes?" prompt for a tab being closed.
/// What "save changes?" was answered.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Save,
DontSave,
Cancel,
}
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
quit_step(app);
let Some(i) = app.docs.closing else { return };
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
let mut answer: Option<u8> = None;
crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal)
let mut answer: Option<Answer> = None;
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
.id(egui::Id::new("sc_close_prompt"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
.show(ctx, |ui| {
ui.label(format!("{name} has unsaved changes."));
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
answer = Some(0);
answer = Some(Answer::Save);
}
if ui.button("Don't Save").clicked() {
answer = Some(1);
answer = Some(Answer::DontSave);
}
if ui.button("Cancel").clicked() {
answer = Some(2);
answer = Some(Answer::Cancel);
}
});
});
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
answer = Some(2);
answer = Some(Answer::Cancel);
app.esc_handled = true;
}
if let Some(a) = answer {
answer_prompt(app, a);
}
}
/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`).
pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
let Some(i) = app.docs.closing else { return };
match answer {
Some(0) => {
Answer::Save => {
switch(app, i);
crate::toolbar::save(app);
// Saved (not cancelled in the file picker): close it.
if !app.session.is_dirty() {
let saved = !app.session.is_dirty();
app.docs.closing = None;
if app.docs.quitting.is_some() {
// Quit only once it is saved; a cancelled picker or a failed save stays open.
if !saved {
app.docs.quitting = None;
}
} else if saved {
close(app, app.docs.active, true);
} else {
app.docs.closing = None;
}
}
Some(1) => close(app, i, true),
Some(2) => app.docs.closing = None,
_ => {}
Answer::DontSave => {
if let Some(skip) = app.docs.quitting.as_mut() {
skip.push(i);
app.docs.closing = None;
} else {
close(app, i, true);
}
}
Answer::Cancel => {
app.docs.closing = None;
app.docs.quitting = None;
}
}
quit_step(app);
}
#[cfg(test)]
@@ -473,6 +555,72 @@ mod tests {
assert!(active_is_blank(&a) && a.home.open);
}
/// Quitting asks once per design with unsaved changes: Don't Save moves on (and keeps no
/// recovery copy), Save saves and moves on, and the app quits after the last answer.
#[test]
fn quit_asks_about_each_unsaved_design_then_quits() {
let mut a = app();
request_quit(&mut a);
assert!(a.quit_requested, "nothing unsaved: quits at once");
let mut a = app();
let file = std::env::temp_dir().join(format!("solvecraft-quit-save-{}.solvecraft", std::process::id()));
let _ = std::fs::remove_file(&file);
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
a.run("file.save_as", json!({"path": file.to_string_lossy()})).unwrap();
a.run("solid.sphere", json!({"diameter": 4})).unwrap();
new_design(&mut a);
a.run("solid.sphere", json!({"diameter": 8})).unwrap();
assert!(dirty(&a, 0) && dirty(&a, 1));
request_quit(&mut a);
assert!(!a.quit_requested);
assert_eq!(a.docs.closing, Some(0), "the first unsaved design is asked about first");
assert_eq!(a.docs.active, 0, "and shown");
answer_prompt(&mut a, Answer::Save);
assert!(!dirty(&a, 0), "saved");
assert_eq!(
std::fs::read(&file).ok().and_then(|b| solvecraft_engine::io::read_design(&b).ok()).map(|d| d.features.len()),
Some(2),
"the file holds the change"
);
assert_eq!(a.docs.closing, Some(1), "then the next one");
assert!(!a.quit_requested);
answer_prompt(&mut a, Answer::DontSave);
assert_eq!(a.docs.closing, None);
assert!(a.quit_requested, "every design answered: quit");
assert!(!any_dirty(&a), "Don't Save discards: nothing is kept for recovery");
let _ = std::fs::remove_file(&file);
}
/// Cancel, Esc's answer, or a Save that saves nothing (a never-saved design whose Save As
/// picker is cancelled) stay open with the changes.
#[test]
fn quit_cancelled_or_unsaved_stays_open() {
let mut a = app();
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
request_quit(&mut a);
assert_eq!(a.docs.closing, Some(0));
answer_prompt(&mut a, Answer::Cancel);
assert!(!a.quit_requested && a.docs.closing.is_none() && a.docs.quitting.is_none());
assert!(a.session.is_dirty(), "the changes are still there");
// No Save As picker answer (the default services have none): not saved, so no quit.
request_quit(&mut a);
answer_prompt(&mut a, Answer::Save);
assert!(!a.quit_requested && a.docs.quitting.is_none() && a.session.is_dirty());
// A picked file: saved, then quit.
let file = std::env::temp_dir().join(format!("solvecraft-quit-saveas-{}.solvecraft", std::process::id()));
let _ = std::fs::remove_file(&file);
let to = file.to_string_lossy().to_string();
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone())));
request_quit(&mut a);
answer_prompt(&mut a, Answer::Save);
assert!(a.quit_requested && file.exists());
let _ = std::fs::remove_file(&file);
}
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
#[test]
fn adopt_reuses_a_blank_design() {
+5
View File
@@ -738,6 +738,11 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
return Err(format!("start page open: got {}, want {v}", ui["home"]));
}
}
"quit_requested" | "quitting" => {
if ui[k] != *v {
return Err(format!("{k}: got {}, want {v}", ui[k]));
}
}
"documents" => {
if ui["documents"] != *v {
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
+5 -2
View File
@@ -57,8 +57,11 @@ pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> R
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
"toggle" => toggle_maximize(ctx),
"close" => {
app.quit_requested = true;
ctx.send_viewport_cmd(ViewportCommand::Close);
// Unsaved designs are asked about first; the window closes once they are answered.
crate::documents::request_quit(app);
if app.quit_requested {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
}
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
}
+1 -1
View File
@@ -194,7 +194,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
close = true;
}
if item(ui, "Quit", "") {
app.quit_requested = true;
crate::documents::request_quit(app);
close = true;
}
});
@@ -0,0 +1,16 @@
[
{"note": "Issue #33: quitting with unsaved changes asks \"Save changes to <name>?\"; Cancel stays open."},
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}},
{"call": "ui.window", "params": {"action": "close"}},
{"expect": {"shown": "Don't Save", "quitting": true, "quit_requested": false}},
{"widget": "Cancel"},
{"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false, "bodies": 1}},
{"note": "The File menu's Quit asks the same; Esc answers Cancel."},
{"call": "app.quit"},
{"expect": {"shown": "Don't Save", "quit_requested": false}},
{"key": "Escape"},
{"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false}},
{"call": "app.quit"},
{"widget": "Don't Save"},
{"expect": {"quit_requested": true}}
]
+3 -3
View File
@@ -45,15 +45,15 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) |
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
| `ui.documents` | `action?`: `new`, `switch`, `close`; `index?`; `force?` (close without asking) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt |
| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking |
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.resize` | `width`, `height` | |
| `app.quit` | — | |
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
## MCP