Skip to main content

binocular/preview/structured_log/
reducer.rs

1use 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}