diff --git a/Cargo.toml b/Cargo.toml index bd16f70..3911081 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ jiff = {version = "0.2", features = ["serde"]} ratatui = {version = "0.30.0", features=["unstable-rendered-line-info"]} ratatui-themes = { version = "0.2", features = ["serde"] } tui-widget-list = "0.15" -serde = {version = "1", features = ["derive"]} +serde = {version = "1", features = ["derive", "rc"]} serde_json = "1" thiserror = "2" itertools = "0.14" diff --git a/src/tui/filter.rs b/src/tui/filter.rs index af97065..e345ea8 100644 --- a/src/tui/filter.rs +++ b/src/tui/filter.rs @@ -1,13 +1,34 @@ use regex::bytes::Regex; +use serde::{Deserialize, Serialize}; use crate::tui::{ - log_viewer::{FieldMatcher, InputTarget, LogViewer}, + log_viewer::{ + LogViewer, + input::{FieldMatcher, InputTarget}, + }, model::LogEntry, }; +mod serialize_regex { + use regex::bytes::Regex; + use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error}; + + pub fn serialize(r: &Regex, serializer: S) -> Result { + r.as_str().serialize(serializer) + } + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Regex::new(&s).map_err(|e| D::Error::custom(e.to_string())) + } +} + +#[derive(Serialize, Deserialize)] pub enum MatcherValue { Exact(String), - Regex(Regex), + Regex(#[serde(with = "serialize_regex")] Regex), Prefix(String), Contains(String), } @@ -32,6 +53,7 @@ impl MatcherValue { } } +#[derive(Serialize, Deserialize)] pub enum Matcher { Field { name: String, value: MatcherValue }, Message { value: MatcherValue }, @@ -76,12 +98,13 @@ impl Matcher { } } -#[derive(Clone)] +#[derive(Clone, Serialize, Deserialize)] pub enum FilterKind { Inline, Remove, } +#[derive(Serialize, Deserialize)] pub struct Filter { pub matcher: Matcher, pub kind: FilterKind, diff --git a/src/tui/log_viewer/filters.rs b/src/tui/log_viewer/filters.rs new file mode 100644 index 0000000..587b75c --- /dev/null +++ b/src/tui/log_viewer/filters.rs @@ -0,0 +1,41 @@ +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; + +use crate::tui::filter::Filter; + +#[derive(Serialize, Deserialize)] +pub struct Filters { + filters: Vec>, + undo_pos: usize, +} + +impl Filters { + pub fn new() -> Self { + Self { + filters: Vec::new(), + undo_pos: 0, + } + } + + pub fn get(&self) -> &[Rc] { + &self.filters[0..self.undo_pos] + } + + pub fn push(&mut self, filter: Rc) { + self.filters.truncate(self.undo_pos); + self.filters.push(filter); + self.undo_pos = self.filters.len(); + } + + pub fn redo(&mut self) { + self.undo_pos += 1; + if self.undo_pos > self.filters.len() { + self.undo_pos = self.filters.len() + } + } + + pub fn undo(&mut self) { + self.undo_pos = self.undo_pos.saturating_sub(1); + } +} diff --git a/src/tui/log_viewer/input.rs b/src/tui/log_viewer/input.rs new file mode 100644 index 0000000..5b2beb1 --- /dev/null +++ b/src/tui/log_viewer/input.rs @@ -0,0 +1,109 @@ +use std::mem; + +use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget}; + +use crate::tui::widgets::styled::Styled; + +#[derive(Clone)] +pub enum FieldMatcher { + EqualTo, + Prefix(String), + Regex(String), + Contains(String), +} + +impl FieldMatcher { + pub fn show(&self) -> String { + match self { + FieldMatcher::EqualTo => "equal to selected value".to_string(), + Self::Prefix(s) => format!("with a prefix of `{s}`"), + Self::Regex(s) => format!("matching /{s}/"), + Self::Contains(s) => format!("containing `{s}`"), + } + } +} + +#[derive(Clone)] +pub enum InputTarget { + Fields(Option), + Text(FieldMatcher), + This, + Surround, +} + +impl InputTarget { + pub fn show(&self) -> String { + match self { + Self::Fields(None) => "logs with a field...".to_string(), + Self::Fields(Some(fm)) => format!("logs with the selected field {}", fm.show()), + Self::Text(fm) => format!("logs {}", fm.show()), + Self::This => format!("this log"), + Self::Surround => format!("the log surrounding the current view"), + } + } +} + +#[derive(Clone)] +pub enum InputState { + None, + Target(InputTarget), +} + +impl Widget for Styled<'_, &InputState> { + fn render(self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + Line::from(self.inner.show()) + .style(self.styles.default) + .render(area, buf) + } +} + +impl InputState { + pub fn capture_string(&mut self) -> Option<&mut String> { + match self { + InputState::None => None, + InputState::Target(InputTarget::This) => None, + InputState::Target(InputTarget::Fields(None)) => None, + InputState::Target(InputTarget::Surround) => None, + + // require arbitrary text input + InputState::Target(InputTarget::Fields(Some(FieldMatcher::Contains(s)))) => Some(s), + InputState::Target(InputTarget::Text(FieldMatcher::Contains(s))) => Some(s), + InputState::Target(InputTarget::Fields(Some(FieldMatcher::Prefix(s)))) => Some(s), + InputState::Target(InputTarget::Text(FieldMatcher::Prefix(s))) => Some(s), + InputState::Target(InputTarget::Fields(Some(FieldMatcher::Regex(s)))) => Some(s), + InputState::Target(InputTarget::Text(FieldMatcher::Regex(s))) => Some(s), + + InputState::Target(InputTarget::Fields(Some(FieldMatcher::EqualTo))) => None, + InputState::Target(InputTarget::Text(FieldMatcher::EqualTo)) => None, + } + } + + pub fn captures_input(&mut self) -> bool { + self.capture_string().is_some() + } + + pub fn show(&self) -> String { + match self { + InputState::None => "".to_string(), + InputState::Target(input_target) => input_target.show(), + } + } + + pub fn reset(&mut self) { + *self = Self::None; + } + + pub fn target(&mut self, target: InputTarget) { + if let Self::Target(t) = self + && mem::discriminant(t) == mem::discriminant(&target) + && !self.captures_input() + { + self.reset(); + } else { + *self = Self::Target(target); + } + } +} diff --git a/src/tui/log_viewer.rs b/src/tui/log_viewer/mod.rs similarity index 72% rename from src/tui/log_viewer.rs rename to src/tui/log_viewer/mod.rs index 016c144..69a0afa 100644 --- a/src/tui/log_viewer.rs +++ b/src/tui/log_viewer/mod.rs @@ -2,147 +2,24 @@ use std::{collections::HashMap, iter, mem, rc::Rc}; use crate::tui::{ filter::Filter, + log_viewer::{ + filters::Filters, + input::{FieldMatcher, InputState, InputTarget}, + view::LogView, + }, model::LogEntry, processing::{IntoLogStream, LogStream}, - widgets::styled::Styled, }; -use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget}; use tui_widget_list::ListState; -pub struct LogView { - iter: Box, - selection_offset: usize, -} - -impl LogView { - pub fn selected(&self) -> Option<(Rc, usize)> { - let mut temp_iter = self.iter.clone(); - for _ in 0..self.selection_offset { - let _ = temp_iter.next()?; - } - - temp_iter.next() - } -} - -impl Clone for LogView { - fn clone(&self) -> Self { - Self { - iter: self.iter.clone(), - selection_offset: self.selection_offset.clone(), - } - } -} - -#[derive(Clone)] -pub enum FieldMatcher { - EqualTo, - Prefix(String), - Regex(String), - Contains(String), -} - -impl FieldMatcher { - pub fn show(&self) -> String { - match self { - FieldMatcher::EqualTo => "equal to selected value".to_string(), - Self::Prefix(s) => format!("with a prefix of `{s}`"), - Self::Regex(s) => format!("matching /{s}/"), - Self::Contains(s) => format!("containing `{s}`"), - } - } -} - -#[derive(Clone)] -pub enum InputTarget { - Fields(Option), - Text(FieldMatcher), - This, - Surround, -} - -impl InputTarget { - pub fn show(&self) -> String { - match self { - Self::Fields(None) => "logs with a field...".to_string(), - Self::Fields(Some(fm)) => format!("logs with the selected field {}", fm.show()), - Self::Text(fm) => format!("logs {}", fm.show()), - Self::This => format!("this log"), - Self::Surround => format!("the log surrounding the current view"), - } - } -} - -#[derive(Clone)] -pub enum InputState { - None, - Target(InputTarget), -} - -impl Widget for Styled<'_, &InputState> { - fn render(self, area: Rect, buf: &mut Buffer) - where - Self: Sized, - { - Line::from(self.inner.show()) - .style(self.styles.default) - .render(area, buf) - } -} - -impl InputState { - pub fn capture_string(&mut self) -> Option<&mut String> { - match self { - InputState::None => None, - InputState::Target(InputTarget::This) => None, - InputState::Target(InputTarget::Fields(None)) => None, - InputState::Target(InputTarget::Surround) => None, - - // require arbitrary text input - InputState::Target(InputTarget::Fields(Some(FieldMatcher::Contains(s)))) => Some(s), - InputState::Target(InputTarget::Text(FieldMatcher::Contains(s))) => Some(s), - InputState::Target(InputTarget::Fields(Some(FieldMatcher::Prefix(s)))) => Some(s), - InputState::Target(InputTarget::Text(FieldMatcher::Prefix(s))) => Some(s), - InputState::Target(InputTarget::Fields(Some(FieldMatcher::Regex(s)))) => Some(s), - InputState::Target(InputTarget::Text(FieldMatcher::Regex(s))) => Some(s), - - InputState::Target(InputTarget::Fields(Some(FieldMatcher::EqualTo))) => None, - InputState::Target(InputTarget::Text(FieldMatcher::EqualTo)) => None, - } - } - - pub fn captures_input(&mut self) -> bool { - self.capture_string().is_some() - } - - pub fn show(&self) -> String { - match self { - InputState::None => "".to_string(), - InputState::Target(input_target) => input_target.show(), - } - } - - pub fn reset(&mut self) { - *self = Self::None; - } - - pub fn target(&mut self, target: InputTarget) { - if let Self::Target(t) = self - && mem::discriminant(t) == mem::discriminant(&target) - && !self.captures_input() - { - self.reset(); - } else { - *self = Self::Target(target); - } - } -} +pub mod filters; +pub mod input; +pub mod view; pub struct LogViewer { pub stack: Vec, curr: LogView, cache: HashMap, LogView>, - filters: Vec>, pub root_stream: Box, @@ -152,7 +29,7 @@ pub struct LogViewer { pub last_fields_height: usize, pub footer_list: ListState, - + filters: Filters, pub input_state: InputState, } @@ -173,22 +50,21 @@ impl LogViewer { last_fields_offset: 0, last_fields_height: 0, - filters: Vec::new(), + filters: Filters::new(), input_state: InputState::None, } } pub fn filtered_root_stream(&self) -> Box { let mut curr = self.root_stream.clone(); - for filter in &self.filters { + for filter in self.filters.get() { curr = Box::new(curr.filter(Rc::clone(filter))); } curr } - pub fn add_filter(&mut self, filter: Rc) { - self.filters.push(Rc::clone(&filter)); + fn update_filters(&mut self) { self.cache.clear(); let offsets_list: Vec<_> = self .stack @@ -230,9 +106,9 @@ impl LogViewer { // If the value we're looking for is removed by the filter, // we'll have a hard time finding it so quit - if filter.removes(&elem) { - break; - } + // if filter.removes(&elem) { + // break; + // } // find the nearest stream in which this element can be found let mut curr = current_stream.as_ref(); @@ -286,6 +162,11 @@ impl LogViewer { self.stack = new_stack; } + pub fn add_filter(&mut self, filter: Rc) { + self.filters.push(Rc::clone(&filter)); + self.update_filters(); + } + pub fn update_num_items(&mut self, num_visible_items: usize) { while self.curr.selection_offset >= num_visible_items { if self.curr.selection_offset == 0 { @@ -436,6 +317,16 @@ impl LogViewer { self.input_state.reset(); } + pub fn undo(&mut self) { + self.filters.undo(); + self.update_filters(); + } + + pub fn redo(&mut self) { + self.filters.redo(); + self.update_filters(); + } + pub fn enter(&mut self) { match self.input_state { InputState::None => { diff --git a/src/tui/log_viewer/view.rs b/src/tui/log_viewer/view.rs new file mode 100644 index 0000000..19ffbf3 --- /dev/null +++ b/src/tui/log_viewer/view.rs @@ -0,0 +1,28 @@ +use std::rc::Rc; + +use crate::tui::{model::LogEntry, processing::LogStream}; + +pub struct LogView { + pub iter: Box, + pub selection_offset: usize, +} + +impl LogView { + pub fn selected(&self) -> Option<(Rc, usize)> { + let mut temp_iter = self.iter.clone(); + for _ in 0..self.selection_offset { + let _ = temp_iter.next()?; + } + + temp_iter.next() + } +} + +impl Clone for LogView { + fn clone(&self) -> Self { + Self { + iter: self.iter.clone(), + selection_offset: self.selection_offset.clone(), + } + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index f963c70..d80095b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -14,15 +14,13 @@ use std::{ use tui_widget_list::{ListBuilder, ListView}; use crate::tui::{ - filter::FilterKind, - log_viewer::{InputState, InputTarget, LogViewer}, - widgets::{hyperlink::Hyperlink, items::Items, last_error::LastError}, -}; -use crate::tui::{ - filter::{Filter, Matcher}, - log_viewer::FieldMatcher, + filter::{Filter, FilterKind, Matcher}, + log_viewer::{ + LogViewer, + input::{FieldMatcher, InputState, InputTarget}, + }, reader::LogfileReader, - widgets::styled::IntoStyled, + widgets::{hyperlink::Hyperlink, items::Items, last_error::LastError, styled::IntoStyled}, }; use ratatui::{ DefaultTerminal, Terminal, @@ -67,7 +65,6 @@ targeting logs: either a field after `f` or the text of the current log: p ... with a prefix r ... matching a regex - / ... matching a regex e ... equal to selected c ... containing @@ -298,6 +295,10 @@ impl App { KeyCode::Backspace | KeyCode::Left => lv.back(), KeyCode::Right => lv.enter(), KeyCode::Enter => lv.enter(), + + KeyCode::Char('u') => lv.undo(), + KeyCode::Char('r') => lv.redo(), + KeyCode::Char('f') => { lv.input_state.target(InputTarget::Fields(None)); lv.footer_list.select(Some(0)); @@ -309,7 +310,7 @@ impl App { KeyCode::Char('t') => { lv.input_state.target(InputTarget::This); } - KeyCode::Char('r') | KeyCode::Char('/') => { + KeyCode::Char('/') => { let v = FieldMatcher::Regex(String::new()); if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state { *f = Some(v); diff --git a/src/tui/widgets/items.rs b/src/tui/widgets/items.rs index 214626f..b6ae252 100644 --- a/src/tui/widgets/items.rs +++ b/src/tui/widgets/items.rs @@ -5,7 +5,7 @@ use ratatui::widgets::{List, ListItem, Widget}; use regex::Regex; use crate::tui::{ - log_viewer::{FieldMatcher, InputState, InputTarget}, + log_viewer::input::{FieldMatcher, InputState, InputTarget}, model::LogEntry, widgets::{ last_error::LastError,