Skip to main content

tilo_app/framework/
dispatch.rs

1//! Submitting edits: every change a view makes is an action the host checks
2//! against permissions, applies, records for undo and history, and
3//! replicates.
4
5#[cfg(test)]
6#[path = "dispatch_tests.rs"]
7mod tests;
8
9use std::cell::{Cell, RefCell};
10use std::collections::VecDeque;
11use std::rc::Rc;
12
13use tilo_actions::{
14    ActionAddress, ActionBinding, ActionContext, ActionError, ActionInvoker, ActionNeed,
15    ControlInvocation,
16};
17
18use super::controls::Actions;
19use tilo_resource::{Edit, Effect, Invoke, Resource};
20use ui_core::LocalState;
21use ui_core::automerge::ChangeHash;
22use ui_core::permissions::{ActionKind, LivePermissions, PermissionScope, TreePath, policy};
23
24/// What a view asks whether its participant may do.
25#[derive(Clone, Debug, PartialEq, Eq)]
26enum Asked {
27    Need(ActionNeed),
28    Govern,
29    /// Whether what the workspace holds has arrived, so a choice made from the
30    /// record (a free seat) is made from what everyone sees.
31    Arrived,
32}
33use ui_core::widget::Edits;
34use ui_core::widget::controls::{AttributedControlInvocation, Origin};
35
36/// What reacting needs: an undoable change below the reactions field,
37/// wherever it is.
38fn reaction_need() -> ActionNeed {
39    ActionNeed::reactions()
40}
41
42/// Whether the field edit in `arguments` reacts or takes a reaction back.
43fn reacts(arguments: &serde_json::Map<String, serde_json::Value>) -> bool {
44    arguments
45        .get(tilo_resource::EDIT_PARAM)
46        .and_then(|edit| edit.get("op"))
47        .and_then(serde_json::Value::as_str)
48        .is_some_and(|op| op == "react" || op == "unreact")
49}
50
51/// A queued invocation and who asked for it.
52type Queued = (AttributedControlInvocation, Origin);
53
54/// Something a dispatcher submits and a button is bound to: a field edit
55/// made at a path (`p.favorite.set(true)`), a verb with its arguments, or an
56/// invocation built some other way. No app writes an action id or an
57/// argument map.
58pub trait Submit<R: Resource>: Sized {
59    /// The public id of the action this submits.
60    fn action_id(&self) -> &'static str;
61
62    /// The invocation of the host door this submits.
63    fn into_invoke(self) -> Result<Invoke<R>, String>;
64
65    /// The binding of a button that submits this when pressed, or `None`
66    /// (and a failure in tests) when the resource does not publish it.
67    fn control_binding(self, actions: &Actions<R>) -> Option<ActionBinding<()>> {
68        let id = self.action_id();
69        match self.into_invoke() {
70            Ok(invoke) => actions.control_invocation(invoke),
71            Err(error) => {
72                super::controls::unbound::<R>(&format!("`{id}`"), &error);
73                None
74            }
75        }
76    }
77}
78
79impl<R: Resource> Submit<R> for Invoke<R> {
80    fn action_id(&self) -> &'static str {
81        self.id
82    }
83
84    fn into_invoke(self) -> Result<Invoke<R>, String> {
85        match self.refusal() {
86            Some(refusal) => Err(refusal.to_owned()),
87            None => Ok(self),
88        }
89    }
90}
91
92impl<R: Resource> Submit<R> for Edit<R> {
93    fn action_id(&self) -> &'static str {
94        tilo_resource::FIELD_EDIT
95    }
96
97    fn into_invoke(self) -> Result<Invoke<R>, String> {
98        Invoke::edit(&self).map_err(|error| error.to_string())
99    }
100}
101
102/// Submits a view's edits. Cloneable; clones share one queue.
103///
104/// Two doors, one path. [`submit`](Self::submit) and [`apply`](Self::apply)
105/// queue an invocation the host resolves after the event, exactly as a
106/// control bound to an action would. [`apply_now`](Self::apply_now) applies
107/// one typed action immediately, inside the event or the settle pass
108/// ([`Widget::settle`](ui_core::widget::Widget::settle)), for an editor that must
109/// see the result of its own edit before the next keystroke (the paragraph a
110/// split created, the cell a table insert made). Both check the
111/// participant's permission, apply through the replayable executor with
112/// attribution, and are undone per author.
113///
114/// # A drag as one undo step
115///
116/// A slider or a drag handle a surface draws itself applies each value it
117/// passes through with [`apply_now`](Self::apply_now), so the document — and
118/// every collaborator — follows the drag live. The resource declares that
119/// one value continues the one before it ([`Resource::coalesces`]: the same
120/// field of the same item), which makes the whole drag one step, and the
121/// surface calls [`settle`](Self::settle) when the pointer is let go, so the
122/// next drag is a step of its own:
123///
124/// ```ignore
125/// fn coalesces(previous: &Action, next: &Action) -> bool {
126///     matches!((previous, next), (Action::SetLevel(..), Action::SetLevel(..)))
127/// }
128///
129/// // In the surface's `handle_event`:
130/// WindowEvent::CursorMoved { .. } if self.dragging => {
131///     let _ = self.dispatch.apply_now(&mut ctx.edits(), &Action::SetLevel(level));
132/// }
133/// WindowEvent::MouseInput { state: ElementState::Released, .. } => {
134///     self.dragging = false;
135///     self.dispatch.settle();
136/// }
137/// ```
138///
139/// A toolkit slider bound with `ViewCx::control` instead invokes its action
140/// once per drag when built with `Slider::commits_on_release`.
141pub struct Dispatcher<R: Resource> {
142    /// Everything that does not name the resource's action type, compiled
143    /// once for every resource.
144    core: DispatchCore,
145    /// The invocations of the open group, and how many groups are open.
146    group: Rc<RefCell<(usize, Vec<Invoke<R>>)>>,
147    /// What applying an action needs: the resource's own
148    /// [`Resource::permission`], or for a child its parent's action's.
149    permission_of: PermissionOf<R>,
150    /// Applies one immediate action: the resource's own, or for a child
151    /// resource its parent's action carrying it.
152    submit_now: SubmitNow<R>,
153    /// Applies a group of the resource's own actions now as one action;
154    /// `None` for a dispatcher of a nested or other record.
155    group_now: Option<GroupNow<R>>,
156    /// The last immediate action and its serial, to tell whether the next
157    /// continues it.
158    last: Rc<RefCell<Option<Serialed<R>>>>,
159}
160
161/// What a dispatcher keeps that does not name the resource's action type
162/// ([`DispatchState`]), dropped out of line: a dispatcher is dropped in code
163/// generic over its resource, and dropping each of these fields there would
164/// compile it into every app.
165pub(crate) struct DispatchCore(std::mem::ManuallyDrop<DispatchState>);
166
167impl Drop for DispatchCore {
168    #[inline(never)]
169    fn drop(&mut self) {
170        // SAFETY: the state is dropped here once, as its owner goes, and
171        // nothing reads it after.
172        unsafe { std::mem::ManuallyDrop::drop(&mut self.0) }
173    }
174}
175
176impl DispatchCore {
177    fn of(state: DispatchState) -> Self {
178        Self(std::mem::ManuallyDrop::new(state))
179    }
180}
181
182impl std::ops::Deref for DispatchCore {
183    type Target = DispatchState;
184
185    fn deref(&self) -> &DispatchState {
186        &self.0
187    }
188}
189
190impl std::ops::DerefMut for DispatchCore {
191    fn deref_mut(&mut self) -> &mut DispatchState {
192        &mut self.0
193    }
194}
195
196/// What a dispatcher keeps that does not name the resource's action type:
197/// who edits, the door's queue, permissions, refusals and the undo-step
198/// bookkeeping.
199pub(crate) struct DispatchState {
200    app_id: &'static str,
201    resource_id: Rc<str>,
202    /// The route of the record the resource is, as the workspace names its node
203    /// (empty for the app's own record of the workspace): an action on an item
204    /// of it is decided at that item's node.
205    record: Rc<str>,
206    /// [`Resource::KIND`] and [`Resource::LABEL`] of the resource.
207    kind: &'static str,
208    label: &'static str,
209    /// For a resource nested in another ([`Dispatcher::child`]), the kind and
210    /// label of the record that holds it. Its edits apply as that record's
211    /// field edits, so history and the change's claim name that record's
212    /// kind: the app's surface describes no other, and every receiver holds
213    /// (and the signer refuses) a change claiming the nested kind.
214    applied_as: Option<(&'static str, &'static str)>,
215    /// The readable name published by the resource host, shared with surfaces.
216    resource_label: LocalState<String>,
217    /// Who edits through this dispatcher; `None` in a view of a past version.
218    me: Option<Rc<str>>,
219    /// A dispatcher lent to an admitted effect keeps its original custody.
220    /// Clones cannot turn later work into the host participant's invocation.
221    /// Share the immutable scope rather than embedding its owner/lease in every
222    /// view dispatcher: copying the full scope also inflates construction stacks.
223    custody: Option<Rc<ui_core::invocation_scope::ResourceInvocationScope>>,
224    /// Each queued invocation with the effect invocation it continues,
225    /// when an effect's handler or completion queued it.
226    queue: Rc<RefCell<VecDeque<Queued>>>,
227    /// Who asks for what is queued now: the person, unless the app submits
228    /// it on its own ([`Dispatcher::submit_on_its_own`]).
229    asking: Rc<Cell<Origin>>,
230    /// The references the views opened ([`Dispatcher::open_reference`]),
231    /// on their way through the session's action door.
232    pub(crate) opener: super::reference::Opener,
233    read_only: LocalState<bool>,
234    /// The participant's permissions as the host keeps them, for an action
235    /// that needs less than writing (a reply a commenter may add) or more.
236    /// Absent outside a host: a past version, which nobody edits, or a
237    /// test's detached dispatcher, which may do what writing allows.
238    permissions: Rc<RefCell<Option<LivePermissions>>>,
239    permission_states: PermissionStates,
240    pub(crate) refused: LocalState<Option<Refused>>,
241    /// An edit applied now was refused: the views that may have previewed
242    /// it are presented the stored state again, as after a refusal at the
243    /// door.
244    represent: Rc<Cell<bool>>,
245    /// The last submission of this view the door applied.
246    landed: LocalState<Option<Landed>>,
247    /// What the views said they did ([`Dispatcher::say_when_kept`]), not yet
248    /// handed to the record to hold for the workspace.
249    notices: Rc<RefCell<Vec<String>>>,
250    /// The last of them the workspace kept, to say now ([`Dispatcher::said`]).
251    pub(crate) said: LocalState<Option<String>>,
252    /// What the effects that queued submissions here wait on: how many are
253    /// still to land, per effect invocation.
254    waits: Rc<RefCell<Waits>>,
255    /// Why the record does not read as the resource, while it does not:
256    /// nothing is submitted against a state nobody can see.
257    unreadable: Rc<RefCell<Option<String>>>,
258    host: Rc<RefCell<ui_core::local_dispatch::LocalDispatch>>,
259    /// Counts the immediate actions of the resource and its children, so a
260    /// dispatcher can tell that nothing came between its last action and
261    /// its next.
262    serial: Rc<Cell<u64>>,
263    /// Every immediate action since the last undo boundary continued the one
264    /// before it ([`Resource::coalesces`]): one step, still open.
265    continuing: Rc<Cell<bool>>,
266    /// An immediate action was applied since the last boundary was taken.
267    applied: Rc<Cell<bool>>,
268    /// The boundary last taken opened a step that stays open.
269    opened: Rc<Cell<bool>>,
270    /// The view shows a past version in History, not the live resource.
271    past: bool,
272    /// The last immediate action may be continued by a later one
273    /// ([`Resource::continuable`]).
274    continuable: Rc<Cell<bool>>,
275    /// The step the open undo step's changes record, decided before each
276    /// change commits, so a coalesced step stays one step after a restart.
277    gesture: Rc<Cell<Option<tilo_actions::ActionId>>>,
278    /// Where the resource's state comes from and where its actions go: the
279    /// document the host lends the pass, or the session over the app's one
280    /// connection ([`super::Source`]).
281    source: super::Source,
282}
283
284impl Clone for DispatchCore {
285    /// Out of line: a derived clone is inlined into every dispatcher's clone, which each app
286    /// compiles for its resource.
287    #[inline(never)]
288    fn clone(&self) -> Self {
289        Self(std::mem::ManuallyDrop::new(DispatchState {
290            app_id: self.app_id,
291            resource_id: self.resource_id.clone(),
292            record: self.record.clone(),
293            kind: self.kind,
294            label: self.label,
295            applied_as: self.applied_as,
296            resource_label: self.resource_label.clone(),
297            me: self.me.clone(),
298            custody: self.custody.clone(),
299            queue: self.queue.clone(),
300            asking: self.asking.clone(),
301            opener: self.opener.clone(),
302            read_only: self.read_only.clone(),
303            permissions: self.permissions.clone(),
304            permission_states: self.permission_states.clone(),
305            refused: self.refused.clone(),
306            represent: self.represent.clone(),
307            landed: self.landed.clone(),
308            notices: self.notices.clone(),
309            said: self.said.clone(),
310            waits: self.waits.clone(),
311            unreadable: self.unreadable.clone(),
312            host: self.host.clone(),
313            serial: self.serial.clone(),
314            continuing: self.continuing.clone(),
315            applied: self.applied.clone(),
316            opened: self.opened.clone(),
317            past: self.past,
318            continuable: self.continuable.clone(),
319            gesture: self.gesture.clone(),
320            source: self.source.clone(),
321        }))
322    }
323}
324
325/// What applying one action needs.
326type PermissionOf<R> = Rc<dyn Fn(&<R as Resource>::Action) -> ActionNeed>;
327
328/// One observable per requested permission, shared by this resource's views.
329type PermissionStates = Rc<RefCell<Vec<(Asked, LocalState<bool>)>>>;
330
331/// An immediate action and the serial it was applied at.
332type Serialed<R> = (u64, <R as Resource>::Action);
333
334/// Applies a group of the resource's actions now, as one action.
335type GroupNow<R> = Rc<
336    dyn Fn(
337        &mut ui_core::local_dispatch::LocalDispatch,
338        &mut Edits<'_>,
339        &tilo_resource::ActionGroup<<R as Resource>::Action>,
340        ActionContext,
341    ) -> Result<(), ActionError>,
342>;
343
344/// Applies one typed action to the document a pass may change, through
345/// the host.
346type SubmitNow<R> = Rc<
347    dyn Fn(
348        &mut ui_core::local_dispatch::LocalDispatch,
349        &mut Edits<'_>,
350        &<R as Resource>::Action,
351        ActionContext,
352    ) -> Result<(), ActionError>,
353>;
354
355impl<R: Resource> Clone for Dispatcher<R> {
356    fn clone(&self) -> Self {
357        Self {
358            core: self.core.clone(),
359            group: Rc::clone(&self.group),
360            permission_of: Rc::clone(&self.permission_of),
361            submit_now: Rc::clone(&self.submit_now),
362            group_now: self.group_now.clone(),
363            last: Rc::clone(&self.last),
364        }
365    }
366}
367
368impl DispatchCore {
369    fn new(
370        app_id: &'static str,
371        resource_id: &str,
372        (kind, label): (&'static str, &'static str),
373        read_only: bool,
374        me: Option<&str>,
375    ) -> Self {
376        Self(std::mem::ManuallyDrop::new(DispatchState {
377            app_id,
378            resource_id: Rc::from(resource_id),
379            record: Rc::from(""),
380            kind,
381            label,
382            resource_label: LocalState::new(label.to_owned()),
383            me: me.map(Rc::from),
384            custody: None,
385            queue: Rc::default(),
386            asking: Rc::default(),
387            opener: super::reference::Opener::default(),
388            read_only: LocalState::new(read_only),
389            permissions: Rc::default(),
390            permission_states: Rc::default(),
391            refused: LocalState::new(None),
392            represent: Rc::default(),
393            landed: LocalState::new(None),
394            notices: Rc::default(),
395            said: LocalState::new(None),
396            waits: Rc::default(),
397            unreadable: Rc::default(),
398            host: Rc::default(),
399            serial: Rc::default(),
400            continuing: Rc::default(),
401            applied: Rc::default(),
402            opened: Rc::default(),
403            past: false,
404            continuable: Rc::default(),
405            gesture: Rc::default(),
406            source: super::Source::Document,
407            applied_as: None,
408        }))
409    }
410
411    /// The core of the dispatcher of another record than this resource's,
412    /// published at `id` ([`Dispatcher::for_record`]).
413    fn for_record(
414        &self,
415        id: &str,
416        (kind, label): (&'static str, &'static str),
417        read_only: bool,
418    ) -> Self {
419        Self::of(DispatchState {
420            app_id: self.app_id,
421            me: self.me.clone(),
422            custody: self.custody.clone(),
423            resource_id: Rc::from(id),
424            record: Rc::from(""),
425            kind,
426            label,
427            resource_label: LocalState::new(label.to_owned()),
428            queue: Rc::clone(&self.queue),
429            asking: Rc::clone(&self.asking),
430            opener: self.opener.clone(),
431            read_only: LocalState::new(read_only || self.past),
432            permissions: Rc::default(),
433            permission_states: Rc::default(),
434            refused: self.refused.clone(),
435            represent: self.represent.clone(),
436            landed: self.landed.clone(),
437            notices: Rc::clone(&self.notices),
438            said: self.said.clone(),
439            waits: Rc::clone(&self.waits),
440            unreadable: Rc::default(),
441            host: Rc::clone(&self.host),
442            serial: Rc::default(),
443            continuing: Rc::default(),
444            applied: Rc::default(),
445            opened: Rc::default(),
446            past: self.past,
447            continuable: Rc::default(),
448            gesture: Rc::default(),
449            // Another record of an app in this process is the app's own,
450            // kept here: only the source of this one may be the session's.
451            source: super::Source::Document,
452            applied_as: None,
453        })
454    }
455
456    /// The core of the dispatcher of a resource nested in this one at `id`
457    /// ([`Dispatcher::child`]): it shares this one's permissions and undo
458    /// steps.
459    fn child(&self, id: &str, (kind, label): (&'static str, &'static str)) -> Self {
460        let mut child = self.clone();
461        child.resource_id = Rc::from(id);
462        child.kind = kind;
463        child.label = label;
464        child.applied_as = self.applied_as.or(Some((self.kind, self.label)));
465        child.resource_label = LocalState::new(label.to_owned());
466        child
467    }
468
469    fn queue_invocation(
470        &self,
471        address: ActionAddress,
472        arguments: serde_json::Map<String, serde_json::Value>,
473    ) {
474        let on_behalf_of = match &self.custody {
475            Some(custody) => {
476                if let Err(error) = custody.check() {
477                    self.refuse(&address.action, error);
478                    return;
479                }
480                Some(custody.invocation())
481            }
482            None => super::effect::acting_for(),
483        };
484        if let Some(invocation) = on_behalf_of {
485            self.waits.borrow_mut().submitted(invocation);
486        }
487        self.queue.borrow_mut().push_back((
488            (ControlInvocation { address, arguments }, on_behalf_of),
489            self.asking.get(),
490        ));
491    }
492
493    /// Whether the session keeps this resource's record, so submissions go
494    /// over the app's connection rather than into a document the host lent
495    /// ([`super::Source::Connection`]).
496    fn submits_over_connection(&self) -> bool {
497        self.source.is_connection()
498    }
499
500    /// Submit a control the app's view pressed, over the connection that
501    /// keeps the record, as the person's gesture (`one-app-model.md` §4).
502    pub(crate) fn submit_view_control(
503        &self,
504        address: &ActionAddress,
505        arguments: serde_json::Map<String, serde_json::Value>,
506    ) {
507        self.submit_remote(address, arguments, true);
508    }
509
510    /// Submit `address` with `arguments` over the connection, as the person's
511    /// gesture when `in_person`; the refusal is told in
512    /// [`Self::refuse`] as an edit applied now is.
513    fn submit_remote(
514        &self,
515        address: &ActionAddress,
516        arguments: serde_json::Map<String, serde_json::Value>,
517        in_person: bool,
518    ) {
519        let ask = super::Ask::new(
520            address.resource.clone(),
521            address.action.clone(),
522            arguments,
523            in_person,
524        );
525        match self
526            .source
527            .submit(&ask)
528            .unwrap_or_else(|| Err("this resource is kept here; it has no connection".to_owned()))
529        {
530            Ok(()) => {}
531            Err(reason) => self.refuse(&address.action, reason),
532        }
533    }
534
535    pub(crate) fn note_continuation(
536        &self,
537        invocation: tilo_actions::InvocationId,
538        action: &str,
539        result: &Result<ui_core::host_operation::ResourceOperationStart, tilo_actions::DoorError>,
540    ) {
541        let landed = match result {
542            Ok(ui_core::host_operation::ResourceOperationStart::Completed(output)) => {
543                Ok(Landed::of(action, output))
544            }
545            // Handed on to the broker: it applies there, answering nothing here.
546            Ok(ui_core::host_operation::ResourceOperationStart::Accepted) => Ok(Landed {
547                action: action.to_owned(),
548                answer: None,
549                created: Vec::new(),
550            }),
551            Err(error) => Err(error.to_string()),
552        };
553        self.waits.borrow_mut().landed(invocation, landed);
554    }
555
556    /// This core, lent to an admitted effect under `custody`.
557    #[inline(never)]
558    fn for_custody(&self, custody: ui_core::invocation_scope::ResourceInvocationScope) -> Self {
559        let mut core = self.clone();
560        core.custody = Some(Rc::new(custody));
561        core
562    }
563
564    fn past(&mut self) {
565        self.past = true;
566        self.read_only.set(true);
567        self.permission_states = Rc::default();
568    }
569
570    pub(crate) fn follow(&self, permissions: LivePermissions) {
571        *self.permissions.borrow_mut() = Some(permissions);
572        self.refresh_permission_states();
573    }
574
575    fn asked_state(&self, asked: Asked) -> LocalState<bool> {
576        let mut states = self.permission_states.borrow_mut();
577        if let Some((_, state)) = states.iter().find(|(held, _)| *held == asked) {
578            return state.clone();
579        }
580        let state = LocalState::new(self.may_asked(&asked));
581        states.push((asked, state.clone()));
582        state
583    }
584
585    pub(crate) fn refresh_permission_states(&self) {
586        for (asked, state) in self.permission_states.borrow().iter() {
587            let allowed = self.may_asked(asked);
588            if state.get() != allowed {
589                state.set(allowed);
590            }
591        }
592    }
593
594    fn may_asked(&self, asked: &Asked) -> bool {
595        match asked {
596            Asked::Need(need) => self.may(need),
597            Asked::Govern => self.may_govern(),
598            Asked::Arrived => !ui_core::workspace_sync::before_first_sync(),
599        }
600    }
601
602    fn may(&self, need: &ActionNeed) -> bool {
603        let mut node = self.node();
604        if let Some(field) = &need.field {
605            node = node.field(field);
606        }
607        self.may_scope(&PermissionScope::Action {
608            node,
609            kind: need.kind,
610        })
611    }
612
613    /// The node below the workspace where the workspace decides this resource's
614    /// changes, as far as a decision here can name it: an item of the
615    /// record for a resource nested in one at `<kind>/<field>/<item>` (a
616    /// note's body), so what the workspace grants on that item alone (an item
617    /// shared to edit) lets it be changed here too; else the app's.
618    fn node(&self) -> TreePath {
619        let app = TreePath::root().app(self.app_id);
620        if self.applied_as.is_none() {
621            return app;
622        }
623        match self
624            .resource_id
625            .rsplit_once('/')
626            .and_then(|(collection, item)| Some((collection.split_once('/')?, item)))
627        {
628            Some(((kind, field), item))
629                if !kind.is_empty()
630                    && !field.is_empty()
631                    && !field.contains('/')
632                    && !item.is_empty() =>
633            {
634                app.kind(kind).resource(&*self.record).item(item)
635            }
636            _ => app,
637        }
638    }
639
640    /// Whether the participant may react here: never in a past version,
641    /// else as the workspace grants reactions, which the Commenter role does as
642    /// it grants comments.
643    pub(crate) fn may_react(&self) -> bool {
644        !self.past && self.may(&reaction_need())
645    }
646
647    /// Whether the participant may read what this window holds: one who may
648    /// not is found nothing of it.
649    pub(crate) fn may_read(&self) -> bool {
650        self.may(&ActionNeed::of(ActionKind::Read))
651    }
652
653    /// Whether the workspace this record is in has anyone besides this window's
654    /// participant, its owner or a member, here now or not: what it holds is
655    /// shared with them.
656    pub(crate) fn workspace_has_others(&self) -> bool {
657        self.permissions
658            .borrow()
659            .as_ref()
660            .is_some_and(ui_core::permissions::LivePermissions::workspace_has_others)
661    }
662
663    /// React (or take a reaction back) at `path`, a [`Reactions`](tilo_resource::Reactions)
664    /// field of the resource, as the person this window is for: the field
665    /// edit `op` through the door, as [`Dispatcher::submit`] submits one,
666    /// for code that does not know the resource's type (the window's bar).
667    pub(crate) fn submit_reaction(
668        &self,
669        path: Vec<tilo_resource::Segment>,
670        op: tilo_resource::EditOp,
671    ) {
672        let id = tilo_resource::FIELD_EDIT;
673        if self.refusal(id, false) {
674            return;
675        }
676        if !self.may_react() {
677            self.refuse(id, self.not_allowed(&reaction_need()));
678            return;
679        }
680        let edit = Edit::<()>::new(path, op);
681        match tilo_resource::__private::json_via_bytes(&edit) {
682            Ok(edit) => self.queue_invocation(
683                ActionAddress::new(self.resource_id.as_ref(), id),
684                serde_json::Map::from_iter([(tilo_resource::EDIT_PARAM.to_owned(), edit)]),
685            ),
686            Err(error) => self.refuse(id, error.to_string()),
687        }
688    }
689
690    fn may_govern(&self) -> bool {
691        self.may_scope(&PermissionScope::governance(
692            policy::roles::governance::SETTINGS,
693        ))
694    }
695
696    /// Whether the participant may invite people to the workspace.
697    pub(crate) fn may_invite(&self) -> bool {
698        self.may_scope(&PermissionScope::governance(
699            policy::roles::governance::INVITE,
700        ))
701    }
702
703    fn may_scope(&self, scope: &PermissionScope) -> bool {
704        match self.permissions.borrow().as_ref() {
705            Some(permissions) => permissions.check(scope),
706            // A detached dispatcher may what writing allows; a past version
707            // nothing.
708            None => !self.read_only.get(),
709        }
710    }
711
712    #[cfg(any(test, feature = "test-support"))]
713    fn refresh_read_only(&self) -> bool {
714        let writes = self.permissions.borrow().as_ref().map(|permissions| {
715            permissions.check(&PermissionScope::app(self.app_id, ActionKind::Undoable))
716        });
717        match writes {
718            Some(writes) => self.set_read_only(!writes),
719            None => false,
720        }
721    }
722
723    pub(crate) fn set_read_only(&self, read_only: bool) -> bool {
724        if self.read_only.get() == read_only {
725            return false;
726        }
727        self.read_only.set(read_only);
728        true
729    }
730
731    fn view_only(&self) -> String {
732        format!("this {} is open to view only", self.label.to_lowercase())
733    }
734
735    /// Why an edit that is `need` was refused.
736    fn not_allowed(&self, need: &ActionNeed) -> String {
737        match (&need.field, need.kind) {
738            (Some(field), _) if field == policy::roles::COMMENTS_FIELD => {
739                format!("you may not comment on this {}", self.label.to_lowercase())
740            }
741            (Some(field), _) if field == policy::roles::REACTIONS_FIELD => {
742                format!("you may not react to this {}", self.label.to_lowercase())
743            }
744            (None, ActionKind::Undoable) => self.view_only(),
745            _ => format!("you may not do this to this {}", self.label.to_lowercase()),
746        }
747    }
748
749    /// Why nothing can be submitted now, when the record is unreadable.
750    fn unreadable(&self) -> Option<String> {
751        self.unreadable.borrow().clone()
752    }
753
754    pub(crate) fn refuse(&self, action: &str, reason: String) {
755        self.refused.set(Some(Refused {
756            action: action.to_owned(),
757            item: None,
758            reason,
759            by_workspace: false,
760        }));
761    }
762
763    /// What the views said they did since last asked, for the record to
764    /// hold until the workspace keeps it.
765    pub(crate) fn take_notices(&self) -> Vec<String> {
766        std::mem::take(&mut *self.notices.borrow_mut())
767    }
768
769    /// The workspace kept what `text` says was done.
770    pub(crate) fn kept(&self, text: &str) {
771        self.said.set(Some(text.to_owned()));
772    }
773
774    /// The workspace refused what `refusal` names after this device applied it;
775    /// the record withdrew it.
776    pub(crate) fn refuse_by_workspace(&self, refusal: &ui_core::record::RecordRefusal) {
777        self.refused.set(Some(Refused {
778            action: refusal.action.clone(),
779            item: refusal.item.clone(),
780            reason: refusal.reason.clone(),
781            by_workspace: true,
782        }));
783    }
784
785    /// Why an invocation `id` submitted now is refused before its
786    /// permission is asked: the record is unreadable (unless it is an
787    /// effect) or the effect lending this dispatcher lost its custody.
788    fn refusal(&self, id: &str, effect: bool) -> bool {
789        if let Some(reason) = self.unreadable()
790            && !effect
791        {
792            self.refuse(id, reason);
793            return true;
794        }
795        if let Some(custody) = &self.custody
796            && let Err(error) = custody.check()
797        {
798            self.refuse(id, error);
799            return true;
800        }
801        false
802    }
803
804    /// Why nothing is applied now: an effect's dispatcher submits through
805    /// the queue, and an unreadable record takes nothing.
806    fn applies_now(&self) -> Result<(), ActionError> {
807        if self.custody.is_some() {
808            return Err(ActionError::Refused(
809                "caller-scoped effects submit document changes through queued actions".into(),
810            ));
811        }
812        if let Some(reason) = self.unreadable() {
813            return Err(ActionError::Refused(reason));
814        }
815        Ok(())
816    }
817
818    /// The context of an action of this resource, attributed to whoever the
819    /// edit is applied for, and owned by this dispatcher's app. Without the
820    /// app, the executor guessed one from the action type (`org.tilo.Chat` of
821    /// `chat.workspace`), and every receiver checking the change against the
822    /// record's app refused it as a verb that app does not publish.
823    fn context(&self) -> ActionContext {
824        let context = self.resource_context();
825        if self.app_id.is_empty() {
826            context
827        } else {
828            context.with_app(self.app_id, self.label)
829        }
830    }
831
832    fn resource_context(&self) -> ActionContext {
833        match self.applied_as {
834            // As `ActionSpec::history_context` names an action a nested
835            // resource re-encodes: the enclosing kind, the nested resource
836            // kept as a facet.
837            Some((kind, label)) => {
838                let mut context =
839                    ActionContext::for_resource(self.resource_id.as_ref(), kind, label);
840                context.facets.push(tilo_actions::ActionFacet::new(
841                    tilo_actions::NESTED_RESOURCE_FACET,
842                    self.resource_label.get(),
843                ));
844                context
845            }
846            None => ActionContext::for_resource(
847                self.resource_id.as_ref(),
848                self.kind,
849                self.resource_label.get(),
850            ),
851        }
852    }
853
854    /// `context` named for this resource and its editor, in the step it
855    /// opens or `continues` when it is a `step`.
856    fn step_context(&self, context: ActionContext, step: bool, continues: bool) -> ActionContext {
857        // History names the resource and who edited it even when the
858        // surface's context does not: whoever the edit is applied for (an
859        // agent at its own caret, a collaborator a scenario stages), else
860        // this window's person.
861        let mut named = self.context();
862        if let Some(invoker) = ui_core::invocation_scope::current_invoker()
863            .or_else(|| self.me.as_ref().map(|me| ActionInvoker::actor(me.as_ref())))
864        {
865            named = named.with_invoker(invoker);
866        }
867        let mut context = context.enriched_by(&named);
868        // An action that continues the open step joins it; any other opens
869        // its own.
870        if step {
871            let gesture = self.gesture.get().filter(|_| continues).unwrap_or_default();
872            self.gesture.set(Some(gesture));
873            context = context.in_gesture(Some(gesture));
874        }
875        context
876    }
877
878    /// Note an immediate action that is a step and was applied: whether it
879    /// `continues` the open one and is `continuable` itself. Its serial.
880    fn stepped(&self, continues: bool, continuable: bool) -> u64 {
881        let serial = self.serial.get().wrapping_add(1);
882        self.serial.set(serial);
883        self.continuable.set(continuable);
884        // The event continues the open step only if every action it applied
885        // did.
886        let first_of_event = !self.applied.replace(true);
887        self.continuing
888            .set(continues && (first_of_event || self.continuing.get()));
889        serial
890    }
891
892    /// Note a group applied now: a step of its own, which nothing
893    /// continues.
894    fn grouped(&self) {
895        self.serial.set(self.serial.get().wrapping_add(1));
896        self.applied.set(true);
897        self.continuing.set(false);
898    }
899
900    pub(crate) fn take_committed(&self) -> Option<Vec<ChangeHash>> {
901        let bracket = self.host.borrow_mut().take_committed();
902        let continuing = self.continuing.replace(false);
903        self.opened
904            .set(bracket.is_some() && !continuing && self.continuable.get());
905        self.applied.set(false);
906        if continuing { None } else { bracket }
907    }
908}
909
910/// What a root reads and drives of its dispatcher, without its type.
911impl DispatchCore {
912    /// Who edits through this dispatcher; `None` in a view of a past version.
913    pub(crate) fn me(&self) -> Option<&str> {
914        self.me.as_deref()
915    }
916
917    /// Whether it is a view of a past version.
918    pub(crate) fn is_past(&self) -> bool {
919        self.past
920    }
921
922    pub(crate) fn note_landed(&self, action: &str, output: &tilo_actions::ActionOutput) {
923        self.landed.set(Some(Landed::of(action, output)));
924    }
925
926    /// Say why the record does not read as the resource (`None` once it
927    /// does again); until then every submission is refused with it.
928    pub(crate) fn set_unreadable(&self, reason: Option<String>) {
929        *self.unreadable.borrow_mut() = reason;
930    }
931
932    /// The next queued invocation, with the effect invocation it continues.
933    /// The host draining it hears who asked for it.
934    pub(crate) fn take_attributed(&self) -> Option<AttributedControlInvocation> {
935        let (taken, origin) = self.queue.borrow_mut().pop_front()?;
936        ui_core::widget::controls::note_drained_origin(origin);
937        Some(taken)
938    }
939
940    pub(crate) fn has_pending(&self) -> bool {
941        !self.queue.borrow().is_empty()
942    }
943
944    /// Whether an edit applied now was refused since the last call, so the
945    /// views are to be presented the stored state again.
946    pub(crate) fn take_represent(&self) -> bool {
947        self.represent.replace(false)
948    }
949
950    /// The step the last boundary opened stays open for the actions that
951    /// continue it ([`Resource::coalesces`]).
952    pub(crate) fn keeps_step_open(&self) -> bool {
953        self.opened.get()
954    }
955
956    pub(crate) fn resource_id(&self) -> &str {
957        &self.resource_id
958    }
959
960    pub(crate) fn app_id(&self) -> &'static str {
961        self.app_id
962    }
963
964    /// Where this dispatcher's resource's state comes from and where its
965    /// edits go ([`super::Source`]).
966    pub(crate) fn source(&self) -> &super::Source {
967        &self.source
968    }
969}
970
971impl<R: Resource> Dispatcher<R> {
972    /// What does not name the resource type.
973    pub(crate) fn core(&self) -> &DispatchCore {
974        &self.core
975    }
976
977    pub(crate) fn for_custody(
978        &self,
979        custody: ui_core::invocation_scope::ResourceInvocationScope,
980    ) -> Self {
981        Self {
982            core: self.core.for_custody(custody),
983            // An effect's group cannot absorb the person's unrelated input.
984            group: Rc::default(),
985            permission_of: Rc::clone(&self.permission_of),
986            submit_now: Rc::clone(&self.submit_now),
987            group_now: self.group_now.clone(),
988            last: Rc::clone(&self.last),
989        }
990    }
991
992    /// What the effect `invocation` submitted here, once it all landed: the
993    /// last submission's [`Landed`], or why one was refused. Nothing arrives
994    /// when it submitted nothing.
995    pub(crate) fn wait_landed(
996        &self,
997        invocation: tilo_actions::InvocationId,
998    ) -> Option<std::sync::mpsc::Receiver<Result<Landed, String>>> {
999        self.core.waits.borrow_mut().wait(invocation)
1000    }
1001
1002    pub(crate) fn new(
1003        app_id: &'static str,
1004        resource_id: &str,
1005        read_only: bool,
1006        me: Option<&str>,
1007    ) -> Self {
1008        Self {
1009            core: DispatchCore::new(app_id, resource_id, (R::KIND, R::LABEL), read_only, me),
1010            group: Rc::default(),
1011            permission_of: Rc::new(tilo_resource::need_of::<R>),
1012            submit_now: Rc::new(|host, edits, action, context| {
1013                edits.apply(host, R::ACTION_TYPE, action, context)
1014            }),
1015            group_now: Some(Rc::new(|host, edits, group, context| {
1016                edits.apply(
1017                    host,
1018                    &tilo_resource::group::group_type::<R>(),
1019                    group,
1020                    context,
1021                )
1022            })),
1023            last: Rc::default(),
1024        }
1025    }
1026
1027    /// A dispatcher for a surface a test builds outside any host, editing as
1028    /// `me`: what the surface applies now ([`Self::apply_now`]) goes into the
1029    /// document the test hands it, as under a host. Queued edits
1030    /// ([`Self::apply`], [`Self::submit`]) wait for a host that never takes
1031    /// them; a test that needs those runs the app
1032    /// ([`TestApp`](super::testing::HeadlessApp)).
1033    #[cfg(any(test, feature = "test-support"))]
1034    #[must_use]
1035    pub fn detached(app_id: &'static str, me: &str) -> Self {
1036        Self::new(app_id, R::KIND, false, Some(me))
1037    }
1038
1039    /// What a [`detached`](Self::detached) dispatcher handed to the door
1040    /// and nothing took yet — a destructive verb or a removal waiting for
1041    /// the person's confirmation — as the actions the door would apply once
1042    /// they agreed. Taking them empties the queue.
1043    #[cfg(any(test, feature = "test-support"))]
1044    pub fn take_asked(&self) -> Result<Vec<R::Action>, String> {
1045        self.core
1046            .queue
1047            .borrow_mut()
1048            .drain(..)
1049            .map(|((invocation, _), _)| {
1050                tilo_resource::build_action::<R>(
1051                    &invocation.address.action,
1052                    &invocation.arguments,
1053                    tilo_resource::origin_now_ms(),
1054                )
1055            })
1056            .collect()
1057    }
1058
1059    /// Who edits through this dispatcher: the participant this view is
1060    /// for, the same id an action they invoke reads as its author
1061    /// ([`ActionCx::author`](tilo_resource::ActionCx::author)). `None` in a
1062    /// view of a past version, which nobody edits.
1063    pub fn me(&self) -> Option<&str> {
1064        self.core.me.as_deref()
1065    }
1066
1067    /// A dispatcher for a past version in History: nothing it is given is
1068    /// ever applied.
1069    pub(crate) fn past(mut self) -> Self {
1070        self.core.past();
1071        self
1072    }
1073
1074    /// The dispatcher of the record the workspace names by `route` (empty for
1075    /// the app's own record of the workspace).
1076    pub(crate) fn on_record(mut self, route: &str) -> Self {
1077        self.core.record = Rc::from(route);
1078        self
1079    }
1080
1081    /// Submissions go over `source`, the session's document host, rather
1082    /// than into the document a host lends the pass
1083    /// ([`super::Source::Connection`]).
1084    pub(crate) fn over_source(mut self, source: super::Source) -> Self {
1085        self.core.source = source;
1086        self
1087    }
1088
1089    /// Whether the view shows a past version in History mode, drawn from a
1090    /// frozen document. Such a view must start no work: no worker, no
1091    /// network, no files. A surface that would fetch or watch something
1092    /// presents what the document holds instead.
1093    pub fn is_past(&self) -> bool {
1094        self.core.past
1095    }
1096
1097    /// Whether edits from this participant will be refused, as of now: it
1098    /// follows role changes while the app runs. Surfaces use it to present
1099    /// themselves read-only instead of letting an edit bounce.
1100    pub fn read_only(&self) -> bool {
1101        self.core.read_only.get()
1102    }
1103
1104    /// [`Self::read_only`] as observable state, for a widget that shows or
1105    /// disables something while the participant may only view.
1106    pub fn read_only_state(&self) -> LocalState<bool> {
1107        self.core.read_only.clone()
1108    }
1109
1110    /// Whether the host currently allows what `need` is, as observable view
1111    /// state. It follows changes even when writing remains allowed and the
1112    /// resource document does not change. Bind it to a conditional pane or
1113    /// label; action invocation still checks the host's current authority.
1114    pub fn permission_state(&self, need: ActionNeed) -> LocalState<bool> {
1115        self.core.asked_state(Asked::Need(need))
1116    }
1117
1118    /// The participant's standing in the workspace as the host keeps it when
1119    /// called: who they are, the workspace's policy and what bounds them. For a
1120    /// runtime that decides its own invocations (a tilolang program's) by
1121    /// the policy the door decides by. `None` outside a host.
1122    pub fn standing_source(&self) -> impl Fn() -> Option<policy::Standing> + 'static {
1123        let permissions = Rc::clone(&self.core.permissions);
1124        move || {
1125            permissions
1126                .borrow()
1127                .as_ref()
1128                .map(|live| live.current().standing().clone())
1129        }
1130    }
1131
1132    /// Whether the participant may govern the workspace (invite, grant, change
1133    /// its settings), as observable view state: for an owner's pane.
1134    pub fn govern_state(&self) -> LocalState<bool> {
1135        self.core.asked_state(Asked::Govern)
1136    }
1137
1138    /// Whether what the workspace holds has arrived, as observable view state:
1139    /// false while the window's workspace has not finished its first sync with
1140    /// its host ([`ui_core::workspace_sync`]), when a record joined from it may
1141    /// still be missing the content its creator wrote. True outside a workspace
1142    /// that syncs with a host, and for a host's own view of a record, which
1143    /// holds what the workspace holds.
1144    pub fn arrived_state(&self) -> LocalState<bool> {
1145        self.core.asked_state(Asked::Arrived)
1146    }
1147
1148    /// The same dispatcher deciding by `permissions`, for a test of a
1149    /// surface whose participant may only comment, or only view.
1150    #[cfg(any(test, feature = "test-support"))]
1151    #[must_use]
1152    pub fn with_permissions(self, permissions: LivePermissions) -> Self {
1153        self.core.follow(permissions);
1154        self.core.refresh_read_only();
1155        self
1156    }
1157
1158    /// Whether the participant may, as of now, do what `need` is: an
1159    /// undoable change for most edits, one of the comments for a reply.
1160    pub fn may(&self, need: &ActionNeed) -> bool {
1161        self.core.may(need)
1162    }
1163
1164    /// Whether the participant may, as of now, change the workspace's settings.
1165    pub fn may_govern(&self) -> bool {
1166        self.core.may_govern()
1167    }
1168
1169    /// Whether the record is shared: someone else belongs to its workspace, or
1170    /// is in it with `people` now. Where it is, reactions are offered.
1171    pub fn is_shared(&self, people: &super::People) -> bool {
1172        self.core.workspace_has_others() || people.others_here()
1173    }
1174
1175    /// Whether the participant may apply `action`
1176    /// ([`tilo_resource::need_of`]).
1177    pub fn may_apply(&self, action: &R::Action) -> bool {
1178        self.core.may(&(self.permission_of)(action))
1179    }
1180
1181    /// The last edit or effect submitted here that was refused — denied
1182    /// permission, unavailable, failed — and why, for a view to show. An
1183    /// edit waiting for the user's confirmation is not refused.
1184    pub fn refused(&self) -> LocalState<Option<Refused>> {
1185        self.core.refused.clone()
1186    }
1187
1188    /// The last submission of this view the door applied, with what it
1189    /// answered: the ids a "new" verb minted ([`Landed::minted`]), for a
1190    /// surface that opens what it just created.
1191    pub fn landed(&self) -> LocalState<Option<Landed>> {
1192        self.core.landed.clone()
1193    }
1194
1195    /// Say `text` of what was just submitted here ("Added Buy milk") once
1196    /// it is kept: at once in a record nobody else decides, once the workspace
1197    /// accepts the change in a shared one, and never when the workspace refuses
1198    /// it ([`Self::refused`] says why instead). The text shows through
1199    /// [`Self::said`].
1200    pub fn say_when_kept(&self, text: impl Into<String>) {
1201        self.core.notices.borrow_mut().push(text.into());
1202    }
1203
1204    /// What [`Self::say_when_kept`] said that was kept, for the view to
1205    /// show and then clear.
1206    pub fn said(&self) -> LocalState<Option<String>> {
1207        self.core.said.clone()
1208    }
1209
1210    /// Why `action` may not be applied by this participant.
1211    fn not_allowed(&self, action: &R::Action) -> ActionError {
1212        ActionError::Refused(self.core.not_allowed(&(self.permission_of)(action)))
1213    }
1214
1215    /// Invoke one of the app's effects (`dispatcher.effect(&Backups::BackUp)`)
1216    /// through the same door as a button bound to it: permission, the
1217    /// effect's availability and a destructive effect's confirmation are
1218    /// checked, then its handler runs. A participant who may only view still
1219    /// invokes the effects declared `kind = "read"`.
1220    pub fn effect<E: Effect>(&self, effect: &E) {
1221        self.core.queue_invocation(
1222            ActionAddress::new(self.core.resource_id.as_ref(), effect.id()),
1223            effect.arguments(),
1224        );
1225    }
1226
1227    /// Whether the person at this device may do `effect` at once, as their
1228    /// own gesture on the surface: a shortcut, a pointer gesture, a row of
1229    /// the surface's own menu, whose arguments the gesture itself supplied
1230    /// (the clipboard they paste). Like a keystroke, such a gesture is its
1231    /// own confirmation, so a surface told `true` performs the effect
1232    /// itself. The confirmation a destructive declaration asks for stays
1233    /// with the callers who supply its arguments — the palette, a script,
1234    /// an agent — because automation is the same door: under any
1235    /// invocation (an invoked action, an effect's handler or completion, a
1236    /// dispatcher lent to one) this is `false`, and the surface submits
1237    /// the effect ([`Self::effect`]) for the host to check and confirm as
1238    /// for any caller. So it is in a past version, and when the
1239    /// participant's role does not grant what the declaration needs.
1240    pub fn is_own_gesture<E: Effect>(&self, effect: &E) -> bool {
1241        if !self.gesture_is_own() {
1242            return false;
1243        }
1244        let id = effect.id();
1245        E::declarations()
1246            .iter()
1247            .find(|decl| decl.id == id)
1248            .is_some_and(|decl| {
1249                self.may(&ActionNeed {
1250                    kind: decl.action_kind,
1251                    field: None,
1252                    privileged: false,
1253                })
1254            })
1255    }
1256
1257    /// Whether what runs now is the person's own gesture at this device,
1258    /// not automation: no invocation, effect, custody or past version is
1259    /// acting.
1260    fn gesture_is_own(&self) -> bool {
1261        !(self.core.past
1262            || self.core.custody.is_some()
1263            || super::effect::acting_for().is_some()
1264            || ui_core::invocation_scope::current_invoker().is_some())
1265    }
1266
1267    /// Invoke `effect` as the live row `row` offers it
1268    /// ([`LiveRow::effect`](super::LiveRow::effect)): under the row's
1269    /// contract, so a confirmation the row asks for (typing the name of the
1270    /// device it erases) is asked here too.
1271    pub fn effect_on<E: Effect>(&self, row: &str, effect: &E) {
1272        self.core
1273            .queue_invocation(ActionAddress::new(row, effect.id()), serde_json::Map::new());
1274    }
1275
1276    /// Submit a field edit (`dispatcher.submit(p.favorite.set(true))`) or a
1277    /// verb (`dispatcher.submit(Snippets::DELETE.with(id))`) through the
1278    /// door, after the event, as a control bound to it would.
1279    ///
1280    /// An effect's invocation ([`Invoke::effect`]) reaches its handler as
1281    /// [`Self::effect`] does.
1282    pub fn submit(&self, submit: impl Submit<R>) {
1283        let id = submit.action_id();
1284        match submit.into_invoke() {
1285            Ok(invoke) => self.submit_invoke(invoke),
1286            Err(error) => {
1287                super::controls::unbound::<R>(&format!("`{id}`"), &error);
1288                self.core.refuse(id, error);
1289            }
1290        }
1291    }
1292
1293    /// [`submit`](Self::submit) what the app does on its own, for nobody's
1294    /// gesture: a read receipt, keeping a record in step with what is shown.
1295    /// It goes through the same door and permissions; a refusal is told in
1296    /// [`Self::refused`] and logged, but never said over the window as if
1297    /// the person had pressed something and been refused.
1298    pub fn submit_on_its_own(&self, submit: impl Submit<R>) {
1299        let before = self.core.asking.replace(Origin::App);
1300        self.submit(submit);
1301        self.core.asking.set(before);
1302    }
1303
1304    fn submit_invoke(&self, invoke: Invoke<R>) {
1305        if self.core.refusal(invoke.id, invoke.is_effect()) {
1306            return;
1307        }
1308        if invoke.is_effect() {
1309            self.queue(invoke);
1310            return;
1311        }
1312        if !self.may_invoke(&invoke) {
1313            self.core.refuse(invoke.id, self.core.view_only());
1314            return;
1315        }
1316        {
1317            let mut group = self.group.borrow_mut();
1318            if group.0 > 0 {
1319                group.1.push(invoke);
1320                return;
1321            }
1322        }
1323        self.queue(invoke);
1324    }
1325
1326    fn queue(&self, invoke: Invoke<R>) {
1327        if self.core.submits_over_connection() {
1328            // The session keeps the record: the action is the person's
1329            // gesture at this view, admitted and committed by its door
1330            // (`one-app-model.md` §4).
1331            self.core.submit_remote(
1332                &ActionAddress::new(self.core.resource_id.as_ref(), invoke.id),
1333                invoke.arguments,
1334                true,
1335            );
1336            return;
1337        }
1338        self.core.queue_invocation(
1339            ActionAddress::new(self.core.resource_id.as_ref(), invoke.id),
1340            invoke.arguments,
1341        );
1342    }
1343
1344    /// Whether the participant may take `invoke`: what its declaration, or
1345    /// the typed value it applies, needs. One this resource does not declare
1346    /// (an edit bound to a surface's view state) is left to the host, which
1347    /// checks what its published action needs. A surface asks before work
1348    /// that only feeds a write (an index it would keep with a row), so a
1349    /// participant who may not make the write is not sent it.
1350    pub fn may_invoke(&self, invoke: &Invoke<R>) -> bool {
1351        // A reaction is decided where the workspace places it, below the
1352        // reactions field, as the door decides it: someone who may only
1353        // comment may react too.
1354        if invoke.id == tilo_resource::FIELD_EDIT && reacts(&invoke.arguments) {
1355            return self.core.may_react();
1356        }
1357        if invoke.id == tilo_resource::APPLY || invoke.id == tilo_resource::FIELD_EDIT {
1358            return match invoke.build(0) {
1359                Ok(action) => self.may_apply(&action),
1360                Err(error) => {
1361                    // What the invocation applies cannot be read here, so the
1362                    // door decides what its published action needs.
1363                    tracing::warn!(%error, action = %invoke.id, "an invocation's typed value could not be built; the door decides");
1364                    !self.read_only()
1365                }
1366            };
1367        }
1368        may_declared(
1369            &self.core,
1370            &tilo_resource::vocabulary::<R>().declarations(),
1371            invoke.id,
1372            invoke.is_group(),
1373        )
1374    }
1375
1376    /// Open the platform search with `request`, narrowed to this app or one
1377    /// part of it (`SearchRequest::within`). Searching is a view, so a
1378    /// participant who may only view can search too.
1379    pub fn open_search(&self, request: &ui_core::search::SearchRequest) {
1380        self.core.queue.borrow_mut().push_back((
1381            (
1382                ui_core::search::open_search_invocation(request),
1383                super::effect::acting_for(),
1384            ),
1385            Origin::Person,
1386        ));
1387    }
1388
1389    /// Ask for the link to `resource` (this resource or one nested in it,
1390    /// such as an item) as a control bound to its `share-link` would: the
1391    /// framework copies it to the clipboard and reports it in
1392    /// [`Self::landed`], or why there is none in [`Self::refused`]. A link
1393    /// grants nothing, so a participant who may only view asks too.
1394    pub fn share_link(&self, resource: &str) {
1395        self.core.queue_invocation(
1396            ActionAddress::new(resource, tilo_resource::share::SHARE_LINK),
1397            serde_json::Map::new(),
1398        );
1399    }
1400
1401    /// The client of the session's action door the views' opens and
1402    /// reads go through.
1403    pub(crate) fn opener(&self) -> &super::reference::Opener {
1404        &self.core.opener
1405    }
1406
1407    /// Open what `href`, a `tilo://open#…` address a link in this view's
1408    /// text carries, names: the app that keeps that resource shows it,
1409    /// started first when no window does.
1410    pub fn open_link(&self, href: &str) -> Result<(), String> {
1411        super::reference::open_link(&self.core.opener, href)
1412    }
1413
1414    /// Open `reference`, which `app` keeps in its `record` (`None`: the
1415    /// app's own record of the workspace), where it lives: its app opens at
1416    /// it, launched first when no window shows it. The open is an action
1417    /// like any other, admitted as the person under their grants; a refusal
1418    /// is told in [`Self::refused`] as `open`.
1419    pub fn open_reference(
1420        &self,
1421        app: &str,
1422        record: Option<&str>,
1423        reference: &tilo_reference::ResourceRef,
1424    ) {
1425        if let Err(reason) = self.core.opener.open(app, record, reference) {
1426            self.core.refuse(super::reference::OPEN, reason);
1427        }
1428    }
1429
1430    /// Send `invoke`, an action of this resource, to the session's host
1431    /// instead of applying it here. The host decides it as the person's, as
1432    /// it decides any caller's, may first put it to the person on its own
1433    /// sheet (what a run asks is answered there, never by the app), and then
1434    /// directs it back to be applied. A refusal is told in
1435    /// [`Self::refused`].
1436    pub fn through_host(&self, invoke: &Invoke<R>) {
1437        if let Some(refusal) = invoke.refusal() {
1438            self.core.refuse(invoke.id, refusal.to_owned());
1439            return;
1440        }
1441        self.core.opener.invoke_own(
1442            ActionAddress::new(self.core.resource_id(), invoke.id),
1443            invoke.arguments.clone(),
1444        );
1445    }
1446
1447    /// Send `call`, another resource's published action, to the session's
1448    /// host, continuing `parent`, the invocation the host delivered here for
1449    /// someone: the host decides it as theirs. A past version calls nothing.
1450    pub(crate) fn call_published(
1451        &self,
1452        parent: Option<tilo_actions::InvocationId>,
1453        call: &super::Call,
1454    ) -> Result<ui_core::InvocationCall, String> {
1455        if self.core.past {
1456            return Err("a past version calls nothing".into());
1457        }
1458        if let Some(custody) = &self.core.custody {
1459            custody.check()?;
1460        }
1461        let (target, argument) = call.request()?;
1462        Ok(self.core.opener.call(parent, target, argument))
1463    }
1464
1465    /// Invoke an action published on a nested resource, such as one item of
1466    /// a collection.
1467    pub(crate) fn invoke_at(
1468        &self,
1469        address: ActionAddress,
1470        arguments: serde_json::Map<String, serde_json::Value>,
1471    ) {
1472        if self.read_only() {
1473            return;
1474        }
1475        if self.core.submits_over_connection() {
1476            self.core.submit_remote(&address, arguments, true);
1477            return;
1478        }
1479        self.core.queue_invocation(address, arguments);
1480    }
1481
1482    /// Run `invoke`, one of the actions `actions` publishes (another
1483    /// record's, such as a settings section), through the host as a control
1484    /// bound to it runs it: where another provider keeps the record, it runs
1485    /// there. From an effect's handler or completion it continues that
1486    /// effect, as its caller's. A refusal is told in [`Self::refused`].
1487    pub fn invoke_on<T: Resource>(&self, actions: &super::Actions<T>, invoke: Invoke<T>) {
1488        if let Some(refusal) = invoke.refusal() {
1489            self.core.refuse(invoke.id, refusal.to_owned());
1490            return;
1491        }
1492        self.invoke_at(
1493            ActionAddress::new(actions.resource_id(), invoke.id),
1494            invoke.arguments,
1495        );
1496    }
1497
1498    /// Submit one typed action value — an edit with no named declaration,
1499    /// such as a drag's final geometry. The door applies it after the
1500    /// event; if it refuses it, [`Self::refused`] says why.
1501    pub fn apply(&self, action: &R::Action) {
1502        match Invoke::<R>::apply(action) {
1503            Ok(invoke) => self.submit_invoke(invoke),
1504            Err(error) => self.core.refuse(tilo_resource::APPLY, error.to_string()),
1505        }
1506    }
1507
1508    /// Submit every edit `edits` makes through this dispatcher (or a clone
1509    /// of it) as one action: one change, one version in history, one undo
1510    /// step. For a gesture that is several edits — split a linked clip pair,
1511    /// delete a selection — which the user takes back with one Ctrl+Z.
1512    ///
1513    /// One action is one undo step by default; a group is the explicit
1514    /// exception. Groups nest: the outermost one submits. A group of one
1515    /// edit submits that edit unchanged.
1516    pub fn group<T>(&self, edits: impl FnOnce() -> T) -> T {
1517        let _group = self.begin_group();
1518        edits()
1519    }
1520
1521    /// Open a group that submits when the returned guard drops: the
1522    /// [`group`](Self::group) for code whose edits are spread over a scope,
1523    /// such as one input event of a custom surface.
1524    #[must_use = "the group submits when this guard drops"]
1525    pub fn begin_group(&self) -> DispatchGroup<R> {
1526        let mut group = self.group.borrow_mut();
1527        group.0 = group.0.saturating_add(1);
1528        drop(group);
1529        DispatchGroup {
1530            dispatcher: self.clone(),
1531        }
1532    }
1533
1534    fn end_group(&self) {
1535        let members = {
1536            let mut group = self.group.borrow_mut();
1537            group.0 = group.0.saturating_sub(1);
1538            if group.0 > 0 {
1539                return;
1540            }
1541            std::mem::take(&mut group.1)
1542        };
1543        let mut members = members.into_iter();
1544        match (members.next(), members.next()) {
1545            (None, _) => {}
1546            (Some(only), None) => self.queue(only),
1547            (Some(first), Some(second)) => {
1548                self.queue(Invoke::group([first, second].into_iter().chain(members)));
1549            }
1550        }
1551    }
1552
1553    /// Apply a field edit (`p.title.splice(..)`) or a verb to the document
1554    /// `edits` lends, now, as [`Self::apply_now`] applies a typed action: for
1555    /// an editor that must see its own edit before the next keystroke.
1556    ///
1557    /// One that needs confirming (a destructive verb, a removal) is not
1558    /// applied now: it goes to the host's door, which asks first, and
1559    /// applies once the person agreed, as [`Self::submit`] does.
1560    ///
1561    /// # Errors
1562    /// Returns why the submission does not build, or as [`Self::apply_now`].
1563    pub fn submit_now(
1564        &self,
1565        edits: &mut Edits<'_>,
1566        submit: impl Submit<R>,
1567    ) -> Result<(), ActionError> {
1568        let invoke = submit.into_invoke().map_err(ActionError::Refused)?;
1569        if invoke.is_group() {
1570            return self.group_now(edits, &invoke);
1571        }
1572        let _capture = self.capture_invocation(&invoke);
1573        let action = invoke
1574            .build(tilo_resource::origin_now_ms())
1575            .map_err(ActionError::Refused)?;
1576        if asks(&action) {
1577            return self.ask_first(invoke, &action);
1578        }
1579        self.apply_now(edits, &action)
1580    }
1581
1582    /// [`Self::submit_now`] of the person's own gesture on the surface: the
1583    /// button they pressed, the shortcut they typed, with arguments the
1584    /// gesture itself supplied. Like a keystroke, such a gesture is its own
1585    /// confirmation, so a destructive verb the surface offers with its own
1586    /// way back (a message held so Undo takes it back unsent) applies now.
1587    /// What has no way back (`#[action(irreversible)]`) is still asked first.
1588    /// The confirmation stays with the callers who supply the arguments
1589    /// themselves (the palette, a script, an agent): under any invocation,
1590    /// an effect's handler, custody or a past version this is
1591    /// [`Self::submit_now`], and the host asks as for any caller.
1592    ///
1593    /// # Errors
1594    /// As [`Self::submit_now`].
1595    pub fn submit_gesture(
1596        &self,
1597        edits: &mut Edits<'_>,
1598        submit: impl Submit<R>,
1599    ) -> Result<(), ActionError> {
1600        if !self.gesture_is_own() {
1601            return self.submit_now(edits, submit);
1602        }
1603        let invoke = submit.into_invoke().map_err(ActionError::Refused)?;
1604        if invoke.is_group() {
1605            return self.group_now(edits, &invoke);
1606        }
1607        let action = invoke
1608            .build(tilo_resource::origin_now_ms())
1609            .map_err(ActionError::Refused)?;
1610        // The gesture confirms only what has a way back: a verb that loses
1611        // something Undo cannot bring back is asked first, as at every door.
1612        if tilo_actions::Action::irreversible(&action) && asks(&action) {
1613            return self.ask_first(invoke, &action);
1614        }
1615        let applied = self.apply_now_as(edits, &action, self.core.context(), true);
1616        // A view that showed the edit before it was refused (a row already
1617        // gone) shows the stored state again.
1618        if applied.is_err() {
1619            self.core.represent.set(true);
1620        }
1621        applied
1622    }
1623
1624    /// [`Self::submit_gesture`] of a choice the person picked that is
1625    /// confirmed before it goes ahead, though Undo would take it back:
1626    /// Delete in a message's context menu. The host asks with the same
1627    /// question it asks for any confirmation; holding Shift skips it, and
1628    /// the choice applies at once as the person's gesture, if and only if
1629    /// Undo takes it back ([`ui_core::widget::shift_skips_confirmation`]).
1630    /// What Undo cannot take back is asked, Shift or not.
1631    ///
1632    /// A control the person presses is its own confirmation
1633    /// ([`Self::submit_gesture`]); this is for a choice made from a list of
1634    /// them, where a slip of the pointer picks the neighbour.
1635    ///
1636    /// # Errors
1637    /// As [`Self::submit_gesture`].
1638    pub fn submit_confirming(
1639        &self,
1640        edits: &mut Edits<'_>,
1641        submit: impl Submit<R>,
1642    ) -> Result<(), ActionError> {
1643        if !self.gesture_is_own() {
1644            return self.submit_now(edits, submit);
1645        }
1646        let invoke = submit.into_invoke().map_err(ActionError::Refused)?;
1647        if invoke.is_group() {
1648            return self.group_now(edits, &invoke);
1649        }
1650        let action = invoke
1651            .build(tilo_resource::origin_now_ms())
1652            .map_err(ActionError::Refused)?;
1653        if asks(&action) {
1654            // The contract asks already: the door's question, which Shift
1655            // skips there for what Undo takes back.
1656            return self.ask_first(invoke, &action);
1657        }
1658        let undoable = tilo_actions::undo_takes_back(
1659            tilo_actions::Action::action_kind(&action),
1660            tilo_actions::Action::change_kind(&action),
1661            tilo_actions::Action::irreversible(&action),
1662        );
1663        if ui_core::widget::shift_skips_confirmation(undoable) {
1664            return self.submit_gesture(edits, invoke);
1665        }
1666        if let Some(reason) = self.core.unreadable() {
1667            return Err(ActionError::Refused(reason));
1668        }
1669        if !self.may_apply(&action) {
1670            return Err(self.not_allowed(&action));
1671        }
1672        let before = self.core.asking.replace(Origin::PersonConfirming);
1673        self.submit_invoke(invoke);
1674        self.core.asking.set(before);
1675        Ok(())
1676    }
1677
1678    fn ask_first(&self, invoke: Invoke<R>, action: &R::Action) -> Result<(), ActionError> {
1679        if let Some(reason) = self.core.unreadable() {
1680            return Err(ActionError::Refused(reason));
1681        }
1682        if !self.may_apply(action) {
1683            return Err(self.not_allowed(action));
1684        }
1685        self.submit_invoke(invoke);
1686        Ok(())
1687    }
1688
1689    /// [`Self::submit_now`] of a group (`Invoke::group(edits)`): its members
1690    /// applied as one action, one change and one entry in history, all or
1691    /// nothing.
1692    fn group_now(&self, edits: &mut Edits<'_>, invoke: &Invoke<R>) -> Result<(), ActionError> {
1693        let _nonlocal = self.capture_nonlocal();
1694        let _capture = self.capture_invocation(invoke);
1695        let Some(group_now) = &self.group_now else {
1696            return Err(ActionError::Refused(
1697                "this record's edits are applied one at a time".into(),
1698            ));
1699        };
1700        self.core.applies_now()?;
1701        let group = tilo_resource::group::build_group::<R>(
1702            &invoke.arguments,
1703            tilo_resource::origin_now_ms(),
1704        )
1705        .map_err(ActionError::Refused)?;
1706        if let Some(refused) = group.actions.iter().find(|action| !self.may_apply(action)) {
1707            return Err(self.not_allowed(refused));
1708        }
1709        // A group holding a removal asks first, as the queued door does.
1710        if group.actions.iter().any(asks) {
1711            self.submit_invoke(invoke.clone());
1712            return Ok(());
1713        }
1714        let mut context = self.core.context();
1715        if let Some(me) = &self.core.me {
1716            context = context.with_invoker(ActionInvoker::actor(me.as_ref()));
1717        }
1718        group_now(&mut self.core.host.borrow_mut(), edits, &group, context)?;
1719        // A group is a step of its own: nothing continues it.
1720        *self.last.borrow_mut() = None;
1721        self.core.grouped();
1722        Ok(())
1723    }
1724
1725    /// Apply one typed action to the document `edits` lends — an event's,
1726    /// or a settle pass's — now, attributed to the participant, with history
1727    /// recording what the action did.
1728    /// One event's actions are one undo step, and an action that continues
1729    /// the previous one ([`Resource::coalesces`], the next letter of a word)
1730    /// joins its step. An action that needs confirming (a destructive verb,
1731    /// a removal) is not applied now: it is submitted to the host's door,
1732    /// which asks the person first ([`Self::apply`]).
1733    ///
1734    /// # Errors
1735    /// Returns [`ActionError::Refused`] for a participant who may only view,
1736    /// and the action's own refusal; nothing is written either way.
1737    pub fn apply_now(&self, edits: &mut Edits<'_>, action: &R::Action) -> Result<(), ActionError> {
1738        self.apply_now_as(edits, action, self.core.context(), false)
1739    }
1740
1741    /// [`Self::apply_now`] of an action the person already confirmed, as
1742    /// the host applies it once they agreed: what a test resolving the
1743    /// queue as confirmed applies.
1744    ///
1745    /// # Errors
1746    /// As [`apply_now`](Self::apply_now).
1747    pub fn apply_confirmed(
1748        &self,
1749        edits: &mut Edits<'_>,
1750        action: &R::Action,
1751    ) -> Result<(), ActionError> {
1752        self.apply_now_as(edits, action, self.core.context(), true)
1753    }
1754
1755    /// Apply `action` now to the document `edits` lends, for `invoker`: a
1756    /// step a door carries out for whoever invoked it that writes this
1757    /// record too (a container listing a new child), attributed to them.
1758    ///
1759    /// # Errors
1760    /// As [`apply_now`](Self::apply_now).
1761    pub(crate) fn apply_now_for(
1762        &self,
1763        edits: &mut Edits<'_>,
1764        action: &R::Action,
1765        invoker: &tilo_actions::ActionInvoker,
1766    ) -> Result<(), ActionError> {
1767        let context = self.core.context().with_invoker(invoker.clone());
1768        self.apply_now_as(edits, action, context, true)
1769    }
1770
1771    fn apply_now_as(
1772        &self,
1773        edits: &mut Edits<'_>,
1774        action: &R::Action,
1775        context: ActionContext,
1776        confirmed: bool,
1777    ) -> Result<(), ActionError> {
1778        let _nonlocal = self.capture_nonlocal();
1779        self.core.applies_now()?;
1780        if !self.may_apply(action) {
1781            return Err(self.not_allowed(action));
1782        }
1783        if self.core.submits_over_connection() {
1784            // Nothing to apply to here: the session keeps the record, so the
1785            // edit is the same typed action submitted over the connection
1786            // (`one-app-model.md` §4). The view shows its own result until
1787            // the session's reading follows it.
1788            let invoke = Invoke::<R>::apply(action).map_err(ActionError::Encode)?;
1789            self.core.submit_remote(
1790                &ActionAddress::new(self.core.resource_id.as_ref(), invoke.id),
1791                invoke.arguments,
1792                true,
1793            );
1794            return Ok(());
1795        }
1796        // Applying now never skips the person's consent: an action that
1797        // needs confirming goes to the door, which asks first.
1798        if asks(action) && !confirmed {
1799            self.apply(action);
1800            return Ok(());
1801        }
1802        // Content from outside is nobody's step: it neither continues the
1803        // open one nor ends it.
1804        let step = tilo_actions::Action::change_kind(action).is_step();
1805        // Only an action right after this dispatcher's last one, with nothing
1806        // in between, can continue it.
1807        let serial = self.core.serial.get();
1808        let continues = step
1809            && self
1810                .last
1811                .borrow()
1812                .as_ref()
1813                .is_some_and(|(at, previous)| *at == serial && R::coalesces(previous, action));
1814        let context = self.core.step_context(context, step, continues);
1815        let fallback = if !ui_core::local_document_action::input_is_active()
1816            || ui_core::local_document_action::has_invocation()
1817        {
1818            None
1819        } else {
1820            Some(Invoke::<R>::apply(action).map_err(ActionError::Encode)?)
1821        };
1822        let _capture = fallback
1823            .as_ref()
1824            .and_then(|invoke| self.capture_invocation(invoke));
1825        (self.submit_now)(&mut self.core.host.borrow_mut(), edits, action, context)?;
1826        if !step {
1827            return Ok(());
1828        }
1829        let serial = self.core.stepped(continues, R::continuable(action));
1830        *self.last.borrow_mut() = Some((serial, action.clone()));
1831        Ok(())
1832    }
1833
1834    /// The dispatcher of another record than this resource's — the
1835    /// person's own record, a settings section — published at `id`: its
1836    /// queued invocations go through the same door as this resource's, and
1837    /// what it applies now `apply` applies to that record. The workspace's roles
1838    /// do not reach it; only its person changes it.
1839    pub(crate) fn for_record<C: Resource>(
1840        &self,
1841        id: &str,
1842        read_only: bool,
1843        apply: impl Fn(&C::Action) -> Result<(), ActionError> + 'static,
1844    ) -> Dispatcher<C> {
1845        Dispatcher {
1846            core: self.core.for_record(id, (C::KIND, C::LABEL), read_only),
1847            group: Rc::default(),
1848            permission_of: Rc::new(|_: &C::Action| ActionNeed::of(tilo_actions::ActionKind::Read)),
1849            submit_now: Rc::new(move |_, _, action: &C::Action, _| apply(action)),
1850            group_now: None,
1851            last: Rc::default(),
1852        }
1853    }
1854
1855    /// The dispatcher of a record the app keeps beside its own in the workspace,
1856    /// published at `id` (the parts of a chain): its queued invocations are
1857    /// addressed to `id`, `apply` applies one now, and what an action needs
1858    /// is its own declared need, checked against the participant's roles in
1859    /// the workspace as this resource's actions are.
1860    pub(crate) fn for_workspace_record<C: Resource>(
1861        &self,
1862        id: &str,
1863        read_only: bool,
1864        apply: impl Fn(&C::Action) -> Result<(), ActionError> + 'static,
1865    ) -> Dispatcher<C> {
1866        let mut core = self.core.for_record(id, (C::KIND, C::LABEL), read_only);
1867        core.permissions = Rc::clone(&self.core.permissions);
1868        Dispatcher {
1869            core,
1870            group: Rc::default(),
1871            permission_of: Rc::new(tilo_resource::need_of::<C>),
1872            submit_now: Rc::new(move |_, _, action: &C::Action, _| apply(action)),
1873            group_now: None,
1874            last: Rc::default(),
1875        }
1876    }
1877
1878    /// The dispatcher of a resource nested in this one at `id` — a note's
1879    /// rich-text body, a draft's SQL — whose every edit is this resource's
1880    /// action `wrap(edit)`.
1881    ///
1882    /// The child is edited like a root: its named actions,
1883    /// [`apply`](Self::apply), [`group`](Self::group) and
1884    /// [`apply_now`](Self::apply_now) take `C`'s own actions, coalesce into
1885    /// undo steps by `C`'s [`Resource::coalesces`], and history names them
1886    /// after `C`'s labels. Underneath, each applies as this resource's
1887    /// action: the same permission check, history, undo and replay. Queued
1888    /// invocations are addressed to the child resource `id`, so this
1889    /// resource publishes the child at the same id with the same `wrap`
1890    /// ([`tilo_resource::publish_child`] in [`Resource::children`]), which
1891    /// is also what lets the palette, agents and scripts reach it.
1892    #[must_use]
1893    pub fn child<C: Resource>(
1894        &self,
1895        id: &str,
1896        wrap: impl Fn(C::Action) -> R::Action + 'static,
1897    ) -> Dispatcher<C> {
1898        let parent = Rc::clone(&self.submit_now);
1899        let parent_permission = Rc::clone(&self.permission_of);
1900        let wrap = Rc::new(wrap);
1901        let wrap_for_permission = Rc::clone(&wrap);
1902        Dispatcher {
1903            core: self.core.child(id, (C::KIND, C::LABEL)),
1904            group: Rc::default(),
1905            permission_of: Rc::new(move |action: &C::Action| {
1906                parent_permission(&wrap_for_permission(action.clone()))
1907            }),
1908            // History names the child and its edit; what applies is the
1909            // parent's action carrying it.
1910            group_now: None,
1911            submit_now: Rc::new(move |host, edits, action: &C::Action, context| {
1912                parent(host, edits, &wrap(action.clone()), context)
1913            }),
1914            last: Rc::default(),
1915        }
1916    }
1917
1918    fn capture_nonlocal(&self) -> Option<ui_core::local_document_action::Scope> {
1919        (self.core.custody.is_some() || super::effect::acting_for().is_some())
1920            .then(ui_core::local_document_action::suspend)
1921    }
1922
1923    fn capture_invocation(
1924        &self,
1925        invoke: &Invoke<R>,
1926    ) -> Option<ui_core::local_document_action::InvocationScope> {
1927        if !ui_core::local_document_action::input_is_active()
1928            || self.core.custody.is_some()
1929            || super::effect::acting_for().is_some()
1930            || ui_core::local_document_action::has_invocation()
1931        {
1932            return None;
1933        }
1934        Some(ui_core::local_document_action::with_invocation(
1935            ui_core::local_document_action::PublishedInvocation {
1936                resource: self.core.resource_id.to_string(),
1937                action: invoke.id.to_owned(),
1938                arguments: invoke.arguments.clone(),
1939            },
1940        ))
1941    }
1942
1943    /// End the gesture the last immediate actions belonged to: the pointer
1944    /// was let go of a slider or a drag handle. The next action starts a
1945    /// step of its own even when it would continue the last one
1946    /// ([`Resource::coalesces`]), so two drags of one slider are two undo
1947    /// steps while every value one drag passes through is one.
1948    pub fn settle(&self) {
1949        self.last.borrow_mut().take();
1950    }
1951
1952    pub(crate) fn name_resource(&self, label: &str) {
1953        self.core.resource_label.set(label.to_owned());
1954    }
1955
1956    /// The id of the app whose resource this is, which presence keys name
1957    /// ([`ResourceSelection::presence`](super::ResourceSelection::presence)).
1958    pub fn app_id(&self) -> &'static str {
1959        self.core.app_id
1960    }
1961}
1962
1963/// Whether the participant `core` edits for may take the action `id` the
1964/// resource's `declarations` may declare: what its declaration needs; a
1965/// group of edits what writing does; anything else what reading does.
1966fn may_declared(
1967    core: &DispatchCore,
1968    declarations: &[tilo_resource::Declaration],
1969    id: &str,
1970    group: bool,
1971) -> bool {
1972    match declarations.iter().find(|decl| decl.id == id) {
1973        Some(decl) => core.may(&ActionNeed {
1974            kind: decl.kind,
1975            field: decl.field.map(str::to_owned),
1976            privileged: false,
1977        }),
1978        None if group => !core.read_only.get(),
1979        None => core.may(&ActionNeed::of(ActionKind::Read)),
1980    }
1981}
1982
1983/// An edit or effect the door refused ([`Dispatcher::refused`]), or one the
1984/// workspace refused after this device applied it.
1985#[derive(Clone, Debug, PartialEq, Eq)]
1986pub struct Refused {
1987    /// The id of the refused action or effect: for one the workspace refused,
1988    /// the verb's key or the edited field's path.
1989    pub action: String,
1990    /// The item it acted on, when the workspace refused it and it named one.
1991    pub item: Option<String>,
1992    /// Why, for a person.
1993    pub reason: String,
1994    /// The workspace refused it: it was applied here, never reached anyone
1995    /// else, and was withdrawn again. Submitting it again is a new action.
1996    pub by_workspace: bool,
1997}
1998
1999/// A submission the door applied ([`Dispatcher::landed`]).
2000#[derive(Clone, Debug, PartialEq)]
2001pub struct Landed {
2002    /// The id of the applied action.
2003    pub action: String,
2004    /// What it answered, if anything.
2005    pub answer: Option<serde_json::Value>,
2006    /// The resources it created (`<kind>/<field>/<id>` for an item).
2007    pub created: Vec<String>,
2008}
2009
2010impl Landed {
2011    fn of(action: &str, output: &tilo_actions::ActionOutput) -> Self {
2012        Self {
2013            action: action.to_owned(),
2014            answer: output.value.clone(),
2015            created: output.resources.clone(),
2016        }
2017    }
2018
2019    /// The ids the action minted, in the order it minted them: the item a
2020    /// "new" verb created first.
2021    #[must_use]
2022    pub fn minted(&self) -> Vec<tilo_resource::Id> {
2023        self.created
2024            .iter()
2025            .filter_map(|resource| resource.rsplit('/').next())
2026            .map(tilo_resource::Id::from_raw)
2027            .collect()
2028    }
2029}
2030
2031/// Whether `action` needs the person's confirmation before it applies, by
2032/// the one rule every door asks (`tilo_actions::asks_first`): its risk asks
2033/// for it and Undo cannot take it back. A removal that only changes the
2034/// document runs at once; Undo is how the person takes it back. One that
2035/// loses something outside the document (`irreversible`) still asks.
2036fn asks<A: tilo_actions::Action>(action: &A) -> bool {
2037    tilo_actions::asks_first(
2038        tilo_actions::Action::risk(action),
2039        tilo_actions::Action::action_kind(action),
2040        tilo_actions::Action::change_kind(action),
2041        tilo_actions::Action::irreversible(action),
2042    )
2043}
2044
2045/// The submissions each effect queued that have not landed yet, and the
2046/// effects waiting for theirs.
2047#[derive(Default)]
2048struct Waits(std::collections::HashMap<tilo_actions::InvocationId, Wait>);
2049
2050#[derive(Default)]
2051struct Wait {
2052    /// Queued, not yet answered by the door.
2053    outstanding: usize,
2054    /// The last that landed, or the first refusal.
2055    result: Option<Result<Landed, String>>,
2056    waiter: Option<std::sync::mpsc::Sender<Result<Landed, String>>>,
2057}
2058
2059impl Waits {
2060    fn submitted(&mut self, invocation: tilo_actions::InvocationId) {
2061        let wait = self.0.entry(invocation).or_default();
2062        wait.outstanding = wait.outstanding.saturating_add(1);
2063    }
2064
2065    fn landed(&mut self, invocation: tilo_actions::InvocationId, landed: Result<Landed, String>) {
2066        let Some(wait) = self.0.get_mut(&invocation) else {
2067            return;
2068        };
2069        wait.outstanding = wait.outstanding.saturating_sub(1);
2070        // The first refusal stands: the effect failed with it.
2071        if !matches!(wait.result, Some(Err(_))) {
2072            wait.result = Some(landed);
2073        }
2074        self.settle(invocation);
2075    }
2076
2077    fn wait(
2078        &mut self,
2079        invocation: tilo_actions::InvocationId,
2080    ) -> Option<std::sync::mpsc::Receiver<Result<Landed, String>>> {
2081        let wait = self.0.get_mut(&invocation)?;
2082        let (sender, receiver) = std::sync::mpsc::channel();
2083        wait.waiter = Some(sender);
2084        self.settle(invocation);
2085        Some(receiver)
2086    }
2087
2088    /// Answer the waiting effect once nothing it submitted is outstanding;
2089    /// forget an effect nobody waits on then.
2090    fn settle(&mut self, invocation: tilo_actions::InvocationId) {
2091        let Some(wait) = self.0.get_mut(&invocation) else {
2092            return;
2093        };
2094        if wait.outstanding > 0 {
2095            return;
2096        }
2097        if let Some(waiter) = wait.waiter.take()
2098            && let Some(result) = wait.result.take()
2099        {
2100            let _ = waiter.send(result);
2101        }
2102        self.0.remove(&invocation);
2103    }
2104}
2105
2106/// An open [`Dispatcher::begin_group`]: every edit submitted until it drops
2107/// becomes one action.
2108pub struct DispatchGroup<R: Resource> {
2109    dispatcher: Dispatcher<R>,
2110}
2111
2112impl<R: Resource> Drop for DispatchGroup<R> {
2113    fn drop(&mut self) {
2114        self.dispatcher.end_group();
2115    }
2116}