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/<id>.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 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 04:16:18 +07:00 committed by GitHub
parent 15ccc379ab
commit 72b8c5d154
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1218 additions and 111 deletions

View File

@ -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%).

View File

@ -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)

View File

@ -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<CommandSpec> {
}),
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<Value> =
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<CommandSpec> {
]
}
type Version = (String, String, u64, Arc<Document>);
fn version_list() -> &'static std::sync::Mutex<std::collections::HashMap<u64, Vec<Version>>> {
static V: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<u64, Vec<Version>>>> = 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<Document>) {
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<usize> {
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<Arc<Document>> {
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<Value> {
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<Value> {
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<String>)> {
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<String>)> {
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<String>, 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<String>, 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<Value> {
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<Vec<u8>> {
figcraft_render::encode_png(&pix).ok()
}
fn save_to(s: &mut Session, path: String) -> Result<Value> {
let ds = s.ds_mut()?;
/// The `.figcraft` file for a document: content, thumbnail and version history.
pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
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<Value> {
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<Value> {
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)
}

View File

@ -155,9 +155,5 @@ pub fn specs() -> Vec<CommandSpec> {
/// A guide index from params (never negative).
fn guide_index(cmd: &str, p: &Value) -> crate::Result<usize> {
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")
}

View File

@ -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<vectoredit::VectorEdit>,
pub uid: u64,
/// The file's version history, oldest first (saved in the `.figcraft` file; not undoable).
pub versions: Vec<figcraft_format::Version>,
/// 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<figcraft_format::Version>) -> Self {
self.versions = versions;
self
}
/// The version history, for editing (the file then has unsaved changes).
pub fn versions_mut(&mut self) -> &mut Vec<figcraft_format::Version> {
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;

View File

@ -32,6 +32,15 @@ pub fn req_num(cmd: &str, p: &Value, key: &str) -> Result<f64> {
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<usize> {
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<String> {
match get(p, key)? {
Value::String(s) => Some(s.clone()),

View File

@ -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<Value> {
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);
}

View File

@ -1,16 +1,20 @@
//! The native `.figcraft` format.
//!
//! A zip archive holding `document.json` (the [`Document`] as JSON, with a `format`/`version`
//! header), `images/<sha256>` 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/<sha256>` 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/<id>.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<Document>),
/// As read from a file: its `document.json`-style JSON, parsed on first use, and the images
/// it uses.
Stored { json: Arc<Vec<u8>>, images: BTreeMap<String, ImageData> },
}
impl Version {
/// A version of `doc`. The name and description are clipped to their maximum lengths.
pub fn new(id: impl Into<String>, name: &str, description: &str, time: u64, doc: Arc<Document>) -> 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<Arc<Document>, 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<String, ImageData> {
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<String>,
pub versions: Vec<Version>,
}
impl From<Document> 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<Value, FormatError> {
/// Parse `document.json` content.
pub fn from_json(text: &str) -> Result<(Document, Vec<String>), 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<BTreeSet<String>>;
/// Parse `document.json` content; also returns its [`ImageIndex`].
fn parse_json(text: &str) -> Result<(Document, Vec<String>, 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<W: Write + Seek>(z: &mut zip::ZipWriter<W>, 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<Vec<u8>, 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<Vec<u8>, 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<String>), 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<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str, limit: u64) -> Result<Vec<u8>, 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<Loaded, FormatError> {
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<String> = 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<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Version> {
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::<Value>(&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<Version> = 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<String>), Form
load_bytes(&bytes)
}
#[cfg(test)]
mod version_tests;
#[cfg(test)]
mod tests {
use super::*;

View File

@ -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<u8> {
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<u8> {
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<u8> {
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<serde_json::Value> = (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<String> = (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);
}

View File

@ -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<u64> {
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) {

View File

@ -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.

View File

@ -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,

View File

@ -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();

View File

@ -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<Form>,
/// The version waiting for delete confirmation.
pub confirm_delete: Option<String>,
}
/// Title and description being written; `id` is the version being edited (`None` = a new one).
pub struct Form {
pub id: Option<String>,
pub name: String,
pub description: String,
focus: bool,
}
impl Form {
fn new(id: Option<String>, 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<Row> = 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<Action> {
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<Action> {
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<Action>) -> Option<Action> {
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<Action> {
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<bool> {
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
}

View File

@ -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));

View File

@ -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/<imageRef>.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.