diff --git a/Cargo.lock b/Cargo.lock index 32c3a66..77c0b36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -821,7 +821,7 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "logparse" -version = "0.1.1" +version = "0.1.2" dependencies = [ "insta", "proptest", diff --git a/src/tui/filter.rs b/src/tui/filter.rs index 8f31845..7762efa 100644 --- a/src/tui/filter.rs +++ b/src/tui/filter.rs @@ -6,7 +6,7 @@ use crate::tui::{ LogViewer, input::{FieldMatcher, InputTarget}, }, - model::LogEntry, + model::{FieldsName, LogEntry}, }; mod serialize_regex { @@ -55,19 +55,26 @@ impl MatcherValue { #[derive(Serialize, Deserialize, Debug)] pub enum Matcher { - Field { name: String, value: MatcherValue }, - Message { value: MatcherValue }, - Specific { hash: u64 }, + Field { + span: FieldsName, + name: String, + value: MatcherValue, + }, + Message { + value: MatcherValue, + }, + Specific { + hash: u64, + }, } impl Matcher { pub fn matches(&self, entry: &LogEntry) -> bool { match self { Matcher::Specific { hash } => entry.hash() == *hash, - Matcher::Field { name, value } => entry - .all_fields() - .fields - .get(name) + Matcher::Field { span, name, value } => entry + .spans() + .find(span, name) .is_some_and(|v| value.matches(v)), Matcher::Message { value } => { entry.message_or_name().is_some_and(|v| value.matches(&v)) @@ -78,16 +85,20 @@ impl Matcher { pub fn from_input(target: InputTarget, lv: &LogViewer) -> Option { match target { InputTarget::Fields(fm) => { - let value = lv.footer_fields().get(lv.footer_list.selected?)?.clone(); + let (span, name, value) = lv.get_selected_field()?; Some(Self::Field { - name: value.0, - value: MatcherValue::from_field_matcher(fm?, Some(value.1))?, + span, + name, + value: MatcherValue::from_field_matcher(fm?, Some(value))?, }) } InputTarget::Text(fm) => Some(Self::Message { value: MatcherValue::from_field_matcher( fm, - lv.selected().and_then(|(i, _)| i.message_or_name()), + lv.selected() + .as_ref() + .and_then(|(i, _)| i.message_or_name()) + .map(|i| i.to_string()), )?, }), InputTarget::This => { diff --git a/src/tui/log_viewer/mod.rs b/src/tui/log_viewer/mod.rs index fe17e52..5427692 100644 --- a/src/tui/log_viewer/mod.rs +++ b/src/tui/log_viewer/mod.rs @@ -7,12 +7,11 @@ use crate::tui::{ input::{FieldMatcher, InputState, InputTarget}, view::LogView, }, - model::{LogEntry, id}, + model::{FieldsName, LogEntry, id}, processing::Cursor, - widgets::last_error::LastError, + widgets::{fieldtree, last_error::LastError}, }; use dumpster::sync::Gc; -use tui_widget_list::ListState; pub mod filters; pub mod input; @@ -28,9 +27,10 @@ pub struct LogViewer { pub last_fields_offset: usize, pub last_fields_height: usize, - pub footer_list: ListState, pub filters: Filters, pub input_state: InputState, + + pub field_state: fieldtree::State, } impl LogViewer { @@ -42,7 +42,6 @@ impl LogViewer { selection_offset: 0, }, cache: HashMap::new(), - footer_list: ListState::default(), last_height: 0, last_offset: 0, @@ -51,9 +50,20 @@ impl LogViewer { filters, input_state: InputState::None, + field_state: fieldtree::State::default(), } } + pub fn get_selected_field(&self) -> Option<(FieldsName, String, String)> { + let (span_idx, name_idx) = self.field_state.get()?; + let entry = self.selected().map(|(s, _)| s)?; + + let (span, fields) = entry.spans().named().nth(span_idx)?; + let (name, value) = fields.fields.iter().nth(name_idx)?; + + Some((span, name.clone(), value.clone())) + } + pub fn add_filter(&mut self, filter: Arc) { self.filters.push(Arc::clone(&filter)); if !self.view.cursor.update_with_parents(&self.filters) { @@ -72,27 +82,27 @@ impl LogViewer { self.last_height = num_visible_items; } - pub fn footer_fields(&self) -> Vec<(String, String)> { - if let Some((selected, _)) = self.selected() { - let ret = match selected.as_ref() { - LogEntry::Single { .. } => Default::default(), - LogEntry::Sub { children, .. } => children.last_child.as_ref().and_then(|i| { - i.all_fields() - .get_key_value("return") - .map(|(k, v)| (k.clone(), v.clone())) - }), - }; - - selected - .all_relevant_fields() - .fields - .into_iter() - .chain(ret) - .collect::>() - } else { - Vec::new() - } - } + // pub fn footer_fields(&self) -> Vec<(String, String)> { + // if let Some((selected, _)) = self.selected() { + // let ret = match selected.as_ref() { + // LogEntry::Single { .. } => Default::default(), + // LogEntry::Sub { children, .. } => children.last_child.as_ref().and_then(|i| { + // i.all_fields() + // .get_key_value("return") + // .map(|(k, v)| (k.clone(), v.clone())) + // }), + // }; + // + // selected + // .all_relevant_fields() + // .fields + // .into_iter() + // .chain(ret) + // .collect::>() + // } else { + // Vec::new() + // } + // } pub fn items(&mut self, max: usize) -> Option<(Vec<(Gc, usize)>, usize)> { let mut temp_iter = self.view.cursor.clone(); @@ -131,7 +141,8 @@ impl LogViewer { if row_in_fields < self.last_fields_height { self.input_state = InputState::Target(InputTarget::Fields(Some(FieldMatcher::EqualTo))); - self.footer_list.select(Some(row_in_fields)); + todo!() + // self.footer_list.select(Some(row_in_fields)); } } } @@ -143,7 +154,7 @@ impl LogViewer { pub fn prev(&mut self) { match self.input_state { InputState::Target(InputTarget::Fields(..)) => { - self.footer_list.previous(); + self.field_state.up(); self.input_state = InputState::Target(InputTarget::Fields(None)); } _ => { @@ -160,7 +171,7 @@ impl LogViewer { pub fn next(&mut self) { match self.input_state { InputState::Target(InputTarget::Fields(..)) => { - self.footer_list.next(); + self.field_state.down(); self.input_state = InputState::Target(InputTarget::Fields(None)); } _ => { @@ -189,7 +200,7 @@ impl LogViewer { pub fn home(&mut self) { match self.input_state { InputState::Target(InputTarget::Fields(..)) => { - self.footer_list.select(Some(0)); + self.field_state.home(); self.input_state = InputState::Target(InputTarget::Fields(None)); } _ => { @@ -218,13 +229,21 @@ impl LogViewer { } pub fn back(&mut self) { - self.add_to_cache(); - if self.view.cursor.exit(&self.filters) { - self.update_offset_from_cache(); - self.view.cursor.prev(&self.filters); + match self.input_state { + InputState::None => { + 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(); + } + InputState::Target(InputTarget::Fields(None)) => { + self.field_state.left(); + } + _ => {} } - // self.cache.insert(self.path(), self.curr.clone()); - self.input_state.reset(); } pub fn undo(&mut self) { @@ -235,6 +254,18 @@ impl LogViewer { self.filters.redo(); } + pub fn right(&mut self) { + match self.input_state { + InputState::None => { + self.enter(); + } + InputState::Target(InputTarget::Fields(None)) => { + self.field_state.right(); + } + _ => {} + } + } + 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 index 18df8a7..dcab8c7 100644 --- a/src/tui/log_viewer/view.rs +++ b/src/tui/log_viewer/view.rs @@ -16,8 +16,7 @@ impl LogView { } } - // TODO: inline depth - Some((temp_iter.curr(), 0)) + Some((temp_iter.curr(), self.cursor.inline_depth())) } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 17e48e9..f0e8779 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -7,6 +7,7 @@ use crossterm::{ }; use ratatui_themes::{Color, Theme, ThemeName}; use std::{ + borrow::Cow, fs::{self, DirEntry}, io::{self, Stdout}, ops::ControlFlow, @@ -15,7 +16,6 @@ use std::{ sync::Arc, time::Duration, }; -use tui_widget_list::{ListBuilder, ListView}; use crate::tui::{ filter::{Filter, FilterKind, Matcher}, @@ -24,7 +24,10 @@ use crate::tui::{ input::{FieldMatcher, InputState, InputTarget}, }, reader::LogfileReader, - widgets::{hyperlink::Hyperlink, items::Items, last_error::LastError, styled::IntoStyled}, + widgets::{ + fieldtree::FieldTree, hyperlink::Hyperlink, items::Items, last_error::LastError, + styled::IntoStyled, + }, }; use ratatui::{ DefaultTerminal, Terminal, @@ -33,6 +36,7 @@ use ratatui::{ layout::{Constraint, HorizontalAlignment, Layout, Rect}, prelude::CrosstermBackend, style::Style, + symbols::merge::MergeStrategy, text::Line, widgets::{ Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap, @@ -59,12 +63,16 @@ const HELP_TEXT: &str = "Generic: <- / backspace / h exit nested view -> / enter / l enter nested view + f toggle show active filters + ─────────────────────────────────────────────────────── targeting logs: - f fields - t the selected log - s surrounding element + tab switch to fields display + (to target values of fields) + + t the currently selected log + the surrounding element (when inside a span) either a field after `f` or the text of the current log: p ... with a prefix @@ -314,15 +322,14 @@ impl App { KeyCode::Char('G') | KeyCode::Home => lv.home(), KeyCode::Char('g') | KeyCode::End => todo!(), KeyCode::Backspace | KeyCode::Left => lv.back(), - KeyCode::Right => lv.enter(), + KeyCode::Right => lv.right(), KeyCode::Enter => lv.enter(), KeyCode::Char('u') => lv.undo(), KeyCode::Char('r') => lv.redo(), - KeyCode::Char('f') => { + KeyCode::Tab => { lv.input_state.target(InputTarget::Fields(None)); - lv.footer_list.select(Some(0)); } KeyCode::Esc => lv.input_state.reset(), KeyCode::Char('s') if !lv.view.cursor.toplevel() => { @@ -515,20 +522,35 @@ impl App { } pub fn block_around(&self, area: Rect, buf: &mut Buffer, selected: bool) -> Rect { - let styles = self.styles(); - let block = Block::bordered() - .style(styles.default) - .border_style(if selected { - styles.border_highlighted - } else { - styles.border - }); - let inner = block.inner(area); - block.render(area, buf); - inner + block_around(area, buf, selected, &self.styles(), None::<&'static str>) } } +pub fn block_around( + area: Rect, + buf: &mut Buffer, + selected: bool, + styles: &Styles, + title: Option>>, +) -> Rect { + let mut block = Block::bordered() + .style(styles.default) + .border_style(if selected { + styles.border_highlighted + } else { + styles.border + }) + .merge_borders(MergeStrategy::Fuzzy); + + if let Some(title) = title { + block = block.title_top(title.into()); + } + + let inner = block.inner(area); + block.render(area, buf); + inner +} + pub struct Styles { default: Style, highlighted: Style, @@ -580,7 +602,6 @@ impl Widget for &mut App { }; let main_area = self.block_around(main_area, buf, header_focused); - let footer_area = self.block_around(footer_area, buf, footer_focused); let [left, middle, right] = Layout::horizontal([ Constraint::Ratio(1, 3), @@ -669,39 +690,39 @@ impl Widget for &mut App { &lv.filters, selected_offset, &lv.input_state, - lv.footer_list.selected.and_then(|idx| { - lv.footer_fields() - .get(idx) - .map(|(a, b)| (a.clone(), b.clone())) - }), + lv.get_selected_field(), self.last_error.clone(), ) .styled_ref(&styles) .render(main_area, buf); - let items = lv.footer_fields(); - lv.last_fields_offset = footer_area.y as usize; - lv.last_fields_height = items.len(); + FieldTree::new(lv, footer_focused) + .styled_mut(&styles) + .render(footer_area, buf); - let width = 20; - let builder = ListBuilder::new(|cx| { - let Some((k, v)) = &items.get(cx.index) else { - return (Paragraph::new(""), 1); - }; - - let mut res = - Paragraph::new(format!("{k:width$} {v}")).wrap(Wrap { trim: false }); - - if cx.is_selected { - res = res.style(styles.highlighted); - } - - let height = res.line_count(footer_area.width) as u16; - (res, height) - }); - - let list = ListView::new(builder, items.len()).style(styles.default); - StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list); + // let items = lv.footer_fields(); + // lv.last_fields_offset = footer_area.y as usize; + // lv.last_fields_height = items.len(); + // + // let width = 20; + // let builder = ListBuilder::new(|cx| { + // let Some((k, v)) = &items.get(cx.index) else { + // return (Paragraph::new(""), 1); + // }; + // + // let mut res = + // Paragraph::new(format!("{k:width$} {v}")).wrap(Wrap { trim: false }); + // + // if cx.is_selected { + // res = res.style(styles.highlighted); + // } + // + // let height = res.line_count(footer_area.width) as u16; + // (res, height) + // }); + // + // let list = ListView::new(builder, items.len()).style(styles.default); + // StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list); } Tab::Empty => {} Tab::Help => { diff --git a/src/tui/model.rs b/src/tui/model.rs index 6c9d7a9..575d238 100644 --- a/src/tui/model.rs +++ b/src/tui/model.rs @@ -1,13 +1,14 @@ use std::{ collections::BTreeMap, hash::{DefaultHasher, Hash, Hasher}, + iter, path::PathBuf, sync::OnceLock, }; use dumpster::{Trace, TraceWith, Visitor, sync::Gc}; use jiff::Timestamp; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::tui::{ @@ -141,7 +142,7 @@ impl LogEntry { } pub fn is_return(&self) -> bool { - self.all_fields().get("return").is_some() + self.spans().main.get("return").is_some() } pub fn hash(&self) -> u64 { @@ -158,20 +159,27 @@ impl LogEntry { } } - pub fn all_fields(&self) -> LogFields { + pub fn spans(&self) -> SpansRef<'_> { match self { - LogEntry::Single { entry, .. } => entry.all_fields(), - LogEntry::Sub { enter, exit, .. } => enter.all_fields().merge(&exit.all_fields()), + LogEntry::Sub { + enter, + exit: _, + children: _, + } => SpansRef { + main: enter.span.as_ref().unwrap_or(&enter.fields), + spans: &enter.spans, + }, + LogEntry::Single { + entry, + prev: _, + next: _, + } => SpansRef { + main: &entry.fields, + spans: &entry.spans, + }, } } - pub fn all_relevant_fields(&self) -> LogFields { - let mut res = self.all_fields(); - res.fields - .retain(|k, v| !(k == "message" && (v == "enter" || v == "exit"))); - res - } - pub fn all_children(&self, filters: &Filters) -> usize { match self { Self::Single { .. } => 0, @@ -220,7 +228,7 @@ impl LogEntry { .direct_children_cache .lock() .unwrap() - .get(&id(&first_child)) + .get(&id(first_child)) .copied() }; if let Some(cached) = cached { @@ -236,7 +244,7 @@ impl LogEntry { .direct_children_cache .lock() .unwrap() - .insert(id(&first_child), count); + .insert(id(first_child), count); count } @@ -247,17 +255,9 @@ impl LogEntry { } } - pub fn message_or_name(&self) -> Option { - match self { - LogEntry::Single { entry, .. } => entry.fields.message().map(|i| i.to_string()), - LogEntry::Sub { enter, .. } => { - if let Some(val) = enter.all_fields().fields.get("name") { - Some(val.clone()) - } else { - enter.fields.message().map(|i| i.to_string()) - } - } - } + pub fn message_or_name(&self) -> Option<&str> { + let spans = self.spans(); + spans.main.message_or_name() } pub fn has_only_return(&self) -> bool { @@ -274,13 +274,13 @@ impl LogEntry { const NO_MESSAGE: &str = ""; const SPACES_BEFORE: &str = " "; - let single_field = |raw: &RawLogEntry| { - raw.fields + let single_field = |fields: &LogFields| { + fields .message() .map(|i| i.to_string()) - .or_else(|| raw.fields.fields.get("return").map(|v| format!("↩ {v}"))) + .or_else(|| fields.fields.get("return").map(|v| format!("↩ {v}"))) .or_else(|| { - raw.fields + fields .fields .iter() .next() @@ -292,12 +292,13 @@ impl LogEntry { match self { LogEntry::Single { entry, .. } => LineText::new( SPACES_BEFORE.to_string(), - single_field(entry), - self.message_or_name(), + single_field(&entry.fields), + self.message_or_name().map(|i| i.to_string()), tree, ), LogEntry::Sub { enter, .. } => { - if let Some(val) = enter.all_fields().fields.get("name") { + let spans = self.spans(); + if let Some(val) = spans.main.get("name") { let (prefix, sym) = if self.has_only_return() { (SPACES_BEFORE.to_string(), "⟲") } else if !self.can_enter(filters) { @@ -313,12 +314,17 @@ impl LogEntry { ) }; - LineText::new(prefix, format!("{sym} {val}"), self.message_or_name(), tree) + LineText::new( + prefix, + format!("{sym} {val}"), + self.message_or_name().map(|i| i.to_string()), + tree, + ) } else { LineText::new( SPACES_BEFORE.to_string(), - single_field(enter), - self.message_or_name(), + single_field(enter.span.as_ref().unwrap_or(&enter.fields)), + self.message_or_name().map(|i| i.to_string()), tree, ) } @@ -357,23 +363,16 @@ impl LogFields { self.fields.get("message").map(|i| i.as_str()) } - pub fn merge(&self, other: &Self) -> Self { - Self { - fields: self - .fields - .iter() - .chain(other.fields.iter()) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - } + pub fn name(&self) -> Option<&str> { + self.fields.get("name").map(|i| i.as_str()) } pub fn get(&self, key: impl AsRef) -> Option<&String> { self.fields.get(key.as_ref()) } - pub fn get_key_value(&self, key: impl AsRef) -> Option<(&String, &String)> { - self.fields.get_key_value(key.as_ref()) + pub fn message_or_name(&self) -> Option<&str> { + self.message().or(self.name()) } } #[derive(Deserialize, Debug, Hash)] @@ -384,6 +383,8 @@ pub struct RawLogEntry { pub line_number: usize, pub fields: LogFields, #[serde(default)] + pub span: Option, + #[serde(default)] pub spans: Vec, } @@ -394,12 +395,37 @@ unsafe impl TraceWith for RawLogEntry { } } -impl RawLogEntry { - pub fn all_fields(&self) -> LogFields { - let mut res = self.fields.clone(); - for i in &self.spans { - res = res.merge(i); - } - res +#[derive(PartialEq, Eq, Debug, Hash, Clone, PartialOrd, Ord, Serialize, Deserialize)] +pub enum FieldsName { + /// The main fields (not of a span) of a log entry + Main, + Span(String), + Numbered(usize), +} + +pub struct SpansRef<'a> { + main: &'a LogFields, + spans: &'a [LogFields], +} + +impl<'a> SpansRef<'a> { + pub fn find(&self, span: &FieldsName, name: &str) -> Option<&String> { + self.named() + .find(|(name, _)| name == span) + .and_then(|(_, fields)| fields.get(name)) + } + + pub fn named(&self) -> impl Iterator { + iter::once((FieldsName::Main, self.main)).chain(self.spans.iter().rev().enumerate().map( + |(idx, fields)| { + ( + fields + .name() + .map(|i| FieldsName::Span(i.to_string())) + .unwrap_or_else(|| FieldsName::Numbered(idx)), + fields, + ) + }, + )) } } diff --git a/src/tui/reader.rs b/src/tui/reader.rs index 86ae548..401ce32 100644 --- a/src/tui/reader.rs +++ b/src/tui/reader.rs @@ -167,6 +167,7 @@ mod tests { use crate::tui::{ filter::{Filter, FilterKind, Matcher, MatcherValue}, log_viewer::filters::Filters, + model::FieldsName, processing::Cursor, widgets::last_error::LastError, }; @@ -194,7 +195,7 @@ mod tests { #[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())); + assert_eq!(c.curr().message_or_name(), Some("foo")); } #[test] @@ -202,12 +203,12 @@ mod tests { 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_eq!(c.curr().message_or_name(), Some("foo")); 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())); + assert_eq!(c.curr().message_or_name(), Some("foo")); } #[test] @@ -221,17 +222,17 @@ mod tests { ); let f = filters(); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert_eq!(c.curr().message_or_name(), Some("bar")); assert!(!c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert_eq!(c.curr().message_or_name(), Some("bar")); assert!(c.prev(&f)); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(!c.prev(&f)); assert!(!c.enter(&f)); assert!(!c.exit(&f)); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); } #[test] @@ -249,14 +250,14 @@ mod tests { ); let f = filters(); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); 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_eq!(c.curr().message_or_name(), Some("bar")); assert!(c.prev(&f)); assert!(c.enter(&f)); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); assert!(!c.enter(&f)); assert!(!c.prev(&f)); assert!(c.exit(&f)); @@ -265,7 +266,7 @@ mod tests { assert!(c.next(&f)); assert!(!c.enter(&f)); assert!(!c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert_eq!(c.curr().message_or_name(), Some("meow")); assert!(c.exit(&f)); } @@ -282,7 +283,7 @@ mod tests { ); let f = filters(); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(c.next(&f)); assert!( matches!(Gc::as_ref(&c.curr()), LogEntry::Sub { children, .. } if children.first_child.is_none() ) @@ -291,7 +292,7 @@ mod tests { 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())); + assert_eq!(c.curr().message_or_name(), Some("bar")); } #[test] @@ -310,12 +311,13 @@ mod tests { matcher: Matcher::Field { name: "message".to_string(), value: MatcherValue::Exact("foo".to_string()), + span: FieldsName::Main, }, kind: FilterKind::Remove, })); assert!(c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); assert!(!c.prev(&f)); } @@ -335,15 +337,16 @@ mod tests { matcher: Matcher::Field { name: "message".to_string(), value: MatcherValue::Exact("baz".to_string()), + span: FieldsName::Main, }, kind: FilterKind::Remove, })); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert_eq!(c.curr().message_or_name(), Some("meow")); assert!(c.prev(&f)); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(!c.prev(&f)); } @@ -365,25 +368,26 @@ mod tests { matcher: Matcher::Field { name: "name".to_string(), value: MatcherValue::Exact("nest".to_string()), + span: FieldsName::Main, }, kind: FilterKind::Inline, })); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); assert!(c.next(&f)); assert!(!c.exit(&f)); - assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert_eq!(c.curr().message_or_name(), Some("meow")); assert!(c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert_eq!(c.curr().message_or_name(), Some("bar")); assert!(c.prev(&f)); - assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert_eq!(c.curr().message_or_name(), Some("meow")); assert!(!c.exit(&f)); assert!(c.prev(&f)); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); assert!(c.prev(&f)); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(!c.prev(&f)); } @@ -402,38 +406,39 @@ mod tests { ); let mut f = filters(); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(c.next(&f)); assert!(c.enter(&f)); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); // inline the current item f.push(Arc::new(Filter { matcher: Matcher::Field { name: "name".to_string(), value: MatcherValue::Exact("nest".to_string()), + span: FieldsName::Main, }, kind: FilterKind::Inline, })); c.update_with_parents(&f); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); println!("undo"); f.undo(); c.update_with_parents(&f); - assert_eq!(c.curr().message_or_name(), Some("nest".to_string())); + assert_eq!(c.curr().message_or_name(), Some("nest")); assert!(c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("bar".to_string())); + assert_eq!(c.curr().message_or_name(), Some("bar")); assert!(!c.next(&f)); assert!(c.prev(&f)); assert!(c.enter(&f)); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); assert!(c.next(&f)); - assert_eq!(c.curr().message_or_name(), Some("meow".to_string())); + assert_eq!(c.curr().message_or_name(), Some("meow")); 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_eq!(c.curr().message_or_name(), Some("baz")); assert!(c.prev(&f)); - assert_eq!(c.curr().message_or_name(), Some("foo".to_string())); + assert_eq!(c.curr().message_or_name(), Some("foo")); assert!(!c.prev(&f)); } @@ -452,7 +457,7 @@ mod tests { let mut f = filters(); c.enter(&f); c.enter(&f); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); c.exit(&f); c.exit(&f); @@ -460,6 +465,7 @@ mod tests { matcher: Matcher::Field { name: "name".to_string(), value: MatcherValue::Exact("nest1".to_string()), + span: FieldsName::Main, }, kind: FilterKind::Inline, })); @@ -467,11 +473,12 @@ mod tests { matcher: Matcher::Field { name: "name".to_string(), value: MatcherValue::Exact("nest2".to_string()), + span: FieldsName::Main, }, kind: FilterKind::Inline, })); c.update_with_parents(&f); - assert_eq!(c.curr().message_or_name(), Some("baz".to_string())); + assert_eq!(c.curr().message_or_name(), Some("baz")); } } diff --git a/src/tui/widgets/fieldtree.rs b/src/tui/widgets/fieldtree.rs new file mode 100644 index 0000000..e841ccb --- /dev/null +++ b/src/tui/widgets/fieldtree.rs @@ -0,0 +1,298 @@ +use std::borrow::Cow; + +use dumpster::sync::Gc; +use itertools::Itertools; +use logparse::{self as lp, Config, SpanKind, into_spans, parse_input}; +use ratatui::{ + buffer::Buffer, + layout::{Constraint, Layout, Rect, Spacing}, + text::{Line, Span}, + widgets::{Paragraph, StatefulWidget, Widget, Wrap}, +}; +use ratatui_themes::Style; +use tui_widget_list::{ListBuilder, ListState, ListView}; + +use crate::tui::{ + block_around, + log_viewer::LogViewer, + model::{FieldsName, LogEntry, LogFields}, + widgets::{line_text::style_span, styled::Styled}, +}; + +#[derive(Default)] +pub struct State { + spans_focussed: bool, + + current_span: ListState, + current_field: ListState, +} + +impl State { + pub fn home(&mut self) { + if self.spans_focussed { + self.current_span.select(Some(0)); + } else { + self.current_field.select(Some(0)); + } + } + + pub fn down(&mut self) { + if self.spans_focussed { + self.current_span.next(); + } else { + self.current_field.next(); + } + } + pub fn up(&mut self) { + if self.spans_focussed { + self.current_span.previous(); + } else { + self.current_field.previous(); + } + } + pub fn right(&mut self) { + self.spans_focussed = false; + } + pub fn left(&mut self) { + self.spans_focussed = true; + } + + pub fn get(&self) -> Option<(usize, usize)> { + self.current_span.selected.zip(self.current_field.selected) + } +} + +pub struct FieldTree<'a> { + focussed: bool, + lv: &'a mut LogViewer, +} + +impl<'a> FieldTree<'a> { + pub fn new(lv: &'a mut LogViewer, focussed: bool) -> Self { + if lv.field_state.current_span.selected.is_none() { + lv.field_state.current_span.select(Some(0)); + } + if lv.field_state.current_field.selected.is_none() { + lv.field_state.current_field.select(Some(0)); + } + + Self { focussed, lv } + } + + fn state(&self) -> &State { + &self.lv.field_state + } + + fn state_mut(&mut self) -> &mut State { + &mut self.lv.field_state + } + + fn current_entry(&self) -> Option> { + self.lv.selected().map(|(s, _)| s) + } + + fn current_span_fields(&mut self, spans: &[(FieldsName, &LogFields)]) -> Vec<(String, String)> { + let mut selected = self.state().current_span.selected.unwrap_or(0); + if selected > spans.len() { + selected = spans.len().saturating_sub(1); + self.state_mut().current_span.select(Some(selected)); + } + + spans + .get(selected) + .map(|(_, fields)| { + fields + .fields + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect_vec() + }) + .unwrap_or_default() + } + + fn spans_focussed(&self) -> bool { + self.focussed && self.state().spans_focussed + } + + fn fields_focussed(&self) -> bool { + self.focussed && !self.state().spans_focussed + } +} + +impl Styled<'_, &mut FieldTree<'_>> { + fn areas(&self, area: Rect, buf: &mut Buffer, name: Option<&FieldsName>) -> (Rect, Rect, Rect) { + let [spans_area, fields_area] = Layout::horizontal([ + Constraint::Length(if self.spans_focussed() || !self.focussed { + 25 + } else { + 12 + }), + Constraint::Fill(1), + ]) + .spacing(Spacing::Overlap(1)) + .areas(area); + + let spans_area = block_around( + spans_area, + buf, + self.spans_focussed(), + self.styles, + Some("spans"), + ); + let fields_area = block_around( + fields_area, + buf, + self.fields_focussed(), + self.styles, + Some(if let Some(i) = name { + match i { + FieldsName::Main => "own fields".to_string(), + FieldsName::Span(s) => format!("fields of {s}"), + FieldsName::Numbered(idx) => format!("fields of span #{idx}"), + } + } else { + "fields".to_string() + }), + ); + + let [field_names, field_values] = + Layout::horizontal([Constraint::Length(15), Constraint::Fill(1)]).areas(fields_area); + + (spans_area, field_names, field_values) + } +} + +impl Widget for Styled<'_, &mut FieldTree<'_>> { + fn render(mut self, area: Rect, buf: &mut Buffer) + where + Self: Sized, + { + let self_focussed = self.focussed; + + let entry = self.current_entry(); + let spans = entry + .as_ref() + .map(|i| i.spans().named().collect_vec()) + .unwrap_or_default(); + + let current_span_name = self + .state() + .current_span + .selected + .and_then(|i| spans.get(i)) + .map(|(i, _)| i); + let (spans_area, field_names_area, field_values_area) = + self.areas(area, buf, current_span_name); + + let spans_list = { + let builder = ListBuilder::new(|cx| { + let Some((span_name, _)) = &spans.get(cx.index) else { + return (Line::from(""), 1); + }; + + let mut item = Line::from(match span_name { + FieldsName::Main => "own fields".to_string(), + FieldsName::Span(s) => s.to_string(), + FieldsName::Numbered(idx) => format!("span #{idx}"), + }); + if cx.is_selected && self_focussed { + item = item.style(self.styles.highlighted); + } else { + item = item.style(self.styles.default); + } + (item, 1) + }); + + ListView::new(builder, spans.len()) + }; + + let (values_list, names_list) = { + let values_width = field_values_area.width; + let styles = self.styles; + + let current_span_fields = self.current_span_fields(&spans); + + let paragraph = move |field_value: &str, base_style: Style| { + // TODO: cache this parse + let spans = parse_input(field_value) + .map(|i| { + into_spans( + i, + Config { + collapse_space: true, + }, + ) + }) + .unwrap_or_else(|_| { + vec![lp::Span { + text: Cow::Borrowed(field_value), + kind: SpanKind::Text, + }] + }); + + let spans = spans + .into_iter() + .map(|lp::Span { text, kind }| { + let span = Span::from(text.into_owned()); + + let style = style_span(kind, base_style, styles); + span.style(style) + }) + .collect_vec(); + + Paragraph::new(Line::from(spans)).wrap(Wrap { trim: true }) + }; + + let num_fields = current_span_fields.len(); + + let values_builder = ListBuilder::new({ + let current_span_fields = current_span_fields.clone(); + move |cx| { + let Some((_, field_value)) = ¤t_span_fields.get(cx.index) else { + return (Paragraph::new(Span::from("")), 1); + }; + + let base_style = if cx.is_selected && self_focussed { + self.styles.highlighted + } else { + self.styles.default + }; + + let item = paragraph(field_value, base_style); + assert_eq!(cx.cross_axis_size, values_width); + let height = item.line_count(values_width); + + (item, height as u16) + } + }); + + let names_builder = ListBuilder::new({ + move |cx| { + let Some((name, value)) = ¤t_span_fields.get(cx.index) else { + return (Line::from(""), 1); + }; + + let item = paragraph(value, self.styles.default); + let height = item.line_count(values_width); + + let mut item = Line::from(name.clone()); + if cx.is_selected && self_focussed { + item = item.style(self.styles.highlighted); + } else { + item = item.style(self.styles.default); + } + (item, height as u16) + } + }); + + ( + ListView::new(values_builder, num_fields), + ListView::new(names_builder, num_fields), + ) + }; + + spans_list.render(spans_area, buf, &mut self.state_mut().current_span); + values_list.render(field_values_area, buf, &mut self.state_mut().current_field); + names_list.render(field_names_area, buf, &mut self.state_mut().current_field); + } +} diff --git a/src/tui/widgets/items.rs b/src/tui/widgets/items.rs index 1313040..8fc568c 100644 --- a/src/tui/widgets/items.rs +++ b/src/tui/widgets/items.rs @@ -9,7 +9,7 @@ use crate::tui::{ filters::Filters, input::{FieldMatcher, InputState, InputTarget}, }, - model::LogEntry, + model::{FieldsName, LogEntry}, widgets::{ last_error::LastError, line_text::Highlighted, @@ -23,7 +23,7 @@ pub struct Items<'a> { input_state: &'a InputState, filters: &'a Filters, - selected_footer_field: Option<(String, String)>, + selected_footer_field: Option<(FieldsName, String, String)>, last_error: LastError, } @@ -33,7 +33,7 @@ impl<'a> Items<'a> { filters: &'a Filters, selected_offset: usize, input_state: &'a InputState, - selected_footer_field: Option<(String, String)>, + selected_footer_field: Option<(FieldsName, String, String)>, last_error: LastError, ) -> Self { Self { @@ -95,6 +95,7 @@ impl Widget for Styled<'_, &Items<'_>> { FieldMatcher::EqualTo => { if self .selected() + .as_ref() .and_then(|i| i.message_or_name()) .is_some_and(|m| &m == msg) { @@ -142,8 +143,8 @@ impl Widget for Styled<'_, &Items<'_>> { } } else if let InputState::Target(InputTarget::Fields(Some(f))) = self.input_state - && let Some((name, value)) = &self.selected_footer_field - && let Some(current_log_value) = entry.all_fields().get(&name) + && let Some((span, name, value)) = &self.selected_footer_field + && let Some(current_log_value) = entry.spans().find(span, name) { let matches = match f { FieldMatcher::EqualTo => value == current_log_value, diff --git a/src/tui/widgets/line_text.rs b/src/tui/widgets/line_text.rs index ee8bd02..4913bcb 100644 --- a/src/tui/widgets/line_text.rs +++ b/src/tui/widgets/line_text.rs @@ -1,8 +1,9 @@ use std::borrow::Cow; use ratatui::text::{Line, Span, Text}; +use ratatui_themes::Style; -use crate::tui::widgets::styled::Styled; +use crate::tui::{Styles, widgets::styled::Styled}; use logparse::{self as lp, Config, SpanKind, into_spans, parse_input}; #[derive(Debug)] @@ -159,6 +160,21 @@ fn highlight_spans<'a>( }) } +pub fn style_span(kind: SpanKind, style: Style, styles: &Styles) -> Style { + match kind { + SpanKind::Delimiter(_) => style.fg(styles.delimiter).bold(), + SpanKind::Separator => style.fg(styles.faded), + SpanKind::Number => style.fg(styles.literal), + SpanKind::Literal => style.fg(styles.literal).dim(), + SpanKind::String => style.fg(styles.string), + SpanKind::Path => style.fg(styles.literal).underlined(), + SpanKind::Space(_) => style, + SpanKind::Constructor => style.fg(styles.literal), + SpanKind::StringSurroundings => style.fg(styles.faded), + SpanKind::Text => style, + } +} + impl Into> for Styled<'_, LineText> { fn into(self) -> Line<'static> { let mut spans = Vec::new(); @@ -210,19 +226,7 @@ impl Into> for Styled<'_, LineText> { self.styles.default }; - let style = match kind { - SpanKind::Delimiter(_) => style.fg(self.styles.delimiter).bold(), - SpanKind::Separator => style.fg(self.styles.faded), - SpanKind::Number => style.fg(self.styles.literal), - SpanKind::Literal => style.fg(self.styles.literal).dim(), - SpanKind::String => style.fg(self.styles.string), - SpanKind::Path => style.fg(self.styles.literal).underlined(), - SpanKind::Space(_) => style, - SpanKind::Constructor => style.fg(self.styles.literal), - SpanKind::StringSurroundings => style.fg(self.styles.faded), - SpanKind::Text => style, - }; - + let style = style_span(kind, style, self.styles); span.style(style) }, ) diff --git a/src/tui/widgets/mod.rs b/src/tui/widgets/mod.rs index bb56e95..8158c4b 100644 --- a/src/tui/widgets/mod.rs +++ b/src/tui/widgets/mod.rs @@ -1,3 +1,4 @@ +pub mod fieldtree; pub mod hyperlink; pub mod items; pub mod last_error; diff --git a/src/tui/widgets/styled.rs b/src/tui/widgets/styled.rs index 99371a3..80c54db 100644 --- a/src/tui/widgets/styled.rs +++ b/src/tui/widgets/styled.rs @@ -8,6 +8,7 @@ pub struct Styled<'a, T> { pub trait IntoStyled<'a>: Sized { fn styled_ref(&self, styles: &'a Styles) -> Styled<'a, &Self>; + fn styled_mut(&mut self, styles: &'a Styles) -> Styled<'a, &mut Self>; fn styled(self, styles: &'a Styles) -> Styled<'a, Self>; } impl<'a, T> IntoStyled<'a> for T { @@ -17,6 +18,12 @@ impl<'a, T> IntoStyled<'a> for T { inner: self, } } + fn styled_mut(&mut self, styles: &'a Styles) -> Styled<'a, &mut Self> { + Styled { + styles, + inner: self, + } + } fn styled(self, styles: &'a Styles) -> Styled<'a, Self> { Styled { styles,