From bab3d9078a1b2821415e36faa153ab9538a42962 Mon Sep 17 00:00:00 2001 From: dailz Date: Tue, 14 Apr 2026 09:07:18 +0800 Subject: [PATCH] feat(tui): replace O(N) scanning with progressive loading and VisualHeightIndex Replace synchronous file loading with AppLoadingState state machine (Empty/Loading/Ready/Error) for instant interactivity. Add ViewportCache for on-demand viewport computation, replacing global wrap/level caches. Integrate background indexer polling and file watcher events into the TUI event loop. Add loading UI with progress percentage, estimated line numbers with ~ prefix, and error state display. Eliminate all O(N) linear scans using VisualHeightIndex binary search. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- crates/tui/Cargo.toml | 1 + crates/tui/src/app.rs | 1148 ++++++++++++++++++++++++++++++++-------- crates/tui/src/main.rs | 2 + crates/tui/src/ui.rs | 261 +++++++-- 4 files changed, 1161 insertions(+), 251 deletions(-) diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 7912dc8..dcf3756 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -10,6 +10,7 @@ clap.workspace = true anyhow.workspace = true log-viewer-core.workspace = true serde_json.workspace = true +crossbeam-channel.workspace = true [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 1c83175..a7dbd45 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -2,8 +2,13 @@ use std::path::Path; use std::time::Instant; use log_viewer_core::config::ColorConfig; -use log_viewer_core::io::file_reader::FileReader; +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; @@ -13,79 +18,82 @@ pub(crate) enum AppMode { Settings, } -/// Split a line into chunks of exactly `width` characters (display columns). -/// For a log viewer, we want character-level wrapping, not word-level. -fn wrap_line_chars(line: &str, width: usize) -> Vec { - if width == 0 { - return vec![String::new()]; - } - if line.is_empty() { - return vec![String::new()]; - } - let mut result = Vec::new(); - let mut row = String::new(); - let mut col = 0; - for ch in line.chars() { - let w = if ch == '\t' { 4 } else { 1 }; - if col + w > width && !row.is_empty() { - result.push(std::mem::take(&mut row)); - col = 0; - } - if ch == '\t' { - row.push_str(" "); - col += 4; - } else { - row.push(ch); - col += w; - } - if col >= width { - result.push(std::mem::take(&mut row)); - col = 0; - } - } - if !row.is_empty() { - result.push(row); - } - if result.is_empty() { - result.push(String::new()); - } - result +pub(crate) enum AppLoadingState { + Empty, + Loading { + reader: ProgressiveFileReader, + estimated_lines: u64, + progress_percent: f64, + }, + Ready { + reader: ProgressiveFileReader, + }, + Error(String), } -/// Format a line as pretty-printed JSON if it's a JSON Object. -/// Returns the original line unchanged for non-JSON or non-Object content. -fn format_json_line(line: &str) -> String { - if line.trim().is_empty() { - return String::new(); - } - // Quick pre-check: only try parsing if it starts with '{' - if !line.trim_start().starts_with('{') { - return line.to_string(); - } - match serde_json::from_str::(line) { - Ok(value) if value.is_object() => { - serde_json::to_string_pretty(&value).unwrap_or_else(|_| line.to_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 } - _ => line.to_string(), } } +// ── App ────────────────────────────────────────────────────────── + pub struct App { pub should_quit: bool, // File state - file_reader: Option, + loading_state: AppLoadingState, pub(crate) file_path: Option, // Scroll state pub(crate) cursor_line: usize, pub(crate) v_offset: usize, - // Soft wrap cache - pub(crate) wrap_cache: Vec>, - pub(crate) visual_heights: Vec, - pub(crate) total_visual_rows: usize, - pub(crate) cached_width: usize, + // Viewport cache (on-demand, viewport-sized) + pub(crate) viewport_cache: ViewportCache, // Viewport #[allow(dead_code)] @@ -98,108 +106,197 @@ pub struct App { // JSON formatting pub(crate) json_format: bool, - // Mode & level coloring + // Mode & coloring pub(crate) mode: AppMode, - pub(crate) level_cache: Vec>, 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, - file_reader: None, + loading_state: AppLoadingState::Empty, file_path: None, cursor_line: 0, v_offset: 0, - wrap_cache: Vec::new(), - visual_heights: Vec::new(), - total_visual_rows: 0, - cached_width: 0, + viewport_cache: ViewportCache::new(), content_width: 0, content_height: 0, last_g_press: None, json_format: false, mode: AppMode::Normal, - level_cache: Vec::new(), color_config: ColorConfig::default(), settings_cursor: 0, settings_draft: ColorConfig::default(), + file_watcher: None, } } pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> { - let reader = FileReader::open(Path::new(path)).map_err(|e| anyhow::anyhow!("{e}"))?; - self.file_reader = Some(reader); + // 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.wrap_cache.clear(); - self.visual_heights.clear(); - self.total_visual_rows = 0; - self.cached_width = 0; // force wrap cache rebuild on next render + self.viewport_cache.invalidate(); self.last_g_press = None; // reset gg state machine self.json_format = false; - self.level_cache.clear(); self.mode = AppMode::Normal; Ok(()) } - #[allow(dead_code)] - pub fn recompute_wrap_cache(&mut self, width: usize) { - if !self.is_loaded() || width == 0 { - return; + // ── 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)); } - if self.cached_width == width { - return; + 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 { + if self.is_loading() { + return 1; // sampling mode: 1 visual row per line + } + 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 line_count = self.total_lines(); - self.wrap_cache.clear(); - self.visual_heights.clear(); - self.visual_heights.reserve(line_count); - self.level_cache.clear(); + let params_changed = self.viewport_cache.needs_recompute(width, self.json_format); - for i in 0..line_count { - let raw = self.get_line(i).unwrap_or("").to_owned(); - let level = log_viewer_core::parser::level::detect_level(&raw); - self.level_cache.push(level); - let display_text = if self.json_format { - format_json_line(&raw) - } else { - raw - }; - // Critical: to_string_pretty returns a single string with \n, - // but wrap_line_chars doesn't handle \n. Must split on \n first, - // then wrap each sub-line individually. - let mut wrapped = Vec::new(); - for sub_line in display_text.split('\n') { - wrapped.extend(wrap_line_chars(sub_line, width)); + 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() { + // Sampling: 1 visual row per line + (v_offset.min(self.total_lines().saturating_sub(1)), 0) + } 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 height = wrapped.len().max(1); - self.wrap_cache.push(wrapped); - self.visual_heights.push(height); + let entry = self.compute_line_entry(line_idx, width); + rows_remaining = rows_remaining.saturating_sub(entry.visual_height); + self.viewport_cache.entries.push(entry); } - self.total_visual_rows = self.visual_heights.iter().sum(); - self.cached_width = width; + (start_logical, offset_in_line) + } - // Clamp v_offset - let max_offset = self - .total_visual_rows - .saturating_sub(self.content_height as usize); - self.v_offset = self.v_offset.min(max_offset); - - // Center on cursor (NOT ensure_cursor_visible — that does minimum - // scroll and would jump cursor to viewport edge on format toggle). - 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(); + /// 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 ────────────────────────────────────────────── @@ -299,11 +396,11 @@ impl App { return; } let cursor_first = self.cursor_to_first_visual_row(self.cursor_line); - let height = self - .visual_heights - .get(self.cursor_line) - .copied() - .unwrap_or(1); + 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; @@ -318,25 +415,29 @@ impl App { fn clamp_v_offset(&mut self) { let max_offset = self - .total_visual_rows + .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 { - self.visual_heights.iter().take(line).sum() + 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 { - let mut acc: usize = 0; - for (i, &h) in self.visual_heights.iter().enumerate() { - if acc.saturating_add(h) > visual_row { - return i; - } - acc += h; + if self.is_loading() { + return visual_row.min(self.total_lines().saturating_sub(1)); } - // Boundary: visual_row >= total_visual_rows → return last line - self.visual_heights.len().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 ──────────────────────────────────────────────── @@ -407,8 +508,17 @@ impl App { self.last_g_press = None; } KeyCode::Tab => { - self.json_format = !self.json_format; - self.cached_width = 0; // Force wrap cache rebuild + if !self.is_loading() { + 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') @@ -506,8 +616,12 @@ impl App { // ── Utility methods ───────────────────────────────────────────── #[allow(dead_code)] - pub fn get_line(&self, idx: usize) -> Option<&str> { - self.file_reader.as_ref().and_then(|r| r.get_line(idx)) + 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)] @@ -518,16 +632,270 @@ impl App { } pub fn total_lines(&self) -> usize { - self.file_reader.as_ref().map_or(0, |r| r.line_count()) + match &self.loading_state { + AppLoadingState::Ready { reader } => reader.line_count(), + AppLoadingState::Loading { reader, .. } => { + reader.sampled_line_count() + } + _ => 0, + } } pub fn is_loaded(&self) -> bool { - self.file_reader.is_some() + 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 { - self.file_reader.as_ref().map_or(0, |r| r.file_size()) + 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(); + + // 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; + } } } @@ -551,18 +919,23 @@ mod tests { 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.file_reader.is_none()); + assert!(!app.is_loaded()); assert!(app.file_path.is_none()); assert_eq!(app.cursor_line, 0); assert_eq!(app.v_offset, 0); - assert!(app.wrap_cache.is_empty()); - assert!(app.visual_heights.is_empty()); - assert_eq!(app.total_visual_rows, 0); - assert_eq!(app.cached_width, 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()); @@ -632,7 +1005,6 @@ mod tests { let path = make_temp_file(""); let result = std::panic::catch_unwind(|| { let mut app = App::new(); - // FileReader on empty file should have 0 lines (or 1 empty line depending on impl) app.load_file(path.to_str().unwrap()).unwrap(); app.scroll_down_line(); @@ -679,59 +1051,60 @@ mod tests { } #[test] - fn test_wrap_cache_correctness() { + 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(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.content_height = 50; // enough viewport - app.recompute_wrap_cache(20); + app.ensure_viewport_cache(20); // Long line should wrap into multiple visual rows + let entry0 = app.viewport_cache.get_entry(0).unwrap(); assert!( - app.visual_heights[0] > 1, + entry0.visual_height > 1, "expected wrapping but got height {}", - app.visual_heights[0] + entry0.visual_height ); // Short line is 1 row - assert_eq!(app.visual_heights[1], 1); - assert!(app.total_visual_rows > 2); + let entry1 = app.viewport_cache.get_entry(1).unwrap(); + assert_eq!(entry1.visual_height, 1); }); cleanup(&path); assert!(result.is_ok()); } #[test] - fn test_recompute_wrap_cache_not_loaded() { + fn test_ensure_viewport_cache_not_loaded() { let mut app = App::new(); - app.recompute_wrap_cache(80); + app.ensure_viewport_cache(80); // Should not panic and cache should stay empty - assert!(app.wrap_cache.is_empty()); + assert!(app.viewport_cache.entries.is_empty()); } #[test] - fn test_recompute_wrap_cache_zero_width() { + 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.recompute_wrap_cache(0); + app.ensure_viewport_cache(0); // Should not panic, cache should stay empty (width 0 is guarded) - assert!(app.wrap_cache.is_empty()); + assert!(app.viewport_cache.entries.is_empty()); }); cleanup(&path); assert!(result.is_ok()); } #[test] - fn test_load_file_resets_cached_width() { + 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.cached_width = 80; + app.viewport_cache.width = 80; app.load_file(path.to_str().unwrap()).unwrap(); - assert_eq!(app.cached_width, 0); + assert_eq!(app.viewport_cache.width, 0); }); cleanup(&path); assert!(result.is_ok()); @@ -761,13 +1134,13 @@ mod tests { 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(); + 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.cached_width, 0, "Tab should reset cached_width"); + 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"); @@ -876,7 +1249,7 @@ mod tests { } #[test] - fn test_wrap_cache_with_json_format() { + fn test_viewport_cache_with_json_format() { let json_content = r#"{"key1":"value1","key2":"value2"} plain text line {"nested":{"inner":true}} @@ -884,34 +1257,43 @@ plain text line let path = make_temp_file(json_content); let result = std::panic::catch_unwind(|| { let mut app = App::new(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.content_height = 50; // First without formatting - app.recompute_wrap_cache(80); - let raw_heights: Vec = app.visual_heights.clone(); + 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.cached_width = 0; - app.recompute_wrap_cache(80); + 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!( - app.visual_heights[0] > raw_heights[0], + entry0.visual_height > raw_heights[0], "JSON line 0 should have more visual rows when formatted" ); assert_eq!( - app.visual_heights[1], raw_heights[1], + entry1.visual_height, raw_heights[1], "Plain text line should have same visual rows" ); assert!( - app.visual_heights[2] > raw_heights[2], + entry2.visual_height > raw_heights[2], "JSON line 2 should have more visual rows when formatted" ); - // Check that wrap_cache contains indentation - let json_line_wrapped: String = app.wrap_cache[0].join(""); + // 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" @@ -922,39 +1304,54 @@ plain text line } #[test] - fn test_wrap_cache_toggle_restore() { + 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(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.content_height = 50; // Compute raw wrap cache - app.recompute_wrap_cache(80); - let raw_cache: Vec> = app.wrap_cache.clone(); + 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.cached_width = 0; - app.recompute_wrap_cache(80); - let formatted_cache: Vec> = app.wrap_cache.clone(); + 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_cache, formatted_cache, + raw_rows, formatted_rows, "Formatted wrap cache should differ from raw" ); // Toggle off: back to raw app.json_format = false; - app.cached_width = 0; - app.recompute_wrap_cache(80); - let restored_cache: Vec> = app.wrap_cache.clone(); + 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_cache, restored_cache, + raw_rows, restored_rows, "Toggle off should restore original wrap cache" ); }); @@ -972,11 +1369,12 @@ plain text line 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(); + load_file_ready(&mut app, &path); app.content_height = 20; - app.recompute_wrap_cache(80); - assert_eq!(app.visual_heights[0], 1); + 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(); @@ -992,11 +1390,12 @@ plain text line let v_offset_before = app.v_offset; app.json_format = true; - app.cached_width = 0; - app.recompute_wrap_cache(80); + app.viewport_cache.invalidate(); + app.ensure_viewport_cache(80); + let height_0 = app.compute_visual_height(0, 80); assert!( - app.visual_heights[0] > 1, + height_0 > 1, "JSON lines should expand when formatted" ); @@ -1020,7 +1419,7 @@ plain text line assert!(result.is_ok()); } - // ── Task 3 tests: AppMode, level_cache, color_config ─────────── + // ── Task 3 tests: AppMode, color_config ─────────────────────── #[test] fn test_color_config_default_in_new() { @@ -1061,11 +1460,11 @@ plain text line let path = make_temp_file("ERROR: fail\nWARN: maybe\njust text\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.content_height = 50; - app.recompute_wrap_cache(80); - assert!(!app.level_cache.is_empty()); - assert_eq!(app.level_cache.len(), 3); + 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()); @@ -1079,12 +1478,12 @@ plain text line let mut app = App::new(); app.load_file(path_a.to_str().unwrap()).unwrap(); app.content_height = 50; - app.recompute_wrap_cache(80); - assert_eq!(app.level_cache.len(), 2); + app.ensure_viewport_cache(80); + assert_eq!(app.viewport_cache.entries.len(), 2); app.load_file(path_b.to_str().unwrap()).unwrap(); - app.recompute_wrap_cache(80); - assert_eq!(app.level_cache.len(), 1); + app.ensure_viewport_cache(80); + assert_eq!(app.viewport_cache.entries.len(), 1); cleanup(&path_b); }); cleanup(&path_a); @@ -1097,10 +1496,11 @@ plain text line let path = make_temp_file(&format!("{line}\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.content_height = 50; - app.recompute_wrap_cache(80); - assert_eq!(app.level_cache[0], Some(LogLevel::Error)); + 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()); @@ -1111,10 +1511,11 @@ plain text line let path = make_temp_file("ERROR: something\n"); let result = std::panic::catch_unwind(|| { let mut app = App::new(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.content_height = 50; - app.recompute_wrap_cache(80); - assert_eq!(app.level_cache[0], Some(LogLevel::Error)); + 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()); @@ -1126,28 +1527,29 @@ plain text line let path = make_temp_file(&format!("{json_line}\n")); let result = std::panic::catch_unwind(|| { let mut app = App::new(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.json_format = true; app.content_height = 50; - app.recompute_wrap_cache(80); - assert_eq!(app.level_cache[0], Some(LogLevel::Warn)); + 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_wrap_cache_length_match() { + 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(); - app.load_file(path.to_str().unwrap()).unwrap(); + load_file_ready(&mut app, &path); app.content_height = 50; - app.recompute_wrap_cache(80); + app.ensure_viewport_cache(80); assert_eq!( - app.level_cache.len(), - app.wrap_cache.len(), - "level_cache and wrap_cache must have same length" + app.viewport_cache.entries.len(), + 3, + "viewport cache entries should cover all lines" ); }); cleanup(&path); @@ -1330,4 +1732,320 @@ plain text line ); 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(); + } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index ee3587a..46edb6e 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -31,6 +31,8 @@ fn main() -> anyhow::Result<()> { } while !app.should_quit { + app.poll_background_indexer(); + app.poll_file_watcher(); terminal.draw(|frame| ui::render(frame, &mut app))?; if crossterm::event::poll(std::time::Duration::from_millis(100))? { match crossterm::event::read()? { diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 8f1b3f0..ce1d0a2 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1,4 +1,4 @@ -use ratatui::style::{Color, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use crate::app::{App, AppMode}; @@ -42,6 +42,10 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { // ── Title bar ────────────────────────────────────────────────── let title_text = if app.mode == AppMode::Settings { " Color Settings".to_string() + } else if app.is_loading() { + let name = app.file_name().unwrap_or("unknown"); + let pct = app.loading_progress().map_or(0, |p| p as usize); + format!(" {} [Loading... {}%]", name, pct) } else if app.is_loaded() { let name = app.file_name().unwrap_or("unknown"); let cursor_display = if app.total_lines() == 0 { @@ -61,6 +65,22 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { // ── Content area ─────────────────────────────────────────────── if app.mode == AppMode::Settings { render_settings(frame, app, outer[1]); + } else if app.is_error() { + let msg = app.error_message().unwrap_or_default(); + let error_lines = vec![ + Line::from(""), + Line::from(""), + Line::styled( + format!(" Error: {}", msg), + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Line::from(""), + Line::styled(" Press q to quit", Style::default().fg(Color::DarkGray)), + ]; + frame.render_widget(Paragraph::new(error_lines).centered(), outer[1]); + } else if app.is_loading() { + // Show content from sampling during loading + render_content(frame, app, outer[1]); } else if !app.is_loaded() { frame.render_widget(Paragraph::new(" No file loaded"), outer[1]); } else { @@ -70,6 +90,30 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) { // ── Status bar ───────────────────────────────────────────────── let status_text = if app.mode == AppMode::Settings { " j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel" + } else if app.is_error() { + " Press q to quit" + } else if app.is_loading() { + let pct = app.loading_progress().map_or(0, |p| p as usize); + let est = app + .estimated_lines() + .map_or("?".to_string(), |e| format!("~{}", e)); + let name = app.file_name().unwrap_or("unknown"); + let status = format!( + " Indexing... {}% | {} lines | {} | j/k:scroll q:quit", + pct, est, name + ); + frame.render_widget( + Paragraph::new(status).style(Style::default().fg(Color::Yellow)), + outer[2], + ); + return; + } else if app.is_loaded() { + let name = app.file_name().unwrap_or("unknown"); + let total = app.total_lines(); + let cursor_display = if total == 0 { 0 } else { app.cursor_line + 1 }; + let status = format!(" {} [{}/{}] | j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit", name, cursor_display, total); + frame.render_widget(Paragraph::new(status), outer[2]); + return; } else { " j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit" }; @@ -165,8 +209,11 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo } else { 0 }; + + let is_loading = app.is_loading(); + let gutter_prefix_extra = if is_loading { 1 } else { 0 }; let gutter_width = if total_lines > 0 { - line_num_width + 1 + 1 + line_num_width + gutter_prefix_extra + 1 + 1 } else { 0 }; @@ -177,62 +224,73 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo return; } - app.recompute_wrap_cache(actual_content_width); - let mut visual_acc: usize = 0; - let mut start_logical: usize = 0; - let mut offset_in_line: usize = 0; - let v_offset = app.v_offset; - - for (i, &h) in app.visual_heights.iter().enumerate() { - if visual_acc.saturating_add(h) > v_offset { - start_logical = i; - offset_in_line = v_offset.saturating_sub(visual_acc); - break; - } - visual_acc += h; - if i == app.visual_heights.len() - 1 { - start_logical = i; - offset_in_line = 0; - } - } + let (start_logical, offset_in_line) = app.ensure_viewport_cache(actual_content_width); let mut lines: Vec = Vec::new(); let mut current_visual_offset: usize = 0; let available_rows = content_height; - for logical_line in start_logical..total_lines { - let wrapped = &app.wrap_cache[logical_line]; + let gutter_style = if is_loading { + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::DIM) + } else { + Style::default().fg(Color::DarkGray) + }; + + for (entry_idx, entry) in app.viewport_cache.entries.iter().enumerate() { + let logical_line = app.viewport_cache.logical_start + entry_idx; let start_row = if logical_line == start_logical { offset_in_line } else { 0 }; - for (visual_row, text) in wrapped.iter().enumerate().skip(start_row) { + for (visual_row, text) in entry.wrapped_rows.iter().enumerate().skip(start_row) { if current_visual_offset >= available_rows { break; } let is_cursor = logical_line == app.cursor_line; - let level = app.level_cache.get(logical_line).and_then(|l| l.as_ref()); + let level = entry.level.as_ref(); + + let bg_color = if is_cursor { + Color::DarkGray + } else { + Color::Reset + }; + let level_fg = level_fg(level, &app.color_config).unwrap_or(Color::White); let gutter_text = if visual_row == 0 { - format!( - "{:>width$} \u{2502}", - logical_line + 1, - width = line_num_width - ) + if is_loading { + format!( + "~{:>width$} \u{2502}", + logical_line + 1, + width = line_num_width + ) + } else { + format!( + "{:>width$} \u{2502}", + logical_line + 1, + width = line_num_width + ) + } + } else if is_loading { + format!(" {:width$} \u{2502}", "", width = line_num_width) } else { format!("{:width$} \u{2502}", "", width = line_num_width) }; - lines.push(build_line_spans( - gutter_text, - text.clone(), - is_cursor, - level, - &app.color_config, - )); + let effective_gutter_style = if is_cursor { + gutter_style.bg(bg_color) + } else { + gutter_style + }; + + lines.push(Line::from(vec![ + Span::styled(gutter_text, effective_gutter_style), + Span::styled(text.clone(), Style::default().fg(level_fg).bg(bg_color)), + ])); current_visual_offset += 1; } @@ -304,4 +362,135 @@ mod tests { assert_eq!(line.spans[0].style.bg, Some(Color::Reset)); assert_eq!(line.spans[1].style.bg, Some(Color::Reset)); } + + fn render_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).unwrap(); + terminal.draw(|frame| render(frame, app)).unwrap(); + terminal.backend().buffer().clone() + } + + fn make_temp_file(content: &str) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir(); + let name = format!("log_viewer_ui_test_{}_{}", std::process::id(), id); + let path = dir.join(name); + std::fs::write(&path, content).unwrap(); + path + } + + #[test] + fn test_render_error_state() { + let mut app = App::new(); + app.set_error_state("file not found"); + + let buf = render_to_buffer(&mut app, 80, 24); + + let mut found_error = false; + let mut found_quit = false; + for row in 0..23 { + let content: String = (0..80) + .map(|c| buf.cell((c, row)).unwrap().symbol().to_string()) + .collect(); + if content.contains("Error") && content.contains("file not found") { + found_error = true; + } + if content.contains("Press q to quit") { + found_quit = true; + } + } + assert!(found_error, "content area should show error message"); + assert!(found_quit, "content area should show quit hint"); + } + + #[test] + fn test_render_loading_state_status_bar() { + let path = make_temp_file("line1\nline2\nline3\nline4\nline5\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + + if app.is_loading() { + let buf = render_to_buffer(&mut app, 80, 24); + + let status: String = (0..80) + .map(|c| buf.cell((c, 23)).unwrap().symbol().to_string()) + .collect(); + assert!( + status.contains("Indexing"), + "status bar should show 'Indexing', got: {}", + status + ); + assert!( + status.contains("%"), + "status bar should show percentage, got: {}", + status + ); + assert!( + status.contains("~"), + "status bar should show ~ for estimated lines, got: {}", + status + ); + } + }); + let _ = std::fs::remove_file(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_render_loading_gutter_tilde_prefix() { + let path = make_temp_file("line1\nline2\nline3\n"); + let result = std::panic::catch_unwind(|| { + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + + if app.is_loading() { + let buf = render_to_buffer(&mut app, 80, 24); + + let first_line: String = (0..10) + .map(|c| buf.cell((c, 1)).unwrap().symbol().to_string()) + .collect(); + assert!( + first_line.contains("~"), + "loading state gutter should have ~ prefix, got: {}", + first_line + ); + } + }); + let _ = std::fs::remove_file(&path); + assert!(result.is_ok()); + } + + #[test] + fn test_render_ready_state_status_bar() { + let path = make_temp_file("alpha\nbeta\ngamma\n"); + let result = std::panic::catch_unwind(|| { + 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); + + let mut app = App::new(); + app.load_file(path.to_str().unwrap()).unwrap(); + + assert!( + app.is_loaded() && !app.is_loading(), + "should be in Ready state with cache hit" + ); + + let buf = render_to_buffer(&mut app, 80, 24); + + let status: String = (0..80) + .map(|c| buf.cell((c, 23)).unwrap().symbol().to_string()) + .collect(); + assert!( + status.contains("1/") || status.contains("1/3"), + "status bar should show cursor position, got: {}", + status + ); + }); + let _ = std::fs::remove_file(&path); + assert!(result.is_ok()); + } }