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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
//! The sheep pane's own keymap, and the feed it hands to the full screen.
use super::super::*;
impl App {
/// `b`, from inside the sheep pane: hands the embedded feed's own state
/// to `Body::Bleats` rather than [`BleatsPane::new`]ing a fresh one, so
/// a filter narrowed in the pane's own column survives going full
/// screen. A no-op on any other screen; `on_sheep_pane_key` only reaches
/// this while [`Self::sheep_pane`] is `Some`, but the match on `body`
/// stays defensive rather than assuming that.
pub(in crate::lookout::app) fn promote_feed_to_full_screen(&mut self) -> Effect {
if let Body::Sheep(pane) = &self.body {
self.body = Body::Bleats(pane.feed().clone());
}
// The embedded feed never clamps its own offset: it draws no
// scrollback, so `N` can walk the stored value past anything the full
// screen can scroll back to. Clamping on arrival is what
// `scroll_bleats_back` already documents.
let ceiling = self.bleats_pane().map_or(0, |pane| {
crate::lookout::view::bleats_full::max_scroll_offset(self, pane)
});
if let Some(pane) = self.bleats_pane_mut() {
pane.clamp_scroll(ceiling);
}
Effect::None
}
/// The sheep pane's own keymap, in force while [`Self::sheep_pane`] is
/// `Some`. `esc` closes it. `e` opens the config editor over it.
/// `J`/`K` step to the next or previous sheep. `x`/`R`/`L` arm a confirm
/// against the pane's pinned sheep, and `↵` confirms it while any other
/// key cancels, through the same [`Self::on_armed_key`] prelude `on_key`
/// runs. `j`/`k` and `g`/`G` scroll the config column through its own
/// `Viewport`. Every other key is inert.
pub(in crate::lookout::app) fn on_sheep_pane_key(&mut self, key: KeyPress) -> Effect {
if let Some(effect) = self.on_armed_key(key) {
return effect;
}
self.notice = None;
// `o`, `m` and `/`: claimed ahead of this keymap by the same
// dispatcher the full-screen pane runs, so the two screens cannot
// answer one of them differently.
if let Some(effect) = self.on_feed_key(key, Self::sheep_feed_mut) {
return effect;
}
match key {
KeyPress::Quit => Effect::Quit,
KeyPress::Escape => {
self.close_pane();
Effect::None
}
KeyPress::Edit => self.ask_for_sheep_pane_config(),
KeyPress::StepDown => self.step_sheep_pane(1),
KeyPress::StepUp => self.step_sheep_pane(-1),
KeyPress::Action(verb) => self.arm_sheep_pane(verb),
KeyPress::SelectUp => {
if let Some(pane) = self.sheep_pane_mut() {
pane.move_by(-1);
}
Effect::None
}
KeyPress::SelectDown => {
if let Some(pane) = self.sheep_pane_mut() {
pane.move_by(1);
}
Effect::None
}
KeyPress::SelectFirst => {
if let Some(pane) = self.sheep_pane_mut() {
pane.move_to_first();
}
Effect::None
}
KeyPress::SelectLast => {
if let Some(pane) = self.sheep_pane_mut() {
pane.move_to_last();
}
Effect::None
}
// `b`: hands the embedded feed's own state to `Body::Bleats`
// rather than rebuilding one, so a filter narrowed here survives
// going full screen.
KeyPress::Bleats => self.promote_feed_to_full_screen(),
// Named here only to keep this keymap exhaustive: the guard above
// already answered all three. See the same arm in
// [`Self::on_bleats_key`].
KeyPress::FilterStart | KeyPress::StreamCycle | KeyPress::LevelCycle => Effect::None,
// `f`: toggles following explicitly.
KeyPress::FollowToggle => {
if let Some(feed) = self.sheep_feed_mut() {
feed.toggle_follow();
}
Effect::None
}
// `w`: toggles whether a long line wraps or truncates.
KeyPress::WrapToggle => {
if let Some(feed) = self.sheep_feed_mut() {
feed.toggle_wrap();
}
Effect::None
}
// `n`: one match toward the newest line. A no-op with no match
// axis set: see `BleatsPane::match_next`.
KeyPress::MatchNext => {
if let Some(feed) = self.sheep_feed_mut() {
feed.match_next();
}
Effect::None
}
// `N`: the same, toward the oldest matching line. Unlike
// `on_bleats_key`'s own `MatchPrev` arm, this does not clamp: the
// embedded feed draws no scrollback of its own, and `window_range`
// saturates a stale offset rather than reading past the end.
KeyPress::MatchPrev => {
let stepping = self
.sheep_pane()
.is_some_and(|pane| pane.feed().filters().match_text().is_some());
if stepping && let Some(feed) = self.sheep_feed_mut() {
feed.scroll_up(1);
}
Effect::None
}
KeyPress::Help => self.open_keymap(),
KeyPress::Refresh
| KeyPress::Confirm
| KeyPress::TextChar(_)
| KeyPress::TextBackspace
| KeyPress::TextEdit(_)
| KeyPress::TextApply
| KeyPress::TextAbandon
| KeyPress::Settings
| KeyPress::Catalogue
| KeyPress::Cycle
| KeyPress::Remove
| KeyPress::FoldView
| KeyPress::Collapse
| KeyPress::PageDown
| KeyPress::PageUp
// The secrets pane's own six. `S` opens that pane from the
// dashboard, not from here, and the other five mean nothing
// outside it.
| KeyPress::Secrets
| KeyPress::Reveal
| KeyPress::Copy
| KeyPress::TabPrev
| KeyPress::TabNext
| KeyPress::ArmDelete => Effect::None,
// The groups and the filed edit set belong to the config pane;
// this read-only list has neither to switch or take back.
| KeyPress::NextGroup
| KeyPress::PrevGroup
| KeyPress::NextField
| KeyPress::PrevField
| KeyPress::Group(_)
| KeyPress::Undo
| KeyPress::Continue => Effect::None,
}
}
}
#[cfg(test)]
mod tests {
use super::super::tests::{apply_match, fixture_with_feed};
use super::*;
use shep_core::config::LevelRule;
/// The embedded feed's surviving lines, filtered the way its own
/// `Filters` would narrow them, for a test to inspect without reaching
/// into `view::sheep::draw` for a rendered row.
fn feed_rows(app: &App) -> Vec<String> {
let Body::Sheep(pane) = app.body() else {
panic!("the sheep pane is not open")
};
pane.feed()
.visible(&app.feed().lines, &app.feed_classifier())
.into_iter()
.map(|line| line.text.clone())
.collect()
}
/// The pane's own filters, not a second set. The header advertises them,
/// so they have to work here and not only in the full-screen pane.
#[test]
fn a_filter_applied_in_the_sheep_pane_narrows_its_feed() {
let mut app = fixture_with_feed();
let _ = app.update(Msg::Key(KeyPress::Confirm));
apply_match(&mut app, "boom");
assert!(feed_rows(&app).iter().all(|row| row.contains("boom")));
}
/// An app that declares `rules`, with `lines` already read off its log,
/// and its sheep pane open. The rules arrive on the listing row, which
/// is the only way a real one ever gets them.
fn fixture_with_declared_levels(rules: Vec<LevelRule>, lines: &[&str]) -> App {
let t0 = Instant::now();
let mut app = App::new(
Palette::detect(None, None, None),
Control::ReadOnly,
"/home/ada/.shep".to_string(),
t0,
);
app.update(Msg::Snapshot {
rows: vec![
ProcessInfo::builder(1, "alpha", ProcStatus::Online)
.pid(Some(1001))
.uptime_ms(60_000)
.level_rules(rules)
.build(),
],
at: t0,
});
app.update(Msg::Bleats {
tail: crate::lookout::tail::Tail {
lines: lines
.iter()
.map(|text| crate::lookout::tail::TailLine::new(Stream::Out, text))
.collect(),
missed_lines: 0,
missed_bytes: 0,
read_bytes: 0,
note: None,
},
});
let _ = app.update(Msg::Key(KeyPress::Confirm));
app
}
/// Presses `m` until the feed's minimum sits at `wanted`, the way an
/// operator reaches one, and fails rather than looping if the cycle
/// stops passing through it.
fn cycle_min_level_to(app: &mut App, wanted: Level) {
for _ in 0..6 {
let _ = app.update(Msg::Key(KeyPress::LevelCycle));
if let Body::Sheep(pane) = app.body()
&& pane.feed().filters().min_level == Some(wanted)
{
return;
}
}
panic!("the level cycle never reached {wanted}");
}
/// The whole point of the field: the built-in reading classifies neither
/// of these lines, so under it both survive any minimum. The rules
/// classify both, and the minimum then tells them apart.
#[test]
fn a_declared_rule_classifies_a_line_the_built_in_reading_misses() {
let mut app = fixture_with_declared_levels(
vec![
LevelRule {
pattern: r#""severity":"ERROR""#.to_string(),
level: Level::Error,
},
LevelRule {
pattern: r#""severity":"DEBUG""#.to_string(),
level: Level::Debug,
},
],
&[
r#"{"severity":"ERROR","msg":"boom"}"#,
r#"{"severity":"DEBUG","msg":"tick"}"#,
],
);
cycle_min_level_to(&mut app, Level::Warn);
let rows = feed_rows(&app);
assert_eq!(rows.len(), 1, "{rows:?}");
assert!(rows[0].contains("boom"), "{rows:?}");
}
/// Declared rules replace the built-in reading rather than adding to it.
/// `WARN pool exhausted` is a line that reading classifies and no rule
/// here matches, so it stays unclassified and survives a floor above it.
/// Under a fallback it would read as `Warn` and be filtered out.
#[test]
fn a_declared_rule_set_replaces_the_built_in_reading() {
let mut app = fixture_with_declared_levels(
vec![LevelRule {
pattern: "^E/".to_string(),
level: Level::Error,
}],
&["WARN pool exhausted", "E/tag boom"],
);
cycle_min_level_to(&mut app, Level::Error);
assert_eq!(feed_rows(&app).len(), 2);
}
/// `b` hands the same pane the whole screen, carrying its filters.
#[test]
fn b_promotes_the_feed_to_full_screen_with_its_filters() {
let mut app = fixture_with_feed();
let _ = app.update(Msg::Key(KeyPress::Confirm));
apply_match(&mut app, "boom");
let _ = app.update(Msg::Key(KeyPress::Bleats));
let Body::Bleats(pane) = app.body() else {
panic!("full screen")
};
assert_eq!(pane.match_filter(), Some("boom"));
}
/// Promotion clamps the offset the embedded feed never clamps itself.
///
/// `N` walks the stored value up one line at a time and the embedded
/// feed draws no scrollback, so nothing bounds it there. Carried across
/// unclamped, the full screen renders the oldest survivor while the
/// stored value sits past it, and `j` does nothing visible until it has
/// been pressed back down through the excess.
#[test]
fn promotion_clamps_an_offset_the_embedded_feed_left_out_of_range() {
let mut app = fixture_with_feed();
let _ = app.update(Msg::Key(KeyPress::Confirm));
if let Some(feed) = app.sheep_feed_mut() {
feed.scroll_up(9_999);
}
let _ = app.update(Msg::Key(KeyPress::Bleats));
let Body::Bleats(pane) = app.body() else {
panic!("full screen")
};
let ceiling = crate::lookout::view::bleats_full::max_scroll_offset(&app, pane);
assert!(
pane.scroll_offset() <= ceiling,
"offset {} should have been clamped to {ceiling}",
pane.scroll_offset()
);
}
}