From 00ac44f6caf4dd8f6081aa96927c507081c35ba1 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Wed, 14 Oct 2026 16:48:56 +0200 Subject: [PATCH 1/3] M1: save files automatically Preferences > Save files automatically (pref.autoSave, off by default; hidden in the browser, which has no files to save to). With it on, each open file that has a path is saved 3 s after it changes, never during a gesture, a live text preview or while a version is shown. A failed save says why in a toast and tries again a minute later. Version history checkpoints (version.autosave, every 30 minutes) only ran for files with unsaved changes, so a file that saves itself would stop getting them; with auto-save on, edits made since the file was opened count. Closes #296 --- ROADMAP.md | 4 ++- crates/engine/src/autosave_tests.rs | 19 +++++++++++++ crates/engine/src/cmd/file.rs | 7 +++-- crates/engine/src/cmd/view.rs | 1 + crates/engine/src/lib.rs | 4 +++ crates/ui-egui/locales/de.toml | 3 +++ crates/ui-egui/locales/en.toml | 2 ++ crates/ui-egui/locales/es.toml | 3 +++ crates/ui-egui/locales/fr.toml | 3 +++ crates/ui-egui/locales/ja.toml | 3 +++ crates/ui-egui/locales/ko.toml | 3 +++ crates/ui-egui/locales/pt-BR.toml | 3 +++ crates/ui-egui/locales/zh-Hans.toml | 3 +++ crates/ui-egui/src/dialogs.rs | 4 +++ crates/ui-egui/src/idle_tests.rs | 23 ++++++++++++++++ crates/ui-egui/src/lib.rs | 42 +++++++++++++++++++++++++++++ docs/mcp.md | 2 ++ docs/roadmap.md | 3 ++- 18 files changed, 128 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46c0fd29..f19a72e4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (auto-save (#296); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-14** · Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. @@ -312,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs index a02c661c..e9f0427d 100644 --- a/crates/engine/src/autosave_tests.rs +++ b/crates/engine/src/autosave_tests.rs @@ -73,3 +73,22 @@ 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); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b0..1b6fe49c 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) { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 6135e5df..9c1371cc 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 3bc49a90..9ee0c372 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -564,6 +564,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, @@ -616,6 +619,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 7552cca3..aad1d643 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -301,6 +301,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" @@ -1070,6 +1071,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" @@ -1784,6 +1786,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 c35b1677..550268f7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -310,6 +310,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" @@ -1112,6 +1113,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 6514ac7b..fa5e5ff2 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -311,6 +311,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" @@ -1113,6 +1114,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" @@ -1865,6 +1867,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 c8665934..e3022acb 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -301,6 +301,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" @@ -1070,6 +1071,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é" @@ -1784,6 +1786,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 f7b9cf9f..36bdfd43 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.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/ko.toml b/crates/ui-egui/locales/ko.toml index 59bb2386..c81420b3 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -302,6 +302,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" = "먼저 변수 컬렉션을 만드세요" @@ -1071,6 +1072,7 @@ status = "draft — needs native review" "Save" = "저장" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" +"Save files automatically" = "파일 자동 저장" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" "Saved {name}" = "{name} 저장됨" @@ -1785,6 +1787,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 a692a8ce..ea0904d7 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -300,6 +300,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" @@ -1069,6 +1070,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" @@ -1783,6 +1785,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 d2431df0..08246546 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.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/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee636..0b712ccb 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 2d4e5757..33a9086c 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -288,3 +288,26 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[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"); + h.time += crate::AUTO_SAVE_SECS; + h.frame(vec![]); + assert!(!h.app.session.ds().unwrap().is_dirty(), "saved"); + 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"); + 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 613f18f9..6ba2d5da 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -241,6 +241,11 @@ 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). +pub const AUTO_SAVE_SECS: f64 = 3.0; +const AUTO_SAVE_RETRY_SECS: f64 = 60.0; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -376,6 +381,8 @@ 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, /// 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). @@ -460,6 +467,7 @@ impl Default for UiState { last_copy: None, next_autosave: 0.0, + auto_save_at: None, libraries: false, nav: a11y::Nav::default(), plugins_window: false, @@ -921,6 +929,39 @@ 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). + 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()); + } + } + self.session.active = active; + 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)])); + } + } + /// 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"))] @@ -930,6 +971,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 2b66657c..352e96e0 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|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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 8e2780d6..e31aa387 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (auto-save (#296); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Auto-save (#296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only) saves each open file that has a path 3 s after it changes, never during a gesture, a live preview or while a version is shown; a failed save says why and tries again a minute later. Version history checkpoints keep coming every 30 minutes for files that save themselves. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | | 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | From 8616f3d1c5f0af0c4cb68280df0c5c20cb0bf738 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:11:52 +0700 Subject: [PATCH 2/3] #305: save a file from a snapshot encoded elsewhere FileSnapshot takes what saving writes, encodes it (thumbnail and all) on any thread, and writes it back as of the snapshot: edits since stay unsaved, and bytes from before another save never overwrite the newer file. --- crates/engine/src/autosave_tests.rs | 30 ++++++++++++ crates/engine/src/cmd/file.rs | 73 ++++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 7 deletions(-) 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()})) } From b75e5bea3bed0062914d583fbb8b952f2fc7de44 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 18:11:54 +0700 Subject: [PATCH 3/3] #305: auto-save after a pause, off the UI thread Nothing is collected while auto-save is off. The wait restarts whenever an unsaved file changes, so continuous editing doesn't save every few seconds, and the file is encoded on a thread of its own, then written. --- crates/ui-egui/src/idle_tests.rs | 19 +++++- crates/ui-egui/src/lib.rs | 103 ++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 31 deletions(-) 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"))]