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)
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}