M6: autosave checkpoints in version history (#38)

Every 30 minutes, each open document with unsaved changes since its last
version gets an automatic "Autosave" version (flagged `auto` in the file).
The newest 20 autosaves are kept; older ones make room and user versions
are never dropped for them. Runs of autosaves fold into one line in the
Version history panel. New command version.autosave.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 05:48:21 +07:00 committed by GitHub
parent 32dabdb5fa
commit e0200acbae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 224 additions and 13 deletions

View File

@ -159,7 +159,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 |
| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 |
| Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 |
| Comments, versions, misc | 3 | 40% | autosave checkpoints, comment polish, plugins (WASM) | 10–17 |
| Comments, versions, misc | 3 | 45% | comment polish, plugins (WASM) | 9–15 |
Weighted feature depth ≈ **58%** (self-graded; the table's arithmetic gives ~59%).

View File

@ -0,0 +1,75 @@
//! Autosave checkpoints: only for unsaved changes since the last version, capped (oldest
//! autosaves go first, user versions stay), kept in the file with their flag.
use serde_json::{Value, json};
use crate::Session;
fn rect(s: &mut Session, x: f64) {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap();
}
fn autosave(s: &mut Session) -> bool {
s.execute("version.autosave", &json!({})).unwrap()["saved"].as_bool().unwrap()
}
fn list(s: &mut Session) -> Vec<Value> {
s.execute("version.list", &json!({})).unwrap().as_array().unwrap().clone()
}
#[test]
fn autosaves_follow_unsaved_changes_only() {
let mut s = Session::new();
assert!(!autosave(&mut s), "nothing changed");
rect(&mut s, 0.0);
assert!(autosave(&mut s));
let l = list(&mut s);
assert_eq!((l[0]["name"].as_str(), l[0]["auto"].as_bool()), (Some("Autosave"), Some(true)));
assert!(!autosave(&mut s), "no change since the checkpoint");
// A version the user saves is a checkpoint too.
rect(&mut s, 20.0);
s.execute("file.saveVersion", &json!({"name": "Mine"})).unwrap();
assert!(!autosave(&mut s));
assert!(list(&mut s)[1].get("auto").is_none());
// Not while a version is shown.
rect(&mut s, 40.0);
s.execute("version.preview", &json!({"index": 0})).unwrap();
assert!(!autosave(&mut s));
s.execute("version.exitPreview", &json!({})).unwrap();
assert!(autosave(&mut s));
}
#[test]
fn oldest_autosaves_make_room_and_user_versions_stay() {
let mut s = Session::new();
rect(&mut s, 0.0);
s.execute("file.saveVersion", &json!({"name": "Keep me"})).unwrap();
for i in 0..(crate::cmd::file::MAX_AUTOSAVES + 5) {
rect(&mut s, i as f64 * 20.0);
assert!(autosave(&mut s));
}
let l = list(&mut s);
assert_eq!(l.iter().filter(|v| v["auto"] == true).count(), crate::cmd::file::MAX_AUTOSAVES);
assert_eq!(l[0]["name"], "Keep me");
}
#[test]
fn autosaves_keep_their_flag_in_the_file() {
let dir = std::env::temp_dir().join(format!("figcraft-autosave-{}", 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();
rect(&mut s, 0.0);
assert!(autosave(&mut s));
s.execute("file.saveVersion", &json!({"name": "Named"})).unwrap();
s.execute("file.save", &json!({"path": path})).unwrap();
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
let l = list(&mut t);
assert_eq!(l.len(), 2);
assert_eq!(l[0]["auto"], true);
assert!(l[1].get("auto").is_none());
// Just opened: nothing to autosave.
assert!(!autosave(&mut t));
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"version.list",
"List versions",
"{} the version history, oldest first: [{id, index, name, description, time (Unix seconds), previewing?}]",
"{} the version history, oldest first: [{id, index, name, description, time (Unix seconds), auto?, previewing?}]",
false,
|s, _| Ok(version_list(s.ds()?))
),
@ -146,6 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(out)
}
),
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?}",
false,
autosave
),
spec!("version.exitPreview", "Back to current version", "{} end a version preview", false, |s, _| {
Ok(json!({"ended": s.ds_mut()?.end_preview()}))
}),
@ -190,7 +197,47 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn version_json(index: usize, v: &Version) -> Value {
json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time})
let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time});
if v.auto {
j["auto"] = json!(true);
}
j
}
/// Autosaves kept per file; older ones make room for new ones.
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<Value> {
let ds = s.ds_mut()?;
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
return Ok(json!({"saved": false}));
}
// Make room: the oldest autosave goes first; user versions are never dropped.
let autos = ds.versions.iter().filter(|v| v.auto).count();
if (autos >= MAX_AUTOSAVES || ds.versions.len() >= figcraft_format::MAX_VERSIONS)
&& let Some(oldest) = ds.versions.iter().position(|v| v.auto)
{
ds.versions_mut().remove(oldest);
}
if ds.versions.len() >= figcraft_format::MAX_VERSIONS {
return Ok(json!({"saved": false}));
}
let mut v = snapshot_version(ds, "Autosave", "");
v.auto = true;
let out = version_json(ds.versions.len(), &v);
ds.versions_mut().push(v);
ds.mark_checkpoint();
Ok(json!({"saved": true, "version": out}))
}
/// A version of `ds`'s document as it is now, opening on the page shown.
fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version {
let mut snap = ds.doc.clone();
if snap.current_page != Some(ds.page) {
Arc::make_mut(&mut snap).current_page = Some(ds.page);
}
Version::new(new_version_id(&ds.versions), name, description, super::comment::now_secs(), snap)
}
fn version_list(ds: &DocState) -> Value {
@ -249,14 +296,11 @@ fn save_version(s: &mut Session, p: &Value) -> Result<Value> {
let name =
string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1));
let description = string(p, "description").unwrap_or_default();
let mut snap = ds.doc.clone();
// The version opens on the page shown when it was saved.
if snap.current_page != Some(ds.page) {
Arc::make_mut(&mut snap).current_page = Some(ds.page);
}
let v = Version::new(new_version_id(&ds.versions), &name, description.trim(), super::comment::now_secs(), snap);
let v = snapshot_version(ds, &name, description.trim());
let out = version_json(ds.versions.len(), &v);
ds.versions_mut().push(v);
ds.mark_checkpoint();
Ok(out)
}

View File

@ -228,6 +228,8 @@ pub struct DocState {
versions_changed: bool,
/// A version shown in place of the document (read-only).
pub preview: Option<VersionPreview>,
/// The document as of the last version saved (or as opened): autosaves only follow changes.
checkpoint: Arc<Document>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@ -259,6 +261,7 @@ impl DocState {
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
let doc = Arc::new(doc);
let mut st = Self {
checkpoint: doc.clone(),
saved_doc: doc.clone(),
doc,
selection: Vec::new(),
@ -292,6 +295,16 @@ impl DocState {
&mut self.versions
}
/// The document changed since the last version was saved (or since it was opened).
pub fn changed_since_checkpoint(&self) -> bool {
!Arc::ptr_eq(self.preview.as_ref().map_or(&self.doc, |p| &p.doc), &self.checkpoint)
}
/// Note that a version of the current document was just saved.
pub(crate) fn mark_checkpoint(&mut self) {
self.checkpoint = self.doc.clone();
}
pub fn is_dirty(&self) -> bool {
let doc = self.preview.as_ref().map_or(&self.doc, |p| &p.doc);
!Arc::ptr_eq(doc, &self.saved_doc) || self.versions_changed
@ -852,6 +865,8 @@ pub fn normalize(doc: &mut Document, before: &Document) {
#[cfg(test)]
mod al_drag_tests;
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod frame_tests;
#[cfg(test)]
mod mask_tests;

View File

@ -68,6 +68,8 @@ pub struct Version {
pub description: String,
/// When it was saved (Unix seconds).
pub time: u64,
/// Saved automatically (a checkpoint) rather than by the user.
pub auto: bool,
snapshot: Snapshot,
}
@ -88,6 +90,7 @@ impl Version {
name: clip(name, MAX_VERSION_NAME),
description: clip(description, MAX_VERSION_DESCRIPTION),
time,
auto: false,
snapshot: Snapshot::Doc(doc),
}
}
@ -251,7 +254,11 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
Snapshot::Stored { json, .. } => put(&mut z, &name, deflated, json)?,
}
let images: Vec<&String> = v.images().keys().collect();
index.push(json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images}));
let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images});
if v.auto {
entry["auto"] = json!(true);
}
index.push(entry);
}
put(&mut z, VERSION_INDEX, deflated, &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?;
}
@ -363,6 +370,7 @@ fn read_versions<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
name: text("name", MAX_VERSION_NAME),
description: text("description", MAX_VERSION_DESCRIPTION),
time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
auto: e.get("auto").and_then(Value::as_bool).unwrap_or(false),
snapshot: Snapshot::Stored { json: Arc::new(json), images },
});
}

View File

@ -94,6 +94,9 @@ pub enum LeftTab {
Assets,
}
/// How often open documents get an autosave checkpoint (seconds).
pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0;
/// UI-only state (serialisable for `ui.inspect`).
pub struct UiState {
pub left_width: f32,
@ -140,6 +143,8 @@ pub struct UiState {
/// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste
/// uses our copied layers; anything else there (an image, SVG, text) came from another app.
pub last_copy: Option<String>,
/// When (UI clock, seconds) open documents next get an autosave checkpoint.
pub next_autosave: f64,
/// The Assets panel shows the libraries view (publish this file, enabled libraries).
pub libraries: bool,
}
@ -186,6 +191,7 @@ impl Default for UiState {
versions: versions::VersionUi::default(),
last_copy: None,
next_autosave: 0.0,
libraries: false,
}
}
@ -337,6 +343,23 @@ impl FigApp {
}
}
/// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version
/// gets an autosave checkpoint in its version history.
fn autosave_tick(&mut self, now: f64) {
if now < self.ui.next_autosave {
return;
}
if self.ui.next_autosave > 0.0 {
let active = self.session.active;
for i in 0..self.session.docs.len() {
self.session.active = i;
let _ = self.session.execute("version.autosave", &json!({}));
}
self.session.active = active;
}
self.ui.next_autosave = now + AUTOSAVE_SECS;
}
/// Draw the whole UI.
pub fn ui(&mut self, ui: &mut egui::Ui) {
#[cfg(not(target_arch = "wasm32"))]
@ -344,6 +367,7 @@ impl FigApp {
let ctx = ui.ctx().clone();
let first = !self.fonts_installed;
self.logic(&ctx);
self.autosave_tick(ctx.input(|i| i.time));
if first {
// New fonts apply from the next frame; draw nothing until then.
ctx.request_repaint();

View File

@ -19,6 +19,8 @@ pub struct VersionUi {
pub form: Option<Form>,
/// The version waiting for delete confirmation.
pub confirm_delete: Option<String>,
/// Runs of autosaves shown unfolded (keyed by their newest version's id).
pub open_groups: std::collections::BTreeSet<String>,
}
/// Title and description being written; `id` is the version being edited (`None` = a new one).
@ -55,12 +57,14 @@ struct Row {
name: String,
description: String,
time: u64,
auto: bool,
}
enum Action {
Close,
Preview(String),
ExitPreview,
ToggleGroup(String),
New,
Submit,
CancelForm,
@ -111,7 +115,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
ds.versions
.iter()
.rev()
.map(|v| Row { id: v.id.clone(), name: v.name.clone(), description: v.description.clone(), time: v.time })
.map(|v| Row { id: v.id.clone(), name: v.name.clone(), description: v.description.clone(), time: v.time, auto: v.auto })
.collect()
})
.unwrap_or_default();
@ -124,7 +128,25 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
action = Some(Action::ExitPreview);
}
let last = rows.len().saturating_sub(1);
for (i, row) in rows.iter().enumerate() {
let mut i = 0;
let mut group_end = 0;
while let Some(row) = rows.get(i) {
// Two or more autosaves in a row fold into one line (open while one of them is shown).
if i >= group_end {
let run = rows.iter().skip(i).take_while(|r| r.auto).count();
if run >= 2 {
group_end = i + run;
let open = app.ui.versions.open_groups.contains(&row.id)
|| rows.iter().skip(i).take(run).any(|r| previewing.as_deref() == Some(r.id.as_str()));
if autosave_group(ui, run, open, group_end > last && !open) {
action = Some(Action::ToggleGroup(row.id.clone()));
}
if !open {
i = group_end;
continue;
}
}
}
let a = if app.ui.versions.confirm_delete.as_deref() == Some(row.id.as_str()) {
inline_row(ui, i == last, |ui| confirm_row(ui, row))
} else if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.as_deref() == Some(row.id.as_str())) {
@ -135,6 +157,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
if a.is_some() {
action = a;
}
i += 1;
}
if rows.is_empty() {
ui.add_space(8.0);
@ -171,6 +194,11 @@ fn act(app: &mut FigApp, a: Action, rows: &[Row]) {
Action::ExitPreview => {
app.run("version.exitPreview", json!({}));
}
Action::ToggleGroup(id) => {
if !st.open_groups.remove(&id) {
st.open_groups.insert(id);
}
}
Action::New => {
st.confirm_delete = None;
st.form = if st.form.as_ref().is_some_and(|f| f.id.is_none()) { None } else { Some(Form::new(None, "", "")) };
@ -410,6 +438,21 @@ pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
}
}
/// The folded line for `n` autosaves in a row; true when clicked (fold / unfold).
fn autosave_group(ui: &mut Ui, n: usize, open: bool, last: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
timeline(ui, r, r.center().y, true, !last, false);
let chevron = Rect::from_center_size(pos2(r.min.x + TEXT_X + 6.0, r.center().y), vec2(12.0, 12.0));
icons::paint(ui.painter(), chevron, if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary);
let label = format!("{n} autosave versions");
ui.painter().text(pos2(chevron.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text_secondary);
resp.on_hover_text(if open { "Fold autosaves" } else { "Show autosaves" }).clicked()
}
/// A row holding a form (rename, delete confirmation), with the timeline running past it.
fn inline_row(ui: &mut Ui, last: bool, add: impl FnOnce(&mut Ui) -> Option<Action>) -> Option<Action> {
let top = ui.cursor().min.y;

View File

@ -182,8 +182,10 @@ name stays), `version.rename {id | index, name, description?}`, `version.delete
gestures are refused until `version.restore` keeps it or `version.exitPreview` goes back. Saving,
closing or saving a version ends the preview first, and `version.list` marks the shown version
`previewing`. Indexes are validated
(a negative or out-of-range index is an error). A file keeps at most 100 versions; there are no
automatic versions. `file.versionHistory` returns the same list and opens the panel in the app.
(a negative or out-of-range index is an error). A file keeps at most 100 versions.
`version.autosave` saves an automatic checkpoint (`auto: true`, named "Autosave") when the document
has unsaved changes since its last version; the app runs it every 30 minutes for each open document,
and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app.
Tool failures come back as results with `isError: true` and a message; protocol problems (unknown
tool, malformed JSON, wrong parameter types) are JSON-RPC errors. Nothing crashes the server.