Skip to main content

tilo_app/framework/
root.rs

1//! The root widget of a framework app: keeps the resource state current,
2//! publishes the resource and its actions, forwards edits to the host, and
3//! indexes the resource for search.
4
5use std::collections::{BTreeMap, BTreeSet, HashSet};
6use std::rc::Rc;
7use std::sync::Arc;
8use ui_core::widget::{AppModel, ModelPath};
9
10use crate::runner::with_native_presentations;
11use tilo_resource::{Resource, ScopeDecl, SearchEntry};
12use tilo_search::{Document, FieldWeight, Matching, Page, SearchIndex};
13use ui_core::PresencePayload;
14use ui_core::application_view::{
15    ApplicationFocusRestore, ApplicationLocalView, ApplicationViewState,
16};
17use ui_core::automerge::AutoCommit;
18use ui_core::permissions::{LivePermissions, PermissionScope};
19use ui_core::presence::{PresenceCtx, PresenceKey};
20use ui_core::record::RecordSaveStatus;
21use ui_core::search::{
22    DEFAULT_SEARCH_CANDIDATE_LIMIT, DEFAULT_SEARCH_RESULT_LIMIT, SearchCursor, SearchPage,
23    SearchProvider, SearchQuery, SearchResult,
24};
25use ui_core::text_render::TextRenderer;
26use ui_core::vello::Scene;
27use ui_core::vello::kurbo::Affine;
28use ui_core::view::ThawError;
29use ui_core::widget::capability::{capability, capability_mut};
30use ui_core::widget::{AppKind, AppWidget, Decorator, Element, IconDescriptor, Widget};
31use ui_core::{LocalState, Theme};
32
33use super::dispatch::DispatchCore;
34use super::rows::{find, join, merge, outside_document};
35use super::scenario::Run;
36use super::stage::Stages;
37use super::surface::SurfacePublication;
38use super::view::ViewCx;
39
40mod shared_tree;
41mod typed;
42mod widget;
43use shared_tree::{ItemChildren, NestedViews, PublishedChildren, RecordChildren, SharedTreeCache};
44use typed::TypedRoot;
45pub(crate) use typed::{ResourceType, Typed};
46pub(crate) use widget::{RootApp, RootControls, RootModel, RootPublishes, RootSearch, RootWidget};
47
48pub(crate) struct ResourceRoot {
49    title: &'static str,
50    icon: IconDescriptor,
51    label: String,
52    /// The resource type the root shows, and what it says of itself.
53    resource: ResourceType,
54    /// What depends on the resource type: its live state, the views'
55    /// updaters and bound edits, the dispatcher and the effects. Everything
56    /// else here is compiled once in the shared runtime.
57    typed: Box<dyn TypedRoot>,
58    /// What bound controls look up ([`super::controls::Actions`]).
59    action_bindings: super::controls::ActionTable,
60    /// The live revision bound controls last followed.
61    followed: std::cell::Cell<u64>,
62    _alive: Rc<()>,
63    dispatch: DispatchCore,
64    saved: LocalState<RecordSaveStatus>,
65    /// The participant's permissions, re-checked every sync so a role change
66    /// while the app runs takes effect at once. `None` for a read-only view
67    /// of a past version.
68    permissions: Option<LivePermissions>,
69    write: Option<PermissionScope>,
70    stages: Stages,
71    feeds: super::history::HistoryFeeds,
72    /// What the view was built for, and the fixtures a capture's views read.
73    pub(crate) run: Run,
74    /// The settings the view follows, re-read when their document changes.
75    pub(crate) storage: Rc<super::settings::ViewStorage>,
76    /// The app's view: the custom surface itself when the view is one.
77    pub(crate) content: Element,
78    /// What is shown instead of the view while the record does not read as
79    /// the resource, and why.
80    unreadable: Option<(String, Element)>,
81    /// The view was swapped for the unreadable state or back since layout.
82    swapped: bool,
83    /// View and effect operations of the app's presentation, attached to the
84    /// published resource. Document specs are dropped.
85    presentation: Vec<tilo_actions::ActionSpec>,
86    /// The resource's and its surfaces' search entries, indexed.
87    search: std::cell::RefCell<Option<SearchCache>>,
88    /// The document-owned tree at one live and translation revision, shared
89    /// with the last one wherever the change left it as it was. Surface
90    /// bindings, local rows and effect availability are decorated afresh
91    /// for every caller.
92    publication_tree: std::cell::RefCell<Option<SharedTreeCache>>,
93    /// The item children last published, kept so the next tree builds only
94    /// the items whose label changed.
95    item_children: std::cell::RefCell<Option<ItemChildren>>,
96    /// The document children as the shared publication last carried them.
97    published_children: std::cell::RefCell<Option<PublishedChildren>>,
98    /// The children with the records kept beside the resource, as the shared
99    /// publication last carried them.
100    record_children: std::cell::RefCell<Option<RecordChildren>>,
101    /// The head bound controls were last refreshed from, kept between syncs
102    /// for a resource whose publication may be reused
103    /// ([`Resource::publication_cache_epoch`]).
104    binding_head: std::cell::RefCell<Option<BindingHead>>,
105    /// What bound controls share of the publication at one head: built
106    /// once per head, never copied per refresh.
107    binding_base: std::cell::RefCell<Option<Rc<BindingBase>>>,
108    /// The view operation types of the nested resources at one live revision.
109    nested_views: std::cell::RefCell<Option<NestedViews>>,
110    /// Whether the resource keeps document state History shows.
111    history: bool,
112    /// Platform presentation actions; the browser implementation has no state.
113    native_presentation: crate::runner::Presentation,
114    /// The links `share-link` is minting.
115    share: super::share_link::ShareLinks,
116}
117
118impl ui_core::component_lifecycle::WindowActivation for ResourceRoot {
119    fn take_window_activation_intent(&self) -> bool {
120        self.native_presentation.take_window_activation_intent()
121    }
122}
123
124/// The operation type of the framework's `open` view: show the item at an
125/// anchor, as picking a search hit does.
126pub(crate) const OPEN_TYPE: &str = "tilo.app.open";
127
128/// The parameter of `open` naming what to show.
129const OPEN_ANCHOR: &str = "anchor";
130
131/// `open`: show the item at an anchor (a search entry's anchor). Published
132/// when a view shows items ([`ViewCx::on_open`](super::ViewCx::on_open)), so
133/// an agent opens the app where a person's search hit would.
134fn open_spec() -> tilo_actions::ActionSpec {
135    tilo_actions::ActionSpec::typed_operation(
136        "open",
137        "Open",
138        "Item to open",
139        tilo_actions::OperationKind::View,
140        OPEN_TYPE,
141        vec![tilo_actions::ActionParam::required(
142            OPEN_ANCHOR,
143            tilo_actions::ParamType::Text,
144            "Item to open: the anchor of a search hit",
145        )],
146        |args| {
147            tilo_actions::EncodedAction::encode_operation(
148                OPEN_TYPE,
149                &serde_json::json!({ OPEN_ANCHOR: args.text(OPEN_ANCHOR) }),
150            )
151            .unwrap_or_else(|_| tilo_actions::EncodedAction {
152                type_id: OPEN_TYPE.into(),
153                payload: Vec::new(),
154            })
155        },
156    )
157    .of_kind(tilo_actions::ActionKind::Read)
158    .with_description("Open the app at one item: the anchor of a search hit.")
159}
160
161/// The label of the item view the framework gives an item resource that
162/// presents no view of its own.
163const ITEM_VIEW_LABEL: &str = "Item";
164
165/// `open` on one item resource: show that item, as its search hit would.
166fn item_open_spec(anchor: &str) -> tilo_actions::ActionSpec {
167    let encoded = tilo_actions::EncodedAction::encode_operation(
168        OPEN_TYPE,
169        &serde_json::json!({ OPEN_ANCHOR: anchor }),
170    )
171    .unwrap_or_else(|_| tilo_actions::EncodedAction {
172        type_id: OPEN_TYPE.into(),
173        payload: Vec::new(),
174    });
175    tilo_actions::ActionSpec::typed_operation(
176        "open",
177        "Open",
178        "Open",
179        tilo_actions::OperationKind::View,
180        OPEN_TYPE,
181        Vec::new(),
182        move |_| encoded.clone(),
183    )
184    .of_kind(tilo_actions::ActionKind::Read)
185    .with_description("Show this item.")
186}
187
188/// Offer `open` on every item resource under `resource` (an item of the
189/// collections of the resource `item_of` knows the items of), bound to that
190/// item.
191fn offer_item_open(item_of: fn(&str) -> bool, resource: &mut tilo_actions::Resource) {
192    if item_of(resource.id.as_str())
193        && !resource
194            .actions
195            .iter()
196            .any(|existing| existing.id == "open")
197    {
198        resource.actions.push(item_open_spec(resource.id.as_str()));
199        // The opener is also a presentation binding, not just a verb in the
200        // catalogue. Keep an app's explicit views; otherwise advertise the
201        // item view that this exact bound opener actually shows. The view
202        // names what it shows, the item, and not which item: the item's own
203        // label is the resource's, published once with it. Every item's
204        // `open` is then one catalogue entry however many items there are,
205        // where a copy of the item's label made each item's `open`, and its
206        // whole action template, an entry of its own.
207        if resource.views.is_empty() {
208            resource.views.push(
209                tilo_actions::ResourceViewSpec::new("item", ITEM_VIEW_LABEL, "open")
210                    .with_primary(true),
211            );
212        }
213    }
214    for child in &mut resource.children {
215        offer_item_open(item_of, child);
216    }
217}
218
219/// The ids of the actions the framework publishes on every app's resource
220/// beside the app's own: the generic `apply`, `group` and `field-edit`, the
221/// views `open` and `read`, history, and the presentation operations. An app
222/// verb, effect or view under one of these ids shadows the framework's or is
223/// shadowed by it, so `tilo-app check` refuses it.
224#[must_use]
225pub fn reserved_action_ids() -> Vec<String> {
226    let mut ids: Vec<String> = [
227        tilo_resource::APPLY,
228        tilo_resource::GROUP,
229        tilo_resource::FIELD_EDIT,
230    ]
231    .into_iter()
232    .map(str::to_owned)
233    .collect();
234    ids.push(open_spec().id);
235    ids.push(read_spec().id);
236    ids.push(tilo_resource::share::SHARE_LINK.to_owned());
237    ids.push(tilo_resource::share::SHARE_INVITE_LINK.to_owned());
238    ids.extend(super::history::specs().into_iter().map(|spec| spec.id));
239    ids.extend(
240        with_native_presentations(tilo_actions::Resource::new("", "", ""))
241            .actions
242            .into_iter()
243            .map(|spec| spec.id),
244    );
245    ids.sort();
246    ids.dedup();
247    ids
248}
249
250/// The operation type of the framework's `read` view.
251pub(crate) const READ_TYPE: &str = "tilo.resource.read";
252
253/// `read`: what a [readable](Resource::READABLE) resource holds, as typed
254/// data. With no `item` it answers the resource itself; with an `item` id, the
255/// nested resource of that id. It is a view: it changes nothing, needs only
256/// read permission and is checked like every other action.
257fn read_spec() -> tilo_actions::ActionSpec {
258    let mut spec = tilo_actions::ActionSpec::typed_operation(
259        "read",
260        "Read the content",
261        "Read the content",
262        tilo_actions::OperationKind::View,
263        READ_TYPE,
264        vec![
265            tilo_actions::ActionParam::optional(
266                "item",
267                tilo_actions::ParamType::Text,
268                "A nested resource's id, as the first read lists it (default: the whole resource)",
269            )
270            .nullable(),
271            tilo_actions::ActionParam::optional(
272                "version",
273                tilo_actions::ParamType::Text,
274                "A change id, exact causal frontier, or checkpoint name (default: current)",
275            )
276            .nullable(),
277        ],
278        |args| {
279            tilo_actions::EncodedAction::encode_operation(
280                READ_TYPE,
281                &serde_json::json!({ "item": args.text("item"), "version": args.text("version") }),
282            )
283            .unwrap_or_else(|_| tilo_actions::EncodedAction {
284                type_id: READ_TYPE.into(),
285                payload: Vec::new(),
286            })
287        },
288    )
289    .of_kind(tilo_actions::ActionKind::Read)
290    .with_description(
291        "What this holds as typed data: with no item, the resource and the ids of what it contains; with an item id, that item's content. With version, read frozen historical content and its exact heads without changing the live resource.",
292    );
293    spec.contract.result = tilo_actions::ResultSpec::Json {
294        schema: serde_json::json!({"type": "object"}),
295    };
296    spec.reads_document()
297}
298
299/// Answer a `read` request against the committed document of the resource
300/// `typed` reads, whose state is `state`.
301fn read(
302    typed: &dyn TypedRoot,
303    state: &dyn std::any::Any,
304    doc: &AutoCommit,
305    payload: &[u8],
306) -> Result<tilo_actions::ActionOutput, String> {
307    let args: serde_json::Value = if payload.is_empty() {
308        serde_json::Value::Null
309    } else {
310        tilo_actions::decode_json(payload).map_err(|error| error.to_string())?
311    };
312    let item = args.get("item").and_then(serde_json::Value::as_str);
313    let value = match args.get("version").and_then(serde_json::Value::as_str) {
314        Some(version) => {
315            let heads = ui_core::history_source::steps::resolve(doc, version)?;
316            let frozen = doc
317                .clone()
318                .fork_at(&heads)
319                .map_err(|error| error.to_string())?;
320            let past = typed.read(&frozen).map_err(|error| error.to_string())?;
321            let content = typed.read_content(past.as_ref(), &frozen, item)?;
322            super::history::review::bounded(serde_json::json!({
323                "version": heads.iter().map(ToString::to_string).collect::<Vec<_>>().join("+"),
324                "heads": heads.iter().map(ToString::to_string).collect::<Vec<_>>(),
325                "content": content,
326            }))?
327        }
328        None => typed.read_content(state, doc, item)?,
329    };
330    Ok(tilo_actions::ActionOutput {
331        value: Some(value),
332        resources: Vec::new(),
333    })
334}
335
336impl ResourceRoot {
337    /// The window ground is the framework's: an app never fills it, and one
338    /// that paints nothing still stands on it.
339    fn paint_ground(
340        &mut self,
341        scene: &mut Scene,
342        transform: Affine,
343        theme: &Theme,
344        presence: &PresenceCtx,
345    ) {
346        self.native_presentation.track_theme(theme);
347        scene.fill(
348            ui_core::vello::peniko::Fill::NonZero,
349            transform,
350            theme.tokens.colors.surfaces.content,
351            None,
352            &self.content.get_rect(),
353        );
354        self.run.people.follow(presence);
355    }
356
357    /// A root over `content`, the view built in `cx`, whose handles the root
358    /// keeps; `alive` is the scope `cx`'s bindings are tied to. Only this
359    /// constructor knows `R`.
360    pub(crate) fn new<R: Resource>(
361        title: &'static str,
362        icon: IconDescriptor,
363        label: String,
364        cx: ViewCx<R>,
365        alive: Rc<()>,
366        content: Element,
367    ) -> Self {
368        let native_presentation = crate::runner::Presentation::new(&cx);
369        cx.dispatch.name_resource(&label);
370        let ViewCx {
371            env,
372            updaters,
373            action_bindings,
374            stages,
375            feeds,
376            bindings,
377            ..
378        } = cx;
379        let dispatch = env.dispatch.core().clone();
380        let typed = Typed {
381            live: env.live,
382            updaters,
383            dispatch: env.dispatch,
384            bindings,
385            effects: env.effects,
386        };
387        Self::assemble(
388            (title, icon, label),
389            ResourceType::of::<R>(),
390            Box::new(typed),
391            action_bindings.table(),
392            Parts {
393                alive,
394                dispatch,
395                saved: env.saved,
396                stages,
397                feeds,
398                run: env.run,
399                storage: env.storage,
400                content,
401                native_presentation,
402            },
403        )
404    }
405
406    /// [`Self::new`] past what names the resource type.
407    fn assemble(
408        (title, icon, label): (&'static str, IconDescriptor, String),
409        resource: ResourceType,
410        typed: Box<dyn TypedRoot>,
411        action_bindings: super::controls::ActionTable,
412        parts: Parts,
413    ) -> Self {
414        let Parts {
415            alive,
416            dispatch,
417            saved,
418            stages,
419            feeds,
420            run,
421            storage,
422            content,
423            native_presentation,
424        } = parts;
425        Self {
426            title,
427            icon,
428            label,
429            resource,
430            typed,
431            action_bindings,
432            followed: std::cell::Cell::new(0),
433            _alive: alive,
434            dispatch,
435            saved,
436            permissions: None,
437            write: None,
438            stages,
439            feeds,
440            run,
441            storage,
442            content,
443            unreadable: None,
444            swapped: false,
445            presentation: Vec::new(),
446            search: std::cell::RefCell::new(None),
447            publication_tree: std::cell::RefCell::new(None),
448            item_children: std::cell::RefCell::new(None),
449            published_children: std::cell::RefCell::new(None),
450            record_children: std::cell::RefCell::new(None),
451            binding_head: std::cell::RefCell::new(None),
452            binding_base: std::cell::RefCell::new(None),
453            nested_views: std::cell::RefCell::new(None),
454            history: true,
455            native_presentation,
456            share: super::share_link::ShareLinks::default(),
457        }
458    }
459
460    /// A root whose resource keeps no document state: it publishes no
461    /// History actions and its window offers no History.
462    pub(crate) fn without_history(mut self) -> Self {
463        self.history = false;
464        self
465    }
466
467    #[cfg(not(target_family = "wasm"))]
468    pub(crate) fn drawing_past(mut self, past: crate::runner::DrawPast) -> Self {
469        self.native_presentation.drawing_past(past);
470        self
471    }
472
473    pub(crate) fn with_presentation(mut self, operations: Vec<tilo_actions::ActionSpec>) -> Self {
474        self.presentation = operations;
475        self
476    }
477
478    /// Stage a screenshot scenario's view state: every view's
479    /// [`on_stage`](super::ViewCx::on_stage) callback runs now, and every
480    /// hosted surface stages on its next sync.
481    /// Stage the views' state for `scenario`, and answer the collaborators
482    /// the capture shows as the presence frames their windows would send.
483    pub(crate) fn stage(
484        &self,
485        scenario: Option<&str>,
486    ) -> Vec<(String, PresenceKey, PresencePayload)> {
487        self.stages.run(scenario);
488        self.run
489            .collaborators()
490            .iter()
491            .map(|(name, at)| {
492                let (key, payload) = at.presence_of(self.resource.kind, self.dispatch.app_id());
493                (name.clone(), key, payload)
494            })
495            .collect()
496    }
497
498    /// Follow `permissions`: the resource is editable while they grant
499    /// `write`.
500    pub(crate) fn following(
501        mut self,
502        permissions: Option<LivePermissions>,
503        write: PermissionScope,
504    ) -> Self {
505        if let Some(permissions) = &permissions {
506            self.dispatch.follow(permissions.clone());
507        }
508        self.permissions = permissions;
509        self.write = Some(write);
510        self
511    }
512
513    /// Apply a permission change made while the app runs: dispatchers stop
514    /// (or resume) submitting, bound state and surfaces see the new value.
515    fn refresh_access(&self) {
516        let _profile = tilo_resource::profiling::Phase::start("framework.access_refresh");
517        if let (Some(permissions), Some(write)) = (&self.permissions, &self.write) {
518            self.dispatch.set_read_only(!permissions.check(write));
519        }
520        self.dispatch.refresh_permission_states();
521    }
522
523    /// The root as the widget a host mounts.
524    pub(crate) fn into_widget(self) -> Box<dyn AppWidget> {
525        RootWidget::boxed(Box::new(self))
526    }
527
528    /// The root as the widget a past version's preview draws. Boxed here,
529    /// not where the resource's type is known, so its widget table is
530    /// compiled once for every app.
531    pub(crate) fn into_root_widget(self) -> Box<dyn Widget> {
532        Box::new(RootWidget::new(Box::new(self)))
533    }
534
535    /// The handles of the root that name its resource type, when it is `R`.
536    pub(crate) fn typed<R: Resource>(&self) -> Option<&Typed<R>> {
537        self.typed.as_any().downcast_ref::<Typed<R>>()
538    }
539
540    /// What the launch that opened the window named.
541    pub(crate) fn launch(&self) -> super::launch::LaunchInput<'_> {
542        self.run.launch()
543    }
544
545    /// Every hosted surface of this resource in the view, in tree order.
546    fn surfaces(&self, visit: &mut dyn FnMut(&dyn SurfacePublication)) {
547        fn walk(
548            widget: &dyn Widget,
549            resource: std::any::TypeId,
550            visit: &mut dyn FnMut(&dyn SurfacePublication),
551        ) {
552            if let Some(surface) =
553                ui_core::widget::capability::capability::<dyn SurfacePublication>(widget)
554                && surface.resource() == resource
555            {
556                visit(surface);
557            }
558            widget.for_each_child(&mut |child| walk(child, resource, visit));
559        }
560        walk(self.content.as_ref(), self.resource.id, visit);
561    }
562
563    /// Bind the edits surfaces offer on the rows `rows` name
564    /// ([`ResourceSurface::row_declarations`](super::ResourceSurface::row_declarations)).
565    ///
566    /// One walk of the view asks every surface about every row, so binding
567    /// costs the view plus surfaces × rows, never the view once per row. Each
568    /// row binds its edits in tree order of the surfaces offering them.
569    #[inline(never)]
570    fn bind_row_declarations(&self, rows: &mut [tilo_actions::Resource]) {
571        // Every row, nested ones too (a playlist's tracks), asked of the
572        // surfaces in one walk.
573        fn ids(rows: &[tilo_actions::Resource], out: &mut Vec<String>) {
574            for row in rows {
575                out.push(row.id.as_str().to_owned());
576                ids(&row.children, out);
577            }
578        }
579        fn bind(
580            typed: &dyn TypedRoot,
581            rows: &mut [tilo_actions::Resource],
582            offered: &mut std::collections::HashMap<String, Box<dyn std::any::Any>>,
583        ) {
584            for row in rows {
585                if let Some(decls) = offered.remove(row.id.as_str()) {
586                    let bound = std::mem::replace(row, tilo_actions::Resource::new("", "", ""));
587                    *row = typed.bind(bound, decls);
588                }
589                bind(typed, &mut row.children, offered);
590            }
591        }
592        if rows.is_empty() {
593            return;
594        }
595        let mut all = Vec::new();
596        ids(rows, &mut all);
597        let mut offered: std::collections::HashMap<String, Box<dyn std::any::Any>> =
598            std::collections::HashMap::new();
599        let mut spare: Option<Box<dyn std::any::Any>> = None;
600        self.surfaces(&mut |surface| {
601            for id in &all {
602                if let Some(decls) = offered.get_mut(id.as_str()) {
603                    surface.row_declarations(id, decls.as_mut());
604                    continue;
605                }
606                let decls = spare.get_or_insert_with(|| self.typed.no_declarations());
607                if surface.row_declarations(id, decls.as_mut())
608                    && let Some(decls) = spare.take()
609                {
610                    offered.insert(id.clone(), decls);
611                }
612            }
613        });
614        bind(self.typed.as_ref(), rows, &mut offered);
615    }
616
617    /// The resource as published: its own actions and children, the edits
618    /// views and surfaces bound to where the user is, the live rows surfaces
619    /// show, and the framework's operations every app answers.
620    #[inline(never)]
621    fn publication(&self) -> tilo_actions::Resource {
622        let _profile = tilo_resource::profiling::Phase::start("framework.publication");
623        let state = self.typed.state();
624        let tree = self.shared_document_tree(&*state).to_resource();
625        self.decorate_publication(&*state, tree, Decoration::Tree)
626    }
627
628    /// The document-owned part of what controls bind to: the live
629    /// resource's identity and own actions, without its children. Taken
630    /// from the cached tree when it holds this change; else published
631    /// without building a child, so it costs the same however many items
632    /// the resource holds.
633    #[inline(never)]
634    fn binding_head(&self) -> BindingHead {
635        let state = self.typed.state();
636        let revision = (
637            self.typed.revision(),
638            ui_core::i18n::I18nManager::revision(),
639        );
640        let epoch = self.typed.cache_epoch(&*state);
641        let cached = epoch.as_ref().and_then(|epoch| {
642            self.publication_tree
643                .borrow()
644                .as_ref()
645                .filter(|cached| cached.revision == (revision.0, revision.1, epoch.clone()))
646                .map(|cached| cached.tree.head().clone())
647        });
648        let resource = cached.unwrap_or_else(|| {
649            let _profile = tilo_resource::profiling::Phase::start("framework.resource_head");
650            self.typed
651                .head(self.dispatch.resource_id(), &self.label, &*state)
652        });
653        BindingHead {
654            revision,
655            epoch,
656            resource,
657        }
658    }
659
660    /// The head bound controls were last refreshed from, while it still
661    /// holds: the resource may reuse its publication
662    /// ([`Resource::publication_cache_epoch`]) and nothing it depends on
663    /// moved since. A resource that may not is published afresh each sync.
664    fn retained_head(&self) -> Option<BindingHead> {
665        let head = self.binding_head.borrow_mut().take()?;
666        let current = self.typed.cache_epoch(&*self.typed.state());
667        (head.epoch.is_some() && head.epoch == current).then_some(head)
668    }
669
670    /// What bound controls look up ([`super::controls::Actions`]), as one
671    /// resource: the published resource's identity and actions over `head`,
672    /// exactly as [`Self::publication`] publishes them, without building or
673    /// decorating its children.
674    #[cfg(test)]
675    fn binding_publication(&self, head: &BindingHead) -> tilo_actions::Resource {
676        let base = self.binding_base(head);
677        let overlay = self.binding_overlay(&base);
678        let mut resource = (*base.resource).clone();
679        for spec in overlay {
680            match resource.actions.iter_mut().find(|own| own.id == spec.id) {
681                Some(own) => *own = spec,
682                None => resource.actions.push(spec),
683            }
684        }
685        resource
686    }
687
688    /// The part of what bound controls look up that only `head` and the
689    /// framework decide: the head with the presentation's, the framework's
690    /// and History's actions, as [`Self::decorate_publication`] adds them.
691    /// Built once per head and kept, so a refresh shares it.
692    fn binding_base(&self, head: &BindingHead) -> Rc<BindingBase> {
693        let key = BaseKey {
694            revision: head.revision,
695            epoch: head.epoch.clone(),
696            causal: self.typed.has_causal_drains(),
697            unreadable: self.typed.unreadable(),
698        };
699        if let Some(base) = self
700            .binding_base
701            .borrow()
702            .as_ref()
703            .filter(|base| base.key == key)
704        {
705            return Rc::clone(base);
706        }
707        let mut resource = head.resource.clone();
708        let head_len = resource.actions.len();
709        resource = self.with_presentation_specs(resource);
710        let shadow_len = resource.actions.len();
711        resource = self.with_framework_specs(resource);
712        if key.causal {
713            mark_document_jobs(&mut resource);
714        }
715        if let Some(unreadable) = &key.unreadable {
716            withhold_document_actions(&mut resource, unreadable);
717        }
718        let base = Rc::new(BindingBase {
719            key,
720            resource: Rc::new(resource),
721            head_len,
722            shadow_len,
723        });
724        *self.binding_base.borrow_mut() = Some(Rc::clone(&base));
725        base
726    }
727
728    /// What the views and surfaces add over `base` now, as
729    /// [`Self::decorate_publication`] adds it: the edits they bound, their
730    /// availability, and the app's effects. Few actions, built per refresh.
731    fn binding_overlay(&self, base: &BindingBase) -> Vec<tilo_actions::ActionSpec> {
732        let state = self.typed.state();
733        let mut bound = {
734            let _profile = tilo_resource::profiling::Phase::start("framework.bound_declarations");
735            self.typed.declarations(&*state)
736        };
737        let mut availability = Vec::new();
738        self.surfaces(&mut |surface| {
739            let _profile = tilo_resource::profiling::Phase::start("framework.surface_publication");
740            surface.bound_declarations(bound.as_mut());
741            availability.extend(surface.availability());
742        });
743        let own = base
744            .resource
745            .actions
746            .get(..base.head_len)
747            .unwrap_or_default();
748        let shadows = base
749            .resource
750            .actions
751            .get(..base.shadow_len)
752            .unwrap_or_default();
753        // The head's actions the edits bind over, refined where they say so.
754        let ids = self.typed.declaration_ids(&*bound);
755        let mut overlay = tilo_actions::Resource::new("", "", "");
756        overlay.actions = own
757            .iter()
758            .filter(|spec| ids.contains(&spec.id.as_str()))
759            .cloned()
760            .collect();
761        overlay = self.typed.bind(overlay, bound);
762        for status in &availability {
763            if !overlay.actions.iter().any(|spec| spec.id == status.id())
764                && let Some(spec) = own.iter().find(|spec| spec.id == status.id())
765            {
766                overlay.actions.push(spec.clone());
767            }
768            for spec in overlay
769                .actions
770                .iter_mut()
771                .filter(|spec| spec.id == status.id())
772            {
773                status.overlay(spec);
774            }
775        }
776        let effects = {
777            let _profile = tilo_resource::profiling::Phase::start("framework.effect_specs");
778            self.typed.effect_specs()
779        };
780        for spec in effects {
781            let shadowed = shadows
782                .iter()
783                .chain(&overlay.actions)
784                .any(|existing| existing.id == spec.id);
785            debug_assert!(!shadowed, "effect `{}` shadows an action", spec.id);
786            if shadowed {
787                tracing::warn!(effect = spec.id, "effect shadows an action; not published");
788                continue;
789            }
790            overlay.actions.push(spec);
791        }
792        if base.key.causal {
793            mark_document_jobs(&mut overlay);
794        }
795        if let Some(unreadable) = &base.key.unreadable {
796            withhold_document_actions(&mut overlay, unreadable);
797        }
798        overlay.actions
799    }
800
801    /// Refresh the actions bound controls look up, reusing `head` while the
802    /// live state and locale are those it was built at. Answers the head
803    /// they were refreshed from.
804    #[inline(never)]
805    fn refresh_bindings(&self, head: Option<BindingHead>) -> BindingHead {
806        let _profile = tilo_resource::profiling::Phase::start("framework.action_bindings");
807        let revision = (
808            self.typed.revision(),
809            ui_core::i18n::I18nManager::revision(),
810        );
811        let head = match head {
812            Some(head) if head.revision == revision => head,
813            _ => self.binding_head(),
814        };
815        let base = self.binding_base(&head);
816        let overlay = self.binding_overlay(&base);
817        self.action_bindings
818            .update_shared(Rc::clone(&base.resource), overlay);
819        head
820    }
821
822    /// [`Self::publication`] of the resource as `doc` holds it now, which a
823    /// change from another window or participant may have moved past what
824    /// this view last synced.
825    #[inline(never)]
826    fn current_publication(&self, doc: &AutoCommit) -> tilo_actions::Resource {
827        if !self.typed.follows(doc)
828            && let Ok(current) = self.typed.read(doc)
829        {
830            // Publication can run after an edit and before the view syncs.
831            // Validate its nested views against this same state, rather than
832            // withdrawing the new resource tree using the previous frame's
833            // handler table. The next live revision replaces this cache.
834            self.pin_nested_views(&*current);
835            return self.publication_of(&*current);
836        }
837        self.publication()
838    }
839
840    #[inline(never)]
841    fn publication_of(&self, state: &dyn std::any::Any) -> tilo_actions::Resource {
842        let _profile = tilo_resource::profiling::Phase::start("framework.publication");
843        let tree = self.shared_document_tree(state).to_resource();
844        self.decorate_publication(state, tree, Decoration::Tree)
845    }
846
847    /// Add to `resource`, the document-owned tree, what the view and the
848    /// framework publish on it. [`Decoration::Actions`] decorates only the
849    /// resource's own actions; its children stay as they are.
850    #[inline(never)]
851    fn decorate_publication(
852        &self,
853        state: &dyn std::any::Any,
854        mut resource: tilo_actions::Resource,
855        decoration: Decoration,
856    ) -> tilo_actions::Resource {
857        let children = decoration == Decoration::Tree;
858        let mut bound = {
859            let _profile = tilo_resource::profiling::Phase::start("framework.bound_declarations");
860            self.typed.declarations(state)
861        };
862        let mut rows = Vec::new();
863        let mut availability = Vec::new();
864        self.surfaces(&mut |surface| {
865            let _profile = tilo_resource::profiling::Phase::start("framework.surface_publication");
866            surface.bound_declarations(bound.as_mut());
867            availability.extend(surface.availability());
868            if children {
869                rows.extend(
870                    surface
871                        .live_rows()
872                        .into_iter()
873                        .map(super::LiveRow::into_resource),
874                );
875            }
876        });
877        resource = self.typed.bind(resource, bound);
878        for status in &availability {
879            for spec in resource
880                .actions
881                .iter_mut()
882                .filter(|spec| spec.id == status.id())
883            {
884                status.overlay(spec);
885            }
886        }
887        if children {
888            merge(&mut resource.children, rows);
889            let _profile = tilo_resource::profiling::Phase::start("framework.row_declarations");
890            self.bind_row_declarations(&mut resource.children);
891        }
892        resource = self.with_presentation_specs(resource);
893        // The app's own effects come before the framework's history and
894        // render operations: an app answering `diff` its own way is asked.
895        let effects = {
896            let _profile = tilo_resource::profiling::Phase::start("framework.effect_specs");
897            self.typed.effect_specs()
898        };
899        for spec in effects {
900            let shadowed = resource
901                .actions
902                .iter()
903                .any(|existing| existing.id == spec.id);
904            debug_assert!(!shadowed, "effect `{}` shadows an action", spec.id);
905            if shadowed {
906                tracing::warn!(effect = spec.id, "effect shadows an action; not published");
907                continue;
908            }
909            resource = resource.with_action(spec);
910        }
911        resource = self.with_framework_specs(resource);
912        if self.typed.has_causal_drains() {
913            mark_document_jobs(&mut resource);
914        }
915        if let Some(unreadable) = self.typed.unreadable() {
916            withhold_document_actions(&mut resource, &unreadable);
917        }
918        if children {
919            resource.children.extend(self.storage.record_resources());
920        }
921        resource
922    }
923
924    /// `resource` with the view and effect operations of the app's
925    /// presentation it does not offer itself.
926    fn with_presentation_specs(
927        &self,
928        mut resource: tilo_actions::Resource,
929    ) -> tilo_actions::Resource {
930        for spec in &self.presentation {
931            if spec.kind == tilo_actions::OperationKind::Document {
932                continue;
933            }
934            if resource
935                .actions
936                .iter()
937                .any(|existing| existing.id == spec.id)
938            {
939                continue;
940            }
941            resource = resource.with_action(spec.clone());
942        }
943        resource
944    }
945
946    /// `resource` with the framework's render, `read` and History actions it
947    /// does not offer itself.
948    fn with_framework_specs(&self, mut resource: tilo_actions::Resource) -> tilo_actions::Resource {
949        let specs = framework_specs();
950        let read = self.resource.readable.then_some(&specs.read);
951        let history = specs.history.iter().filter(|_| self.history);
952        for spec in specs.native.iter().chain(read).chain(history) {
953            if !resource
954                .actions
955                .iter()
956                .any(|existing| existing.id == spec.id)
957            {
958                resource = resource.with_action(spec.clone());
959            }
960        }
961        resource
962    }
963
964    /// Show why the record does not read as the resource instead of the
965    /// view while it does not, never the last state as if it were current,
966    /// and refuse submissions with the same reason.
967    #[inline(never)]
968    fn follow_readability(&mut self) {
969        let reason = self.typed.unreadable();
970        if self.unreadable.as_ref().map(|(shown, _)| shown) == reason.as_ref() {
971            return;
972        }
973        self.dispatch.set_unreadable(reason.clone());
974        self.unreadable = reason.map(|reason| {
975            let shown: Element = Box::new(ui_core::widget::EmptyState::error(
976                ui_core::i18n::I18nManager::lookup_plain("framework-unreadable"),
977                reason.clone(),
978            ));
979            (reason, shown)
980        });
981        self.swapped = true;
982    }
983
984    /// Follow the document when it moved — a local action, a
985    /// collaborator's change, an undo or a restore — reading only what the
986    /// change touched.
987    ///
988    /// Answers the document-owned head the bindings were refreshed from, for
989    /// the refresh after the view synced to reuse.
990    fn refresh(&self, doc: &AutoCommit) -> BindingHead {
991        if let Err(error) = self.typed.sync(doc) {
992            tracing::warn!(%error, kind = self.resource.kind, "resource unreadable");
993        }
994        // Refresh the same declarations the host publishes before rebuilt
995        // controls look up their bindings. Retained surface handles share it.
996        let head = self.refresh_bindings(self.retained_head());
997        self.typed.follow_change(doc, &self.followed);
998        self.feeds.refresh(doc);
999        self.typed.drain(self.typed.revision());
1000        head
1001    }
1002}
1003
1004/// What [`ResourceRoot::new`] hands [`ResourceRoot::assemble`] of the view
1005/// it was built in.
1006struct Parts {
1007    alive: Rc<()>,
1008    dispatch: DispatchCore,
1009    saved: LocalState<RecordSaveStatus>,
1010    stages: Stages,
1011    feeds: super::history::HistoryFeeds,
1012    run: Run,
1013    storage: Rc<super::settings::ViewStorage>,
1014    content: Element,
1015    native_presentation: crate::runner::Presentation,
1016}
1017
1018/// `widget`'s hosted surface, when it is one of the resource type
1019/// `resource`.
1020fn surface_mut(
1021    widget: &mut dyn Widget,
1022    resource: std::any::TypeId,
1023) -> Option<&mut (dyn SurfacePublication + 'static)> {
1024    capability_mut::<dyn SurfacePublication>(widget)
1025        .filter(|surface| surface.resource() == resource)
1026}
1027
1028/// What [`ResourceRoot::binding_base`] is built from.
1029#[derive(Clone, PartialEq)]
1030struct BaseKey {
1031    revision: (u64, u64),
1032    epoch: Option<String>,
1033    causal: bool,
1034    unreadable: Option<String>,
1035}
1036
1037/// The shared part of what bound controls look up, at one [`BaseKey`].
1038struct BindingBase {
1039    key: BaseKey,
1040    resource: Rc<tilo_actions::Resource>,
1041    /// How many of its actions are the head's own.
1042    head_len: usize,
1043    /// How many are the head's and the presentation's: what an effect of
1044    /// the same id would shadow.
1045    shadow_len: usize,
1046}
1047
1048/// The framework's actions every app's resource offers unless it offers
1049/// them itself, at one locale.
1050struct FrameworkSpecs {
1051    native: Vec<tilo_actions::ActionSpec>,
1052    read: tilo_actions::ActionSpec,
1053    history: Vec<tilo_actions::ActionSpec>,
1054}
1055
1056thread_local! {
1057    static FRAMEWORK_SPECS: std::cell::RefCell<Option<(u64, Rc<FrameworkSpecs>)>> =
1058        const { std::cell::RefCell::new(None) };
1059}
1060
1061/// [`FrameworkSpecs`] at the current locale, built once per locale on
1062/// each thread rather than on every publication.
1063fn framework_specs() -> Rc<FrameworkSpecs> {
1064    let locale = ui_core::i18n::I18nManager::revision();
1065    FRAMEWORK_SPECS.with_borrow_mut(|kept| {
1066        if let Some((at, specs)) = kept.as_ref()
1067            && *at == locale
1068        {
1069            return Rc::clone(specs);
1070        }
1071        let specs = Rc::new(FrameworkSpecs {
1072            native: with_native_presentations(tilo_actions::Resource::new("", "", "")).actions,
1073            read: read_spec(),
1074            history: super::history::specs(),
1075        });
1076        *kept = Some((locale, Rc::clone(&specs)));
1077        specs
1078    })
1079}
1080
1081/// How much of a publication [`ResourceRoot::decorate_publication`] adds.
1082#[derive(Clone, Copy, PartialEq, Eq)]
1083enum Decoration {
1084    /// The whole published tree: the resource's actions and its children.
1085    Tree,
1086    /// Only the resource's identity and own actions, which is all bound
1087    /// controls read.
1088    Actions,
1089}
1090
1091/// The document-owned head of the publication, at the live and locale
1092/// revision it was built at.
1093#[derive(Clone)]
1094struct BindingHead {
1095    revision: (u64, u64),
1096    /// [`Resource::publication_cache_epoch`] at the time.
1097    epoch: Option<String>,
1098    resource: tilo_actions::Resource,
1099}
1100
1101/// `resource` without its children, cloning nothing of them.
1102#[cfg(all(test, not(target_arch = "wasm32")))]
1103fn without_children(resource: &tilo_actions::Resource) -> tilo_actions::Resource {
1104    let tilo_actions::Resource {
1105        id,
1106        kind,
1107        label,
1108        presentation,
1109        semantic_identity,
1110        children: _,
1111        actions,
1112        views,
1113    } = resource;
1114    tilo_actions::Resource {
1115        id: id.clone(),
1116        kind: kind.clone(),
1117        label: label.clone(),
1118        presentation: presentation.clone(),
1119        semantic_identity: semantic_identity.clone(),
1120        children: Vec::new(),
1121        actions: actions.clone(),
1122        views: views.clone(),
1123    }
1124}
1125
1126/// Every document action of `resource` and its children, unavailable for
1127/// `reason`: the record does not read as the resource.
1128fn withhold_document_actions(resource: &mut tilo_actions::Resource, reason: &str) {
1129    for spec in &mut resource.actions {
1130        if spec.kind == tilo_actions::OperationKind::Document {
1131            spec.contract.availability = tilo_actions::Availability::Unavailable {
1132                code: "unreadable".into(),
1133                reason: reason.to_owned(),
1134            };
1135        }
1136    }
1137    for child in &mut resource.children {
1138        withhold_document_actions(child, reason);
1139    }
1140}
1141
1142/// Visit the surface of type `S` the root of a resource app of `R` shows: for
1143/// a test, or a host that mounted the app, reaching the surface between
1144/// frames (`App::with_component_root` lends the root of a mounted one).
1145/// `None` when `root` is not such a root or shows no `S`.
1146pub fn with_surface<R: Resource, S: 'static, T>(
1147    root: &mut dyn std::any::Any,
1148    visit: impl FnOnce(&mut S) -> T,
1149) -> Option<T> {
1150    fn find<S: 'static>(widget: &mut dyn ui_core::widget::Widget) -> Option<&mut S> {
1151        if widget.as_any_mut().is_some_and(|any| any.is::<S>()) {
1152            return widget.as_any_mut()?.downcast_mut::<S>();
1153        }
1154        widget.children_mut().into_iter().find_map(find::<S>)
1155    }
1156    let root = root.downcast_mut::<ResourceRoot>()?;
1157    find::<S>(root.view_mut()).map(visit)
1158}
1159
1160/// The document actions of `resource` and its children that can queue an
1161/// outside consequence (a verb taking an outbox, every action of a record
1162/// with a `queue`, and undo, redo and restore, which queue the compensation
1163/// of one), published as jobs: the invocation answers when what it queued
1164/// has settled. Every other document action is deterministic and applies
1165/// where it is invoked, at once.
1166fn mark_document_jobs(resource: &mut tilo_actions::Resource) {
1167    for spec in &mut resource.actions {
1168        let queues = spec.contract.kind == tilo_actions::ActionKind::Outside
1169            || matches!(spec.id.as_str(), "undo" | "redo" | "restore");
1170        if spec.kind == tilo_actions::OperationKind::Document && queues {
1171            spec.contract.execution = tilo_actions::ExecutionMode::Background { cancellable: true };
1172            if matches!(spec.contract.result, tilo_actions::ResultSpec::None) {
1173                spec.contract.result = tilo_actions::ResultSpec::Job;
1174            }
1175        }
1176    }
1177    for child in &mut resource.children {
1178        mark_document_jobs(child);
1179    }
1180}
1181
1182impl ui_core::widget::document_effects::DocumentEffects for ResourceRoot {
1183    fn start_document_effects(
1184        &mut self,
1185        operation: &ui_core::host_operation::ResourceOperation<'_>,
1186        applied: &ui_core::widget::document_effects::AppliedDocument<'_>,
1187    ) -> Result<Option<ui_core::host_operation::ResourceOperationStart>, String> {
1188        self.typed.start_document_effects(operation, applied)
1189    }
1190}
1191
1192/// How many search entries are indexed away from the UI thread rather than
1193/// in the frame that asks for them.
1194const OFF_THREAD_ENTRIES: usize = 1024;
1195
1196/// The index of the resource's search entries and its surfaces', as of one
1197/// revision of the resource and one set of surface entries.
1198struct SearchCache {
1199    revision: u64,
1200    shown: Vec<SearchEntry>,
1201    indexed: Arc<IndexedEntries>,
1202    /// An index of newer entries being built away from the UI thread: the
1203    /// revision and surface entries it is of, and where it arrives.
1204    building: Option<(
1205        u64,
1206        Vec<SearchEntry>,
1207        std::sync::mpsc::Receiver<IndexedEntries>,
1208    )>,
1209}
1210
1211/// A [`SearchText`](tilo_resource::SearchText), as the index reads it.
1212struct BodyText(Arc<dyn tilo_resource::SearchText>);
1213
1214impl tilo_search::BodySource for BodyText {
1215    fn segment_count(&self) -> usize {
1216        self.0.segment_count()
1217    }
1218
1219    fn piece_count(&self, segment: usize) -> usize {
1220        self.0.piece_count(segment)
1221    }
1222
1223    fn piece(&self, segment: usize, piece: usize) -> Option<tilo_search::BodyPiece<'_>> {
1224        self.0
1225            .piece(segment, piece)
1226            .map(|piece| tilo_search::BodyPiece {
1227                text: piece.text,
1228                space_before: piece.space_before,
1229            })
1230    }
1231
1232    fn locates(&self, segment: usize) -> bool {
1233        self.0.locates(segment)
1234    }
1235}
1236
1237/// A resource's search entries and their index, keyed by position.
1238pub(crate) struct IndexedEntries {
1239    entries: Vec<SearchEntry>,
1240    facets: Vec<ui_core::search::Facet>,
1241    index: SearchIndex,
1242    /// Which build of the entries this is: a page cursor from another build
1243    /// starts over rather than skipping or repeating.
1244    generation: u64,
1245}
1246
1247impl IndexedEntries {
1248    pub(crate) fn built(entries: Vec<SearchEntry>, generation: u64) -> Self {
1249        // Entry titles are names a person half remembers: typos match too.
1250        let mut index = SearchIndex::new(Matching::LOOSE);
1251        index.replace_all(entries.iter().enumerate().map(|(at, entry)| {
1252            let mut document = Document::new(at as u64)
1253                .field(entry.title.as_str(), FieldWeight::TITLE)
1254                .field(entry.text.as_str(), FieldWeight::BODY)
1255                .field(
1256                    entry.subtitle.as_deref().unwrap_or_default(),
1257                    FieldWeight::SUBTITLE,
1258                );
1259            for (text, weight) in &entry.fields {
1260                document = document.field(text.as_str(), field_weight(*weight));
1261            }
1262            match &entry.body {
1263                Some(body) => document.body(Arc::new(BodyText(body.shared()))),
1264                None => document,
1265            }
1266        }));
1267        if let Err(error) = index.commit() {
1268            tracing::warn!(%error, "could not index resource search entries");
1269        }
1270        let mut groups: BTreeMap<String, (String, BTreeSet<String>)> = BTreeMap::new();
1271        for entry in &entries {
1272            for (key, label, value) in &entry.facets {
1273                groups
1274                    .entry(key.clone())
1275                    .or_insert_with(|| (label.clone(), BTreeSet::new()))
1276                    .1
1277                    .insert(value.clone());
1278            }
1279        }
1280        let facets = groups
1281            .into_iter()
1282            .map(|(key, (label, values))| {
1283                values
1284                    .into_iter()
1285                    .fold(ui_core::search::Facet::new(key, label), |facet, value| {
1286                        facet.value(value.clone(), value)
1287                    })
1288            })
1289            .collect();
1290        Self {
1291            entries,
1292            facets,
1293            index,
1294            generation,
1295        }
1296    }
1297}
1298
1299/// The index's weight for a field an entry ranks as `weight`.
1300fn field_weight(weight: tilo_resource::SearchWeight) -> FieldWeight {
1301    use tilo_resource::SearchWeight;
1302    match weight {
1303        SearchWeight::Title => FieldWeight::TITLE,
1304        SearchWeight::Identity => FieldWeight::IDENTITY,
1305        SearchWeight::Subtitle => FieldWeight::SUBTITLE,
1306        SearchWeight::Metadata => FieldWeight::METADATA,
1307        SearchWeight::Body => FieldWeight::BODY,
1308        SearchWeight::Unranked => FieldWeight::UNRANKED,
1309    }
1310}
1311
1312/// A search provider over one snapshot of the resource's entries.
1313pub(crate) struct ResourceSearch {
1314    name: &'static str,
1315    indexed: Arc<IndexedEntries>,
1316    scopes: Vec<ScopeDecl>,
1317    queries: Vec<super::surface::SearchQueries>,
1318}
1319
1320/// `result` read as the entry's row: its subtitle, its icon and its shape.
1321fn with_row(mut result: SearchResult, title: &str, row: &tilo_resource::SearchRow) -> SearchResult {
1322    use tilo_resource::SearchRowShape;
1323    use ui_core::search::SearchRow;
1324    if let Some(subtitle) = &row.subtitle {
1325        result = result.with_subtitle(subtitle.clone());
1326    }
1327    if let Some(icon) = row
1328        .icon
1329        .as_deref()
1330        .and_then(ui_core::icons::Icon::from_name)
1331    {
1332        result = result.with_icon(icon);
1333    }
1334    let shaped = match &row.shape {
1335        SearchRowShape::Plain => None,
1336        SearchRowShape::Message {
1337            sender,
1338            snippet,
1339            date,
1340            unread,
1341        } => {
1342            let mut shaped = SearchRow::message(sender.clone(), title).unread(*unread);
1343            if let Some(snippet) = snippet {
1344                shaped = shaped.snippet(snippet.clone());
1345            }
1346            if let Some(date) = date {
1347                shaped = shaped.date(date.clone());
1348            }
1349            Some(shaped)
1350        }
1351        SearchRowShape::Media { byline, duration } => {
1352            let mut shaped = SearchRow::media(title);
1353            if let Some(byline) = byline {
1354                shaped = shaped.byline(byline.clone());
1355            }
1356            if let Some(duration) = duration {
1357                shaped = shaped.duration(duration.clone());
1358            }
1359            Some(shaped)
1360        }
1361        SearchRowShape::FileHit {
1362            path,
1363            line,
1364            snippet,
1365        } => {
1366            let mut shaped = SearchRow::file_hit(path.clone(), snippet.clone());
1367            if let Some(line) = line {
1368                shaped = shaped.line(*line);
1369            }
1370            Some(shaped)
1371        }
1372    };
1373    match shaped {
1374        Some(shaped) => result.with_row(shaped),
1375        None => result,
1376    }
1377}
1378
1379impl ResourceSearch {
1380    /// The search source over `indexed`, listed under `name` unless an
1381    /// entry names its own group.
1382    pub(crate) fn new(name: &'static str, indexed: Arc<IndexedEntries>) -> Self {
1383        Self {
1384            name,
1385            indexed,
1386            scopes: Vec::new(),
1387            queries: Vec::new(),
1388        }
1389    }
1390}
1391
1392impl SearchProvider for ResourceSearch {
1393    fn name(&self) -> &str {
1394        self.name
1395    }
1396
1397    fn search(&self, query: &str) -> Vec<SearchResult> {
1398        self.search_limited(query, DEFAULT_SEARCH_RESULT_LIMIT)
1399    }
1400
1401    // No caller materialises more than one interactive page in one turn;
1402    // complete discovery goes through `search_page`'s cursor.
1403    fn search_limited(&self, query: &str, limit: usize) -> Vec<SearchResult> {
1404        let typed = SearchQuery::new(query);
1405        for queries in &self.queries {
1406            queries.submit(&typed);
1407        }
1408        self.results(&typed, Page::first(limit.min(DEFAULT_SEARCH_RESULT_LIMIT)))
1409            .0
1410    }
1411
1412    fn action_directory_generation(&self) -> Option<u64> {
1413        Some(self.indexed.generation)
1414    }
1415
1416    fn action_directory_cursor_is_current(&self, cursor: &SearchCursor) -> bool {
1417        cursor.opaque_parts().0 == self.indexed.generation
1418    }
1419
1420    // Every entry stays findable however many there are: a page continues
1421    // where the last one stopped, within the caller's budgets.
1422    fn search_page(
1423        &self,
1424        query: &SearchQuery,
1425        cursor: Option<&SearchCursor>,
1426        work_budget: usize,
1427        result_limit: usize,
1428    ) -> SearchPage {
1429        for queries in &self.queries {
1430            queries.submit(query);
1431        }
1432        let limit = work_budget
1433            .min(DEFAULT_SEARCH_CANDIDATE_LIMIT)
1434            .min(result_limit.min(DEFAULT_SEARCH_RESULT_LIMIT));
1435        let folded = format!(
1436            "{}\0{:?}",
1437            ui_core::search::normalize_search_text(query.text()),
1438            query.filters()
1439        );
1440        let offset = cursor
1441            .map(SearchCursor::opaque_parts)
1442            .filter(|(generation, at_query, at_scope, _)| {
1443                *generation == self.indexed.generation
1444                    && *at_query == folded
1445                    && *at_scope == query.scope()
1446            })
1447            .and_then(|(_, _, _, offset)| usize::try_from(offset?).ok())
1448            .unwrap_or(0);
1449        if limit == 0 {
1450            return SearchPage {
1451                results: Vec::new(),
1452                next: None,
1453                inspected: 0,
1454                pending: None,
1455            };
1456        }
1457        let (results, more, inspected) = self.results(query, Page { offset, limit });
1458        let next = more.then(|| {
1459            SearchCursor::opaque(
1460                self.indexed.generation,
1461                folded,
1462                query.scope().map(str::to_owned),
1463                u64::try_from(offset.saturating_add(inspected)).ok(),
1464            )
1465        });
1466        SearchPage {
1467            results,
1468            next,
1469            inspected,
1470            pending: None,
1471        }
1472    }
1473
1474    fn facets(&self) -> Vec<ui_core::search::Facet> {
1475        self.indexed.facets.clone()
1476    }
1477
1478    fn search_scopes(&self) -> Vec<ScopeDecl> {
1479        self.scopes.clone()
1480    }
1481}
1482
1483impl ResourceSearch {
1484    /// One page of hits for `query`, and whether more follow it.
1485    fn results(&self, query: &SearchQuery, page: Page) -> (Vec<SearchResult>, bool, usize) {
1486        let found = self.indexed.index.search(query.text(), page);
1487        let inspected = found.hits.len();
1488        let results = found
1489            .hits
1490            .into_iter()
1491            .filter_map(|hit| {
1492                let parent = self.indexed.entries.get(usize::try_from(hit.key).ok()?)?;
1493                // A match in a located segment of a body is that location's
1494                // hit, listed with its entry unless it names its own group.
1495                let located = hit.segment.and_then(|segment| {
1496                    let mut location = parent.body.as_ref()?.text().location(segment)?;
1497                    if location.group.is_none() {
1498                        location.group.clone_from(&parent.group);
1499                    }
1500                    Some(location)
1501                });
1502                let entry = located.as_ref().unwrap_or(parent);
1503                if query.scope().is_some_and(|scope| !entry.in_scope(scope))
1504                    || query.filters().iter().any(|filter| {
1505                        !parent.facets.iter().any(|(key, _, value)| {
1506                            key.eq_ignore_ascii_case(&filter.key)
1507                                && query.filter_values(&filter.key).any(|selected| {
1508                                    ui_core::search::normalize_search_text(value)
1509                                        == ui_core::search::normalize_search_text(selected)
1510                                })
1511                        })
1512                    })
1513                {
1514                    return None;
1515                }
1516                let group = entry.group.as_deref().unwrap_or(self.name);
1517                let mut result = match &entry.action {
1518                    Some(action) => SearchResult::for_action(group, entry.title.clone(), action),
1519                    None => SearchResult::new(
1520                        group,
1521                        entry.title.clone(),
1522                        entry.anchor.clone().unwrap_or_default(),
1523                    ),
1524                };
1525                if let Some(subtitle) = &entry.subtitle {
1526                    result = result.with_subtitle(subtitle.clone());
1527                }
1528                if let Some(icon) = entry
1529                    .icon
1530                    .as_deref()
1531                    .and_then(ui_core::icons::Icon::from_name)
1532                {
1533                    result = result.with_icon(icon);
1534                }
1535                if !entry.text.is_empty() {
1536                    result = result.with_preview(entry.text.clone());
1537                }
1538                if let Some(within) = &entry.within {
1539                    result = result.within(within.clone());
1540                }
1541                if let Some(row) = &entry.row {
1542                    result = with_row(result, &entry.title, row);
1543                }
1544                Some(result.with_score(hit.rank.unwrap_or(0).saturating_add(entry.boost)))
1545            })
1546            .collect();
1547        (results, found.more, inspected)
1548    }
1549}
1550
1551// The root is the app's view plus what the framework adds: it keeps the
1552// state current, publishes the resource, forwards edits and indexes search.
1553// Everything else — layout, input, traversal, operation admission — is the
1554// view's own, forwarded by the decorator.
1555impl RootModel for ResourceRoot {
1556    ui_core::provides!(dyn ui_core::undo::CommittedActions, dyn ui_core::history_mode::capability::DocumentHistory, dyn ui_core::widget::document_effects::DocumentEffects, dyn ui_core::widget::Collaborative, #[cfg(not(target_family = "wasm"))] dyn ui_core::component_lifecycle::WindowActivation; inner = content);
1557
1558    fn view(&self) -> &(dyn Widget + 'static) {
1559        match &self.unreadable {
1560            Some((_, shown)) => shown.as_ref(),
1561            None => self.content.as_ref(),
1562        }
1563    }
1564
1565    fn view_mut(&mut self) -> &mut (dyn Widget + 'static) {
1566        match &mut self.unreadable {
1567            Some((_, shown)) => shown.as_mut(),
1568            None => self.content.as_mut(),
1569        }
1570    }
1571
1572    fn take_structure_dirty(&mut self) -> bool {
1573        let swapped = std::mem::take(&mut self.swapped);
1574        self.view_mut().take_structure_dirty() || swapped
1575    }
1576
1577    // Input is forwarded unchanged; model, storage and policy wakes remain
1578    // owned by the runner and still request their normal synchronization.
1579    fn view_only_scroll(&self, event: &ui_core::winit::event::WindowEvent) -> bool {
1580        self.content.view_only_scroll(event)
1581    }
1582
1583    // The root adds no structure of its own beyond swapping its view for
1584    // the unreadable state: a change its view can describe stays described.
1585    fn take_structure_change(&mut self) -> ui_core::widget::StructureChange {
1586        let change = self.view_mut().take_structure_change();
1587        if std::mem::take(&mut self.swapped) {
1588            ui_core::widget::StructureChange::Rebuild
1589        } else {
1590            change
1591        }
1592    }
1593
1594    fn record_owners(&self) -> Vec<ui_core::history_mode::HistoryOwner> {
1595        let mut owners = self.storage.person_owners();
1596        owners.extend(self.content.record_owners());
1597        owners
1598    }
1599
1600    fn sync(&mut self, state: &AppModel, path: &ModelPath) {
1601        let (state, path) = ui_core::doc::document_ref(state, path);
1602        self.refresh_access();
1603        let head = self.refresh(state);
1604        self.follow_readability();
1605        self.storage.refresh();
1606        {
1607            let _profile = tilo_resource::profiling::Phase::start("framework.content_sync");
1608            self.content.sync(state, path);
1609        }
1610        // What the view's sync changed (a surface's bound edits, its
1611        // availability) reaches the bindings; the document-owned head is
1612        // rebuilt only when the sync moved the state.
1613        let head = self.refresh_bindings(Some(head));
1614        *self.binding_head.borrow_mut() = Some(head);
1615    }
1616
1617    fn settle(&mut self, edits: &mut ui_core::widget::Edits<'_>, path: &ui_core::automerge::ObjId) {
1618        self.typed.apply_settlements(edits);
1619        self.content.settle(edits, path);
1620    }
1621
1622    fn draw(
1623        &mut self,
1624        scene: &mut Scene,
1625        transform: Affine,
1626        text: &mut TextRenderer,
1627        theme: &Theme,
1628        presence: &PresenceCtx,
1629    ) {
1630        self.paint_ground(scene, transform, theme, presence);
1631        self.content.draw(scene, transform, text, theme, presence);
1632    }
1633
1634    fn needs_redraw(&self) -> bool {
1635        self.native_presentation.needs_redraw() || self.content.needs_redraw()
1636    }
1637
1638    // A screenshot harness reaches the root to stage a scenario's view state
1639    // (`framework::stage`).
1640    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1641        self
1642    }
1643
1644    // The resource is the same for every participant (an agent task sees and
1645    // invokes the same actions); only the content's tree is per participant.
1646
1647    // The undo boundary of a surface's immediate actions this event; the
1648    // host records one step per event and author.
1649
1650    // A surface's step stays open for the actions that continue it (the
1651    // letters of a word), which report no boundary of their own.
1652
1653    /// Resource and app presentation doors publish effect/view actions; their
1654    /// operation types must be in the retained handler table or publication
1655    /// withdraws the whole component surface.
1656    fn resource_operation_handlers(&self) -> HashSet<String> {
1657        let mut handlers = self.view().resource_operation_handlers();
1658        handlers.extend(
1659            crate::runner::operation_types()
1660                .iter()
1661                .map(|op| (*op).to_owned()),
1662        );
1663        handlers.insert(super::history::VIEW_TYPE.to_owned());
1664        handlers.insert(super::person::HISTORY_TYPE.to_owned());
1665        handlers.insert(super::record::RECORD_ACTION_TYPE.to_owned());
1666        handlers.insert(tilo_resource::share::SHARE_TYPE.to_owned());
1667        handlers.insert(tilo_resource::share::SHARE_INVITE_TYPE.to_owned());
1668        if self.stages.opens() {
1669            handlers.insert(OPEN_TYPE.to_owned());
1670        }
1671        // Opening another app is routed by the host to the workspace
1672        // broker, whichever resource offers it.
1673        handlers.insert(tilo_actions::remote::ApplicationLaunch::TYPE.to_owned());
1674        if self.resource.readable {
1675            handlers.insert(READ_TYPE.to_owned());
1676        }
1677        handlers.extend(
1678            self.typed
1679                .effect_operations()
1680                .into_iter()
1681                .map(str::to_owned),
1682        );
1683        for spec in (self.resource.local_operations)()
1684            .into_iter()
1685            .chain(self.presentation.iter().cloned())
1686        {
1687            if let Some(type_id) = spec.operation_type {
1688                handlers.insert(type_id);
1689            }
1690        }
1691        handlers.extend(self.nested_views().iter().cloned());
1692        handlers
1693    }
1694
1695    fn poll_resource_operation(
1696        &mut self,
1697        invocation: tilo_actions::InvocationId,
1698        cancellation_requested: bool,
1699        doc: &AutoCommit,
1700        path: &ui_core::automerge::ObjId,
1701    ) -> Option<tilo_actions::InvocationUpdate> {
1702        if let Some((update, copied)) = self.share.poll(invocation, cancellation_requested) {
1703            if copied && let tilo_actions::InvocationUpdate::Succeeded { output, .. } = &update {
1704                self.dispatch
1705                    .note_landed(tilo_resource::share::SHARE_LINK, output);
1706            }
1707            return Some(update);
1708        }
1709        self.typed
1710            .poll_effect(invocation, cancellation_requested)
1711            .or_else(|| {
1712                self.content
1713                    .poll_resource_operation(invocation, cancellation_requested, doc, path)
1714            })
1715    }
1716
1717    fn start_operation(
1718        &mut self,
1719        op: &ui_core::host_operation::ResourceOperation<'_>,
1720        doc: &mut ui_core::widget::Edits<'_>,
1721        path: &ui_core::automerge::ObjId,
1722    ) -> Result<Option<ui_core::host_operation::ResourceOperationStart>, String> {
1723        if let Some(output) =
1724            self.native_presentation
1725                .start_operation(op, doc, self.content.as_mut())?
1726        {
1727            return Ok(Some(
1728                ui_core::host_operation::ResourceOperationStart::Completed(output),
1729            ));
1730        }
1731        if self.resource.readable
1732            && let Some(request) = op.unscoped()
1733            && request.type_id == READ_TYPE
1734        {
1735            let output = read(
1736                self.typed.as_ref(),
1737                &*self.typed.state(),
1738                doc,
1739                request.payload,
1740            )?;
1741            return Ok(Some(
1742                ui_core::host_operation::ResourceOperationStart::Completed(output),
1743            ));
1744        }
1745        if let Some(request) = op.unscoped()
1746            && super::share_link::handles(request.type_id)
1747        {
1748            return self.start_share(request.invocation, request.payload);
1749        }
1750        if let Some(request) = op.unscoped()
1751            && request.type_id == OPEN_TYPE
1752        {
1753            let args: serde_json::Value =
1754                tilo_actions::decode_json(request.payload).map_err(|error| error.to_string())?;
1755            let anchor = args
1756                .get(OPEN_ANCHOR)
1757                .and_then(serde_json::Value::as_str)
1758                .ok_or_else(|| "open needs the anchor of an item".to_owned())?;
1759            self.stages.open(anchor)?;
1760            return Ok(Some(
1761                ui_core::host_operation::ResourceOperationStart::Completed(
1762                    tilo_actions::ActionOutput::default(),
1763                ),
1764            ));
1765        }
1766        if let Some(request) = op.unscoped()
1767            && request.type_id == super::history::VIEW_TYPE
1768        {
1769            let output = super::history::answer(doc, request.payload)?;
1770            return Ok(Some(
1771                ui_core::host_operation::ResourceOperationStart::Completed(output),
1772            ));
1773        }
1774        if let Some(request) = op.unscoped()
1775            && request.type_id == super::record::RECORD_ACTION_TYPE
1776        {
1777            let output = self
1778                .storage
1779                .record_action(self.dispatch.me(), request.payload)?;
1780            return Ok(Some(
1781                ui_core::host_operation::ResourceOperationStart::Completed(output),
1782            ));
1783        }
1784        if let Some(request) = op.unscoped()
1785            && request.type_id == super::person::HISTORY_TYPE
1786        {
1787            let output = self.storage.person_history(request.payload)?;
1788            return Ok(Some(
1789                ui_core::host_operation::ResourceOperationStart::Completed(output),
1790            ));
1791        }
1792        // An effect reads the resource as the document stands, though no
1793        // frame has run since a collaborator's change arrived.
1794        if let Err(error) = self.typed.sync(doc) {
1795            tracing::warn!(%error, kind = self.resource.kind, "resource unreadable");
1796        }
1797        if let Some(started) = self.typed.start_effect(op)? {
1798            return Ok(Some(started));
1799        }
1800        if let Some(started) = self.view_mut().start_operation(op, doc, path)? {
1801            return Ok(Some(started));
1802        }
1803        // View openers from the resource/presentation catalogue and from its
1804        // nested resources (e.g. a rich-text body's `tilo.rich-text.view`
1805        // editor/compact) are already mounted; accept them so agents and
1806        // Ctrl+K can switch without a missing-handler fault.
1807        let Some(request) = op.unscoped() else {
1808            return Ok(None);
1809        };
1810        if request.kind != tilo_actions::OperationKind::View {
1811            return Ok(None);
1812        }
1813        let known = (self.resource.local_operations)()
1814            .into_iter()
1815            .chain(self.presentation.iter().cloned())
1816            .any(|spec| spec.operation_type.as_deref() == Some(request.type_id))
1817            || self.nested_views().contains(request.type_id);
1818        if known {
1819            Ok(Some(
1820                ui_core::host_operation::ResourceOperationStart::Completed(
1821                    tilo_actions::ActionOutput::default(),
1822                ),
1823            ))
1824        } else {
1825            Ok(None)
1826        }
1827    }
1828}
1829
1830impl ResourceRoot {
1831    /// The index of the resource's entries at `revision` and the surfaces'
1832    /// `shown` ones. A large set of entries (a browsing history, a store
1833    /// catalogue) or one with prose bodies (whole books) is indexed away
1834    /// from the UI thread: until that index arrives, search answers from the
1835    /// one before it.
1836    fn current_search_index(&self, revision: u64, shown: Vec<SearchEntry>) -> Arc<IndexedEntries> {
1837        let mut cached = self.search.borrow_mut();
1838        if let Some(cache) = cached.as_mut()
1839            && let Some((at, entries_shown, arriving)) = cache.building.take()
1840        {
1841            match arriving.try_recv() {
1842                Ok(indexed) => {
1843                    cache.revision = at;
1844                    cache.shown = entries_shown;
1845                    cache.indexed = Arc::new(indexed);
1846                }
1847                Err(std::sync::mpsc::TryRecvError::Empty) => {
1848                    cache.building = Some((at, entries_shown, arriving));
1849                }
1850                Err(std::sync::mpsc::TryRecvError::Disconnected) => {}
1851            }
1852        }
1853        if let Some(cache) = cached.as_ref() {
1854            let current = cache.revision == revision && cache.shown == shown;
1855            let arriving = cache
1856                .building
1857                .as_ref()
1858                .is_some_and(|(at, building, _)| *at == revision && *building == shown);
1859            if current || arriving {
1860                return Arc::clone(&cache.indexed);
1861            }
1862        }
1863        let generation = cached
1864            .as_ref()
1865            .map_or(0, |cache| cache.indexed.generation.wrapping_add(1));
1866        let mut entries = self.typed.search(&*self.typed.state());
1867        entries.extend(shown.iter().cloned());
1868        if entries.len() >= OFF_THREAD_ENTRIES || entries.iter().any(|entry| entry.body.is_some()) {
1869            let (send, arriving) = std::sync::mpsc::channel();
1870            let building = entries.clone();
1871            let spawned = ui_core::background::Background::current().spawn(
1872                "resource-search-index",
1873                move || {
1874                    let _ = send.send(IndexedEntries::built(building, generation));
1875                },
1876            );
1877            if spawned.is_ok() {
1878                let cache = cached.get_or_insert_with(|| SearchCache {
1879                    revision,
1880                    shown: Vec::new(),
1881                    indexed: Arc::new(IndexedEntries::built(Vec::new(), generation)),
1882                    building: None,
1883                });
1884                cache.building = Some((revision, shown, arriving));
1885                return Arc::clone(&cache.indexed);
1886            }
1887        }
1888        let indexed = Arc::new(IndexedEntries::built(entries, generation));
1889        *cached = Some(SearchCache {
1890            revision,
1891            shown,
1892            indexed: Arc::clone(&indexed),
1893            building: None,
1894        });
1895        indexed
1896    }
1897}
1898
1899impl ResourceRoot {
1900    /// Start minting a link to one of the resources this root publishes.
1901    fn start_share(
1902        &mut self,
1903        invocation: tilo_actions::InvocationId,
1904        payload: &[u8],
1905    ) -> Result<Option<ui_core::host_operation::ResourceOperationStart>, String> {
1906        let record = self
1907            .run
1908            .launched
1909            .record
1910            .as_ref()
1911            .map(|record| record.as_str().to_owned());
1912        let publisher = super::share_link::Publisher {
1913            app: self.dispatch.app_id(),
1914            record: record.as_deref(),
1915            root: self.dispatch.resource_id(),
1916        };
1917        let may_invite = self.dispatch.may_invite();
1918        let started = self
1919            .share
1920            .start(invocation, payload, &publisher, may_invite)?;
1921        Ok(Some(match started {
1922            Some(minted) => {
1923                if minted.copied {
1924                    self.dispatch
1925                        .note_landed(tilo_resource::share::SHARE_LINK, &minted.output);
1926                }
1927                ui_core::host_operation::ResourceOperationStart::Completed(minted.output)
1928            }
1929            None => ui_core::host_operation::ResourceOperationStart::Accepted,
1930        }))
1931    }
1932}
1933
1934// The root's own client for the action door (minting links) shares the
1935// app's collaboration connection with whatever the view keeps there.
1936impl ui_core::widget::Collaborative for ResourceRoot {
1937    fn take_collab_requests(&mut self) -> Vec<ui_core::collaboration::CollabRequest> {
1938        let mut requests =
1939            ui_core::widget::Collaborative::take_collab_requests(self.share.remote_mut());
1940        if let Some(content) =
1941            capability_mut::<dyn ui_core::widget::Collaborative>(self.content.as_mut())
1942        {
1943            requests.extend(content.take_collab_requests());
1944        }
1945        requests
1946    }
1947
1948    fn handle_collab_event(&mut self, event: &ui_core::collaboration::CollabEvent) {
1949        ui_core::widget::Collaborative::handle_collab_event(self.share.remote_mut(), event);
1950        if let Some(content) =
1951            capability_mut::<dyn ui_core::widget::Collaborative>(self.content.as_mut())
1952        {
1953            content.handle_collab_event(event);
1954        }
1955    }
1956}
1957
1958impl RootSearch for ResourceRoot {
1959    fn search_providers(&self) -> Vec<Box<dyn SearchProvider>> {
1960        // The resource's entries change with its revision; what the surfaces
1961        // show (a page's text, live rows) changes on their own, so their
1962        // entries are read now and the index rebuilt only when either moved.
1963        let revision = self.typed.revision();
1964        let mut shown = Vec::new();
1965        let mut queries = Vec::new();
1966        self.surfaces(&mut |surface| {
1967            shown.extend(surface.search_entries());
1968            queries.push(surface.search_queries());
1969        });
1970        let indexed = self.current_search_index(revision, shown);
1971        // The app's one search source: what its content widgets show beside
1972        // the resource reaches it as their surfaces' entries, never as a
1973        // provider of their own.
1974        let mut source = ResourceSearch::new(self.resource.label, indexed);
1975        source.scopes = self.typed.search_scopes(&*self.typed.state());
1976        source.queries = queries;
1977        vec![Box::new(source)]
1978    }
1979
1980    /// A picked hit opens the app at its anchor: the views registered with
1981    /// `on_open` show it, then any hit the content provided reaches it.
1982    fn activate_search_result(
1983        &mut self,
1984        action_id: &str,
1985        doc: &mut ui_core::widget::Edits<'_>,
1986        path: &ModelPath,
1987    ) {
1988        if self.stages.open(action_id).is_err() {
1989            ui_core::search::capability::activate_search_result(
1990                self.content.as_mut(),
1991                action_id,
1992                doc,
1993                path,
1994            );
1995        }
1996    }
1997}
1998
1999impl RootPublishes for ResourceRoot {
2000    fn resources(&self, doc: &AutoCommit) -> Vec<tilo_actions::Resource> {
2001        RootPublishes::resources_for(self, None, doc)
2002    }
2003    fn shared_resources_for(
2004        &self,
2005        participant: Option<&tilo_actions::ParticipantId>,
2006        doc: &AutoCommit,
2007    ) -> Option<Vec<ui_core::published::SharedResource>> {
2008        self.shared_resources(participant, doc)
2009    }
2010    fn resources_for(
2011        &self,
2012        participant: Option<&tilo_actions::ParticipantId>,
2013        doc: &AutoCommit,
2014    ) -> Vec<tilo_actions::Resource> {
2015        // What surfaces bind to where someone is, they bind to this
2016        // participant's view.
2017        let _viewer = participant
2018            .cloned()
2019            .map(ui_core::invocation_scope::ViewerScope::enter);
2020        let mut resource = self.current_publication(doc);
2021        // The content may add live external entities beside the resource,
2022        // never a document action: those are the resource's own. A hosted
2023        // surface has already admitted what it adds. One naming the resource
2024        // joins it rather than publishing it twice.
2025        let surface = ui_core::widget::capability::capability::<dyn SurfacePublication>(
2026            self.content.as_ref(),
2027        )
2028        .is_some_and(|surface| surface.resource() == self.resource.id);
2029        let mut extras = Vec::new();
2030        for extra in ui_core::resources::resources_for(self.content.as_ref(), participant, doc) {
2031            let extra = if surface {
2032                extra
2033            } else {
2034                outside_document(extra)
2035            };
2036            if extra.id == resource.id {
2037                join(&mut resource, extra);
2038            } else {
2039                extras.push(extra);
2040            }
2041        }
2042        if self.stages.opens()
2043            && !resource
2044                .actions
2045                .iter()
2046                .any(|existing| existing.id == "open")
2047        {
2048            resource = resource.with_action(open_spec());
2049        }
2050        if self.stages.opens() {
2051            offer_item_open(self.resource.item_of, &mut resource);
2052        }
2053        let mut resources = vec![resource];
2054        resources.append(&mut extras);
2055        resources
2056    }
2057    fn resource(&self, doc: &AutoCommit, id: &str) -> Option<tilo_actions::Resource> {
2058        RootPublishes::resource_for(self, None, doc, id)
2059    }
2060    /// Resource `id` in the published tree, or one the tree leaves out: a
2061    /// child the resource resolves by id, or a live row a surface resolves.
2062    fn resource_for(
2063        &self,
2064        participant: Option<&tilo_actions::ParticipantId>,
2065        doc: &AutoCommit,
2066        id: &str,
2067    ) -> Option<tilo_actions::Resource> {
2068        let _viewer = participant
2069            .cloned()
2070            .map(ui_core::invocation_scope::ViewerScope::enter);
2071        // A change from another window or participant may not have reached
2072        // this view yet: an item it removed is gone, one it made is there.
2073        if let Some(published) = find(&[self.current_publication(doc)], id) {
2074            return Some(published);
2075        }
2076        let current = if self.typed.follows(doc) {
2077            None
2078        } else {
2079            self.typed.read(doc).ok()
2080        };
2081        let state = current.unwrap_or_else(|| self.typed.state());
2082        let child = self.typed.child(&*state, doc, id);
2083        let mut row = None;
2084        self.surfaces(&mut |surface| {
2085            if row.is_none() {
2086                row = surface.live_row(id).map(super::LiveRow::into_resource);
2087            }
2088        });
2089        // An item past the published page is resolved on its own, and the row
2090        // a surface shows for it joins it as it does in the page.
2091        let resolved = match (child, row) {
2092            (Some(mut child), Some(row)) => {
2093                join(&mut child, row);
2094                Some(child)
2095            }
2096            (child, row) => child.or(row),
2097        };
2098        if let Some(mut resolved) = resolved {
2099            self.bind_row_declarations(std::slice::from_mut(&mut resolved));
2100            if self.stages.opens() {
2101                offer_item_open(self.resource.item_of, &mut resolved);
2102            }
2103            return Some(resolved);
2104        }
2105        // Last, what the content resolves by id beyond what it publishes (a
2106        // place, a record past the published page), without a document
2107        // action: those are the resource's own.
2108        ui_core::resources::resource_for(self.content.as_ref(), None, doc, id)
2109            .map(super::rows::resolved_outside_document)
2110    }
2111    fn document_action_result(
2112        &self,
2113        doc: &AutoCommit,
2114        address: &tilo_actions::ActionAddress,
2115        changes: &[ui_core::automerge::Patch],
2116    ) -> Option<serde_json::Value> {
2117        // The record's own actions, and its verbs bound on its items.
2118        if address.resource == self.dispatch.resource_id()
2119            || (self.resource.item_of)(address.resource.as_str())
2120        {
2121            if super::history::is_edit(&address.action) {
2122                return super::history::result(&address.action, doc, changes);
2123            }
2124            // What the action minted rides beside its own answer, the
2125            // resource's or else its surface's. An answer that is not an
2126            // object (an editor's summary naming the blocks it made) already
2127            // says what it made and is kept as it is.
2128            let minted = tilo_resource::take_minted();
2129            let answer = self
2130                .typed
2131                .answer(doc, &address.action, changes)
2132                .or_else(|| {
2133                    ui_core::resources::document_action_result(
2134                        self.content.as_ref(),
2135                        doc,
2136                        address,
2137                        changes,
2138                    )
2139                });
2140            return match answer {
2141                _ if minted.is_empty() => answer,
2142                Some(serde_json::Value::Object(mut fields)) => {
2143                    fields.insert("minted".into(), minted.into());
2144                    Some(serde_json::Value::Object(fields))
2145                }
2146                Some(other) => Some(other),
2147                None => Some(serde_json::json!({ "minted": minted })),
2148            };
2149        }
2150        ui_core::resources::document_action_result(self.content.as_ref(), doc, address, changes)
2151    }
2152}
2153
2154impl RootControls for ResourceRoot {
2155    fn take_control_invocation(&mut self) -> Option<tilo_actions::ControlInvocation> {
2156        self.take_attributed_control_invocation()
2157            .map(|(invocation, _)| invocation)
2158    }
2159    fn take_attributed_control_invocation(
2160        &mut self,
2161    ) -> Option<ui_core::widget::controls::AttributedControlInvocation> {
2162        let taken = self.dispatch.take_attributed().or_else(|| {
2163            ui_core::widget::controls::take_attributed_control_invocation(self.content.as_mut())
2164        });
2165        if let Some((invocation, _)) = &taken {
2166            self.share.note_view_invocation(invocation);
2167        }
2168        taken
2169    }
2170    /// An edit this resource's dispatcher submitted was refused, or is held
2171    /// for the user's confirmation. Nothing changed in the document, so a
2172    /// surface that previewed the edit is re-presented the stored state; a
2173    /// confirmed edit then arrives as an ordinary document change.
2174    fn on_control_invocation_result(
2175        &mut self,
2176        address: &tilo_actions::ActionAddress,
2177        result: &Result<ui_core::host_operation::ResourceOperationStart, tilo_actions::DoorError>,
2178    ) {
2179        // The host delivers feedback to the exact emitting document owner;
2180        // forwarding stops at nested owner boundaries. Its children may have
2181        // arbitrary ids, including records removed since submission. Repair
2182        // this owner's projection without inferring custody from an id prefix
2183        // or retaining requests that History may capture or discard.
2184        if let Some(effect) = ui_core::widget::controls::result_on_behalf_of() {
2185            self.dispatch
2186                .note_continuation(effect, &address.action, result);
2187        }
2188        match result {
2189            Err(error) => {
2190                if !matches!(error, tilo_actions::DoorError::ConfirmationRequired) {
2191                    self.dispatch.refuse(&address.action, error.to_string());
2192                }
2193                self.typed.represent();
2194            }
2195            Ok(ui_core::host_operation::ResourceOperationStart::Completed(output)) => {
2196                self.dispatch.note_landed(&address.action, output);
2197            }
2198            Ok(_) => {}
2199        }
2200        ui_core::widget::controls::on_control_invocation_result(
2201            self.content.as_mut(),
2202            address,
2203            result,
2204        );
2205    }
2206    fn has_pending_control_invocation(&self) -> bool {
2207        self.dispatch.has_pending()
2208            || ui_core::widget::controls::has_pending_control_invocation(self.content.as_ref())
2209    }
2210}
2211
2212// The root owns the file whose history it shows beside the resource's (the
2213// runner checks the source it registered is this root's); everything else
2214// about History is its content's, as before.
2215impl ui_core::history_mode::capability::DocumentHistory for ResourceRoot {
2216    fn history_registration(
2217        &self,
2218    ) -> Result<
2219        Option<ui_core::history_mode::HistoryRegistration>,
2220        ui_core::history_mode::HistoryRegistrationError,
2221    > {
2222        match &self.run.file_history {
2223            Some((source, definition)) => {
2224                let scopes = definition.scopes.iter().map(|scope| {
2225                    ui_core::history_mode::HistorySourceRegistration::CheckedFiles {
2226                        scope: scope.key,
2227                        source: source.clone(),
2228                    }
2229                });
2230                ui_core::history_mode::HistoryRegistration::new(definition, scopes.collect())
2231                    .map(Some)
2232            }
2233            None => ui_core::history_mode::capability::history_registration(self.content.as_ref()),
2234        }
2235    }
2236
2237    fn history_restore_readiness(&self, scope: &str) -> Result<(), String> {
2238        ui_core::widget::capability::capability::<
2239            dyn ui_core::history_mode::capability::DocumentHistory,
2240        >(self.content.as_ref())
2241        .map_or(Ok(()), |content| content.history_restore_readiness(scope))
2242    }
2243
2244    fn document_history_definition(
2245        &self,
2246    ) -> Option<&'static ui_core::history_mode::HistoryDefinition> {
2247        ui_core::history_mode::capability::document_history_definition(self.content.as_ref())
2248    }
2249
2250    fn provides_document_history(&self) -> bool {
2251        !self.history
2252            || ui_core::widget::capability::capability::<
2253                dyn ui_core::history_mode::capability::DocumentHistory,
2254            >(self.content.as_ref())
2255            .is_some_and(
2256                ui_core::history_mode::capability::DocumentHistory::provides_document_history,
2257            )
2258    }
2259
2260    fn physical_text_policy(
2261        &self,
2262        previous: Option<&tilo_actions::EncodedAction>,
2263        next: &tilo_actions::EncodedAction,
2264    ) -> (bool, bool) {
2265        self.typed.text_policy(previous, next)
2266    }
2267
2268    fn document_action_applied(
2269        &mut self,
2270        action: &tilo_actions::EncodedAction,
2271        before: &[ui_core::automerge::ChangeHash],
2272        doc: &ui_core::automerge::AutoCommit,
2273        path: &ui_core::automerge::ObjId,
2274    ) {
2275        // An action a participant took (an agent's verb) leaves them where
2276        // the resource says it lands, once the state follows it.
2277        if let Some(participant) = ui_core::invocation_scope::current_participant() {
2278            self.typed.note_applied(&participant, action);
2279        }
2280        if let Some(content) = ui_core::widget::capability::capability_mut::<
2281            dyn ui_core::history_mode::capability::DocumentHistory,
2282        >(self.content.as_mut())
2283        {
2284            content.document_action_applied(action, before, doc, path);
2285        }
2286    }
2287}
2288
2289impl ui_core::undo::CommittedActions for ResourceRoot {
2290    fn take_committed_action(&mut self) -> Option<Vec<ui_core::automerge::ChangeHash>> {
2291        self.dispatch.take_committed()
2292    }
2293    fn keeps_undo_step_open(&self) -> bool {
2294        self.dispatch.keeps_step_open()
2295    }
2296}
2297
2298impl RootApp for ResourceRoot {
2299    fn app_kind(&self) -> AppKind {
2300        AppKind::Editor
2301    }
2302
2303    fn app_title(&self) -> &str {
2304        self.title
2305    }
2306
2307    fn app_icon(&self) -> IconDescriptor {
2308        self.icon.clone()
2309    }
2310
2311    fn device_shell_operation(
2312        &mut self,
2313        operation: &ui_core::floating_surface::DeviceShellOperation,
2314    ) -> Result<serde_json::Value, String> {
2315        fn perform(
2316            widget: &mut dyn Widget,
2317            resource: std::any::TypeId,
2318            operation: &ui_core::floating_surface::DeviceShellOperation,
2319        ) -> Option<Result<serde_json::Value, String>> {
2320            if let Some(surface) = surface_mut(widget, resource)
2321                && surface.supports_device_shell()
2322            {
2323                return Some(surface.device_shell_operation(operation));
2324            }
2325            widget
2326                .children_mut()
2327                .into_iter()
2328                .find_map(|child| perform(child, resource, operation))
2329        }
2330        perform(self.content.as_mut(), self.resource.id, operation)
2331            .unwrap_or_else(|| Err("Terminal is not available".into()))
2332    }
2333
2334    fn request_device_shell(&mut self) -> bool {
2335        fn request(widget: &mut dyn Widget, resource: std::any::TypeId) -> bool {
2336            if let Some(surface) = surface_mut(widget, resource)
2337                && surface.request_device_shell()
2338            {
2339                return true;
2340            }
2341            widget
2342                .children_mut()
2343                .into_iter()
2344                .any(|child| request(child, resource))
2345        }
2346        request(self.content.as_mut(), self.resource.id)
2347    }
2348
2349    fn take_host_intent(&mut self) -> Option<ui_core::widget::AppHostIntent> {
2350        fn take(
2351            widget: &mut dyn Widget,
2352            resource: std::any::TypeId,
2353        ) -> Option<ui_core::widget::AppHostIntent> {
2354            if let Some(surface) = surface_mut(widget, resource)
2355                && let Some(intent) = surface.take_host_intent()
2356            {
2357                return Some(intent);
2358            }
2359            widget
2360                .children_mut()
2361                .into_iter()
2362                .find_map(|child| take(child, resource))
2363        }
2364        take(self.content.as_mut(), self.resource.id)
2365    }
2366
2367    fn take_save_retry(&mut self) -> bool {
2368        fn take(widget: &mut dyn Widget, resource: std::any::TypeId) -> bool {
2369            let own =
2370                surface_mut(widget, resource).is_some_and(SurfacePublication::take_save_retry);
2371            // Consume every surface's one-shot even when another requested it.
2372            widget
2373                .children_mut()
2374                .into_iter()
2375                .fold(own, |retry, child| take(child, resource) || retry)
2376        }
2377        take(self.content.as_mut(), self.resource.id)
2378    }
2379
2380    fn record_save_status(&mut self, status: RecordSaveStatus) {
2381        self.saved.set(status);
2382    }
2383
2384    // A surface that keeps presentation of its own across restarts (the
2385    // open item, the caret) saves it as the window's.
2386    fn capture_application_view(&self) -> Result<Option<ApplicationLocalView>, ThawError> {
2387        capability::<dyn ApplicationViewState>(self.content.as_ref())
2388            .map_or(Ok(None), ApplicationViewState::capture_application_view)
2389    }
2390
2391    fn restore_application_view(
2392        &mut self,
2393        local: &ApplicationLocalView,
2394        doc: &AutoCommit,
2395    ) -> Result<ApplicationFocusRestore, ThawError> {
2396        match capability_mut::<dyn ApplicationViewState>(self.content.as_mut()) {
2397            Some(view) => view.restore_application_view(local, doc),
2398            None => Err(ThawError::State(
2399                "application-local restoration unsupported".into(),
2400            )),
2401        }
2402    }
2403
2404    fn finish_application_view_restore(&mut self) -> Result<(), ThawError> {
2405        capability_mut::<dyn ApplicationViewState>(self.content.as_mut()).map_or(
2406            Ok(()),
2407            ApplicationViewState::finish_application_view_restore,
2408        )
2409    }
2410}
2411
2412#[cfg(test)]
2413#[cfg(not(target_arch = "wasm32"))]
2414mod search_tests {
2415    use super::*;
2416    use crate::prelude::*;
2417    use std::cell::RefCell;
2418
2419    thread_local! {
2420        static OPENED: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
2421    }
2422
2423    #[derive(Model, Default)]
2424    pub struct Mailbox {
2425        pub subject: String,
2426    }
2427
2428    fn entries(_: &Mailbox) -> Vec<SearchEntry> {
2429        vec![
2430            SearchEntry {
2431                anchor: Some("folder:inbox/msg:1".into()),
2432                title: "Invoice from Ada".into(),
2433                text: "March".into(),
2434                ..Default::default()
2435            },
2436            SearchEntry {
2437                anchor: Some("folder:sent/msg:2".into()),
2438                title: "Invoice to Grace".into(),
2439                text: "April".into(),
2440                ..Default::default()
2441            },
2442            // A flat id that lies in the inbox, read as a message row.
2443            SearchEntry {
2444                anchor: Some("msg:3".into()),
2445                title: "Invoice reminder".into(),
2446                text: "May".into(),
2447                within: Some("folder:inbox".into()),
2448                row: Some(
2449                    tilo_resource::SearchRow::shaped(tilo_resource::SearchRowShape::Message {
2450                        sender: "Ada".into(),
2451                        snippet: None,
2452                        date: None,
2453                        unread: true,
2454                    })
2455                    .icon("mail"),
2456                ),
2457                ..Default::default()
2458            },
2459        ]
2460    }
2461
2462    fn scopes(_: &Mailbox) -> Vec<ScopeDecl> {
2463        vec![ScopeDecl::new("folder:inbox", "Inbox")]
2464    }
2465
2466    #[resource(kind = "test.mailbox", label = "Mailbox", search = entries, scopes = scopes)]
2467    impl Mailbox {
2468        /// Set the subject.
2469        #[action]
2470        pub fn set_subject(&mut self, subject: String) {
2471            self.subject = subject;
2472        }
2473    }
2474
2475    struct MailApp;
2476
2477    impl ResourceApp for MailApp {
2478        type Resource = Mailbox;
2479        const ID: &'static str = "so.tilo.test-mailbox";
2480        const TITLE: &'static str = "Mailbox";
2481
2482        fn view(cx: &mut ViewCx<Mailbox>) -> Element {
2483            cx.on_open(|anchor| OPENED.with_borrow_mut(|opened| opened.push(anchor.to_owned())));
2484            Box::new(Column::new().push(Box::new(
2485                cx.search_button(ui_core::search::SearchRequest::this_app()),
2486            )))
2487        }
2488    }
2489
2490    thread_local! {
2491        static QUERIES: RefCell<Vec<SearchQuery>> = const { RefCell::new(Vec::new()) };
2492        static SAVE_RETRY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
2493    }
2494
2495    struct QuerySurface(ui_core::widget::Text);
2496
2497    impl ui_core::widget::Decorator for QuerySurface {
2498        type Inner = ui_core::widget::Text;
2499        fn inner(&self) -> &Self::Inner {
2500            &self.0
2501        }
2502        fn inner_mut(&mut self) -> &mut Self::Inner {
2503            &mut self.0
2504        }
2505    }
2506
2507    impl crate::framework::ResourceSurface<Mailbox> for QuerySurface {
2508        fn present(&mut self, _: &Rc<Mailbox>) {}
2509        fn take_save_retry(&mut self) -> bool {
2510            SAVE_RETRY.with(|retry| retry.replace(false))
2511        }
2512        fn search_query(&mut self, query: &SearchQuery) {
2513            QUERIES.with_borrow_mut(|queries| queries.push(query.clone()));
2514        }
2515    }
2516
2517    struct QueryApp;
2518    impl ResourceApp for QueryApp {
2519        type Resource = Mailbox;
2520        const ID: &'static str = "so.tilo.test-search-query";
2521        const TITLE: &'static str = "Query";
2522        fn view(cx: &mut ViewCx<Mailbox>) -> Element {
2523            cx.surface(QuerySurface(ui_core::widget::Text::new("Results")))
2524        }
2525    }
2526
2527    #[test]
2528    fn record_owner_consumes_the_surfaces_save_retry_once() {
2529        SAVE_RETRY.with(|retry| retry.set(false));
2530        let mut app = TestApp::<QueryApp>::new().unwrap();
2531        assert!(!app.take_save_retry().unwrap());
2532        SAVE_RETRY.with(|retry| retry.set(true));
2533        assert!(app.take_save_retry().unwrap());
2534        assert!(!app.take_save_retry().unwrap());
2535    }
2536
2537    #[test]
2538    fn platform_search_delivers_only_the_latest_bounded_query_to_the_surface() {
2539        QUERIES.with_borrow_mut(Vec::clear);
2540        let mut app = TestApp::<QueryApp>::new().unwrap();
2541        QUERIES.with_borrow_mut(Vec::clear);
2542        app.harness()
2543            .with_widget_mut(|root: &mut ResourceRoot| {
2544                let providers = RootSearch::search_providers(root);
2545                assert_eq!(providers.len(), 1, "one canonical resource source");
2546                let search = &providers[0];
2547                search.search("old");
2548                let query = SearchQuery::new("invoice").within("folder:inbox");
2549                search.search_page(&query, None, 10, 10);
2550                search.search_page(&query, None, 10, 10);
2551                search.search(&"x".repeat(ui_core::search::MAX_SEARCH_QUERY_SCALARS + 1));
2552                assert!(
2553                    QUERIES.with_borrow(Vec::is_empty),
2554                    "delivery waits for UI sync"
2555                );
2556            })
2557            .unwrap();
2558        app.settle();
2559        QUERIES.with_borrow(|queries| {
2560            assert_eq!(
2561                queries,
2562                &[SearchQuery::new("invoice").within("folder:inbox")]
2563            );
2564        });
2565        app.settle();
2566        assert_eq!(
2567            QUERIES.with_borrow(Vec::len),
2568            1,
2569            "a settled query is not repeated"
2570        );
2571    }
2572
2573    #[test]
2574    fn a_hit_opens_the_app_at_its_anchor_and_scopes_narrow_the_search() {
2575        let mut app = TestApp::<MailApp>::new().unwrap();
2576        let (titles, mut scoped, declared, reminder) = app
2577            .harness()
2578            .with_widget_mut(|root: &mut ResourceRoot| {
2579                let providers = RootSearch::search_providers(root);
2580                let search = providers.first().unwrap();
2581                let titles = |query: &SearchQuery| {
2582                    search
2583                        .search_page(query, None, 10, 10)
2584                        .results
2585                        .into_iter()
2586                        .map(|hit| hit.title)
2587                        .collect::<Vec<_>>()
2588                };
2589                let reminder = search
2590                    .search_page(&SearchQuery::new("reminder"), None, 10, 10)
2591                    .results
2592                    .into_iter()
2593                    .next();
2594                (
2595                    titles(&SearchQuery::new("invoice")),
2596                    titles(&SearchQuery::new("invoice").within("folder:inbox")),
2597                    search.search_scopes(),
2598                    reminder,
2599                )
2600            })
2601            .unwrap();
2602        assert_eq!(titles.len(), 3);
2603        scoped.sort();
2604        assert_eq!(scoped, ["Invoice from Ada", "Invoice reminder"]);
2605        let reminder = reminder.expect("the flat-id entry is found");
2606        assert_eq!(reminder.anchor(), "folder:inbox");
2607        assert_eq!(reminder.icon, Some(ui_core::icons::Icon::Mail));
2608        assert!(matches!(
2609            reminder.row,
2610            Some(ui_core::search::SearchRow::Message { unread: true, .. })
2611        ));
2612        assert_eq!(declared, [ScopeDecl::new("folder:inbox", "Inbox")]);
2613
2614        // Picking a hit opens the app at it.
2615        app.harness()
2616            .with_widget_mut(|root: &mut ResourceRoot| {
2617                let mut doc = ui_core::automerge::AutoCommit::new();
2618                RootSearch::activate_search_result(
2619                    root,
2620                    "folder:inbox/msg:1",
2621                    &mut ui_core::widget::Edits::lend(&mut doc),
2622                    &ui_core::automerge::ROOT,
2623                );
2624            })
2625            .unwrap();
2626        OPENED.with_borrow(|opened| assert_eq!(opened.as_slice(), ["folder:inbox/msg:1"]));
2627
2628        // An agent opens it through the same door.
2629        app.call("open", serde_json::json!({ "anchor": "folder:sent/msg:2" }))
2630            .unwrap();
2631        OPENED.with_borrow(|opened| {
2632            assert_eq!(opened.last().map(String::as_str), Some("folder:sent/msg:2"));
2633        });
2634    }
2635}
2636
2637/// The window of a record that did not read as the resource when it was
2638/// opened: why, instead of the app's view, and the resource's actions
2639/// published as unavailable for that reason.
2640pub(crate) struct Unopened {
2641    title: &'static str,
2642    icon: IconDescriptor,
2643    /// What it publishes: the resource's declared actions, every document
2644    /// one unavailable for the reason.
2645    resource: tilo_actions::Resource,
2646    shown: ui_core::widget::EmptyState,
2647}
2648
2649impl Unopened {
2650    /// The window of a record that does not read as the resource of
2651    /// `kind` and `label`, whose actions are `declarations`.
2652    pub(crate) fn new(
2653        title: &'static str,
2654        icon: IconDescriptor,
2655        (kind, label): (&'static str, &'static str),
2656        declarations: &[tilo_resource::Declaration],
2657        reason: String,
2658    ) -> Self {
2659        let mut resource = tilo_actions::Resource::new(kind, kind, label);
2660        for decl in declarations {
2661            resource = resource.with_action(tilo_resource::declaration_spec(decl));
2662        }
2663        withhold_document_actions(&mut resource, &reason);
2664        Self {
2665            title,
2666            icon,
2667            shown: ui_core::widget::EmptyState::error(
2668                ui_core::i18n::I18nManager::lookup_plain("framework-unreadable"),
2669                reason,
2670            ),
2671            resource,
2672        }
2673    }
2674}
2675
2676impl Unopened {
2677    /// The window as a host mounts it, its widget table compiled once for
2678    /// every app.
2679    pub(crate) fn boxed(self) -> Box<dyn AppWidget> {
2680        Box::new(self)
2681    }
2682}
2683
2684impl Decorator for Unopened {
2685    ui_core::provides!(dyn ui_core::resources::PublishesResources);
2686
2687    type Inner = ui_core::widget::EmptyState;
2688
2689    fn inner(&self) -> &ui_core::widget::EmptyState {
2690        &self.shown
2691    }
2692
2693    fn inner_mut(&mut self) -> &mut ui_core::widget::EmptyState {
2694        &mut self.shown
2695    }
2696}
2697
2698impl ui_core::resources::PublishesResources for Unopened {
2699    fn resources(&self, _doc: &AutoCommit) -> Vec<tilo_actions::Resource> {
2700        vec![self.resource.clone()]
2701    }
2702}
2703
2704impl AppWidget for Unopened {
2705    fn app_kind(&self) -> AppKind {
2706        AppKind::Editor
2707    }
2708
2709    fn app_title(&self) -> &str {
2710        self.title
2711    }
2712
2713    fn app_icon(&self) -> IconDescriptor {
2714        self.icon.clone()
2715    }
2716}
2717
2718#[cfg(test)]
2719mod tests {
2720    use super::*;
2721
2722    /// The ids the framework keeps are the ones it publishes on every
2723    /// app: the generic actions, `open`, `read`, history and presentation.
2724    #[test]
2725    fn the_reserved_ids_are_what_the_framework_publishes() {
2726        let reserved = reserved_action_ids();
2727        for id in [
2728            "apply",
2729            "group",
2730            "field-edit",
2731            "open",
2732            "read",
2733            "undo",
2734            "redo",
2735            "restore",
2736            "restore-reviewed",
2737            "review-restore",
2738            "checkpoint",
2739            "history",
2740            "diff",
2741            "render",
2742        ] {
2743            assert!(
2744                reserved.iter().any(|kept| kept == id),
2745                "{id} in {reserved:?}"
2746            );
2747        }
2748        assert!(
2749            !reserved.iter().any(|kept| kept == "rename"),
2750            "an app's own verb name is not kept"
2751        );
2752    }
2753
2754    #[test]
2755    fn a_search_entry_naming_an_action_is_a_hit_that_invokes_it() {
2756        let open = tilo_actions::ActionAddress::new("notes.page:1", "open");
2757        let search = ResourceSearch {
2758            name: "Notes",
2759            indexed: Arc::new(IndexedEntries::built(
2760                vec![
2761                    SearchEntry::new("Groceries", "milk and eggs").invoking(open.clone()),
2762                    SearchEntry::new("Garden", "roses").anchored("page:2"),
2763                ],
2764                0,
2765            )),
2766            scopes: Vec::new(),
2767            queries: Vec::new(),
2768        };
2769        let groceries = search.search("groceries");
2770        assert_eq!(groceries.len(), 1);
2771        assert_eq!(groceries[0].action_address(), Some(open));
2772        let garden = search.search("garden");
2773        assert_eq!(garden.len(), 1);
2774        assert_eq!(garden[0].action_address(), None);
2775        assert_eq!(garden[0].action_id, "page:2");
2776    }
2777
2778    #[test]
2779    fn a_search_entry_reads_as_its_group_subtitle_and_icon() {
2780        let reveal = tilo_actions::ActionAddress::new("video.marker:1", "reveal");
2781        let search = ResourceSearch {
2782            name: "Project",
2783            indexed: Arc::new(IndexedEntries::built(
2784                vec![
2785                    SearchEntry::new("Sunset", "the last light")
2786                        .invoking(reveal)
2787                        .grouped("Timeline markers")
2788                        .subtitled("0:21 · Drone")
2789                        .with_icon("star")
2790                        .boosted(5),
2791                    SearchEntry::new("Sunset take", ""),
2792                ],
2793                0,
2794            )),
2795            scopes: Vec::new(),
2796            queries: Vec::new(),
2797        };
2798        let hits = search.search("sunset");
2799        let marker = hits
2800            .iter()
2801            .find(|hit| hit.title == "Sunset")
2802            .expect("the marker is found");
2803        assert_eq!(marker.kind, "Timeline markers");
2804        assert_eq!(marker.subtitle.as_deref(), Some("0:21 · Drone"));
2805        assert_eq!(marker.icon, Some(ui_core::icons::Icon::Star));
2806        let plain = hits
2807            .iter()
2808            .find(|hit| hit.title == "Sunset take")
2809            .expect("the plain entry is found");
2810        assert_eq!(plain.kind, "Project", "an ungrouped hit is the resource's");
2811        assert!(plain.preview.is_none(), "no text, no preview");
2812        // Subtitles are searched too: a marker is found by the shot it is on.
2813        assert!(
2814            search
2815                .search("drone")
2816                .iter()
2817                .any(|hit| hit.title == "Sunset")
2818        );
2819    }
2820
2821    #[test]
2822    fn every_entry_stays_findable_page_by_page() {
2823        let search = ResourceSearch::new(
2824            "Tracks",
2825            Arc::new(IndexedEntries::built(
2826                (0..257)
2827                    .map(|n| SearchEntry::new(format!("Common track {n}"), ""))
2828                    .collect(),
2829                0,
2830            )),
2831        );
2832        let mut cursor = None;
2833        let mut seen = std::collections::HashSet::new();
2834        loop {
2835            let page = search.search_page(&SearchQuery::new("common"), cursor.as_ref(), 17, 5);
2836            assert!(page.results.len() <= 5);
2837            for hit in &page.results {
2838                assert!(seen.insert(hit.title.clone()), "{} came twice", hit.title);
2839            }
2840            let Some(next) = page.next else { break };
2841            assert!(search.action_directory_cursor_is_current(&next));
2842            cursor = Some(next);
2843        }
2844        assert_eq!(seen.len(), 257, "every match is reachable");
2845    }
2846
2847    #[test]
2848    fn no_caller_materialises_more_than_one_page_at_once() {
2849        let search = ResourceSearch::new(
2850            "Tables",
2851            Arc::new(IndexedEntries::built(
2852                (0..300)
2853                    .map(|n| SearchEntry::new(format!("table {n}"), ""))
2854                    .collect(),
2855                0,
2856            )),
2857        );
2858        assert_eq!(
2859            search.search_limited("", usize::MAX).len(),
2860            DEFAULT_SEARCH_RESULT_LIMIT
2861        );
2862        assert_eq!(
2863            search
2864                .search_page(&SearchQuery::new(""), None, usize::MAX, usize::MAX)
2865                .results
2866                .len(),
2867            DEFAULT_SEARCH_RESULT_LIMIT
2868        );
2869    }
2870
2871    /// A chapter of a book: one paragraph per segment, each opening the
2872    /// book where it is.
2873    struct Chapter(Vec<&'static str>);
2874
2875    impl tilo_resource::SearchText for Chapter {
2876        fn segment_count(&self) -> usize {
2877            self.0.len()
2878        }
2879
2880        fn piece_count(&self, segment: usize) -> usize {
2881            usize::from(segment < self.0.len())
2882        }
2883
2884        fn piece(&self, segment: usize, piece: usize) -> Option<tilo_resource::SearchPiece<'_>> {
2885            let text = self.0.get(segment)?;
2886            (piece == 0).then_some(tilo_resource::SearchPiece {
2887                text,
2888                space_before: segment > 0,
2889            })
2890        }
2891
2892        fn locates(&self, _segment: usize) -> bool {
2893            true
2894        }
2895
2896        fn location(&self, segment: usize) -> Option<SearchEntry> {
2897            Some(
2898                SearchEntry::new(format!("Paragraph {}", segment.saturating_add(1)), "").invoking(
2899                    tilo_actions::ActionAddress::new(format!("book.location:{segment}"), "open"),
2900                ),
2901            )
2902        }
2903    }
2904
2905    #[test]
2906    fn a_match_in_a_body_is_the_place_it_is_in() {
2907        let body = tilo_resource::SearchBody::new(Arc::new(Chapter(vec![
2908            "It was a dark and stormy night.",
2909            "The lighthouse keeper counted the waves.",
2910        ])));
2911        let search = ResourceSearch::new(
2912            "Library",
2913            Arc::new(IndexedEntries::built(
2914                vec![
2915                    SearchEntry::new("Storm Tales", "")
2916                        .invoking(tilo_actions::ActionAddress::new("book:1", "open"))
2917                        .grouped("Books")
2918                        .with_body(body.clone()),
2919                ],
2920                0,
2921            )),
2922        );
2923        let hits = search.search("lighthouse keeper");
2924        let place = hits
2925            .iter()
2926            .find(|hit| hit.title == "Paragraph 2")
2927            .expect("the paragraph the words are in is a hit");
2928        assert_eq!(
2929            place.action_address(),
2930            Some(tilo_actions::ActionAddress::new("book.location:1", "open"))
2931        );
2932        assert_eq!(place.kind, "Books", "a location is listed with its book");
2933        assert!(
2934            search
2935                .search("storm tales")
2936                .iter()
2937                .any(|hit| hit.title == "Storm Tales"),
2938            "the book itself is found by its title"
2939        );
2940        assert_eq!(body, body.clone(), "a body is the text it shares");
2941    }
2942
2943    #[test]
2944    fn a_field_ranks_by_its_own_weight() {
2945        use tilo_resource::SearchWeight;
2946        let search = ResourceSearch::new(
2947            "Mail",
2948            Arc::new(IndexedEntries::built(
2949                vec![
2950                    SearchEntry::new("Lunch plans", "")
2951                        .field("[email protected]", SearchWeight::Metadata),
2952                    SearchEntry::new("Quarterly report", "")
2953                        .field("[email protected]", SearchWeight::Identity),
2954                ],
2955                0,
2956            )),
2957        );
2958        let hits = search.search("[email protected]");
2959        assert_eq!(hits.len(), 2, "both are found by the address");
2960        assert_eq!(
2961            hits[0].title, "Quarterly report",
2962            "a match in an identity outranks one in metadata"
2963        );
2964    }
2965}
2966
2967// Compiled only into native tests: the file's own `cfg` says so.
2968mod publication_tests;