Skip to main content

tilo_app/framework/
rows.rs

1//! Live external rows: what a surface shows that lives outside the
2//! resource's document — processes, devices, containers, journal entries,
3//! fetched catalogue rows, running jobs — published beneath the resource for
4//! the palette, agents and scripts.
5
6use tilo_actions::{ActionSpec, ResourcePresentation, ResourceSemanticIdentity, ResourceSpec};
7use tilo_resource::Effect;
8
9/// One live external entity a surface publishes beneath its resource, with
10/// the view and effect operations it offers (inspect a process, stop a
11/// container).
12///
13/// A row is not document state: it has no history, no undo and nothing to
14/// sync, so it can never carry a document mutation. A change to the
15/// document is the resource's `Action` through the
16/// [`Dispatcher`](super::Dispatcher), bound to a row's item through
17/// [`Publishes::row_edits`](super::Publishes::row_edits) or the resource's
18/// `children`.
19///
20/// Rows are merged by id into the resource's published children: a row with
21/// the id of a document child adds its operations to that child instead of
22/// publishing a second resource under the same id.
23///
24/// A row is shared, not copied: a surface that keeps the rows it made while
25/// what they show holds hands the same rows to every publication, and the
26/// publication reuses what it built from each one instead of building it
27/// again.
28#[derive(Clone)]
29pub struct LiveRow {
30    resource: std::rc::Rc<ResourceSpec>,
31}
32
33impl LiveRow {
34    /// A row with a stable `id` (unique within the resource, and naming the
35    /// same entity for as long as it lives: a process id with its start
36    /// time, a container id), a `kind` tag and a human `label`.
37    pub fn new(id: impl Into<String>, kind: impl Into<String>, label: impl Into<String>) -> Self {
38        Self {
39            resource: std::rc::Rc::new(ResourceSpec::new(id, kind, label)),
40        }
41    }
42
43    /// A row of the declared type `R` (a resource type of `R`'s kind), whose
44    /// noun its type publishes, of the app the type is declared in: its
45    /// `id`, its `label`, and the [`ResourceType`](tilo_actions::ResourceType)
46    /// search matches "videos of …" against. A row an app declares an item of
47    /// a `#[resource]` type publishes (`Video`, `Channel`) is one of these,
48    /// so what it is is the type's and the search's, nothing the app writes.
49    pub fn typed<R: tilo_resource::words::RowType>(
50        id: impl Into<String>,
51        label: impl Into<String>,
52    ) -> Self {
53        Self {
54            resource: std::rc::Rc::new(ResourceSpec::new(id, R::KIND, label).with_type(R::words())),
55        }
56    }
57
58    /// This row's type, saying what it is as people name it ([`Self::typed`]);
59    /// a row made with [`Self::new`] says nothing.
60    #[must_use]
61    pub fn with_type(mut self, words: tilo_actions::ResourceType) -> Self {
62        std::rc::Rc::make_mut(&mut self.resource).resource_type = Some(words);
63        self
64    }
65
66    /// Attach the stable source-domain identity used to find this row across
67    /// live dispatch aliases. It is descriptive metadata only and grants no
68    /// permission or authority. Invalid identities are omitted at this
69    /// publication boundary.
70    #[must_use]
71    pub fn semantic_identity(mut self, identity: ResourceSemanticIdentity) -> Self {
72        std::rc::Rc::make_mut(&mut self.resource).semantic_identity =
73            identity.is_valid().then_some(identity);
74        self
75    }
76
77    /// `resource`, an entity the surface already describes as a resource
78    /// (a search hit, a catalogue entry), as a row: its presentation,
79    /// views, operations and children. Its document actions are dropped at
80    /// any depth: the resource's own publication carries them.
81    pub fn from_resource(resource: ResourceSpec) -> Self {
82        Self {
83            resource: std::rc::Rc::new(resolved_outside_document(resource)),
84        }
85    }
86
87    /// Offer a view or effect operation on the row.
88    ///
89    /// # Panics
90    /// In a debug build, when `operation` is a document mutation; a release
91    /// build drops it.
92    #[must_use]
93    pub fn operation(mut self, operation: ActionSpec) -> Self {
94        if admits(&operation, self.id()) {
95            std::rc::Rc::make_mut(&mut self.resource)
96                .actions
97                .push(operation);
98        }
99        self
100    }
101
102    /// How the entity stands right now (a process's CPU use, what a timer
103    /// has left), shown beside its label. A row merged into a document child
104    /// that has no presentation of its own lends it this one.
105    #[must_use]
106    pub fn presentation(mut self, presentation: ResourcePresentation) -> Self {
107        std::rc::Rc::make_mut(&mut self.resource).presentation = Some(presentation);
108        self
109    }
110
111    /// Offer `effect`, bound to this row's entity (stop *this* process):
112    /// invoked here, it reaches the surface's or view's handler of `E` as
113    /// that value, under the effect's declared contract.
114    #[must_use]
115    pub fn effect<E: Effect>(self, effect: &E) -> Self {
116        self.operation(effect.bound())
117    }
118
119    /// Offer `view` of the row's entity: a way to show it (a message in
120    /// its conversation, a range of its text) that a link or an agent
121    /// navigates to, opened by one of the row's own operations.
122    #[must_use]
123    pub fn view(mut self, view: tilo_actions::ResourceViewSpec) -> Self {
124        std::rc::Rc::make_mut(&mut self.resource).views.push(view);
125        self
126    }
127
128    /// Nest `row` beneath this one (a container's logs).
129    #[must_use]
130    pub fn row(mut self, row: LiveRow) -> Self {
131        std::rc::Rc::make_mut(&mut self.resource)
132            .children
133            .push(std::rc::Rc::unwrap_or_clone(row.resource));
134        self
135    }
136
137    pub fn id(&self) -> &str {
138        self.resource.id.as_str()
139    }
140
141    /// The row as the published tree carries it.
142    pub fn into_resource(self) -> ResourceSpec {
143        let mut resource = std::rc::Rc::unwrap_or_clone(self.resource);
144        tilo_resource::share::offer_tree(&mut resource);
145        resource
146    }
147
148    /// Whether `self` and `other` are the same row, handed out again: a
149    /// clone of one row, not two rows that read alike.
150    pub fn same_row(&self, other: &Self) -> bool {
151        std::rc::Rc::ptr_eq(&self.resource, &other.resource)
152    }
153}
154
155use ui_core::resources::admits_outside_document as admits;
156pub(crate) use ui_core::resources::{outside_document, resolved_outside_document};
157
158/// Merge `rows` by id into `children`: a row naming an existing child adds
159/// the operations and rows that child lacks, and its presentation when the
160/// child has none; any other row is appended. The first publisher of an id
161/// keeps its label and kind.
162pub(crate) fn merge(
163    children: &mut Vec<ResourceSpec>,
164    rows: impl IntoIterator<Item = ResourceSpec>,
165) {
166    for row in rows {
167        match children.iter_mut().find(|child| child.id == row.id) {
168            Some(existing) => join(existing, row),
169            None => children.push(row),
170        }
171    }
172}
173
174/// Add to `existing` the presentation, semantic identity, operations, views and rows of `row`,
175/// which names it, that it lacks.
176pub(crate) fn join(existing: &mut ResourceSpec, row: ResourceSpec) {
177    if existing.presentation.is_none() {
178        existing.presentation = row.presentation;
179    }
180    if existing.semantic_identity.is_none() {
181        existing.semantic_identity = row
182            .semantic_identity
183            .filter(ResourceSemanticIdentity::is_valid);
184    }
185    for action in row.actions {
186        if !existing.actions.iter().any(|known| known.id == action.id) {
187            existing.actions.push(action);
188        }
189    }
190    let mut views_added = false;
191    for view in row.views {
192        if !existing.views.iter().any(|known| known.id == view.id) {
193            existing.views.push(view);
194            views_added = true;
195        }
196    }
197    if views_added {
198        // The link actions name the views they were offered with.
199        tilo_resource::share::reoffer(existing);
200    }
201    merge(&mut existing.children, row.children);
202}
203
204pub(crate) fn find(resources: &[ResourceSpec], id: &str) -> Option<ResourceSpec> {
205    resources.iter().find_map(|resource| {
206        if resource.id.as_str() == id {
207            Some(resource.clone())
208        } else {
209            find(&resource.children, id)
210        }
211    })
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn merging_retains_the_selector_identity_without_overwriting_the_first_publisher() {
220        let identity = ResourceSemanticIdentity::new("chat.agent", "chat.agent:scribe").unwrap();
221        let row = || {
222            LiveRow::new("agent:1", "chat.agent", "Scribe")
223                .semantic_identity(identity.clone())
224                .into_resource()
225        };
226        let mut children = vec![ResourceSpec::new("agent:1", "chat.agent", "Document label")];
227        merge(&mut children, [row()]);
228        assert_eq!(children.len(), 1);
229        assert_eq!(children[0].semantic_identity, Some(identity.clone()));
230        assert_eq!(children[0].label, "Document label");
231
232        let first = ResourceSemanticIdentity::new("chat.agent", "chat.agent:reed").unwrap();
233        children[0].semantic_identity = Some(first.clone());
234        merge(&mut children, [row()]);
235        assert_eq!(children[0].semantic_identity, Some(first));
236    }
237
238    #[test]
239    fn live_row_retains_only_a_valid_typed_semantic_identity() {
240        let identity = ResourceSemanticIdentity::new("chat.agent", "chat.agent:scribe")
241            .expect("the bounded typed identity is valid");
242        let resource = LiveRow::new("opaque:1", "chat.agent", "Scribe")
243            .semantic_identity(identity.clone())
244            .into_resource();
245        assert_eq!(resource.semantic_identity, Some(identity));
246
247        let invalid = ResourceSemanticIdentity {
248            kind: "chat agent".into(),
249            key: "chat.agent:scribe".into(),
250        };
251        assert!(
252            LiveRow::new("opaque:2", "chat.agent", "Scribe")
253                .semantic_identity(invalid)
254                .into_resource()
255                .semantic_identity
256                .is_none(),
257            "a live row never publishes malformed selector metadata"
258        );
259    }
260}