diff --git a/ROADMAP.md b/ROADMAP.md index 7a9d97b..2bb145f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%). diff --git a/crates/engine/src/autosave_tests.rs b/crates/engine/src/autosave_tests.rs new file mode 100644 index 0000000..a02c661 --- /dev/null +++ b/crates/engine/src/autosave_tests.rs @@ -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 { + 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); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4624060..9887c47 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -95,7 +95,7 @@ pub fn specs() -> Vec { 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 { 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 { } 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 { + 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 { 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) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 50ba56c..5eb9fd8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -228,6 +228,8 @@ pub struct DocState { versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, + /// The document as of the last version saved (or as opened): autosaves only follow changes. + checkpoint: Arc, } /// 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; diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 487182b..78daa77 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -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(z: &mut zip::ZipArchive, images: &BTreeMap, + /// 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(); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index c0f5d1f..adcd609 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -19,6 +19,8 @@ pub struct VersionUi { pub form: Option
, /// The version waiting for delete confirmation. pub confirm_delete: Option, + /// Runs of autosaves shown unfolded (keyed by their newest version's id). + pub open_groups: std::collections::BTreeSet, } /// 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) -> Option { let top = ui.cursor().min.y; diff --git a/docs/mcp.md b/docs/mcp.md index bbaf1be..9a0e7e0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.