diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index e9f0427d..1e8ba30f 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -92,3 +92,33 @@ fn files_that_save_themselves_still_get_checkpoints() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_file_encoded_elsewhere_is_saved_as_of_its_snapshot() { + use crate::cmd::file::FileSnapshot; + let dir = std::env::temp_dir().join(format!("figcraft-snapshot-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("snap.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("file.save", &json!({"path": path})).unwrap(); + rect(&mut s, 0.0); + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 50.0); + assert!(file.save(&mut s, &bytes).unwrap()); + assert!(s.ds().unwrap().is_dirty(), "the edit since the snapshot is still unsaved"); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 10.0, "the file is the snapshot"); + // Saved meanwhile: the older bytes never overwrite the newer file. + let file = FileSnapshot::of(s.ds().unwrap()); + let bytes = file.encode().unwrap(); + rect(&mut s, 100.0); + s.execute("file.save", &json!({})).unwrap(); + assert!(!file.save(&mut s, &bytes).unwrap()); + assert!(!s.ds().unwrap().is_dirty()); + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(t.ds().unwrap().doc.page_content_bounds(t.ds().unwrap().page).unwrap().x1, 110.0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c3b243c7..5043ea40 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -696,10 +696,71 @@ pub fn thumbnail(doc: &Document) -> Option> { /// The `.figcraft` file for a document: content, thumbnail and version history. pub fn file_bytes(ds: &DocState) -> Result> { - let mut doc = (*ds.doc).clone(); - doc.current_page = Some(ds.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string())) + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?; + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +/// 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)) { + tab.mark_saved(); + } } fn color_profile(s: &mut Session, p: &Value) -> Result { @@ -724,9 +785,7 @@ fn save_to(s: &mut Session, path: String) -> Result { ds.mark_saved(); ds.revision += 1; let uid = ds.uid; - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } + branch_tabs_saved(s, uid); remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) }