Skip to main content

tilo_app/framework/
env.rs

1//! What a view and a custom surface both reach: the record's dispatcher
2//! and live state, the run it is part of, its storage and effects.
3//! [`ViewCx`](super::ViewCx) and [`SurfaceCx`](super::SurfaceCx) dereference
4//! to it, so each reads the same accessors.
5
6use std::rc::{Rc, Weak};
7
8use tilo_resource::Resource;
9use ui_core::LocalState;
10use ui_core::record::RecordSaveStatus;
11
12use super::dispatch::Dispatcher;
13use super::effect::{EffectAnswer, EffectCx, Effects, Projection};
14use super::live::Live;
15use super::scenario::{Run, RunMode};
16use super::settings::{Setting, ViewStorage};
17
18/// The record, run and storage a view or surface works in. See the module
19/// docs.
20pub struct RecordEnv<R: Resource> {
21    pub(crate) live: Live<R>,
22    pub(crate) alive: Weak<()>,
23    pub(crate) dispatch: Dispatcher<R>,
24    pub(crate) saved: LocalState<RecordSaveStatus>,
25    pub(crate) saves: super::saves::Saves,
26    pub(crate) run: Run,
27    pub(crate) storage: Rc<ViewStorage>,
28    pub(crate) effects: Effects<R>,
29}
30
31/// One of the documents the app's windows started in the workspace
32/// ([`RecordEnv::window_documents`]).
33pub struct WindowDocument<R: Resource> {
34    /// The record it is.
35    pub record: ui_core::record::RecordId,
36    /// What it holds, as last kept.
37    pub state: Rc<R::State>,
38    /// When it last changed, in milliseconds since the epoch; `0` when no
39    /// change of it says.
40    pub changed_ms: u64,
41}
42
43impl<R: Resource> WindowDocument<R> {
44    /// The launch argument naming it, for [`RecordEnv::open_here`].
45    pub fn uri(&self) -> String {
46        self.record.uri()
47    }
48}
49
50impl<R: Resource> Clone for WindowDocument<R> {
51    fn clone(&self) -> Self {
52        Self {
53            record: self.record.clone(),
54            state: Rc::clone(&self.state),
55            changed_ms: self.changed_ms,
56        }
57    }
58}
59
60impl<R: Resource> Clone for RecordEnv<R> {
61    fn clone(&self) -> Self {
62        let Shared {
63            alive,
64            saved,
65            saves,
66            run,
67            storage,
68        } = Shared::of(
69            &self.alive,
70            &self.saved,
71            (&self.saves, &self.run, &self.storage),
72        );
73        Self {
74            live: self.live.clone(),
75            alive,
76            dispatch: self.dispatch.clone(),
77            saved,
78            saves,
79            run,
80            storage,
81            effects: self.effects.clone(),
82        }
83    }
84}
85
86/// What a [`RecordEnv`] holds that does not name its resource type, cloned
87/// here once rather than in every app's clone of it.
88struct Shared {
89    alive: Weak<()>,
90    saved: LocalState<RecordSaveStatus>,
91    saves: super::saves::Saves,
92    run: Run,
93    storage: Rc<ViewStorage>,
94}
95
96impl Shared {
97    #[inline(never)]
98    fn of(
99        alive: &Weak<()>,
100        status: &LocalState<RecordSaveStatus>,
101        (saves, run, storage): (&super::saves::Saves, &Run, &Rc<ViewStorage>),
102    ) -> Self {
103        Self {
104            alive: alive.clone(),
105            saved: status.clone(),
106            saves: saves.clone(),
107            run: run.clone(),
108            storage: Rc::clone(storage),
109        }
110    }
111}
112
113impl<R: Resource> RecordEnv<R> {
114    /// Wakes the app from another thread — a worker's result, a socket's
115    /// message — so the window syncs and draws it without waiting for
116    /// input.
117    pub fn waker(&self) -> ui_core::WakeHandle {
118        self.effects
119            .env()
120            .map_or_else(ui_core::WakeHandle::new, |env| env.wake.clone())
121    }
122
123    /// The same environment for a scope that lives as long as `alive`.
124    pub(crate) fn living(&self, alive: &Rc<()>) -> Self {
125        Self {
126            alive: Rc::downgrade(alive),
127            ..self.clone()
128        }
129    }
130
131    /// Open, in this window and in place of the record it shows, what a
132    /// launch of the app naming `arguments` opens: the record
133    /// [`ResourceApp::launch_record`](super::ResourceApp::launch_record)
134    /// names, with its launch actions — the document a person chose with
135    /// the app's own Open. The window's view is rebuilt for that record on
136    /// the next frame. Refused in a past version and a capture.
137    ///
138    /// The outcome answers once the host opened it, or why it could not.
139    pub fn open_here(
140        &self,
141        arguments: &[String],
142    ) -> Result<ui_core::application::ReplacementOutcome, String> {
143        self.run.open_here(arguments)
144    }
145
146    /// The host-owned identity of the record this window shows. A durable
147    /// activation link can name it without relying on the last-open window.
148    /// Absent in a historical view that cannot launch live work.
149    pub fn record_id(&self) -> Option<&ui_core::record::RecordId> {
150        self.run.launched.record.as_ref()
151    }
152
153    /// What [`Self::open_here`] does, kept for later: the input that asks
154    /// for another record arrives after the view is built (a file chosen in
155    /// a picker, a clicked notification). `None` in a past version and a
156    /// capture.
157    pub fn opener(&self) -> Option<super::OpenHere> {
158        self.run.opener.clone()
159    }
160
161    /// The documents this app's windows started in this workspace, each as
162    /// last kept, the most recently changed first, without the one this
163    /// window shows: what an app whose windows each show a document of their
164    /// own ([`ResourceApp::WINDOW_RESOURCE`](super::ResourceApp::WINDOW_RESOURCE))
165    /// lists, so a document made in a window that was closed is reached
166    /// again. Open one here with [`Self::open_here`] naming
167    /// [`WindowDocument::uri`]. Reads every one of them, so ask when the
168    /// list is shown, not on every frame. Empty in a past version and where
169    /// the window keeps no records in a workspace.
170    pub fn window_documents(&self) -> Vec<WindowDocument<R>> {
171        if self.is_past() {
172            return Vec::new();
173        }
174        let Some(store) = self.storage.workspace_store() else {
175            return Vec::new();
176        };
177        let kind = ui_core::record::RecordKind::new(R::KIND);
178        let shown = self.run.launched.record.as_ref();
179        let mut documents: Vec<WindowDocument<R>> = store
180            .window_records(&kind)
181            .into_iter()
182            .filter(|(record, _)| Some(record) != shown)
183            .filter_map(|(record, mut doc)| {
184                let changed_s = doc
185                    .get_heads()
186                    .iter()
187                    .filter_map(|head| doc.get_change_by_hash(head).map(|change| change.timestamp()))
188                    .max()
189                    .unwrap_or(0);
190                let state = tilo_resource::Reader::<R>::read(&doc)
191                    .map_err(|error| {
192                        tracing::warn!(record = %record, %error, "window document unreadable; left out");
193                    })
194                    .ok()?
195                    .state()
196                    .clone();
197                Some(WindowDocument {
198                    record,
199                    state,
200                    changed_ms: u64::try_from(changed_s).unwrap_or(0).saturating_mul(1000),
201                })
202            })
203            .collect();
204        documents.sort_by_key(|document| std::cmp::Reverse(document.changed_ms));
205        documents
206    }
207
208    /// The names of the participants, for showing the ids actions record
209    /// ([`People::name`](super::People::name)).
210    pub fn people(&self) -> super::People {
211        self.run.people.clone()
212    }
213
214    /// Where edits go. Custom widgets keep a clone.
215    pub fn dispatcher(&self) -> Dispatcher<R> {
216        self.dispatch.clone()
217    }
218
219    /// The dispatcher of the resource nested in this one at `id`, whose
220    /// edits are this resource's action `wrap(edit)`: a rich-text body, a
221    /// draft, a SQL buffer. See [`Dispatcher::child`].
222    pub fn child<C: Resource>(
223        &self,
224        id: &str,
225        wrap: impl Fn(C::Action) -> R::Action + 'static,
226    ) -> Dispatcher<C> {
227        self.dispatch.child(id, wrap)
228    }
229
230    /// The chain of `P` the app keeps beside its record
231    /// ([`Chain`](super::Chain)): its parts, read as they change, and its
232    /// dispatcher. `None` in a view of a past version, or where `P`'s base
233    /// is not this record's resource.
234    pub fn chain<P: super::Chained<Base = R>>(&self) -> Option<super::Chain<P>> {
235        let live = self.live.clone();
236        let state = self.storage.chain::<P>(move || live.get())?;
237        self.chain_of(state)
238    }
239
240    /// The chain of `P` kept beside the record `beside` rather than beside
241    /// this window's own ([`Chained::chain_beside`](super::Chained::chain_beside)):
242    /// the messages of one direct conversation, kept in the record the two
243    /// people share. `P`'s base is the type of `beside`'s record.
244    pub fn chain_beside<P: super::Chained<Base = R>>(
245        &self,
246        beside: &super::WorkspaceRecord<R>,
247    ) -> Option<super::Chain<P>> {
248        let live = beside.live();
249        let state = self
250            .storage
251            .chain_beside::<P>(beside.id(), move || live.get())?;
252        self.chain_of(state)
253    }
254
255    /// The chain held by its state, with the dispatcher that applies its
256    /// actions as the window's person.
257    fn chain_of<P: super::Chained>(
258        &self,
259        state: Rc<super::chain::ChainState<P>>,
260    ) -> Option<super::Chain<P>> {
261        let applying = Rc::downgrade(&state);
262        let me = self.dispatch.me()?.to_owned();
263        let dispatch = self.dispatch.for_workspace_record::<P>(
264            state.id(),
265            self.dispatch.is_past(),
266            move |action| {
267                let state = applying.upgrade().ok_or_else(|| {
268                    tilo_actions::ActionError::Refused("the chain is closed".into())
269                })?;
270                state
271                    .apply_as(action, &tilo_actions::ActionInvoker::actor(&me))
272                    .map(drop)
273            },
274        );
275        Some(super::Chain::new(state, dispatch))
276    }
277
278    /// The children of `C` this record lists ([`Children`](super::Children)):
279    /// each a record of its own, read as they change, with the one
280    /// lifecycle every container has. `None` in a view of a past version,
281    /// or where `C`'s parent is not this record's resource.
282    pub fn children<C: super::ChildRecord<Parent = R>>(&self) -> Option<super::Children<C>> {
283        let live = self.live.clone();
284        let state = self.storage.children::<C>(move || live.get())?;
285        let me = self.dispatch.me()?.to_owned();
286        Some(super::Children::new(state, me).dispatching(&self.dispatch))
287    }
288
289    /// Who this view is for: the participant reading and editing through
290    /// it, the same id an action they invoke reads as its author
291    /// ([`ActionCx::author`](tilo_resource::ActionCx::author)). Compare the
292    /// authors the resource records with it ("your message", "you reacted")
293    /// instead of keeping the reader in the resource state, which is the
294    /// same for everyone. `None` in a view of a past version.
295    pub fn me(&self) -> Option<&str> {
296        self.dispatch.me()
297    }
298
299    /// Whether this view shows a past version in History mode, drawn from a
300    /// frozen document. It must start no work there: a surface that fetches,
301    /// watches or spawns a worker is built without it and presents what the
302    /// document holds.
303    pub fn is_past(&self) -> bool {
304        self.dispatch.is_past()
305    }
306
307    /// Whether this participant may only view, when the view is built.
308    /// Bound controls already follow permission changes on their own; to show
309    /// or disable something else while the participant may only view, bind to
310    /// [`ViewCx::read_only_state`](super::ViewCx::read_only_state), which follows role changes while the app
311    /// runs.
312    pub fn read_only(&self) -> bool {
313        self.dispatch.read_only()
314    }
315
316    /// Whether the record's latest edits are durable, for automation and
317    /// an accessibility status. Saving is automatic and the window already
318    /// says what there is to say (a save that stalls or fails, and the
319    /// document's unsaved mark then): never show this as "Saving…" or
320    /// "Saved", nor mark the document edited from it.
321    pub fn save_status(&self) -> LocalState<RecordSaveStatus> {
322        self.saved.clone()
323    }
324
325    /// The window's report of saves the app makes outside its record (a
326    /// file it writes), presented with the record's own on the window's
327    /// save status. See [`Saves`](super::Saves).
328    pub fn saves(&self) -> super::Saves {
329        self.saves.clone()
330    }
331
332    /// The workspace the window keeps its records in: what an app keys what it keeps on
333    /// this device for the workspace by (a cache, an index). `None` in a past version and where the
334    /// window keeps no records in a workspace.
335    pub fn workspace(&self) -> Option<String> {
336        if self.is_past() {
337            return None;
338        }
339        self.storage
340            .workspace_store()
341            .map(ui_core::record::RecordStore::home)
342    }
343
344    /// The records every app of the window's workspace opens, held for what
345    /// settles after the view that asked for one (an action's reply, a
346    /// message from another device): a conversation's record is opened and
347    /// followed through it, in a view that is built once. `None` in a past
348    /// version and where the window keeps no workspace records.
349    pub fn workspace_handle(&self) -> Option<super::WorkspaceHandle> {
350        if self.is_past() {
351            return None;
352        }
353        self.storage.workspace_handle()
354    }
355
356    /// What the view is built for: a window someone uses, a past version in
357    /// History, or a screenshot. Start work that reaches outside the process
358    /// (an engine, a socket, a probe) only when it [is live](RunMode::is_live).
359    pub fn mode(&self) -> RunMode {
360        self.run.mode
361    }
362
363    /// The capture's stand-in for a live source of type `V`
364    /// ([`Fixture::provide`](super::Fixture::provide)); `None` outside a
365    /// capture, or when the scenario provides none.
366    pub fn fixture<V: 'static>(&self) -> Option<Rc<V>> {
367        self.run.fixture::<V>()
368    }
369
370    /// The content store the host lent the app: opaque blobs by hash that
371    /// outlive it. A capture's holds the blobs its scenario put there.
372    pub fn content(&self) -> Option<tilo_content::ContentStore> {
373        self.run.content()
374    }
375
376    /// What the launch that opened this window named — the file a person
377    /// opened the app with, its command line — for a view that shows what
378    /// the launch's actions ([`ResourceApp::launch`](super::ResourceApp::launch))
379    /// made: the note a launch imported, opened. Empty for a plain start and
380    /// for a past version.
381    pub fn launch(&self) -> super::launch::LaunchInput<'_> {
382        self.run.launch()
383    }
384
385    /// What the window's launch named (paths, URLs), for a view that acts
386    /// on them beyond the actions the launch took: the file a save writes
387    /// back to. Empty for a plain start, a past version and a capture.
388    pub fn launch_arguments(&self) -> &[String] {
389        &self.run.launched.arguments
390    }
391
392    /// The file whose history History shows beside the resource's, for an
393    /// app that declares [`ResourceApp::FILE_HISTORY`](super::ResourceApp::FILE_HISTORY):
394    /// set its target to the file the window shows (the selected one) and
395    /// open it from the window's own History command. `None` for any other
396    /// app, and in a past version.
397    pub fn file_history(&self) -> Option<ui_core::history_mode::checked_files::FileHistorySource> {
398        self.run
399            .file_history
400            .as_ref()
401            .map(|(source, _)| source.clone())
402    }
403
404    /// The value of the app's own option `name` (`--purpose`) the window's
405    /// launch named; see [`ResourceApp::OPTIONS`](super::ResourceApp::OPTIONS).
406    /// `None` for a past version.
407    pub fn launch_option(&self, name: &str) -> Option<&str> {
408        super::launch::option(&self.run.launched.options, name)
409    }
410
411    /// Whether the window's launch named the app's switch `name`; see
412    /// [`ResourceApp::FLAGS`](super::ResourceApp::FLAGS).
413    pub fn launch_flag(&self, name: &str) -> bool {
414        super::launch::flag(&self.run.launched.options, name)
415    }
416
417    /// The service of type `T` the host that mounts the app in its own
418    /// process lent it ([`HostContext::lend`](ui_core::application::HostContext::lend)),
419    /// taken once: the shell's connection to the session's process
420    /// supervisor for a terminal it hosts. `None` in the app's own process,
421    /// and for a past version or a capture, which start nothing.
422    pub fn take_lent<S: 'static>(&self) -> Option<S> {
423        if !self.run.mode.is_live() {
424            return None;
425        }
426        self.run.launched.lent.take::<S>()
427    }
428
429    /// Whether the window is private ([`ResourceApp::PRIVATE_WINDOWS`](super::ResourceApp::PRIVATE_WINDOWS)):
430    /// its record, settings and files are thrown away with the process. A
431    /// view keeps what it would otherwise remember on the device (visits,
432    /// a session to restore) to itself.
433    pub fn is_private(&self) -> bool {
434        self.run.private
435    }
436
437    /// The app's typed settings of type `S`, kept current while this scope
438    /// lives. `S` declares whether they follow the person or stay on the
439    /// device ([`Scope`](ui_core::storage::Scope)).
440    pub fn settings<S>(&self) -> Setting<S>
441    where
442        S: ui_core::storage::SettingsSchema + Clone + PartialEq + Send + Sync,
443    {
444        self.storage.follow(self.alive.clone())
445    }
446
447    /// The device this window runs on: stable across launches, distinct on
448    /// each of the person's devices. `None` in a view of a past version. An
449    /// action that records where something happened (a speed test run on
450    /// this device) takes it as an argument, so every replica applies the
451    /// same value.
452    pub fn device(&self) -> Option<&str> {
453        self.storage.device()
454    }
455
456    /// Read the result of the view action at `target` with `argument`,
457    /// through the session's action door as the person using the app, and
458    /// keep it current ([`Query`](super::Query)): a live action's answer
459    /// follows its provider's changes, one that answers once is read once.
460    /// It is followed while this scope lives.
461    pub fn query<T>(
462        &self,
463        target: tilo_actions::remote::RemoteActionTarget,
464        argument: tilo_actions::remote::InvokeArgument,
465    ) -> super::Query<T>
466    where
467        T: serde::de::DeserializeOwned + Send + Sync + 'static,
468    {
469        if !self.run.mode.is_live() {
470            // A past version or a capture reaches no provider: a capture
471            // reads what its scenario answers for the action.
472            let answers = self.run.fixture::<super::scenario::Answers>();
473            return super::Query::fixed(
474                answers.as_deref().and_then(|answers| answers.get(&target)),
475            );
476        }
477        super::Query::start(self.dispatch.opener(), &self.alive, target, argument)
478    }
479
480    /// Invoke the action at `target` of another provider with `argument`,
481    /// through the session's action door as the person using the app: a
482    /// write a [`Self::query`] then reads back. Why it did not go ahead is
483    /// told as any refused call of the window is. A past version or a
484    /// capture invokes nothing.
485    pub fn invoke(
486        &self,
487        target: tilo_actions::remote::RemoteActionTarget,
488        argument: tilo_actions::remote::InvokeArgument,
489    ) {
490        if self.run.mode.is_live() {
491            self.dispatch.opener().invoke(target, argument);
492        }
493    }
494
495    /// The app's record of `P` ([`PersonRecord`](super::PersonRecord)):
496    /// one per kind, kept with the app's other records in the workspace it
497    /// is open in and synced with it. `None` where the host keeps no such
498    /// records (a view of a past version, a private window) or the record
499    /// cannot be opened.
500    pub fn person<P: Resource>(&self) -> Option<super::PersonRecord<P>> {
501        self.storage.person::<P>()
502    }
503
504    /// The record `P` that `owner` keeps in the window's workspace and every app
505    /// of the workspace opens (`collab_types::WORKSPACE_RECORDS`): the
506    /// workspace's emoji, which are Chat's. Changes to it are decided by the
507    /// workspace's grants on `owner`'s resource. `None` where the host keeps no
508    /// such records (a view of a past version) or it cannot be opened.
509    pub fn shared_workspace<P: Resource>(
510        &self,
511        owner: &'static str,
512    ) -> Option<super::WorkspaceRecord<P>> {
513        self.storage.shared_workspace::<P>(owner)
514    }
515
516    /// The record `id` of `P` that `owner` keeps in the window's workspace,
517    /// named otherwise than [`Self::shared_workspace`]'s: a conversation's,
518    /// which the two people it is between name. Held and decided like any
519    /// record of the owner's: the workspace's grants on `owner` decide every
520    /// change. `None` where the host keeps no records in a workspace (a view
521    /// of a past version), for an id that names no record, or where it
522    /// cannot be opened.
523    pub fn shared_record<P: Resource>(
524        &self,
525        owner: &'static str,
526        id: &str,
527    ) -> Option<super::WorkspaceRecord<P>> {
528        self.storage.shared_record::<P>(owner, id)
529    }
530
531    /// The person's record `P` that one app keeps for all of theirs — their
532    /// accounts, which Settings keeps: the same record on every device, held
533    /// here too and converging with the keeper's. Changed only by its owner
534    /// and editors ([`SharedRecord`](tilo_resource::SharedRecord),
535    /// [`PersonRecord::editable`](super::PersonRecord::editable)); its owner
536    /// opens it here too.
537    pub fn shared_person<P: tilo_resource::SharedRecord>(&self) -> Option<super::PersonRecord<P>> {
538        self.storage.shared_person::<P>()
539    }
540
541    /// The app's files, secrets and settings documents, as its host gave
542    /// them. A secret of one document item is
543    /// `cx.storage().secrets().item(purpose, id)`.
544    pub fn storage(&self) -> &ui_core::storage::AppStorage {
545        self.storage.storage()
546    }
547
548    /// Carry out the entries of the [`Outbox`](tilo_resource::Outbox) at
549    /// `outbox` that this device owns, and record how each settled.
550    ///
551    /// Every replica holds the entries; `owns` says which are this device's
552    /// to carry out (the device whose disk a file operation names, the one
553    /// holding the mail account). For each such pending entry the framework
554    /// runs `run` in the live app — never in History or a capture — at once
555    /// or as a job through its [`EffectCx`], and records the answer with
556    /// the outbox's settle edit: content from outside, nobody's undo step.
557    /// The answer settles the entry: [`Effected::settled`](super::Effected::settled)
558    /// with how it went, [`Effected::value`](super::Effected::value) with
559    /// the result `O`, or [`Effected::done`](super::Effected::done) for
560    /// `O`'s default. An `Err` leaves the entry pending, to run again after
561    /// the next change or when the returned [`Projection`] is told the
562    /// outside changed (the server came back); a failure for good is a
563    /// [`Settled::Failed`](tilo_resource::Settled::Failed).
564    pub fn outbox<E, O>(
565        &self,
566        outbox: tilo_resource::Path<R, tilo_resource::Outbox<E, O>>,
567        owns: impl Fn(&R::State, &E) -> bool + 'static,
568        run: impl FnMut(&tilo_resource::Id, E, &mut EffectCx<R>) -> EffectAnswer + 'static,
569    ) -> Projection
570    where
571        R: Resource<State = R> + tilo_resource::Model + 'static,
572        E: tilo_resource::Model + Clone + 'static,
573        O: serde::Serialize + serde::de::DeserializeOwned + Default + 'static,
574    {
575        self.effects
576            .outbox(self.alive.clone(), outbox, owns, run, false)
577    }
578
579    /// [`Self::outbox`], where an entry is the outside half of the action
580    /// that queued it: one an admitted invocation on this device queues runs
581    /// at once under that invocation — `cx.invocation()` names it, and its
582    /// custody authorises what the handler does for it (a native host that
583    /// acts only on behalf of an invocation) — and the invocation completes
584    /// only once its entries settled, failing with a failed one. Entries
585    /// queued elsewhere, replayed or still pending after a restart run as
586    /// [`Self::outbox`] runs them, with no invocation.
587    pub fn outbox_awaited<E, O>(
588        &self,
589        outbox: tilo_resource::Path<R, tilo_resource::Outbox<E, O>>,
590        owns: impl Fn(&R::State, &E) -> bool + 'static,
591        run: impl FnMut(&tilo_resource::Id, E, &mut EffectCx<R>) -> EffectAnswer + 'static,
592    ) -> Projection
593    where
594        R: Resource<State = R> + tilo_resource::Model + 'static,
595        E: tilo_resource::Model + Clone + 'static,
596        O: serde::Serialize + serde::de::DeserializeOwned + Default + 'static,
597    {
598        self.effects
599            .outbox(self.alive.clone(), outbox, owns, run, true)
600    }
601}