mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
* perf(collab): memoise layer depths in diff and skip the sibling scan for a new child * address review: bound the depth walk by the document's node count
230 lines
8.8 KiB
Rust
230 lines
8.8 KiB
Rust
//! Diffing: the changes between a replica's document and an edited version of it.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use figcraft_doc::{Document, NodeId};
|
|
|
|
use crate::change::{Change, Json, Prop};
|
|
use crate::frac::{self, FIRST_KEY, KEY_RESPACE_LEN};
|
|
use crate::replica::{Replica, create_change};
|
|
use crate::{Error, docprops};
|
|
|
|
/// The result of [`diff`]: the changes, and the replica of the edited document (with the position
|
|
/// keys the changes give it).
|
|
#[derive(Clone, Debug)]
|
|
pub struct Diff {
|
|
/// One batch: field writes, creations (parents first), moves, deletions, then document-level
|
|
/// changes. Empty when nothing synced changed.
|
|
pub changes: Vec<Change>,
|
|
/// The edited document as a replica: what applying `changes` to the original gives.
|
|
pub replica: Replica,
|
|
}
|
|
|
|
/// The changes that turn `from`'s document into `to` (an edited version of it, e.g. after a
|
|
/// command), at property granularity. Only nodes whose `Arc` differs from `from`'s are looked at,
|
|
/// so the cost follows the size of the edit, not of the document. Children that were added, moved
|
|
/// or reordered get new position keys; their siblings keep theirs.
|
|
pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
|
|
let a = from.document();
|
|
let ids = to.changed_ids(a, usize::MAX).unwrap_or_default();
|
|
let mut positions = from.positions().clone();
|
|
let (mut fields, mut added, mut removed) = (Vec::new(), Vec::new(), BTreeSet::new());
|
|
// Parents whose children need keys (`None`: the page list).
|
|
let mut parents: BTreeSet<Option<NodeId>> = BTreeSet::new();
|
|
let mut moved: BTreeSet<NodeId> = BTreeSet::new();
|
|
for id in ids {
|
|
match (a.node(id), to.node(id)) {
|
|
(None, Some(n)) => {
|
|
added.push(id);
|
|
parents.insert(n.parent);
|
|
}
|
|
(Some(_), None) => {
|
|
removed.insert(id);
|
|
}
|
|
(Some(o), Some(n)) => {
|
|
for p in Prop::FIELDS {
|
|
if p.differs(o, n) {
|
|
fields.push(Change::Set { id, prop: *p, value: p.get(n)? });
|
|
}
|
|
}
|
|
if o.parent != n.parent {
|
|
parents.insert(n.parent);
|
|
moved.insert(id);
|
|
}
|
|
if o.children != n.children {
|
|
parents.insert(Some(id));
|
|
}
|
|
}
|
|
(None, None) => {}
|
|
}
|
|
}
|
|
if a.pages != to.pages {
|
|
parents.insert(None);
|
|
}
|
|
let mut rekeyed: BTreeMap<NodeId, String> = BTreeMap::new();
|
|
for p in parents {
|
|
let kids = match p {
|
|
None => &to.pages,
|
|
Some(p) => match to.node(p) {
|
|
Some(n) => &n.children,
|
|
None => continue,
|
|
},
|
|
};
|
|
// A child keeps its key only if it was under the same parent before.
|
|
let old = |c: NodeId| a.node(c).filter(|n| n.parent == p).and_then(|_| from.positions().get(&c)).map(String::as_str);
|
|
for (c, k) in keys_for(kids, old)? {
|
|
positions.insert(c, k.clone());
|
|
rekeyed.insert(c, k);
|
|
}
|
|
}
|
|
// Unlisted pages (the library store) still need a key.
|
|
for id in &added {
|
|
if !rekeyed.contains_key(id) && to.node(*id).is_some_and(|n| n.parent.is_none()) {
|
|
positions.insert(*id, FIRST_KEY.to_string());
|
|
}
|
|
}
|
|
let replica = {
|
|
let mut doc = to.clone();
|
|
doc.published = None;
|
|
doc.take_dirty();
|
|
for r in &removed {
|
|
positions.remove(r);
|
|
}
|
|
Replica::from_parts(doc, positions)
|
|
};
|
|
let mut changes = fields;
|
|
let new: BTreeSet<NodeId> = added.iter().copied().collect();
|
|
// Creations, parents before children.
|
|
let mut memo = BTreeMap::new();
|
|
added.sort_by_cached_key(|id| (depth(to, *id, &mut memo), *id));
|
|
for id in &added {
|
|
if let Some(n) = to.node(*id) {
|
|
changes.push(create_change(n, replica.key(*id))?);
|
|
}
|
|
}
|
|
for (c, k) in &rekeyed {
|
|
if new.contains(c) {
|
|
continue;
|
|
}
|
|
if moved.contains(c) {
|
|
let parent = to.node(*c).and_then(|n| n.parent);
|
|
changes.push(Change::Set { id: *c, prop: Prop::Parent, value: Json::of(&parent)? });
|
|
}
|
|
changes.push(Change::Set { id: *c, prop: Prop::Position, value: Json::of(k)? });
|
|
}
|
|
// Deletions last (a layer moved out of a deleted frame has moved by then); one per subtree.
|
|
for id in &removed {
|
|
if a.node(*id).and_then(|n| n.parent).is_none_or(|p| !removed.contains(&p)) {
|
|
changes.push(Change::Delete { id: *id });
|
|
}
|
|
}
|
|
docprops::diff(a, to, &mut changes)?;
|
|
Ok(Diff { changes, replica })
|
|
}
|
|
|
|
/// Depth of `id` in `doc` (pages are 0). `memo` holds the depths found so far, so a deep chain of
|
|
/// new layers is walked once rather than once per layer.
|
|
fn depth(doc: &Document, id: NodeId, memo: &mut BTreeMap<NodeId, usize>) -> usize {
|
|
let mut chain = vec![id];
|
|
let mut base = 0;
|
|
let mut cur = doc.node(id).and_then(|n| n.parent);
|
|
while let Some(p) = cur {
|
|
if let Some(d) = memo.get(&p) {
|
|
base = *d + 1;
|
|
break;
|
|
}
|
|
// An acyclic chain is no longer than the document: this only stops a cycle.
|
|
if chain.len() > doc.node_count() {
|
|
break;
|
|
}
|
|
chain.push(p);
|
|
cur = doc.node(p).and_then(|n| n.parent);
|
|
}
|
|
// `chain` runs from `id` up; the last entry's depth is `base` when it ends at a root or a memo hit.
|
|
let top = chain.len();
|
|
for (i, c) in chain.iter().enumerate() {
|
|
memo.insert(*c, base + (top - 1 - i));
|
|
}
|
|
memo.get(&id).copied().unwrap_or(0)
|
|
}
|
|
|
|
/// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the
|
|
/// longest run of old keys that is still increasing is kept, the rest get keys between their kept
|
|
/// neighbours. Falls back to re-spacing every child when keys would get long.
|
|
fn keys_for<'a>(kids: &[NodeId], old: impl Fn(NodeId) -> Option<&'a str>) -> Result<Vec<(NodeId, String)>, Error> {
|
|
let olds: Vec<Option<&str>> = kids.iter().map(|c| old(*c)).collect();
|
|
let kept = increasing_run(&olds);
|
|
let mut out = Vec::new();
|
|
let mut i = 0;
|
|
let mut prev: Option<&str> = None;
|
|
while i < kids.len() {
|
|
if kept.get(i).copied().unwrap_or(false) {
|
|
prev = olds.get(i).copied().flatten();
|
|
i += 1;
|
|
continue;
|
|
}
|
|
let start = i;
|
|
while i < kids.len() && !kept.get(i).copied().unwrap_or(false) {
|
|
i += 1;
|
|
}
|
|
let next = if i < kids.len() { olds.get(i).copied().flatten() } else { None };
|
|
let keys = frac::n_between(prev, next, i - start).map_err(|e| Error::Invalid(e.to_string()))?;
|
|
if keys.iter().any(|k| k.len() > KEY_RESPACE_LEN) {
|
|
return respace(kids, &olds);
|
|
}
|
|
out.extend(kids.get(start..i).unwrap_or_default().iter().copied().zip(keys));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Fresh, short keys for every child (those whose key changes).
|
|
fn respace(kids: &[NodeId], olds: &[Option<&str>]) -> Result<Vec<(NodeId, String)>, Error> {
|
|
let keys = frac::n_between(None, None, kids.len()).map_err(|e| Error::Invalid(e.to_string()))?;
|
|
Ok(kids.iter().zip(olds).zip(keys).filter(|((_, o), k)| **o != Some(k.as_str())).map(|((c, _), k)| (*c, k)).collect())
|
|
}
|
|
|
|
/// Which entries form a longest strictly increasing subsequence of the present keys.
|
|
fn increasing_run(keys: &[Option<&str>]) -> Vec<bool> {
|
|
// Patience sorting: `tails[l]` is the index ending the best run of length l + 1.
|
|
let mut tails: Vec<usize> = Vec::new();
|
|
let mut prev: Vec<Option<usize>> = vec![None; keys.len()];
|
|
for (i, k) in keys.iter().enumerate() {
|
|
let Some(k) = k else { continue };
|
|
let at = tails.partition_point(|t| keys.get(*t).copied().flatten().is_some_and(|tk| tk < *k));
|
|
if let Some(p) = prev.get_mut(i) {
|
|
*p = at.checked_sub(1).and_then(|b| tails.get(b)).copied();
|
|
}
|
|
match tails.get_mut(at) {
|
|
Some(t) => *t = i,
|
|
None => tails.push(i),
|
|
}
|
|
}
|
|
let mut kept = vec![false; keys.len()];
|
|
let mut cur = tails.last().copied();
|
|
while let Some(i) = cur {
|
|
if let Some(k) = kept.get_mut(i) {
|
|
*k = true;
|
|
}
|
|
cur = prev.get(i).copied().flatten();
|
|
}
|
|
kept
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod depth_tests {
|
|
use super::*;
|
|
use figcraft_doc::NodeKind;
|
|
|
|
#[test]
|
|
fn a_chain_longer_than_a_fixed_bound_is_walked_whole() {
|
|
let mut doc = Document::new("File");
|
|
let mut parent = doc.first_page().unwrap();
|
|
let mut last = parent;
|
|
for i in 0..100_050 {
|
|
last = doc.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 1.0, 1.0).unwrap();
|
|
parent = last;
|
|
}
|
|
assert_eq!(depth(&doc, last, &mut BTreeMap::new()), 100_050);
|
|
}
|
|
}
|