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:
parent
15ccc379ab
commit
72b8c5d154
|
|
@ -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%).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
12
docs/mcp.md
12
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/<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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue