Files
figcraft/crates/engine/src/sharing/live/sync.rs
T

348 lines
15 KiB
Rust

use super::host::hosting_json;
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
use super::*;
impl Session {
/// 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<Option<u32>> {
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 {
self.live.doc(uid).is_some_and(Live::syncing)
}
/// Sync the edits of session document `uid` as one batch: one step of the user's undo history
/// called `label`, or not undoable (`None`: also what changed it outside a command). Nothing
/// while a gesture or preview shows, or inside a group (it syncs when the group ends).
pub(crate) fn live_edit(&mut self, uid: u64, label: Option<&str>) {
let Some(ds) = self.docs.iter().find(|d| d.uid == uid) else { return };
let Some(live) = self.live.doc(uid) else { return };
if live.hold > 0 || busy(ds) || live.synced.as_ref().is_some_and(|s| Arc::ptr_eq(s, &ds.doc)) {
return;
}
let (id, doc, synced) = (live.id, ds.doc.clone(), live.synced.clone());
if self.live_read_only(uid).is_some() {
// Nothing goes out: what changed outside a command is put back.
if let Some(s) = synced {
self.apply_remote(uid, s);
}
return;
}
let Some(live) = self.live.sessions.get_mut(&id) else { return };
let Some(client) = live.client.as_mut() else { return };
match client.edit_with(&doc, label) {
Ok(msg) => {
live.synced = Some(doc);
self.live.send(id, msg);
}
Err(e) => self.live_fail(id, format!("Couldn't sync an edit: {e}")),
}
}
/// A group of commands on document `uid` starts (`on`) or ends: in between its edits don't
/// sync on their own.
pub(crate) fn live_hold(&mut self, uid: u64, on: bool) {
if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) {
live.hold = if on { live.hold.saturating_add(1) } else { live.hold.saturating_sub(1) };
}
}
/// Undo (`back`) or redo the user's own last step on the active document, in its session:
/// what it wrote goes back where nobody else wrote since. Returns whether there was a step to
/// take back.
pub(crate) fn live_travel(&mut self, back: bool) -> bool {
let Some(uid) = self.tab() else { return false };
// Edits not synced yet go first.
self.live_edit(uid, None);
let Some(live) = self.live.doc_mut(uid) else { return false };
let Some(client) = live.client.as_mut() else { return false };
let (undo, redo) = client.history_len();
let msgs = match if back { client.undo(&EngineRecompute) } else { client.redo(&EngineRecompute) } {
Ok(m) => m,
Err(e) => {
log::warn!("live session: {} failed: {e}", if back { "undo" } else { "redo" });
return false;
}
};
let (u, r) = client.history_len();
let did = if back { r > redo } else { u > undo };
let doc = Arc::new(client.document().clone());
if did {
live.synced = Some(doc.clone());
}
let id = live.id;
self.live.send(id, msgs);
if did {
self.apply_remote(uid, doc);
}
did
}
/// Apply the server's batches that waited in session `id` (none while a gesture or preview
/// shows on its document).
pub(super) fn live_deferred(&mut self, id: SessionId) {
let Some(live) = self.live.sessions.get(&id).filter(|l| !l.deferred.is_empty() && l.syncing()) else { return };
let Some(uid) = live.doc_uid else { return };
if self.docs.iter().find(|d| d.uid == uid).is_none_or(busy) {
return;
}
// Your own edits first: the client replays them over what others did.
self.live_edit(uid, None);
let Some(live) = self.live.sessions.get_mut(&id) else { return };
let Some(client) = live.client.as_mut() else { return };
let mut out = Vec::new();
for m in std::mem::take(&mut live.deferred) {
match client.receive(m, &EngineRecompute) {
Ok(more) => out.extend(more),
Err(e) => return self.live_fail(id, format!("The live session got out of step ({e}); join again")),
}
}
let doc = Arc::new(client.document().clone());
// Edits the server refused were rolled back: say why (the UI's toast).
let notices: Vec<String> = client.take_notices().iter().map(figcraft_collab::Notice::message).collect();
live.synced = Some(doc.clone());
if !notices.is_empty() {
self.message = Some(notices.join("; "));
}
self.live.send(id, out);
self.apply_remote(uid, doc);
}
/// Show the peer followed in session `id`: their viewport (and their page, in the session's
/// document) on the session's tab.
pub(super) fn live_follow_apply(&mut self, id: SessionId) {
let Some(live) = self.live.sessions.get_mut(&id) else { return };
let Some(peer) = live.follow else { return };
if !live.peers.contains_key(&peer) {
live.unfollow();
return;
}
let Some((page, x, y, w, h)) = live.presence.get(&peer).and_then(|p| p.viewport) else { return };
let Some(ds) = self.docs.iter_mut().find(|d| Some(d.uid) == live.tab) else { return };
if live.syncing() && page != ds.page && ds.doc.pages.contains(&page) {
ds.page = page;
ds.selection.clear();
ds.text_edit = None;
}
if !(w > 0.0 && h > 0.0 && x.is_finite() && y.is_finite()) {
return;
}
ds.view.cx = x + w / 2.0;
ds.view.cy = y + h / 2.0;
let zoom = (ds.view.vw / w).min(ds.view.vh / h);
if zoom.is_finite() {
ds.view.zoom = zoom.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
}
ds.revision += 1;
live.followed = Some(FollowedView { page: ds.page, cx: ds.view.cx, cy: ds.view.cy, zoom: ds.view.zoom });
}
/// Sessions whose tab was closed end (hosts stop hosting, guests leave); the others go on. A
/// join still on its way from a closed tab shows on the one in front.
pub(super) fn live_closed(&mut self) {
let (docs, tab) = (&self.docs, self.tab());
let mut gone = Vec::new();
for l in self.live.sessions.values_mut().filter(|l| l.tab != tab && !docs.iter().any(|d| Some(d.uid) == l.tab)) {
if l.doc_uid.is_none() && l.status.active() {
l.tab = tab;
} else {
gone.push((l.id, l.status == Status::Live));
}
}
for (id, was_live) in gone {
self.live.end(id);
if was_live {
self.message = Some("The live session's file was closed, so you left the session".into());
}
}
}
/// Send your edits and presence in every session when they changed (headless sessions call
/// it after each command). A view you moved yourself stops following.
pub fn live_sync(&mut self) {
self.live_sync_in(|_| true);
}
/// [`live_sync`](Self::live_sync) for the sessions whose document (`None`: a join on its way)
/// `mine` picks: a window calls it every frame it draws, after input, for the documents it
/// holds.
pub fn live_sync_in(&mut self, mine: impl Fn(Option<u64>) -> bool) {
// A recovery copy whose session was left has its outcome too.
if !self.live.recovering.is_empty() {
self.online_recoveries();
}
if self.live.sessions.is_empty() {
return;
}
self.live_closed();
let ids: Vec<SessionId> = self.live.sessions.values().filter(|l| l.status == Status::Live && mine(l.doc_uid)).map(|l| l.id).collect();
for id in ids {
self.live_deferred(id);
if let Some(uid) = self.live.get(id).and_then(|l| l.doc_uid) {
self.live_edit(uid, None);
}
self.live_present(id);
}
}
/// Hand your presence in session `id` to the link when it changed.
fn live_present(&mut self, id: SessionId) {
let Some(live) = self.live.get(id).filter(|l| l.status == Status::Live) else { return };
let moved = live.followed.is_some_and(|f| {
self.docs
.iter()
.find(|d| Some(d.uid) == live.tab)
.is_some_and(|ds| ds.page != f.page || !close(ds.view.cx, f.cx) || !close(ds.view.cy, f.cy) || !close(ds.view.zoom, f.zoom))
});
let p = self.presence_in(live);
let changed = live.sent.as_ref() != Some(&p);
let Some(live) = self.live.sessions.get_mut(&id) else { return };
if moved {
live.unfollow();
}
if changed {
if let Some(link) = self.live.link.as_mut() {
link.presence(id, &p);
}
live.sent = Some(p);
}
}
/// Stop following in the active document's session (Escape, `collab.follow {}`). False when
/// you weren't following anyone.
pub fn live_unfollow(&mut self) -> bool {
self.live_mut().is_some_and(Live::unfollow)
}
/// Follow `peer`'s viewport in the active document's session (shown at once when known).
pub fn live_follow(&mut self, peer: u32) -> Result<()> {
let Some(live) = self.live_mut().filter(|l| l.peers.contains_key(&peer)) else {
return Err(bad("collab.follow", format!("nobody with client id {peer} is in the session")));
};
live.follow = Some(peer);
live.followed = None;
let id = live.id;
self.live_follow_apply(id);
Ok(())
}
/// The active document's session as JSON (`collab.peers`), with every session in brief.
pub fn live_summary(&self) -> Value {
self.summary(self.live())
}
/// The index of the open document session `l` syncs.
fn live_index(&self, l: &Live) -> Option<usize> {
l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u))
}
pub(super) fn summary(&self, l: &Live) -> Value {
let info = l.info.as_ref();
let reason = |l: &Live| match &l.status {
Status::Failed { reason } => Some(reason.clone()),
_ => None,
};
let synced = self.live_index(l).and_then(|i| self.docs.get(i));
// Who types in which layer of the session document.
let editing = |e: &EditingText| {
let layer = synced.and_then(|d| d.doc.node(e.node)).map(|n| n.name.as_str());
json!({"node": e.node, "layer": layer, "caret": e.caret, "selection": e.selection})
};
let peers: Vec<Value> = l
.peers
.values()
.map(|p| {
let presence = l.presence.get(&p.client_id);
let typing = presence.and_then(|pr| pr.editing_text.as_deref()).map(editing);
let chat = l.chats.get(&p.client_id);
json!({"id": p.client_id, "name": p.name, "color": p.color, "editing": typing, "chat": chat, "presence": presence})
})
.collect();
let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t)));
let sessions: Vec<Value> = self
.live
.iter()
.map(|o| {
let info = o.info.as_ref();
json!({
"session": o.id,
"document": self.live_index(o),
"status": o.status.id(),
"reason": reason(o),
"role": o.role(),
"fileName": info.map(|i| i.file_name.as_str()),
"link": info.map(|i| i.link.as_str()),
"peers": o.peers.len(),
})
})
.collect();
json!({
"available": self.live.available(),
"canHost": self.live.can_host(),
"session": (l.id != NO_SESSION).then_some(l.id),
"status": l.status.id(),
"reason": reason(l),
"attempt": match l.status { Status::Reconnecting { attempt } => Some(attempt), _ => None },
"role": info.map(|i| i.role),
"me": info.map(|i| i.me),
"name": info.map(|i| i.name.as_str()),
"color": info.map(|i| i.color.as_str()),
"fileName": info.map(|i| i.file_name.as_str()),
"pages": info.map(|i| &i.pages),
"link": info.map(|i| i.link.as_str()),
"url": info.map(|i| i.url.as_str()),
"address": info.map(|i| i.address.as_str()),
"network": info.map(|i| i.network),
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
"readOnly": l.doc_uid.is_some_and(|u| self.live_read_only(u).is_some()),
"published": l.published,
"code": l.share.as_ref().and_then(Share::code),
"share": l.share.as_ref().map(|sh| {
let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref()));
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
json!({
"via": sh.via,
"access": sh.access.id(),
"shared": sh.links.is_some(),
"editCode": code(edit),
"viewCode": code(view),
"editLink": edit.map(|l| l.link.as_str()),
"viewLink": view.map(|l| l.link.as_str()),
})
}),
"hosting": l.hosting.as_ref().map(hosting_json),
"follow": l.follow,
"signals": l.signals,
"chat": l.chat,
"spotlight": l.spotlight,
"spotlighting": l.spotlighting,
"syncing": l.syncing(),
"document": self.live_index(l),
"editing": mine,
"peers": peers,
"sessions": sessions,
})
}
}