Files
figcraft/crates/engine/src/collab.rs
T
BFlatandClaude Opus 5.5 94ff3d4df6 M9: collaboration: per-user undo, rejections, reconnect (#105)
* M9: collaboration: per-user undo, rejections, reconnect

Step 3 of docs/collab-protocol.md, the parts that don't need the transport:

- Per-user undo and redo (figcraft_collab::Client::{undo, redo}): each undoable batch is a step
  (values before/after by target, nodes created/deleted). Others' batches ordered after the step
  take its targets for good; creates invert to deletes (not when others built inside), deletes
  re-create the subtree. An undo or redo waits for every earlier batch and is remade from the
  confirmed state when it goes out; derived data follows as a follow-up batch.
- Rejections: refused batches leave the history (with an undo waiting on them) and queue a
  Notice; the engine shows it through Session::message (the UI's toast).
- Offline queue and reconnect: Hello.resume {session, client_id, seq}; the server keeps a
  bounded log and answers with the missed batches or a snapshot, plus what it did with the
  client's batches (processed {last_batch, refused}); queued batches then go out.
  Server::state / restore persist the session (client-id counter, log, session id).
- Presence carries editing_text {node, caret, selection}.
- Engine: documents in a live session (LiveDoc, Session::collab_*): commands, gestures and
  groups sync as one step each; edit.undo / edit.redo route to the per-user history; remote
  edits show under a gesture in progress. Outside a session snapshot undo is unchanged.
- Sim: dropped connections, undo/redo, and a checker that no undo reverts a change by someone
  else its author had seen. New tests in undo_tests, reconnect_tests, collab_live_tests,
  collab_undo_tests.
- CLAUDE.md / AGENTS.md: collab in the L2 layer list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: collaboration wording in the progress docs after per-user undo and reconnect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M9: protocol doc: what the engine's live-session tests cover

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:19:24 +07:00

53 lines
2.2 KiB
Rust

//! The engine's side of real-time collaboration (`figcraft_collab`): its normalize pass as the
//! recompute hook, and `collab.diff`, which shows the change batch an edit syncs as.
use std::collections::BTreeSet;
use std::sync::Arc;
use figcraft_collab::{Recompute, Replica, diff, message};
use figcraft_doc::{Document, NodeId};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::string;
use crate::{EngineError, Result, Session, bad, normalize, spec};
/// Derived data follows the engine's normalize pass: text boxes, groups, instances following
/// their mains, variable bindings and auto layout, recomputed over the touched nodes.
pub struct EngineRecompute;
impl Recompute for EngineRecompute {
fn recompute(&self, doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
doc.mark_dirty(touched.iter().copied());
normalize(doc, before);
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"collab.diff",
"Show sync changes",
"{against? (undo: the last undoable action, the default | saved: since the last save)} → the changes a live session would send, their count and binary size",
false,
diff_cmd
)]
}
fn diff_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ds = s.ds()?;
let from: Arc<Document> = match string(p, "against").as_deref().unwrap_or("undo") {
"undo" if s.live_doc(ds.uid) => {
return Err(EngineError::Disabled("collab.diff".into(), "in a live session undo is per user: compare against \"saved\"".into()));
}
"undo" => {
ds.history.undo.last().map(|e| e.doc.clone()).ok_or_else(|| EngineError::Disabled("collab.diff".into(), "nothing to undo".into()))?
}
"saved" => ds.saved_doc.clone(),
other => return Err(bad("collab.diff", format!("`against` is \"undo\" or \"saved\", not {other:?}"))),
};
let base = Replica::new((*from).clone());
let d = diff(&base, &ds.doc).map_err(|e| EngineError::Other(e.to_string()))?;
let bytes = message::encode(&d.changes).map_err(|e| EngineError::Other(e.to_string()))?.len();
Ok(json!({"changes": d.changes, "count": d.changes.len(), "bytes": bytes}))
}