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.
This commit is contained in:
Ridzzz0Alam
2026-10-21 22:48:51 +02:00
parent 09ff20ffd6
commit 82b05803b1
5 changed files with 32 additions and 13 deletions
+27 -12
View File
@@ -55,6 +55,13 @@ pub fn specs() -> Vec<CommandSpec> {
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<String>) -> Result<Value> {
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<Value> {
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<Value> {
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");
+2 -1
View File
@@ -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);
+1
View File
@@ -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"
+1
View File
@@ -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;
+1
View File
@@ -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]