From 72b8c5d154636293d466d8fbac43391d5257faff Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 04:16:18 +0700 Subject: [PATCH] M6: version history: versions saved in the .figcraft file, version commands and panel (#21) - Versions now belong to the document (DocState.versions) instead of a process-global map keyed by document uid, so they survive closing the file. A new, renamed or deleted version marks the file as changed. - .figcraft: `versions/index.json` (id, name, description, time, image hashes) plus one deflated `versions/.json` snapshot per version; images only versions use are stored once and kept with them. Snapshots read from a file are parsed on first use (and written back as-is). Readers that don't know versions ignore the entries; files without versions load unchanged. Damaged, missing, oversized or duplicate entries are skipped and reported, never failing the load. At most 100 versions per file; names and descriptions are clipped. - Commands: `file.saveVersion {name?, description?}` returns the version; `version.list`; `version.restore {id | index}` (one undo step, keeps the file name, stays on the current page); `version.rename {id | index, name, description?}`; `version.delete`; `version.duplicate` (opens the snapshot in a new tab). Indexes are validated: a negative index used to restore version 0. `req_index` param helper shared with the guide commands. - Version history panel in the left sidebar in place of Pages and Layers (File > Show version history; Save to version history opens it with the form): save form (title, description), "Current version" on top and the versions newest first on a timeline with name, relative time and description; per-version menu (also right-click): Restore, Rename (title and description inline), Duplicate, Delete (confirmed inline). New shared `widgets::button`. - `figcraft-cli run --out x.figcraft` keeps the version history. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 8 +- apps/figcraft-cli/src/files.rs | 7 +- crates/engine/src/cmd/file.rs | 228 +++++++++++++----- crates/engine/src/cmd/page.rs | 6 +- crates/engine/src/lib.rs | 23 +- crates/engine/src/params.rs | 9 + crates/engine/src/version_tests.rs | 173 ++++++++++++++ crates/format/src/lib.rs | 275 ++++++++++++++++++--- crates/format/src/version_tests.rs | 152 ++++++++++++ crates/ui-egui/src/comments.rs | 28 ++- crates/ui-egui/src/layers.rs | 10 +- crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/versions.rs | 369 +++++++++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 23 ++ docs/mcp.md | 12 + 16 files changed, 1218 insertions(+), 111 deletions(-) create mode 100644 crates/engine/src/version_tests.rs create mode 100644 crates/format/src/version_tests.rs create mode 100644 crates/ui-egui/src/versions.rs diff --git a/ROADMAP.md b/ROADMAP.md index 17e4a9c..2afa9cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,8 +76,10 @@ dimension measured side by side. of prototype interactions and variables. 20–32 h. 7. **Look & feel and interaction-fidelity pass**: tool by tool, panel by panel against the incumbent (cursors, modifier keys, hover states, menus, popovers). 40–60 h. -8. **Collaboration (local):** comments landed (pins, threads, resolve, panel). Version history UI, - multiplayer and plugins are not started. 30–60 h. +8. **Collaboration (local):** comments landed (pins, threads, resolve, panel). Version history + landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel); + previews on the canvas and autosave checkpoints are missing. Multiplayer and plugins are not + started. 25–55 h. 9. **Hardening and performance**: real-world file corpus, perf budgets at 50k layers (tile cache, incremental re-render), Windows/Linux/browser QA, accessibility. 40–60 h. @@ -146,7 +148,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Prototyping | 6 | 65% | multi-selection editing, scrolling inside device frames | 4–7 | | 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 | 25% | comments, version history UI, plugins (WASM) | 15–25 | +| Comments, versions, misc | 3 | 35% | version preview on the canvas, autosave checkpoints, comment polish, plugins (WASM) | 12–20 | Weighted feature depth ≈ **58%** (self-graded; the table's arithmetic gives ~59%). diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index a540e1c..57cedd7 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -303,7 +303,12 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; - write_doc(&ds.doc, ds.page, out)?; + if ext(out) == "figcraft" { + // Through the engine, so the version history is kept. + write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?; + } else { + write_doc(&ds.doc, ds.page, out)?; + } eprintln!("wrote {out}"); } Ok(ExitCode::SUCCESS) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b9fd03e..56c8c28 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use base64::Engine as _; use figcraft_doc::*; +use figcraft_format::Version; use serde_json::{Value, json}; use super::{CommandSpec, ok}; @@ -72,41 +73,77 @@ pub fn specs() -> Vec { }), spec!("file.revert", "Revert to saved", "{}", false, |s, _| { let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?; - let (doc, _) = load(&path)?; + let (loaded, _) = load_path(&path)?; let i = s.active; if let Some(slot) = s.docs.get_mut(i) { - *slot = DocState::new(doc, Some(path)); + *slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions); } ok() }), - spec!("file.saveVersion", "Save to version history", "{name?, description?}", false, |s, p| { - let name = string(p, "name").unwrap_or_else(|| format!("Version {}", s.ds().map(|d| versions(d).len() + 1).unwrap_or(1))); - let ds = s.ds_mut()?; - let snap = ds.doc.clone(); - push_version(ds, name, string(p, "description").unwrap_or_default(), snap); - ok() - }), - spec!("file.versionHistory", "Show version history", "{}", false, |s, _| { - let ds = s.ds()?; - let v: Vec = - versions(ds).iter().enumerate().map(|(i, v)| json!({"index": i, "name": v.0, "description": v.1, "time": v.2})).collect(); + spec!( + "file.saveVersion", + "Save to version history", + "{name?, description?} snapshot the document into the file's version history (kept when the file is saved); returns the version", + false, + save_version + ), + spec!("file.versionHistory", "Show version history", "{} opens the panel; returns the versions like `version.list`", false, |s, _| { + let list = version_list(s.ds()?); s.request_ui("panel.versionHistory", json!({})); - Ok(Value::Array(v)) + Ok(list) }), - spec!("version.restore", "Restore version", "{index}", true, |s, p| { - let i = req_num("version.restore", p, "index")? as usize; - let ds = s.ds_mut()?; - let Some((_, _, _, doc)) = - version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&ds.uid).and_then(|v| v.get(i)).cloned() - else { - return Err(bad("version.restore", "no such version")); - }; - ds.doc = doc; - if !ds.doc.pages.contains(&ds.page) { - ds.page = ds.doc.first_page().unwrap_or(ds.page); + spec!( + "version.list", + "List versions", + "{} the version history, oldest first: [{id, index, name, description, time (Unix seconds)}]", + false, + |s, _| Ok(version_list(s.ds()?)) + ), + spec!( + "version.restore", + "Restore version", + "{id | index} replace the document with the version (one undo step; the file name stays)", + true, + restore_version + ), + spec!("version.rename", "Rename version", "{id | index, name, description?}", false, |s, p| { + const CMD: &str = "version.rename"; + let name = req_string(CMD, p, "name")?; + if name.trim().is_empty() { + return Err(bad(CMD, "`name` can't be empty")); } + let ds = s.ds_mut()?; + let i = version_index(CMD, ds, p)?; + let description = string(p, "description"); + let v = ds.versions_mut().get_mut(i).ok_or_else(|| bad(CMD, "no such version"))?; + v.name = figcraft_format::clip(name.trim(), figcraft_format::MAX_VERSION_NAME); + if let Some(d) = description { + v.description = figcraft_format::clip(d.trim(), figcraft_format::MAX_VERSION_DESCRIPTION); + } + Ok(version_json(i, v)) + }), + spec!("version.delete", "Delete version", "{id | index}", false, |s, p| { + let ds = s.ds_mut()?; + let i = version_index("version.delete", ds, p)?; + ds.versions_mut().remove(i); ok() }), + spec!( + "version.duplicate", + "Duplicate version", + "{id | index} open the version as a new document; returns its tab `index`", + false, + |s, p| { + let ds = s.ds()?; + let v = ds.versions.get(version_index("version.duplicate", ds, p)?).ok_or_else(|| bad("version.duplicate", "no such version"))?; + let mut doc = (*read_version(v)?).clone(); + doc.name = format!("{} ({})", ds.doc.name, v.name); + doc.take_dirty(); + s.docs.push(DocState::new(doc, None)); + s.active = s.docs.len() - 1; + Ok(json!({"index": s.active})) + } + ), spec!("doc.switch", "Switch document", "{index}", false, |s, p| { let i = req_num("doc.switch", p, "index")? as usize; if i >= s.docs.len() { @@ -131,28 +168,85 @@ pub fn specs() -> Vec { ] } -type Version = (String, String, u64, Arc); - -fn version_list() -> &'static std::sync::Mutex>> { - static V: std::sync::OnceLock>>> = std::sync::OnceLock::new(); - V.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +fn version_json(index: usize, v: &Version) -> Value { + json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}) } -fn now_secs() -> u64 { - super::comment::now_secs() +fn version_list(ds: &DocState) -> Value { + Value::Array(ds.versions.iter().enumerate().map(|(i, v)| version_json(i, v)).collect()) } -fn push_version(ds: &DocState, name: String, desc: String, doc: Arc) { - version_list().lock().unwrap_or_else(std::sync::PoisonError::into_inner).entry(ds.uid).or_default().push((name, desc, now_secs(), doc)); +/// The version named by `id`, else by `index` (validated: never negative, never past the end). +fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { + if let Some(id) = string(p, "id") { + return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`"))); + } + if get(p, "index").is_none() { + return Err(bad(cmd, "pass the version's `id` or `index`")); + } + let i = req_index(cmd, p, "index")?; + if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } } -pub fn versions(ds: &DocState) -> Vec<(String, String, u64)> { - version_list() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .get(&ds.uid) - .map(|v| v.iter().map(|(a, b, c, _)| (a.clone(), b.clone(), *c)).collect()) - .unwrap_or_default() +fn read_version(v: &Version) -> Result> { + v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) +} + +/// A new version id: the time in milliseconds (hex), unique in the history. +fn new_version_id(versions: &[Version]) -> String { + let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + loop { + let id = format!("{n:x}"); + if !versions.iter().any(|v| v.id == id) { + return id; + } + n = n.wrapping_add(1); + } +} + +fn save_version(s: &mut Session, p: &Value) -> Result { + let ds = s.ds_mut()?; + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return Err(EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + )); + } + 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 out = version_json(ds.versions.len(), &v); + ds.versions_mut().push(v); + Ok(out) +} + +fn restore_version(s: &mut Session, p: &Value) -> Result { + let ds = s.ds_mut()?; + let i = version_index("version.restore", ds, p)?; + let v = ds.versions.get(i).ok_or_else(|| bad("version.restore", "no such version"))?; + let mut doc = read_version(v)?; + let out = version_json(i, v); + let d = Arc::make_mut(&mut doc); + d.name = ds.doc.name.clone(); + // The snapshot is already consistent: nothing to re-normalise. + d.take_dirty(); + // Stay on the current page when the version has it. + ds.page = Some(ds.page) + .filter(|pg| d.pages.contains(pg)) + .or_else(|| d.current_page.filter(|pg| d.pages.contains(pg))) + .or_else(|| d.first_page()) + .unwrap_or(ds.page); + ds.doc = doc; + ds.selection.clear(); + ds.text_edit = None; + ds.vector_edit = None; + Ok(out) } #[cfg(not(target_arch = "wasm32"))] @@ -188,28 +282,29 @@ fn file_stem(path: &str) -> String { /// Load a document from any supported file (REST API file JSON images come from an `images` /// folder next to the file). pub fn load(path: &str) -> Result<(Document, Vec)> { - load_path(path).map(|(doc, notes, _)| (doc, notes)) + load_path(path).map(|(l, _)| (l.doc, l.issues)) } pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { - load_with(bytes, name_hint, &figcraft_format::rest::no_images).map(|(doc, notes, _)| (doc, notes)) + load_with(bytes, name_hint, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues)) } -/// [`load`], also telling whether the file was REST API file JSON. -pub fn load_path(path: &str) -> Result<(Document, Vec, bool)> { +/// [`load`] with the version history, also telling whether the file was REST API file JSON. +pub fn load_path(path: &str) -> Result<(figcraft_format::Loaded, bool)> { let bytes = read(path)?; load_with(&bytes, path, &rest_images(path)) } -/// Load from bytes. Returns the document, notes (repairs of a native file, or import warnings) and -/// whether it was imported from REST API file JSON (such a file is never saved over). -fn load_with(bytes: &[u8], name_hint: &str, images: figcraft_format::rest::ImageResolver) -> Result<(Document, Vec, bool)> { +/// Load from bytes. Returns the document with its notes (repairs of a native file, or import +/// warnings) and version history, and whether it was imported from REST API file JSON (such a file +/// is never saved over). +fn load_with(bytes: &[u8], name_hint: &str, images: figcraft_format::rest::ImageResolver) -> Result<(figcraft_format::Loaded, bool)> { match ext(name_hint).as_str() { "svg" => { let mut doc = Document::new(file_stem(name_hint)); let page = doc.first_page().ok_or(EngineError::NoDocument)?; figcraft_svg::import_svg(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)).map_err(|e| EngineError::Other(e.to_string()))?; - Ok((doc, vec![], false)) + Ok((doc.into(), false)) } "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => { let mut doc = Document::new(file_stem(name_hint)); @@ -217,19 +312,19 @@ fn load_with(bytes: &[u8], name_hint: &str, images: figcraft_format::rest::Image let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?; let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?; doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - Ok((doc, vec![], false)) + Ok((doc.into(), false)) } _ => { if let Some(r) = figcraft_format::rest::try_import(bytes, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; link_instances(&mut doc); - return Ok((doc, warnings, true)); + return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); } - let (mut doc, issues) = figcraft_format::load_bytes(bytes).map_err(|e| EngineError::Other(e.to_string()))?; - if doc.name.is_empty() { - doc.name = file_stem(name_hint); + let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + if loaded.doc.name.is_empty() { + loaded.doc.name = file_stem(name_hint); } - Ok((doc, issues, false)) + Ok((loaded, false)) } } } @@ -280,12 +375,12 @@ fn open(s: &mut Session, p: &Value) -> Result { s.active = i; return Ok(json!({"index": i})); } - let (doc, notes, rest) = load_path(&path)?; + let (loaded, rest) = load_path(&path)?; let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); - s.docs.push(DocState::new(doc, native.then_some(path))); + s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions)); s.active = s.docs.len() - 1; let mut out = json!({"index": s.active}); - out[if rest { "warnings" } else { "repairs" }] = json!(notes); + out[if rest { "warnings" } else { "repairs" }] = json!(loaded.issues); Ok(out) } @@ -304,12 +399,17 @@ pub fn thumbnail(doc: &Document) -> Option> { figcraft_render::encode_png(&pix).ok() } -fn save_to(s: &mut Session, path: String) -> Result { - let ds = s.ds_mut()?; +/// 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); - let bytes = figcraft_format::save_bytes(&doc, thumb.as_deref()).map_err(|e| EngineError::Other(e.to_string()))?; + figcraft_format::save_bytes_with_versions(&doc, thumb.as_deref(), &ds.versions).map_err(|e| EngineError::Other(e.to_string())) +} + +fn save_to(s: &mut Session, path: String) -> Result { + let ds = s.ds_mut()?; + let bytes = file_bytes(ds)?; write(&path, &bytes)?; ds.path = Some(path.clone()); ds.mark_saved(); @@ -468,11 +568,11 @@ fn import(s: &mut Session, p: &Value) -> Result { Some(r) => r, None => &figcraft_format::rest::no_images, }; - let (other, notes, rest) = load_with(&bytes, &name, images)?; - let pages = append_pages(s.doc_mut()?, &other)?; + let (other, rest) = load_with(&bytes, &name, images)?; + let pages = append_pages(s.doc_mut()?, &other.doc)?; let mut out = json!({"pages": pages}); if rest { - out["warnings"] = json!(notes); + out["warnings"] = json!(other.issues); } Ok(out) } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 4b3e658..12e6433 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -155,9 +155,5 @@ pub fn specs() -> Vec { /// A guide index from params (never negative). fn guide_index(cmd: &str, p: &Value) -> crate::Result { - let k = req_num(cmd, p, "index")?; - if !(0.0..1e9).contains(&k) { - return Err(bad(cmd, "`index` out of range")); - } - Ok(k as usize) + req_index(cmd, p, "index") } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 95f8083..7105540 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -220,6 +220,10 @@ pub struct DocState { /// Vector edit mode: the vector being edited, its selected points and the edit tool. pub vector_edit: Option, pub uid: u64, + /// The file's version history, oldest first (saved in the `.figcraft` file; not undoable). + pub versions: Vec, + /// The version history changed since the last save. + versions_changed: bool, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -245,17 +249,32 @@ impl DocState { hover: None, vector_edit: None, uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + versions: Vec::new(), + versions_changed: false, }; st.zoom_to_fit(); st } + /// With the version history read from its file. + pub fn with_versions(mut self, versions: Vec) -> Self { + self.versions = versions; + self + } + + /// The version history, for editing (the file then has unsaved changes). + pub fn versions_mut(&mut self) -> &mut Vec { + self.versions_changed = true; + &mut self.versions + } + pub fn is_dirty(&self) -> bool { - !Arc::ptr_eq(&self.doc, &self.saved_doc) + !Arc::ptr_eq(&self.doc, &self.saved_doc) || self.versions_changed } pub fn mark_saved(&mut self) { self.saved_doc = self.doc.clone(); + self.versions_changed = false; } pub fn doc_mut(&mut self) -> &mut Document { @@ -744,3 +763,5 @@ mod tests; mod text_fill_tests; #[cfg(test)] mod variable_tests; +#[cfg(test)] +mod version_tests; diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index c0f46f6..4eca144 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -32,6 +32,15 @@ pub fn req_num(cmd: &str, p: &Value, key: &str) -> Result { num(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a number"))) } +/// A list index from params: a whole number, never negative (no casting -1 to 0). +pub fn req_index(cmd: &str, p: &Value, key: &str) -> Result { + let k = req_num(cmd, p, key)?; + if !(0.0..1e9).contains(&k) || k.fract() != 0.0 { + return Err(bad(cmd, format!("`{key}` out of range"))); + } + Ok(k as usize) +} + pub fn string(p: &Value, key: &str) -> Option { match get(p, key)? { Value::String(s) => Some(s.clone()), diff --git a/crates/engine/src/version_tests.rs b/crates/engine/src/version_tests.rs new file mode 100644 index 0000000..1f61546 --- /dev/null +++ b/crates/engine/src/version_tests.rs @@ -0,0 +1,173 @@ +//! Version history: save, list, restore (one undo step), rename, delete, duplicate, persistence in +//! the `.figcraft` file and bad parameters. + +use serde_json::{Value, json}; + +use crate::params::id_of; +use crate::{EngineError, Session}; + +fn rect(s: &mut Session, x: f64) -> figcraft_doc::NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap() +} + +fn count(s: &Session) -> usize { + s.doc().unwrap().node_count() +} + +fn list(s: &mut Session) -> Vec { + s.execute("version.list", &json!({})).unwrap().as_array().unwrap().clone() +} + +#[test] +fn save_list_restore_and_undo() { + let mut s = Session::new(); + s.execute("file.rename", &json!({"name": "Shop"})).unwrap(); + rect(&mut s, 0.0); + let one = count(&s); + let v = s.execute("file.saveVersion", &json!({"name": " First ", "description": "one box"})).unwrap(); + assert_eq!(v["name"], "First"); + assert_eq!(v["description"], "one box"); + assert_eq!(v["index"], 0); + assert!(s.ds().unwrap().is_dirty(), "a new version is an unsaved change"); + rect(&mut s, 20.0); + let v2 = s.execute("file.saveVersion", &json!({})).unwrap(); + assert_eq!(v2["name"], "Version 2", "untitled versions are numbered"); + rect(&mut s, 40.0); + let three = count(&s); + let l = list(&mut s); + assert_eq!(l.len(), 2); + assert_eq!(l[0]["name"], "First"); + assert!(l[0]["time"].as_u64().unwrap() > 0); + // The panel request comes with the same list. + assert_eq!(s.execute("file.versionHistory", &json!({})).unwrap(), Value::Array(l.clone())); + assert!(s.ui_requests.iter().any(|r| r.kind == "panel.versionHistory")); + + // Restore by id: the content goes back, the file name stays; one undo step brings it back. + s.execute("file.rename", &json!({"name": "Shop v2"})).unwrap(); + let r = s.execute("version.restore", &json!({"id": l[0]["id"]})).unwrap(); + assert_eq!(r["name"], "First"); + assert_eq!(count(&s), one); + assert_eq!(s.doc().unwrap().name, "Shop v2"); + assert!(s.selection().is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count(&s), three); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(count(&s), one); + // By index. + s.execute("version.restore", &json!({"index": 1})).unwrap(); + assert_eq!(count(&s), one + 1); + assert_eq!(list(&mut s).len(), 2, "restoring keeps the history"); +} + +#[test] +fn rename_delete_duplicate() { + let mut s = Session::new(); + s.execute("file.rename", &json!({"name": "App"})).unwrap(); + rect(&mut s, 0.0); + let a = s.execute("file.saveVersion", &json!({"name": "A", "description": "first"})).unwrap(); + rect(&mut s, 20.0); + s.execute("file.saveVersion", &json!({"name": "B"})).unwrap(); + // Rename keeps the description unless one is given. + let r = s.execute("version.rename", &json!({"id": a["id"], "name": "Alpha"})).unwrap(); + assert_eq!((r["name"].as_str(), r["description"].as_str()), (Some("Alpha"), Some("first"))); + s.execute("version.rename", &json!({"index": 0, "name": "Alpha", "description": "changed"})).unwrap(); + assert_eq!(list(&mut s)[0]["description"], "changed"); + assert!(s.execute("version.rename", &json!({"index": 0, "name": " "})).is_err()); + // Long names are clipped. + s.execute("version.rename", &json!({"index": 1, "name": "x".repeat(5000)})).unwrap(); + assert_eq!(list(&mut s)[1]["name"].as_str().unwrap().chars().count(), figcraft_format::MAX_VERSION_NAME); + + // Duplicate opens the version in a new tab without touching this one. + let before = count(&s); + let d = s.execute("version.duplicate", &json!({"id": a["id"]})).unwrap(); + assert_eq!(d["index"], 1); + assert_eq!(s.active, 1); + assert_eq!(s.doc().unwrap().name, "App (Alpha)"); + assert_eq!(count(&s), before - 1); + assert!(s.ds().unwrap().versions.is_empty()); + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + assert_eq!(count(&s), before); + + // Delete. + s.execute("version.delete", &json!({"id": a["id"]})).unwrap(); + let l = list(&mut s); + assert_eq!(l.len(), 1); + assert_eq!(l[0]["index"], 0); + assert!(s.execute("version.restore", &json!({"id": a["id"]})).is_err()); +} + +#[test] +fn bad_indexes_and_ids_are_rejected() { + let mut s = Session::new(); + rect(&mut s, 0.0); + s.execute("file.saveVersion", &json!({"name": "Only"})).unwrap(); + rect(&mut s, 20.0); + let n = count(&s); + for p in [ + json!({"index": -1}), + json!({"index": 1}), + json!({"index": 0.5}), + json!({"index": "NaN"}), + json!({"index": 1e300}), + json!({"id": "nope"}), + json!({}), + json!(null), + ] { + for cmd in ["version.restore", "version.delete", "version.duplicate"] { + assert!(matches!(s.execute(cmd, &p), Err(EngineError::BadParams { .. })), "{cmd} {p}"); + } + let mut q = p.as_object().cloned().unwrap_or_default(); + q.insert("name".into(), json!("x")); + assert!(s.execute("version.rename", &Value::Object(q)).is_err(), "rename {p}"); + } + // Nothing changed: a negative index never restores version 0. + assert_eq!(count(&s), n); + assert_eq!(list(&mut s).len(), 1); + assert_eq!(s.docs.len(), 1); +} + +#[test] +fn history_is_capped() { + let mut s = Session::new(); + for i in 0..figcraft_format::MAX_VERSIONS { + s.execute("file.saveVersion", &json!({"name": format!("v{i}")})).unwrap(); + } + assert!(matches!(s.execute("file.saveVersion", &json!({})), Err(EngineError::Disabled(..)))); + let l = list(&mut s); + assert_eq!(l.len(), figcraft_format::MAX_VERSIONS); + // Ids are unique even when saved within the same millisecond. + let ids: std::collections::BTreeSet<&str> = l.iter().filter_map(|v| v["id"].as_str()).collect(); + assert_eq!(ids.len(), l.len()); +} + +#[test] +fn versions_persist_in_the_file() { + let dir = std::env::temp_dir().join(format!("figcraft-versions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("history.figcraft").to_string_lossy().to_string(); + let mut s = Session::new(); + rect(&mut s, 0.0); + let one = count(&s); + s.execute("file.saveVersion", &json!({"name": "Start", "description": "a box"})).unwrap(); + rect(&mut s, 20.0); + s.execute("file.save", &json!({"path": path})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + + // Reopen in a fresh session: the history is there and restores. + let mut t = Session::empty(); + t.execute("file.open", &json!({"path": path})).unwrap(); + let l = list(&mut t); + assert_eq!(l.len(), 1); + assert_eq!((l[0]["name"].as_str(), l[0]["description"].as_str()), (Some("Start"), Some("a box"))); + assert!(!t.ds().unwrap().is_dirty()); + t.execute("version.restore", &json!({"index": 0})).unwrap(); + assert_eq!(count(&t), one); + // Deleting a version is an unsaved change; saving drops it from the file. + t.execute("edit.undo", &json!({})).unwrap(); + t.execute("version.delete", &json!({"index": 0})).unwrap(); + assert!(t.ds().unwrap().is_dirty()); + t.execute("file.save", &json!({})).unwrap(); + t.execute("file.revert", &json!({})).unwrap(); + assert!(list(&mut t).is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 7cd4e7b..8a69726 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1,16 +1,20 @@ //! The native `.figcraft` format. //! //! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version` -//! header), `images/` for every image the document uses, and an optional -//! `thumbnail.png`. Plain JSON (the `document.json` alone) also opens. Reading is lenient: -//! structural problems are repaired (see [`Document::repair`]) and reported. +//! header), `images/` for every image the document (or a saved version) uses, an optional +//! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id, +//! name, description, time, images}]}`, oldest first) plus one `versions/.json` snapshot per +//! version (a `document.json` of its own). Readers that don't know about versions ignore those +//! entries. Plain JSON (the `document.json` alone) also opens. Reading is lenient: structural +//! problems are repaired (see [`Document::repair`]) and reported, damaged versions are skipped. //! //! [`rest`] imports the public REST API file JSON. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod rest; -use std::io::{Read, Write}; +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{Read, Seek, Write}; use std::sync::Arc; use figcraft_doc::{Document, ImageData}; @@ -22,6 +26,15 @@ pub const FORMAT: &str = "figcraft"; pub const VERSION: u32 = 1; /// Largest archive member we read (guards against zip bombs). const MAX_MEMBER: u64 = 512 * 1024 * 1024; +/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. +pub const MAX_VERSIONS: usize = 100; +/// Longest version name and description kept (chars). +pub const MAX_VERSION_NAME: usize = 200; +pub const MAX_VERSION_DESCRIPTION: usize = 2000; +/// Largest version index and total size of the version snapshots read from one file. +const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; +const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; +const VERSION_INDEX: &str = "versions/index.json"; #[derive(Debug)] pub enum FormatError { @@ -42,6 +55,87 @@ impl std::fmt::Display for FormatError { impl std::error::Error for FormatError {} +/// One entry of a file's version history: a named snapshot of the document. +#[derive(Clone, Debug)] +pub struct Version { + /// Unique within the file; ASCII letters, digits, `-` and `_` (see [`is_valid_version_id`]). + pub id: String, + pub name: String, + pub description: String, + /// When it was saved (Unix seconds). + pub time: u64, + snapshot: Snapshot, +} + +#[derive(Clone, Debug)] +enum Snapshot { + /// Saved in this session (or already read). + Doc(Arc), + /// As read from a file: its `document.json`-style JSON, parsed on first use, and the images + /// it uses. + Stored { json: Arc>, images: BTreeMap }, +} + +impl Version { + /// A version of `doc`. The name and description are clipped to their maximum lengths. + pub fn new(id: impl Into, name: &str, description: &str, time: u64, doc: Arc) -> Self { + Self { + id: id.into(), + name: clip(name, MAX_VERSION_NAME), + description: clip(description, MAX_VERSION_DESCRIPTION), + time, + snapshot: Snapshot::Doc(doc), + } + } + + /// The snapshot (with its images). Versions read from a file are parsed here, so a damaged + /// one fails now rather than when the file opens. + pub fn document(&self) -> Result, FormatError> { + match &self.snapshot { + Snapshot::Doc(d) => Ok(d.clone()), + Snapshot::Stored { json, images } => { + let text = std::str::from_utf8(json).map_err(|e| FormatError::Corrupt(e.to_string()))?; + let (mut doc, _) = from_json(text)?; + doc.images = images.clone(); + Ok(Arc::new(doc)) + } + } + } + + /// The images the snapshot uses, by hash. + pub fn images(&self) -> &BTreeMap { + match &self.snapshot { + Snapshot::Doc(d) => &d.images, + Snapshot::Stored { images, .. } => images, + } + } +} + +/// Whether `id` can name a version (and its archive entry). +pub fn is_valid_version_id(id: &str) -> bool { + (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// `s` cut to at most `max` chars. +pub fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +/// A file as read: the document, the repairs made (and versions skipped), and its version +/// history, oldest first. +#[derive(Debug)] +pub struct Loaded { + pub doc: Document, + pub issues: Vec, + pub versions: Vec, +} + +impl From for Loaded { + fn from(doc: Document) -> Self { + Self { doc, issues: Vec::new(), versions: Vec::new() } + } +} + /// Content hash used as an image's key. pub fn image_hash(bytes: &[u8]) -> String { let d = sha2::Sha256::digest(bytes); @@ -74,71 +168,189 @@ pub fn to_json(doc: &Document) -> Result { /// Parse `document.json` content. pub fn from_json(text: &str) -> Result<(Document, Vec), FormatError> { + parse_json(text).map(|(doc, issues, _)| (doc, issues)) +} + +/// The image hashes a `document.json` lists in its `imageIndex` (`None` without one). +type ImageIndex = Option>; + +/// Parse `document.json` content; also returns its [`ImageIndex`]. +fn parse_json(text: &str) -> Result<(Document, Vec, ImageIndex), FormatError> { let v: Value = serde_json::from_str(text).map_err(|e| FormatError::Corrupt(e.to_string()))?; if let Some(ver) = v.get("version").and_then(Value::as_u64) && ver > u64::from(VERSION) { return Err(FormatError::Newer(ver as u32)); } + let index = v + .get("imageIndex") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|i| i.get("hash").and_then(Value::as_str).map(str::to_string)).collect()); let mut doc: Document = serde_json::from_value(v).map_err(|e| FormatError::Corrupt(e.to_string()))?; let issues = doc.validate(); if !issues.is_empty() { doc.repair(); } - Ok((doc, issues)) + Ok((doc, issues, index)) +} + +fn io_err(e: impl std::fmt::Display) -> FormatError { + FormatError::Io(e.to_string()) +} + +/// Add one member to the archive. +fn put(z: &mut zip::ZipWriter, name: &str, opts: zip::write::SimpleFileOptions, bytes: &[u8]) -> Result<(), FormatError> { + z.start_file(name, opts).map_err(io_err)?; + z.write_all(bytes).map_err(io_err) } /// Write a `.figcraft` archive to bytes. pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result, FormatError> { + save_bytes_with_versions(doc, thumbnail_png, &[]) +} + +/// Write a `.figcraft` archive with a version history (oldest first) to bytes. +pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, versions: &[Version]) -> Result, FormatError> { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); - let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); - z.start_file("document.json", opts).map_err(|e| FormatError::Io(e.to_string()))?; - let json = serde_json::to_vec(&to_json(doc)?).map_err(|e| FormatError::Io(e.to_string()))?; - z.write_all(&json).map_err(|e| FormatError::Io(e.to_string()))?; - for (hash, img) in &doc.images { - z.start_file(format!("images/{hash}"), stored).map_err(|e| FormatError::Io(e.to_string()))?; - z.write_all(&img.bytes).map_err(|e| FormatError::Io(e.to_string()))?; + put(&mut z, "document.json", deflated, &serde_json::to_vec(&to_json(doc)?).map_err(io_err)?)?; + // Every image once, whether the document or a version uses it. + let mut written = BTreeSet::new(); + for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).flatten() { + if written.insert(hash) { + put(&mut z, &format!("images/{hash}"), stored, &img.bytes)?; + } } if let Some(t) = thumbnail_png { - z.start_file("thumbnail.png", stored).map_err(|e| FormatError::Io(e.to_string()))?; - z.write_all(t).map_err(|e| FormatError::Io(e.to_string()))?; + put(&mut z, "thumbnail.png", stored, t)?; } - z.finish().map_err(|e| FormatError::Io(e.to_string()))?; + if !versions.is_empty() { + let mut ids = BTreeSet::new(); + let mut index = Vec::with_capacity(versions.len()); + for v in versions { + if !is_valid_version_id(&v.id) || !ids.insert(v.id.as_str()) { + return Err(FormatError::Io(format!("invalid or duplicate version id `{}`", v.id))); + } + let name = format!("versions/{}.json", v.id); + match &v.snapshot { + Snapshot::Doc(d) => put(&mut z, &name, deflated, &serde_json::to_vec(&to_json(d)?).map_err(io_err)?)?, + 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})); + } + put(&mut z, VERSION_INDEX, deflated, &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?; + } + z.finish().map_err(io_err)?; } Ok(buf.into_inner()) } /// Read a `.figcraft` archive (or bare JSON) from bytes. Returns the document and any repairs made. pub fn load_bytes(bytes: &[u8]) -> Result<(Document, Vec), FormatError> { + load_bytes_with_versions(bytes).map(|l| (l.doc, l.issues)) +} + +/// Read archive member `name`, refusing members larger than `limit` bytes. +fn read_member(z: &mut zip::ZipArchive, name: &str, limit: u64) -> Result, FormatError> { + let f = z.by_name(name).map_err(|e| FormatError::Corrupt(format!("{name}: {e}")))?; + let too_large = || FormatError::Corrupt(format!("{name} is too large")); + if f.size() > limit { + return Err(too_large()); + } + let mut data = Vec::new(); + f.take(limit.saturating_add(1)).read_to_end(&mut data).map_err(|e| FormatError::Corrupt(format!("{name}: {e}")))?; + if data.len() as u64 > limit { + return Err(too_large()); + } + Ok(data) +} + +/// Read a `.figcraft` archive (or bare JSON) with its version history. +pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i)); if trimmed == Some(&b'{') { let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?; - return from_json(text); + let (doc, issues) = from_json(text)?; + return Ok(Loaded { issues, ..Loaded::from(doc) }); } let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?; - let text = { - let f = z.by_name("document.json").map_err(|e| FormatError::Corrupt(e.to_string()))?; - let mut s = String::new(); - f.take(MAX_MEMBER).read_to_string(&mut s).map_err(|e| FormatError::Corrupt(e.to_string()))?; - s - }; - let (mut doc, issues) = from_json(&text)?; + let text = String::from_utf8(read_member(&mut z, "document.json", MAX_MEMBER)?).map_err(|e| FormatError::Corrupt(e.to_string()))?; + let (mut doc, mut issues, own_images) = parse_json(&text)?; let names: Vec = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect(); + let mut images = BTreeMap::new(); for name in names { - let Ok(f) = z.by_name(&name) else { continue }; - let mut data = Vec::new(); - if f.take(MAX_MEMBER).read_to_end(&mut data).is_err() { - continue; - } + let Ok(data) = read_member(&mut z, &name, MAX_MEMBER) else { continue }; let hash = name.trim_start_matches("images/").to_string(); let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data)).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()).unwrap_or((0, 0)); - doc.images.insert(hash, ImageData { bytes: Arc::new(data), width: w, height: h }); + images.insert(hash, ImageData { bytes: Arc::new(data), width: w, height: h }); } - Ok((doc, issues)) + let versions = read_versions(&mut z, &images, &mut issues); + // Images only the version history uses stay with the versions. + let version_only: BTreeSet<&String> = + versions.iter().flat_map(|v| v.images().keys()).filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h))).collect(); + doc.images = images.into_iter().filter(|(h, _)| !version_only.contains(h)).collect(); + Ok(Loaded { doc, issues, versions }) +} + +/// The version history of an archive (none when it has no index). Damaged entries are skipped and +/// reported in `issues`; nothing here fails the load. +fn read_versions(z: &mut zip::ZipArchive, images: &BTreeMap, issues: &mut Vec) -> Vec { + if z.index_for_name(VERSION_INDEX).is_none() { + return Vec::new(); + } + let index = match read_member(z, VERSION_INDEX, MAX_VERSION_INDEX) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| FormatError::Corrupt(e.to_string()))) + { + Ok(v) => v, + Err(e) => { + issues.push(format!("the version history was skipped: {e}")); + return Vec::new(); + } + }; + let entries = index.get("versions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out: Vec = Vec::new(); + let mut budget = MAX_VERSION_BYTES; + for e in entries { + if out.len() >= MAX_VERSIONS { + issues.push(format!("only the first {MAX_VERSIONS} versions were kept")); + break; + } + let id = e.get("id").and_then(Value::as_str).unwrap_or_default(); + if !is_valid_version_id(id) || out.iter().any(|v| v.id == id) { + issues.push("a version with an invalid id was skipped".into()); + continue; + } + let json = match read_member(z, &format!("versions/{id}.json"), budget.min(MAX_MEMBER)) { + Ok(j) => j, + Err(err) => { + issues.push(format!("version `{id}` was skipped: {err}")); + continue; + } + }; + budget = budget.saturating_sub(json.len() as u64); + let text = |k: &str, max: usize| e.get(k).and_then(Value::as_str).map(|s| clip(s, max)).unwrap_or_default(); + let images = e + .get("images") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone()))) + .collect(); + out.push(Version { + id: id.to_string(), + name: text("name", MAX_VERSION_NAME), + description: text("description", MAX_VERSION_DESCRIPTION), + time: e.get("time").and_then(Value::as_u64).unwrap_or(0), + snapshot: Snapshot::Stored { json: Arc::new(json), images }, + }); + } + out } /// Save atomically: write a temp file next to `path`, then rename over it. @@ -157,6 +369,9 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec), Form load_bytes(&bytes) } +#[cfg(test)] +mod version_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/format/src/version_tests.rs b/crates/format/src/version_tests.rs new file mode 100644 index 0000000..0fbf8ca --- /dev/null +++ b/crates/format/src/version_tests.rs @@ -0,0 +1,152 @@ +//! The version history in `.figcraft` files: round trips, files without versions, damaged entries. + +use std::io::Write; +use std::sync::Arc; + +use figcraft_doc::{Document, NodeKind}; + +use super::*; + +fn png(r: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([r, 0, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +fn zip_of(members: &[(&str, &[u8])]) -> Vec { + let mut buf = std::io::Cursor::new(Vec::new()); + let mut z = zip::ZipWriter::new(&mut buf); + for (name, bytes) in members { + z.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap(); + buf.into_inner() +} + +fn doc_json(name: &str) -> Vec { + serde_json::to_vec(&to_json(&Document::new(name)).unwrap()).unwrap() +} + +#[test] +fn roundtrip_with_versions() { + // The version has an image the current document no longer uses, and vice versa. + let mut old = Document::new("File"); + let p = old.first_page().unwrap(); + old.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + let (old_img, ..) = add_image(&mut old, png(1)).unwrap(); + let mut cur = Document::new("File"); + let (cur_img, ..) = add_image(&mut cur, png(2)).unwrap(); + let versions = vec![Version::new("v1", "First", "with a box", 1_700_000_000, Arc::new(old.clone()))]; + + let bytes = save_bytes_with_versions(&cur, None, &versions).unwrap(); + let l = load_bytes_with_versions(&bytes).unwrap(); + assert!(l.issues.is_empty(), "{:?}", l.issues); + assert_eq!(l.versions.len(), 1); + let v = &l.versions[0]; + assert_eq!((v.id.as_str(), v.name.as_str(), v.description.as_str(), v.time), ("v1", "First", "with a box", 1_700_000_000)); + let snap = v.document().unwrap(); + assert_eq!(snap.node_count(), old.node_count()); + assert!(snap.images.contains_key(&old_img)); + // Images only the history uses stay out of the document. + assert!(l.doc.images.contains_key(&cur_img)); + assert!(!l.doc.images.contains_key(&old_img)); + + // Saving what was read (not yet parsed) keeps it intact. + let again = load_bytes_with_versions(&save_bytes_with_versions(&l.doc, None, &l.versions).unwrap()).unwrap(); + assert_eq!(again.versions.len(), 1); + assert_eq!(again.versions[0].document().unwrap().node_count(), old.node_count()); + assert!(again.versions[0].images().contains_key(&old_img)); + + // Readers that ignore versions still open the file. + let (plain, _) = load_bytes(&bytes).unwrap(); + assert_eq!(plain.node_count(), cur.node_count()); +} + +#[test] +fn files_without_versions_load_unchanged() { + let mut d = Document::new("Old"); + let (h, ..) = add_image(&mut d, png(3)).unwrap(); + let l = load_bytes_with_versions(&save_bytes(&d, None).unwrap()).unwrap(); + assert!(l.versions.is_empty() && l.issues.is_empty()); + assert!(l.doc.images.contains_key(&h)); + // A minimal archive (document.json only) and bare JSON. + assert!(load_bytes_with_versions(&zip_of(&[("document.json", &doc_json("Min"))])).unwrap().versions.is_empty()); + assert_eq!(load_bytes_with_versions(&doc_json("Bare")).unwrap().doc.name, "Bare"); +} + +#[test] +fn damaged_versions_never_fail_the_load() { + let doc = doc_json("D"); + // An unreadable index: the file opens without a history. + let l = load_bytes_with_versions(&zip_of(&[("document.json", &doc), ("versions/index.json", b"{nope")])).unwrap(); + assert!(l.versions.is_empty()); + assert_eq!(l.issues.len(), 1); + + // Bad ids, a missing snapshot, duplicates, junk fields and a damaged snapshot. + let index = serde_json::json!({"versions": [ + {"id": "../escape", "name": "bad id"}, + {"id": 7}, + {"id": "missing", "name": "no member"}, + {"id": "ok", "name": "Fine", "description": 5, "time": "soon", "images": [1, "unknown-hash"]}, + {"id": "ok", "name": "duplicate"}, + {"id": "broken", "name": "Broken"}, + ]}); + let bytes = zip_of(&[ + ("document.json", &doc), + ("versions/index.json", &serde_json::to_vec(&index).unwrap()), + ("versions/ok.json", &doc_json("Snap")), + ("versions/broken.json", b"\xff\xfe not json"), + ]); + let l = load_bytes_with_versions(&bytes).unwrap(); + let names: Vec<&str> = l.versions.iter().map(|v| v.name.as_str()).collect(); + assert_eq!(names, ["Fine", "Broken"]); + assert_eq!(l.issues.len(), 4, "{:?}", l.issues); + let ok = &l.versions[0]; + assert_eq!((ok.description.as_str(), ok.time), ("", 0)); + assert!(ok.images().is_empty()); + assert_eq!(ok.document().unwrap().name, "Snap"); + // The damaged snapshot fails only when used. + assert!(l.versions[1].document().is_err()); + // A snapshot from a newer format is refused too. + let newer = zip_of(&[ + ("document.json", &doc), + ("versions/index.json", br#"{"versions":[{"id":"n","name":"Newer"}]}"#), + ("versions/n.json", br#"{"version": 999}"#), + ]); + assert!(matches!(load_bytes_with_versions(&newer).unwrap().versions[0].document(), Err(FormatError::Newer(999)))); +} + +#[test] +fn oversized_and_excess_versions_are_skipped() { + let doc = doc_json("D"); + // Member size guard. + let bytes = zip_of(&[("big", &[0u8; 64])]); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + assert!(read_member(&mut z, "big", 63).is_err()); + assert_eq!(read_member(&mut z, "big", 64).unwrap().len(), 64); + assert!(read_member(&mut z, "absent", 64).is_err()); + + // More entries than the cap: the first MAX_VERSIONS are kept. + let n = MAX_VERSIONS + 3; + let entries: Vec = (0..n).map(|i| serde_json::json!({"id": format!("v{i}"), "name": format!("V{i}")})).collect(); + let index = serde_json::to_vec(&serde_json::json!({ "versions": entries })).unwrap(); + let names: Vec = (0..n).map(|i| format!("versions/v{i}.json")).collect(); + let mut members: Vec<(&str, &[u8])> = vec![("document.json", &doc), ("versions/index.json", &index)]; + members.extend(names.iter().map(|m| (m.as_str(), doc.as_slice()))); + let l = load_bytes_with_versions(&zip_of(&members)).unwrap(); + assert_eq!(l.versions.len(), MAX_VERSIONS); + assert_eq!(l.issues.len(), 1); +} + +#[test] +fn saving_refuses_bad_version_ids() { + let d = Arc::new(Document::new("x")); + assert!(save_bytes_with_versions(&d, None, &[Version::new("a/b", "n", "", 0, d.clone())]).is_err()); + assert!(save_bytes_with_versions(&d, None, &[Version::new("a", "n", "", 0, d.clone()), Version::new("a", "m", "", 0, d.clone())]).is_err()); + // Names and descriptions are clipped. + let v = Version::new("a", &"n".repeat(MAX_VERSION_NAME + 10), &"é".repeat(MAX_VERSION_DESCRIPTION + 1), 0, d); + assert_eq!(v.name.chars().count(), MAX_VERSION_NAME); + assert_eq!(v.description.chars().count(), MAX_VERSION_DESCRIPTION); +} diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index c183dbd..b8849dd 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -81,13 +81,15 @@ fn avatar(painter: &egui::Painter, center: Pos2, r: f32, name: &str) { painter.text(center, Align2::CENTER_CENTER, initials(name), semibold(r * 0.8), Color32::WHITE); } +/// Seconds since `t` (Unix seconds); `None` for unknown or future times. +fn age(t: u64) -> Option { + let now = figcraft_engine::cmd::comment::now_secs(); + (t != 0 && now >= t).then(|| now - t) +} + /// "now", "5m", "3h", "2d", or a week count. pub fn ago(created: u64) -> String { - let now = figcraft_engine::cmd::comment::now_secs(); - if created == 0 || now < created { - return String::new(); - } - let d = now - created; + let Some(d) = age(created) else { return String::new() }; match d { 0..60 => "now".into(), 60..3600 => format!("{}m", d / 60), @@ -97,6 +99,22 @@ pub fn ago(created: u64) -> String { } } +/// "Just now", "5 minutes ago", "Yesterday", "3 weeks ago"… +pub fn ago_long(t: u64) -> String { + let Some(d) = age(t) else { return String::new() }; + let units = |n: u64, unit: &str| if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") }; + match d { + 0..60 => "Just now".into(), + 60..3600 => units(d / 60, "minute"), + 3600..86_400 => units(d / 3600, "hour"), + 86_400..172_800 => "Yesterday".into(), + 172_800..604_800 => units(d / 86_400, "day"), + 604_800..2_592_000 => units(d / 604_800, "week"), + 2_592_000..31_536_000 => units(d / 2_592_000, "month"), + _ => units(d / 31_536_000, "year"), + } +} + /// Draw the pins (and the draft pin). pub fn draw(app: &FigApp, painter: &egui::Painter) { if !visible(app) { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 44e04e6..c01d8d4 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -82,7 +82,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { let ir = Rect::from_center_size(pos2(r.center().x, y + 14.0), vec2(36.0, 28.0)); let hit = Rect::from_min_max(pos2(r.min.x, y), pos2(r.max.x, y + 48.0)); let resp = ui.interact(hit, ui.id().with(("rail", label)), Sense::click()).on_hover_text(label); - let active = tab.is_some_and(|tb| tb == app.ui.left_tab && !app.ui.left_collapsed); + let active = tab.is_some_and(|tb| tb == app.ui.left_tab && !app.ui.left_collapsed && !app.ui.versions.open); if active { ui.painter().rect_filled(ir, CornerRadius::same(6), t.selected_row); } else if resp.hovered() { @@ -93,12 +93,14 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { if resp.clicked() { match tab { Some(tb) => { - if app.ui.left_tab == tb && !app.ui.left_collapsed { + // Leaving the version history shows the tab rather than collapsing it. + if active { app.ui.left_collapsed = true; } else { app.ui.left_tab = tb; app.ui.left_collapsed = false; } + app.ui.versions = Default::default(); } None => { ui.data_mut(|d| d.insert_temp(Id::new("variables_open"), true)); @@ -110,6 +112,10 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { + if app.ui.versions.open { + crate::versions::panel(app, ui); + return; + } let t = Tokens::get(ui.ctx()); let full = ui.max_rect(); // Header: file name (or "Assets") + drafts + collapse. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ad78098..cb5c36c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -33,6 +33,7 @@ pub mod toolbar; pub mod typography; pub mod variable_ui; pub mod vector_panel; +pub mod versions; pub mod widgets; use std::collections::BTreeSet; @@ -121,6 +122,7 @@ pub struct UiState { pub find: Option<(String, String)>, pub left_collapsed: bool, pub comments: comments::CommentUi, + pub versions: versions::VersionUi, } impl Default for UiState { @@ -161,6 +163,7 @@ impl Default for UiState { find: None, left_collapsed: false, comments: comments::CommentUi::default(), + versions: versions::VersionUi::default(), } } } @@ -225,6 +228,7 @@ impl FigApp { "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.layers" => self.ui.left_tab = LeftTab::File, "panel.assets" => self.ui.left_tab = LeftTab::Assets, + "panel.versionHistory" => versions::open(self), "home" => self.ui.home = true, "dialog.saveAs" => self.save_as_dialog(), "present" => { @@ -411,6 +415,7 @@ impl FigApp { "presenting": self.ui.present.is_some(), "mainMenu": self.ui.main_menu, "palette": self.ui.palette.is_some(), + "versionHistory": self.ui.versions.open, "comments": {"open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "showResolved": self.ui.comments.show_resolved}, "frameMs": self.ui.frame_ms, "renderMs": self.ui.render_ms, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 43b3180..9dba90b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -34,6 +34,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { "file.placeImage" => app.import_dialog(true), "file.save" => app.save(), "file.saveAs" => app.save_as_dialog(), + "file.saveVersion" => crate::versions::start_save(app), "file.export" | "file.exportSvg" => app.ui.export_preview = true, "edit.copyAsCss" => { let ids = app.session.selection(); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs new file mode 100644 index 0000000..5db22c9 --- /dev/null +++ b/crates/ui-egui/src/versions.rs @@ -0,0 +1,369 @@ +//! The Version history panel, shown in the left sidebar in place of Pages and Layers: save a +//! version (title and description), the history newest first under the current version, and per +//! version Restore, Rename, Duplicate and Delete. + +use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2}; +use serde_json::json; + +use crate::FigApp; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular, semibold}; +use crate::widgets; + +/// Version panel state. +#[derive(Default)] +pub struct VersionUi { + pub open: bool, + /// The save form (new version) or the edit form of a version. + pub form: Option
, + /// The version waiting for delete confirmation. + pub confirm_delete: Option, +} + +/// Title and description being written; `id` is the version being edited (`None` = a new one). +pub struct Form { + pub id: Option, + pub name: String, + pub description: String, + focus: bool, +} + +impl Form { + fn new(id: Option, name: &str, description: &str) -> Self { + Self { id, name: name.to_string(), description: description.to_string(), focus: true } + } +} + +/// Open the panel (File ▸ Show version history). +pub fn open(app: &mut FigApp) { + app.ui.versions.open = true; + app.ui.left_collapsed = false; + app.ui.left_tab = crate::LeftTab::File; +} + +/// Open the panel with the save form (File ▸ Save to version history…). +pub fn start_save(app: &mut FigApp) { + open(app); + app.ui.versions.confirm_delete = None; + app.ui.versions.form = Some(Form::new(None, "", "")); +} + +/// One version as listed. +struct Row { + id: String, + name: String, + description: String, + time: u64, +} + +enum Action { + Close, + New, + Submit, + CancelForm, + Restore(String), + Edit(String), + Duplicate(String), + AskDelete(String), + Delete(String), + CancelDelete, +} + +const DOT_X: f32 = 20.0; +const TEXT_X: f32 = 36.0; + +pub fn panel(app: &mut FigApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let mut action = None; + + // Header: title, save, close. + let (head, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover()); + ui.painter().text(pos2(head.min.x + 16.0, head.center().y), Align2::LEFT_CENTER, "Version history", semibold(13.0), t.text); + let mut bar = + ui.new_child(egui::UiBuilder::new().max_rect(head.shrink2(vec2(12.0, 12.0))).layout(egui::Layout::right_to_left(egui::Align::Center))); + if icons::button(&mut bar, Icon::Close, 24.0, false, "Close version history").clicked() { + action = Some(Action::Close); + } + let new_open = app.ui.versions.form.as_ref().is_some_and(|f| f.id.is_none()); + let tip = format!("Save version {}", crate::toolbar::pretty_shortcut("Alt+Cmd+S")); + if icons::button(&mut bar, Icon::Plus, 24.0, new_open, &tip).clicked() { + action = Some(Action::New); + } + ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.border)); + + if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.is_none()) { + ui.add_space(12.0); + if let Some(a) = form(ui, f, "Save", 16.0) { + action = Some(a); + } + ui.add_space(12.0); + widgets::separator(ui); + } + + let rows: Vec = app + .session + .ds() + .map(|ds| { + ds.versions + .iter() + .rev() + .map(|v| Row { id: v.id.clone(), name: v.name.clone(), description: v.description.clone(), time: v.time }) + .collect() + }) + .unwrap_or_default(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + // Rows touch so the timeline is continuous. + ui.spacing_mut().item_spacing.y = 0.0; + ui.add_space(4.0); + current_row(ui, !rows.is_empty()); + let last = rows.len().saturating_sub(1); + for (i, row) in rows.iter().enumerate() { + 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())) { + inline_row(ui, i == last, |ui| form(ui, f, "Save", TEXT_X)) + } else { + version_row(ui, row, i == last) + }; + if a.is_some() { + action = a; + } + } + if rows.is_empty() { + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.add( + egui::Label::new( + egui::RichText::new("No saved versions yet. Save a version to mark a point in your work you can come back to.") + .font(regular(11.0)) + .color(t.text_secondary), + ) + .wrap(), + ); + }); + } + ui.add_space(16.0); + }); + if let Some(a) = action { + act(app, a, &rows); + } +} + +fn act(app: &mut FigApp, a: Action, rows: &[Row]) { + let st = &mut app.ui.versions; + let name_of = |id: &str| rows.iter().find(|r| r.id == id).map(|r| r.name.clone()).unwrap_or_default(); + match a { + Action::Close => *st = VersionUi::default(), + 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, "", "")) }; + } + Action::CancelForm => st.form = None, + Action::CancelDelete => st.confirm_delete = None, + Action::Edit(id) => { + st.confirm_delete = None; + st.form = rows.iter().find(|r| r.id == id).map(|r| Form::new(Some(id.clone()), &r.name, &r.description)); + } + Action::AskDelete(id) => { + st.form = None; + st.confirm_delete = Some(id); + } + Action::Submit => { + let Some(f) = st.form.take() else { return }; + let (cmd, params) = match &f.id { + Some(id) => ("version.rename", json!({"id": id, "name": f.name.trim(), "description": f.description.trim()})), + None => ("file.saveVersion", json!({"name": f.name.trim(), "description": f.description.trim()})), + }; + if app.run(cmd, params).is_none() { + app.ui.versions.form = Some(f); + } + } + Action::Delete(id) => { + st.confirm_delete = None; + app.run("version.delete", json!({"id": id})); + } + Action::Restore(id) => { + if app.run("version.restore", json!({"id": id})).is_some() { + app.toast(format!("Restored “{}”", name_of(&id))); + } + } + Action::Duplicate(id) => { + if app.run("version.duplicate", json!({"id": id})).is_some() { + app.toast(format!("Opened “{}” in a new tab", name_of(&id))); + } + } + } +} + +/// Title and description fields with Cancel / `submit` buttons, `indent` from the left. +fn form(ui: &mut Ui, f: &mut Form, submit: &str, indent: f32) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out = None; + let field = |ui: &mut Ui, add: &mut dyn FnMut(&mut Ui) -> egui::Response| { + egui::Frame::NONE.fill(t.field).corner_radius(CornerRadius::same(5)).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, add).inner + }; + ui.horizontal(|ui| { + ui.add_space(indent); + ui.vertical(|ui| { + ui.set_width(ui.available_width() - 16.0); + let w = ui.available_width() - 16.0; + let title = field(ui, &mut |ui| { + ui.add(egui::TextEdit::singleline(&mut f.name).hint_text("Title").font(regular(11.0)).frame(egui::Frame::NONE).desired_width(w)) + }); + if std::mem::take(&mut f.focus) { + title.request_focus(); + } + ui.add_space(8.0); + let desc = field(ui, &mut |ui| { + ui.add( + egui::TextEdit::multiline(&mut f.description) + .hint_text("Describe what changed") + .font(regular(11.0)) + .frame(egui::Frame::NONE) + .desired_rows(3) + .desired_width(w), + ) + }); + let (enter, cmd_enter, esc) = ui.input(|i| { + (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Enter) && i.modifiers.command, i.key_pressed(egui::Key::Escape)) + }); + let editing_existing = f.id.is_some(); + let can_submit = !editing_existing || !f.name.trim().is_empty(); + if esc && (title.has_focus() || title.lost_focus() || desc.has_focus() || desc.lost_focus()) { + out = Some(Action::CancelForm); + } else if can_submit && ((title.lost_focus() && enter) || (desc.has_focus() && cmd_enter)) { + out = Some(Action::Submit); + } + ui.add_space(8.0); + match buttons(ui, submit, can_submit) { + Some(true) => out = Some(Action::Submit), + Some(false) => out = Some(Action::CancelForm), + None => {} + } + }); + }); + out +} + +/// The timeline's line and dot for a row (`top`/`bottom`: the line continues above/below). +fn timeline(ui: &Ui, r: Rect, dot_y: f32, top: bool, bottom: bool, current: bool) { + let t = Tokens::get(ui.ctx()); + let x = r.min.x + DOT_X; + let p = ui.painter(); + let line = Stroke::new(1.0, t.border_strong); + if top { + p.line_segment([pos2(x, r.min.y), pos2(x, dot_y)], line); + } + if bottom { + p.line_segment([pos2(x, dot_y), pos2(x, r.max.y)], line); + } + if current { + p.circle_filled(pos2(x, dot_y), 4.0, t.accent); + } else { + p.circle(pos2(x, dot_y), 3.5, t.panel, line); + } +} + +fn current_row(ui: &mut Ui, more: bool) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::hover()); + timeline(ui, r, r.center().y, false, more, true); + ui.painter().text(pos2(r.min.x + TEXT_X, r.center().y), Align2::LEFT_CENTER, "Current version", semibold(11.0), t.text); +} + +fn version_row(ui: &mut Ui, row: &Row, last: bool) -> Option { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let text_w = (w - TEXT_X - 36.0).max(40.0); + // Up to three lines of description, then an ellipsis. + let desc = (!row.description.is_empty()).then(|| { + let mut job = egui::text::LayoutJob::simple(row.description.clone(), regular(11.0), t.text, text_w); + job.wrap.max_rows = 3; + ui.painter().layout_job(job) + }); + let desc_h = desc.as_ref().map(|g| g.size().y + 4.0).unwrap_or(0.0); + let (r, resp) = ui.allocate_exact_size(vec2(w, 50.0 + desc_h), Sense::click()); + let hovered = ui.rect_contains_pointer(r); + if hovered { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + let name_y = r.min.y + 17.0; + timeline(ui, r, name_y, true, !last, false); + let clip = Rect::from_min_max(pos2(r.min.x + TEXT_X, r.min.y), pos2(r.min.x + TEXT_X + text_w, r.max.y)); + let p = ui.painter().with_clip_rect(clip); + p.text(pos2(clip.min.x, name_y), Align2::LEFT_CENTER, &row.name, semibold(11.0), t.text); + p.text(pos2(clip.min.x, name_y + 16.0), Align2::LEFT_CENTER, crate::comments::ago_long(row.time), regular(11.0), t.text_secondary); + if let Some(g) = desc { + p.galley(pos2(clip.min.x, name_y + 26.0), g, t.text); + } + let mut out = None; + // More menu (on hover), also on right-click. + let br = Rect::from_center_size(pos2(r.max.x - 22.0, name_y), vec2(24.0, 24.0)); + let more = ui.interact(br, Id::new(("version_more", &row.id)), Sense::click()); + let menu_open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&more)); + if hovered || menu_open { + if more.hovered() || menu_open { + ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed); + } + icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon); + } + let more = more.on_hover_text("More"); + let mut menu = |ui: &mut Ui| { + for (label, a) in [ + ("Restore this version", Action::Restore(row.id.clone())), + ("Rename", Action::Edit(row.id.clone())), + ("Duplicate", Action::Duplicate(row.id.clone())), + ("Delete", Action::AskDelete(row.id.clone())), + ] { + if crate::right::menu_item(ui, label, "") { + out = Some(a); + ui.close(); + } + } + }; + egui::Popup::menu(&more).show(&mut menu); + egui::Popup::context_menu(&resp).show(&mut menu); + if resp.double_clicked() { + out = Some(Action::Edit(row.id.clone())); + } + out +} + +/// 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; + ui.add_space(8.0); + let a = add(ui); + ui.add_space(8.0); + let r = Rect::from_min_max(pos2(ui.min_rect().min.x, top), pos2(ui.min_rect().max.x, ui.cursor().min.y)); + timeline(ui, r, top + 20.0, true, !last, false); + a +} + +fn confirm_row(ui: &mut Ui, row: &Row) -> Option { + let t = Tokens::get(ui.ctx()); + let mut out = None; + egui::Frame::NONE.inner_margin(egui::Margin { left: TEXT_X as i8, right: 16, top: 4, bottom: 0 }).show(ui, |ui| { + ui.add( + egui::Label::new(egui::RichText::new(format!("Delete “{}”? This can't be undone.", row.name)).font(regular(11.0)).color(t.text)).wrap(), + ); + ui.add_space(8.0); + out = buttons(ui, "Delete", true).map(|delete| if delete { Action::Delete(row.id.clone()) } else { Action::CancelDelete }); + }); + out +} + +/// Right-aligned Cancel and `primary` buttons: `Some(true)` for the primary one, `Some(false)` for +/// Cancel. +fn buttons(ui: &mut Ui, primary: &str, enabled: bool) -> Option { + let size = vec2(ui.available_width(), widgets::FIELD_H); + ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + let ok = widgets::button(ui, primary, true, enabled).clicked(); + let cancel = widgets::button(ui, "Cancel", false, true).clicked(); + if ok { Some(true) } else { cancel.then_some(false) } + }) + .inner +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 9449bff..7859357 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -480,6 +480,29 @@ pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool { resp.clicked() } +/// A text button: `primary` is filled with the accent colour, otherwise outlined. A disabled +/// button is dimmed and never reports clicks. +pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Response { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text); + let sense = if enabled { Sense::click() } else { Sense::hover() }; + let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 24.0, FIELD_H), sense); + let hot = enabled && resp.hovered(); + let (fill, text) = match (primary, hot) { + (true, false) => (t.accent, Color32::WHITE), + (true, true) => (t.accent_hover, Color32::WHITE), + (false, false) => (Color32::TRANSPARENT, t.text), + (false, true) => (t.hover, t.text), + }; + let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.4) }; + ui.painter().rect_filled(rect, CornerRadius::same(5), dim(fill)); + if !primary { + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); + } + ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), dim(text)); + resp +} + /// Text in a row. pub fn label(ui: &mut Ui, text: &str, color: Color32) { ui.label(egui::RichText::new(text).font(regular(11.0)).color(color)); diff --git a/docs/mcp.md b/docs/mcp.md index bacb758..7dc4fb6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -141,6 +141,18 @@ instances and component properties are mapped. Image fills keep their `imageRef` placeholder unless the image files sit in an `images` folder next to the JSON, named by `imageRef` (`images/.png`); nothing is fetched from the network. +### Version history + +Named snapshots of the document, kept in the `.figcraft` file (they persist once the file is saved; +a new, renamed or deleted version marks the file as changed). Driven with `run_command`: +`file.saveVersion {name?, description?}` (returns `{id, index, name, description, time}`; untitled +versions are named "Version N"), `version.list` (oldest first; `time` is Unix seconds), +`version.restore {id | index}` (replaces the document with the version as one undo step; the file +name stays), `version.rename {id | index, name, description?}`, `version.delete {id | index}` and +`version.duplicate {id | index}` (opens the version as a new document tab). 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. + 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.