mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
Merge pull request #2046 from StrajnarFilip/claude/fix-2026-merge-layout-conflicts
M9: one auto layout edit on each side of a branch is one conflict (#2026)
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
+204
-13
@@ -9,16 +9,20 @@
|
||||
//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by
|
||||
//! item: both sides changing different items of the list (or one adding items at its end) is no
|
||||
//! conflict, only the same item changed both ways or the list shortened on a side while the other
|
||||
//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for
|
||||
//! one side, and the other side's changes to it are left out. Everything else from both sides is
|
||||
//! kept.
|
||||
//! changed it. Auto layout settings (a frame's and a child's) merge field by field: each field
|
||||
//! changed both ways is a conflict of its own, and the other fields merge. Values a file works out
|
||||
//! for itself (a size or place auto layout gives a layer, text sizing to its characters, a value a
|
||||
//! layer in an instance takes from its main component) never conflict: the merged file works them
|
||||
//! out again from what they follow. Nothing that conflicts is merged until it is resolved: each
|
||||
//! conflict is resolved for one side, and the other side's changes to it are left out. Everything
|
||||
//! else from both sides is kept.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, Sizing, TextAutoResize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json, Prop};
|
||||
use crate::replica::{Mode, Positions, Replica, create_change};
|
||||
@@ -33,6 +37,7 @@ pub enum Side {
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The side that isn't `self`.
|
||||
pub fn other(self) -> Side {
|
||||
match self {
|
||||
Side::Branch => Side::Main,
|
||||
@@ -48,6 +53,7 @@ impl Side {
|
||||
}
|
||||
}
|
||||
|
||||
/// The side `"branch"` or `"main"` names (`None` for anything else).
|
||||
pub fn parse(s: &str) -> Option<Side> {
|
||||
match s {
|
||||
"branch" => Some(Side::Branch),
|
||||
@@ -62,6 +68,9 @@ impl Side {
|
||||
pub enum Key {
|
||||
/// A layer property. A layer's parent and position are one property (where it is).
|
||||
Node(NodeId, Prop),
|
||||
/// One field of a layer property that merges field by field ([`PER_FIELD`]): `padding` of
|
||||
/// `layout`, `sizing_h` of `childLayout`…
|
||||
Field(NodeId, Prop, String),
|
||||
/// A layer deleted on one side and edited (it or what's inside it) on the other.
|
||||
Layer(NodeId),
|
||||
/// A document-level property.
|
||||
@@ -78,11 +87,13 @@ fn camel<T: Serialize>(v: &T) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `12/fills`, `12/layer`, `doc/styleOrder`, `style/S:3`, `comment/7`… (the keys resolutions use).
|
||||
/// `12/fills`, `12/layout.padding`, `12/layer`, `doc/styleOrder`, `style/S:3`, `comment/7`… (the
|
||||
/// keys resolutions use).
|
||||
impl fmt::Display for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Key::Node(id, prop) => write!(f, "{id}/{}", camel(prop)),
|
||||
Key::Field(id, prop, field) => write!(f, "{id}/{}.{field}", camel(prop)),
|
||||
Key::Layer(id) => write!(f, "{id}/layer"),
|
||||
Key::Doc(prop) => write!(f, "doc/{}", camel(prop)),
|
||||
Key::Entry(e) => match e {
|
||||
@@ -129,10 +140,25 @@ pub struct Plan {
|
||||
/// The main file's changes since the base.
|
||||
pub main: Vec<Change>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
/// Layer properties changed both ways to values both files work out for themselves
|
||||
/// ([`computed`]): no conflict, and a merge leaves the file merged into its own value.
|
||||
derived: BTreeSet<Key>,
|
||||
/// Layer properties ([`Key::Node`]) merged field by field with fields in conflict.
|
||||
fields: BTreeMap<Key, Fields>,
|
||||
branch_positions: Positions,
|
||||
main_positions: Positions,
|
||||
}
|
||||
|
||||
/// A layer property merged field by field ([`merge_fields`]): the value with every field changed
|
||||
/// one way (or the same way on both sides) merged, and each side's values of the fields changed
|
||||
/// both ways (`Null` for a field a side left out).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Fields {
|
||||
merged: Map<String, Value>,
|
||||
branch: Map<String, Value>,
|
||||
main: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Document-level properties that belong to each copy, not to the work: the file name (a branch
|
||||
/// is named after itself) and the page it opens on.
|
||||
fn merges(c: &Change) -> bool {
|
||||
@@ -152,6 +178,7 @@ fn key_of(c: &Change) -> Option<Key> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The changes (indexes) in `changes` grouped by what they write ([`key_of`]).
|
||||
fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
||||
let mut out: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
@@ -193,6 +220,108 @@ fn merged_list(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change
|
||||
merge_items(o.0.as_array()?, bv.0.as_array()?, mv.0.as_array()?).map(|v| Json(Value::Array(v)))
|
||||
}
|
||||
|
||||
/// Layer properties whose fields merge one by one: auto layout settings, a frame's (`layout`) and
|
||||
/// a child's (`childLayout`). Changing the padding on one side and the gap on the other is no
|
||||
/// conflict, and each field changed both ways is a conflict of its own ([`Key::Field`]).
|
||||
const PER_FIELD: &[Prop] = &[Prop::Layout, Prop::ChildLayout];
|
||||
|
||||
/// Three-way merge of object `o` changed to `b` on one side and `m` on the other, field by field:
|
||||
/// a field changed on one side takes that side's value, and one changed both ways (differently)
|
||||
/// is in conflict. `None` when any of them isn't an object (auto layout added or removed).
|
||||
fn merge_fields(o: &Value, b: &Value, m: &Value) -> Option<Fields> {
|
||||
let (o, b, m) = (o.as_object()?, b.as_object()?, m.as_object()?);
|
||||
let names: BTreeSet<&String> = o.keys().chain(b.keys()).chain(m.keys()).collect();
|
||||
let mut out = Fields::default();
|
||||
for name in names {
|
||||
let (base, x, y) = (o.get(name), b.get(name), m.get(name));
|
||||
let kept = if x == base {
|
||||
y
|
||||
} else if y == base || x == y {
|
||||
x
|
||||
} else {
|
||||
out.branch.insert(name.clone(), x.cloned().unwrap_or(Value::Null));
|
||||
out.main.insert(name.clone(), y.cloned().unwrap_or(Value::Null));
|
||||
base
|
||||
};
|
||||
if let Some(v) = kept {
|
||||
out.merged.insert(name.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Both sides' writes `b` and `m` of property `key` (one of [`PER_FIELD`]) merged field by field
|
||||
/// against the base; `None` when `key` isn't such a property or a value isn't an object.
|
||||
fn merged_fields(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change>) -> Option<Fields> {
|
||||
let Key::Node(id, prop) = key else { return None };
|
||||
let (Some(Change::Set { value: bv, .. }), Some(Change::Set { value: mv, .. })) = (b, m) else { return None };
|
||||
if !PER_FIELD.contains(prop) {
|
||||
return None;
|
||||
}
|
||||
let o = prop.get(base.node(*id)?).ok()?;
|
||||
merge_fields(&o.0, &bv.0, &mv.0)
|
||||
}
|
||||
|
||||
/// Whether layer `n` of `doc` is placed by its parent's auto layout (not absolute).
|
||||
fn in_flow(doc: &Document, n: &Node) -> bool {
|
||||
!n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())
|
||||
}
|
||||
|
||||
/// Whether auto layout or text works out layer `n`'s width (`horizontal`) or height: an auto layout
|
||||
/// frame hugging its contents, a child filling its auto layout parent, or text sizing to its
|
||||
/// characters.
|
||||
fn sized_for_itself(doc: &Document, n: &Node, horizontal: bool) -> bool {
|
||||
let sizing = if horizontal { n.child_layout.sizing_h } else { n.child_layout.sizing_v };
|
||||
let by_layout = match sizing {
|
||||
Sizing::Hug => n.layout.is_some(),
|
||||
Sizing::Fill => in_flow(doc, n),
|
||||
Sizing::Fixed => false,
|
||||
};
|
||||
let by_text = match &n.kind {
|
||||
NodeKind::Text(t) => t.auto_resize == TextAutoResize::WidthAndHeight || (!horizontal && t.auto_resize == TextAutoResize::Height),
|
||||
_ => false,
|
||||
};
|
||||
by_layout || by_text
|
||||
}
|
||||
|
||||
/// Whether layer `n` of `doc` (an instance, or a layer inside one) has property `prop` as its main
|
||||
/// component's layer has it: it follows the main rather than overriding it.
|
||||
fn follows_main(doc: &Document, n: &Node, prop: Prop) -> bool {
|
||||
let main = match &n.kind {
|
||||
NodeKind::Instance { component, .. } => Some(*component),
|
||||
_ => n.source,
|
||||
};
|
||||
Prop::FIELDS.contains(&prop) && main.and_then(|m| doc.node(m)).is_some_and(|m| !prop.differs(n, m))
|
||||
}
|
||||
|
||||
/// Whether layer property `key` holds a value `doc` works out for itself rather than one set on
|
||||
/// the layer: a size auto layout or text gives it ([`sized_for_itself`]), its place in an auto
|
||||
/// layout parent, or a value it takes from its main component ([`follows_main`]).
|
||||
fn computed(doc: &Document, key: &Key) -> bool {
|
||||
let Key::Node(id, prop) = key else { return false };
|
||||
let Some(n) = doc.node(*id) else { return false };
|
||||
let by_layout = match prop {
|
||||
Prop::Width => sized_for_itself(doc, n, true),
|
||||
Prop::Height => sized_for_itself(doc, n, false),
|
||||
Prop::Transform => in_flow(doc, n),
|
||||
_ => false,
|
||||
};
|
||||
by_layout || follows_main(doc, n, *prop)
|
||||
}
|
||||
|
||||
/// Whether property `key`, changed both ways, is one both files work out for themselves
|
||||
/// ([`computed`]), so neither side's value is a choice to make. A place given by auto layout is
|
||||
/// only that when both sides also turn and scale the layer the same way (a rotation is set on the
|
||||
/// layer, not worked out).
|
||||
fn derived_on_both(branch: &Document, main: &Document, key: &Key) -> bool {
|
||||
if !(computed(branch, key) && computed(main, key)) {
|
||||
return false;
|
||||
}
|
||||
let Key::Node(id, Prop::Transform) = key else { return true };
|
||||
let linear = |doc: &Document| doc.node(*id).map(|n| n.transform.as_coeffs()).map(|c| [c[0], c[1], c[2], c[3]]);
|
||||
linear(branch) == linear(main)
|
||||
}
|
||||
|
||||
/// The change at the only index in `c`.
|
||||
fn only<'a>(c: &[usize], all: &'a [Change]) -> Option<&'a Change> {
|
||||
match c {
|
||||
@@ -265,6 +394,8 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
let mut mc: Vec<Change> = m.changes.into_iter().filter(merges).collect();
|
||||
let mut conflicts = Vec::new();
|
||||
let mut lists = Vec::new();
|
||||
let mut derived = BTreeSet::new();
|
||||
let mut fields = BTreeMap::new();
|
||||
let km = by_key(&mc);
|
||||
for (k, bi) in by_key(&bc) {
|
||||
// Deletions conflict with edits (below); images are content-addressed, so never differ.
|
||||
@@ -279,13 +410,28 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
match merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
|
||||
Some(value) => lists.push((bi, mi.clone(), value)),
|
||||
None => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }),
|
||||
if derived_on_both(branch, main, &k) {
|
||||
derived.insert(k);
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
|
||||
lists.push((bi, mi.clone(), value));
|
||||
continue;
|
||||
}
|
||||
match (&k, merged_fields(base, &k, only(&bi, &bc), only(mi, &mc))) {
|
||||
(_, Some(f)) if f.branch.is_empty() => lists.push((bi, mi.clone(), Json(Value::Object(f.merged)))),
|
||||
(Key::Node(id, prop), Some(f)) => {
|
||||
for name in f.branch.keys() {
|
||||
let key = Key::Field(*id, *prop, name.clone());
|
||||
conflicts.push(Conflict { key, deleted_by: None, branch: bi.clone(), main: mi.clone() });
|
||||
}
|
||||
fields.insert(k, f);
|
||||
}
|
||||
_ => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }),
|
||||
}
|
||||
}
|
||||
// Both sides' writes of a list merged item by item become the merged list, so merging either
|
||||
// way writes it.
|
||||
// Both sides' writes of a list merged item by item (or of auto layout settings merged field by
|
||||
// field) become the merged value, so merging either way writes it.
|
||||
for (bi, mi, value) in lists {
|
||||
for (changes, at) in [(&mut bc, bi), (&mut mc, mi)] {
|
||||
for i in at {
|
||||
@@ -313,6 +459,8 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
branch: bc,
|
||||
main: mc,
|
||||
conflicts,
|
||||
derived,
|
||||
fields,
|
||||
branch_positions: b.replica.positions().clone(),
|
||||
main_positions: m.replica.positions().clone(),
|
||||
})
|
||||
@@ -332,6 +480,42 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer properties both sides changed to values both files work out for themselves (a size or
|
||||
/// place auto layout gives, a value an instance takes from its main): not conflicts, and
|
||||
/// merging leaves the file merged into its own value for the merged file to work out again.
|
||||
pub fn derived(&self) -> &BTreeSet<Key> {
|
||||
&self.derived
|
||||
}
|
||||
|
||||
/// The branch's and main's values of field conflict `key` ([`Key::Field`]); `None` for other
|
||||
/// keys.
|
||||
pub fn field_values(&self, key: &Key) -> Option<(&Value, &Value)> {
|
||||
let Key::Field(id, prop, name) = key else { return None };
|
||||
let f = self.fields.get(&Key::Node(*id, *prop))?;
|
||||
Some((f.branch.get(name)?, f.main.get(name)?))
|
||||
}
|
||||
|
||||
/// The side `pick` chose for each field in conflict of every property merged field by field,
|
||||
/// written into the value merged from the other fields: the writes that replace both sides'
|
||||
/// own writes of those properties.
|
||||
fn field_writes(&self, pick: &dyn Fn(&Key) -> Option<Side>) -> Vec<Change> {
|
||||
let mut out = Vec::new();
|
||||
for (key, f) in &self.fields {
|
||||
let Key::Node(id, prop) = key else { continue };
|
||||
let mut value = f.merged.clone();
|
||||
for name in f.branch.keys() {
|
||||
let won = if pick(&Key::Field(*id, *prop, name.clone())) == Some(Side::Branch) { &f.branch } else { &f.main };
|
||||
match won.get(name) {
|
||||
Some(Value::Null) | None => value.remove(name),
|
||||
Some(v) => value.insert(name.clone(), v.clone()),
|
||||
};
|
||||
}
|
||||
out.push(Change::Set { id: *id, prop: *prop, value: Json(Value::Object(value)) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `side`'s positions (fractional keys) of the layers, for applying its changes elsewhere.
|
||||
fn positions(&self, side: Side) -> &Positions {
|
||||
match side {
|
||||
Side::Branch => &self.branch_positions,
|
||||
@@ -355,7 +539,10 @@ impl Plan {
|
||||
let mut skip: BTreeSet<usize> = BTreeSet::new();
|
||||
let mut batch = Vec::new();
|
||||
for c in &self.conflicts {
|
||||
if pick(&c.key) != Some(from) {
|
||||
if matches!(c.key, Key::Field(..)) {
|
||||
// Written whole below, each field from the side picked for it.
|
||||
skip.extend(c.changes(from));
|
||||
} else if pick(&c.key) != Some(from) {
|
||||
skip.extend(c.changes(from));
|
||||
} else if let (Key::Layer(id), Some(by)) = (&c.key, c.deleted_by)
|
||||
&& by != from
|
||||
@@ -372,7 +559,11 @@ impl Plan {
|
||||
// shortcut: images a side stopped using are kept (unused ones cost file size only); drop
|
||||
// them if files grow.
|
||||
let kept = |c: &Change| !matches!(c, Change::Remove { entry: Entry::Image(_) });
|
||||
batch.extend(self.changes(from).iter().enumerate().filter(|(i, c)| !skip.contains(i) && kept(c)).map(|(_, c)| c.clone()));
|
||||
// Values both files work out for themselves stay as `into` has them; normalising the merged
|
||||
// file works them out again from what changed.
|
||||
let own = |c: &Change| key_of(c).is_some_and(|k| self.derived.contains(&k));
|
||||
batch.extend(self.changes(from).iter().enumerate().filter(|(i, c)| !skip.contains(i) && kept(c) && !own(c)).map(|(_, c)| c.clone()));
|
||||
batch.extend(self.field_writes(pick));
|
||||
let mut r = Replica::from_parts(into.clone(), self.positions(from.other()).clone());
|
||||
let applied = r.apply(&batch, Mode::Strict)?;
|
||||
Ok((r.document().clone(), applied.touched))
|
||||
|
||||
@@ -193,6 +193,109 @@ fn branch_commands_refuse_what_makes_no_sense() {
|
||||
assert!(s.execute("branch.merge", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Node id `v` of a command's answer (`{"id": …}` or a bare id).
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
NodeId(v.get("id").unwrap_or(v).as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// The issue #2026 file, with a branch of it made and shown: a component `Card` (horizontal auto
|
||||
/// layout hugging its contents, padding 12, one 40×20 rectangle) and an instance of it in a
|
||||
/// vertical auto layout frame hugging its contents. Returns (session, branch id, card, instance,
|
||||
/// frame, rectangle).
|
||||
fn card_file() -> (Session, String, NodeId, NodeId, NodeId, NodeId) {
|
||||
let mut s = Session::with_document(Document::new("Cards"));
|
||||
let props = json!({"layout": "horizontal", "padding": 12, "sizingH": "hug", "sizingV": "hug"});
|
||||
let frame = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Card", "props": props})));
|
||||
let rect = idv(&ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": frame})));
|
||||
let card = idv(&ok(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
let props = json!({"layout": "vertical", "padding": 0, "sizingH": "hug", "sizingV": "hug"});
|
||||
let stack = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 200, "width": 100, "height": 100, "name": "Stack", "props": props})));
|
||||
let inst = idv(&ok(&mut s, "instance.create", json!({"component": card, "parent": stack})));
|
||||
let b = ok(&mut s, "branch.create", json!({"name": "padding 8"}));
|
||||
(s, b["id"].as_str().unwrap().to_string(), card, inst, stack, rect)
|
||||
}
|
||||
|
||||
/// Size and place of every layer of `doc` (what auto layout works out).
|
||||
fn geometry(doc: &Document) -> Vec<(NodeId, [f64; 8])> {
|
||||
doc.nodes()
|
||||
.map(|n| {
|
||||
let c = n.transform.as_coeffs();
|
||||
(n.id, [n.width, n.height, c[0], c[1], c[2], c[3], c[4], c[5]])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Issue #2026: one auto layout edit on each side is one conflict (the field changed both ways),
|
||||
/// not one for every size and place auto layout works out from it; the merged file lays out
|
||||
/// from the padding picked.
|
||||
#[test]
|
||||
fn one_conflicting_auto_layout_edit_is_one_conflict() {
|
||||
for (pick, padding) in [("branch", 8.0), ("main", 16.0)] {
|
||||
let (mut s, id, card, inst, _, _) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16}}));
|
||||
// What main looks like with the picked padding set directly.
|
||||
let mut direct = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut direct, "node.set", json!({"id": card, "props": {"padding": padding}}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
let conflicts = review["conflicts"].as_array().unwrap();
|
||||
assert_eq!(conflicts.len(), 1, "{review}");
|
||||
let key = format!("{card}/layout.padding");
|
||||
assert_eq!(conflicts[0]["key"], key.as_str());
|
||||
assert_eq!(
|
||||
(&conflicts[0]["property"], &conflicts[0]["branch"], &conflicts[0]["main"]),
|
||||
(&json!("auto layout padding"), &json!("8, 8, 8, 8"), &json!("16, 16, 16, 16"))
|
||||
);
|
||||
let err = s.execute("branch.merge", &json!({"id": id})).unwrap_err().to_string();
|
||||
assert!(err.contains(&key) && !err.contains("width"), "{err}");
|
||||
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {&key: pick}}));
|
||||
let doc = s.doc().unwrap();
|
||||
for n in [card, inst] {
|
||||
assert_eq!(doc.node(n).unwrap().layout.as_ref().unwrap().padding, [padding; 4], "{pick} wins");
|
||||
}
|
||||
assert_eq!(geometry(doc), geometry(direct.doc().unwrap()), "{pick} wins: laid out from the padding picked");
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout merges field by field: a gap changed only on the branch is kept whichever side's
|
||||
/// padding wins, both when merging and when updating the branch from main.
|
||||
#[test]
|
||||
fn auto_layout_fields_changed_one_way_survive_a_conflict() {
|
||||
for (cmd, pick, padding) in [("branch.merge", "main", 16.0), ("branch.merge", "branch", 8.0), ("branch.updateFromMain", "main", 16.0)] {
|
||||
let (mut s, id, card, _, _, _) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8, "gap": 30}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16, "mainAlign": "center"}}));
|
||||
let key = format!("{card}/layout.padding");
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
assert_eq!(review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].clone()).collect::<Vec<_>>(), vec![json!(key)], "{review}");
|
||||
ok(&mut s, cmd, json!({"id": id, "resolutions": {&key: pick}}));
|
||||
if cmd == "branch.updateFromMain" {
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
}
|
||||
let layout = s.doc().unwrap().node(card).unwrap().layout.clone().unwrap();
|
||||
assert_eq!((layout.padding, layout.gap, layout.main_align), ([padding; 4], 30.0, figcraft_doc::MainAlign::Center), "{cmd}, {pick} wins");
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout on one side and a hugging frame's size on both: the sizes auto layout works out
|
||||
/// on each side are no conflict, and merging leaves no stale size behind.
|
||||
#[test]
|
||||
fn sizes_auto_layout_works_out_never_conflict() {
|
||||
let (mut s, id, card, _, stack, rect) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": rect, "props": {"width": 60}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 4}}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
assert_eq!(review["conflicts"], json!([]), "{review}");
|
||||
let mut direct = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut direct, "node.set", json!({"id": rect, "props": {"width": 60}}));
|
||||
ok(&mut s, "branch.merge", json!({"id": id}));
|
||||
assert_eq!(s.doc().unwrap().node(stack).unwrap().width, 60.0 + 8.0);
|
||||
assert_eq!(geometry(s.doc().unwrap()), geometry(direct.doc().unwrap()));
|
||||
}
|
||||
|
||||
/// Every change a side made (that the other side didn't overwrite) shows in `doc`.
|
||||
fn shows(changes: &[Change], doc: &Document) -> Result<(), String> {
|
||||
let r = Replica::new(doc.clone());
|
||||
@@ -282,16 +385,25 @@ fn random_edits_on_both_sides_merge() {
|
||||
let none = |_: &figcraft_collab::merge::Key| None;
|
||||
let (into_main, _) = p.merge(&m, Side::Branch, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
let (into_branch, _) = p.merge(&b, Side::Main, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
// Values both sides work out for themselves (sizes and places auto layout gives) stay
|
||||
// as the file merged into has them.
|
||||
let chosen = |changes: &[Change]| -> Vec<Change> {
|
||||
changes
|
||||
.iter()
|
||||
.filter(|c| !matches!(c, Change::Set { id, prop, .. } if p.derived().contains(&Key::Node(*id, *prop))))
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
let mut both = Replica::new(start.clone());
|
||||
both.apply(&p.main, Mode::Strict).unwrap();
|
||||
both.apply(&p.branch, Mode::Strict).unwrap();
|
||||
both.apply(&chosen(&p.branch), Mode::Strict).unwrap();
|
||||
let mut want = both.document().clone();
|
||||
(want.name, want.current_page) = (m.name.clone(), m.current_page);
|
||||
assert_eq!(into_main, want, "seed {seed}");
|
||||
for doc in [&into_main, &into_branch] {
|
||||
assert!(doc.validate().is_empty(), "seed {seed}: {:?}", doc.validate());
|
||||
for side in [&p.branch, &p.main] {
|
||||
shows(side, doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
|
||||
shows(&chosen(side), doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -611,6 +611,21 @@ fn doc_change(plan: &Plan, c: &Change) -> Option<DocChange> {
|
||||
Some(DocChange { what, change: entry_change(plan, c) })
|
||||
}
|
||||
|
||||
/// One side's value of an auto layout field in conflict, in words: `16`, `8, 8, 8, 8`, `min`,
|
||||
/// `on`, `none`.
|
||||
fn field_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Null => "none".to_string(),
|
||||
Value::Bool(on) => if *on { "on" } else { "off" }.to_string(),
|
||||
Value::Number(n) => n.as_f64().map_or_else(|| n.to_string(), |x| x.to_string()),
|
||||
Value::String(s) => words(s),
|
||||
Value::Array(items) => items.iter().map(field_text).collect::<Vec<_>>().join(", "),
|
||||
Value::Object(_) => v.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The review's row for conflict `c`: the layer (or file entry) and property, and what each side
|
||||
/// changed it to.
|
||||
fn conflict_row(plan: &Plan, c: &Conflict, base: &Document, head: &Document, main: &Document) -> ConflictRow {
|
||||
let layer = |id: NodeId| main.node(id).or(head.node(id)).or(base.node(id)).map_or_else(|| id.to_string(), |n| n.name.clone());
|
||||
let text = |side: Side| match c.changes(side).first().and_then(|i| plan.changes(side).get(*i)) {
|
||||
@@ -627,6 +642,11 @@ fn conflict_row(plan: &Plan, c: &Conflict, base: &Document, head: &Document, mai
|
||||
let property = if *prop == Prop::Parent { "place in layers".to_string() } else { words(prop) };
|
||||
(Some(*id), layer(*id), property, b, m)
|
||||
}
|
||||
Key::Field(id, prop, field) => {
|
||||
let (b, m) = plan.field_values(&c.key).map_or_else(|| ("changed".into(), "changed".into()), |(b, m)| (field_text(b), field_text(m)));
|
||||
let owner = if *prop == Prop::Layout { "auto layout".to_string() } else { words(prop) };
|
||||
(Some(*id), layer(*id), format!("{owner} {}", field.replace('_', " ")), b, m)
|
||||
}
|
||||
Key::Layer(id) => {
|
||||
let (b, m) = if c.deleted_by == Some(Side::Branch) { ("deleted", "edited") } else { ("edited", "deleted") };
|
||||
(Some(*id), layer(*id), "layer".to_string(), b.to_string(), m.to_string())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (; a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. Auto layout settings (a frame's and a child's) merge field by field, each field changed both ways a conflict of its own (`<id>/layout.padding`), and sizes and places auto layout works out (hugging, filling, a child's place) or that an instance takes from its main are no conflict: the merged file lays them out again. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
|
||||
| Notifications, activity | yes | no | with sharing |
|
||||
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | patch | M9, #2026: one conflicting auto layout edit added a conflict for every size and place it moved (a padding changed on both sides of a file with one instance gave 10, with 15 instances 138), and a `layout` conflict took all auto layout fields together, so a gap changed only on the branch was lost if main won the padding. `merge::plan` now leaves out values both sides work out for themselves: a hugging frame's or a filling child's size, text sizing to its characters, an auto layout child's place (when both sides turn it the same way) and a value a layer in an instance takes from its main; merging keeps the file merged into's own value and normalising lays it out from the merged inputs. `layout` and `childLayout` merge field by field: a field changed on one side merges, each field changed both ways is its own conflict (`<id>/layout.padding`, shown as auto layout padding with both sides' values). Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`; the merge fuzz test leaves worked-out values out of what each side must show |
|
||||
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
|
||||
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
|
||||
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
|
||||
|
||||
+4
-1
@@ -1353,7 +1353,10 @@ branches). Driven with `run_command`:
|
||||
export settings, guides, prototype interactions, annotations and Dev Mode resources compare item
|
||||
by item, so changing different items of one list on each side, or adding items at its end on one
|
||||
side, merges without a conflict; other lists, such as code links, compare whole; a layer's parent
|
||||
and position are one property, `parent`), a layer deleted on one side while the other edited it or what's inside it
|
||||
and position are one property, `parent`; auto layout settings, `layout` and `childLayout`, compare
|
||||
field by field, each field changed both ways its own conflict, `<layer id>/layout.padding`; sizes and
|
||||
places auto layout works out, and values a layer in an instance takes from its main, are never
|
||||
conflicts: the merged file works them out again), a layer deleted on one side while the other edited it or what's inside it
|
||||
(`<layer id>/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides
|
||||
(`style/<id>`, `variable/<id>`, `comment/<id>`, `doc/<property>`…).
|
||||
- `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step,
|
||||
|
||||
Reference in New Issue
Block a user