diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 33a9086c..44d76806 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -301,13 +301,26 @@ fn auto_save_writes_a_changed_file_then_rests() { h.run("shape.rectangle", json!({"x": 500, "y": 300, "width": 40, "height": 40})); assert!(h.frame(vec![]) <= Duration::from_secs_f64(crate::AUTO_SAVE_SECS), "wakes up to save it"); assert!(h.app.session.ds().unwrap().is_dirty(), "not saved at once"); - h.time += crate::AUTO_SAVE_SECS; + // Another change restarts the wait. + h.time += crate::AUTO_SAVE_SECS - 0.5; + h.run("shape.rectangle", json!({"x": 600, "y": 300, "width": 40, "height": 40})); h.frame(vec![]); - assert!(!h.app.session.ds().unwrap().is_dirty(), "saved"); + h.time += 1.0; + h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() && h.app.session.ds().unwrap().is_dirty(), "waits for a pause in the changes"); + h.time += crate::AUTO_SAVE_SECS; + // The file is encoded on a thread of its own, then written. + let start = Instant::now(); + while h.app.session.ds().unwrap().is_dirty() { + assert!(start.elapsed() < Duration::from_secs(20), "saved"); + let delay = h.frame(vec![]); + assert!(h.app.ui.auto_saving.is_empty() || delay <= Duration::from_millis(100), "looks again for the file being encoded"); + std::thread::sleep(Duration::from_millis(5)); + } let mut t = Session::empty(); t.execute("file.open", &json!({"path": path})).unwrap(); let ds = t.ds().unwrap(); - assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 540.0, "the new rectangle is in the file"); + assert!(ds.doc.page_content_bounds(ds.page).unwrap().x1 >= 640.0, "the new rectangles are in the file"); h.assert_idle("an auto-saved file"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad811b43..25fe084e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -271,11 +271,14 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; -/// With auto-save on: how long after a file changes it is saved, and how long a failed save waits -/// before trying again (seconds). +/// With auto-save on: how long after a file last changed it is saved, and how long a failed save +/// waits before trying again (seconds). pub const AUTO_SAVE_SECS: f64 = 3.0; const AUTO_SAVE_RETRY_SECS: f64 = 60.0; +/// A file being encoded for auto-save: its snapshot and, once done, its bytes. +pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result>)>; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -411,8 +414,11 @@ pub struct UiState { pub last_copy: Option, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, - /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved. - pub auto_save_at: Option, + /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and + /// the revisions of those files it waits on (a change restarts the wait). + pub auto_save_at: Option<(f64, u64)>, + /// Files being encoded for auto-save on threads of their own (tab `uid`). + pub auto_saving: Vec<(u64, AutoSaveEncoding)>, /// The Assets panel shows the libraries view (publish this file, enabled libraries). pub libraries: bool, /// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab). @@ -498,6 +504,7 @@ impl Default for UiState { last_copy: None, next_autosave: 0.0, auto_save_at: None, + auto_saving: Vec::new(), libraries: false, nav: a11y::Nav::default(), plugins_window: false, @@ -961,39 +968,79 @@ impl FigApp { repaint_in(ctx, AUTOSAVE_SECS); } - /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they change - /// (not during a gesture, a live preview or while a version is shown). + /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last + /// changed (not during a gesture, a live preview or while a version is shown). The file is + /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. fn auto_save_tick(&mut self, ctx: &egui::Context) { - let s = &self.session; - let unsaved = |d: &figcraft_engine::DocState| d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty(); - let due: Vec = s.docs.iter().enumerate().filter(|(_, d)| unsaved(d)).map(|(i, _)| i).collect(); - if !s.prefs.auto_save || due.is_empty() { - self.ui.auto_save_at = None; - return; - } let now = ctx.input(|i| i.time); - let at = *self.ui.auto_save_at.get_or_insert(now + AUTO_SAVE_SECS); - if now < at || s.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { - // Due but busy: look again in a second. - repaint_in(ctx, if now < at { at - now } else { 1.0 }); - return; - } - let active = self.session.active; - let mut failed = None; - for i in due { - self.session.active = i; - if let Err(e) = self.session.execute("file.save", &json!({})) { - failed.get_or_insert(e.to_string()); + let mut failed = self.auto_save_done(); + // The browser has no files to save to. + let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; + let saving = &self.ui.auto_saving; + let due = |d: &&figcraft_engine::DocState| { + d.path.is_some() && d.branch_of.is_none() && d.preview.is_none() && d.is_dirty() && !saving.iter().any(|(uid, _)| *uid == d.uid) + }; + let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None }; + match changes { + None => self.ui.auto_save_at = None, + Some(changes) => { + let at = match self.ui.auto_save_at { + Some((at, c)) if c == changes && failed.is_none() => at, + _ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS }, + }; + self.ui.auto_save_at = Some((at, changes)); + if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { + // Due but busy: look again in a second. + repaint_in(ctx, if now < at { at - now } else { 1.0 }); + } else { + self.ui.auto_save_at = None; + let files: Vec<_> = + self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect(); + for (uid, file) in files { + let encode = move || { + let bytes = file.encode(); + (file, bytes) + }; + match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) { + Ok(h) => self.ui.auto_saving.push((uid, h)), + Err(e) => { + failed.get_or_insert(e.to_string()); + self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes)); + } + } + } + } } } - self.session.active = active; + if !self.ui.auto_saving.is_empty() { + // Look again shortly for the files being encoded. + repaint_in(ctx, 0.1); + } if let Some(e) = failed { - self.ui.auto_save_at = Some(now + AUTO_SAVE_RETRY_SECS); - repaint_in(ctx, AUTO_SAVE_RETRY_SECS); self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); } } + /// Write the files whose auto-save encoding finished; the first error, if one failed. + fn auto_save_done(&mut self) -> Option { + if self.ui.auto_saving.is_empty() { + return None; + } + let mut failed = None; + let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect(); + for (_, h) in done { + let saved = match h.join() { + Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()), + Ok((_, Err(e))) => Err(e.to_string()), + Err(_) => Err("the file couldn't be encoded".to_string()), + }; + if let Err(e) = saved { + failed.get_or_insert(e); + } + } + failed + } + /// Draw the whole UI: the main window (the other windows draw themselves). pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))]