From bcb3ad7bc7b155cb47125b0858d0956f380d5d2b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:03:25 +0700 Subject: [PATCH] M10: branches in live sessions Branches of a file in a live session: create, edit, review, update from main and merge while the file is in a session. The branch stays with its author and makes ids in their own client space far above the main file's; merging is one synced edit and one undo step; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers is refused with a reason. Branch history entries for creation and updates from main. Fuzz and junk tests extended to export settings, guides and the live case. Docs: gap 17, collaboration parity, ROADMAP, mcp, protocol. --- ROADMAP.md | 3 +- crates/engine/src/branch_live_tests.rs | 240 +++++++++++++++++++ crates/engine/src/branch_tests.rs | 60 ++++- crates/engine/src/branch_versions_tests.rs | 12 +- crates/engine/src/branches.rs | 132 ++++++++-- crates/engine/src/history_behaviour_tests.rs | 6 +- crates/engine/src/lib.rs | 2 + crates/engine/src/live.rs | 28 ++- crates/engine/src/save_format_tests.rs | 2 +- docs/collab-protocol.md | 9 + docs/collaboration-parity.md | 5 +- docs/gaps.md | 28 ++- docs/mcp.md | 20 +- 13 files changed, 502 insertions(+), 45 deletions(-) create mode 100644 crates/engine/src/branch_live_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 05b7ed65..9d6a7b8d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries, gap 17; boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -328,6 +328,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | diff --git a/crates/engine/src/branch_live_tests.rs b/crates/engine/src/branch_live_tests.rs new file mode 100644 index 00000000..ae1a5c87 --- /dev/null +++ b/crates/engine/src/branch_live_tests.rs @@ -0,0 +1,240 @@ +//! Branches of a file in a live session: the branch stays with its author and makes ids in their +//! own client space; merging is one synced edit (and one step of the author's undo history) that +//! everyone receives; peers' edits on main while the branch is open merge against the branch's own +//! base, and conflicts with them resolve as offline; update from main takes them in; a view link +//! still refuses merging; a branch made outside the session that adds layers is refused instead +//! of being sent for the server to refuse. Plus a fuzz test with random real commands. + +use figcraft_collab::sim::Rng; +use figcraft_doc::ids::space_of; +use figcraft_doc::{Document, NodeId}; +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::branch_tests::{base_with_lists, random_edit}; +use crate::collab_live_tests::{Room, frame_and_rect}; +use crate::collab_tests::base; +use crate::live::{Access, Event, Role, VIEW_ONLY}; +use crate::live_tests::{info, push, relay, serve_as, session}; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn me(s: &Session) -> u32 { + s.live().client().unwrap().id() +} + +fn version_names(s: &mut Session) -> Vec { + ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +/// The conflict keys of branch `id`'s review. +fn conflict_keys(s: &mut Session, id: &str) -> Vec { + let review = ok(s, "branch.review", json!({"id": id})); + review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn a_branch_merges_into_the_session_as_one_synced_edit() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let you = me(room.s(0)); + let id = ok(room.s(0), "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string(); + // On the branch tab: a recolour, a new layer and a new style. + ok(room.s(0), "fill.set", json!({"ids": [r], "color": "#FF0000"})); + let made = NodeId(ok(room.s(0), "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + assert_eq!(space_of(made.0), you, "the branch makes ids in your own client space"); + ok(room.s(0), "style.create", json!({"type": "paint", "name": "Brand", "from": r})); + room.settle(); + assert!(!room.server.document().contains(made), "the branch stays with its author"); + // Someone else edits the main file meanwhile. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Renamed by a peer"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + assert_eq!(ok(room.s(0), "branch.merge", json!({"id": id}))["merged"], true); + room.settle(); + room.check(); + let doc = room.server.document(); + assert!(doc.contains(made), "everyone has the merge"); + assert_eq!(doc.node(f).unwrap().name, "Renamed by a peer", "the peer's edit stays"); + assert!(doc.styles.iter().any(|st| st.name == "Brand")); + assert_ne!(doc.node(r).unwrap().fills, base().node(r).unwrap().fills); + // The merge is one step of the author's own undo history. + ok(room.s(0), "edit.undo", json!({})); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + assert_eq!(room.server.document().node(f).unwrap().name, "Renamed by a peer"); +} + +#[test] +fn conflicts_with_peers_resolve_as_offline() { + let mut room = Room::new(base(), 2, 3); + let (f, r) = frame_and_rect(room.server.document()); + let r2 = room.server.document().node(f).unwrap().children[1]; + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Kept on the branch"}})); + ok(room.s(0), "node.set", json!({"id": r2, "props": {"opacity": 0.5}})); + // A peer deletes the layer the branch edited and sets the same property another way. + ok(room.s(1), "edit.delete", json!({"ids": [r]})); + ok(room.s(1), "node.set", json!({"id": r2, "props": {"opacity": 0.2}})); + room.settle(); + let keys = conflict_keys(room.s(0), &id); + let mut want = vec![format!("{r}/layer"), format!("{r2}/opacity")]; + want.sort(); + let mut got = keys.clone(); + got.sort(); + assert_eq!(got, want); + assert!(room.s(0).execute("branch.merge", &json!({"id": id})).is_err(), "conflicts first"); + // The branch's edits win the deletion, main wins the opacity. + let picks: Map = keys.iter().map(|k| (k.clone(), json!(if k.ends_with("/layer") { "branch" } else { "main" }))).collect(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).map(|n| n.name.as_str()), Some("Kept on the branch"), "the deleted layer is back, with the branch's edit"); + assert_eq!(doc.node(r2).unwrap().opacity, 0.2); +} + +#[test] +fn update_from_main_takes_in_peers_edits_and_shows_in_the_branch_history() { + let mut room = Room::new(base(), 2, 5); + let (f, r) = frame_and_rect(room.server.document()); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Branch"}})); + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 1"}})); + room.settle(); + assert_eq!(ok(room.s(0), "branch.updateFromMain", json!({}))["updated"], true); + assert_eq!(room.s(0).doc().unwrap().node(f).unwrap().name, "Peer 1", "the branch tab has the peer's edit"); + assert_eq!(version_names(room.s(0)), ["Branch created", "Updated from main"]); + // The new base is main as it was then: a later peer edit is main's change, not a conflict. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 2"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + ok(room.s(0), "branch.merge", json!({})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!((doc.node(f).unwrap().name.as_str(), doc.node(r).unwrap().name.as_str()), ("Peer 2", "Branch")); +} + +#[test] +fn a_view_link_still_refuses_merging() { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + let mut doc = Document::new("Shared"); + let page = doc.first_page().unwrap(); + let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let (_server, id, welcome) = serve_as(&doc, Access::View); + let mut you = info(Role::Guest, id); + you.access = Access::View; + push(&script, vec![Event::Welcome { info: you, peers: vec![] }, welcome]); + s.live_poll(); + assert_eq!(s.active_read_only(), Some(VIEW_ONLY)); + // The branch is yours to edit; merging it would edit the file you may only view. + ok(&mut s, "branch.create", json!({})); + ok(&mut s, "node.set", json!({"id": card, "props": {"name": "Mine"}})); + ok(&mut s, "branch.updateFromMain", json!({})); + match s.execute("branch.merge", &json!({})) { + Err(crate::EngineError::Disabled(_, why)) => assert_eq!(why, VIEW_ONLY), + other => panic!("expected a refusal, got {other:?}"), + } + assert!(script.lock().unwrap().msgs.is_empty(), "nothing was sent"); +} + +#[test] +fn a_branch_made_outside_the_session_merges_edits_but_not_new_layers() { + let (mut s, script) = session(); + let r = NodeId(ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let edits = ok(&mut s, "branch.create", json!({"name": "Edits"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Edited before the session"}})); + ok(&mut s, "doc.switch", json!({"index": 0})); + let adds = ok(&mut s, "branch.create", json!({"name": "Adds"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "shape.ellipse", json!({"x": 90, "y": 0, "width": 20, "height": 20})); + ok(&mut s, "doc.switch", json!({"index": 0})); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve_as(s.doc().unwrap(), Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live().syncing()); + // Its new layer is in another id space: refused here rather than by the server. + match s.execute("branch.merge", &json!({"id": adds})) { + Err(crate::EngineError::Disabled(_, why)) => assert!(why.contains("outside this live session"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node_count(), server.document().node_count(), "nothing merged"); + // Edits of layers everyone has go through. + ok(&mut s, "branch.merge", json!({"id": edits})); + let answers = relay(&script, &mut server, id); + push(&script, answers); + s.live_poll(); + assert_eq!(server.document().node(r).unwrap().name, "Edited before the session"); + assert!(*s.doc().unwrap() == *server.document()); +} + +/// The live fuzz test: three people edit with random real commands (export settings and guides +/// included) while one of them works on a branch; every conflict resolved for a random side, the +/// merge goes through the server as it is and everyone converges on it. +#[test] +fn random_edits_on_a_branch_merge_into_a_live_session() { + let (mut created, mut conflicted) = (0, 0); + for seed in 0..8 { + let mut room = Room::new(base_with_lists(), 3, seed); + let mut rng = Rng::new(seed + 50); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + for _ in 0..30 { + let i = rng.below(3); + let (cmd, params) = random_edit(room.s(i).doc().unwrap(), &mut rng, true); + let _ = room.s(i).execute(cmd, ¶ms); + if rng.chance(0.3) { + room.settle(); + } + } + room.settle(); + let keys = conflict_keys(room.s(0), &id); + conflicted += usize::from(!keys.is_empty()); + let picks: Map = keys.into_iter().map(|k| (k, json!(if rng.chance(0.5) { "branch" } else { "main" }))).collect(); + let before = room.server.document().clone(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + let merged = room.s(0).doc().unwrap().clone(); + assert!(merged.validate().is_empty(), "seed {seed}: {:?}", merged.validate()); + created += usize::from(merged.ids().any(|n| !before.contains(n))); + room.settle(); + room.check(); + assert!(*room.server.document() == merged, "seed {seed}: the server took the merge as it is"); + } + assert!(created >= 3 && conflicted >= 2, "{created} merges added layers, {conflicted} had conflicts"); +} + +/// No branch command panics on junk parameters in a live session, from the main file's tab or +/// the branch's, and the session stays in sync. +#[test] +fn branch_commands_survive_junk_params_in_a_live_session() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!({"id": 5, "name": 1e308, "resolutions": [1]}), + json!({"id": "nope", "resolutions": {"1/layer": "both", "doc/name": 7}}), + json!({"name": "\u{0}".repeat(500), "resolutions": {"x": "branch"}}), + ]; + let mut room = Room::new(base(), 2, 11); + ok(room.s(0), "branch.create", json!({})); + let ids: Vec<&str> = crate::branches::specs().iter().map(|s| s.id).collect(); + for tab in [0, 1] { + for id in &ids { + for j in &junk { + let s = room.s(0); + if s.docs.len() > tab { + let _ = s.execute("doc.switch", &json!({"index": tab})); + } + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + room.settle(); + } + } + } + room.check(); +} diff --git a/crates/engine/src/branch_tests.rs b/crates/engine/src/branch_tests.rs index 1c2cc769..f0e8a72e 100644 --- a/crates/engine/src/branch_tests.rs +++ b/crates/engine/src/branch_tests.rs @@ -1,9 +1,9 @@ //! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step, //! update from main, archive, saving with the file, and a fuzz test over random real commands. -use figcraft_collab::merge::{Side, plan}; +use figcraft_collab::merge::{Key, Side, plan}; use figcraft_collab::sim::Rng; -use figcraft_collab::{Change, DocProp, Mode, Replica, Target}; +use figcraft_collab::{Change, DocProp, Mode, Prop, Replica, Target}; use figcraft_doc::{Document, NodeId, NodeKind}; use serde_json::{Value, json}; @@ -215,26 +215,68 @@ fn shows(changes: &[Change], doc: &Document) -> Result<(), String> { Ok(()) } -/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both -/// ways) equals applying both change sets and shows every change of either side; with conflicts, -/// resolving them all for either side gives a valid document. +/// [`base`] with two export settings and two guides on its first frame (lists that merge item by +/// item). +pub(crate) fn base_with_lists() -> Document { + let mut doc = base(); + let f = doc.node(doc.first_page().unwrap()).unwrap().children[0]; + let n = doc.get_mut(f).unwrap(); + n.exports = ["@1", "@2"].map(|suffix| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }).to_vec(); + n.guides = vec![figcraft_doc::Guide { vertical: true, offset: 10.0 }, figcraft_doc::Guide { vertical: false, offset: 20.0 }]; + doc +} + +/// A random real command: often an edit of a frame's export settings or guides, else +/// [`random_command`]. +pub(crate) fn random_edit(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) { + if rng.chance(0.6) { + return random_command(doc, rng, with_undo); + } + let frames: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.id).collect(); + let f = rng.pick(&frames).map_or(json!(0), |i| json!(i)); + let i = rng.below(3); + match rng.below(6) { + 0 => ("export.add", json!({"ids": [f]})), + 1 => ("export.update", json!({"ids": [f], "index": i, "suffix": format!("@{}", rng.below(9))})), + 2 => ("export.remove", json!({"ids": [f], "index": i})), + 3 => ("guide.add", json!({"frame": f, "vertical": rng.chance(0.5), "offset": rng.below(300)})), + 4 => ("guide.move", json!({"frame": f, "index": i, "offset": rng.below(300)})), + _ => ("guide.remove", json!({"frame": f, "index": i})), + } +} + +/// The fuzz test: random real commands on a branch and on main (export settings and guides +/// included). Without conflicts the merge (both ways) equals applying both change sets and shows +/// every change of either side; with conflicts, resolving them all for either side gives a valid +/// document. #[test] fn random_edits_on_both_sides_merge() { - let (mut disjoint, mut conflicted) = (0, 0); + let (mut disjoint, mut conflicted, mut lists) = (0, 0, 0); for seed in 0..60 { let mut rng = Rng::new(seed); - let start = base(); + let start = base_with_lists(); let mut on_branch = start.clone(); on_branch.set_client_id(start.highest_id_space() + 1); let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())]; for s in &mut sides { for _ in 0..1 + rng.below(5) { - let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false); + let (cmd, params) = random_edit(s.doc().unwrap(), &mut rng, false); let _ = s.execute(cmd, ¶ms); } } let [b, m] = sides.map(|s| s.doc().unwrap().clone()); let p = plan(&start, &b, &m).unwrap(); + // Export settings or guides of a layer changed on both sides that merged item by item. + let list_writes = |changes: &[Change]| -> std::collections::BTreeSet { + changes + .iter() + .filter_map(|c| match c { + Change::Set { id, prop: prop @ (Prop::Exports | Prop::Guides), .. } => Some(Key::Node(*id, *prop)), + _ => None, + }) + .collect() + }; + lists += list_writes(&p.branch).intersection(&list_writes(&p.main)).filter(|k| !p.conflicts.iter().any(|c| c.key == **k)).count(); if p.conflicts.is_empty() { disjoint += 1; let none = |_: &figcraft_collab::merge::Key| None; @@ -260,5 +302,5 @@ fn random_edits_on_both_sides_merge() { } } } - assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts"); + assert!(disjoint >= 10 && conflicted >= 5 && lists >= 3, "{disjoint} disjoint, {conflicted} with conflicts, {lists} lists merged item by item"); } diff --git a/crates/engine/src/branch_versions_tests.rs b/crates/engine/src/branch_versions_tests.rs index 30bdf736..30f5134e 100644 --- a/crates/engine/src/branch_versions_tests.rs +++ b/crates/engine/src/branch_versions_tests.rs @@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) { 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()); + // A new branch's history starts with its creation: the main file's versions stay with it. + assert_eq!(names(&mut s), ["Branch created"]); 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"]); + assert_eq!(names(&mut s), ["Branch created", "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"); @@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() { 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"]); + assert_eq!(names(&mut t), ["Branch created", "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"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); std::fs::remove_dir_all(&dir).ok(); } @@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() { 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"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); } #[test] diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 8bff0568..7200ef9b 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -4,6 +4,13 @@ //! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a //! 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. +//! +//! In a live session a branch stays with the person who made it (others don't see it), and +//! merging it is one edit of the session document, which syncs to everyone like any other. The +//! session server takes new layers only in the sender's own id space, so a branch made in the +//! session makes its ids in yours, far above the ids the main file makes there ([`WINDOW`]). +//! Merging a branch made outside the session is refused when it adds layers, styles, variables or +//! comments (their ids are in another space). use std::collections::BTreeMap; use std::sync::Arc; @@ -11,6 +18,7 @@ use std::sync::Arc; pub use figcraft_collab::merge::Side; use figcraft_collab::merge::{self, Conflict, Key, Plan}; use figcraft_collab::{Change, Entry, Prop, Target}; +use figcraft_doc::ids::space_of; use figcraft_doc::{Document, NodeId}; use figcraft_format::Branch; pub use figcraft_format::BranchState; @@ -27,7 +35,7 @@ pub fn specs() -> Vec { spec!( "branch.create", "Create branch", - "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base) → {id, name, index (its tab)}", + "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base); in a live session it stays yours → {id, name, index (its tab)}", false, create ), @@ -53,14 +61,14 @@ pub fn specs() -> Vec { spec!( "branch.updateFromMain", "Update from main", - "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch; refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch (a version in its history); refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", false, |s, p| combine(s, p, Side::Main) ), spec!( "branch.merge", "Merge branch", - "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step, then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step (in a live session one edit everyone gets; refused on a view link), then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", false, |s, p| combine(s, p, Side::Branch) ), @@ -219,11 +227,42 @@ fn disabled(cmd: &str, why: impl Into) -> EngineError { EngineError::Disabled(cmd.into(), why.into()) } -/// shortcut: branches of a file in a live session are refused (its participants' client ids -/// aren't known here, so a branch's id space could clash); allow them once the server hands out -/// branch id spaces. -fn refuse_live(s: &Session, cmd: &str, uid: u64) -> Result<()> { - if s.live_doc(uid) { Err(disabled(cmd, "branches aren't available in a live session")) } else { Ok(()) } +/// shortcut: in a live session a branch makes ids in your own client space starting this far +/// above every id made there on the main file or its other branches, so neither side makes the +/// other's ids before the merge unless one makes 16 million first; have the server hand out +/// branch id spaces if that ever matters. +const WINDOW: u64 = 1 << 24; + +/// Where a new branch of the file in tab `main` starts making ids in your live session's client +/// space `client`: [`WINDOW`] above the main file's and every branch's next id there (`None`: the +/// space is full). +fn live_window(s: &Session, main: u64, client: u32) -> Option { + let m = s.docs.iter().find(|d| d.uid == main)?; + let tabs = s.docs.iter().filter(|d| d.branch_of.as_ref().is_some_and(|(t, _)| *t == main)).map(|d| d.doc.next_id()); + let next = m.branches.iter().map(|b| b.head.next_id()).chain(tabs).chain([m.doc.next_id()]); + let top = next.filter(|n| space_of(n.0) == client).max()?; + let start = top.0.checked_add(WINDOW)?; + (space_of(start) == client).then_some(NodeId(start)) +} + +/// What merging into a live session's document would add that its server won't take from client +/// `client`: a new layer outside your id space (unless it is a layer of a base taken in this +/// session, `in_session`, that main deleted since: the server takes those back), or a new style, +/// variable, collection or comment in another client's space. `None`: everything goes through. +fn foreign(plan: &Plan, base: &Document, main: &Document, merged: &Document, client: u32, in_session: bool) -> Option { + let outside = |n: NodeId| space_of(n.0) != client && !(in_session && base.contains(n)); + if let Some(n) = merged.nodes().find(|n| !main.contains(n.id) && outside(n.id)) { + return Some(format!("layer “{}”", n.name)); + } + plan.changes(Side::Branch).iter().find_map(|c| match c { + Change::Put { entry, .. } + if entry.id_space().is_some_and(|sp| sp != 0 && sp != client) + && plan.base().value(&Target::Entry(entry.clone())).ok().flatten().is_none() => + { + Some(entry.describe()) + } + _ => None, + }) } fn create(s: &mut Session, p: &Value) -> Result { @@ -232,7 +271,7 @@ fn create(s: &mut Session, p: &Value) -> Result { if ds.branch_of.is_some() { return Err(disabled(CMD, "this tab is a branch: make branches from the main file")); } - refuse_live(s, CMD, ds.uid)?; + let live = s.live_client_id(CMD, ds.uid)?; if ds.branches.len() >= figcraft_format::MAX_BRANCHES { return Err(disabled(CMD, format!("the file has the most branches it can keep ({})", figcraft_format::MAX_BRANCHES))); } @@ -240,12 +279,22 @@ fn create(s: &mut Session, p: &Value) -> Result { string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Branch {}", ds.branches.len() + 1)); let name = figcraft_format::clip(&name, figcraft_format::MAX_VERSION_NAME); let base = ds.document_itself().clone(); - // Its own id space, above every space the file, its live sessions and its branches used. - let client = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); - let client = client.checked_add(1).ok_or_else(|| disabled(CMD, "no id space left for a new branch"))?; + let no_space = || disabled(CMD, "no id space left for a new branch"); + // In a live session: your own client space, far above the main file's ids there. Otherwise its + // own id space, above every space the file, its live sessions and its branches used. + let (client, start) = match live { + Some(c) => (c, Some(live_window(s, ds.uid, c).ok_or_else(no_space)?)), + None => { + let top = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); + (top.checked_add(1).ok_or_else(no_space)?, None) + } + }; let mut head = (*base).clone(); head.name = name.clone(); head.set_client_id(client); + if let Some(start) = start { + head.reserve_ids(NodeId(start.0 - 1)); + } let id = crate::cmd::file::unique_id(|id| ds.branches.iter().any(|b| b.id == id)); let uid = ds.uid; let time = crate::cmd::comment::now_secs(); @@ -259,7 +308,13 @@ fn create(s: &mut Session, p: &Value) -> Result { head: Arc::new(head), versions: Vec::new(), }); + if live.is_some() { + s.live_branch_base(uid, &id); + } let index = open_tab(s, uid, &id)?; + if let Some(tab) = s.docs.get_mut(index) { + crate::cmd::file::add_version(tab, CREATED, "", false); + } Ok(json!({"id": id, "name": name, "index": index})) } @@ -269,11 +324,22 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result { s.active = i; return Ok(i); } - let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; - let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; + let live = s.live_client_id("branch.open", main)?; + let m = s.docs.iter().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; let mut doc = (*b.head).clone(); // A branch read from a file has no client id yet. doc.set_client_id(b.client); + // In your live session, a branch that would make ids right where the main file makes them (one + // read from a file that made none yet) moves up. + if live == Some(b.client) + && doc.next_id().0.abs_diff(m.doc.next_id().0) < WINDOW + && let Some(start) = live_window(s, main, b.client) + { + doc.reserve_ids(NodeId(start.0 - 1)); + } + let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; // 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())); @@ -340,7 +406,13 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } // The main file itself, not a version shown in its place. m.end_preview(); - refuse_live(s, cmd, main_uid)?; + // Merging edits the session document, which a view link (or a file on its way) can't. + if from == Side::Branch + && let Some(why) = s.live_read_only(main_uid) + { + return Err(disabled(cmd, why)); + } + let live = s.live_client_id(cmd, main_uid)?; let picks = resolutions(cmd, p)?; let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?; let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?; @@ -351,6 +423,14 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } let into = if from == Side::Branch { &main } else { &head }; let (mut doc, touched) = plan.merge(into, from, &pick).map_err(|e| EngineError::Other(format!("can't merge: {e}")))?; + if let Some(client) = live.filter(|_| from == Side::Branch) + && let Some(what) = foreign(&plan, &base, &main, &doc, client, s.live_branch_in_session(main_uid, &id)) + { + let why = format!( + "the branch adds {what}, made outside this live session, which the session can't take: merge it once the file has left the session" + ); + return Err(disabled(cmd, why)); + } doc.mark_dirty(touched); normalize(&mut doc, into); let doc = Arc::new(doc); @@ -383,11 +463,33 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { b.base = main; b.head = doc; } + if live.is_some() { + s.live_branch_base(main_uid, &id); + } + note(s, mi, bi, UPDATED); out["updated"] = json!(true); } Ok(out) } +/// The branch's own history entries for its creation and its updates from main. +const CREATED: &str = "Branch created"; +const UPDATED: &str = "Updated from main"; + +/// Add version `name` to the history of branch `bi` of the file in tab `mi`: its tab's when it is +/// open (it goes into the branch with [`sync`]), else the branch's. +fn note(s: &mut Session, mi: usize, bi: usize, name: &str) { + let Some((uid, id)) = s.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone()))) else { return }; + if let Some(tab) = tab_of(s, uid, &id).and_then(|t| s.docs.get_mut(t)) { + crate::cmd::file::add_version(tab, name, "", false); + return; + } + let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) else { return }; + let mut tab = DocState::new((*b.head).clone(), None).with_versions(std::mem::take(&mut b.versions)); + crate::cmd::file::add_version(&mut tab, name, "", false); + b.versions = tab.versions; +} + /// Show merged `doc` in tab `ds`. fn show(ds: &mut DocState, doc: Arc) { ds.doc = doc; diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs index e20f1bd1..45e0c7bf 100644 --- a/crates/engine/src/history_behaviour_tests.rs +++ b/crates/engine/src/history_behaviour_tests.rs @@ -547,7 +547,11 @@ fn branches_and_the_version_history() { // On the branch tab. // 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::>(), [json!("On the branch")], "a branch's own history"); + assert_eq!( + versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), + [json!("Branch created"), 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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2cd224e7..013b4c54 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1623,6 +1623,8 @@ mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; #[cfg(test)] +mod branch_live_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index cd8a0f03..f33abd1c 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -37,7 +37,7 @@ //! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone //! follow the person who started it; moving your own view stops following, as with any follow. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::IpAddr; use std::sync::Arc; @@ -599,6 +599,9 @@ pub struct Live { pub spotlight: Option, /// You spotlight: everyone follows you. pub spotlighting: bool, + /// The branches of the session's file made or last updated from main in this session: every + /// layer of their base was in it (see [`crate::branches`]). + branch_bases: BTreeSet, } impl Live { @@ -1251,6 +1254,29 @@ impl Session { self.tab().and_then(|uid| self.live_read_only(uid)) } + /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for + /// `cmd` while the session is on its way: there is no id yet. + pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result> { + let Some(live) = self.live.doc(uid) else { return Ok(None) }; + match &live.client { + Some(c) => Ok(Some(c.id())), + None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())), + None => Ok(None), + } + } + + /// Branch `id` of document `uid` got its base from the session document just now. + pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) { + if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { + live.branch_bases.insert(id.to_string()); + } + } + + /// Whether branch `id` of document `uid` got its base in the live session it is in now. + pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool { + self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id)) + } + /// Whether document `uid` is a live session's synced document: its edits sync and its undo /// history is the user's own. pub fn live_doc(&self, uid: u64) -> bool { diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index fc4ca775..37f22ce7 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -252,7 +252,7 @@ fn fig_copy_from_branch_exports_active_document_without_saving_main() { let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); - assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert_eq!(persisted.branches[0].versions.last().unwrap().name, "Branch version"); assert!(!s.ds().unwrap().is_dirty()); assert!(!s.docs[0].is_dirty()); } diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 38ef96ec..decfff6f 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -99,6 +99,15 @@ fit FigCraft's architecture: collide with what a connected client makes, because a client's numbers continue above every id of its space already in the document. Removed entries are part of the saved session state (`SessionState::removed`). +- **Branches** of a file in a session stay with the person who made them; nothing about them goes + over the wire until a merge, which is an ordinary batch from that person (no message or format + change). So that the server takes the layers a branch made, a branch made in the session makes + its ids in its author's own client space, 2^24 above every id made there on the main file and the + author's other branches. Layers of the branch's base that main deleted during the session come + back with their ids (the server accepts re-creating deleted ids). Merging a branch whose new + layers, styles, variables or comments lie in another id space (one made offline or in an earlier + session) is refused by the app before anything is sent, since the server would refuse it (see + `crates/engine/src/branches.rs`). - Objects inside an entry are not separate objects: modes are part of their collection, a variable's values per mode part of the variable and replies part of their comment thread. Two people adding a mode to the same collection, editing different modes of one variable, or replying diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index ae503633..c9af478f 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) 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 and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; 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)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its 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: 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; 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. 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, local**: 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); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 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. 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-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 40f97032..44a78e64 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -30,7 +30,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | | 16 | Dev Mode: measurements kept on the canvas, a read-only layers panel, publishing mappings from the code repository; the editor extension is left to the MCP server (import lines, compare with versions and an inspect-only canvas landed 2026-10-15) | Feature | 2–4 | [dev-mode-parity](dev-mode-parity.md) | -| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | +| 17 | Branching: text runs merged whole, branches only their author sees in a live session (branches in live sessions, export settings and guides item by item, and history entries for creation and updates landed 2026-10-24) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | | 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | @@ -412,9 +412,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe different items changed on each side, or items added at the end on one side, merge without a conflict; only the same item changed both ways, an item removed on one side, or different items added on both sides conflict (as the whole list). -- **Missing:** branches of a file in a live session are refused; other lists (text runs, export - settings, guides…) still conflict as a whole; the branch history has no entries of its own for - its creation or updates from main (only saved versions, autosaves and the merge on main). +- **Landed (2026-10-18, #798):** layout grids, export settings, guides, prototype interactions, + accessibility annotations and Dev Mode resources merge item by item like fills (code links merge + whole: one per language). +- **Landed (2026-10-24):** branches of a file in a live session: creating, editing, reviewing, + updating from main and merging work while the file is in a session. A branch stays with the + person who made it; merging it is one edit of the session document that everyone gets, and one + step of its author's own undo history. A branch made in the session makes its ids in its author's + own client space, 2^24 above every id made there on the main file, so the session server takes + the layers it adds; others' edits on main while it is open are main's changes against the + branch's own base, and conflicts with them resolve as offline (a layer a peer deleted comes back + with its id when the branch's edits win). A view link still refuses merging, and merging a branch + made outside the session that adds layers, styles, variables or comments (ids in another space) + is refused with a reason instead of being sent for the server to refuse. A branch's history gets + entries for its creation and each update from main. Engine tests through a session server with + several people, a fuzz test with random real commands (export settings and guides included, + offline and live) and a junk-parameter sweep of the branch commands in a session. +- **Missing:** text runs still conflict as a whole (runs are character ranges into the text, so + lining them up item by item would need a text diff, not positions); branches in a live session + are their author's only (others can't see or open them: that needs the server to keep them); + a branch made offline that adds layers merges only outside a session. - **Estimate:** 1–2 h. ### 18. Display P3 on screen (several windows landed) @@ -471,6 +488,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Gap 17: branches of a file in a live session (create, edit, review, update from main and merge while the file is in a session; the branch stays with its author and makes ids in their own client space far above the main file's; merging is one synced edit and one step of the author's undo history; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers is refused with a reason); a branch's history gets entries for its creation and updates from main; the list-merge wording brought up to date with #798 (export settings and guides already merged item by item); text runs still merge whole. Ranking and hours unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index ccaeb401..7736ae78 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1047,7 +1047,8 @@ branches). Driven with `run_command`: - `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`. The branch remembers the file as it is now (its base) and makes new ids in its own id space, so - layers, styles and comments made on both sides never clash. + layers, styles and comments made on both sides never clash. Its history starts with a `Branch + created` version. - `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to): `[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab). - `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document. @@ -1069,15 +1070,26 @@ branches). Driven with `run_command`: conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so neither side's work is dropped without a choice. - `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the - branch the same way (the new base is the main file as it is now). It is not undoable: the branch - tab's undo history starts over. + branch the same way (the new base is the main file as it is now) and adds an `Updated from main` + version to the branch's history. It is not undoable: the branch tab's undo history starts over. - `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be opened, but not merged). Each 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, which are saved in the file with the branch and stay with it when it is merged -or archived. Branches aren't available in a live session. +or archived. + +In a live session branches work the same way and stay with the person who made them (others +don't see them). A branch made in the session makes its ids in your own client space, far above +the ids the main file makes, so the session server takes them. Everyone's edits on main while the +branch is open are main's changes in the review (against the branch's own base), and conflicts +with them are resolved as offline. `branch.merge` is one edit of the session document: everyone +gets it, and it is one step of your own undo history. It is refused on a view link, and for a +branch made outside the session (or in an earlier one) whose new layers, styles, variables or +comments lie in another id space, which the server would refuse; merge those once the file has +left the session. While a session is still starting, branch commands that need your client id +wait for it. ### Collaboration changes