From 82b05803b18f6ebfd38e423aabf90b95cc4995f1 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 22:48:51 +0200 Subject: [PATCH] M1: a refused browser download keeps the unsaved changes (#969) `file.saveAs {data: true}` marked the file saved before the UI handed the bytes to `Services::download`. When the download was refused, the toast said "Couldn't download" but the document was clean, so closing it no longer warned and the edits could be lost. The data result now leaves the save state alone. A new `file.markSaved` (the bookkeeping a native save already did: the file and its branch tabs) runs from `save_as_dialog` only once the download was delivered. It ends a version preview like the other commands that act on the document itself. Tests: the engine save test checks the file stays dirty until `file.markSaved`; `a_refused_download_says_so` checks the document is still dirty. --- crates/engine/src/cmd/file.rs | 39 +++++++++++++++++++--------- crates/engine/src/lib.rs | 3 ++- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/web_save_tests.rs | 1 + 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d5023372..aa2ea340 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -55,6 +55,13 @@ pub fn specs() -> Vec { false, save_as ), + spec!( + "file.markSaved", + "Mark saved", + "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", + false, + mark_saved + ), spec!( "file.export", "Export", @@ -869,24 +876,30 @@ fn save_to(s: &mut Session, path: Option) -> Result { Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; - if let Some(path) = &path { - write(path, &bytes)?; - } - ds.doc = snapshot.doc; - if path.is_some() { - ds.path.clone_from(&path); - } - ds.mark_saved(); - ds.revision += 1; - let (uid, name) = (ds.uid, format!("{}.figcraft", ds.doc.name)); - branch_tabs_saved(s, uid); let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); }; + write(&path, &bytes)?; + ds.doc = snapshot.doc; + ds.path = Some(path.clone()); + mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { return save_to(s, Some(path)); @@ -1590,7 +1603,7 @@ mod rest_import_tests { } #[test] - fn save_as_data_hands_back_the_file_and_marks_it_saved() { + fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { // The browser has no paths: Save as returns the file for a download (#969). let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); @@ -1598,6 +1611,8 @@ mod rest_import_tests { let recent = s.prefs.recent_files.clone(); let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..e32d7543 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -402,7 +402,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore /// Commands that first end a version preview: they act on the document itself. Saving does so /// only with a native destination; export copies and pending format choices keep the preview. -const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; +const ENDS_PREVIEW: &[&str] = + &["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index bd572dbe..25f3f258 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1920,6 +1920,7 @@ status = "draft — needs native review" "file.revert" = "Volver a lo guardado" "file.save" = "Guardar" "file.saveAs" = "Guardar como" +"file.markSaved" = "Marcar como guardado" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 788bbf4f..0707723d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1146,6 +1146,7 @@ impl FigApp { if let Some(r) = self.run("file.saveAs", json!({"data": true})) && self.download(r["name"].as_str().unwrap_or_default(), &r) { + self.run("file.markSaved", json!({})); dialogs::file_saved(self, &r); } return; diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs index 5fbb2524..4879add1 100644 --- a/crates/ui-egui/src/web_save_tests.rs +++ b/crates/ui-egui/src/web_save_tests.rs @@ -52,6 +52,7 @@ fn a_refused_download_says_so() { app.save_as_dialog(); assert_eq!(got.lock().unwrap().len(), 1); assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); + assert!(app.session.ds().unwrap().is_dirty(), "no file arrived: closing must still warn about the edits"); } #[test]