use crossterm::event::{KeyCode, KeyEvent, MouseEventKind};
use ratatui::{
prelude::*,
style::{Color, Style},
widgets::*,
};
use crate::app::Action;
use crate::menus::help::HelpContext;
use crate::menus::{Menu, OpenMenu};
use crate::mouse_input::MouseInput;
use crate::update_content::{LogFollowRequest, LogFollowUpdate};
const MAX_BUFFER_LINES: usize = 10_000;
const MAX_BUFFER_BYTES: usize = 2 * 1024 * 1024;
const WHEEL_SCROLL_LINES: usize = 3;
pub struct LogViewer {
open: bool,
pub path: String,
lines: Vec<String>,
partial_line: bool,
offset: Option<u64>,
dropped: bool,
follow: bool,
scroll: usize,
view_height: usize,
waiting: bool,
loaded: bool,
search_mode: bool,
search_input: String,
pattern: String,
current_match: Option<usize>,
}
impl Default for LogViewer {
fn default() -> Self {
Self::new()
}
}
impl LogViewer {
pub fn new() -> Self {
Self {
open: false,
path: String::new(),
lines: Vec::new(),
partial_line: false,
offset: None,
dropped: false,
follow: true,
scroll: 0,
view_height: 0,
waiting: false,
loaded: false,
search_mode: false,
search_input: String::new(),
pattern: String::new(),
current_match: None,
}
}
}
impl LogViewer {
pub fn activate(&mut self, path: &str) {
*self = Self::new();
self.path = path.to_string();
self.open = true;
}
pub fn deactivate(&mut self) {
self.open = false;
}
pub fn follow_request(&self) -> Option<LogFollowRequest> {
self.open.then(|| LogFollowRequest {
path: self.path.clone(),
offset: self.offset,
})
}
pub fn apply_update(&mut self, update: LogFollowUpdate) {
if !self.open || update.path != self.path {
return;
}
let chunk = match update.chunk {
Some(chunk) => chunk,
None => {
self.waiting = true;
return;
}
};
self.waiting = false;
self.loaded = true;
if chunk.truncated {
self.lines.clear();
self.partial_line = false;
self.current_match = None;
self.scroll = 0;
}
self.append_text(&chunk.content);
self.offset = Some(chunk.offset);
if self.follow {
self.scroll_to_bottom();
} else {
self.clamp_scroll();
}
}
fn append_text(&mut self, text: &str) {
if text.is_empty() {
return;
}
let ends_with_newline = text.ends_with('\n');
let mut parts = text.split('\n');
if let Some(first) = parts.next() {
match (self.partial_line, self.lines.last_mut()) {
(true, Some(last)) => last.push_str(first),
_ => self.lines.push(first.to_string()),
}
}
for part in parts {
self.lines.push(part.to_string());
}
if ends_with_newline {
self.lines.pop();
}
self.partial_line = !ends_with_newline;
self.enforce_cap();
}
fn enforce_cap(&mut self) {
let mut drop = self.lines.len().saturating_sub(MAX_BUFFER_LINES);
let mut bytes: usize = self.lines.iter().map(|line| line.len()).sum();
while bytes > MAX_BUFFER_BYTES && drop < self.lines.len() {
bytes -= self.lines[drop].len();
drop += 1;
}
if drop == 0 {
return;
}
self.lines.drain(..drop);
self.dropped = true;
self.scroll = self.scroll.saturating_sub(drop);
self.current_match = match self.current_match {
Some(line) if line >= drop => Some(line - drop),
_ => None,
};
}
pub fn lines(&self) -> &[String] {
&self.lines
}
}
impl LogViewer {
fn max_scroll(&self) -> usize {
self.lines.len().saturating_sub(self.view_height.max(1))
}
fn clamp_scroll(&mut self) {
self.scroll = self.scroll.min(self.max_scroll());
}
fn scroll_to_bottom(&mut self) {
self.scroll = self.max_scroll();
}
fn scroll_up(&mut self, amount: usize) {
self.scroll = self.scroll.saturating_sub(amount);
self.follow = false;
}
fn scroll_down(&mut self, amount: usize) {
self.scroll = (self.scroll + amount).min(self.max_scroll());
}
fn go_to_bottom(&mut self) {
self.scroll_to_bottom();
self.follow = true;
}
fn toggle_follow(&mut self) {
self.follow = !self.follow;
if self.follow {
self.scroll_to_bottom();
}
}
fn line_matches(&self, index: usize) -> bool {
self.lines[index].to_lowercase().contains(&self.pattern)
}
fn match_count(&self) -> usize {
if self.pattern.is_empty() {
return 0;
}
(0..self.lines.len())
.filter(|&index| self.line_matches(index))
.count()
}
fn confirm_search(&mut self) {
self.search_mode = false;
self.pattern = self.search_input.to_lowercase();
self.current_match = None;
self.search_step(1);
}
fn search_step(&mut self, direction: isize) {
if self.pattern.is_empty() || self.lines.is_empty() {
return;
}
let count = self.lines.len() as isize;
let start = match self.current_match {
Some(current) => (current as isize + direction).rem_euclid(count),
None => self.scroll.min(self.lines.len() - 1) as isize,
};
for step in 0..count {
let index = (start + step * direction).rem_euclid(count) as usize;
if self.line_matches(index) {
self.jump_to(index);
return;
}
}
self.current_match = None;
}
fn jump_to(&mut self, line: usize) {
self.current_match = Some(line);
self.follow = false;
let height = self.view_height.max(1);
if line < self.scroll || line >= self.scroll + height {
self.scroll = line.saturating_sub(height / 2);
}
self.clamp_scroll();
}
}
impl Menu for LogViewer {
fn is_open(&self) -> bool {
self.open
}
fn render(&mut self, f: &mut Frame, _area: &Rect) {
let rect = f.area();
f.render_widget(Clear, rect);
let mut title = format!(" {} ", self.path);
if self.dropped {
title.push_str("(truncated) ");
}
let follow_hint = if self.follow {
" FOLLOW (f to stop) "
} else {
" f/G to follow "
};
let mut block = Block::default()
.title_top(Line::from(title).alignment(Alignment::Left))
.title_top(Line::from(follow_hint).alignment(Alignment::Right))
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Blue))
.title_bottom(Line::from(" Esc/q/L: close ?: help ").alignment(Alignment::Right));
if self.search_mode {
let bar = format!(" /{}▏", self.search_input);
block = block.title_bottom(
Line::from(bar)
.alignment(Alignment::Left)
.style(Style::default().fg(Color::Yellow)),
);
} else if !self.pattern.is_empty() {
let bar = format!(" /{} ({} matches, n/N) ", self.pattern, self.match_count());
block = block.title_bottom(Line::from(bar).alignment(Alignment::Left));
}
let inner = block.inner(rect);
f.render_widget(block, rect);
self.view_height = inner.height as usize;
if self.lines.is_empty() {
let text = if self.waiting || !self.loaded {
"waiting for log file..."
} else {
"(the log file is empty)"
};
let paragraph = Paragraph::new(text)
.style(Style::default().fg(Color::Gray))
.alignment(Alignment::Center);
f.render_widget(paragraph, inner);
return;
}
if self.follow {
self.scroll_to_bottom();
}
self.clamp_scroll();
let end = (self.scroll + self.view_height).min(self.lines.len());
let visible: Vec<Line> = (self.scroll..end)
.map(|index| {
let line = Line::from(self.lines[index].clone());
if Some(index) == self.current_match {
line.style(Style::default().reversed())
} else if !self.pattern.is_empty() && self.line_matches(index) {
line.style(Style::default().fg(Color::Yellow))
} else {
line
}
})
.collect();
f.render_widget(Paragraph::new(visible), inner);
}
fn input(&mut self, action: &mut Action, key_event: KeyEvent) -> bool {
if self.search_mode {
match key_event.code {
KeyCode::Esc => {
self.search_mode = false;
self.search_input.clear();
}
KeyCode::Enter => {
self.confirm_search();
}
KeyCode::Backspace => {
self.search_input.pop();
}
KeyCode::Char(c) => {
self.search_input.push(c);
}
_ => {}
}
return true;
}
match key_event.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('L') => {
self.deactivate();
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_down(1);
}
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_up(1);
}
KeyCode::Char('d') => {
self.scroll_down(self.view_height.max(2) / 2);
}
KeyCode::Char('u') => {
self.scroll_up(self.view_height.max(2) / 2);
}
KeyCode::PageDown => {
self.scroll_down(self.view_height.max(1));
}
KeyCode::PageUp => {
self.scroll_up(self.view_height.max(1));
}
KeyCode::Char('g') => {
self.scroll = 0;
self.follow = false;
}
KeyCode::Char('G') | KeyCode::End => {
self.go_to_bottom();
}
KeyCode::Char('f') => {
self.toggle_follow();
}
KeyCode::Char('/') => {
self.search_mode = true;
self.search_input.clear();
}
KeyCode::Char('n') => {
self.search_step(1);
}
KeyCode::Char('N') => {
self.search_step(-1);
}
KeyCode::Char('?') => {
*action = Action::OpenMenu(OpenMenu::Help(HelpContext::LogView));
}
_ => {}
}
true
}
fn mouse_input(&mut self, _action: &mut Action, mouse_input: &mut MouseInput) {
if let Some(event_kind) = mouse_input.kind() {
match event_kind {
MouseEventKind::ScrollUp => {
self.scroll_up(WHEEL_SCROLL_LINES);
}
MouseEventKind::ScrollDown => {
self.scroll_down(WHEEL_SCROLL_LINES);
}
_ => {}
}
mouse_input.handled = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scheduler::LogChunk;
use crossterm::event::KeyModifiers;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn viewer_with_lines(count: usize) -> LogViewer {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
viewer.view_height = 5;
let content: String = (0..count).map(|i| format!("line {}\n", i)).collect();
viewer.apply_update(update_for(&viewer, &content, content.len() as u64, false));
viewer
}
fn update_for(
viewer: &LogViewer,
content: &str,
offset: u64,
truncated: bool,
) -> LogFollowUpdate {
LogFollowUpdate {
path: viewer.path.clone(),
chunk: Some(LogChunk {
content: content.to_string(),
offset,
truncated,
}),
}
}
#[test]
fn test_follow_mode_auto_scrolls_to_new_content() {
let mut viewer = viewer_with_lines(20);
assert!(viewer.follow);
assert_eq!(viewer.scroll, 15);
viewer.apply_update(update_for(&viewer, "more 1\nmore 2\n", 999, false));
assert_eq!(viewer.lines.len(), 22);
assert_eq!(viewer.scroll, 17);
assert_eq!(viewer.offset, Some(999));
}
#[test]
fn test_manual_scroll_up_disables_follow() {
let mut viewer = viewer_with_lines(20);
let mut action = Action::None;
viewer.input(&mut action, key(KeyCode::Char('k')));
assert!(!viewer.follow);
assert_eq!(viewer.scroll, 14);
viewer.apply_update(update_for(&viewer, "more\n", 999, false));
assert_eq!(viewer.scroll, 14);
let mut viewer = viewer_with_lines(20);
viewer.input(&mut action, key(KeyCode::PageUp));
assert!(!viewer.follow);
let mut viewer = viewer_with_lines(20);
viewer.input(&mut action, key(KeyCode::Char('u')));
assert!(!viewer.follow);
let mut viewer = viewer_with_lines(20);
viewer.input(&mut action, key(KeyCode::Char('g')));
assert!(!viewer.follow);
assert_eq!(viewer.scroll, 0);
}
#[test]
fn test_g_and_f_reenable_follow() {
let mut viewer = viewer_with_lines(20);
let mut action = Action::None;
viewer.input(&mut action, key(KeyCode::Char('k')));
assert!(!viewer.follow);
viewer.input(&mut action, key(KeyCode::Char('G')));
assert!(viewer.follow);
assert_eq!(viewer.scroll, 15);
viewer.input(&mut action, key(KeyCode::Char('f')));
assert!(!viewer.follow);
viewer.input(&mut action, key(KeyCode::Char('k')));
viewer.input(&mut action, key(KeyCode::Char('f')));
assert!(viewer.follow);
assert_eq!(viewer.scroll, 15);
}
#[test]
fn test_scrolling_stays_in_bounds() {
let mut viewer = viewer_with_lines(20);
let mut action = Action::None;
viewer.input(&mut action, key(KeyCode::Char('j')));
assert_eq!(viewer.scroll, 15);
for _ in 0..40 {
viewer.input(&mut action, key(KeyCode::Char('k')));
}
assert_eq!(viewer.scroll, 0);
viewer.input(&mut action, key(KeyCode::Char('d')));
assert_eq!(viewer.scroll, 2);
viewer.input(&mut action, key(KeyCode::PageDown));
assert_eq!(viewer.scroll, 7);
}
#[test]
fn test_partial_lines_are_continued_across_chunks() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
viewer.apply_update(update_for(&viewer, "complete\npartial", 16, false));
assert_eq!(viewer.lines(), ["complete", "partial"]);
viewer.apply_update(update_for(&viewer, " continued\nnext\n", 32, false));
assert_eq!(viewer.lines(), ["complete", "partial continued", "next"]);
viewer.apply_update(update_for(&viewer, "fresh\n", 38, false));
assert_eq!(
viewer.lines(),
["complete", "partial continued", "next", "fresh"]
);
}
#[test]
fn test_buffer_cap_drops_oldest_lines() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
viewer.view_height = 5;
let content: String = (0..MAX_BUFFER_LINES + 50)
.map(|i| format!("line {}\n", i))
.collect();
viewer.apply_update(update_for(&viewer, &content, content.len() as u64, false));
assert_eq!(viewer.lines().len(), MAX_BUFFER_LINES);
assert_eq!(viewer.lines()[0], "line 50");
assert!(viewer.dropped);
let content = render_to_string(&mut viewer, 80, 10);
assert!(content.contains("(truncated)"));
}
#[test]
fn test_byte_cap_drops_oldest_lines() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
let line = "x".repeat(4095) + "\n";
let content = line.repeat(1100);
viewer.apply_update(update_for(&viewer, &content, content.len() as u64, false));
assert!(viewer.dropped);
let bytes: usize = viewer.lines().iter().map(|l| l.len()).sum();
assert!(bytes <= MAX_BUFFER_BYTES);
assert!(!viewer.lines().is_empty());
}
#[test]
fn test_truncated_chunk_resets_the_buffer() {
let mut viewer = viewer_with_lines(20);
viewer.apply_update(update_for(&viewer, "restarted\n", 10, true));
assert_eq!(viewer.lines(), ["restarted"]);
assert_eq!(viewer.offset, Some(10));
}
#[test]
fn test_missing_file_keeps_waiting_and_stale_updates_are_ignored() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
viewer.apply_update(LogFollowUpdate {
path: "/logs/run.log".to_string(),
chunk: None,
});
assert!(viewer.waiting);
assert_eq!(viewer.offset, None);
let content = render_to_string(&mut viewer, 60, 8);
assert!(content.contains("waiting for log file..."));
viewer.apply_update(LogFollowUpdate {
path: "/logs/other.log".to_string(),
chunk: Some(LogChunk {
content: "bogus\n".to_string(),
offset: 6,
truncated: false,
}),
});
assert!(viewer.lines().is_empty());
viewer.deactivate();
assert_eq!(viewer.follow_request(), None);
viewer.apply_update(update_for(&viewer, "late\n", 5, false));
assert!(viewer.lines().is_empty());
}
#[test]
fn test_follow_request_carries_path_and_offset() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
assert_eq!(
viewer.follow_request(),
Some(LogFollowRequest {
path: "/logs/run.log".to_string(),
offset: None,
})
);
viewer.apply_update(update_for(&viewer, "a\n", 2, false));
assert_eq!(viewer.follow_request().unwrap().offset, Some(2));
}
fn search(viewer: &mut LogViewer, pattern: &str) {
let mut action = Action::None;
viewer.input(&mut action, key(KeyCode::Char('/')));
for c in pattern.chars() {
viewer.input(&mut action, key(KeyCode::Char(c)));
}
viewer.input(&mut action, key(KeyCode::Enter));
}
#[test]
fn test_search_finds_matches_and_n_cycles() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
viewer.view_height = 3;
viewer.apply_update(update_for(
&viewer,
"alpha\nERROR: one\nbeta\nerror: two\ngamma\nError three\n",
99,
false,
));
let mut action = Action::None;
assert_eq!(viewer.scroll, 3);
search(&mut viewer, "error");
assert_eq!(viewer.current_match, Some(3));
assert!(!viewer.follow);
viewer.input(&mut action, key(KeyCode::Char('n')));
assert_eq!(viewer.current_match, Some(5));
viewer.input(&mut action, key(KeyCode::Char('n')));
assert_eq!(viewer.current_match, Some(1)); viewer.input(&mut action, key(KeyCode::Char('n')));
assert_eq!(viewer.current_match, Some(3));
viewer.input(&mut action, key(KeyCode::Char('N')));
assert_eq!(viewer.current_match, Some(1));
assert!(viewer.scroll <= 1 && 1 < viewer.scroll + viewer.view_height);
}
#[test]
fn test_search_without_match_and_prompt_editing() {
let mut viewer = viewer_with_lines(5);
let mut action = Action::None;
search(&mut viewer, "no such text");
assert_eq!(viewer.current_match, None);
viewer.input(&mut action, key(KeyCode::Char('/')));
viewer.input(&mut action, key(KeyCode::Char('x')));
viewer.input(&mut action, key(KeyCode::Backspace));
assert_eq!(viewer.search_input, "");
viewer.input(&mut action, key(KeyCode::Esc));
assert!(!viewer.search_mode);
assert!(viewer.is_open());
}
#[test]
fn test_close_keys_and_help_action() {
for close_key in [KeyCode::Esc, KeyCode::Char('q'), KeyCode::Char('L')] {
let mut viewer = viewer_with_lines(3);
let mut action = Action::None;
assert!(viewer.input(&mut action, key(close_key)));
assert!(!viewer.is_open());
}
let mut viewer = viewer_with_lines(3);
let mut action = Action::None;
viewer.input(&mut action, key(KeyCode::Char('?')));
assert!(matches!(
action,
Action::OpenMenu(OpenMenu::Help(HelpContext::LogView))
));
}
#[test]
fn test_reactivation_resets_the_state() {
let mut viewer = viewer_with_lines(20);
search(&mut viewer, "line");
viewer.deactivate();
viewer.activate("/logs/next.log");
assert!(viewer.is_open());
assert_eq!(viewer.path, "/logs/next.log");
assert!(viewer.lines().is_empty());
assert!(viewer.follow);
assert_eq!(viewer.offset, None);
assert!(viewer.pattern.is_empty());
}
fn render_to_string(viewer: &mut LogViewer, width: u16, height: u16) -> String {
render_terminal(viewer, width, height)
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect()
}
fn render_terminal(viewer: &mut LogViewer, width: u16, height: u16) -> Terminal<TestBackend> {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = f.area();
viewer.render(f, &area);
})
.unwrap();
terminal
}
#[test]
fn test_render_fullscreen_shows_title_and_content() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
viewer.apply_update(update_for(&viewer, "first line\nsecond line\n", 23, false));
let content = render_to_string(&mut viewer, 60, 10);
assert!(content.contains("/logs/run.log"));
assert!(content.contains("first line"));
assert!(content.contains("second line"));
assert!(content.contains("FOLLOW"));
}
#[test]
fn test_render_follow_keeps_bottom_visible_on_small_terminal() {
let mut viewer = LogViewer::new();
viewer.activate("/logs/run.log");
let content: String = (0..50).map(|i| format!("line {:02}\n", i)).collect();
viewer.apply_update(update_for(&viewer, &content, 99, false));
let content = render_to_string(&mut viewer, 40, 8);
assert!(content.contains("line 49"));
assert!(!content.contains("line 00"));
}
#[test]
fn test_snapshot_log_view() {
let mut viewer = LogViewer::new();
viewer.activate("/work/logs/train_model-424242.out");
viewer.apply_update(update_for(
&viewer,
"epoch 1/5: loss 0.91\nepoch 2/5: loss 0.55\nepoch 3/5: loss 0.34\n",
62,
false,
));
let terminal = render_terminal(&mut viewer, 100, 12);
insta::assert_snapshot!("log_view_fullscreen", terminal.backend());
}
}