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