mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 02:47:02 -06:00
- 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>
174 lines
7.1 KiB
Rust
174 lines
7.1 KiB
Rust
//! 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);
|
|
}
|