Skip to main content

tilo_app/framework/
workspace.rs

1//! The records every app of the window's workspace opens beside its own
2//! ([`ui_core::record::workspace`]): the workspace's emoji among them,
3//! which any view of any app draws for `:name:`, and a conversation, which
4//! the two people it is between open from either side.
5//!
6//! Such a record is its owner's resource: the workspace's grants on the owner's
7//! node decide every change, here at the door as on the relay, whichever
8//! app's window makes it. The window opens it on first use, follows it
9//! change by change, and reports its route so the runtime syncs it; only
10//! the owner's own window publishes it (and lists it in its surface).
11
12use std::any::Any;
13use std::cell::RefCell;
14use std::collections::HashMap;
15use std::rc::Rc;
16
17use tilo_actions::{Action as _, ActionContext, ActionError, ActionInvoker, ChangeKind};
18use tilo_resource::{Invoke, Resource};
19use ui_core::LocalState;
20use ui_core::history_mode::HistoryOwner;
21use ui_core::permissions::{LivePermissions, PermissionScope, TreePath};
22use ui_core::record::StoredRecord;
23use ui_core::record::workspace::WorkspaceRecords;
24use ui_core::wake::WakeHandle;
25
26use super::live::Live;
27
28use super::chain::{Chain, Chained};
29
30/// A record every app of the window's workspace opens, held for the window
31/// ([`RecordEnv::workspace`](super::RecordEnv::workspace)).
32pub struct WorkspaceRecord<P: Resource> {
33    shared: Rc<Shared<P>>,
34}
35
36impl<P: Resource> Clone for WorkspaceRecord<P> {
37    fn clone(&self) -> Self {
38        Self {
39            shared: Rc::clone(&self.shared),
40        }
41    }
42}
43
44struct Shared<P: Resource> {
45    record: StoredRecord,
46    /// The app that keeps it: whose node the workspace's grants decide on.
47    owner: &'static str,
48    app_id: &'static str,
49    app_title: &'static str,
50    me: String,
51    permissions: Option<LivePermissions>,
52    /// The state, followed change by change.
53    live: Live<P>,
54    /// The same state as view state: a view that read it is built again
55    /// when another app's or device's change arrives.
56    state: LocalState<Rc<P::State>>,
57    /// Wakes the window: for an action that waits for the record.
58    wake: Option<WakeHandle>,
59    /// Actions asked for while the record could not be changed (its store
60    /// in use by whatever runs now), applied in order once it can.
61    waiting: RefCell<Vec<Waiting<P>>>,
62}
63
64/// An action that waits for its record ([`WorkspaceRecord::invoke_then`]),
65/// with what to do once it was applied or refused.
66struct Waiting<P: Resource> {
67    action: P::Action,
68    then: Box<dyn FnOnce(Result<(), ActionError>)>,
69    /// The window's refreshes it asked for: a few, so a record held
70    /// throughout them waits for the window's next refresh of its own
71    /// instead of keeping the window busy.
72    woken: u8,
73}
74
75/// How many refreshes an action that waits for its record asks for.
76const WAITING_WAKES: u8 = 4;
77
78/// What became of an action applied to the record.
79enum Applied {
80    Done(Result<(), ActionError>),
81    /// The record cannot be changed right now; nothing ran.
82    Waits,
83}
84
85impl<P: Resource> Shared<P> {
86    /// Follow the record when it moved. Whether it did.
87    fn refresh(&self) -> bool {
88        self.apply_waiting();
89        let moved = match self.record.read(|doc| self.live.sync(doc)) {
90            Some(Ok(moved)) => moved,
91            Some(Err(error)) => {
92                tracing::warn!(kind = P::KIND, %error, "workspace record unreadable");
93                false
94            }
95            None => false,
96        };
97        let _ = self.live.take_change();
98        if moved {
99            self.state.set(self.live.get());
100        }
101        moved
102    }
103
104    /// Apply the actions that wait for the record, in order, while it can
105    /// be changed: what still cannot waits on, asking the window for a
106    /// refresh a few times.
107    fn apply_waiting(&self) {
108        let Ok(mut waiting) = self.waiting.try_borrow_mut() else {
109            return;
110        };
111        if waiting.is_empty() {
112            return;
113        }
114        let queue = std::mem::take(&mut *waiting);
115        drop(waiting);
116        let mut left = Vec::new();
117        for entry in queue {
118            // Order is kept: once one waits, the ones after it wait too.
119            if !left.is_empty() {
120                left.push(entry);
121                continue;
122            }
123            match self.apply(&entry.action) {
124                Applied::Done(result) => (entry.then)(result),
125                Applied::Waits => left.push(entry),
126            }
127        }
128        let Some(first) = left.first_mut() else {
129            return;
130        };
131        let wake = first.woken < WAITING_WAKES;
132        if wake {
133            first.woken = first.woken.saturating_add(1);
134        }
135        let mut waiting = self.waiting.borrow_mut();
136        left.append(&mut waiting);
137        *waiting = left;
138        drop(waiting);
139        if wake && let Some(window) = &self.wake {
140            window.wake();
141        }
142    }
143
144    fn apply(&self, action: &P::Action) -> Applied {
145        let encoded = match tilo_resource::encode::<P>(action) {
146            Ok(encoded) => encoded,
147            Err(error) => return Applied::Done(Err(error)),
148        };
149        let change = action.change_kind();
150        let context = ActionContext::for_resource(P::KIND, P::KIND, P::LABEL)
151            .with_app(self.app_id, self.app_title)
152            .with_invoker(ActionInvoker::actor(&self.me))
153            .with_change(change);
154        let edited = self.record.try_edit(change == ChangeKind::Content, |doc| {
155            let writer = ui_core::history_source::authors::device_actor(&self.me, doc.get_actor());
156            tilo_actions::apply_local_with_context(doc, action, encoded, Some(&writer), context)
157                .map(|_| ())
158                .map_err(|error| error.to_string())
159        });
160        match edited {
161            Ok(result) => Applied::Done(result.map_err(ActionError::Refused)),
162            Err(unavailable) if unavailable.is_momentary() => Applied::Waits,
163            Err(unavailable) => Applied::Done(Err(ActionError::Refused(unavailable.to_string()))),
164        }
165    }
166}
167
168impl<P: Resource> WorkspaceRecord<P> {
169    /// The record `P` that `owner` keeps in the workspace `records` opens,
170    /// created with the resource's genesis where nothing holds it yet and
171    /// brought to its version: what an app window holds
172    /// ([`RecordEnv::workspace`](super::RecordEnv::workspace)), and what a
173    /// host that is no app window (the session's workspace settings) opens
174    /// the same way. Changes are attributed to `app` (its id and title) and,
175    /// where the host knows the workspace's grants as `permissions`, decided by
176    /// them first; `wake` is woken when another replica's change arrives.
177    pub fn open(
178        records: &WorkspaceRecords,
179        owner: &'static str,
180        app: (&'static str, &'static str),
181        permissions: Option<LivePermissions>,
182        wake: Option<WakeHandle>,
183    ) -> Option<Self> {
184        let opened = records.open_record(owner, P::KIND, &|doc| {
185            P::initialize(doc).map_err(|error| error.to_string())
186        });
187        Self::hold(
188            opened,
189            owner,
190            app,
191            permissions,
192            wake,
193            records.author().0.clone(),
194        )
195    }
196
197    /// [`Self::open`] of the record `id` of `kind` `P` that `owner` keeps:
198    /// a record of the owner's that the workspace names otherwise than
199    /// [`Self::open`]'s, such as a conversation's, which the two people it
200    /// is between name. The workspace's grants decide its changes like any
201    /// other.
202    pub fn open_at(
203        records: &WorkspaceRecords,
204        owner: &'static str,
205        id: &str,
206        app: (&'static str, &'static str),
207        permissions: Option<LivePermissions>,
208        wake: Option<WakeHandle>,
209    ) -> Option<Self> {
210        let opened = records.open_record_at(owner, id, P::KIND, &|doc| {
211            P::initialize(doc).map_err(|error| error.to_string())
212        });
213        Self::hold(
214            opened,
215            owner,
216            app,
217            permissions,
218            wake,
219            records.author().0.clone(),
220        )
221    }
222
223    /// [`Self::open_at`] of the record `owner` keeps as its own primary one of
224    /// `P::KIND` in the workspace: the record an app keeps as itself (Chat's
225    /// of its chat, `chat.workspace`), which `open_record` cannot open because
226    /// the package's primary id is a sentinel no [`RecordId::parse`] accepts.
227    /// An app's own window opens it this way; so does the session, reading the
228    /// conversations its agents answer in.
229    pub fn open_primary(
230        records: &WorkspaceRecords,
231        owner: &'static str,
232        app: (&'static str, &'static str),
233        permissions: Option<LivePermissions>,
234        wake: Option<WakeHandle>,
235    ) -> Option<Self> {
236        let opened = records.open_primary(owner, P::KIND, &|doc| {
237            P::initialize(doc).map_err(|error| error.to_string())
238        });
239        Self::hold(
240            opened,
241            owner,
242            app,
243            permissions,
244            wake,
245            records.author().0.clone(),
246        )
247    }
248
249    /// [`Self::open`]'s record, opened (or not) by the store it is kept in:
250    /// brought to the resource's version, read, and followed change by
251    /// change.
252    fn hold(
253        opened: Result<StoredRecord, ui_core::record::RecordOpenError>,
254        owner: &'static str,
255        app: (&'static str, &'static str),
256        permissions: Option<LivePermissions>,
257        wake: Option<WakeHandle>,
258        me: String,
259    ) -> Option<Self> {
260        let record = opened
261            .map_err(|error| tracing::warn!(kind = P::KIND, %error, "workspace record unavailable"))
262            .ok()?;
263        if record.read(tilo_resource::evolution::needs_migrate::<P>)?
264            && let Err(error) = record.edit(false, |doc| {
265                tilo_resource::evolution::migrate::<P>(doc).map_err(|error| error.to_string())
266            })
267        {
268            tracing::warn!(kind = P::KIND, %error, "workspace record not migrated");
269        }
270        let reader = record
271            .read(tilo_resource::Reader::<P>::read)?
272            .map_err(|error| tracing::warn!(kind = P::KIND, %error, "workspace record unreadable"))
273            .ok()?;
274        if let Some(wake) = &wake {
275            record.wake_on_arrival(wake.clone());
276        }
277        let live = Live::new(reader);
278        Some(Self {
279            shared: Rc::new(Shared::<P> {
280                record,
281                owner,
282                app_id: app.0,
283                app_title: app.1,
284                me,
285                permissions,
286                state: LocalState::new(live.get()),
287                live,
288                wake,
289                waiting: RefCell::default(),
290            }),
291        })
292    }
293
294    /// The record's id, which names it in the workspace.
295    #[must_use]
296    pub fn id(&self) -> &ui_core::record::RecordId {
297        &self.shared.record.checkout().id
298    }
299
300    /// The record's state as the window follows it, for the chain kept
301    /// beside it ([`RecordEnv::chain_beside`](super::RecordEnv::chain_beside)).
302    pub(crate) fn live(&self) -> Live<P> {
303        self.shared.live.clone()
304    }
305
306    /// The current state.
307    pub fn get(&self) -> Rc<P::State> {
308        self.shared.refresh();
309        self.shared.state.with(Rc::clone)
310    }
311
312    /// Increases whenever the state changes.
313    pub fn revision(&self) -> u64 {
314        self.shared.refresh();
315        self.shared.live.revision()
316    }
317
318    /// The person who acts here.
319    pub fn me(&self) -> &str {
320        &self.shared.me
321    }
322
323    /// Whether the workspace's grants let the person invoke the verb `verb` of
324    /// the record, as its owner publishes it. A window that knows nothing
325    /// of the workspace's grants (a test host) lets the record decide.
326    pub fn may(&self, verb: &str) -> bool {
327        let Some(permissions) = &self.shared.permissions else {
328            return true;
329        };
330        let Some(declared) = P::declarations().into_iter().find(|decl| decl.id == verb) else {
331            return false;
332        };
333        permissions.check(&PermissionScope::Action {
334            node: tilo_actions::action_node(
335                &TreePath::root(),
336                self.shared.owner,
337                P::KIND,
338                self.shared.record.checkout().id.as_str(),
339                declared.field,
340                verb,
341            ),
342            kind: declared.kind,
343        })
344    }
345
346    /// Apply the action `invoke` names: decided by the workspace's grants on the
347    /// owner's node, then one change of the record, attributed to the
348    /// person, in its history and one undo step of theirs.
349    ///
350    /// # Errors
351    /// The arguments do not build the action, the grants refuse it, the
352    /// record does, or the record cannot be changed right now (for that,
353    /// [`Self::invoke_then`] waits).
354    pub fn invoke(&self, invoke: &Invoke<P>) -> Result<(), ActionError> {
355        let action = self.admit(invoke)?;
356        match self.shared.apply(&action) {
357            Applied::Done(result) => {
358                self.shared.refresh();
359                result
360            }
361            Applied::Waits => Err(ActionError::Refused(
362                ui_core::record::HistoryUnavailable::StoreInUse.to_string(),
363            )),
364        }
365    }
366
367    /// [`Self::invoke`], and `then` with what became of it: at once, or,
368    /// where the record cannot be changed right now (its store in use by
369    /// whatever runs as this is asked), once it can, on a refresh of the
370    /// window. Actions that wait are applied in the order they were asked
371    /// for, after the ones that waited before them.
372    pub fn invoke_then(
373        &self,
374        invoke: &Invoke<P>,
375        then: impl FnOnce(Result<(), ActionError>) + 'static,
376    ) {
377        let action = match self.admit(invoke) {
378            Ok(action) => action,
379            Err(error) => return then(Err(error)),
380        };
381        let shared = &self.shared;
382        let first = shared
383            .waiting
384            .try_borrow()
385            .is_ok_and(|waiting| waiting.is_empty());
386        if first && let Applied::Done(result) = shared.apply(&action) {
387            shared.refresh();
388            return then(result);
389        }
390        shared.waiting.borrow_mut().push(Waiting {
391            action,
392            then: Box::new(then),
393            woken: 0,
394        });
395        if let Some(window) = &shared.wake {
396            window.wake();
397        }
398    }
399
400    /// The action `invoke` names, once the workspace's grants admit it.
401    fn admit(&self, invoke: &Invoke<P>) -> Result<P::Action, ActionError> {
402        let action = invoke
403            .build(tilo_resource::origin_now_ms())
404            .map_err(ActionError::Refused)?;
405        if let Some(verb) = verb_of::<P>(&action)
406            && !self.may(&verb)
407        {
408            return Err(ActionError::Refused(format!(
409                "you may not change the workspace's {}",
410                P::LABEL.to_lowercase()
411            )));
412        }
413        Ok(action)
414    }
415
416    /// The record underneath, for a test or host that carries it between
417    /// devices itself.
418    #[must_use]
419    pub fn record(&self) -> &StoredRecord {
420        &self.shared.record
421    }
422}
423
424/// The verb `action` invokes, as its record publishes it; `None` for a
425/// field edit.
426fn verb_of<P: Resource>(action: &P::Action) -> Option<String> {
427    let mut doc = ui_core::automerge::AutoCommit::new();
428    P::initialize(&mut doc).ok()?;
429    match action.describe(&doc)?.what {
430        tilo_actions::Did::Verb(call) => Some(call.action.action),
431        _ => None,
432    }
433}
434
435/// The records of the window's workspace, for what settles after the view
436/// that asked for them: a reply, or a message another device sent
437/// ([`RecordEnv::workspace_handle`](super::RecordEnv::workspace_handle)).
438#[derive(Clone)]
439pub struct WorkspaceHandle {
440    state: Rc<WorkspaceState>,
441}
442
443impl WorkspaceHandle {
444    /// The record `id` of `P` that `owner` keeps: opened, created where
445    /// nothing holds it yet, and followed change by change. `None` where it
446    /// cannot be opened.
447    pub fn record<P: Resource>(&self, owner: &'static str, id: &str) -> Option<WorkspaceRecord<P>> {
448        self.state.open_at::<P>(owner, id)
449    }
450
451    /// Whether the record `id` of `P` that `owner` keeps is held already,
452    /// without opening or creating it.
453    #[must_use]
454    pub fn holds_record<P: Resource>(&self, owner: &'static str, id: &str) -> bool {
455        self.state.holds_record(owner, id, P::KIND)
456    }
457
458    /// The chain of `P` kept beside one of the workspace's records
459    /// ([`Chained::chain_beside`]): the messages of one direct conversation,
460    /// in parts of the record the two people share, opened in the store that
461    /// keeps that record. `None` where it cannot be opened.
462    pub fn chain_beside<P: Chained>(
463        &self,
464        owner: &'static str,
465        base: &WorkspaceRecord<P::Base>,
466    ) -> Option<Chain<P>> {
467        self.state.chain_beside::<P>(owner, base)
468    }
469
470    /// What wakes the window: for an answer that arrives later.
471    #[must_use]
472    pub fn wake(&self) -> Option<&WakeHandle> {
473        self.state.wake()
474    }
475}
476
477/// A record open in a window: its owner and kind, and which record of them
478/// (the workspace's one of the kind, or a pair's).
479type OpenKey = (&'static str, &'static str, String);
480
481/// The records every app of the workspace opens that a window's views opened:
482/// one [`WorkspaceRecord`] per kind.
483pub(crate) struct WorkspaceState {
484    records: WorkspaceRecords,
485    app_id: &'static str,
486    app_title: &'static str,
487    permissions: Option<LivePermissions>,
488    /// Wakes the window when another replica's change arrives.
489    wake: Option<WakeHandle>,
490    opened: RefCell<HashMap<OpenKey, Rc<dyn Any>>>,
491    /// The chains kept beside the opened records (a chat's message parts,
492    /// [`WorkspaceHandle::chain_beside`]).
493    chains: super::chain::Chains,
494    owners: RefCell<Vec<HistoryOwner>>,
495    refreshers: RefCell<Vec<Box<dyn Fn() -> bool>>>,
496}
497
498impl WorkspaceState {
499    pub(crate) fn new(
500        records: WorkspaceRecords,
501        app_id: &'static str,
502        app_title: &'static str,
503        permissions: Option<LivePermissions>,
504        wake: Option<WakeHandle>,
505    ) -> Self {
506        Self {
507            records,
508            app_id,
509            app_title,
510            permissions,
511            wake,
512            opened: RefCell::default(),
513            chains: super::chain::Chains::default(),
514            owners: RefCell::default(),
515            refreshers: RefCell::default(),
516        }
517    }
518
519    /// What wakes the window: for an answer that arrives later, such as a
520    /// chooser's.
521    pub(crate) fn wake(&self) -> Option<&WakeHandle> {
522        self.wake.as_ref()
523    }
524
525    /// This state as the window's views hold it on
526    /// ([`RecordEnv::workspace_handle`](super::RecordEnv::workspace_handle)).
527    pub(crate) fn handle(self: &Rc<Self>) -> WorkspaceHandle {
528        WorkspaceHandle {
529            state: Rc::clone(self),
530        }
531    }
532
533    /// The window's app.
534    pub(crate) fn app_id(&self) -> &'static str {
535        self.app_id
536    }
537
538    /// Whether the record `id` of `kind` that `owner` keeps is held already,
539    /// without opening or creating it: what a list asks.
540    pub(crate) fn holds_record(&self, owner: &str, id: &str, kind: &str) -> bool {
541        self.records
542            .holds_record(owner, id, kind)
543            .is_ok_and(|held| held)
544    }
545
546    /// The record `P` that `owner` keeps in the workspace, opened on first use
547    /// and created with the resource's genesis where it does not exist yet.
548    pub(crate) fn open<P: Resource>(&self, owner: &'static str) -> Option<WorkspaceRecord<P>> {
549        let id = ui_core::record::workspace::record_id(P::KIND);
550        self.open_with::<P>(owner, &id, |records| {
551            WorkspaceRecord::open(
552                records,
553                owner,
554                (self.app_id, self.app_title),
555                self.permissions.clone(),
556                self.wake.clone(),
557            )
558        })
559    }
560
561    /// The record `id` of `P` that `owner` keeps, opened on first use and
562    /// created with the resource's genesis where it does not exist yet: a
563    /// conversation's record, named by the two people it is between.
564    pub(crate) fn open_at<P: Resource>(
565        &self,
566        owner: &'static str,
567        id: &str,
568    ) -> Option<WorkspaceRecord<P>> {
569        self.open_with::<P>(owner, id, |records| {
570            WorkspaceRecord::open_at(
571                records,
572                owner,
573                id,
574                (self.app_id, self.app_title),
575                self.permissions.clone(),
576                self.wake.clone(),
577            )
578        })
579    }
580
581    /// [`Self::open`]'s and [`Self::open_at`]'s record, opened once per
582    /// window: held for the views that ask again, and refreshed with them.
583    fn open_with<P: Resource>(
584        &self,
585        owner: &'static str,
586        id: &str,
587        open: impl FnOnce(&WorkspaceRecords) -> Option<WorkspaceRecord<P>>,
588    ) -> Option<WorkspaceRecord<P>> {
589        let key = (owner, P::KIND, id.to_owned());
590        if let Some(open) = self.opened.borrow().get(&key) {
591            return Rc::clone(open)
592                .downcast::<Shared<P>>()
593                .ok()
594                .map(|shared| WorkspaceRecord { shared });
595        }
596        // The owner's own window lists the record in its surface: its
597        // changes are decided against the owner's surface.
598        if owner == self.app_id {
599            super::controls::opened_record::<P>();
600        }
601        let opened = open(&self.records);
602        self.held(key, opened)
603    }
604
605    /// The chain of `P` kept beside one of the workspace's records
606    /// ([`Chained::chain_beside`]), opened on first use in the store that keeps
607    /// that record: a conversation's messages, decided by the workspace's
608    /// grants on the app like the record they are kept beside. `None` where it
609    /// cannot be opened.
610    pub(crate) fn chain_beside<P: Chained>(
611        &self,
612        owner: &'static str,
613        base: &WorkspaceRecord<P::Base>,
614    ) -> Option<Chain<P>> {
615        super::controls::opened_record::<P>();
616        let workspace = super::chain::WorkspaceRecords {
617            store: self.records.store_of(owner).ok()?,
618            me: self.records.author().0.clone(),
619            permissions: self.permissions.clone(),
620            app_id: self.app_id,
621            app_title: self.app_title,
622        };
623        let live = base.live();
624        let chain = P::chain_beside(base.id());
625        let state = self
626            .chains
627            .open_at::<P>(&workspace, &chain, move || live.get())?;
628        Some(Chain::applying(&state, self.app_id, base.me()))
629    }
630
631    /// Keep an opened record for the window: its route reported to the
632    /// runtime, its changes followed, and the record held for the views that
633    /// ask for it again.
634    fn held<P: Resource>(
635        &self,
636        key: OpenKey,
637        opened: Option<WorkspaceRecord<P>>,
638    ) -> Option<WorkspaceRecord<P>> {
639        let WorkspaceRecord { shared } = opened?;
640        self.owners.borrow_mut().push(shared.record.history_owner());
641        let weak = Rc::downgrade(&shared);
642        self.refreshers.borrow_mut().push(Box::new(move || {
643            weak.upgrade().is_some_and(|shared| shared.refresh())
644        }));
645        self.opened
646            .borrow_mut()
647            .insert(key, Rc::clone(&shared) as Rc<dyn Any>);
648        Some(WorkspaceRecord { shared })
649    }
650
651    /// Read every open record that moved. Whether one did.
652    pub(crate) fn refresh(&self) -> bool {
653        let moved = self
654            .refreshers
655            .borrow()
656            .iter()
657            .fold(false, |moved, refresh| refresh() | moved);
658        // A part another device wrote into a chain kept beside a record is
659        // read here, so the app's views show it.
660        for chain in self.chains.doors() {
661            let _ = chain.revision();
662        }
663        moved
664    }
665
666    /// The routes of the open records, which the runtime syncs.
667    pub(crate) fn owners(&self) -> Vec<HistoryOwner> {
668        self.owners.borrow().clone()
669    }
670}