1use 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 resource: ResourceType,
54 typed: Box<dyn TypedRoot>,
58 action_bindings: super::controls::ActionTable,
60 followed: std::cell::Cell<u64>,
62 _alive: Rc<()>,
63 dispatch: DispatchCore,
64 saved: LocalState<RecordSaveStatus>,
65 permissions: Option<LivePermissions>,
69 write: Option<PermissionScope>,
70 stages: Stages,
71 feeds: super::history::HistoryFeeds,
72 pub(crate) run: Run,
74 pub(crate) storage: Rc<super::settings::ViewStorage>,
76 pub(crate) content: Element,
78 unreadable: Option<(String, Element)>,
81 swapped: bool,
83 presentation: Vec<tilo_actions::ActionSpec>,
86 search: std::cell::RefCell<Option<SearchCache>>,
88 publication_tree: std::cell::RefCell<Option<SharedTreeCache>>,
93 item_children: std::cell::RefCell<Option<ItemChildren>>,
96 published_children: std::cell::RefCell<Option<PublishedChildren>>,
98 record_children: std::cell::RefCell<Option<RecordChildren>>,
101 binding_head: std::cell::RefCell<Option<BindingHead>>,
105 binding_base: std::cell::RefCell<Option<Rc<BindingBase>>>,
108 nested_views: std::cell::RefCell<Option<NestedViews>>,
110 history: bool,
112 native_presentation: crate::runner::Presentation,
114 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
124pub(crate) const OPEN_TYPE: &str = "tilo.app.open";
127
128const OPEN_ANCHOR: &str = "anchor";
130
131fn 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
161const ITEM_VIEW_LABEL: &str = "Item";
164
165fn 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
188fn 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 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#[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
250pub(crate) const READ_TYPE: &str = "tilo.resource.read";
252
253fn 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
299fn 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 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 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 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 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 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 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 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 pub(crate) fn into_widget(self) -> Box<dyn AppWidget> {
525 RootWidget::boxed(Box::new(self))
526 }
527
528 pub(crate) fn into_root_widget(self) -> Box<dyn Widget> {
532 Box::new(RootWidget::new(Box::new(self)))
533 }
534
535 pub(crate) fn typed<R: Resource>(&self) -> Option<&Typed<R>> {
537 self.typed.as_any().downcast_ref::<Typed<R>>()
538 }
539
540 pub(crate) fn launch(&self) -> super::launch::LaunchInput<'_> {
542 self.run.launch()
543 }
544
545 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 #[inline(never)]
570 fn bind_row_declarations(&self, rows: &mut [tilo_actions::Resource]) {
571 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 #[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 #[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 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 #[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 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 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 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 #[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 #[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 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 #[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 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 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 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 #[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 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 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
1004struct 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
1018fn 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#[derive(Clone, PartialEq)]
1030struct BaseKey {
1031 revision: (u64, u64),
1032 epoch: Option<String>,
1033 causal: bool,
1034 unreadable: Option<String>,
1035}
1036
1037struct BindingBase {
1039 key: BaseKey,
1040 resource: Rc<tilo_actions::Resource>,
1041 head_len: usize,
1043 shadow_len: usize,
1046}
1047
1048struct 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
1061fn 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#[derive(Clone, Copy, PartialEq, Eq)]
1083enum Decoration {
1084 Tree,
1086 Actions,
1089}
1090
1091#[derive(Clone)]
1094struct BindingHead {
1095 revision: (u64, u64),
1096 epoch: Option<String>,
1098 resource: tilo_actions::Resource,
1099}
1100
1101#[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
1126fn 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
1142pub 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
1160fn 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
1192const OFF_THREAD_ENTRIES: usize = 1024;
1195
1196struct SearchCache {
1199 revision: u64,
1200 shown: Vec<SearchEntry>,
1201 indexed: Arc<IndexedEntries>,
1202 building: Option<(
1205 u64,
1206 Vec<SearchEntry>,
1207 std::sync::mpsc::Receiver<IndexedEntries>,
1208 )>,
1209}
1210
1211struct 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
1237pub(crate) struct IndexedEntries {
1239 entries: Vec<SearchEntry>,
1240 facets: Vec<ui_core::search::Facet>,
1241 index: SearchIndex,
1242 generation: u64,
1245}
1246
1247impl IndexedEntries {
1248 pub(crate) fn built(entries: Vec<SearchEntry>, generation: u64) -> Self {
1249 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
1299fn 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
1312pub(crate) struct ResourceSearch {
1314 name: &'static str,
1315 indexed: Arc<IndexedEntries>,
1316 scopes: Vec<ScopeDecl>,
1317 queries: Vec<super::surface::SearchQueries>,
1318}
1319
1320fn 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 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 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 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 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 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
1551impl 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 fn view_only_scroll(&self, event: &ui_core::winit::event::WindowEvent) -> bool {
1580 self.content.view_only_scroll(event)
1581 }
1582
1583 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 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 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
1641 self
1642 }
1643
1644 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 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 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 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 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 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
1934impl 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 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 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 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 let _viewer = participant
2018 .cloned()
2019 .map(ui_core::invocation_scope::ViewerScope::enter);
2020 let mut resource = self.current_publication(doc);
2021 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 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 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 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 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 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 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 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 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
2212impl 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 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 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 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 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 #[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 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 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
2637pub(crate) struct Unopened {
2641 title: &'static str,
2642 icon: IconDescriptor,
2643 resource: tilo_actions::Resource,
2646 shown: ui_core::widget::EmptyState,
2647}
2648
2649impl Unopened {
2650 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 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 #[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 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 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
2967mod publication_tests;