use itertools::Itertools; use ratatui_themes::{Theme, ThemeName}; use regex::bytes::Regex; use std::{ fs::{self, DirEntry}, io, ops::ControlFlow, path::{Path, PathBuf}, process::exit, rc::Rc, }; use tui_widget_list::{ListBuilder, ListView}; use crate::tui::{ filter::FilterKind, log_viewer::{InputState, InputTarget, LogViewer}, model::pretty_print_value, }; use crate::tui::{ filter::{Filter, Matcher}, log_viewer::FieldMatcher, reader::LogfileReader, }; use ratatui::{ DefaultTerminal, buffer::Buffer, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, layout::{Constraint, HorizontalAlignment, Layout, Rect}, style::Style, text::{Line, Span, Text}, 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; const HELP_TEXT: &str = "Generic: ? show help 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, theme: ThemeName) { let terminal = ratatui::init(); let theme = Theme::new(theme); let app_result = App::new(logs_dir, compiler_root, theme).run(terminal); ratatui::restore(); if let Err(e) = app_result { eprintln!("error in tui: {e:?}"); exit(1); } } enum Tab { FileChooser { files: Vec, state: ListState, last_height: 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, logs_dir: PathBuf, compiler_root: Option, current_file: Option, theme: Theme, } impl App { fn new(logs_dir: PathBuf, compiler_root: Option, theme: Theme) -> Self { let mut res = Self { tabs: Vec::new(), current_file: None, logs_dir, compiler_root, theme, }; res.replace_tab(res.choose_file()); res } fn current_file_path(&self) -> Option { 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> { let mut files = Vec::new(); for file in fs::read_dir(logs_dir)? { let file = file?; files.push(file); } Ok(files) } match init(&self.logs_dir) { Ok(files) => Tab::FileChooser { files, state: ListState::default(), last_height: 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 handle_current_tab_keycode(&mut self, key: KeyEvent) { match self.tabs.last_mut().unwrap() { Tab::Help => {} Tab::Empty => {} Tab::FileChooser { files, state, last_height, } => 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) { match LogfileReader::new(&selected.path()) { Ok(i) => { self.current_file = Some(i.clone()); self.replace_tab(Tab::LogViewer(LogViewer::new(i.iter()))); } Err(_) => { panic!() } } } } _ => {} }, 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('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('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.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)); } } _ => {} }, } } 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 = ratatui::init(); } 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, mut terminal: DefaultTerminal) -> io::Result<()> { loop { terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?; if let Event::Key(key) = event::read()? { // to initialize, but we then do get manually for borrow reasons self.current_tab(); // always active if self.handle_generic_keycode(key, &mut terminal).is_break() { break Ok(()); } // match (key.code, self.tabs.last_mut().unwrap()) { // (KeyCode::Tab, Tab::LogViewer(lv)) => { // lv.switch_focus(); // } // (KeyCode::Char('r'), Tab::LogViewer(lv)) => { // let filter = initialize_filter(lv, Some(FilterKind::Remove)); // self.push_tab(Tab::CreateFilter { filter }); // } // (KeyCode::Char('i'), Tab::LogViewer(lv)) => { // let filter = initialize_filter(lv, Some(FilterKind::Inline)); // self.push_tab(Tab::CreateFilter { filter }); // } // if let FilterSelection::Confirm = filter.selection { // let filter_clone = filter.clone(); // if let Some(lv) = self.tabs.iter_mut().rev().find_map(|i| { // if let Tab::LogViewer(lv) = i { // Some(lv) // } else { // None // } // }) && let Some(filter) = filter_clone.validate() // { // lv.add_filter(Rc::new(filter)); // self.pop_tab(); // if let Tab::LogViewer(lv) = self.current_tab() { // lv.footer_selected = false; // } // } // } else { // filter.selection.next(); // } // }, // _ => {} // } } } } } impl Widget for &mut App { fn render(self, area: Rect, buf: &mut Buffer) where Self: Sized, { 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_selected = Style::new().fg(palette.secondary).bg(palette.bg); 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 = { let block = Block::bordered() .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([ 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::>() .join("►"); Paragraph::new(breadcrumbs) .wrap(Wrap { trim: false }) .render(left, buf); Paragraph::new(self.current_tab().name(current_file_path.as_deref())) .alignment(HorizontalAlignment::Center) .wrap(Wrap { trim: false }) .render(middle, buf); for tab in &mut self.tabs { match tab { Tab::FileChooser { files, state, last_height, } => { let list = List::new(files.iter().map(|file| { ListItem::new(file.file_name().to_string_lossy().into_owned()) })) .style(default) .highlight_style(highlighted); *last_height = main_area.height as usize; StatefulWidget::render(list, main_area, buf, state); } Tab::LogViewer(lv) => { 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)); Line::from(lv.input_state.show()).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); 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: {}", file.display())).style(default), format!("file://{}:{line}", full_file_path.display()), ) .render(first_line, buf); } else { Line::from(format!("In file: {}:{line}", file.display())) .style(default) .render(first_line, buf); } } let items = lv.footer_fields(); let width = 20; let builder = ListBuilder::new(|cx| { let Some((k, v)) = &items.get(cx.index) else { return (Paragraph::new(""), 1); }; let contents = pretty_print_value(&v); let mut res = Paragraph::new(format!("{k:width$} {contents}")) .wrap(Wrap { trim: false }); if cx.is_selected { res = res.style(highlighted); } let height = res.line_count(footer_area.width) as u16; (res, height) }); let list = ListView::new(builder, items.len()).style(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(default) .padding(Padding::symmetric(3, 1)) .border_style(border_selected); let inner = block.inner(popup_area); block.render(popup_area, buf); inner }; Paragraph::new(HELP_TEXT).render(popup_area, buf); } } } } } struct Hyperlink<'content> { text: Text<'content>, url: String, } impl<'content> Hyperlink<'content> { fn new(text: impl Into>, url: impl Into) -> 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::(); 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()); } } }