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}