mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
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:
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user