use std::path::Path; use std::time::Instant; use log_viewer_core::config::ColorConfig; use log_viewer_core::io::progressive_reader::{ IndexerMessage, ProgressiveFileReader, VisualHeightIndex, compute_line_visual_height, spawn_indexer, }; use log_viewer_core::io::wrap::{format_json_line, wrap_line_chars}; use log_viewer_core::types::LogLevel; use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher}; use crate::color::AVAILABLE_COLORS; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum AppMode { Normal, Settings, } pub(crate) enum AppLoadingState { Empty, Loading { reader: ProgressiveFileReader, estimated_lines: u64, progress_percent: f64, }, Ready { reader: ProgressiveFileReader, }, Error(String), } // ── Viewport cache (on-demand, viewport-sized) ─────────────────── pub(crate) struct ViewportEntry { pub(crate) wrapped_rows: Vec, pub(crate) level: Option, pub(crate) visual_height: usize, } pub(crate) struct ViewportCache { pub(crate) entries: Vec, pub(crate) logical_start: usize, pub(crate) width: usize, json_format: bool, cached_total_visual_rows: Option, } impl ViewportCache { pub(crate) fn new() -> Self { Self { entries: Vec::new(), logical_start: 0, width: 0, json_format: false, cached_total_visual_rows: None, } } pub(crate) fn invalidate(&mut self) { self.entries.clear(); self.logical_start = 0; self.width = 0; self.cached_total_visual_rows = None; } pub(crate) fn needs_recompute(&self, width: usize, json_format: bool) -> bool { self.width != width || self.json_format != json_format } pub(crate) fn get_entry(&self, logical_line: usize) -> Option<&ViewportEntry> { if logical_line >= self.logical_start { let idx = logical_line - self.logical_start; self.entries.get(idx) } else { None } } } // ── App ────────────────────────────────────────────────────────── pub struct App { pub should_quit: bool, // File state loading_state: AppLoadingState, pub(crate) file_path: Option, // Scroll state pub(crate) cursor_line: usize, pub(crate) v_offset: usize, pub(crate) v_sub_offset: usize, // Viewport cache (on-demand, viewport-sized) pub(crate) viewport_cache: ViewportCache, // Viewport #[allow(dead_code)] pub(crate) content_width: u16, pub(crate) content_height: u16, // gg state machine pub(crate) last_g_press: Option, // JSON formatting pub(crate) json_format: bool, // Mode & coloring pub(crate) mode: AppMode, pub(crate) color_config: ColorConfig, // Settings panel state pub(crate) settings_cursor: usize, pub(crate) settings_draft: ColorConfig, // File watcher file_watcher: Option, } impl App { pub fn new() -> Self { Self { should_quit: false, loading_state: AppLoadingState::Empty, file_path: None, cursor_line: 0, v_offset: 0, v_sub_offset: 0, viewport_cache: ViewportCache::new(), content_width: 0, content_height: 0, last_g_press: None, json_format: false, mode: AppMode::Normal, color_config: ColorConfig::default(), settings_cursor: 0, settings_draft: ColorConfig::default(), file_watcher: None, } } pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> { // Cancel any existing background indexer by dropping the old state self.file_watcher = None; let mut pfr = ProgressiveFileReader::open(Path::new(path)) .map_err(|e| anyhow::anyhow!("{e}"))?; if pfr.is_sampling() { // Cache miss: spawn background indexer let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1); let generation = pfr.generation(); let indexer_rx = spawn_indexer( pfr.path().to_path_buf(), generation, 80, false, cancel_rx, ); pfr = ProgressiveFileReader::with_channels( Path::new(path), cancel_tx, indexer_rx, generation, ).map_err(|e| anyhow::anyhow!("{e}"))?; let estimated = pfr.line_count() as u64; self.loading_state = AppLoadingState::Loading { reader: pfr, estimated_lines: estimated, progress_percent: 0.0, }; } else { // Cache hit: Ready state self.loading_state = AppLoadingState::Ready { reader: pfr }; } self.file_watcher = FileWatcher::watch(Path::new(path)).ok(); self.file_path = Some(path.to_string()); self.cursor_line = 0; self.v_offset = 0; self.v_sub_offset = 0; self.viewport_cache.invalidate(); self.last_g_press = None; // reset gg state machine self.json_format = false; self.mode = AppMode::Normal; Ok(()) } // ── On-demand viewport computation ─────────────────────────── /// Compute a single line's viewport entry (wrapped rows + level + height). fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry { let raw = self.get_line(line).unwrap_or_default(); let level = log_viewer_core::parser::level::detect_level(&raw); let display_text = if self.json_format { format_json_line(&raw) } else { raw }; let mut wrapped = Vec::new(); for sub_line in display_text.split('\n') { wrapped.extend(wrap_line_chars(sub_line, width)); } let visual_height = wrapped.len().max(1); ViewportEntry { wrapped_rows: wrapped, level, visual_height, } } /// Compute visual height for a single line without storing it. fn compute_visual_height(&self, line: usize, width: usize) -> usize { let raw = self.get_line(line).unwrap_or_default(); let display_text = if self.json_format { format_json_line(&raw) } else { raw }; let mut height = 0; for sub_line in display_text.split('\n') { height += wrap_line_chars(sub_line, width).len(); } height.max(1) } /// Find (logical_line, offset_in_line) for a given visual row offset. fn find_logical_line_at_visual_row(&self, visual_row: usize, _width: usize) -> (usize, usize) { if let Some(index) = self.get_visual_height_index() { return index.visual_row_to_logical_row_with_offset(visual_row as u64); } (visual_row.min(self.total_lines().saturating_sub(1)), 0) } /// Ensure the viewport cache covers the visible range. /// Returns (start_logical, offset_in_line) for rendering. pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) { let viewport_height = self.content_height as usize; let v_offset = self.v_offset; if !self.is_loaded() || width == 0 || viewport_height == 0 { return (0, 0); } let params_changed = self.viewport_cache.needs_recompute(width, self.json_format); if params_changed { self.viewport_cache.invalidate(); self.viewport_cache.width = width; self.viewport_cache.json_format = self.json_format; self.ensure_visual_height_index(width); let cursor_first = self.cursor_to_first_visual_row(self.cursor_line); let half_height = (self.content_height as usize) / 2; self.v_offset = cursor_first.saturating_sub(half_height); self.clamp_v_offset(); } // Find start logical line from v_offset let (start_logical, offset_in_line) = if self.is_loading() { (v_offset.min(self.total_lines().saturating_sub(1)), self.v_sub_offset) } else { self.find_logical_line_at_visual_row(self.v_offset, width) }; // Compute viewport entries self.viewport_cache.entries.clear(); self.viewport_cache.logical_start = start_logical; let total = self.total_lines(); let mut rows_remaining = viewport_height + offset_in_line; for line_idx in start_logical..total { if rows_remaining == 0 { break; } let entry = self.compute_line_entry(line_idx, width); rows_remaining = rows_remaining.saturating_sub(entry.visual_height); self.viewport_cache.entries.push(entry); } // Post-check: ensure cursor_line is within rendered entries (Loading + JSON expansion) if self.is_loading() { let entries = &self.viewport_cache.entries; let last_entry_end = self.viewport_cache.logical_start + entries.len() + entries.last().map(|e| e.visual_height).unwrap_or(0).saturating_sub(1); let first_entry_start = self.viewport_cache.logical_start; if self.cursor_line >= last_entry_end || self.cursor_line < first_entry_start { self.v_offset = self.cursor_line; self.viewport_cache.entries.clear(); self.viewport_cache.logical_start = self.cursor_line; self.fill_viewport_entries(self.cursor_line, width, viewport_height); } } (start_logical, offset_in_line) } fn fill_viewport_entries(&mut self, start_logical: usize, width: usize, viewport_height: usize) { let total = self.total_lines(); let mut rows_remaining = viewport_height; for line_idx in start_logical..total { if rows_remaining == 0 { break; } let entry = self.compute_line_entry(line_idx, width); rows_remaining = rows_remaining.saturating_sub(entry.visual_height); self.viewport_cache.entries.push(entry); } } /// Compute total visual rows (cached, lazily evaluated). /// Returns `total_lines` for sampling mode (1:1 mapping). fn total_visual_rows(&mut self) -> usize { if self.is_loading() { return self.total_lines(); } if let Some(index) = self.get_visual_height_index() { return index.total_visual_rows() as usize; } self.total_lines() } // ── Scroll methods ────────────────────────────────────────────── pub fn scroll_down_line(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } // VHI present → visual-row scroll if self.get_visual_height_index().is_some() { let max_offset = self .total_visual_rows() .saturating_sub(self.content_height as usize); if self.v_offset < max_offset { self.v_offset = self.v_offset.saturating_add(1); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } else { let last = self.total_lines() - 1; if self.cursor_line < last { self.cursor_line += 1; } } } else { // Loading/no-index: visual-row scroll via v_sub_offset let width = self.get_content_width(); if width > 0 && self.total_lines() > 0 { let current_height = self.compute_visual_height(self.v_offset, width); if self.v_sub_offset + 1 < current_height { self.v_sub_offset += 1; } else { let last = self.total_lines() - 1; if self.v_offset < last { self.v_offset += 1; self.v_sub_offset = 0; } } self.cursor_line = self.v_offset; } } } pub fn scroll_up_line(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } // VHI present → visual-row scroll if self.get_visual_height_index().is_some() { if self.v_offset > 0 { self.v_offset = self.v_offset.saturating_sub(1); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } else { self.cursor_line = self.cursor_line.saturating_sub(1); } } else { // Loading/no-index: visual-row scroll via v_sub_offset if self.v_sub_offset > 0 { self.v_sub_offset -= 1; } else if self.v_offset > 0 { self.v_offset -= 1; let width = self.get_content_width(); self.v_sub_offset = if width > 0 { self.compute_visual_height(self.v_offset, width).saturating_sub(1) } else { 0 }; } self.cursor_line = self.v_offset; } } pub fn scroll_down_half_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let half = self.content_height as usize / 2; self.v_offset = self.v_offset.saturating_add(half); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_up_half_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let half = self.content_height as usize / 2; self.v_offset = self.v_offset.saturating_sub(half); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_down_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let page = self.content_height as usize; self.v_offset = self.v_offset.saturating_add(page); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_up_page(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } let page = self.content_height as usize; self.v_offset = self.v_offset.saturating_sub(page); let center_visual = self .v_offset .saturating_add(self.content_height as usize / 2); self.cursor_line = self.visual_row_to_logical_row(center_visual); self.clamp_v_offset(); } pub fn scroll_to_top(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } self.cursor_line = 0; self.v_offset = 0; self.v_sub_offset = 0; } pub fn scroll_to_bottom(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } self.cursor_line = self.total_lines().saturating_sub(1); self.v_sub_offset = 0; self.ensure_cursor_visible(); self.clamp_v_offset(); } // ── Internal helpers ──────────────────────────────────────────── fn ensure_cursor_visible(&mut self) { if !self.is_loaded() || self.total_lines() == 0 { return; } if self.is_loading() { return; } let cursor_first = self.cursor_to_first_visual_row(self.cursor_line); let height = if let Some(index) = self.get_visual_height_index() { index.visual_height_of_line(self.cursor_line) } else { 1 }; let cursor_last = cursor_first + height.saturating_sub(1); let content_h = self.content_height as usize; if cursor_first < self.v_offset { self.v_offset = cursor_first; } else if cursor_last >= self.v_offset.saturating_add(content_h) { self.v_offset = cursor_last.saturating_sub(content_h).saturating_add(1); } self.clamp_v_offset(); } fn clamp_v_offset(&mut self) { let max_offset = self .total_visual_rows() .saturating_sub(self.content_height as usize); self.v_offset = self.v_offset.min(max_offset); } pub(crate) fn cursor_to_first_visual_row(&self, line: usize) -> usize { if self.is_loading() { return line; } if let Some(index) = self.get_visual_height_index() { return index.cursor_to_first_visual_row(line) as usize; } line } pub(crate) fn visual_row_to_logical_row(&self, visual_row: usize) -> usize { if self.is_loading() { return visual_row.min(self.total_lines().saturating_sub(1)); } if let Some(index) = self.get_visual_height_index() { return index.visual_row_to_logical_row(visual_row as u64); } visual_row.min(self.total_lines().saturating_sub(1)) } // ── Key handling ──────────────────────────────────────────────── pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) { match self.mode { AppMode::Normal => self.handle_normal_key(key), AppMode::Settings => self.handle_settings_key(key), } } fn handle_normal_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::{KeyCode, KeyModifiers}; match key.code { KeyCode::Char('q') | KeyCode::Esc => { self.should_quit = true; self.last_g_press = None; } KeyCode::Char('j') | KeyCode::Down => { self.scroll_down_line(); self.last_g_press = None; } KeyCode::Char('k') | KeyCode::Up => { self.scroll_up_line(); self.last_g_press = None; } KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_down_half_page(); self.last_g_press = None; } KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_up_half_page(); self.last_g_press = None; } KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_down_page(); self.last_g_press = None; } KeyCode::PageDown => { self.scroll_down_page(); self.last_g_press = None; } KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.scroll_up_page(); self.last_g_press = None; } KeyCode::PageUp => { self.scroll_up_page(); self.last_g_press = None; } KeyCode::Char('G') | KeyCode::End => { self.scroll_to_bottom(); self.last_g_press = None; } KeyCode::Char('g') => { if let Some(instant) = self.last_g_press && instant.elapsed().as_millis() < 500 { self.scroll_to_top(); self.last_g_press = None; return; } self.last_g_press = Some(Instant::now()); } KeyCode::Home => { self.scroll_to_top(); self.last_g_press = None; } KeyCode::Tab => { self.json_format = !self.json_format; self.viewport_cache.invalidate(); let width = self.viewport_cache.width; if let AppLoadingState::Ready { reader } = &mut self.loading_state { reader.invalidate_visual_height_index(); if width > 0 { reader.start_visual_height_rebuild(width, self.json_format); } } self.last_g_press = None; } KeyCode::Char('s') | KeyCode::Char('S') if !key.modifiers.contains(KeyModifiers::CONTROL) => { self.settings_draft = self.color_config.clone(); self.mode = AppMode::Settings; } _ => { self.last_g_press = None; } } } fn handle_settings_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::KeyCode; match key.code { KeyCode::Esc | KeyCode::Char('q') => { self.mode = AppMode::Normal; } KeyCode::Enter => { self.color_config = self.settings_draft.clone(); let _ = self.color_config.save(); self.mode = AppMode::Normal; } KeyCode::Char('j') | KeyCode::Down => { if self.settings_cursor < 5 { self.settings_cursor += 1; } } KeyCode::Char('k') | KeyCode::Up => { self.settings_cursor = self.settings_cursor.saturating_sub(1); } KeyCode::Left => { self.cycle_color(self.settings_cursor, false); } KeyCode::Right => { self.cycle_color(self.settings_cursor, true); } KeyCode::Char(c) if ('1'..='8').contains(&c) => { let idx = (c as usize) - ('1' as usize); self.set_color(self.settings_cursor, AVAILABLE_COLORS[idx]); } _ => {} } } fn cycle_color(&mut self, level_idx: usize, forward: bool) { let current = self.get_settings_color(level_idx).to_string(); let colors = AVAILABLE_COLORS; let pos = colors.iter().position(|&c| c == current); let new_pos = match pos { Some(p) => { if forward { (p + 1) % colors.len() } else { p.saturating_sub(1).min(colors.len() - 1) } } None => { if forward { 0 } else { colors.len() - 1 } } }; self.set_color(level_idx, colors[new_pos]); } fn get_settings_color(&self, level_idx: usize) -> &str { match level_idx { 0 => &self.settings_draft.error, 1 => &self.settings_draft.warn, 2 => &self.settings_draft.info, 3 => &self.settings_draft.debug, 4 => &self.settings_draft.trace, 5 => &self.settings_draft.unknown, _ => "white", } } fn set_color(&mut self, level_idx: usize, color_name: &str) { match level_idx { 0 => self.settings_draft.error = color_name.to_string(), 1 => self.settings_draft.warn = color_name.to_string(), 2 => self.settings_draft.info = color_name.to_string(), 3 => self.settings_draft.debug = color_name.to_string(), 4 => self.settings_draft.trace = color_name.to_string(), 5 => self.settings_draft.unknown = color_name.to_string(), _ => {} } } // ── Utility methods ───────────────────────────────────────────── #[allow(dead_code)] pub fn get_line(&self, idx: usize) -> Option { match &self.loading_state { AppLoadingState::Ready { reader } => reader.get_line(idx), AppLoadingState::Loading { reader, .. } => reader.get_line(idx), _ => None, } } #[allow(dead_code)] pub fn file_name(&self) -> Option<&str> { self.file_path .as_ref() .and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str())) } pub fn total_lines(&self) -> usize { match &self.loading_state { AppLoadingState::Ready { reader } => reader.line_count(), AppLoadingState::Loading { reader, estimated_lines, .. } => { // Use estimated total lines (not sampled_line_count) so the user can // scroll freely during indexing. get_line() incrementally scans // forward on demand, so lines beyond the initial 64KB are still // accessible. The .max() guards against under-estimates. (*estimated_lines as usize).max(reader.sampled_line_count()) } _ => 0, } } pub fn is_loaded(&self) -> bool { matches!( self.loading_state, AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. } ) } pub fn is_loading(&self) -> bool { matches!(self.loading_state, AppLoadingState::Loading { .. }) } pub fn is_error(&self) -> bool { matches!(self.loading_state, AppLoadingState::Error(_)) } fn get_visual_height_index(&self) -> Option<&VisualHeightIndex> { match &self.loading_state { AppLoadingState::Ready { reader } => match &reader.state { log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index, .. } => visual_height_index.as_ref(), _ => None, }, _ => None, } } fn ensure_visual_height_index(&mut self, width: usize) { let needs_rebuild = match self.get_visual_height_index() { Some(idx) => !idx.is_valid_for(self.json_format, width), None => true, }; if needs_rebuild { if let AppLoadingState::Ready { reader } = &mut self.loading_state { reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); } } } pub fn error_message(&self) -> Option<&str> { match &self.loading_state { AppLoadingState::Error(msg) => Some(msg), _ => None, } } pub fn loading_progress(&self) -> Option { match &self.loading_state { AppLoadingState::Loading { progress_percent, .. } => Some(*progress_percent), _ => None, } } pub fn estimated_lines(&self) -> Option { match &self.loading_state { AppLoadingState::Loading { estimated_lines, .. } => Some(*estimated_lines), _ => None, } } #[cfg(test)] pub fn set_error_state(&mut self, msg: impl Into) { self.loading_state = AppLoadingState::Error(msg.into()); } #[allow(dead_code)] pub fn file_size(&self) -> u64 { match &self.loading_state { AppLoadingState::Ready { reader } => { reader.reader().map_or(0, |r| r.file_size()) } AppLoadingState::Loading { reader, .. } => { reader.reader().map_or(0, |r| r.file_size()) } _ => 0, } } fn get_content_width(&self) -> usize { if self.content_width > 0 { self.content_width as usize } else { 80 } } pub fn poll_file_watcher(&mut self) { let events: Vec = match &mut self.file_watcher { Some(w) => std::iter::from_fn(|| w.try_recv()).collect(), None => return, }; for event in events { match event { FileEvent::Appended { new_size: _ } => { self.handle_file_appended(); } FileEvent::Truncated { new_size: _ } => { self.handle_file_truncated(); } FileEvent::Rotated { new_inode: _ } => { // Don't auto-switch; old content preserved. // User can reload manually if desired. } } } } fn handle_file_appended(&mut self) { let width = self.get_content_width(); match &mut self.loading_state { AppLoadingState::Ready { reader } => { if let Ok(_new_lines) = reader.update_for_append() { let _ = reader.save_cache(); let (old_line_count, can_extend) = { match &reader.state { log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index: Some(idx), .. } => (idx.line_count(), idx.is_valid_for(self.json_format, width)), _ => (0, false), } }; let new_line_count = reader.line_count(); if can_extend && new_line_count > old_line_count { if let log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index: Some(index), reader: fr, } = &mut reader.state { let mut new_heights = Vec::with_capacity(new_line_count - old_line_count); for i in old_line_count..new_line_count { let line_text = fr.get_line(i).unwrap_or(""); new_heights.push(compute_line_visual_height( line_text, width, self.json_format, )); } index.extend_from_heights(&new_heights); } } else { reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); } self.viewport_cache.invalidate(); } } _ => {} } } fn handle_file_truncated(&mut self) { let width = self.get_content_width(); match &mut self.loading_state { AppLoadingState::Ready { reader } => { let _ = reader.reload(); let _ = reader.save_cache(); reader.invalidate_visual_height_index(); reader.start_visual_height_rebuild(width, self.json_format); self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1)); self.viewport_cache.invalidate(); } _ => {} } } /// Poll the background indexer for progress/completion. /// Transitions Loading → Ready when indexing completes. /// Must be called every frame in the event loop. pub fn poll_background_indexer(&mut self) { // Poll visual height rebuild (Ready state only) if let AppLoadingState::Ready { reader } = &mut self.loading_state { if let Some(index) = reader.poll_visual_height_rebuild() { if let log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index, .. } = &mut reader.state { *visual_height_index = Some(index); self.viewport_cache.invalidate(); } } } // Poll main indexer (Loading state) let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty); if let AppLoadingState::Loading { mut reader, estimated_lines, mut progress_percent, } = old_state { if let Some(msg) = reader.poll_indexer() { match msg { IndexerMessage::Progress { percent, .. } => { progress_percent = percent; self.loading_state = AppLoadingState::Loading { reader, estimated_lines, progress_percent, }; } IndexerMessage::Complete { reader: fr, visual_height_index, .. } => { let saved_cursor = self.cursor_line; reader.set_ready(fr, visual_height_index); self.loading_state = AppLoadingState::Ready { reader }; self.viewport_cache.invalidate(); // Clamp cursor if exact count < estimated self.cursor_line = saved_cursor.min(self.total_lines().saturating_sub(1)); // Loading uses 1:1 logical-line offsets; Ready uses visual-row // offsets derived from the prefix-sum index. Recompute v_offset // so the same logical line stays visible (falls back to 1:1 when // the index is absent, which is the case right after invalidate). self.v_offset = self.cursor_to_first_visual_row(self.cursor_line); self.clamp_v_offset(); self.v_sub_offset = 0; // Gutter width changes (~N → N) shift content_width, so any // VisualHeightIndex built with the old width is stale. if let AppLoadingState::Ready { reader } = &mut self.loading_state { reader.invalidate_visual_height_index(); } } IndexerMessage::Error { message, .. } => { self.loading_state = AppLoadingState::Error(message); } } } else { self.loading_state = AppLoadingState::Loading { reader, estimated_lines, progress_percent, }; } } else { self.loading_state = old_state; } } } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static COUNTER: AtomicU64 = AtomicU64::new(0); fn make_temp_file(content: &str) -> std::path::PathBuf { let id = COUNTER.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir(); let name = format!("log_viewer_test_{}_{}", std::process::id(), id); let path = dir.join(name); std::fs::write(&path, content).unwrap(); path } fn cleanup(path: &std::path::Path) { let _ = std::fs::remove_file(path); } fn load_file_ready(app: &mut App, path: &std::path::Path) { let data = std::fs::read(path).unwrap(); let index = log_viewer_core::io::line_index::LineIndex::from_bytes(&data); let _ = log_viewer_core::io::index_cache::IndexCache::save(path, &index); app.load_file(path.to_str().unwrap()).unwrap(); } #[test] fn test_app_new_defaults() { let app = App::new(); assert!(!app.should_quit); assert!(!app.is_loaded()); assert!(app.file_path.is_none()); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); assert!(app.viewport_cache.entries.is_empty()); assert_eq!(app.viewport_cache.width, 0); assert_eq!(app.content_width, 0); assert_eq!(app.content_height, 0); assert!(app.last_g_press.is_none()); } #[test] fn test_load_file_success() { let path = make_temp_file("line1\nline2\nline3\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); assert!(app.load_file(path.to_str().unwrap()).is_ok()); assert!(app.is_loaded()); assert_eq!(app.total_lines(), 3); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_state() { let path = make_temp_file("a\nb\nc\nd\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.cursor_line = 3; app.v_offset = 2; let path2 = make_temp_file("x\ny\n"); app.load_file(path2.to_str().unwrap()).unwrap(); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); assert_eq!(app.total_lines(), 2); cleanup(&path2); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_nonexistent() { let mut app = App::new(); let result = app.load_file("/tmp/no_such_file_log_viewer_test_xyz"); assert!(result.is_err()); assert!(!app.is_loaded()); } #[test] fn test_unloaded_scroll_safety() { let mut app = App::new(); // None of these should panic app.scroll_down_line(); app.scroll_up_line(); app.scroll_down_half_page(); app.scroll_up_half_page(); app.scroll_down_page(); app.scroll_up_page(); app.scroll_to_top(); app.scroll_to_bottom(); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); } #[test] fn test_empty_file_scroll_safety() { let path = make_temp_file(""); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.scroll_down_line(); app.scroll_up_line(); app.scroll_down_half_page(); app.scroll_up_half_page(); app.scroll_down_page(); app.scroll_up_page(); app.scroll_to_top(); app.scroll_to_bottom(); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_scroll_down_at_bottom() { let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.cursor_line = 2; // last line app.v_offset = 2; app.scroll_down_line(); assert_eq!(app.cursor_line, 2); // stays at last }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_scroll_up_at_top() { let path = make_temp_file("a\nb\nc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert_eq!(app.cursor_line, 0); app.scroll_up_line(); assert_eq!(app.cursor_line, 0); // stays at 0 }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_viewport_cache_correctness() { let long_line = "a".repeat(200); let path = make_temp_file(&format!("{long_line}\nshort\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; // enough viewport app.ensure_viewport_cache(20); // Long line should wrap into multiple visual rows let entry0 = app.viewport_cache.get_entry(0).unwrap(); assert!( entry0.visual_height > 1, "expected wrapping but got height {}", entry0.visual_height ); // Short line is 1 row let entry1 = app.viewport_cache.get_entry(1).unwrap(); assert_eq!(entry1.visual_height, 1); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_ensure_viewport_cache_not_loaded() { let mut app = App::new(); app.ensure_viewport_cache(80); // Should not panic and cache should stay empty assert!(app.viewport_cache.entries.is_empty()); } #[test] fn test_ensure_viewport_cache_zero_width() { let path = make_temp_file("hello\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.ensure_viewport_cache(0); // Should not panic, cache should stay empty (width 0 is guarded) assert!(app.viewport_cache.entries.is_empty()); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_viewport_cache() { let path = make_temp_file("hello\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.viewport_cache.width = 80; app.load_file(path.to_str().unwrap()).unwrap(); assert_eq!(app.viewport_cache.width, 0); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_gg_state() { let path = make_temp_file("hello\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.last_g_press = Some(Instant::now()); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.last_g_press.is_none()); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_json_format_default_off() { let app = App::new(); assert!(!app.json_format); } #[test] fn test_tab_toggles_json_format() { let path = make_temp_file("test\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); app.handle_key(tab); assert!(app.json_format, "Tab should toggle json_format to true"); assert_eq!(app.viewport_cache.width, 0, "Tab should invalidate viewport cache"); app.handle_key(tab); assert!(!app.json_format, "Second Tab should toggle back to false"); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_load_file_resets_json_format() { let path = make_temp_file("test\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); app.json_format = true; let path2 = make_temp_file("test2\n"); app.load_file(path2.to_str().unwrap()).unwrap(); assert!( !app.json_format, "load_file should reset json_format to false" ); cleanup(&path2); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_format_json_line_valid() { let result = format_json_line(r#"{"a":1,"b":2}"#); assert!( result.contains('\n'), "formatted JSON should contain newlines" ); assert!( result.contains(" "), "formatted JSON should contain 2-space indentation" ); assert!( result.contains("\"a\""), "formatted JSON should contain key a" ); assert!( result.contains("\"b\""), "formatted JSON should contain key b" ); } #[test] fn test_format_json_line_invalid() { let input = "hello world"; let result = format_json_line(input); assert_eq!(result, input, "plain text should be returned unchanged"); } #[test] fn test_format_json_line_empty() { let result = format_json_line(""); assert_eq!(result, "", "empty input should return empty string"); } #[test] fn test_format_json_line_array() { let input = "[1,2,3]"; let result = format_json_line(input); assert_eq!(result, input, "JSON array should be returned unchanged"); } #[test] fn test_format_json_line_primitives() { assert_eq!( format_json_line(r#""hello""#), r#""hello""#, "JSON string should be unchanged" ); assert_eq!( format_json_line("42"), "42", "JSON number should be unchanged" ); assert_eq!( format_json_line("true"), "true", "JSON boolean should be unchanged" ); assert_eq!( format_json_line("null"), "null", "JSON null should be unchanged" ); } #[test] fn test_format_json_line_whitespace_prefix() { let input = r#" {"a":1}"#; let result = format_json_line(input); assert!( result.contains('\n'), "JSON with leading whitespace should be formatted" ); assert!( result.contains("\"a\""), "formatted result should contain key a" ); } #[test] fn test_viewport_cache_with_json_format() { let json_content = r#"{"key1":"value1","key2":"value2"} plain text line {"nested":{"inner":true}} "#; let path = make_temp_file(json_content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; // First without formatting app.ensure_viewport_cache(80); let raw_heights: Vec = app .viewport_cache .entries .iter() .map(|e| e.visual_height) .collect(); // Now with formatting app.json_format = true; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); // JSON lines (0, 2) should have more visual rows when formatted let entry0 = app.viewport_cache.get_entry(0).unwrap(); let entry1 = app.viewport_cache.get_entry(1).unwrap(); let entry2 = app.viewport_cache.get_entry(2).unwrap(); assert!( entry0.visual_height > raw_heights[0], "JSON line 0 should have more visual rows when formatted" ); assert_eq!( entry1.visual_height, raw_heights[1], "Plain text line should have same visual rows" ); assert!( entry2.visual_height > raw_heights[2], "JSON line 2 should have more visual rows when formatted" ); // Check that wrapped rows contain indentation let json_line_wrapped: String = entry0.wrapped_rows.join(""); assert!( json_line_wrapped.contains(" "), "Formatted JSON wrap cache should contain indentation" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_viewport_cache_toggle_restore() { let json_content = r#"{"a":1,"b":2} "#; let path = make_temp_file(json_content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; // Compute raw wrap cache app.ensure_viewport_cache(80); let raw_rows: Vec> = app .viewport_cache .entries .iter() .map(|e| e.wrapped_rows.clone()) .collect(); // Toggle on: formatted app.json_format = true; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); let formatted_rows: Vec> = app .viewport_cache .entries .iter() .map(|e| e.wrapped_rows.clone()) .collect(); // Formatted should differ from raw assert_ne!( raw_rows, formatted_rows, "Formatted wrap cache should differ from raw" ); // Toggle off: back to raw app.json_format = false; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); let restored_rows: Vec> = app .viewport_cache .entries .iter() .map(|e| e.wrapped_rows.clone()) .collect(); assert_eq!( raw_rows, restored_rows, "Toggle off should restore original wrap cache" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_tab_toggle_keeps_cursor_visible() { let mut content = String::new(); for i in 0..100 { content.push_str(&format!(r#"{{"line":{},"data":"value{}"}}"#, i, i)); content.push('\n'); } let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 20; app.ensure_viewport_cache(80); let entry0 = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry0.visual_height, 1); app.cursor_line = 50; app.ensure_cursor_visible(); let cursor_visual = app.cursor_to_first_visual_row(50); assert!( cursor_visual >= app.v_offset && cursor_visual < app.v_offset + app.content_height as usize, "cursor should be visible before Tab: cursor_visual={}, v_offset={}, content_height={}", cursor_visual, app.v_offset, app.content_height, ); let v_offset_before = app.v_offset; app.json_format = true; app.viewport_cache.invalidate(); app.ensure_viewport_cache(80); let height_0 = app.compute_visual_height(0, 80); assert!( height_0 > 1, "JSON lines should expand when formatted" ); let cursor_visual_after = app.cursor_to_first_visual_row(50); assert!( cursor_visual_after >= app.v_offset && cursor_visual_after < app.v_offset + app.content_height as usize, "cursor should be visible after Tab: cursor_visual={}, v_offset={}, content_height={}, v_offset_before={}", cursor_visual_after, app.v_offset, app.content_height, v_offset_before, ); assert_ne!( app.v_offset, v_offset_before, "v_offset should change after JSON formatting toggle (cursor was on line 50)" ); }); cleanup(&path); assert!(result.is_ok()); } // ── Task 3 tests: AppMode, color_config ─────────────────────── #[test] fn test_color_config_default_in_new() { let app = App::new(); assert_eq!(app.color_config, ColorConfig::default()); } #[test] fn test_app_mode_default_normal() { let app = App::new(); assert_eq!(app.mode, AppMode::Normal); } #[test] fn test_s_key_enters_settings() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut app = App::new(); let s_key = KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE); app.handle_key(s_key); assert_eq!(app.mode, AppMode::Settings); } #[test] fn test_load_file_resets_mode() { let path = make_temp_file("test\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.mode = AppMode::Settings; app.load_file(path.to_str().unwrap()).unwrap(); assert_eq!(app.mode, AppMode::Normal); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_populated() { let path = make_temp_file("ERROR: fail\nWARN: maybe\njust text\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); assert!(!app.viewport_cache.entries.is_empty()); assert_eq!(app.viewport_cache.entries.len(), 3); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_cleared_on_reload() { let path_a = make_temp_file("ERROR: a\nINFO: b\n"); let path_b = make_temp_file("WARN: c\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path_a.to_str().unwrap()).unwrap(); app.content_height = 50; app.ensure_viewport_cache(80); assert_eq!(app.viewport_cache.entries.len(), 2); app.load_file(path_b.to_str().unwrap()).unwrap(); app.ensure_viewport_cache(80); assert_eq!(app.viewport_cache.entries.len(), 1); cleanup(&path_b); }); cleanup(&path_a); assert!(result.is_ok()); } #[test] fn test_level_cache_json_level() { let line = r#"{"level":"ERROR","message":"fail"}"#; let path = make_temp_file(&format!("{line}\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); let entry = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry.level, Some(LogLevel::Error)); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_plain_text() { let path = make_temp_file("ERROR: something\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); let entry = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry.level, Some(LogLevel::Error)); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_uses_raw_line() { let json_line = r#"{"level":"WARN","msg":"careful"}"#; let path = make_temp_file(&format!("{json_line}\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.json_format = true; app.content_height = 50; app.ensure_viewport_cache(80); let entry = app.viewport_cache.get_entry(0).unwrap(); assert_eq!(entry.level, Some(LogLevel::Warn)); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_level_cache_entries_cover_all_lines() { let path = make_temp_file("ERROR: a\nINFO: b\nDEBUG: c\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 50; app.ensure_viewport_cache(80); assert_eq!( app.viewport_cache.entries.len(), 3, "viewport cache entries should cover all lines" ); }); cleanup(&path); assert!(result.is_ok()); } // ── Task 5 tests: Settings panel ──────────────────────────────── fn make_key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) } fn enter_settings(app: &mut App) { app.handle_key(make_key(crossterm::event::KeyCode::Char('s'))); } #[test] fn test_settings_enter() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.mode, AppMode::Settings); } #[test] fn test_settings_esc_cancel() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.settings_draft, app.color_config); app.handle_key(make_key(crossterm::event::KeyCode::Right)); app.handle_key(make_key(crossterm::event::KeyCode::Esc)); assert_eq!(app.mode, AppMode::Normal); assert_eq!( app.color_config, ColorConfig::default(), "Esc should NOT save changes" ); } #[test] fn test_settings_enter_save() { let mut app = App::new(); let original = app.color_config.clone(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_ne!( app.settings_draft.error, original.error, "Right should change draft" ); app.handle_key(make_key(crossterm::event::KeyCode::Enter)); assert_eq!(app.mode, AppMode::Normal); assert_eq!( app.color_config, app.settings_draft, "Enter should save draft to config" ); } #[test] fn test_settings_navigation() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.settings_cursor, 0); app.handle_key(make_key(crossterm::event::KeyCode::Char('j'))); assert_eq!(app.settings_cursor, 1); app.handle_key(make_key(crossterm::event::KeyCode::Down)); assert_eq!(app.settings_cursor, 2); app.handle_key(make_key(crossterm::event::KeyCode::Char('k'))); assert_eq!(app.settings_cursor, 1); app.handle_key(make_key(crossterm::event::KeyCode::Up)); assert_eq!(app.settings_cursor, 0); } #[test] fn test_settings_cycle_color() { let mut app = App::new(); enter_settings(&mut app); assert_eq!(app.settings_draft.error, "red"); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_eq!(app.settings_draft.error, "green"); app.handle_key(make_key(crossterm::event::KeyCode::Left)); assert_eq!(app.settings_draft.error, "red"); } #[test] fn test_settings_number_key() { let mut app = App::new(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Char('1'))); assert_eq!(app.settings_draft.error, "red"); app.handle_key(make_key(crossterm::event::KeyCode::Char('4'))); assert_eq!(app.settings_draft.error, "blue"); app.handle_key(make_key(crossterm::event::KeyCode::Char('8'))); assert_eq!(app.settings_draft.error, "white"); } #[test] fn test_settings_cursor_boundary() { let mut app = App::new(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Char('k'))); assert_eq!(app.settings_cursor, 0, "k at 0 should stay 0"); app.settings_cursor = 5; app.handle_key(make_key(crossterm::event::KeyCode::Char('j'))); assert_eq!(app.settings_cursor, 5, "j at 5 should stay 5"); } #[test] fn test_settings_q_cancels() { let mut app = App::new(); let original = app.color_config.clone(); enter_settings(&mut app); app.handle_key(make_key(crossterm::event::KeyCode::Right)); app.handle_key(make_key(crossterm::event::KeyCode::Char('q'))); assert_eq!(app.mode, AppMode::Normal); assert_eq!(app.color_config, original, "q should NOT save changes"); } #[test] fn test_settings_cycle_unknown_color() { let mut app = App::new(); enter_settings(&mut app); app.settings_draft.error = "not_in_list".to_string(); app.handle_key(make_key(crossterm::event::KeyCode::Right)); assert_eq!( app.settings_draft.error, "red", "unknown color Right should go to first (red)" ); app.settings_draft.error = "not_in_list".to_string(); app.handle_key(make_key(crossterm::event::KeyCode::Left)); assert_eq!( app.settings_draft.error, "white", "unknown color Left should go to last (white)" ); } #[test] fn test_settings_draft_synced_on_enter() { let mut app = App::new(); app.color_config.error = "magenta".to_string(); let expected_draft = app.color_config.clone(); enter_settings(&mut app); assert_eq!( app.settings_draft, expected_draft, "draft should sync from color_config on S press" ); } #[test] fn test_color_change_affects_build_line_spans() { use crate::ui::build_line_spans; use ratatui::style::Color; let mut config = ColorConfig::default(); config.error = "blue".to_string(); let line = build_line_spans( "1 │".to_string(), "hello".to_string(), false, Some(&LogLevel::Error), &config, ); assert_eq!(line.spans[1].style.fg, Some(Color::Blue)); } // ── T14: Seamless Loading→Ready transition ──────────────────────── fn app_in_loading_state( file_path: &std::path::Path, generation: u64, ) -> ( App, crossbeam_channel::Sender, ) { if let Some(cp) = log_viewer_core::io::cache_util::cache_path(file_path) { let _ = std::fs::remove_file(cp); } let (tx, rx) = crossbeam_channel::bounded(10); let (cancel_tx, _cancel_rx) = crossbeam_channel::bounded(1); let reader = ProgressiveFileReader::with_channels( file_path, cancel_tx, rx, generation, ) .unwrap(); let estimated = reader.line_count() as u64; let mut app = App::new(); app.file_path = Some(file_path.to_str().unwrap().to_string()); app.loading_state = AppLoadingState::Loading { reader, estimated_lines: estimated, progress_percent: 0.0, }; (app, tx) } fn file_reader_for(path: &std::path::Path) -> log_viewer_core::io::file_reader::FileReader { log_viewer_core::io::file_reader::FileReader::open(path).unwrap() } #[test] fn test_seamless_transition_preserves_position() { let content: String = (0..100) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&path, 42); app.cursor_line = 50; app.v_offset = 50; let fr = file_reader_for(&path); tx.send(IndexerMessage::Complete { generation: 42, reader: fr, visual_height_index: None, }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading(), "should be Ready"); assert_eq!(app.cursor_line, 50, "cursor preserved at line 50"); assert_eq!(app.total_lines(), 100); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_seamless_transition_clamps_cursor() { let big_content: String = (0..100) .map(|i| format!("line {}\n", i)) .collect(); let small_content: String = (0..80) .map(|i| format!("line {}\n", i)) .collect(); let big_path = make_temp_file(&big_content); let small_path = make_temp_file(&small_content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&big_path, 42); app.cursor_line = 90; app.v_offset = 90; let fr = file_reader_for(&small_path); tx.send(IndexerMessage::Complete { generation: 42, reader: fr, visual_height_index: None, }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading()); assert_eq!(app.total_lines(), 80); assert_eq!(app.cursor_line, 79, "clamped to last valid line"); assert!(app.v_offset <= app.cursor_line); }); cleanup(&big_path); cleanup(&small_path); assert!(result.is_ok()); } #[test] fn test_seamless_transition_v_offset_converted() { let content: String = (0..50) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&path, 7); app.content_height = 20; app.cursor_line = 30; app.v_offset = 30; let fr = file_reader_for(&path); tx.send(IndexerMessage::Complete { generation: 7, reader: fr, visual_height_index: None, }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading()); assert_eq!(app.cursor_line, 30); assert_eq!(app.v_offset, 30, "v_offset = cursor_to_first_visual_row(30)"); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_seamless_transition_invalidates_visual_height_index() { let content: String = (0..30) .map(|i| format!("line {}\n", i)) .collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let (mut app, tx) = app_in_loading_state(&path, 1); let fr = file_reader_for(&path); let visual_heights = vec![1usize; 30]; let vhi = VisualHeightIndex::build(&visual_heights); tx.send(IndexerMessage::Complete { generation: 1, reader: fr, visual_height_index: Some(vhi), }).unwrap(); app.poll_background_indexer(); assert!(!app.is_loading()); assert!( app.get_visual_height_index().is_none(), "visual height index should be invalidated (gutter width change)" ); }); cleanup(&path); assert!(result.is_ok()); } // ── FileWatcher integration tests ──────────────────────────────── #[test] fn test_file_watcher_started_on_load() { let path = make_temp_file("line1\nline2\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!( app.file_watcher.is_some(), "file_watcher should be started after load_file" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_stopped_on_new_file() { let path_a = make_temp_file("aaa\n"); let path_b = make_temp_file("bbb\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path_a.to_str().unwrap()).unwrap(); assert!(app.file_watcher.is_some()); app.load_file(path_b.to_str().unwrap()).unwrap(); // After loading a new file, watcher should be watching the new file assert!( app.file_watcher.is_some(), "file_watcher should be started for new file" ); assert_eq!(app.total_lines(), 1); assert_eq!(app.get_line(0), Some("bbb".to_string())); }); cleanup(&path_a); cleanup(&path_b); assert!(result.is_ok()); } #[test] fn test_file_watcher_stopped_on_nonexistent_file() { let path = make_temp_file("data\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.file_watcher.is_some()); let _ = app.load_file("/tmp/no_such_file_log_viewer_test_xyz_999"); assert!( app.file_watcher.is_none(), "file_watcher should be None after failed load_file" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_append_updates_index() { let path = make_temp_file("line1\nline2\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 2); // Append externally { use std::io::Write; let mut f = std::fs::OpenOptions::new() .append(true) .open(&path) .unwrap(); f.write_all(b"line3\nline4\n").unwrap(); } // Give the watcher time to detect the change std::thread::sleep(std::time::Duration::from_millis(500)); app.poll_file_watcher(); assert_eq!( app.total_lines(), 4, "total_lines should increase after append" ); assert_eq!(app.get_line(2), Some("line3".to_string())); assert_eq!(app.get_line(3), Some("line4".to_string())); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_truncate_reloads() { let path = make_temp_file("aaa\nbbb\nccc\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); assert_eq!(app.total_lines(), 3); app.cursor_line = 2; // Truncate externally { let _ = std::fs::File::create(&path).unwrap(); } // Give the watcher time to detect the change std::thread::sleep(std::time::Duration::from_millis(500)); app.poll_file_watcher(); assert_eq!( app.total_lines(), 0, "total_lines should be 0 after truncate" ); // Cursor should be clamped assert!( app.cursor_line == 0, "cursor should be clamped after truncate" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_file_watcher_no_events_no_change() { let path = make_temp_file("stable\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); let lines_before = app.total_lines(); std::thread::sleep(std::time::Duration::from_millis(200)); app.poll_file_watcher(); assert_eq!( app.total_lines(), lines_before, "no events should mean no change" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_poll_file_watcher_no_watcher() { let mut app = App::new(); // Should not panic app.poll_file_watcher(); } // ── Loading + JSON expansion tests ───────────────────────────── #[test] fn test_tab_toggle_during_loading() { let content = "line1\n{\"ts\":\"2025\",\"level\":\"ERROR\",\"msg\":\"hello\"}\nline3\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading(), "should be in Loading state"); use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); app.handle_key(tab); assert!(app.json_format, "Tab should toggle json_format to true during Loading"); assert_eq!( app.viewport_cache.width, 0, "Tab should invalidate viewport cache (width==0)" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_expanded_visual_height() { let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; let content = format!("line1\n{json_line}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; let height = app.compute_visual_height(1, 40); assert!( height > 1, "JSON expanded line should have visual_height > 1, got {height}" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_viewport_contains_cursor() { let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; let mut lines: Vec = (0..50).map(|i| format!("line{i}")).collect(); lines.push(json_line.to_string()); lines.push("end".to_string()); let content = lines.join("\n") + "\n"; let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; app.cursor_line = 50; app.ensure_viewport_cache(80); let entries = &app.viewport_cache.entries; let first = app.viewport_cache.logical_start; let last_logical = first + entries.len().saturating_sub(1); assert!( app.cursor_line >= first && app.cursor_line <= last_logical, "cursor_line {} should be within viewport range [{}, {}]", app.cursor_line, first, last_logical ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_scroll_down_line() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\nline4\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; assert_eq!(app.cursor_line, 0); app.scroll_down_line(); assert_eq!( app.cursor_line, 1, "scroll_down_line should increment cursor_line by 1" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_scroll_half_page() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\nline4\nline5\nline6\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; // Should not panic app.scroll_down_half_page(); let total = app.total_lines(); assert!( app.cursor_line < total, "cursor_line {} should be < total_lines {}", app.cursor_line, total ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_scroll_to_bottom() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\nline4\nline5\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; // Should not panic app.scroll_to_bottom(); assert_eq!( app.cursor_line, app.total_lines() - 1, "scroll_to_bottom should set cursor_line to last line" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_to_ready_preserves_json_format() { let json_line = r#"{"timestamp":"2025-04-14T10:00:00.000Z","level":"INFO","message":"test"}"#; let content = format!("line1\n{json_line}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading(), "should start in Loading state"); // load_file resets json_format to false app.json_format = true; app.content_height = 24; // Manually transition Loading → Ready let old_state = std::mem::replace(&mut app.loading_state, AppLoadingState::Empty); if let AppLoadingState::Loading { reader, .. } = old_state { app.loading_state = AppLoadingState::Ready { reader }; } app.viewport_cache.invalidate(); assert!(!app.is_loading(), "should now be in Ready state"); app.ensure_viewport_cache(80); assert!( app.json_format, "json_format should remain true after Loading→Ready transition" ); let has_expanded = app.viewport_cache.entries.iter().any(|e| e.visual_height > 1); assert!( has_expanded, "viewport should contain entries with visual_height > 1 (JSON expanded)" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_tab_toggle_off_during_loading() { let json_line = r#"{"ts":"2025","level":"ERROR","msg":"hello"}"#; let content = format!("line1\n{json_line}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE); // Tab ON app.handle_key(tab); assert!(app.json_format, "first Tab should set json_format=true"); // Tab OFF app.handle_key(tab); assert!(!app.json_format, "second Tab should set json_format=false"); app.content_height = 24; app.ensure_viewport_cache(80); for (i, entry) in app.viewport_cache.entries.iter().enumerate() { assert_eq!( entry.visual_height, 1, "entry {} should have height 1 with json_format off", i ); } }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_empty_line() { let content = "line1\n\nline3\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; let height = app.compute_visual_height(1, 80); assert_eq!( height, 1, "empty line should have visual_height 1, got {height}" ); }); cleanup(&path); assert!(result.is_ok()); } #[test] fn test_loading_json_deep_nested() { let deep_json = r#"{"a":{"b":{"c":{"d":"value"}}}}"#; let content = format!("line1\n{deep_json}\nline3\n"); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); app.load_file(path.to_str().unwrap()).unwrap(); assert!(app.is_loading()); app.json_format = true; app.content_height = 24; app.ensure_viewport_cache(20); let json_height = app.compute_visual_height(1, 20); assert!( json_height > 1, "deep nested JSON at width 20 should have visual_height > 1, got {json_height}" ); let total_rows: usize = app.viewport_cache.entries.iter().map(|e| e.visual_height).sum(); assert!( total_rows <= app.content_height as usize + 2, "viewport visual rows ({total_rows}) should not wildly exceed content_height ({})", app.content_height ); }); cleanup(&path); assert!(result.is_ok()); } fn install_vhi(app: &mut App, heights: &[usize]) { let vhi = VisualHeightIndex::build(heights); if let AppLoadingState::Ready { reader } = &mut app.loading_state { if let log_viewer_core::io::progressive_reader::ReaderState::Ready { visual_height_index, .. } = &mut reader.state { *visual_height_index = Some(vhi); } } } #[test] fn test_vhi_scroll_down_line_visual_row() { let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; // Each line wraps to 3 visual rows → 30 total, overflows viewport of 10 install_vhi(&mut app, &[3usize; 10]); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); for _ in 0..5 { app.scroll_down_line(); } assert_eq!(app.v_offset, 5, "v_offset should be 5 after 5 visual scrolls"); // center_visual = 5 + 10/2 = 10 → maps to logical line 3 (visual rows: line0=0-2, line1=3-5, line2=6-8, line3=9-11) assert_eq!(app.cursor_line, 3, "cursor should track center at logical line 3"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_scroll_up_line_visual_row() { let content = "line0\nline1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; install_vhi(&mut app, &[3usize; 10]); // Start at v_offset=5, cursor_line=3 (matching center) app.v_offset = 5; app.cursor_line = 3; for _ in 0..5 { app.scroll_up_line(); } assert_eq!(app.v_offset, 0, "v_offset should return to 0"); // After scrolling back up, center_visual = 0 + 5 = 5 → line1 assert!(app.cursor_line <= 2, "cursor should be near top, got {}", app.cursor_line); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_scroll_down_line_small_file_fallback() { let content = "a\nb\nc\n"; let path = make_temp_file(content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 24; // 3 lines, 1 row each → 3 visual rows, fits in viewport of 24 install_vhi(&mut app, &[1usize; 3]); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); app.scroll_down_line(); assert_eq!(app.cursor_line, 1, "cursor should move to line 1 (logical)"); assert_eq!(app.v_offset, 0, "v_offset should stay 0 (content fits)"); cleanup(&path); }); assert!(result.is_ok()); } #[test] fn test_vhi_scroll_j_k_roundtrip() { let content: String = (0..20).map(|i| format!("line{}\n", i)).collect(); let path = make_temp_file(&content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); load_file_ready(&mut app, &path); app.content_height = 10; // 20 lines × 2 rows = 40 visual rows, viewport = 10 install_vhi(&mut app, &[2usize; 20]); let initial_cursor = app.cursor_line; let initial_offset = app.v_offset; for _ in 0..15 { app.scroll_down_line(); } assert!(app.v_offset > 0, "v_offset should have moved down"); for _ in 0..15 { app.scroll_up_line(); } assert_eq!(app.v_offset, initial_offset, "v_offset should roundtrip to {}", initial_offset); assert!( app.cursor_line <= initial_cursor + 3, "cursor should return near top, got {}, expected <= {}", app.cursor_line, initial_cursor + 3 ); cleanup(&path); }); assert!(result.is_ok()); } fn app_in_loading_with_long_lines(app: &mut App, line_count: usize, line_width: usize) -> std::path::PathBuf { let content: String = (0..line_count) .map(|_| "x".repeat(line_width)) .collect::>() .join("\n"); let path = make_temp_file(&content); app.load_file(path.to_str().unwrap()).unwrap(); path } #[test] fn test_loading_scroll_down_advances_sub_offset() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut app = App::new(); let path = app_in_loading_with_long_lines(&mut app, 10000, 200); app.content_height = 24; if !app.is_loading() { cleanup(&path); return; } assert_eq!(app.v_offset, 0); assert_eq!(app.v_sub_offset, 0); app.scroll_down_line(); assert_eq!(app.v_offset, 0, "v_offset should stay 0 after first j"); assert_eq!(app.v_sub_offset, 1, "v_sub_offset should advance to 1"); app.scroll_down_line(); assert_eq!(app.v_offset, 0, "v_offset should stay 0 after second j"); assert_eq!(app.v_sub_offset, 2, "v_sub_offset should advance to 2"); app.scroll_down_line(); assert_eq!(app.v_offset, 1, "v_offset should advance to 1"); assert_eq!(app.v_sub_offset, 0, "v_sub_offset should reset to 0"); cleanup(&path); })); assert!(result.is_ok()); } #[test] fn test_loading_scroll_up_decrements_sub_offset() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut app = App::new(); let path = app_in_loading_with_long_lines(&mut app, 10000, 200); app.content_height = 24; if !app.is_loading() { cleanup(&path); return; } // Scroll down to v_offset=1, v_sub_offset=0 for _ in 0..3 { app.scroll_down_line(); } assert_eq!(app.v_offset, 1); assert_eq!(app.v_sub_offset, 0); app.scroll_up_line(); assert_eq!(app.v_offset, 0, "v_offset should go back to 0"); assert!(app.v_sub_offset > 0, "v_sub_offset should be at end of line 0"); let prev_sub = app.v_sub_offset; app.scroll_up_line(); assert_eq!(app.v_offset, 0, "v_offset should stay 0"); assert_eq!(app.v_sub_offset, prev_sub - 1); cleanup(&path); })); assert!(result.is_ok()); } #[test] fn test_loading_scroll_to_top_resets_sub_offset() { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let mut app = App::new(); let path = app_in_loading_with_long_lines(&mut app, 10000, 200); app.content_height = 24; if !app.is_loading() { cleanup(&path); return; } for _ in 0..5 { app.scroll_down_line(); } assert!(app.v_offset > 0 || app.v_sub_offset > 0); app.scroll_to_top(); assert_eq!(app.v_offset, 0, "v_offset should be 0 after scroll_to_top"); assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after scroll_to_top"); cleanup(&path); })); assert!(result.is_ok()); } }