Files
archify-vscode-ext-sync/crates/server/src/doc.rs
T
Oleg Lukasonok 99778d7489 Archify Sync server, Dockerfile and Helm chart
Rust server (axum, yrs, sqlx/PostgreSQL): live shared models over the y-websocket protocol, versions, browser sign-in with PKCE, invites, single sign-on through OIDC, metrics. Docker image on distroless, compose file, and a Helm chart with one pod, optional bundled PostgreSQL and secrets from existing Secrets.
2026-11-06 12:06:59 +02:00

161 lines
5.1 KiB
Rust

//! 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<Doc> {
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<Doc> {
let doc = new_doc();
doc.transact_mut().apply_update(Update::decode_v1(state)?)?;
Ok(doc)
}
pub fn encode_state(doc: &Doc) -> Vec<u8> {
doc.transact()
.encode_state_as_update_v1(&StateVector::default())
}
/// Path → text of every model file.
pub fn files(doc: &Doc) -> BTreeMap<String, String> {
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<String, String>) -> Vec<u8> {
let map = doc.get_or_insert_map(FILES_MAP);
let mut txn = doc.transact_mut();
let current: Vec<String> = 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<String, String> {
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"
);
}
}