refactor logviewer
This commit is contained in:
parent
7ea9d84228
commit
79639be9da
9 changed files with 425 additions and 239 deletions
|
|
@ -46,7 +46,7 @@ impl Matcher {
|
||||||
.all_fields()
|
.all_fields()
|
||||||
.fields
|
.fields
|
||||||
.get(name)
|
.get(name)
|
||||||
.is_some_and(|v| value.matches(&pretty_print_value(&v))),
|
.is_some_and(|v| value.matches(v)),
|
||||||
Matcher::Message { value } => {
|
Matcher::Message { value } => {
|
||||||
entry.message_or_name().is_some_and(|v| value.matches(&v))
|
entry.message_or_name().is_some_and(|v| value.matches(&v))
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +59,7 @@ impl Matcher {
|
||||||
let value = lv.footer_fields().get(lv.footer_list.selected?)?.clone();
|
let value = lv.footer_fields().get(lv.footer_list.selected?)?.clone();
|
||||||
Some(Self::Field {
|
Some(Self::Field {
|
||||||
name: value.0,
|
name: value.0,
|
||||||
value: MatcherValue::from_field_matcher(fm?, Some(value.1.to_string()))?,
|
value: MatcherValue::from_field_matcher(fm?, Some(value.1))?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
InputTarget::Text(fm) => Some(Self::Message {
|
InputTarget::Text(fm) => Some(Self::Message {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ use crate::tui::{
|
||||||
filter::Filter,
|
filter::Filter,
|
||||||
model::LogEntry,
|
model::LogEntry,
|
||||||
processing::{IntoLogStream, LogStream},
|
processing::{IntoLogStream, LogStream},
|
||||||
|
widgets::styled::Styled,
|
||||||
};
|
};
|
||||||
|
use ratatui::{buffer::Buffer, layout::Rect, text::Line, widgets::Widget};
|
||||||
use tui_widget_list::ListState;
|
use tui_widget_list::ListState;
|
||||||
|
|
||||||
pub struct LogView {
|
pub struct LogView {
|
||||||
|
|
@ -77,6 +79,17 @@ pub enum InputState {
|
||||||
Target(InputTarget),
|
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 {
|
impl InputState {
|
||||||
pub fn capture_string(&mut self) -> Option<&mut String> {
|
pub fn capture_string(&mut self) -> Option<&mut String> {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -274,13 +287,12 @@ impl LogViewer {
|
||||||
self.last_height = num_visible_items;
|
self.last_height = num_visible_items;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn footer_fields(&self) -> Vec<(String, serde_json::Value)> {
|
pub fn footer_fields(&self) -> Vec<(String, String)> {
|
||||||
if let Some((selected, _)) = self.selected() {
|
if let Some((selected, _)) = self.selected() {
|
||||||
let ret = match selected.as_ref() {
|
let ret = match selected.as_ref() {
|
||||||
LogEntry::Single { .. } => Default::default(),
|
LogEntry::Single { .. } => Default::default(),
|
||||||
LogEntry::Sub { sub_entries, .. } => sub_entries.last().and_then(|i| {
|
LogEntry::Sub { sub_entries, .. } => sub_entries.last().and_then(|i| {
|
||||||
i.all_fields()
|
i.all_fields()
|
||||||
.fields
|
|
||||||
.get_key_value("return")
|
.get_key_value("return")
|
||||||
.map(|(k, v)| (k.clone(), v.clone()))
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
279
src/tui/mod.rs
279
src/tui/mod.rs
|
|
@ -1,4 +1,3 @@
|
||||||
use itertools::Itertools;
|
|
||||||
use ratatui_themes::{Theme, ThemeName};
|
use ratatui_themes::{Theme, ThemeName};
|
||||||
use regex::bytes::Regex;
|
use regex::bytes::Regex;
|
||||||
use std::{
|
use std::{
|
||||||
|
|
@ -15,11 +14,13 @@ use crate::tui::{
|
||||||
filter::FilterKind,
|
filter::FilterKind,
|
||||||
log_viewer::{InputState, InputTarget, LogViewer},
|
log_viewer::{InputState, InputTarget, LogViewer},
|
||||||
model::pretty_print_value,
|
model::pretty_print_value,
|
||||||
|
widgets::{hyperlink::Hyperlink, items::Items, line_text::Highlighted},
|
||||||
};
|
};
|
||||||
use crate::tui::{
|
use crate::tui::{
|
||||||
filter::{Filter, Matcher},
|
filter::{Filter, Matcher},
|
||||||
log_viewer::FieldMatcher,
|
log_viewer::FieldMatcher,
|
||||||
reader::LogfileReader,
|
reader::LogfileReader,
|
||||||
|
widgets::styled::IntoStyled,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal,
|
DefaultTerminal,
|
||||||
|
|
@ -27,7 +28,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, Span, Text},
|
text::Line,
|
||||||
widgets::{
|
widgets::{
|
||||||
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
|
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
|
||||||
},
|
},
|
||||||
|
|
@ -38,6 +39,7 @@ pub mod log_viewer;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod processing;
|
pub mod processing;
|
||||||
pub mod reader;
|
pub mod reader;
|
||||||
|
pub mod widgets;
|
||||||
|
|
||||||
const HELP_TEXT: &str = "Generic:
|
const HELP_TEXT: &str = "Generic:
|
||||||
? show help
|
? show help
|
||||||
|
|
@ -367,6 +369,42 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn styles(&self) -> Styles {
|
||||||
|
let palette = self.theme.palette();
|
||||||
|
let default = Style::new().fg(palette.fg).bg(palette.bg);
|
||||||
|
let highlighted = Style::new().fg(palette.accent).bg(palette.selection);
|
||||||
|
let border = Style::new().fg(palette.fg).bg(palette.bg);
|
||||||
|
let border_highlighted = Style::new().fg(palette.secondary).bg(palette.bg);
|
||||||
|
|
||||||
|
Styles {
|
||||||
|
default,
|
||||||
|
highlighted,
|
||||||
|
border,
|
||||||
|
border_highlighted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
styles.border_highlighted
|
||||||
|
});
|
||||||
|
let inner = block.inner(area);
|
||||||
|
block.render(area, buf);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Styles {
|
||||||
|
default: Style,
|
||||||
|
highlighted: Style,
|
||||||
|
border: Style,
|
||||||
|
border_highlighted: Style,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for &mut App {
|
impl Widget for &mut App {
|
||||||
|
|
@ -374,12 +412,7 @@ impl Widget for &mut App {
|
||||||
where
|
where
|
||||||
Self: Sized,
|
Self: Sized,
|
||||||
{
|
{
|
||||||
let palette = self.theme.palette();
|
let styles = self.styles();
|
||||||
let default = Style::new().fg(palette.fg).bg(palette.bg);
|
|
||||||
let highlighted = Style::new().fg(palette.accent).bg(palette.selection);
|
|
||||||
let border = Style::new().fg(palette.fg).bg(palette.bg);
|
|
||||||
let border_selected = Style::new().fg(palette.secondary).bg(palette.bg);
|
|
||||||
|
|
||||||
let [header_area, main_area, footer_area] = Layout::vertical([
|
let [header_area, main_area, footer_area] = Layout::vertical([
|
||||||
Constraint::Length(2),
|
Constraint::Length(2),
|
||||||
Constraint::Fill(1),
|
Constraint::Fill(1),
|
||||||
|
|
@ -411,32 +444,8 @@ impl Widget for &mut App {
|
||||||
Tab::Empty => (false, false),
|
Tab::Empty => (false, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
let main_area = {
|
let main_area = self.block_around(main_area, buf, header_focused);
|
||||||
let block = Block::bordered()
|
let footer_area = self.block_around(footer_area, buf, footer_focused);
|
||||||
.style(default)
|
|
||||||
.border_style(if header_focused {
|
|
||||||
border_selected
|
|
||||||
} else {
|
|
||||||
border
|
|
||||||
});
|
|
||||||
let inner = block.inner(main_area);
|
|
||||||
block.render(main_area, buf);
|
|
||||||
inner
|
|
||||||
};
|
|
||||||
|
|
||||||
let footer_area = {
|
|
||||||
let block = Block::bordered()
|
|
||||||
.style(default)
|
|
||||||
.border_style(if footer_focused {
|
|
||||||
border_selected
|
|
||||||
} else {
|
|
||||||
border
|
|
||||||
});
|
|
||||||
|
|
||||||
let inner = block.inner(footer_area);
|
|
||||||
block.render(footer_area, buf);
|
|
||||||
inner
|
|
||||||
};
|
|
||||||
|
|
||||||
let [left, middle, right] = Layout::horizontal([
|
let [left, middle, right] = Layout::horizontal([
|
||||||
Constraint::Ratio(1, 3),
|
Constraint::Ratio(1, 3),
|
||||||
|
|
@ -453,16 +462,16 @@ impl Widget for &mut App {
|
||||||
.join("►");
|
.join("►");
|
||||||
Paragraph::new(breadcrumbs)
|
Paragraph::new(breadcrumbs)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.style(default)
|
.style(styles.default)
|
||||||
.render(left, buf);
|
.render(left, buf);
|
||||||
|
|
||||||
Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
|
Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
|
||||||
.alignment(HorizontalAlignment::Center)
|
.alignment(HorizontalAlignment::Center)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
.style(default)
|
.style(styles.default)
|
||||||
.render(middle, buf);
|
.render(middle, buf);
|
||||||
|
|
||||||
Line::from("-").style(default).render(right, buf);
|
Paragraph::new("").style(styles.default).render(right, buf);
|
||||||
|
|
||||||
for tab in &mut self.tabs {
|
for tab in &mut self.tabs {
|
||||||
match tab {
|
match tab {
|
||||||
|
|
@ -474,8 +483,8 @@ impl Widget for &mut App {
|
||||||
let list = List::new(files.iter().map(|file| {
|
let list = List::new(files.iter().map(|file| {
|
||||||
ListItem::new(file.file_name().to_string_lossy().into_owned())
|
ListItem::new(file.file_name().to_string_lossy().into_owned())
|
||||||
}))
|
}))
|
||||||
.style(default)
|
.style(styles.default)
|
||||||
.highlight_style(highlighted);
|
.highlight_style(styles.highlighted);
|
||||||
|
|
||||||
*last_height = main_area.height as usize;
|
*last_height = main_area.height as usize;
|
||||||
|
|
||||||
|
|
@ -488,129 +497,7 @@ 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));
|
||||||
|
|
||||||
Line::from(lv.input_state.show())
|
lv.input_state.styled_ref(&styles).render(right, buf);
|
||||||
.style(default)
|
|
||||||
.render(right, buf);
|
|
||||||
|
|
||||||
let list = List::new(items.into_iter().enumerate().map(
|
|
||||||
|(idx, (i, inline_depth))| {
|
|
||||||
let line_text = i.line_text(false, inline_depth);
|
|
||||||
|
|
||||||
let mut line = Line::from(line_text.clone());
|
|
||||||
if idx == selected_offset
|
|
||||||
&& let InputState::None | InputState::Target(InputTarget::This) =
|
|
||||||
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
|
|
||||||
},
|
|
||||||
));
|
|
||||||
Widget::render(list, main_area, buf);
|
|
||||||
|
|
||||||
Clear.render(footer_area, buf);
|
Clear.render(footer_area, buf);
|
||||||
let [first_line, footer_area] =
|
let [first_line, footer_area] =
|
||||||
|
|
@ -625,37 +512,50 @@ impl Widget for &mut App {
|
||||||
{
|
{
|
||||||
let full_file_path = canonical_rustc_root.join(&file);
|
let full_file_path = canonical_rustc_root.join(&file);
|
||||||
Hyperlink::new(
|
Hyperlink::new(
|
||||||
Line::from(format!("In file: {}", file.display())).style(default),
|
Line::from(format!("In file: {}", file.display()))
|
||||||
|
.style(styles.default),
|
||||||
format!("file://{}:{line}", full_file_path.display()),
|
format!("file://{}:{line}", full_file_path.display()),
|
||||||
)
|
)
|
||||||
.render(first_line, buf);
|
.render(first_line, buf);
|
||||||
} else {
|
} else {
|
||||||
Line::from(format!("In file: {}:{line}", file.display()))
|
Line::from(format!("In file: {}:{line}", file.display()))
|
||||||
.style(default)
|
.style(styles.default)
|
||||||
.render(first_line, buf);
|
.render(first_line, buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Items::new(
|
||||||
|
items,
|
||||||
|
selected_offset,
|
||||||
|
&lv.input_state,
|
||||||
|
lv.footer_list.selected.and_then(|idx| {
|
||||||
|
lv.footer_fields()
|
||||||
|
.get(idx)
|
||||||
|
.map(|(a, b)| (a.clone(), b.clone()))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.styled_ref(&styles)
|
||||||
|
.render(main_area, buf);
|
||||||
|
|
||||||
let items = lv.footer_fields();
|
let items = lv.footer_fields();
|
||||||
let width = 20;
|
let width = 20;
|
||||||
let builder = ListBuilder::new(|cx| {
|
let builder = ListBuilder::new(|cx| {
|
||||||
let Some((k, v)) = &items.get(cx.index) else {
|
let Some((k, v)) = &items.get(cx.index) else {
|
||||||
return (Paragraph::new(""), 1);
|
return (Paragraph::new(""), 1);
|
||||||
};
|
};
|
||||||
let contents = pretty_print_value(&v);
|
|
||||||
|
|
||||||
let mut res = Paragraph::new(format!("{k:width$} {contents}"))
|
let mut res =
|
||||||
.wrap(Wrap { trim: false });
|
Paragraph::new(format!("{k:width$} {v}")).wrap(Wrap { trim: false });
|
||||||
|
|
||||||
if cx.is_selected {
|
if cx.is_selected {
|
||||||
res = res.style(highlighted);
|
res = res.style(styles.highlighted);
|
||||||
}
|
}
|
||||||
|
|
||||||
let height = res.line_count(footer_area.width) as u16;
|
let height = res.line_count(footer_area.width) as u16;
|
||||||
(res, height)
|
(res, height)
|
||||||
});
|
});
|
||||||
|
|
||||||
let list = ListView::new(builder, items.len()).style(default);
|
let list = ListView::new(builder, items.len()).style(styles.default);
|
||||||
StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list);
|
StatefulWidget::render(list, footer_area, buf, &mut lv.footer_list);
|
||||||
}
|
}
|
||||||
Tab::Empty => {}
|
Tab::Empty => {}
|
||||||
|
|
@ -664,9 +564,9 @@ impl Widget for &mut App {
|
||||||
let popup_area = {
|
let popup_area = {
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title_top("help")
|
.title_top("help")
|
||||||
.style(default)
|
.style(styles.default)
|
||||||
.padding(Padding::symmetric(3, 1))
|
.padding(Padding::symmetric(3, 1))
|
||||||
.border_style(border_selected);
|
.border_style(styles.border_highlighted);
|
||||||
let inner = block.inner(popup_area);
|
let inner = block.inner(popup_area);
|
||||||
block.render(popup_area, buf);
|
block.render(popup_area, buf);
|
||||||
inner
|
inner
|
||||||
|
|
@ -678,40 +578,3 @@ impl Widget for &mut App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Hyperlink<'content> {
|
|
||||||
text: Text<'content>,
|
|
||||||
url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'content> Hyperlink<'content> {
|
|
||||||
fn new(text: impl Into<Text<'content>>, url: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
text: text.into(),
|
|
||||||
url: url.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget for Hyperlink<'_> {
|
|
||||||
fn render(self, area: Rect, buffer: &mut Buffer) {
|
|
||||||
(&self.text).render(area, buffer);
|
|
||||||
|
|
||||||
// this is a hacky workaround for https://github.com/ratatui/ratatui/issues/902, a bug
|
|
||||||
// in the terminal code that incorrectly calculates the width of ANSI escape sequences. It
|
|
||||||
// works by rendering the hyperlink as a series of 2-character chunks, which is the
|
|
||||||
// calculated width of the hyperlink text.
|
|
||||||
for (i, two_chars) in self
|
|
||||||
.text
|
|
||||||
.to_string()
|
|
||||||
.chars()
|
|
||||||
.chunks(2)
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
{
|
|
||||||
let text = two_chars.collect::<String>();
|
|
||||||
let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", self.url, text);
|
|
||||||
buffer[(area.x + i as u16 * 2, area.y)].set_symbol(hyperlink.as_str());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ use jiff::Timestamp;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::tui::widgets::line_text::LineText;
|
||||||
|
|
||||||
pub fn pretty_print_value(v: &Value) -> String {
|
pub fn pretty_print_value(v: &Value) -> String {
|
||||||
match v {
|
match v {
|
||||||
Value::Null => "null".to_string(),
|
Value::Null => "null".to_string(),
|
||||||
|
|
@ -112,10 +114,8 @@ impl LogEntry {
|
||||||
match self {
|
match self {
|
||||||
LogEntry::Single { raw } => raw.fields.message().map(|i| i.to_string()),
|
LogEntry::Single { raw } => raw.fields.message().map(|i| i.to_string()),
|
||||||
LogEntry::Sub { enter, .. } => {
|
LogEntry::Sub { enter, .. } => {
|
||||||
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()
|
Some(val.clone())
|
||||||
{
|
|
||||||
Some(s.to_string())
|
|
||||||
} else {
|
} else {
|
||||||
enter.fields.message().map(|i| i.to_string())
|
enter.fields.message().map(|i| i.to_string())
|
||||||
}
|
}
|
||||||
|
|
@ -123,63 +123,87 @@ impl LogEntry {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn line_text(&self, accessed: bool, inline_depth: usize) -> String {
|
pub fn line_text(&self, inline_depth: usize) -> LineText {
|
||||||
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 single_field = |raw: &RawLogEntry| {
|
let single_field = |raw: &RawLogEntry| {
|
||||||
raw.fields
|
raw.fields
|
||||||
.message()
|
.message()
|
||||||
.map(|i| i.to_string())
|
.map(|i| i.to_string())
|
||||||
.or_else(|| {
|
.or_else(|| raw.fields.fields.get("return").map(|v| format!("↩ {v}")))
|
||||||
raw.fields
|
|
||||||
.fields
|
|
||||||
.get("return")
|
|
||||||
.map(|v| format!("↩ {}", pretty_print_value(v)))
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
raw.fields
|
raw.fields
|
||||||
.fields
|
.fields
|
||||||
.iter()
|
.iter()
|
||||||
.next()
|
.next()
|
||||||
.map(|(k, v)| format!("{k} = {}", pretty_print_value(v)))
|
.map(|(k, v)| format!("{k} = {v}"))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| NO_MESSAGE.to_string())
|
.unwrap_or_else(|| NO_MESSAGE.to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
LogEntry::Single { raw } => {
|
LogEntry::Single { raw } => LineText::new(
|
||||||
format!("{SPACES_BEFORE}┃{indent}{}", single_field(raw)).into()
|
SPACES_BEFORE.to_string(),
|
||||||
}
|
single_field(raw),
|
||||||
|
self.message_or_name(),
|
||||||
|
inline_depth,
|
||||||
|
),
|
||||||
LogEntry::Sub {
|
LogEntry::Sub {
|
||||||
enter, sub_entries, ..
|
enter, sub_entries, ..
|
||||||
} => {
|
} => {
|
||||||
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()
|
LineText::new(
|
||||||
{
|
|
||||||
format!(
|
format!(
|
||||||
"{:4}⭣{:4}⇊ ┃{indent}↪ {s}",
|
"{:4}⭣{:4}⇊ ",
|
||||||
sub_entries.len(),
|
sub_entries.len(),
|
||||||
self.count().wrapping_sub(1)
|
self.count().wrapping_sub(1)
|
||||||
|
),
|
||||||
|
format!("↪ {val}"),
|
||||||
|
self.message_or_name(),
|
||||||
|
inline_depth,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("{SPACES_BEFORE}┃{indent}{}", single_field(enter)).into()
|
LineText::new(
|
||||||
|
SPACES_BEFORE.to_string(),
|
||||||
|
single_field(enter),
|
||||||
|
self.message_or_name(),
|
||||||
|
inline_depth,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LogFieldsWrapper {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub fields: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LogFieldsWrapper> for LogFields {
|
||||||
|
fn from(value: LogFieldsWrapper) -> Self {
|
||||||
|
Self {
|
||||||
|
fields: value
|
||||||
|
.fields
|
||||||
|
.into_iter()
|
||||||
|
.map(|(k, v)| (k, pretty_print_value(&v)))
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone, Hash)]
|
#[derive(Deserialize, Debug, Clone, Hash)]
|
||||||
|
#[serde(from = "LogFieldsWrapper")]
|
||||||
pub struct LogFields {
|
pub struct LogFields {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub fields: BTreeMap<String, serde_json::Value>,
|
pub fields: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LogFields {
|
impl LogFields {
|
||||||
pub fn message(&self) -> Option<&str> {
|
pub fn message(&self) -> Option<&str> {
|
||||||
self.fields.get("message").and_then(|i| i.as_str())
|
self.fields.get("message").map(|i| i.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge(&self, other: &Self) -> Self {
|
pub fn merge(&self, other: &Self) -> Self {
|
||||||
|
|
@ -192,8 +216,15 @@ impl LogFields {
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, key: impl AsRef<str>) -> Option<&String> {
|
||||||
|
self.fields.get(key.as_ref())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_key_value(&self, key: impl AsRef<str>) -> Option<(&String, &String)> {
|
||||||
|
self.fields.get_key_value(key.as_ref())
|
||||||
|
}
|
||||||
|
}
|
||||||
#[derive(Deserialize, Debug, Hash)]
|
#[derive(Deserialize, Debug, Hash)]
|
||||||
pub struct RawLogEntry {
|
pub struct RawLogEntry {
|
||||||
pub timestamp: Timestamp,
|
pub timestamp: Timestamp,
|
||||||
|
|
|
||||||
39
src/tui/widgets/hyperlink.rs
Normal file
39
src/tui/widgets/hyperlink.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
use itertools::Itertools;
|
||||||
|
use ratatui::{buffer::Buffer, layout::Rect, text::Text, widgets::Widget};
|
||||||
|
|
||||||
|
pub struct Hyperlink<'content> {
|
||||||
|
text: Text<'content>,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'content> Hyperlink<'content> {
|
||||||
|
pub fn new(text: impl Into<Text<'content>>, url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
url: url.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for Hyperlink<'_> {
|
||||||
|
fn render(self, area: Rect, buffer: &mut Buffer) {
|
||||||
|
(&self.text).render(area, buffer);
|
||||||
|
|
||||||
|
// this is a hacky workaround for https://github.com/ratatui/ratatui/issues/902, a bug
|
||||||
|
// in the terminal code that incorrectly calculates the width of ANSI escape sequences. It
|
||||||
|
// works by rendering the hyperlink as a series of 2-character chunks, which is the
|
||||||
|
// calculated width of the hyperlink text.
|
||||||
|
for (i, two_chars) in self
|
||||||
|
.text
|
||||||
|
.to_string()
|
||||||
|
.chars()
|
||||||
|
.chunks(2)
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
let text = two_chars.collect::<String>();
|
||||||
|
let hyperlink = format!("\x1B]8;;{}\x07{}\x1B]8;;\x07", self.url, text);
|
||||||
|
buffer[(area.x + i as u16 * 2, area.y)].set_symbol(hyperlink.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/tui/widgets/items.rs
Normal file
128
src/tui/widgets/items.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use ratatui::widgets::{List, ListItem, Widget};
|
||||||
|
use regex::Regex;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::tui::{
|
||||||
|
log_viewer::{FieldMatcher, InputState, InputTarget},
|
||||||
|
model::{LogEntry, pretty_print_value},
|
||||||
|
widgets::{
|
||||||
|
line_text::Highlighted,
|
||||||
|
styled::{IntoStyled, Styled},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct Items<'a> {
|
||||||
|
items: Vec<(Rc<LogEntry>, usize)>,
|
||||||
|
selected_offset: usize,
|
||||||
|
input_state: &'a InputState,
|
||||||
|
|
||||||
|
selected_footer_field: Option<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Items<'a> {
|
||||||
|
pub fn new(
|
||||||
|
items: Vec<(Rc<LogEntry>, usize)>,
|
||||||
|
selected_offset: usize,
|
||||||
|
input_state: &'a InputState,
|
||||||
|
selected_footer_field: Option<(String, String)>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
items,
|
||||||
|
selected_offset,
|
||||||
|
input_state,
|
||||||
|
selected_footer_field,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected(&self) -> Option<&Rc<LogEntry>> {
|
||||||
|
self.items.get(self.selected_offset).map(|(s, _)| s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for Styled<'_, &Items<'_>> {
|
||||||
|
fn render(self, area: ratatui::prelude::Rect, buf: &mut ratatui::prelude::Buffer)
|
||||||
|
where
|
||||||
|
Self: Sized,
|
||||||
|
{
|
||||||
|
let list = List::new(self.inner.items.iter().enumerate().map(
|
||||||
|
|(idx, (i, inline_depth))| {
|
||||||
|
let line_text = i.line_text(*inline_depth);
|
||||||
|
|
||||||
|
let mut line = line_text.styled(&self.styles);
|
||||||
|
if idx == self.selected_offset
|
||||||
|
&& let InputState::None | InputState::Target(InputTarget::This) =
|
||||||
|
self.input_state
|
||||||
|
{
|
||||||
|
line.highlight(Highlighted::All);
|
||||||
|
} else if let InputState::Target(InputTarget::Text(s)) = self.input_state
|
||||||
|
&& let Some(msg) = &line.message_text
|
||||||
|
{
|
||||||
|
match s {
|
||||||
|
FieldMatcher::EqualTo => {
|
||||||
|
if self
|
||||||
|
.selected()
|
||||||
|
.and_then(|i| i.message_or_name())
|
||||||
|
.is_some_and(|m| &m == msg)
|
||||||
|
{
|
||||||
|
line.highlight(Highlighted::All);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldMatcher::Prefix(p) => {
|
||||||
|
if msg.starts_with(p)
|
||||||
|
&& let Some(offset) = line.message.find(msg)
|
||||||
|
{
|
||||||
|
line.highlight(Highlighted::Range {
|
||||||
|
from: offset,
|
||||||
|
to: offset + p.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldMatcher::Regex(r) => {
|
||||||
|
if let Ok(regex) = Regex::new(r)
|
||||||
|
&& let Some(start_offset) = line.message.find(msg)
|
||||||
|
&& let Some(m) = regex.find(msg)
|
||||||
|
{
|
||||||
|
let from = start_offset + m.start();
|
||||||
|
let to = start_offset + m.end();
|
||||||
|
line.highlight(Highlighted::Range { from, to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FieldMatcher::Contains(c) => {
|
||||||
|
if msg.contains(c)
|
||||||
|
&& let Some(start_offset) = line.message.find(msg)
|
||||||
|
&& let Some(contains_offset) = line.message[start_offset..].find(c)
|
||||||
|
{
|
||||||
|
let start = start_offset + contains_offset;
|
||||||
|
line.highlight(Highlighted::Range {
|
||||||
|
from: start,
|
||||||
|
to: start + c.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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) = i.all_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))
|
||||||
|
}
|
||||||
|
FieldMatcher::Contains(c) => current_log_value.to_string().contains(c),
|
||||||
|
};
|
||||||
|
|
||||||
|
if matches {
|
||||||
|
line.highlight(Highlighted::All);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ListItem::new(line)
|
||||||
|
},
|
||||||
|
));
|
||||||
|
Widget::render(list, area, buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/tui/widgets/line_text.rs
Normal file
69
src/tui/widgets/line_text.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
use ratatui::text::{Line, Span, Text};
|
||||||
|
|
||||||
|
use crate::tui::widgets::styled::Styled;
|
||||||
|
|
||||||
|
pub enum Highlighted {
|
||||||
|
None,
|
||||||
|
All,
|
||||||
|
Range { from: usize, to: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LineText {
|
||||||
|
prefix: String,
|
||||||
|
pub message: String,
|
||||||
|
inline_depth: usize,
|
||||||
|
pub message_text: Option<String>,
|
||||||
|
highlighted: Highlighted,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LineText {
|
||||||
|
pub fn new(
|
||||||
|
prefix: String,
|
||||||
|
message: String,
|
||||||
|
message_text: Option<String>,
|
||||||
|
inline_depth: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
prefix,
|
||||||
|
message,
|
||||||
|
message_text,
|
||||||
|
inline_depth,
|
||||||
|
highlighted: Highlighted::None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn highlight(&mut self, highlighted: Highlighted) {
|
||||||
|
self.highlighted = highlighted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<Line<'static>> for Styled<'_, LineText> {
|
||||||
|
fn into(self) -> Line<'static> {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
|
spans.push(Span::from(self.inner.prefix));
|
||||||
|
spans.push(Span::from("┃"));
|
||||||
|
|
||||||
|
match self.inner.highlighted {
|
||||||
|
Highlighted::None => {
|
||||||
|
spans.push(Span::from(self.inner.message).style(self.styles.default))
|
||||||
|
}
|
||||||
|
Highlighted::All => {
|
||||||
|
spans.push(Span::from(self.inner.message).style(self.styles.highlighted))
|
||||||
|
}
|
||||||
|
Highlighted::Range { from, to } => spans.extend_from_slice(&[
|
||||||
|
Span::from(self.inner.message[..from].to_string()).style(self.styles.default),
|
||||||
|
Span::from(self.inner.message[from..to].to_string()).style(self.styles.highlighted),
|
||||||
|
Span::from(self.inner.message[to..].to_string()).style(self.styles.default),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<Text<'static>> for Styled<'_, LineText> {
|
||||||
|
fn into(self) -> Text<'static> {
|
||||||
|
Text::from(Into::<Line<'static>>::into(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/tui/widgets/mod.rs
Normal file
4
src/tui/widgets/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
pub mod hyperlink;
|
||||||
|
pub mod items;
|
||||||
|
pub mod line_text;
|
||||||
|
pub mod styled;
|
||||||
40
src/tui/widgets/styled.rs
Normal file
40
src/tui/widgets/styled.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
use std::ops::{Deref, DerefMut};
|
||||||
|
|
||||||
|
use crate::tui::Styles;
|
||||||
|
pub struct Styled<'a, T> {
|
||||||
|
pub styles: &'a Styles,
|
||||||
|
pub inner: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IntoStyled<'a>: Sized {
|
||||||
|
fn styled_ref(&self, styles: &'a Styles) -> Styled<'a, &Self>;
|
||||||
|
fn styled(self, styles: &'a Styles) -> Styled<'a, Self>;
|
||||||
|
}
|
||||||
|
impl<'a, T> IntoStyled<'a> for T {
|
||||||
|
fn styled_ref(&self, styles: &'a Styles) -> Styled<'a, &Self> {
|
||||||
|
Styled {
|
||||||
|
styles,
|
||||||
|
inner: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn styled(self, styles: &'a Styles) -> Styled<'a, Self> {
|
||||||
|
Styled {
|
||||||
|
styles,
|
||||||
|
inner: self,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Deref for Styled<'_, T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> DerefMut for Styled<'_, T> {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.inner
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue