logviewer/src/tui/mod.rs
2026-03-31 17:36:18 +02:00

716 lines
26 KiB
Rust

use crossterm::{
event::{
DisableMouseCapture, EnableMouseCapture, KeyEventKind, KeyEventState, MouseButton,
MouseEventKind,
},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui_themes::{Theme, ThemeName};
use std::{
fs::{self, DirEntry},
io::{self, Stdout},
ops::ControlFlow,
path::{Path, PathBuf},
process::exit,
sync::Arc,
time::Duration,
};
use tui_widget_list::{ListBuilder, ListView};
use crate::tui::{
filter::{Filter, FilterKind, Matcher},
log_viewer::{
LogViewer,
input::{FieldMatcher, InputState, InputTarget},
},
reader::LogfileReader,
widgets::{hyperlink::Hyperlink, items::Items, last_error::LastError, styled::IntoStyled},
};
use ratatui::{
DefaultTerminal, Terminal,
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
layout::{Constraint, HorizontalAlignment, Layout, Rect},
prelude::CrosstermBackend,
style::Style,
text::Line,
widgets::{
Block, Clear, List, ListItem, ListState, Padding, Paragraph, StatefulWidget, Widget, Wrap,
},
};
pub mod filter;
pub mod log_viewer;
pub mod model;
pub mod processing;
pub mod reader;
pub mod widgets;
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
/ ... matching a regex
e ... equal to selected
c ... containing
───────────────────────────────────────────────────────
perform action on selected target:
alt-d delete
alt-i inline
";
fn setup_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
let mut stdout = io::stdout();
crossterm::terminal::enable_raw_mode()?;
{
use ::std::io::Write;
crossterm::queue!(stdout, EnterAlternateScreen, EnableMouseCapture)
.and_then(|()| ::std::io::Write::flush(stdout.by_ref()))
}?;
Terminal::new(CrosstermBackend::new(stdout))
}
fn teardown_terminal(_terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> io::Result<()> {
let mut stdout = io::stdout();
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(stdout, LeaveAlternateScreen, DisableMouseCapture,)?;
Ok(())
}
pub fn run(logs_dir: PathBuf, compiler_root: Option<PathBuf>, theme: ThemeName) {
let mut terminal = setup_terminal().unwrap();
let theme = Theme::new(theme);
let app_result = App::new(logs_dir, compiler_root, theme).run(&mut terminal);
teardown_terminal(&mut terminal).unwrap();
if let Err(e) = app_result {
eprintln!("error in tui: {e:?}");
exit(1);
}
}
enum Tab {
FileChooser {
files: Vec<DirEntry>,
state: ListState,
last_height: usize,
last_offset: usize,
},
LogViewer(LogViewer),
Empty,
Help,
}
impl Tab {
fn name(&self, path: Option<&Path>) -> String {
match (self, path) {
(Tab::Empty, _) => "dummy".to_string(),
(Tab::FileChooser { .. }, _) => "choose a file".to_string(),
(Tab::LogViewer(_), Some(path)) => format!("logs of {}", path.display()),
(Tab::LogViewer(_), None) => "logs".to_string(),
(Tab::Help, _) => "help".to_string(),
}
}
}
struct App {
tabs: Vec<Tab>,
logs_dir: PathBuf,
compiler_root: Option<PathBuf>,
current_file: Option<LogfileReader>,
theme: Theme,
last_error: LastError,
}
impl App {
fn new(logs_dir: PathBuf, compiler_root: Option<PathBuf>, theme: Theme) -> Self {
let mut res = Self {
tabs: Vec::new(),
current_file: None,
logs_dir,
compiler_root,
theme,
last_error: LastError::new(),
};
res.replace_tab(res.choose_file());
res
}
fn current_file_path(&self) -> Option<PathBuf> {
self.current_file.as_ref().map(|i| i.path())
}
fn replace_tab(&mut self, tab: Tab) {
if let Some(last) = self.tabs.last_mut() {
*last = tab;
} else {
self.tabs = vec![tab];
}
}
fn push_tab(&mut self, tab: Tab) {
self.tabs.push(tab);
}
fn pop_tab(&mut self) {
let _ = self.tabs.pop();
}
fn choose_file(&self) -> Tab {
fn init(logs_dir: &Path) -> io::Result<Vec<DirEntry>> {
let mut files = Vec::new();
for file in fs::read_dir(logs_dir)? {
let file = file?;
if file.path().extension().is_some_and(|ext| ext == "log") {
files.push(file);
}
}
Ok(files)
}
match init(&self.logs_dir) {
Ok(files) => Tab::FileChooser {
files,
state: ListState::default(),
last_height: 0,
last_offset: 0,
},
Err(_) => Tab::Empty,
}
}
fn current_tab(&mut self) -> &mut Tab {
if self.tabs.is_empty() {
self.tabs.push(Tab::Empty);
}
self.tabs.last_mut().unwrap()
}
fn start_log_viewer(&mut self, path: PathBuf) {
let filters_path = path.with_added_extension("filters.json");
match LogfileReader::new(&path) {
Ok(i) => {
self.current_file = Some(i.clone());
if let Some(first) = i.first() {
self.replace_tab(Tab::LogViewer(LogViewer::new(
first,
filters_path,
self.last_error.clone(),
)));
} else {
panic!("no log entries");
}
}
Err(_) => {
panic!()
}
}
}
fn handle_current_tab_keycode(&mut self, key: KeyEvent) {
match self.tabs.last_mut().unwrap() {
Tab::Help => {}
Tab::Empty => {}
Tab::FileChooser {
files,
state,
last_height,
last_offset: _,
} => match key.code {
KeyCode::Char('j') | KeyCode::Down => state.select_next(),
KeyCode::Char('k') | KeyCode::Up => state.select_previous(),
KeyCode::PageUp => state.scroll_up_by(*last_height as u16),
KeyCode::PageDown => state.scroll_down_by(*last_height as u16),
KeyCode::Char('G') | KeyCode::Home => state.select_first(),
KeyCode::Char('g') | KeyCode::End => state.select_last(),
KeyCode::Enter => {
if let Some(selected) = state.selected()
&& let Some(selected) = files.get(selected)
{
let path = selected.path();
self.start_log_viewer(path);
}
}
_ => {}
},
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(Arc::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(Arc::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('k') | KeyCode::Up => lv.prev(),
KeyCode::PageUp => lv.page_up(),
KeyCode::PageDown => lv.page_down(),
KeyCode::Char('G') | KeyCode::Home => lv.home(),
KeyCode::Char('g') | KeyCode::End => todo!(),
KeyCode::Backspace | KeyCode::Left => lv.back(),
KeyCode::Right => lv.enter(),
KeyCode::Enter => lv.enter(),
KeyCode::Char('u') => lv.undo(),
KeyCode::Char('r') => lv.redo(),
KeyCode::Char('f') => {
lv.input_state.target(InputTarget::Fields(None));
lv.footer_list.select(Some(0));
}
KeyCode::Esc => lv.input_state.reset(),
KeyCode::Char('s') if !lv.view.cursor.toplevel() => {
lv.input_state.target(InputTarget::Surround);
}
KeyCode::Char('t') => {
lv.input_state.target(InputTarget::This);
}
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));
}
}
_ => {}
},
}
}
fn handle_generic_keycode(
&mut self,
key: KeyEvent,
terminal: &mut DefaultTerminal,
) -> ControlFlow<()> {
let num_tabs = self.tabs.len();
match key.code {
KeyCode::Char('q') => return ControlFlow::Break(()),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return ControlFlow::Break(());
}
KeyCode::Char('z') if key.modifiers.contains(KeyModifiers::CONTROL) => {
ratatui::restore();
let self_pid = nix::unistd::getpid();
let _ = nix::sys::signal::kill(self_pid, nix::sys::signal::SIGTSTP);
*terminal = setup_terminal().unwrap();
}
KeyCode::Char('?') => {
if matches!(self.current_tab(), Tab::Help) {
self.pop_tab();
} else {
self.push_tab(Tab::Help);
}
}
KeyCode::Esc if num_tabs > 1 => {
self.pop_tab();
}
_ => {
self.handle_current_tab_keycode(key);
}
}
ControlFlow::Continue(())
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
loop {
self.last_error.check_expired();
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
// update every 250ms or more if there's an event
if !event::poll(Duration::from_millis(250))? {
continue;
}
match event::read()? {
Event::Key(key) => {
// to initialize, but we then do get manually for borrow reasons
self.current_tab();
// always active
if self.handle_generic_keycode(key, terminal).is_break() {
break Ok(());
}
}
Event::FocusGained => {}
Event::FocusLost => {}
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollDown => {
if self
.handle_generic_keycode(
KeyEvent {
code: KeyCode::Down,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
},
terminal,
)
.is_break()
{
break Ok(());
}
}
Event::Mouse(mouse) if mouse.kind == MouseEventKind::ScrollUp => {
if self
.handle_generic_keycode(
KeyEvent {
code: KeyCode::Up,
modifiers: KeyModifiers::empty(),
kind: KeyEventKind::Press,
state: KeyEventState::empty(),
},
terminal,
)
.is_break()
{
break Ok(());
}
}
Event::Mouse(mouse_event) => {
if let MouseEventKind::Up(MouseButton::Left) = mouse_event.kind {
if let Tab::LogViewer(lv) = self.current_tab() {
lv.click(mouse_event.row);
} else if let Tab::FileChooser {
files,
state,
last_height: _,
last_offset,
} = self.current_tab()
{
if mouse_event.row as usize > *last_offset {
let row = mouse_event.row as usize - *last_offset;
if row < files.len() {
if state.selected() == Some(row)
&& let Some(selected) = files.get(row)
{
let path = selected.path();
self.start_log_viewer(path);
} else {
state.select(Some(row));
}
}
}
}
}
}
Event::Paste(_) => {}
Event::Resize(_, _) => {}
}
}
}
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);
let error = Style::new().fg(palette.error).bg(palette.bg);
Styles {
default,
highlighted,
border,
border_highlighted,
error,
}
}
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
}
}
pub struct Styles {
default: Style,
highlighted: Style,
border: Style,
border_highlighted: Style,
error: Style,
}
impl Widget for &mut App {
fn render(self, area: Rect, buf: &mut Buffer)
where
Self: Sized,
{
let styles = self.styles();
let [header_area, main_area, footer_area] = Layout::vertical([
Constraint::Length(2),
Constraint::Fill(1),
Constraint::Ratio(1, 4),
])
.areas(area);
let [_, popup_area, _] = Layout::vertical([
Constraint::Fill(1),
Constraint::Min(40),
Constraint::Fill(1),
])
.areas(area);
let [_, popup_area, _] = Layout::horizontal([
Constraint::Fill(1),
Constraint::Min(40),
Constraint::Fill(1),
])
.areas(popup_area);
let (footer_focused, header_focused) = match self.current_tab() {
Tab::Help => (false, false),
Tab::FileChooser { .. } => (false, true),
Tab::LogViewer(lv) => {
let target_fields =
matches!(lv.input_state, InputState::Target(InputTarget::Fields(..)));
(target_fields, !target_fields)
}
Tab::Empty => (false, false),
};
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),
Constraint::Ratio(1, 3),
Constraint::Fill(1),
])
.areas(header_area);
let current_file_path = self.current_file_path();
let breadcrumbs = self.tabs[..self.tabs.len() - 1]
.iter()
.map(|i| i.name(current_file_path.as_deref()))
.collect::<Vec<_>>()
.join("");
Paragraph::new(breadcrumbs)
.wrap(Wrap { trim: false })
.style(styles.default)
.render(left, buf);
Paragraph::new(self.current_tab().name(current_file_path.as_deref()))
.alignment(HorizontalAlignment::Center)
.wrap(Wrap { trim: false })
.style(styles.default)
.render(middle, buf);
let [right, error] =
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(right);
Paragraph::new("").style(styles.default).render(right, buf);
for tab in &mut self.tabs {
match tab {
Tab::FileChooser {
files,
state,
last_height,
last_offset,
} => {
let list = List::new(files.iter().map(|file| {
ListItem::new(file.file_name().to_string_lossy().into_owned())
}))
.style(styles.default)
.highlight_style(styles.highlighted);
*last_height = main_area.height as usize;
*last_offset = main_area.y as usize;
StatefulWidget::render(list, main_area, buf, state);
}
Tab::LogViewer(lv) => {
lv.last_offset = main_area.y as usize;
lv.update_num_items(main_area.height as usize);
let (items, selected_offset) = lv
.items(main_area.height as usize)
.unwrap_or_else(|| (Vec::new(), 0));
lv.input_state.styled_ref(&styles).render(right, buf);
Clear.render(footer_area, buf);
let [first_line, footer_area] =
Layout::vertical([Constraint::Length(1), Constraint::Fill(1)])
.areas(footer_area);
if let Some((e, _)) = lv.selected() {
let rustc_root = self.compiler_root.as_ref();
let (file, line) = e.file_line_string();
if let Some(rustc_root) = rustc_root
&& let Ok(canonical_rustc_root) = rustc_root.canonicalize()
{
let full_file_path = canonical_rustc_root.join(&file);
Hyperlink::new(
Line::from(format!("In file: {}:{line}", file.display()))
.style(styles.default),
format!("file://{}", full_file_path.display()),
)
.render(first_line, buf);
} else {
Line::from(format!("In file: {}:{line}", file.display()))
.style(styles.default)
.render(first_line, buf);
}
}
Items::new(
items,
&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()))
}),
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();
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 => {
Clear.render(popup_area, buf);
let popup_area = {
let block = Block::bordered()
.title_top("help")
.style(styles.default)
.padding(Padding::symmetric(3, 1))
.border_style(styles.border_highlighted);
let inner = block.inner(popup_area);
block.render(popup_area, buf);
inner
};
Paragraph::new(HELP_TEXT).render(popup_area, buf);
}
}
self.last_error.clone().styled(&styles).render(error, buf);
}
}
}