modal
This commit is contained in:
parent
d989f6e31e
commit
cc4ecf40d7
6 changed files with 402 additions and 331 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1196,6 +1196,7 @@ dependencies = [
|
||||||
"nix 0.31.1",
|
"nix 0.31.1",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"ratatui-themes",
|
"ratatui-themes",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,4 @@ serde_json = "1"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
itertools = "0.14"
|
itertools = "0.14"
|
||||||
nix = {version = "0.31", features = ["process", "signal"]}
|
nix = {version = "0.31", features = ["process", "signal"]}
|
||||||
|
regex = "1"
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,77 @@
|
||||||
use crate::tui::model::LogEntry;
|
use regex::bytes::Regex;
|
||||||
|
|
||||||
#[derive(Clone)]
|
use crate::tui::{
|
||||||
pub enum WipMatcher {
|
log_viewer::{FieldMatcher, InputTarget, LogViewer},
|
||||||
Field {
|
model::{LogEntry, pretty_print_value},
|
||||||
name: Option<String>,
|
};
|
||||||
value: Option<serde_json::Value>,
|
|
||||||
},
|
pub enum MatcherValue {
|
||||||
Specific {
|
Exact(String),
|
||||||
hash: Option<u64>,
|
Regex(Regex),
|
||||||
},
|
Prefix(String),
|
||||||
|
Contains(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WipMatcher {
|
impl MatcherValue {
|
||||||
fn validate(&self) -> Option<Matcher> {
|
pub fn from_field_matcher(fm: FieldMatcher, selected: Option<String>) -> Option<Self> {
|
||||||
|
match fm {
|
||||||
|
FieldMatcher::EqualTo => Some(Self::Exact(selected?)),
|
||||||
|
FieldMatcher::Prefix(p) => Some(Self::Prefix(p)),
|
||||||
|
FieldMatcher::Regex(r) => Some(Self::Regex(Regex::new(&r).ok()?)),
|
||||||
|
FieldMatcher::Contains(c) => Some(Self::Contains(c)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches(&self, v: &str) -> bool {
|
||||||
match self {
|
match self {
|
||||||
WipMatcher::Field {
|
MatcherValue::Exact(e) => e == v,
|
||||||
name: Some(name),
|
MatcherValue::Regex(regex) => regex.is_match(v.as_bytes()),
|
||||||
value: Some(value),
|
MatcherValue::Prefix(p) => v.starts_with(p),
|
||||||
} => Some(Matcher::Field {
|
MatcherValue::Contains(c) => v.contains(c),
|
||||||
name: name.clone(),
|
|
||||||
value: value.clone(),
|
|
||||||
}),
|
|
||||||
WipMatcher::Specific { hash } => Some(Matcher::Specific { hash: (*hash)? }),
|
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Matcher {
|
pub enum Matcher {
|
||||||
Field {
|
Field { name: String, value: MatcherValue },
|
||||||
name: String,
|
Message { value: MatcherValue },
|
||||||
value: serde_json::Value,
|
Specific { hash: u64 },
|
||||||
},
|
|
||||||
Specific {
|
|
||||||
hash: u64,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Matcher {
|
impl Matcher {
|
||||||
pub fn matches(&self, entry: &LogEntry) -> bool {
|
pub fn matches(&self, entry: &LogEntry) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
Matcher::Specific { hash } => entry.hash() == *hash,
|
||||||
Matcher::Field { name, value } => entry
|
Matcher::Field { name, value } => entry
|
||||||
.all_fields()
|
.all_fields()
|
||||||
.fields
|
.fields
|
||||||
.get(name)
|
.get(name)
|
||||||
.is_some_and(|v| v == value),
|
.is_some_and(|v| value.matches(&pretty_print_value(&v))),
|
||||||
Matcher::Specific { hash } => entry.hash() == *hash,
|
Matcher::Message { value } => {
|
||||||
|
entry.message_or_name().is_some_and(|v| value.matches(&v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_input(target: InputTarget, lv: &LogViewer) -> Option<Self> {
|
||||||
|
match target {
|
||||||
|
InputTarget::Fields(fm) => {
|
||||||
|
let value = lv.footer_fields().get(lv.footer_list.selected?)?.clone();
|
||||||
|
Some(Self::Field {
|
||||||
|
name: value.0,
|
||||||
|
value: MatcherValue::from_field_matcher(fm?, Some(value.1.to_string()))?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
InputTarget::Text(fm) => Some(Self::Message {
|
||||||
|
value: MatcherValue::from_field_matcher(
|
||||||
|
fm,
|
||||||
|
lv.selected().and_then(|(i, _)| i.message_or_name()),
|
||||||
|
)?,
|
||||||
|
}),
|
||||||
|
InputTarget::This => lv
|
||||||
|
.selected()
|
||||||
|
.map(|(i, _)| Self::Specific { hash: i.hash() }),
|
||||||
|
InputTarget::Surround => todo!(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -69,99 +95,3 @@ impl Filter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct WipFilter {
|
|
||||||
pub matcher: Option<WipMatcher>,
|
|
||||||
pub kind: Option<FilterKind>,
|
|
||||||
pub selection: FilterSelection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WipFilter {
|
|
||||||
pub fn validate(&self) -> Option<Filter> {
|
|
||||||
let Self {
|
|
||||||
matcher,
|
|
||||||
kind,
|
|
||||||
selection: _,
|
|
||||||
} = self;
|
|
||||||
let Some(matcher) = matcher else { return None };
|
|
||||||
Some(Filter {
|
|
||||||
matcher: matcher.validate()?,
|
|
||||||
kind: kind.clone()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear(&mut self) {
|
|
||||||
match self.selection {
|
|
||||||
FilterSelection::Kind => self.kind = None,
|
|
||||||
FilterSelection::MatcherKind => {}
|
|
||||||
FilterSelection::Matcher => {}
|
|
||||||
FilterSelection::Confirm => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn right(&mut self) {
|
|
||||||
match self.selection {
|
|
||||||
FilterSelection::Kind => {
|
|
||||||
self.kind = Some(match self.kind {
|
|
||||||
None => FilterKind::Inline,
|
|
||||||
Some(FilterKind::Inline) => FilterKind::Remove,
|
|
||||||
Some(FilterKind::Remove) => FilterKind::Inline,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
FilterSelection::MatcherKind => {}
|
|
||||||
FilterSelection::Matcher => {}
|
|
||||||
FilterSelection::Confirm => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn left(&mut self) {
|
|
||||||
match self.selection {
|
|
||||||
FilterSelection::Kind => {
|
|
||||||
self.kind = Some(match self.kind {
|
|
||||||
None => FilterKind::Remove,
|
|
||||||
Some(FilterKind::Remove) => FilterKind::Inline,
|
|
||||||
Some(FilterKind::Inline) => FilterKind::Inline,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
FilterSelection::MatcherKind => {}
|
|
||||||
FilterSelection::Matcher => {}
|
|
||||||
FilterSelection::Confirm => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub enum FilterSelection {
|
|
||||||
Kind,
|
|
||||||
MatcherKind,
|
|
||||||
Matcher,
|
|
||||||
Confirm,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FilterSelection {
|
|
||||||
pub fn next(&mut self) {
|
|
||||||
*self = match *self {
|
|
||||||
Self::Kind => Self::MatcherKind,
|
|
||||||
Self::MatcherKind => Self::Matcher,
|
|
||||||
Self::Matcher => Self::Confirm,
|
|
||||||
Self::Confirm => Self::Confirm,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn prev(&mut self) {
|
|
||||||
*self = match self {
|
|
||||||
Self::Kind => Self::Kind,
|
|
||||||
Self::MatcherKind => Self::Kind,
|
|
||||||
Self::Matcher => Self::MatcherKind,
|
|
||||||
Self::Confirm => Self::Matcher,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub enum FieldMatcherSelection {
|
|
||||||
Field,
|
|
||||||
Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FieldMatcherSelection {}
|
|
||||||
|
|
|
||||||
|
|
@ -32,27 +32,101 @@ impl Clone for LogView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum InputTarget {
|
#[derive(Clone)]
|
||||||
Fields,
|
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<FieldMatcher>),
|
||||||
|
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 {
|
pub enum InputState {
|
||||||
None,
|
None,
|
||||||
Target(InputTarget),
|
Target(InputTarget),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InputState {
|
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) {
|
pub fn reset(&mut self) {
|
||||||
*self = Self::None;
|
*self = Self::None;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn target(&mut self, target: InputTarget) {
|
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);
|
*self = Self::Target(target);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LogViewer {
|
pub struct LogViewer {
|
||||||
stack: Vec<LogView>,
|
pub stack: Vec<LogView>,
|
||||||
curr: LogView,
|
curr: LogView,
|
||||||
cache: HashMap<Vec<usize>, LogView>,
|
cache: HashMap<Vec<usize>, LogView>,
|
||||||
filters: Vec<Rc<Filter>>,
|
filters: Vec<Rc<Filter>>,
|
||||||
|
|
@ -253,9 +327,10 @@ impl LogViewer {
|
||||||
self.curr.selection_offset -= 1;
|
self.curr.selection_offset -= 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputState::Target(InputTarget::Fields) => {
|
InputState::Target(InputTarget::Fields(None)) => {
|
||||||
self.footer_list.previous();
|
self.footer_list.previous();
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,9 +339,10 @@ impl LogViewer {
|
||||||
InputState::None => {
|
InputState::None => {
|
||||||
self.curr.selection_offset += 1;
|
self.curr.selection_offset += 1;
|
||||||
}
|
}
|
||||||
InputState::Target(InputTarget::Fields) => {
|
InputState::Target(InputTarget::Fields(None)) => {
|
||||||
self.footer_list.next();
|
self.footer_list.next();
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -292,9 +368,10 @@ impl LogViewer {
|
||||||
self.curr.selection_offset = 0;
|
self.curr.selection_offset = 0;
|
||||||
while self.curr.iter.prev().is_some() {}
|
while self.curr.iter.prev().is_some() {}
|
||||||
}
|
}
|
||||||
InputState::Target(InputTarget::Fields) => {
|
InputState::Target(InputTarget::Fields(None)) => {
|
||||||
self.footer_list.select(Some(0));
|
self.footer_list.select(Some(0));
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,9 +408,10 @@ impl LogViewer {
|
||||||
self.curr = cached_view.clone();
|
self.curr = cached_view.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InputState::Target(InputTarget::Fields) => {
|
InputState::Target(InputTarget::Fields(None)) => {
|
||||||
self.footer_list.next();
|
self.footer_list.next();
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
435
src/tui/mod.rs
435
src/tui/mod.rs
|
|
@ -1,5 +1,6 @@
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use ratatui_themes::{Theme, ThemeName};
|
use ratatui_themes::{Theme, ThemeName};
|
||||||
|
use regex::bytes::Regex;
|
||||||
use std::{
|
use std::{
|
||||||
fs::{self, DirEntry},
|
fs::{self, DirEntry},
|
||||||
io,
|
io,
|
||||||
|
|
@ -11,12 +12,13 @@ use std::{
|
||||||
use tui_widget_list::{ListBuilder, ListView};
|
use tui_widget_list::{ListBuilder, ListView};
|
||||||
|
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
filter::{FilterKind, WipMatcher},
|
filter::FilterKind,
|
||||||
log_viewer::{InputState, InputTarget, LogViewer},
|
log_viewer::{InputState, InputTarget, LogViewer},
|
||||||
model::pretty_print_value,
|
model::pretty_print_value,
|
||||||
};
|
};
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
filter::{FilterSelection, WipFilter},
|
filter::{Filter, Matcher},
|
||||||
|
log_viewer::FieldMatcher,
|
||||||
reader::LogfileReader,
|
reader::LogfileReader,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
|
@ -25,7 +27,7 @@ use ratatui::{
|
||||||
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
|
||||||
layout::{Constraint, HorizontalAlignment, Layout, Rect},
|
layout::{Constraint, HorizontalAlignment, Layout, Rect},
|
||||||
style::Style,
|
style::Style,
|
||||||
text::{Line, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{
|
widgets::{
|
||||||
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
|
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
|
||||||
},
|
},
|
||||||
|
|
@ -37,6 +39,40 @@ pub mod model;
|
||||||
pub mod processing;
|
pub mod processing;
|
||||||
pub mod reader;
|
pub mod reader;
|
||||||
|
|
||||||
|
const HELP_TEXT: &str = "Generic:
|
||||||
|
? show help
|
||||||
|
<esc> cancel focus or close tab
|
||||||
|
u undo
|
||||||
|
r redo
|
||||||
|
pgdwn&pgup move page
|
||||||
|
down&up move single
|
||||||
|
j&k move single
|
||||||
|
Home/G move to start
|
||||||
|
|
||||||
|
<- / backspace / h exit nested view
|
||||||
|
-> / enter / l enter nested view
|
||||||
|
|
||||||
|
───────────────────────────────────────────────────────
|
||||||
|
targeting logs:
|
||||||
|
|
||||||
|
f fields
|
||||||
|
t the selected log
|
||||||
|
s surrounding element
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
───────────────────────────────────────────────────────
|
||||||
|
perform action on selected target:
|
||||||
|
|
||||||
|
control-d delete
|
||||||
|
control-i inline
|
||||||
|
";
|
||||||
|
|
||||||
pub fn run(logs_dir: PathBuf, compiler_root: Option<PathBuf>, theme: ThemeName) {
|
pub fn run(logs_dir: PathBuf, compiler_root: Option<PathBuf>, theme: ThemeName) {
|
||||||
let terminal = ratatui::init();
|
let terminal = ratatui::init();
|
||||||
let theme = Theme::new(theme);
|
let theme = Theme::new(theme);
|
||||||
|
|
@ -56,9 +92,6 @@ enum Tab {
|
||||||
last_height: usize,
|
last_height: usize,
|
||||||
},
|
},
|
||||||
LogViewer(LogViewer),
|
LogViewer(LogViewer),
|
||||||
CreateFilter {
|
|
||||||
filter: WipFilter,
|
|
||||||
},
|
|
||||||
Empty,
|
Empty,
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
@ -70,36 +103,11 @@ impl Tab {
|
||||||
(Tab::FileChooser { .. }, _) => "choose a file".to_string(),
|
(Tab::FileChooser { .. }, _) => "choose a file".to_string(),
|
||||||
(Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()),
|
(Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()),
|
||||||
(Tab::LogViewer(_), None) => "logs".to_string(),
|
(Tab::LogViewer(_), None) => "logs".to_string(),
|
||||||
(Tab::CreateFilter { .. }, _) => "create filter".to_string(),
|
|
||||||
(Tab::Help, _) => "help".to_string(),
|
(Tab::Help, _) => "help".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn initialize_filter(lv: &mut LogViewer, kind: Option<FilterKind>) -> WipFilter {
|
|
||||||
todo!()
|
|
||||||
// let matcher = if lv.fields_selected {
|
|
||||||
// let footer_fields = lv.footer_fields();
|
|
||||||
// let (key, value) = footer_fields
|
|
||||||
// .get(lv.footer_list.selected.unwrap_or(0))
|
|
||||||
// .map_or((None, None), |(k, v)| (Some(k), Some(v)));
|
|
||||||
// Some(WipMatcher::Field {
|
|
||||||
// name: key.cloned(),
|
|
||||||
// value: value.cloned(),
|
|
||||||
// })
|
|
||||||
// } else {
|
|
||||||
// Some(WipMatcher::Specific {
|
|
||||||
// hash: lv.selected().map(|(i, _)| i.hash()),
|
|
||||||
// })
|
|
||||||
// };
|
|
||||||
|
|
||||||
// WipFilter {
|
|
||||||
// matcher,
|
|
||||||
// kind,
|
|
||||||
// selection: filter::FilterSelection::Kind,
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
tabs: Vec<Tab>,
|
tabs: Vec<Tab>,
|
||||||
logs_dir: PathBuf,
|
logs_dir: PathBuf,
|
||||||
|
|
@ -204,6 +212,54 @@ impl App {
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Tab::LogViewer(lv) => match key.code {
|
Tab::LogViewer(lv) => match key.code {
|
||||||
|
// delete
|
||||||
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||||
|
if let InputState::None = lv.input_state {
|
||||||
|
lv.input_state = InputState::Target(InputTarget::This);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let InputState::Target(t) = lv.input_state.clone()
|
||||||
|
&& let Some(m) = Matcher::from_input(t, lv)
|
||||||
|
{
|
||||||
|
lv.add_filter(Rc::new(Filter {
|
||||||
|
matcher: m,
|
||||||
|
kind: FilterKind::Remove,
|
||||||
|
}));
|
||||||
|
lv.input_state.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// inline
|
||||||
|
KeyCode::Char('i') if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||||
|
if let InputState::None = lv.input_state {
|
||||||
|
lv.input_state = InputState::Target(InputTarget::This);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let InputState::Target(t) = lv.input_state.clone()
|
||||||
|
&& let Some(m) = Matcher::from_input(t, lv)
|
||||||
|
{
|
||||||
|
lv.add_filter(Rc::new(Filter {
|
||||||
|
matcher: m,
|
||||||
|
kind: FilterKind::Inline,
|
||||||
|
}));
|
||||||
|
lv.input_state.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) if lv.input_state.captures_input() => {
|
||||||
|
lv.input_state.capture_string().unwrap().push(c);
|
||||||
|
}
|
||||||
|
KeyCode::Backspace if lv.input_state.captures_input() => {
|
||||||
|
lv.input_state.capture_string().unwrap().pop();
|
||||||
|
}
|
||||||
|
KeyCode::Esc if lv.input_state.captures_input() => {
|
||||||
|
if let InputState::Target(InputTarget::Fields(s @ Some(_))) =
|
||||||
|
&mut lv.input_state
|
||||||
|
{
|
||||||
|
*s = None
|
||||||
|
} else {
|
||||||
|
lv.input_state.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ if lv.input_state.captures_input() => {}
|
||||||
KeyCode::Char('j') | KeyCode::Down => lv.next(),
|
KeyCode::Char('j') | KeyCode::Down => lv.next(),
|
||||||
KeyCode::Char('k') | KeyCode::Up => lv.prev(),
|
KeyCode::Char('k') | KeyCode::Up => lv.prev(),
|
||||||
KeyCode::PageUp => lv.page_up(),
|
KeyCode::PageUp => lv.page_up(),
|
||||||
|
|
@ -214,11 +270,50 @@ impl App {
|
||||||
KeyCode::Right => lv.enter(),
|
KeyCode::Right => lv.enter(),
|
||||||
KeyCode::Enter => lv.enter(),
|
KeyCode::Enter => lv.enter(),
|
||||||
KeyCode::Char('f') => {
|
KeyCode::Char('f') => {
|
||||||
lv.input_state.target(InputTarget::Fields);
|
lv.input_state.target(InputTarget::Fields(None));
|
||||||
|
lv.footer_list.select(Some(0));
|
||||||
|
}
|
||||||
|
KeyCode::Esc => lv.input_state.reset(),
|
||||||
|
KeyCode::Char('s') if !lv.stack.is_empty() => {
|
||||||
|
lv.input_state.target(InputTarget::Surround);
|
||||||
|
}
|
||||||
|
KeyCode::Char('t') => {
|
||||||
|
lv.input_state.target(InputTarget::This);
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') | KeyCode::Char('/') => {
|
||||||
|
let v = FieldMatcher::Regex(String::new());
|
||||||
|
if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state {
|
||||||
|
*f = Some(v);
|
||||||
|
} else {
|
||||||
|
lv.input_state.target(InputTarget::Text(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('p') => {
|
||||||
|
let v = FieldMatcher::Prefix(String::new());
|
||||||
|
if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state {
|
||||||
|
*f = Some(v);
|
||||||
|
} else {
|
||||||
|
lv.input_state.target(InputTarget::Text(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('c') => {
|
||||||
|
let v = FieldMatcher::Contains(String::new());
|
||||||
|
if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state {
|
||||||
|
*f = Some(v);
|
||||||
|
} else {
|
||||||
|
lv.input_state.target(InputTarget::Text(v));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('e') => {
|
||||||
|
let v = FieldMatcher::EqualTo;
|
||||||
|
if let InputState::Target(InputTarget::Fields(f @ None)) = &mut lv.input_state {
|
||||||
|
*f = Some(v);
|
||||||
|
} else {
|
||||||
|
lv.input_state.target(InputTarget::Text(v));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Tab::CreateFilter { filter } => todo!(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -347,11 +442,10 @@ impl Widget for &mut App {
|
||||||
Tab::FileChooser { .. } => (false, true),
|
Tab::FileChooser { .. } => (false, true),
|
||||||
Tab::LogViewer(lv) => {
|
Tab::LogViewer(lv) => {
|
||||||
let target_fields =
|
let target_fields =
|
||||||
matches!(lv.input_state, InputState::Target(InputTarget::Fields));
|
matches!(lv.input_state, InputState::Target(InputTarget::Fields(..)));
|
||||||
(target_fields, !target_fields)
|
(target_fields, !target_fields)
|
||||||
}
|
}
|
||||||
Tab::Empty => (false, false),
|
Tab::Empty => (false, false),
|
||||||
Tab::CreateFilter { .. } => (false, false),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let main_area = {
|
let main_area = {
|
||||||
|
|
@ -427,17 +521,122 @@ impl Widget for &mut App {
|
||||||
.items(main_area.height as usize)
|
.items(main_area.height as usize)
|
||||||
.unwrap_or_else(|| (Vec::new(), 0));
|
.unwrap_or_else(|| (Vec::new(), 0));
|
||||||
|
|
||||||
Paragraph::new(selected_offset.to_string()).render(right, buf);
|
Line::from(lv.input_state.show()).render(right, buf);
|
||||||
|
|
||||||
let list = List::new(items.into_iter().enumerate().map(
|
let list = List::new(items.into_iter().enumerate().map(
|
||||||
|(idx, (i, inline_depth))| {
|
|(idx, (i, inline_depth))| {
|
||||||
let line = i.line_text(false, inline_depth);
|
let line_text = i.line_text(false, inline_depth);
|
||||||
|
|
||||||
let mut list_item = ListItem::new(line);
|
let mut line = Line::from(line_text.clone());
|
||||||
|
if idx == selected_offset
|
||||||
if idx == selected_offset {
|
&& let InputState::None | InputState::Target(InputTarget::This) =
|
||||||
list_item = list_item.style(highlighted);
|
lv.input_state
|
||||||
|
{
|
||||||
|
line = Line::from(line_text).style(highlighted);
|
||||||
|
} else if let InputState::Target(InputTarget::Text(s)) = &lv.input_state
|
||||||
|
&& let Some(msg) = i.message_or_name()
|
||||||
|
{
|
||||||
|
match s {
|
||||||
|
FieldMatcher::EqualTo => {
|
||||||
|
if lv
|
||||||
|
.selected()
|
||||||
|
.and_then(|(i, _)| i.message_or_name())
|
||||||
|
.is_some_and(|m| m == msg)
|
||||||
|
{
|
||||||
|
line = line.style(highlighted);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
FieldMatcher::Prefix(p) => {
|
||||||
|
if msg.starts_with(p)
|
||||||
|
&& let Some(offset) = line_text.find(&msg)
|
||||||
|
{
|
||||||
|
let spans = vec![
|
||||||
|
Span::from(line_text[..offset].to_string()),
|
||||||
|
Span::from(
|
||||||
|
line_text[offset..(offset + p.len())]
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.style(highlighted),
|
||||||
|
Span::from(
|
||||||
|
line_text[(offset + p.len())..].to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
line = Line::from(spans);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldMatcher::Regex(r) => {
|
||||||
|
if let Ok(regex) = Regex::new(r)
|
||||||
|
&& let Some(start_offset) = line_text.find(&msg)
|
||||||
|
&& let Some(m) = regex.find(msg.as_bytes())
|
||||||
|
{
|
||||||
|
let spans = vec![
|
||||||
|
Span::from(
|
||||||
|
line_text[..start_offset + m.start()]
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
Span::from(
|
||||||
|
line_text[start_offset + m.start()
|
||||||
|
..start_offset + m.end()]
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.style(highlighted),
|
||||||
|
Span::from(
|
||||||
|
line_text[start_offset + m.end()..].to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
line = Line::from(spans);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldMatcher::Contains(c) => {
|
||||||
|
if msg.contains(c)
|
||||||
|
&& let Some(start_offset) = line_text.find(&msg)
|
||||||
|
&& let Some(contains_offset) =
|
||||||
|
line_text[start_offset..].find(c)
|
||||||
|
{
|
||||||
|
let start = start_offset + contains_offset;
|
||||||
|
let spans = vec![
|
||||||
|
Span::from(line_text[..start].to_string()),
|
||||||
|
Span::from(
|
||||||
|
line_text[start..(start + c.len())].to_string(),
|
||||||
|
)
|
||||||
|
.style(highlighted),
|
||||||
|
Span::from(
|
||||||
|
line_text[(start + c.len())..].to_string(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
line = Line::from(spans);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let InputState::Target(InputTarget::Fields(Some(f))) =
|
||||||
|
&lv.input_state
|
||||||
|
&& let Some(selected_field_offset) = lv.footer_list.selected
|
||||||
|
&& let Some((name, value)) =
|
||||||
|
lv.footer_fields().get(selected_field_offset)
|
||||||
|
&& let Some(current_log_value) = i.all_fields().fields.get(name)
|
||||||
|
{
|
||||||
|
let matches = match f {
|
||||||
|
FieldMatcher::EqualTo => value == current_log_value,
|
||||||
|
FieldMatcher::Prefix(v) => {
|
||||||
|
current_log_value.to_string().starts_with(v)
|
||||||
|
}
|
||||||
|
FieldMatcher::Regex(r) => Regex::new(r).is_ok_and(|i| {
|
||||||
|
i.is_match(current_log_value.to_string().as_bytes())
|
||||||
|
}),
|
||||||
|
FieldMatcher::Contains(c) => {
|
||||||
|
current_log_value.to_string().contains(c)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches {
|
||||||
|
line = line.style(highlighted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let list_item = ListItem::new(line);
|
||||||
|
|
||||||
list_item
|
list_item
|
||||||
},
|
},
|
||||||
|
|
@ -504,159 +703,7 @@ impl Widget for &mut App {
|
||||||
inner
|
inner
|
||||||
};
|
};
|
||||||
|
|
||||||
Paragraph::new(
|
Paragraph::new(HELP_TEXT).render(popup_area, buf);
|
||||||
"
|
|
||||||
Generic:
|
|
||||||
? show help
|
|
||||||
<esc> cancel focus or close tab
|
|
||||||
u undo
|
|
||||||
r redo
|
|
||||||
pgdwn&pgup move page
|
|
||||||
down&up move single
|
|
||||||
j&k move single
|
|
||||||
Home/G move to start
|
|
||||||
|
|
||||||
<- / backspace / h exit nested view
|
|
||||||
-> / enter / l enter nested view
|
|
||||||
|
|
||||||
───────────────────────────────────────────────────────
|
|
||||||
targeting logs:
|
|
||||||
|
|
||||||
f fields
|
|
||||||
p prefix
|
|
||||||
r regex
|
|
||||||
c current
|
|
||||||
s surrounding element
|
|
||||||
|
|
||||||
───────────────────────────────────────────────────────
|
|
||||||
perform action on selected target:
|
|
||||||
|
|
||||||
d delete
|
|
||||||
i inline
|
|
||||||
",
|
|
||||||
)
|
|
||||||
.render(popup_area, buf);
|
|
||||||
}
|
|
||||||
Tab::CreateFilter { filter } => {
|
|
||||||
Clear.render(popup_area, buf);
|
|
||||||
let popup_area = {
|
|
||||||
let block = Block::bordered()
|
|
||||||
.title_top("create filter")
|
|
||||||
.style(default)
|
|
||||||
.padding(Padding::symmetric(3, 1))
|
|
||||||
.border_style(border_selected);
|
|
||||||
let inner = block.inner(popup_area);
|
|
||||||
block.render(popup_area, buf);
|
|
||||||
inner
|
|
||||||
};
|
|
||||||
|
|
||||||
let [kind, matcher_kind, matcher_area, confirm] = Layout::vertical([
|
|
||||||
Constraint::Length(5),
|
|
||||||
Constraint::Length(5),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
|
||||||
.areas(popup_area);
|
|
||||||
|
|
||||||
let text = match &filter.kind {
|
|
||||||
None => "<unset>",
|
|
||||||
Some(FilterKind::Inline) => "inline items",
|
|
||||||
Some(FilterKind::Remove) => "remove item and sub-items",
|
|
||||||
};
|
|
||||||
Paragraph::new(format!("⏴ {text} ⏵"))
|
|
||||||
.centered()
|
|
||||||
.style(
|
|
||||||
if matches!(filter.selection, filter::FilterSelection::Kind) {
|
|
||||||
highlighted
|
|
||||||
} else {
|
|
||||||
default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.block(
|
|
||||||
Block::bordered()
|
|
||||||
.title_top("transformation")
|
|
||||||
.padding(Padding::uniform(1)),
|
|
||||||
)
|
|
||||||
.render(kind, buf);
|
|
||||||
|
|
||||||
let text = match filter.matcher.as_ref() {
|
|
||||||
None => "<unset>",
|
|
||||||
Some(WipMatcher::Field { .. }) => "all logs where field matches",
|
|
||||||
Some(WipMatcher::Specific { .. }) => "this specific log",
|
|
||||||
};
|
|
||||||
Paragraph::new(format!("⏴ {text} ⏵"))
|
|
||||||
.centered()
|
|
||||||
.style(
|
|
||||||
if matches!(filter.selection, filter::FilterSelection::MatcherKind) {
|
|
||||||
highlighted
|
|
||||||
} else {
|
|
||||||
default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.block(
|
|
||||||
Block::bordered()
|
|
||||||
.title_top("matcher")
|
|
||||||
.padding(Padding::uniform(1)),
|
|
||||||
)
|
|
||||||
.render(matcher_kind, buf);
|
|
||||||
|
|
||||||
match &filter.matcher {
|
|
||||||
Some(WipMatcher::Field { name, value }) => {
|
|
||||||
let [field_area, value_area, _] = Layout::vertical([
|
|
||||||
Constraint::Length(5),
|
|
||||||
Constraint::Length(5),
|
|
||||||
Constraint::Fill(1),
|
|
||||||
])
|
|
||||||
.areas(matcher_area);
|
|
||||||
|
|
||||||
Paragraph::new(format!("{}", name.clone().unwrap_or_default()))
|
|
||||||
.centered()
|
|
||||||
.style(
|
|
||||||
if matches!(filter.selection, filter::FilterSelection::Matcher)
|
|
||||||
{
|
|
||||||
highlighted
|
|
||||||
} else {
|
|
||||||
default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.block(
|
|
||||||
Block::bordered()
|
|
||||||
.title_top("field name")
|
|
||||||
.padding(Padding::uniform(1)),
|
|
||||||
)
|
|
||||||
.render(field_area, buf);
|
|
||||||
|
|
||||||
Paragraph::new(format!("{}", value.clone().unwrap_or_default()))
|
|
||||||
.centered()
|
|
||||||
.style(
|
|
||||||
if matches!(filter.selection, filter::FilterSelection::Matcher)
|
|
||||||
{
|
|
||||||
highlighted
|
|
||||||
} else {
|
|
||||||
default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.block(
|
|
||||||
Block::bordered()
|
|
||||||
.title_top("value")
|
|
||||||
.padding(Padding::uniform(1)),
|
|
||||||
)
|
|
||||||
.render(value_area, buf);
|
|
||||||
}
|
|
||||||
Some(WipMatcher::Specific { .. }) => {}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Paragraph::new("confirm")
|
|
||||||
.centered()
|
|
||||||
.style(
|
|
||||||
if matches!(filter.selection, filter::FilterSelection::Confirm) {
|
|
||||||
highlighted
|
|
||||||
} else {
|
|
||||||
default
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.render(confirm, buf);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use jiff::Timestamp;
|
use jiff::Timestamp;
|
||||||
use ratatui::text::Line;
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
|
@ -109,7 +108,22 @@ impl LogEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn line_text(&self, accessed: bool, inline_depth: usize) -> Line<'static> {
|
pub fn message_or_name(&self) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
LogEntry::Single { raw } => raw.fields.message().map(|i| i.to_string()),
|
||||||
|
LogEntry::Sub { enter, .. } => {
|
||||||
|
if let Some(val) = enter.all_fields().fields.get("name")
|
||||||
|
&& let Some(s) = val.as_str()
|
||||||
|
{
|
||||||
|
Some(s.to_string())
|
||||||
|
} else {
|
||||||
|
enter.fields.message().map(|i| i.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn line_text(&self, accessed: bool, inline_depth: usize) -> String {
|
||||||
const NO_MESSAGE: &str = "<no message>";
|
const NO_MESSAGE: &str = "<no message>";
|
||||||
const SPACES_BEFORE: &str = " ";
|
const SPACES_BEFORE: &str = " ";
|
||||||
let indent = " >".repeat(inline_depth);
|
let indent = " >".repeat(inline_depth);
|
||||||
|
|
@ -144,11 +158,11 @@ impl LogEntry {
|
||||||
if let Some(val) = enter.all_fields().fields.get("name")
|
if let Some(val) = enter.all_fields().fields.get("name")
|
||||||
&& let Some(s) = val.as_str()
|
&& let Some(s) = val.as_str()
|
||||||
{
|
{
|
||||||
Line::from(format!(
|
format!(
|
||||||
"{:4}⭣{:4}⇊ ┃{indent}↪ {s}",
|
"{:4}⭣{:4}⇊ ┃{indent}↪ {s}",
|
||||||
sub_entries.len(),
|
sub_entries.len(),
|
||||||
self.count().wrapping_sub(1)
|
self.count().wrapping_sub(1)
|
||||||
))
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{SPACES_BEFORE}┃{indent}{}", single_field(enter)).into()
|
format!("{SPACES_BEFORE}┃{indent}{}", single_field(enter)).into()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue