Skip to main content

tmprl_core/
outline.rs

1//! The collapsible view over a grouped history, and the virtualisation that makes it
2//! survive a large one.
3//!
4//! Histories routinely reach tens of thousands of events and pathological ones reach
5//! millions, so nothing here ever materialises a list of rendered rows. The outline knows
6//! how many rows it *would* have and can answer "what is row 84,102" without building rows
7//! 0 to 84,101. Scrolling moves an index.
8//!
9//! The trick is one cumulative-offset table, rebuilt only when the shape changes, a group
10//! expanded, plumbing toggled, and never per frame. Looking a row up is then a binary
11//! search over that table.
12
13use crate::history::{Category, Group, NormalizedEvent, Outcome};
14
15/// One line on screen.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum Row {
18    /// A group's own line: the summary of several events.
19    Group {
20        /// Index into [`Outline::groups`].
21        group: usize,
22        expanded: bool,
23    },
24    /// One event inside an expanded group.
25    Event {
26        group: usize,
27        /// Index into [`Outline::events`].
28        event: usize,
29    },
30}
31
32/// A grouped history, with expansion and filtering, addressable by row.
33pub struct Outline {
34    events: Vec<NormalizedEvent>,
35    groups: Vec<Group>,
36    /// Parallel to `groups`.
37    expanded: Vec<bool>,
38    /// Indices into `groups`, in display order, after filtering.
39    visible: Vec<usize>,
40    /// Rows before `visible[i]`. One longer than `visible`, so the last entry is the total
41    /// row count, which is what makes `len()` free.
42    offsets: Vec<usize>,
43    /// Whether workflow-task groups are shown. They are the worker polling, and on a real
44    /// history they are the majority of events and almost never what you came to read.
45    show_plumbing: bool,
46}
47
48impl Outline {
49    pub fn new(events: Vec<NormalizedEvent>, groups: Vec<Group>) -> Self {
50        let expanded = vec![false; groups.len()];
51        let mut o = Self {
52            events,
53            groups,
54            expanded,
55            visible: Vec::new(),
56            offsets: Vec::new(),
57            show_plumbing: false,
58        };
59        o.reindex();
60        o
61    }
62
63    /// Swap in a re-grouped history after another page arrived, keeping what the reader
64    /// has set up.
65    ///
66    /// History is append-only, so re-grouping the accumulated events yields the same groups
67    /// in the same order plus new ones on the end, which is why expansion can be carried
68    /// over by index. Rebuilding the outline from scratch on every page would silently fold
69    /// shut whatever the reader had just opened.
70    pub fn replace(&mut self, events: Vec<NormalizedEvent>, groups: Vec<Group>) {
71        self.expanded.resize(groups.len(), false);
72        self.expanded.truncate(groups.len());
73        self.events = events;
74        self.groups = groups;
75        self.reindex();
76    }
77
78    pub fn groups(&self) -> &[Group] {
79        &self.groups
80    }
81
82    pub fn events(&self) -> &[NormalizedEvent] {
83        &self.events
84    }
85
86    pub fn show_plumbing(&self) -> bool {
87        self.show_plumbing
88    }
89
90    /// Total rows. Free: it is the last cumulative offset, not a count of anything.
91    pub fn len(&self) -> usize {
92        self.offsets.last().copied().unwrap_or(0)
93    }
94
95    pub fn is_empty(&self) -> bool {
96        self.len() == 0
97    }
98
99    /// What is on row `row`, without building the rows before it.
100    pub fn row_at(&self, row: usize) -> Option<Row> {
101        if row >= self.len() {
102            return None;
103        }
104        // The last offset is the total, so a hit is always in `visible`.
105        let slot = match self.offsets.binary_search(&row) {
106            Ok(i) => i,
107            Err(i) => i - 1,
108        };
109        let group = self.visible[slot];
110        let local = row - self.offsets[slot];
111        Some(if local == 0 {
112            Row::Group {
113                group,
114                expanded: self.expanded[group],
115            }
116        } else {
117            let event_id = self.groups[group].events[local - 1];
118            Row::Event {
119                group,
120                event: self.event_index(event_id).unwrap_or(0),
121            }
122        })
123    }
124
125    /// The rows in `[first, first + count)`. Only these are built.
126    pub fn slice(&self, first: usize, count: usize) -> Vec<Row> {
127        (first..first.saturating_add(count))
128            .map_while(|r| self.row_at(r))
129            .collect()
130    }
131
132    pub fn group(&self, index: usize) -> Option<&Group> {
133        self.groups.get(index)
134    }
135
136    pub fn event(&self, index: usize) -> Option<&NormalizedEvent> {
137        self.events.get(index)
138    }
139
140    /// Fold a group open or shut. Returns the row the group's own line now sits on, so a
141    /// caller can keep the cursor on it.
142    pub fn toggle(&mut self, group: usize) -> Option<usize> {
143        *self.expanded.get_mut(group)? = !self.expanded[group];
144        self.reindex();
145        self.row_of_group(group)
146    }
147
148    pub fn is_expanded(&self, group: usize) -> bool {
149        self.expanded.get(group).copied().unwrap_or(false)
150    }
151
152    pub fn expand_all(&mut self) {
153        self.expanded.iter_mut().for_each(|e| *e = true);
154        self.reindex();
155    }
156
157    pub fn collapse_all(&mut self) {
158        self.expanded.iter_mut().for_each(|e| *e = false);
159        self.reindex();
160    }
161
162    /// Show or hide workflow-task groups.
163    pub fn set_show_plumbing(&mut self, show: bool) {
164        self.show_plumbing = show;
165        self.reindex();
166    }
167
168    /// Which row a group's own line is on, if it is visible.
169    pub fn row_of_group(&self, group: usize) -> Option<usize> {
170        let slot = self.visible.iter().position(|g| *g == group)?;
171        Some(self.offsets[slot])
172    }
173
174    /// The next group at or after `from` whose outcome is a failure, `]f`, and what the
175    /// minimap points at. On a long history "where did it go wrong" is the whole question,
176    /// and scrolling to find out does not scale.
177    pub fn next_failure(&self, from: usize) -> Option<usize> {
178        self.visible
179            .iter()
180            .copied()
181            .filter(|g| self.groups[*g].outcome.is_failure())
182            .find(|g| self.row_of_group(*g).is_some_and(|r| r > from))
183            .and_then(|g| self.row_of_group(g))
184    }
185
186    /// The previous failing group before `from`.
187    pub fn prev_failure(&self, from: usize) -> Option<usize> {
188        self.visible
189            .iter()
190            .copied()
191            .filter(|g| self.groups[*g].outcome.is_failure())
192            .filter_map(|g| self.row_of_group(g))
193            .rfind(|r| *r < from)
194    }
195
196    /// Rebuild the visibility and offset tables. O(groups), and only on a shape change,
197    /// never while scrolling.
198    fn reindex(&mut self) {
199        self.visible.clear();
200        self.offsets.clear();
201
202        let mut total = 0usize;
203        for (i, g) in self.groups.iter().enumerate() {
204            if !self.show_plumbing && g.category.is_plumbing() {
205                continue;
206            }
207            self.visible.push(i);
208            self.offsets.push(total);
209            total += 1 + if self.expanded[i] { g.events.len() } else { 0 };
210        }
211        // The sentinel is what makes `len()` free and the binary search total.
212        self.offsets.push(total);
213    }
214
215    /// Event ids are ascending, so this is a binary search rather than a map.
216    fn event_index(&self, id: i64) -> Option<usize> {
217        self.events.binary_search_by_key(&id, |e| e.id).ok()
218    }
219}
220
221/// A one-line summary of the whole run, for the detail header.
222pub fn summarize(groups: &[Group]) -> Summary {
223    let mut s = Summary::default();
224    for g in groups {
225        match g.category {
226            Category::Activity => s.activities += 1,
227            Category::Timer => s.timers += 1,
228            Category::ChildWorkflow => s.children += 1,
229            Category::WorkflowTask
230            | Category::Workflow
231            | Category::ExternalWorkflow
232            | Category::Update
233            | Category::Nexus
234            | Category::Marker
235            | Category::SearchAttributes => {}
236        }
237        if g.outcome.is_failure() {
238            s.failures += 1;
239        }
240        // Plumbing is hidden from the outline, so counting it here would advertise a
241        // running thing the reader cannot find on screen.
242        if g.is_open() && g.category != Category::Workflow && !g.category.is_plumbing() {
243            s.in_flight += 1;
244        }
245        if g.category == Category::Workflow {
246            s.outcome = g.outcome;
247        }
248    }
249    s
250}
251
252#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
253pub struct Summary {
254    pub activities: usize,
255    pub timers: usize,
256    pub children: usize,
257    pub failures: usize,
258    /// Groups still running.
259    pub in_flight: usize,
260    /// How the workflow itself ended.
261    pub outcome: Outcome,
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::history::{GroupRef, Role, group_events};
268
269    fn ev(id: i64, group: GroupRef, role: Role, cat: Category) -> NormalizedEvent {
270        NormalizedEvent::new(id, "E", cat, group, role).with_time(Some(id * 10))
271    }
272
273    /// A workflow with one workflow task and two activities, one of which failed.
274    fn outline() -> Outline {
275        let events = vec![
276            ev(1, GroupRef::Workflow, Role::Opens, Category::Workflow).with_subject("Order"),
277            ev(2, GroupRef::Opened(2), Role::Opens, Category::WorkflowTask),
278            ev(3, GroupRef::Opened(2), Role::Closes, Category::WorkflowTask),
279            ev(4, GroupRef::Opened(4), Role::Opens, Category::Activity).with_subject("Charge"),
280            ev(5, GroupRef::Opened(4), Role::Continues, Category::Activity),
281            ev(6, GroupRef::Opened(4), Role::Closes, Category::Activity)
282                .with_outcome(Outcome::Completed),
283            ev(7, GroupRef::Opened(7), Role::Opens, Category::Activity).with_subject("Ship"),
284            ev(8, GroupRef::Opened(7), Role::Closes, Category::Activity)
285                .with_outcome(Outcome::Failed),
286        ];
287        let groups = group_events(&events);
288        Outline::new(events, groups)
289    }
290
291    #[test]
292    fn collapsed_rows_are_one_per_group_with_plumbing_hidden() {
293        let o = outline();
294        // Workflow, Charge, Ship. The workflow-task group is plumbing.
295        assert_eq!(o.len(), 3);
296        assert_eq!(
297            o.row_at(0),
298            Some(Row::Group {
299                group: 0,
300                expanded: false
301            })
302        );
303        assert_eq!(o.row_at(3), None, "past the end");
304    }
305
306    #[test]
307    fn showing_plumbing_adds_the_workflow_task_group() {
308        let mut o = outline();
309        o.set_show_plumbing(true);
310        assert_eq!(o.len(), 4);
311        assert!(o.show_plumbing());
312    }
313
314    #[test]
315    fn expanding_a_group_inserts_exactly_its_events() {
316        let mut o = outline();
317        let before = o.len();
318
319        // The "Charge" activity is group 2 (workflow, workflow-task, charge, ship).
320        let row = o.toggle(2).expect("the group is visible");
321        assert_eq!(row, 1, "its own line stays where it was");
322        assert_eq!(o.len(), before + 3, "three events joined the outline");
323
324        assert_eq!(
325            o.row_at(1),
326            Some(Row::Group {
327                group: 2,
328                expanded: true
329            })
330        );
331        // Rows 2..4 are its events, in history order.
332        for (offset, id) in [(2usize, 4i64), (3, 5), (4, 6)] {
333            let Some(Row::Event { group, event }) = o.row_at(offset) else {
334                panic!(
335                    "row {offset} should be an event, got {:?}",
336                    o.row_at(offset)
337                );
338            };
339            assert_eq!(group, 2);
340            assert_eq!(o.event(event).unwrap().id, id);
341        }
342        // The next group follows immediately after them.
343        assert!(matches!(o.row_at(5), Some(Row::Group { group: 3, .. })));
344    }
345
346    #[test]
347    fn collapsing_restores_the_previous_shape() {
348        let mut o = outline();
349        let before = o.len();
350        o.toggle(2);
351        o.toggle(2);
352        assert_eq!(o.len(), before);
353        assert!(!o.is_expanded(2));
354    }
355
356    #[test]
357    fn a_row_lookup_does_not_depend_on_reading_earlier_rows() {
358        // The virtualisation property: row_at is a binary search, so asking for a row deep
359        // in a large history costs the same as asking for the first.
360        let events: Vec<NormalizedEvent> = (1..=30_000)
361            .map(|i| {
362                let g = GroupRef::Opened(i - (i % 3));
363                let role = match i % 3 {
364                    0 => Role::Opens,
365                    1 => Role::Continues,
366                    _ => Role::Closes,
367                };
368                ev(i, g, role, Category::Activity)
369            })
370            .collect();
371        let groups = group_events(&events);
372        let mut o = Outline::new(events, groups);
373        o.expand_all();
374
375        let last = o.len() - 1;
376        assert!(o.row_at(last).is_some());
377        assert!(o.row_at(last / 2).is_some());
378        assert_eq!(o.row_at(o.len()), None);
379    }
380
381    #[test]
382    fn expand_and_collapse_all_move_together() {
383        let mut o = outline();
384        let collapsed = o.len();
385        o.expand_all();
386        assert!(o.len() > collapsed);
387        assert!(o.is_expanded(2) && o.is_expanded(3));
388        o.collapse_all();
389        assert_eq!(o.len(), collapsed);
390    }
391
392    #[test]
393    fn failures_are_reachable_without_scrolling_to_them() {
394        let o = outline();
395        // "Ship" failed and is the last row.
396        let at = o.next_failure(0).expect("there is a failure below row 0");
397        assert!(matches!(o.row_at(at), Some(Row::Group { group: 3, .. })));
398        assert_eq!(o.next_failure(at), None, "nothing after the last failure");
399        assert_eq!(o.prev_failure(at), None, "nothing before the first");
400        assert_eq!(o.prev_failure(o.len()), Some(at));
401    }
402
403    #[test]
404    fn hidden_groups_are_not_reachable_by_row() {
405        // Plumbing is filtered out, so no row can resolve to it, otherwise a cursor could
406        // land on something the screen is not showing.
407        let o = outline();
408        for r in 0..o.len() {
409            let group = match o.row_at(r).unwrap() {
410                Row::Group { group, .. } | Row::Event { group, .. } => group,
411            };
412            assert!(
413                !o.group(group).unwrap().category.is_plumbing(),
414                "row {r} resolved to a hidden group"
415            );
416        }
417    }
418
419    #[test]
420    fn a_new_page_does_not_fold_shut_what_the_reader_opened() {
421        let mut o = outline();
422        o.toggle(2);
423        assert!(o.is_expanded(2));
424        let rows_before = o.len();
425
426        // A second page arrives: the same events plus two more, re-grouped from scratch.
427        let mut events: Vec<NormalizedEvent> = o.events().to_vec();
428        events.push(ev(9, GroupRef::Opened(9), Role::Opens, Category::Timer).with_subject("wait"));
429        events.push(
430            ev(10, GroupRef::Opened(9), Role::Closes, Category::Timer)
431                .with_outcome(Outcome::Completed),
432        );
433        let groups = group_events(&events);
434        o.replace(events, groups);
435
436        assert!(o.is_expanded(2), "expansion must survive a new page");
437        assert_eq!(o.len(), rows_before + 1, "one new collapsed group");
438        assert_eq!(o.groups().len(), 5);
439    }
440
441    #[test]
442    fn replacing_with_fewer_groups_does_not_panic() {
443        // Defensive: a refresh against a different run could return a shorter history, and
444        // an expansion vector left longer than the groups would index out of bounds.
445        let mut o = outline();
446        o.expand_all();
447        o.replace(Vec::new(), Vec::new());
448        assert!(o.is_empty());
449        assert_eq!(o.row_at(0), None);
450    }
451
452    #[test]
453    fn an_empty_history_has_no_rows() {
454        let o = Outline::new(Vec::new(), Vec::new());
455        assert!(o.is_empty());
456        assert_eq!(o.row_at(0), None);
457        assert_eq!(o.slice(0, 10), Vec::new());
458        assert_eq!(o.next_failure(0), None);
459    }
460
461    #[test]
462    fn a_slice_builds_only_what_was_asked_for() {
463        let mut o = outline();
464        o.expand_all();
465        let rows = o.slice(1, 3);
466        assert_eq!(rows.len(), 3);
467        assert_eq!(rows[0], o.row_at(1).unwrap());
468        // Asking past the end returns what exists rather than panicking.
469        assert_eq!(o.slice(o.len() - 1, 50).len(), 1);
470    }
471
472    #[test]
473    fn the_summary_counts_what_the_header_shows() {
474        let s = summarize(outline().groups());
475        assert_eq!(s.activities, 2);
476        assert_eq!(s.failures, 1);
477        assert_eq!(s.timers, 0);
478        assert_eq!(
479            s.outcome,
480            Outcome::Pending,
481            "the workflow group never closed"
482        );
483    }
484
485    #[test]
486    fn the_summary_never_counts_something_the_outline_hides() {
487        // An unfinished workflow task is "running", but it is plumbing and the outline
488        // does not show it. Reporting it would send the reader hunting for a row that is
489        // not there.
490        let events = vec![
491            ev(1, GroupRef::Workflow, Role::Opens, Category::Workflow),
492            ev(2, GroupRef::Opened(2), Role::Opens, Category::WorkflowTask),
493        ];
494        let groups = group_events(&events);
495        assert_eq!(summarize(&groups).in_flight, 0);
496
497        let o = Outline::new(events, groups);
498        assert_eq!(o.len(), 1, "only the workflow group is visible");
499    }
500}