diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index a02c661c..1e8ba30f 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -73,3 +73,52 @@ fn autosaves_keep_their_flag_in_the_file() { assert!(!autosave(&mut t)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn files_that_save_themselves_still_get_checkpoints() { + let dir = std::env::temp_dir().join(format!("figcraft-autosave-on-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + s.execute("pref.autoSave", &json!({"on": true})).unwrap(); + rect(&mut s, 0.0); + s.execute("file.save", &json!({"path": path})).unwrap(); + assert!(autosave(&mut s), "saved, but changed since the last version"); + assert!(!autosave(&mut s), "no change since the checkpoint"); + // Just opened: nothing to checkpoint. + let mut t = Session::empty(); + t.execute("pref.autoSave", &json!({"on": true})).unwrap(); + t.execute("file.open", &json!({"path": path})).unwrap(); + 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 8122af42..5043ea40 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { spec!( "version.autosave", "Autosave version", - "{} save an automatic checkpoint when the document has unsaved changes since the last version (the app does this every 30 minutes); keeps the newest 20 autosaves → {saved, version?}", + "{} save an automatic checkpoint when the document has unsaved changes since the last version (with `pref.autoSave` on: changes made since it was opened); the app does this every 30 minutes; keeps the newest 20 autosaves → {saved, version?}", false, autosave ), @@ -257,8 +257,11 @@ pub const MAX_AUTOSAVES: usize = 20; /// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; let ds = s.ds_mut()?; - if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } Ok(match add_version(ds, AUTOSAVE, "", true) { @@ -693,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 { @@ -721,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()})) } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 13f638b9..0f09a6db 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -334,6 +334,7 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc..bf375be1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -573,6 +573,9 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// Files that have a path are saved by themselves a few seconds after they change (the app's + /// timer; off: only File ▸ Save writes them). + pub auto_save: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -625,6 +628,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + auto_save: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index affda97d..d9641b85 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -302,6 +302,7 @@ status = "draft — needs native review" "Corner radius" = "Eckenradius" "Corner smoothing" = "Eckenglättung" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" "Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung" @@ -1071,6 +1072,7 @@ status = "draft — needs native review" "Save" = "Speichern" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" +"Save files automatically" = "Dateien automatisch speichern" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" "Saved {name}" = "{name} gespeichert" @@ -1785,6 +1787,7 @@ status = "draft — needs native review" "plugin.ui" = "Plugin-Bereich" "plugins.manage" = "Plugins verwalten…" "plugins.runLast" = "Letztes Plugin ausführen" +"pref.autoSave" = "Dateien automatisch speichern" "pref.highlightHover" = "Ebenen beim Überfahren hervorheben" "pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen" "pref.language" = "Sprache der Benutzeroberfläche" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 08ac64ba..73c41dcf 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -311,6 +311,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" "Create a variable collection first" = "Create a variable collection first" @@ -1113,6 +1114,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Save files automatically" = "Save files automatically" "Search templates" = "Search templates" "See all templates" = "See all templates" "Select layers and they space themselves as their content changes" = "Select layers and they space themselves as their content changes" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 3200e891..245284dd 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -312,6 +312,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" "Create a variable collection first" = "Crea primero una colección de variables" @@ -1114,6 +1115,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Save files automatically" = "Guardar archivos automáticamente" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" "Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido" @@ -1866,6 +1868,7 @@ status = "draft — needs native review" "plugin.ui" = "Panel del plugin" "plugins.manage" = "Gestionar plugins…" "plugins.runLast" = "Ejecutar el último plugin" +"pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 69798340..efb13973 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -302,6 +302,7 @@ status = "draft — needs native review" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" "Create a variable collection first" = "Créez d’abord une collection de variables" @@ -1071,6 +1072,7 @@ status = "draft — needs native review" "Save" = "Enregistrer" "Save as" = "Enregistrer sous" "Save as preset…" = "Enregistrer comme préréglage…" +"Save files automatically" = "Enregistrer les fichiers automatiquement" "Save version {key}" = "Enregistrer une version {key}" "Saved" = "Enregistré" "Saved {name}" = "{name} enregistré" @@ -1785,6 +1787,7 @@ status = "draft — needs native review" "plugin.ui" = "Panneau du plugin" "plugins.manage" = "Gérer les plugins…" "plugins.runLast" = "Exécuter le dernier plugin" +"pref.autoSave" = "Enregistrer les fichiers automatiquement" "pref.highlightHover" = "Mettre en évidence les calques au survol" "pref.keepTool" = "Garder l’outil sélectionné après usage" "pref.language" = "Langue de l’interface" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index fb81b042..8c01d3d9 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -314,6 +314,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" "Create a variable collection first" = "先に変数コレクションを作成" @@ -1122,6 +1123,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "名前を付けて保存" "Save as preset…" = "プリセットとして保存…" +"Save files automatically" = "ファイルを自動保存" "Save version {key}" = "バージョンを保存 {key}" "Saved" = "保存済み" "Saved {name}" = "{name} を保存済み" @@ -1868,6 +1870,7 @@ status = "draft — needs native review" "plugin.ui" = "プラグインパネル" "plugins.manage" = "プラグインを管理…" "plugins.runLast" = "最後のプラグインを実行" +"pref.autoSave" = "ファイルを自動保存" "pref.highlightHover" = "ホバー時にレイヤーをハイライト" "pref.keepTool" = "使用後もツールを選択したままにする" "pref.language" = "インターフェースの言語" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index aed3529a..7d0c415e 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -303,6 +303,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" "Create a variable collection first" = "먼저 변수 컬렉션을 만드세요" @@ -1072,6 +1073,7 @@ status = "draft — needs native review" "Save" = "저장" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" +"Save files automatically" = "파일 자동 저장" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" "Saved {name}" = "{name} 저장됨" @@ -1786,6 +1788,7 @@ status = "draft — needs native review" "plugin.ui" = "플러그인 패널" "plugins.manage" = "플러그인 관리…" "plugins.runLast" = "마지막 플러그인 실행" +"pref.autoSave" = "파일 자동 저장" "pref.highlightHover" = "마우스를 올리면 레이어 강조" "pref.keepTool" = "사용 후에도 도구 선택 유지" "pref.language" = "인터페이스 언어" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 97500236..0bbbc29f 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -301,6 +301,7 @@ status = "draft — needs native review" "Corner radius" = "Raio do canto" "Corner smoothing" = "Suavização de cantos" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" "Create a variable collection first" = "Crie primeiro uma coleção de variáveis" @@ -1070,6 +1071,7 @@ status = "draft — needs native review" "Save" = "Salvar" "Save as" = "Salvar como" "Save as preset…" = "Salvar como predefinição…" +"Save files automatically" = "Salvar arquivos automaticamente" "Save version {key}" = "Salvar versão {key}" "Saved" = "Salvo" "Saved {name}" = "{name} salvo" @@ -1784,6 +1786,7 @@ status = "draft — needs native review" "plugin.ui" = "Painel do plugin" "plugins.manage" = "Gerenciar plugins…" "plugins.runLast" = "Executar o último plugin" +"pref.autoSave" = "Salvar arquivos automaticamente" "pref.highlightHover" = "Destacar camadas ao passar o cursor" "pref.keepTool" = "Manter a ferramenta selecionada após o uso" "pref.language" = "Idioma da interface" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index d1887735..993f1c57 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -304,6 +304,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建" "Create a variable collection first" = "请先创建变量集合" @@ -1073,6 +1074,7 @@ status = "draft — needs native review" "Save" = "保存" "Save as" = "另存为" "Save as preset…" = "存储为预设…" +"Save files automatically" = "自动保存文件" "Save version {key}" = "保存版本 {key}" "Saved" = "已保存" "Saved {name}" = "已保存 {name}" @@ -1787,6 +1789,7 @@ status = "draft — needs native review" "plugin.ui" = "插件面板" "plugins.manage" = "管理插件…" "plugins.runLast" = "运行上一个插件" +"pref.autoSave" = "自动保存文件" "pref.highlightHover" = "悬停时高亮图层" "pref.keepTool" = "使用后保持工具选中" "pref.language" = "界面语言" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 24fc12a4..d881c882 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -163,6 +163,10 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if app.services.reduce_motion() { crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary); } + // The browser has no files to save to. + if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) { + toggles.push("pref.autoSave"); + } for c in toggles { app.run(c, json!({})); } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index f8f2f8af..c61699b5 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -324,3 +324,39 @@ fn a_hidden_window_answers_requests_that_need_no_frame() { assert_eq!(rx.try_recv().unwrap().id, json!(2)); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn auto_save_writes_a_changed_file_then_rests() { + let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("auto.figcraft").to_string_lossy().to_string(); + let (mut h, _) = document(); + h.run("file.save", json!({"path": path})); + h.run("pref.autoSave", json!({"on": true})); + h.assert_idle("a saved file"); + 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"); + // 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![]); + 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 >= 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 16cb4c20..4d75b4e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -305,6 +305,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 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; @@ -440,6 +448,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, 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). @@ -524,6 +537,8 @@ 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, @@ -1066,6 +1081,79 @@ impl FigApp { repaint_in(ctx, AUTOSAVE_SECS); } + /// 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 now = ctx.input(|i| i.time); + 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)); + } + } + } + } + } + } + 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.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"))] @@ -1076,6 +1164,7 @@ impl FigApp { let first = !self.fonts_installed; self.logic(&ctx); self.autosave_tick(&ctx); + self.auto_save_tick(&ctx); if first { // New fonts apply from the next frame; draw nothing until then. ctx.request_repaint(); diff --git a/docs/mcp.md b/docs/mcp.md index 0704ed40..c16db025 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -349,6 +349,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. +- `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change + (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English.