//! A space's Yjs document: one `Y.Text` per model file in the `files` map. use std::collections::BTreeMap; use archify_sync_protocol::FILES_MAP; use archify_sync_store::StoredDoc; use yrs::updates::decoder::Decode; use yrs::{ Doc, GetString, Map, OffsetKind, Options, Out, ReadTxn, StateVector, Text, TextPrelim, Transact, Update, }; /// A document that counts text offsets in UTF-16, as Yjs in the extension does. pub fn new_doc() -> Doc { Doc::with_options(Options { offset_kind: OffsetKind::Utf16, ..Options::default() }) } pub fn load(stored: &StoredDoc) -> anyhow::Result { let doc = new_doc(); { let mut txn = doc.transact_mut(); if let Some(state) = &stored.state { txn.apply_update(Update::decode_v1(state)?)?; } for update in &stored.updates { txn.apply_update(Update::decode_v1(update)?)?; } } Ok(doc) } pub fn from_state(state: &[u8]) -> anyhow::Result { let doc = new_doc(); doc.transact_mut().apply_update(Update::decode_v1(state)?)?; Ok(doc) } pub fn encode_state(doc: &Doc) -> Vec { doc.transact() .encode_state_as_update_v1(&StateVector::default()) } /// Path → text of every model file. pub fn files(doc: &Doc) -> BTreeMap { let map = doc.get_or_insert_map(FILES_MAP); let txn = doc.transact(); map.iter(&txn) .filter_map(|(path, value)| match value { Out::YText(text) => Some((path.to_owned(), text.get_string(&txn))), _ => None, }) .collect() } /// Makes `doc` hold the files of `target` as ordinary edits, so every copy /// takes them like any other change. Returns the update, empty when nothing changed. pub fn replace_files(doc: &Doc, target: &BTreeMap) -> Vec { let map = doc.get_or_insert_map(FILES_MAP); let mut txn = doc.transact_mut(); let current: Vec = map.keys(&txn).map(str::to_owned).collect(); for path in current.iter().filter(|p| !target.contains_key(*p)) { map.remove(&mut txn, path); } for (path, text) in target { match map.get(&txn, path) { Some(Out::YText(existing)) => { let old = existing.get_string(&txn); if let Some((at, remove, insert)) = minimal_change(&old, text) { if remove > 0 { existing.remove_range(&mut txn, at, remove); } if !insert.is_empty() { existing.insert(&mut txn, at, insert); } } } _ => { map.insert(&mut txn, path.as_str(), TextPrelim::new(text.as_str())); } } } txn.encode_update_v1() } /// The one edit that turns `old` into `new`: UTF-16 offset, UTF-16 length to /// remove, text to insert. Keeps the common start and end, as the extension's /// `minimalChange` does, and never splits a character. pub fn minimal_change<'a>(old: &str, new: &'a str) -> Option<(u32, u32, &'a str)> { if old == new { return None; } let prefix: usize = old .chars() .zip(new.chars()) .take_while(|(a, b)| a == b) .map(|(a, _)| a.len_utf8()) .sum(); let (old_rest, new_rest) = (&old[prefix..], &new[prefix..]); let suffix: usize = old_rest .chars() .rev() .zip(new_rest.chars().rev()) .take_while(|(a, b)| a == b) .map(|(a, _)| a.len_utf8()) .sum(); let removed = &old_rest[..old_rest.len() - suffix]; let inserted = &new_rest[..new_rest.len() - suffix]; let utf16 = |s: &str| s.encode_utf16().count() as u32; Some((utf16(&old[..prefix]), utf16(removed), inserted)) } #[cfg(test)] mod tests { use super::*; fn texts(pairs: &[(&str, &str)]) -> BTreeMap { pairs .iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect() } #[test] fn minimal_change_keeps_common_ends() { assert_eq!( minimal_change("label: API", "label: Gateway API"), Some((7, 0, "Gateway ")) ); assert_eq!(minimal_change("abc", "abc"), None); assert_eq!(minimal_change("a😀b", "a😀cb"), Some((3, 0, "c"))); assert_eq!(minimal_change("x😀y", "x😃y"), Some((1, 2, "😃"))); } #[test] fn replace_files_reaches_target_and_syncs() { let doc = new_doc(); replace_files( &doc, &texts(&[("main.archify", "model: a\n"), ("old.archify", "x")]), ); let other = from_state(&encode_state(&doc)).unwrap(); let target = texts(&[("main.archify", "model: b\n"), ("new.archify", "y")]); let update = replace_files(&doc, &target); assert_eq!(files(&doc), target); other .transact_mut() .apply_update(Update::decode_v1(&update).unwrap()) .unwrap(); assert_eq!(files(&other), target); assert!( replace_files(&doc, &target).len() <= 2, "no change gives an empty update" ); } }