diff --git a/src/tui/log_viewer/filters.rs b/src/tui/log_viewer/filters.rs index 0b55a0f..f776067 100644 --- a/src/tui/log_viewer/filters.rs +++ b/src/tui/log_viewer/filters.rs @@ -20,20 +20,22 @@ pub struct Filters { undo_pos: usize, #[serde(skip)] - path: PathBuf, + path: Option, #[serde(skip)] error: Option, } impl Filters { - pub fn new(path: PathBuf, error: LastError) -> Self { - if path.exists() { + pub fn new(path: Option, error: LastError) -> Self { + if let Some(path) = &path + && path.exists() + { match File::open(&path) { Ok(f) => match serde_json::from_reader(f) { Ok(i) => { return Self { error: Some(error), - path, + path: Some(path.clone()), ..i }; } @@ -66,30 +68,32 @@ impl Filters { fn filters_changed(&self) { self.direct_children_cache.lock().unwrap().clear(); self.all_children_cache.lock().unwrap().clear(); - match File::options() - .create(true) - .write(true) - .truncate(true) - .open(&self.path) - { - Ok(f) => { - if let Err(e) = serde_json::to_writer(f, self) { + if let Some(path) = &self.path { + match File::options() + .create(true) + .write(true) + .truncate(true) + .open(&path) + { + Ok(f) => { + if let Err(e) = serde_json::to_writer(f, self) { + self.error.as_ref().inspect(|i| { + i.set(format!( + "failed to write contents of filter file at {}: {e}", + path.display() + )); + }); + } + } + Err(e) => { self.error.as_ref().inspect(|i| { i.set(format!( - "failed to write contents of filter file at {}: {e}", - self.path.display() + "failed to open filter file at {}: {e}", + path.display() )); }); } } - Err(e) => { - self.error.as_ref().inspect(|i| { - i.set(format!( - "failed to open filter file at {}: {e}", - self.path.display() - )); - }); - } } } diff --git a/src/tui/log_viewer/mod.rs b/src/tui/log_viewer/mod.rs index f884641..2e361fc 100644 --- a/src/tui/log_viewer/mod.rs +++ b/src/tui/log_viewer/mod.rs @@ -7,7 +7,7 @@ use crate::tui::{ input::{FieldMatcher, InputState, InputTarget}, view::LogView, }, - model::LogEntry, + model::{LogEntry, id}, processing::Cursor, widgets::last_error::LastError, }; @@ -19,7 +19,7 @@ pub mod input; pub mod view; pub struct LogViewer { - cache: HashMap, LogView>, + cache: HashMap, pub view: LogView, @@ -35,7 +35,7 @@ pub struct LogViewer { impl LogViewer { pub fn new(start: Gc, filters_path: PathBuf, error: LastError) -> Self { - let filters = Filters::new(filters_path, error); + let filters = Filters::new(Some(filters_path), error); Self { view: LogView { cursor: Cursor::new(start), @@ -200,8 +200,29 @@ impl LogViewer { } } + fn update_offset_from_cache(&mut self) { + let cache_key = self.view.cursor.parent().as_ref().map(id).unwrap_or(0); + + self.view.selection_offset = 0; + if let Some(offset) = self.cache.get(&cache_key) { + for _ in 0..*offset { + self.view.selection_offset += 1; + self.view.cursor.prev(&self.filters); + } + } + } + + fn add_to_cache(&mut self) { + let cache_key = self.view.cursor.parent().as_ref().map(id).unwrap_or(0); + self.cache.insert(cache_key, self.view.selection_offset); + } + pub fn back(&mut self) { - self.view.cursor.exit(&self.filters); + self.add_to_cache(); + if self.view.cursor.exit(&self.filters) { + self.update_offset_from_cache(); + self.view.cursor.prev(&self.filters); + } // self.cache.insert(self.path(), self.curr.clone()); self.input_state.reset(); } @@ -217,38 +238,18 @@ impl LogViewer { pub fn enter(&mut self) { match self.input_state { InputState::None => { + self.add_to_cache(); + + let orig = self.view.cursor.clone(); for _ in 0..(self.view.selection_offset + 1) { self.view.cursor.next(&self.filters); } - self.view.cursor.enter(&self.filters); - // let Some((s, _)) = self.selected() else { - // return; - // }; - // let Some(i) = s.from_start(0).map(|i| i.with_filters(self.filters.get())) else { - // return; - // }; - // - // if i.clone().next(self.filters.get()).is_none() { - // return; - // } - // if i.clone() - // .next(self.filters.get()) - // .is_some_and(|(i, _)| i.is_return()) - // { - // return; - // } - // - // self.stack.push(mem::replace( - // &mut self.curr, - // LogView { - // cursor: i, - // selection_offset: 0, - // }, - // )); - // if let Some(cached_view) = self.cache.get(&self.path()) { - // self.curr = cached_view.clone(); - // } + if self.view.cursor.enter(&self.filters) { + self.update_offset_from_cache(); + } else { + self.view.cursor = orig; + } } InputState::Target(InputTarget::Fields(None)) => { self.input_state = diff --git a/src/tui/model.rs b/src/tui/model.rs index 1313d2f..1cc40ff 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -2,8 +2,7 @@ use std::{ collections::BTreeMap, hash::{DefaultHasher, Hash, Hasher}, path::PathBuf, - sync::{Mutex, OnceLock}, - thread, + sync::OnceLock, }; use dumpster::{Trace, TraceWith, Visitor, sync::Gc}; @@ -28,7 +27,7 @@ pub fn pretty_print_value(v: &Value) -> String { } } -fn id(input: &Gc) -> usize { +pub fn id(input: &Gc) -> usize { Gc::as_ptr(input) as usize } diff --git a/src/tui/processing.rs b/src/tui/processing.rs index f9b685c..4a5a248 100644 --- a/src/tui/processing.rs +++ b/src/tui/processing.rs @@ -1,11 +1,9 @@ -use std::{mem, sync::Arc}; - -pub type FilterList = [Arc]; +use std::mem; use dumpster::sync::Gc; use itertools::Itertools; -use crate::tui::{filter::Filter, log_viewer::filters::Filters, model::LogEntry}; +use crate::tui::{log_viewer::filters::Filters, model::LogEntry}; #[derive(Clone)] pub struct CursorMeta { @@ -62,8 +60,16 @@ impl Cursor { pub fn update(&mut self, filters: &Filters) -> bool { // make a backup now let old = self.clone(); + + // if the current one is inlined + if self.curr_is_inlined(filters) { + // try going one back + self.prev(filters); + self.next(filters); + } + // if the current one is removed... - if self.curr_is_removed(filters) { + if self.curr_is_removed(filters) || self.curr_is_inlined(filters) { // try going forwards to the next visible node if self.next(filters) { return true; @@ -92,6 +98,10 @@ impl Cursor { self.curr.entry.clone() } + pub fn parent(&self) -> Option> { + self.parents.last().map(|i| i.entry.clone()) + } + fn curr_is_removed(&self, filters: &Filters) -> bool { self.curr().is_removed(filters) } @@ -205,33 +215,31 @@ impl Cursor { } pub fn prev(&mut self, filters: &Filters) -> bool { - loop { - if !self.prev_cont_in_parent(filters) { - return false; - } - - if !self.curr_is_inlined(filters) { - return true; - } - - self.enter_end_internal(); - self.curr.continue_in_parent = true; + if !self.prev_cont_in_parent(filters) { + return false; } + + if !self.curr_is_inlined(filters) { + return true; + } + + self.enter_end_internal(); + self.curr.continue_in_parent = true; + self.update(filters) } pub fn next(&mut self, filters: &Filters) -> bool { - loop { - if !self.next_cont_in_parent(filters) { - return false; - } - - if !self.curr_is_inlined(filters) { - return true; - } - - self.enter_start_internal(); - self.curr.continue_in_parent = true; + if !self.next_cont_in_parent(filters) { + return false; } + + if !self.curr_is_inlined(filters) { + return true; + } + + self.enter_start_internal(); + self.curr.continue_in_parent = true; + self.update(filters) } pub fn enter(&mut self, filters: &Filters) -> bool { @@ -249,6 +257,10 @@ impl Cursor { } pub fn exit(&mut self, filters: &Filters) -> bool { + if self.curr.continue_in_parent { + return false; + } + if !self.exit_internal() { return false; } diff --git a/src/tui/reader.rs b/src/tui/reader.rs index 0ff88a9..dfc53ef 100644 --- a/src/tui/reader.rs +++ b/src/tui/reader.rs @@ -1,15 +1,12 @@ use std::{ cell::RefCell, fs::File, - io::{self, BufRead, BufReader}, + io::{self, BufRead, BufReader, Read}, mem, path::{Path, PathBuf}, rc::Rc, - sync::{ - OnceLock, - mpsc::{Receiver, channel}, - }, - thread, + sync::OnceLock, + thread::{self, JoinHandle}, }; use dumpster::sync::Gc; @@ -17,7 +14,7 @@ use dumpster::sync::Gc; use crate::tui::model::{ChildInfo, LogEntry, RawLogEntry}; struct LogFileEntryGenerator { - file: BufReader, + file: BufReader>, } impl LogFileEntryGenerator { @@ -38,7 +35,11 @@ impl LogFileEntryGenerator { match serde_json::from_str(&line) { Ok(i) => Some(i), Err(e) => { + #[cfg(test)] + panic!("deserializing: {e:?} in {line}"); + #[cfg(not(test))] eprintln!("deserializing: {e:?} in {line}"); + #[cfg(not(test))] None } } @@ -106,7 +107,7 @@ impl LogFileEntryGenerator { struct Inner { pub path: PathBuf, - + pub jh: Option>, first: Option>, } @@ -114,32 +115,38 @@ struct Inner { pub struct LogfileReader(Rc>); impl LogfileReader { - pub fn new(p: &Path) -> io::Result { - let file = File::open(p)?; + pub fn new(p: impl AsRef) -> io::Result { + Ok(Self::new_from_read(File::open(p.as_ref())?, p)) + } + pub fn new_from_read(r: impl Read + Send + Sync + 'static, p: impl AsRef) -> Self { let mut generator = LogFileEntryGenerator { - file: BufReader::new(file), + file: BufReader::new(Box::new(r)), }; let first = generator.next_entry(None); - if let Some(mut curr_last) = first.clone() { - thread::spawn(move || { - while let Some(new) = generator.next_entry(Some(curr_last.clone())) { - assert!(new.prev().is_some()); - curr_last.initialize_next().get_or_init({ - let new = new.clone(); - || new - }); - curr_last = new; + let jh = thread::spawn({ + let first = first.clone(); + move || { + if let Some(mut curr_last) = first.clone() { + while let Some(new) = generator.next_entry(Some(curr_last.clone())) { + assert!(new.prev().is_some()); + curr_last.initialize_next().get_or_init({ + let new = new.clone(); + || new + }); + curr_last = new; + } } - }); - } + } + }); - Ok(Self(Rc::new(RefCell::new(Inner { - path: p.to_path_buf(), + Self(Rc::new(RefCell::new(Inner { + path: p.as_ref().to_path_buf(), first, - })))) + jh: Some(jh), + }))) } pub fn path(&self) -> PathBuf { @@ -150,3 +157,282 @@ impl LogfileReader { self.0.borrow().first.clone() } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::tui::{ + filter::{Filter, FilterKind, Matcher, MatcherValue}, + log_viewer::filters::Filters, + processing::Cursor, + widgets::last_error::LastError, + }; + + fn parse(data: &str) -> Cursor { + let r = LogfileReader::new_from_read( + std::io::Cursor::new(data.trim().as_bytes().to_vec()), + "test", + ); + r.0.borrow_mut().jh.take().unwrap().join().unwrap(); + Cursor::new(r.first().unwrap()) + } + + fn with_fields(fields: &str) -> String { + format!( + r#"{{"timestamp": "2026-03-30 23:47Z", "level": "DEBUG", "filename": "foo", "line_number": 30, "fields": {fields}}}"# + ) + } + + fn filters() -> Filters { + let le = LastError::new(); + Filters::new(None, le) + } + + #[test] + fn get_message() { + let c = parse(&[with_fields(r#"{"message": "foo"}"#)].join("\n")); + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + } + + #[test] + fn bounds() { + let mut c = parse(&[with_fields(r#"{"message": "foo"}"#)].join("\n")); + let f = filters(); + + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(!c.next(&f)); + assert!(!c.prev(&f)); + assert!(!c.enter(&f)); + assert!(!c.exit(&f)); + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + } + + #[test] + fn two_singles() { + let mut c = parse( + &[ + with_fields(r#"{"message": "foo"}"#), + with_fields(r#"{"message": "bar"}"#), + ] + .join("\n"), + ); + let f = filters(); + + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert!(!c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert!(c.prev(&f)); + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(!c.prev(&f)); + assert!(!c.enter(&f)); + assert!(!c.exit(&f)); + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + } + + #[test] + fn enter_exit() { + let mut c = parse( + &[ + with_fields(r#"{"message": "foo"}"#), + with_fields(r#"{"message": "enter"}"#), + with_fields(r#"{"message": "baz"}"#), + with_fields(r#"{"message": "meow"}"#), + with_fields(r#"{"message": "exit"}"#), + with_fields(r#"{"message": "bar"}"#), + ] + .join("\n"), + ); + let f = filters(); + + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(c.next(&f)); + assert!(matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { .. })); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert!(c.prev(&f)); + assert!(c.enter(&f)); + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert!(!c.enter(&f)); + assert!(!c.prev(&f)); + assert!(c.exit(&f)); + assert!(matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { .. })); + assert!(c.enter(&f)); + assert!(c.next(&f)); + assert!(!c.enter(&f)); + assert!(!c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert!(c.exit(&f)); + } + + #[test] + fn empty_enter() { + let mut c = parse( + &[ + with_fields(r#"{"message": "foo"}"#), + with_fields(r#"{"message": "enter"}"#), + with_fields(r#"{"message": "exit"}"#), + with_fields(r#"{"message": "bar"}"#), + ] + .join("\n"), + ); + let f = filters(); + + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(c.next(&f)); + assert!( + matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { children, .. } if children.first_child.is_none() ) + ); + assert!(!c.enter(&f)); + assert!(!c.exit(&f)); + assert!(matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { .. })); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + } + + #[test] + fn remove_first() { + let mut c = parse( + &[ + with_fields(r#"{"message": "foo"}"#), + with_fields(r#"{"message": "baz"}"#), + with_fields(r#"{"message": "meow"}"#), + with_fields(r#"{"message": "bar"}"#), + ] + .join("\n"), + ); + let mut f = filters(); + f.push(Arc::new(Filter { + matcher: Matcher::Field { + name: "message".to_string(), + value: MatcherValue::Exact("foo".to_string()), + }, + kind: FilterKind::Remove, + })); + + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert!(!c.prev(&f)); + } + + #[test] + fn remove_middle() { + let mut c = parse( + &[ + with_fields(r#"{"message": "foo"}"#), + with_fields(r#"{"message": "baz"}"#), + with_fields(r#"{"message": "meow"}"#), + with_fields(r#"{"message": "bar"}"#), + ] + .join("\n"), + ); + let mut f = filters(); + f.push(Arc::new(Filter { + matcher: Matcher::Field { + name: "message".to_string(), + value: MatcherValue::Exact("baz".to_string()), + }, + kind: FilterKind::Remove, + })); + + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert!(c.prev(&f)); + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(!c.prev(&f)); + } + + #[test] + fn inline() { + let mut c = parse( + &[ + with_fields(r#"{"message": "foo"}"#), + with_fields(r#"{"message": "enter", "name": "nest"}"#), + with_fields(r#"{"message": "baz"}"#), + with_fields(r#"{"message": "meow"}"#), + with_fields(r#"{"message": "exit"}"#), + with_fields(r#"{"message": "bar"}"#), + ] + .join("\n"), + ); + let mut f = filters(); + f.push(Arc::new(Filter { + matcher: Matcher::Field { + name: "name".to_string(), + value: MatcherValue::Exact("nest".to_string()), + }, + kind: FilterKind::Inline, + })); + + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert!(c.next(&f)); + assert!(!c.exit(&f)); + assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert!(c.prev(&f)); + assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert!(!c.exit(&f)); + assert!(c.prev(&f)); + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert!(c.prev(&f)); + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(!c.prev(&f)); + } + + #[test] + fn inline_while_inside() { + let mut c = parse( + &[ + with_fields(r#"{"message": "foo"}"#), + with_fields(r#"{"message": "enter", "name": "nest"}"#), + with_fields(r#"{"message": "baz"}"#), + with_fields(r#"{"message": "meow"}"#), + with_fields(r#"{"message": "exit"}"#), + with_fields(r#"{"message": "bar"}"#), + ] + .join("\n"), + ); + let mut f = filters(); + + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(c.next(&f)); + assert!(c.enter(&f)); + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + // inline the current item + f.push(Arc::new(Filter { + matcher: Matcher::Field { + name: "name".to_string(), + value: MatcherValue::Exact("nest".to_string()), + }, + kind: FilterKind::Inline, + })); + c.update_with_parents(&f); + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + println!("undo"); + f.undo(); + c.update_with_parents(&f); + assert_eq!(c.curr().message_or_name(), Some("nest".to_string())); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert!(!c.next(&f)); + assert!(c.prev(&f)); + assert!(c.enter(&f)); + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert!(c.next(&f)); + assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + f.redo(); + c.update_with_parents(&f); + // redo inlines, and goes to start of inlined part + assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert!(c.prev(&f)); + assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert!(!c.prev(&f)); + } +}