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:
parent
32dabdb5fa
commit
e0200acbae
|
|
@ -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%).
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue