1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! What one pane owns.
//!
//! Everything here is per-pane: two windows side by side each have their own cursor, their
//! own query, their own history and their own follow task. Everything that is *not* here,
//! the mode, the keymap, the prompt, the note line, the codec cache, belongs to the session
//! and is shared, because there is one keyboard and one status line however many panes are
//! open.
//!
//! Splitting this out of `App` is what makes a window tree possible at all. Before it, the
//! application held one cursor and one history, so a second pane had nothing of its own to
//! show.
use tmprl_client::NamespaceInfo;
use tmprl_core::history::NormalizedEvent;
use tmprl_core::outline::{Outline, Row};
use tmprl_core::{Loadable, ScheduleRow, StatusCounts, WorkflowList, WorkflowRow};
use crate::app::Screen;
use crate::ui::category_label;
pub struct View {
pub screen: Screen,
pub namespaces: Loadable<Vec<NamespaceInfo>>,
pub workflows: Loadable<WorkflowList>,
pub counts: Loadable<StatusCounts>,
pub history: Loadable<Outline>,
pub schedules: Loadable<Vec<ScheduleRow>>,
/// The workflow whose history is on screen.
pub viewing: Option<WorkflowRow>,
/// Whether the history is being tailed. Shown in the statusline, because a view that
/// silently changes under you is worse than one that does not update.
pub following: bool,
/// Whether the payload pane is open under the history list.
pub show_detail: bool,
/// Output of the last `!` filter, shown in the pane in place of the payloads. `Err` is
/// the command's own stderr, which is the only useful thing to show when jq rejects a
/// filter.
pub piped: Option<Result<String, String>>,
/// First visible line of the payload pane, and how far it can usefully go. A payload can
/// be far taller than the pane, clipping a stack trace silently hides its end, which is
/// the part worth reading.
pub detail_scroll: usize,
pub detail_max_scroll: usize,
/// The visibility query, verbatim. This string is the interface: everything that
/// filters the list compiles into it, and it is always on screen and always editable.
pub query: String,
/// Namespaces the workflow list is fanned out over.
pub scope: Vec<String>,
pub cursor: usize,
/// Where a visual selection started, if one is active.
pub anchor: Option<usize>,
/// Rows this pane can show, set by the renderer, used by half-page motions. Per-pane
/// because a half page in a split is half of *that* pane.
pub page: usize,
/// The row the cursor is on, by identity rather than by index. Rows arrive above the
/// cursor on a live list, so an index silently drifts onto a different workflow.
pub cursor_key: Option<(String, String)>,
/// Cursor position on the namespace screen, restored by `-`.
pub namespace_cursor: usize,
/// Cursor position on the workflow list, restored by `-` from a history.
pub workflow_cursor: usize,
/// Bumped whenever the query or scope changes. Replies carrying an older generation
/// belong to a query the user has already moved on from.
///
/// Per-pane, and that matters: two panes fetching at once must not invalidate each
/// other's replies.
pub generation: u64,
/// Every history event loaded so far, for re-grouping when a page arrives.
pub history_events: Vec<NormalizedEvent>,
/// Continuation token for the history being read. Empty means fully loaded.
pub history_token: Vec<u8>,
/// The last *non-empty* token seen. Follow resumes from here: an empty token restarts
/// from event 1, and paging leaves the token empty once it has caught up.
pub history_resume: Vec<u8>,
/// The follow task, so toggling off (or leaving the screen) actually stops the poll.
pub follow_task: Option<tokio::task::JoinHandle<()>>,
/// A page request is in flight; scrolling must not queue a second one.
pub loading_more: bool,
}
impl View {
/// A fresh pane, scoped to one namespace.
pub fn new(namespace: &str) -> Self {
Self {
screen: Screen::Namespaces,
namespaces: Loadable::NotAsked,
workflows: Loadable::NotAsked,
counts: Loadable::NotAsked,
history: Loadable::NotAsked,
schedules: Loadable::NotAsked,
viewing: None,
following: false,
show_detail: false,
piped: None,
detail_scroll: 0,
detail_max_scroll: 0,
query: String::new(),
scope: vec![namespace.to_string()],
cursor: 0,
anchor: None,
page: 10,
cursor_key: None,
namespace_cursor: 0,
workflow_cursor: 0,
generation: 0,
history_events: Vec::new(),
history_token: Vec::new(),
history_resume: Vec::new(),
follow_task: None,
loading_more: false,
}
}
/// A new pane looking at the same place as this one.
///
/// The navigation is copied (screen, scope, query, which workflow) but none of the
/// loaded data, tasks or tokens: the new pane fetches its own. Splitting is almost
/// always "show me this again so I can take one of them somewhere else", and a split
/// that dropped you back at the namespace list would make the diff case two navigations
/// instead of one keystroke.
pub fn fork(&self) -> Self {
// Field-by-field rather than `..View::new(..)`: View has a Drop (it aborts a follow
// poll), and struct update syntax would have to move out of the base value.
let mut out = View::new(self.scope.first().map(String::as_str).unwrap_or_default());
out.screen = self.screen;
out.scope = self.scope.clone();
out.query = self.query.clone();
out.viewing = self.viewing.clone();
out.show_detail = self.show_detail;
out.namespace_cursor = self.namespace_cursor;
out.workflow_cursor = self.workflow_cursor;
out
}
/// Stop this pane's follow poll, if it has one.
///
/// A poll left running holds a request open and keeps feeding a pane that may have been
/// closed, so closing a window has to come through here.
pub fn stop_following(&mut self) {
self.following = false;
if let Some(task) = self.follow_task.take() {
task.abort();
}
}
}
impl Drop for View {
fn drop(&mut self) {
// Closing a window must not leave its long poll running against a pane that no
// longer exists.
if let Some(task) = self.follow_task.take() {
task.abort();
}
}
}
impl View {
pub fn namespace_rows(&self) -> &[NamespaceInfo] {
self.namespaces.value().map(Vec::as_slice).unwrap_or(&[])
}
pub fn workflow_rows(&self) -> &[WorkflowRow] {
self.workflows
.value()
.map(WorkflowList::rows)
.unwrap_or(&[])
}
pub fn schedule_rows(&self) -> &[ScheduleRow] {
self.schedules.value().map(Vec::as_slice).unwrap_or(&[])
}
/// The text `/` matches against, one string per row, in row order.
///
/// Built on demand rather than cached: a search is something a person types, a handful
/// of times a minute, while the rows underneath it change on every page and every
/// refresh. A cache would have to be invalidated from six places and would be wrong in
/// the one case that matters, a list still growing under a follow.
///
/// Each label is generous, wider than the columns that fit on screen: a run id is not
/// rendered but is exactly the kind of thing pasted in from a log line, and a search
/// that cannot find it would send you back to the query bar for something the pane has
/// already loaded.
pub fn search_labels(&self) -> Vec<String> {
match self.screen {
Screen::Namespaces => self
.namespace_rows()
.iter()
.map(|n| format!("{} {} {}", n.name, n.state, n.description))
.collect(),
Screen::Workflows => self
.workflow_rows()
.iter()
.map(|w| {
format!(
"{} {} {} {} {} {}",
w.workflow_id,
w.workflow_type,
w.task_queue,
w.status.query_name(),
w.namespace,
w.run_id,
)
})
.collect(),
Screen::Schedules => self
.schedule_rows()
.iter()
.map(|s| {
format!(
"{} {} {} {} {}",
s.schedule_id,
s.workflow_type,
s.spec,
s.notes,
if s.paused { "paused" } else { "running" },
)
})
.collect(),
Screen::History => self.history_labels(),
}
}
/// Labels for the history outline.
///
/// A group and an event read differently, and both are searchable, because both are
/// rows you can put the cursor on. A group carries the name you would search for, the
/// activity type; an event carries the protocol name and its fields, which is how you
/// find `ActivityTaskTimedOut` or the row whose `activityId` is the one from the alert.
fn history_labels(&self) -> Vec<String> {
let Some(outline) = self.history.value() else {
return Vec::new();
};
(0..outline.len())
.map(|row| match outline.row_at(row) {
Some(Row::Group { group, .. }) => match outline.group(group) {
Some(g) => {
let mut s = format!("{} {}", category_label(g.category), g.subject);
if let Some(f) = &g.failure {
s.push(' ');
s.push_str(f);
}
s
}
None => String::new(),
},
Some(Row::Event { event, .. }) => match outline.event(event) {
Some(e) => {
let mut s = format!("{} {}", e.id, e.name);
for (k, v) in &e.fields {
s.push_str(&format!(" {k}={v}"));
}
if let Some(f) = &e.failure {
s.push(' ');
s.push_str(f);
}
s
}
None => String::new(),
},
None => String::new(),
})
.collect()
}
/// How many rows this pane's screen has.
pub fn row_count(&self) -> usize {
match self.screen {
Screen::Namespaces => self.namespace_rows().len(),
Screen::Workflows => self.workflow_rows().len(),
Screen::History => self.history.value().map(Outline::len).unwrap_or(0),
Screen::Schedules => self.schedule_rows().len(),
}
}
/// Whether this pane is fanned out over more than one namespace, which is when rows
/// need to say which namespace they came from.
pub fn is_fanned_out(&self) -> bool {
self.scope.len() > 1
}
/// The inclusive row range selected, if a visual mode is active in this pane.
pub fn selection(&self) -> Option<(usize, usize)> {
let a = self.anchor?;
Some((a.min(self.cursor), a.max(self.cursor)))
}
pub fn is_selected(&self, i: usize) -> bool {
self.selection().is_some_and(|(lo, hi)| i >= lo && i <= hi)
}
}