mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 20:06:53 -06:00
M8: branch version history and finer list merges
- A branch keeps its own version history. On a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions; the main file's history is untouched. The Version history panel on a branch tab lists the branch's versions (its banner gives way to the version banner while one is viewed). - Branch versions are saved in the `.figcraft` file with the branch (`branches/<id>/versions/index.json` plus one snapshot each, the same layout as the file's own history; images shared; damaged entries skipped and reported; the snapshot byte budget is shared across branches). - Merging adds a version to the main file's history (`Merged "<name>"`, after an autosave of main when it had changed); the merged or archived branch keeps its own history and opens with it. - Fills, strokes and effects merge item by item in `figcraft_collab::merge`: different items changed on each side, or items added at the end on one side (or the same ones on both), merge without a conflict; the same item changed both ways, an item removed on one side, or different items added on both sides still conflict as the whole list. Both sides' writes become the merged list, so merging and Update from main write the same result.
This commit is contained in:
@@ -158,8 +158,6 @@ fn branch_tabs_save_with_the_main_file_and_come_back() {
|
||||
let path = dir.join("file.figcraft").to_string_lossy().to_string();
|
||||
let (mut s, id, r) = branched();
|
||||
ok(&mut s, "node.set", json!({"id": r[0], "props": {"name": "Branch edit"}}));
|
||||
// Versions belong to the main file.
|
||||
assert!(s.execute("file.saveVersion", &json!({})).is_err());
|
||||
// Saving on the branch tab saves the main file, branch included.
|
||||
ok(&mut s, "file.saveAs", json!({"path": path}));
|
||||
assert_eq!(s.docs[0].path.as_deref(), Some(path.as_str()));
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! A branch's own version history: versions saved on a branch tab go into the branch (not the
|
||||
//! main file's history), are viewed and restored there, are saved in the file with the branch and
|
||||
//! stay with it when it is merged or archived; merging adds a version to the main file's history.
|
||||
//! Also fills merging item by item through real commands.
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::collab_tests::base;
|
||||
|
||||
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn names(s: &mut Session) -> Vec<String> {
|
||||
ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
fn layer_name(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// The base file with one version saved on main and a branch "Explore" shown: (session, branch
|
||||
/// id, a rectangle).
|
||||
fn branched() -> (Session, String, NodeId) {
|
||||
let mut s = Session::with_document(base());
|
||||
let r = s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Rectangle)).unwrap().id;
|
||||
ok(&mut s, "file.saveVersion", json!({"name": "Main v1"}));
|
||||
let id = ok(&mut s, "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string();
|
||||
(s, id, r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_saved_on_a_branch_are_the_branchs_own() {
|
||||
let (mut s, _, r) = branched();
|
||||
let original = layer_name(&s, r);
|
||||
// A new branch's history starts empty: the main file's versions stay with it.
|
||||
assert!(names(&mut s).is_empty());
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}}));
|
||||
let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone();
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}}));
|
||||
assert_eq!(names(&mut s), ["Branch v1"]);
|
||||
// Viewing a branch version shows it read-only; going back shows the branch as it is.
|
||||
ok(&mut s, "version.preview", json!({"id": v}));
|
||||
assert_eq!(layer_name(&s, r), "Before");
|
||||
assert!(s.execute("node.set", &json!({"id": r, "props": {"name": "No"}})).is_err());
|
||||
ok(&mut s, "version.exitPreview", json!({}));
|
||||
assert_eq!(layer_name(&s, r), "After");
|
||||
// Restoring is one undo step on the branch.
|
||||
ok(&mut s, "version.restore", json!({"id": v}));
|
||||
assert_eq!(layer_name(&s, r), "Before");
|
||||
ok(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(layer_name(&s, r), "After");
|
||||
ok(&mut s, "edit.redo", json!({}));
|
||||
// Renaming works on the branch's history; main's history is untouched.
|
||||
ok(&mut s, "version.rename", json!({"id": v, "name": "Branch v1 (renamed)"}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
assert_eq!(names(&mut s), ["Main v1"]);
|
||||
assert_eq!(layer_name(&s, r), original, "restoring on the branch leaves main alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_versions_save_with_the_file_and_survive_merging() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-branch-versions-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("file.figcraft").to_string_lossy().to_string();
|
||||
let (mut s, id, r) = branched();
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "On the branch"}}));
|
||||
ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}));
|
||||
// An autosave on a branch tab goes into the branch's history too.
|
||||
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "On the branch, later"}}));
|
||||
assert_eq!(ok(&mut s, "version.autosave", json!({}))["saved"], true);
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "file.saveAs", json!({"path": path}));
|
||||
let mut t = Session::empty();
|
||||
ok(&mut t, "file.open", json!({"path": path}));
|
||||
assert_eq!(names(&mut t), ["Main v1"]);
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
|
||||
// Merging adds a version to the main file's history; the branch keeps its own.
|
||||
ok(&mut t, "branch.merge", json!({}));
|
||||
assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]);
|
||||
assert_eq!(layer_name(&t, r), "On the branch, later");
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn archiving_keeps_the_branch_history() {
|
||||
let (mut s, id, _) = branched();
|
||||
ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}));
|
||||
ok(&mut s, "branch.archive", json!({}));
|
||||
assert_eq!(names(&mut s), ["Main v1"]);
|
||||
ok(&mut s, "branch.open", json!({"id": id}));
|
||||
assert_eq!(names(&mut s), ["Branch v1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_fills_of_a_layer_merge_without_a_conflict() {
|
||||
// Main gives a rectangle a second fill before branching; the branch recolours the first and
|
||||
// main the second: no conflict, both kept. Recolouring the same fill both ways conflicts.
|
||||
let mut s = Session::with_document(base());
|
||||
let r = s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Rectangle)).unwrap().id;
|
||||
ok(&mut s, "fill.add", json!({"ids": [r]}));
|
||||
let id = ok(&mut s, "branch.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
ok(&mut s, "fill.set", json!({"ids": [r], "index": 0, "color": "#FF0000"}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "fill.set", json!({"ids": [r], "index": 1, "color": "#0000FF"}));
|
||||
assert_eq!(ok(&mut s, "branch.review", json!({"id": id}))["conflicts"], json!([]));
|
||||
ok(&mut s, "branch.merge", json!({"id": id}));
|
||||
let fills = serde_json::to_string(&s.doc().unwrap().node(r).unwrap().fills).unwrap();
|
||||
let mut t = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut t, "fill.set", json!({"ids": [r], "index": 0, "color": "#FF0000"}));
|
||||
ok(&mut t, "fill.set", json!({"ids": [r], "index": 1, "color": "#0000FF"}));
|
||||
assert_eq!(fills, serde_json::to_string(&t.doc().unwrap().node(r).unwrap().fills).unwrap(), "both recolours kept");
|
||||
|
||||
let id = ok(&mut s, "branch.create", json!({}))["id"].as_str().unwrap().to_string();
|
||||
ok(&mut s, "fill.set", json!({"ids": [r], "index": 1, "color": "#00FF00"}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "fill.set", json!({"ids": [r], "index": 1, "color": "#FFFF00"}));
|
||||
let conflicts = ok(&mut s, "branch.review", json!({"id": id}))["conflicts"].clone();
|
||||
assert_eq!(conflicts.as_array().unwrap().len(), 1, "{conflicts}");
|
||||
assert_eq!(conflicts[0]["key"], format!("{r}/fills"));
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
//! against the main file and merged back. A branch remembers the main document as it was when
|
||||
//! the branch was made or last updated from main (its base); [`figcraft_collab::merge`] compares
|
||||
//! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a
|
||||
//! branch tab's edits go into its branch ([`sync`]) and are saved with the main file.
|
||||
//! branch tab's edits and its own version history go into its branch ([`sync`]) and are saved with
|
||||
//! the main file. Merging adds a version to the main file's history; the branch keeps its own.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -136,13 +137,21 @@ pub(crate) fn sync(s: &mut Session) {
|
||||
if !s.docs.iter().any(|d| d.branch_of.is_some()) {
|
||||
return;
|
||||
}
|
||||
let tabs: Vec<(u64, String, Arc<Document>)> =
|
||||
s.docs.iter().filter_map(|d| d.branch_of.clone().map(|(m, id)| (m, id, d.document_itself().clone()))).collect();
|
||||
for (main, id, doc) in tabs {
|
||||
let mut tabs = Vec::new();
|
||||
for d in s.docs.iter_mut() {
|
||||
let Some((m, id)) = d.branch_of.clone() else { continue };
|
||||
// Versions saved, renamed or deleted on the tab since the last sync.
|
||||
let versions = std::mem::take(&mut d.versions_changed).then(|| d.versions.clone());
|
||||
tabs.push((m, id, d.document_itself().clone(), versions));
|
||||
}
|
||||
for (main, id, doc, versions) in tabs {
|
||||
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == main) else { continue };
|
||||
let Some(i) = ds.branches.iter().position(|b| b.id == id && !Arc::ptr_eq(&b.head, &doc)) else { continue };
|
||||
let Some(i) = ds.branches.iter().position(|b| b.id == id && (versions.is_some() || !Arc::ptr_eq(&b.head, &doc))) else { continue };
|
||||
if let Some(b) = ds.branches_mut().get_mut(i) {
|
||||
b.head = doc;
|
||||
if let Some(v) = versions {
|
||||
b.versions = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,6 +241,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
state: BranchState::Active,
|
||||
base,
|
||||
head: Arc::new(head),
|
||||
versions: Vec::new(),
|
||||
});
|
||||
let index = open_tab(s, uid, &id)?;
|
||||
Ok(json!({"id": id, "name": name, "index": index}))
|
||||
@@ -248,7 +258,8 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result<usize> {
|
||||
let mut doc = (*b.head).clone();
|
||||
// A branch read from a file has no client id yet.
|
||||
doc.set_client_id(b.client);
|
||||
let mut tab = DocState::new(doc, None);
|
||||
// The branch's own version history: the tab's versions while it is open ([`sync`]).
|
||||
let mut tab = DocState::new(doc, None).with_versions(b.versions.clone());
|
||||
tab.branch_of = Some((main, id.to_string()));
|
||||
// The same content: opening isn't an edit of the main file.
|
||||
b.head = tab.doc.clone();
|
||||
@@ -331,7 +342,8 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result<Value> {
|
||||
if from == Side::Branch {
|
||||
let m = s.docs.get_mut(mi).ok_or(EngineError::NoDocument)?;
|
||||
let (selection, page) = (m.selection.clone(), m.page);
|
||||
// The main file's history keeps it as it was before the merge, then the merge itself.
|
||||
// The main file's history keeps it as it was before the merge, then the merge itself; the
|
||||
// branch keeps its own history.
|
||||
crate::cmd::file::keep_in_history(m);
|
||||
show(m, doc);
|
||||
let name = m.branches.get(bi).map(|b| b.name.clone()).unwrap_or_default();
|
||||
|
||||
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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",
|
||||
"{name?, description?} snapshot the document into the file's version history (on a branch tab: the branch's own history; kept when the file is saved); returns the version",
|
||||
false,
|
||||
save_version
|
||||
),
|
||||
@@ -252,8 +252,7 @@ pub const MAX_AUTOSAVES: usize = 20;
|
||||
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
|
||||
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
// A branch's versions would have nowhere to be saved.
|
||||
if ds.preview.is_some() || ds.branch_of.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
|
||||
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
Ok(match add_version(ds, AUTOSAVE, "", true) {
|
||||
@@ -348,10 +347,14 @@ pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `file.saveVersion`; on a branch tab the version goes into the branch's own history.
|
||||
fn save_version(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.branch_of.is_some() {
|
||||
return Err(EngineError::Disabled("file.saveVersion".into(), "versions are saved on the main file, not on a branch".into()));
|
||||
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));
|
||||
|
||||
@@ -545,7 +545,9 @@ fn branches_and_the_version_history() {
|
||||
let b = ex(&mut s, "branch.create", json!({"name": "Explore"}));
|
||||
let branch = b["id"].as_str().unwrap().to_string();
|
||||
// On the branch tab.
|
||||
assert!(s.execute("file.saveVersion", &json!({})).is_err(), "not yet: a branch has no version history of its own (gap 17)");
|
||||
// A branch keeps its own version history: a version saved on its tab is listed there only.
|
||||
ex(&mut s, "file.saveVersion", json!({"name": "On the branch"}));
|
||||
assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::<Vec<_>>(), [json!("On the branch")], "a branch's own history");
|
||||
rect(&mut s, 900.0, 900.0, None);
|
||||
ex(&mut s, "doc.switch", json!({"index": 0}));
|
||||
let made_on_main = rect(&mut s, -500.0, 0.0, None);
|
||||
|
||||
@@ -1249,6 +1249,8 @@ mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_versions_tests;
|
||||
#[cfg(test)]
|
||||
mod brush_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_fixes_tests;
|
||||
|
||||
Reference in New Issue
Block a user