M1: Save no longer overwrites changes another app made to the open file (#1330)

With a file open in the desktop app, figcraft-cli could change it on disk;
the next Save (or auto-save) wrote the app's copy over it and said
"Saved", losing the CLI's changes.

Each tab now remembers its file's size and modification time when it
opens, reverts or saves it (the Stamp library files already use), and
Save and auto-save refuse while the file on disk differs, pointing to
Revert to saved or Save as. Scripts pass `file.save {overwrite: true}`.
Save as and saves to an explicit path are unchanged.
This commit is contained in:
Ridzzz0Alam
2026-10-26 11:04:01 +01:00
parent 63d926876f
commit a648700fcc
5 changed files with 124 additions and 15 deletions
+30 -9
View File
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.save",
"Save",
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
"{path?, overwrite? (replace what another app wrote to the file since it was opened or saved: without it that save is refused)} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save
),
@@ -167,6 +167,7 @@ pub fn specs() -> Vec<CommandSpec> {
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.note_on_disk();
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
@@ -958,12 +959,12 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
}
let summary = import_summary(&loaded.doc);
let images_left_out = if native { loaded.images_past_budget } else { 0 };
s.docs.push(
DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out),
);
let mut ds = DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out);
ds.note_on_disk();
s.docs.push(ds);
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
@@ -1053,7 +1054,9 @@ impl FileSnapshot {
};
let path = self.path.as_deref().ok_or(EngineError::NoDocument)?;
refuse_dropping_images(ds, path)?;
refuse_overwriting_changes(ds, path)?;
write(path, bytes)?;
ds.note_on_disk();
// The history or branches may have changed since: then they still count as unsaved.
if ds.revision == self.revision {
ds.versions_changed = false;
@@ -1075,6 +1078,17 @@ fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> {
}
}
/// Saving over the tab's own file would lose what another app wrote to it since (#1330).
fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> {
match (&ds.on_disk, crate::library::stat(path)) {
(Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!(
"{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.",
file_stem(path)
))),
_ => Ok(()),
}
}
/// The branch tabs of the file in tab `uid` were saved with it.
fn branch_tabs_saved(s: &mut Session, uid: u64) {
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
@@ -1137,6 +1151,7 @@ fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
write(&path, &bytes)?;
ds.doc = snapshot.doc;
ds.path = Some(path.clone());
ds.note_on_disk();
mark_saved(s, &json!({}))?;
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
@@ -1157,13 +1172,19 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = string(p, "path") {
return save_to(s, Some(path));
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?;
match ds.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
Some(path) if ext(&path) == "fig" => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, Some(path)),
Some(path) => {
if p.get("overwrite").and_then(Value::as_bool) != Some(true) {
refuse_overwriting_changes(ds, &path)?;
}
save_to(s, Some(path))
}
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))