binocular/preview/structured_log/
reducer.rs1use super::actions::{
2 ColumnAction, CursorAction, FilterAction, LogViewerAction, LogViewerOutcome, ModalAction,
3};
4use crate::preview::structured_log::{
5 format_entry_visible, init_visible_cols, LogEntry, StructuredLog,
6};
7use crate::preview::types::LogPreview;
8use crate::preview::PreviewContent;
9pub(crate) fn preview_content(log: StructuredLog) -> PreviewContent {
10 let visible_cols = init_visible_cols(&log.all_fields, &log.entries);
11 let cached_matches = (0..log.entries.len()).rev().collect();
12 let filter_state = crate::preview::structured_log::LogFilterState {
13 cached_matches,
14 visible_cols,
15 ..Default::default()
16 };
17
18 PreviewContent::StructuredLog(LogPreview { log, filter_state })
19}
20
21pub(crate) fn append_entries(lp: &mut LogPreview, entries: Vec<LogEntry>, max_entries: usize) {
22 if lp.filter_state.paused {
23 return;
24 }
25
26 let from = lp.log.entries.len();
27
28 let mut new_fields: std::collections::HashSet<String> =
29 std::collections::HashSet::with_capacity(32);
30
31 for entry in entries {
32 lp.log.total_lines += 1;
33 if lp.log.entries.len() >= max_entries {
34 continue;
35 }
36
37 for (field, _) in &entry.fields {
38 if !lp.log.all_fields.iter().any(|f| f == field) && new_fields.insert(field.clone()) {
39 lp.filter_state.add_new_visible_col(field);
40 }
41 }
42
43 lp.log.entries.push(entry);
44 }
45
46 for field in &new_fields {
47 lp.log.all_fields.push(field.clone());
48 }
49
50 lp.filter_state.extend_matches(&lp.log, from);
51 if !lp.filter_state.input_active && lp.filter_state.cursor == 0 {
52 lp.filter_state.scroll = 0;
53 }
54}
55
56pub(crate) fn apply_action(
57 lp: &mut LogPreview,
58 action: LogViewerAction,
59 standalone_log_mode: bool,
60) -> LogViewerOutcome {
61 match action {
62 LogViewerAction::Filter(action) => apply_filter_action(lp, action),
63 LogViewerAction::Cursor(action) => apply_cursor_action(lp, action),
64 LogViewerAction::Column(action) => apply_column_action(lp, action),
65 LogViewerAction::Modal(action) => apply_modal_action(lp, action),
66 LogViewerAction::TogglePause => lp.filter_state.paused = !lp.filter_state.paused,
67 LogViewerAction::ToggleMark => lp.filter_state.toggle_mark(),
68 LogViewerAction::Copy { raw } => copy_entries(lp, raw),
69 LogViewerAction::ResetView => reset_filter_and_columns(lp),
70 LogViewerAction::Exit => {
71 return if standalone_log_mode {
72 LogViewerOutcome::ExitApp
73 } else {
74 LogViewerOutcome::FocusSearch
75 };
76 }
77 }
78
79 LogViewerOutcome::None
80}
81
82fn reset_filter_and_columns(lp: &mut LogPreview) {
83 lp.filter_state.input.clear();
84 lp.filter_state.filters.clear();
85 lp.filter_state.cursor = 0;
86 lp.filter_state.scroll = 0;
87 lp.filter_state.recompute_matches(&lp.log);
88 lp.filter_state.visible_cols = init_visible_cols(&lp.log.all_fields, &lp.log.entries);
89 lp.filter_state.selected_col = 0;
90 lp.filter_state.col_scroll = 0;
91}
92
93fn apply_filter_action(lp: &mut LogPreview, action: FilterAction) {
94 match action {
95 FilterAction::StartEditing => lp.filter_state.input_active = true,
96 FilterAction::StopEditing => lp.filter_state.input_active = false,
97 FilterAction::Backspace => {
98 lp.filter_state.input.pop();
99 lp.filter_state.apply_input(&lp.log);
100 }
101 FilterAction::Insert(ch) => {
102 lp.filter_state.input.push(ch);
103 lp.filter_state.apply_input(&lp.log);
104 }
105 }
106}
107
108fn apply_cursor_action(lp: &mut LogPreview, action: CursorAction) {
109 match action {
110 CursorAction::ToNewest => {
111 lp.filter_state.cursor = 0;
112 lp.filter_state.scroll = 0;
113 }
114 CursorAction::ToOldest => lp.filter_state.scroll_to_bottom(),
115 CursorAction::Down(count) => lp.filter_state.scroll_down(count),
116 CursorAction::Up(count) => lp.filter_state.scroll_up(count),
117 }
118}
119
120fn apply_column_action(lp: &mut LogPreview, action: ColumnAction) {
121 match action {
122 ColumnAction::MoveLeft => lp.filter_state.move_col_left(),
123 ColumnAction::MoveRight => lp.filter_state.move_col_right(),
124 ColumnAction::HideSelected => lp.filter_state.hide_selected_col(),
125 ColumnAction::IsolateSelected => lp.filter_state.isolate_selected_col(),
126 ColumnAction::OpenPicker => {
127 let fields = lp.log.all_fields.clone();
128 lp.filter_state.open_col_modal(&fields);
129 }
130 ColumnAction::Resize(delta) => lp.filter_state.resize_selected_col(delta),
131 }
132}
133
134fn apply_modal_action(lp: &mut LogPreview, action: ModalAction) {
135 let Some(modal) = &mut lp.filter_state.col_modal else {
136 return;
137 };
138 let field_count = modal.checked.len();
139
140 match action {
141 ModalAction::Close => {
142 lp.filter_state.col_modal = None;
143 }
144 ModalAction::Apply => {
145 let all_fields = lp.log.all_fields.clone();
146 lp.filter_state.apply_modal_changes(&all_fields);
147 }
148 ModalAction::Down => {
149 if field_count > 0 {
150 let modal = lp.filter_state.col_modal.as_mut().expect("modal exists");
151 modal.cursor = (modal.cursor + 1).min(field_count - 1);
152 }
153 }
154 ModalAction::Up => {
155 if let Some(modal) = &mut lp.filter_state.col_modal {
156 modal.cursor = modal.cursor.saturating_sub(1);
157 }
158 }
159 ModalAction::Toggle { advance } => {
160 if let Some(modal) = &mut lp.filter_state.col_modal {
161 if let Some(checked) = modal.checked.get_mut(modal.cursor) {
162 *checked = !*checked;
163 }
164 if advance && modal.cursor + 1 < field_count {
165 modal.cursor += 1;
166 }
167 }
168 }
169 }
170}
171
172fn copy_entries(lp: &mut LogPreview, raw: bool) {
173 let filter_state = &lp.filter_state;
174 let entries = &lp.log.entries;
175
176 let text: String = if !filter_state.marked.is_empty() {
177 let lines: Vec<String> = filter_state
178 .cached_matches
179 .iter()
180 .filter(|&&index| filter_state.marked.contains(&index))
181 .map(|&index| {
182 if raw {
183 entries[index].raw.clone()
184 } else {
185 format_entry_visible(&entries[index], &filter_state.visible_cols)
186 }
187 })
188 .collect();
189 lines.join("\n")
190 } else {
191 let cursor = filter_state
192 .cursor
193 .min(filter_state.cached_matches.len().saturating_sub(1));
194 match filter_state.cached_matches.get(cursor) {
195 Some(&index) => {
196 if raw {
197 entries[index].raw.clone()
198 } else {
199 format_entry_visible(&entries[index], &filter_state.visible_cols)
200 }
201 }
202 None => return,
203 }
204 };
205
206 if text.is_empty() {
207 return;
208 }
209 if let Ok(mut cb) = arboard::Clipboard::new() {
210 let _ = cb.set_text(text);
211 }
212 lp.filter_state.clear_marks();
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::preview::structured_log::actions::LogViewerAction;
219 use crate::preview::structured_log::{LogEntry, LogFormat, StructuredLog};
220 use std::time::{Duration, Instant};
221
222 fn entry(fields: &[(&str, &str)], raw: &str) -> LogEntry {
223 LogEntry {
224 fields: fields
225 .iter()
226 .map(|(key, value)| (key.to_string(), value.to_string()))
227 .collect(),
228 raw: raw.to_string(),
229 }
230 }
231
232 #[test]
233 fn preview_content_initializes_reverse_match_order() {
234 let log = StructuredLog {
235 entries: vec![
236 entry(&[("level", "info")], "level=info"),
237 entry(&[("level", "warn")], "level=warn"),
238 ],
239 total_lines: 2,
240 all_fields: vec!["level".to_string()],
241 format: LogFormat::Logfmt,
242 };
243
244 let PreviewContent::StructuredLog(preview) = preview_content(log) else {
245 panic!("expected structured log preview");
246 };
247
248 assert_eq!(preview.filter_state.cached_matches, vec![1, 0]);
249 assert_eq!(preview.filter_state.visible_cols.len(), 1);
250 assert_eq!(preview.filter_state.visible_cols[0].field, "level");
251 }
252
253 #[test]
254 fn append_entries_tracks_total_lines_even_after_capacity() {
255 let log = StructuredLog {
256 entries: vec![entry(&[("level", "info")], "level=info")],
257 total_lines: 1,
258 all_fields: vec!["level".to_string()],
259 format: LogFormat::Logfmt,
260 };
261 let PreviewContent::StructuredLog(mut preview) = preview_content(log) else {
262 panic!("expected structured log preview");
263 };
264
265 append_entries(
266 &mut preview,
267 vec![
268 entry(&[("msg", "first")], "msg=first"),
269 entry(&[("msg", "second")], "msg=second"),
270 ],
271 2,
272 );
273
274 assert_eq!(preview.log.total_lines, 3);
275 assert_eq!(preview.log.entries.len(), 2);
276 assert_eq!(preview.filter_state.cached_matches, vec![1, 0]);
277 assert!(preview.log.all_fields.iter().any(|field| field == "msg"));
278 }
279
280 #[test]
281 fn filter_actions_recompute_matches() {
282 let PreviewContent::StructuredLog(mut preview) = preview_content(StructuredLog {
283 entries: vec![
284 entry(&[("level", "info")], "level=info"),
285 entry(&[("level", "warn")], "level=warn"),
286 ],
287 total_lines: 2,
288 all_fields: vec!["level".to_string()],
289 format: LogFormat::Logfmt,
290 }) else {
291 panic!("expected structured log preview");
292 };
293
294 apply_action(
295 &mut preview,
296 LogViewerAction::Filter(FilterAction::StartEditing),
297 false,
298 );
299 apply_action(
300 &mut preview,
301 LogViewerAction::Filter(FilterAction::Insert('w')),
302 false,
303 );
304
305 assert_eq!(preview.filter_state.cached_matches, vec![1]);
306 }
307
308 #[test]
309 fn modal_actions_toggle_and_apply() {
310 let PreviewContent::StructuredLog(mut preview) = preview_content(StructuredLog {
311 entries: vec![entry(
312 &[("level", "info"), ("msg", "hello")],
313 "level=info msg=hello",
314 )],
315 total_lines: 1,
316 all_fields: vec!["level".to_string(), "msg".to_string()],
317 format: LogFormat::Logfmt,
318 }) else {
319 panic!("expected structured log preview");
320 };
321 preview
322 .filter_state
323 .open_col_modal(&preview.log.all_fields.clone());
324
325 let outcome = apply_action(
326 &mut preview,
327 LogViewerAction::Modal(ModalAction::Toggle { advance: true }),
328 false,
329 );
330 assert!(matches!(outcome, LogViewerOutcome::None));
331 assert_eq!(
332 preview
333 .filter_state
334 .col_modal
335 .as_ref()
336 .map(|modal| modal.cursor),
337 Some(1)
338 );
339
340 apply_action(
341 &mut preview,
342 LogViewerAction::Modal(ModalAction::Apply),
343 false,
344 );
345 assert!(preview.filter_state.col_modal.is_none());
346 }
347
348 #[test]
349 #[ignore = "performance smoke test"]
350 fn large_log_filtering_smoke_test() {
351 let entries = (0..20_000)
352 .map(|i| {
353 entry(
354 &[
355 ("level", if i % 2 == 0 { "info" } else { "warn" }),
356 ("msg", &format!("message-{i}")),
357 ],
358 "raw",
359 )
360 })
361 .collect();
362
363 let PreviewContent::StructuredLog(mut preview) = preview_content(StructuredLog {
364 entries,
365 total_lines: 20_000,
366 all_fields: vec!["level".to_string(), "msg".to_string()],
367 format: LogFormat::Logfmt,
368 }) else {
369 panic!("expected structured log preview");
370 };
371
372 let started = Instant::now();
373 apply_action(
374 &mut preview,
375 LogViewerAction::Filter(FilterAction::StartEditing),
376 false,
377 );
378 for ch in "warn".chars() {
379 apply_action(
380 &mut preview,
381 LogViewerAction::Filter(FilterAction::Insert(ch)),
382 false,
383 );
384 }
385
386 assert_eq!(preview.filter_state.cached_matches.len(), 10_000);
387 assert!(started.elapsed() < Duration::from_secs(5));
388 }
389
390 #[test]
391 #[ignore = "performance smoke test"]
392 fn frequent_append_smoke_test() {
393 let PreviewContent::StructuredLog(mut preview) = preview_content(StructuredLog {
394 entries: vec![],
395 total_lines: 0,
396 all_fields: vec!["level".to_string(), "msg".to_string()],
397 format: LogFormat::Logfmt,
398 }) else {
399 panic!("expected structured log preview");
400 };
401
402 let started = Instant::now();
403 for batch in 0..200 {
404 let entries = (0..100)
405 .map(|i| {
406 let idx = batch * 100 + i;
407 entry(
408 &[("level", "info"), ("msg", &format!("message-{idx}"))],
409 "raw",
410 )
411 })
412 .collect();
413 append_entries(&mut preview, entries, 50_000);
414 }
415
416 assert_eq!(preview.log.total_lines, 20_000);
417 assert_eq!(preview.log.entries.len(), 20_000);
418 assert!(started.elapsed() < Duration::from_secs(5));
419 }
420}