- Report save failures in settings instead of silently discarding errors - Save draft first, commit color_config only on success (prevents split-brain) - Show error message in red on status bar, stay in Settings mode on failure - Clear error on Esc, entering settings, or any settings edit - Rewrite truncate_to_columns as dedicated O(prefix) truncator - Tab: stop before tab if expansion would exceed width - Fixes pre-existing test_truncate_to_columns_tab failure
3701 lines
130 KiB
Rust
3701 lines
130 KiB
Rust
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, MAX_WRAP_INPUT_LEN};
|
||
use log_viewer_core::types::LogLevel;
|
||
use log_viewer_core::watcher::file_watcher::{FileEvent, FileWatcher};
|
||
use unicode_width::UnicodeWidthChar;
|
||
|
||
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<String>,
|
||
pub(crate) level: Option<LogLevel>,
|
||
pub(crate) visual_height: usize,
|
||
}
|
||
|
||
pub(crate) struct ViewportCache {
|
||
pub(crate) entries: Vec<ViewportEntry>,
|
||
pub(crate) logical_start: usize,
|
||
pub(crate) width: usize,
|
||
json_format: bool,
|
||
cached_total_visual_rows: Option<usize>,
|
||
}
|
||
|
||
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<String>,
|
||
|
||
// 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<Instant>,
|
||
|
||
// 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,
|
||
pub(crate) settings_error: Option<String>,
|
||
|
||
// File watcher
|
||
file_watcher: Option<FileWatcher>,
|
||
|
||
/// Set to true when file-change events arrive during Loading state.
|
||
/// After Loading→Ready transition, a full reload is triggered once.
|
||
reload_after_loading: bool,
|
||
}
|
||
|
||
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(),
|
||
settings_error: None,
|
||
file_watcher: None,
|
||
reload_after_loading: false,
|
||
}
|
||
}
|
||
|
||
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;
|
||
self.json_format = false;
|
||
self.mode = AppMode::Normal;
|
||
self.reload_after_loading = false;
|
||
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();
|
||
|
||
// Guard 1: oversized raw input — skip detect_level and JSON formatting
|
||
// to avoid O(n) parsing overhead on huge lines.
|
||
if raw.len() > MAX_WRAP_INPUT_LEN {
|
||
return ViewportEntry {
|
||
wrapped_rows: vec![truncate_to_columns(&raw, width)],
|
||
level: None,
|
||
visual_height: 1,
|
||
};
|
||
}
|
||
|
||
let level = log_viewer_core::parser::level::detect_level(&raw);
|
||
let display_text = if self.json_format {
|
||
format_json_line(&raw)
|
||
} else {
|
||
raw
|
||
};
|
||
|
||
// Guard 2: JSON pretty-printing may expand a line beyond the limit.
|
||
if display_text.len() > MAX_WRAP_INPUT_LEN {
|
||
return ViewportEntry {
|
||
wrapped_rows: vec![truncate_to_columns(&display_text, width)],
|
||
level,
|
||
visual_height: 1,
|
||
};
|
||
}
|
||
|
||
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();
|
||
|
||
// Guard 1: oversized raw input.
|
||
if raw.len() > MAX_WRAP_INPUT_LEN {
|
||
return 1;
|
||
}
|
||
|
||
let display_text = if self.json_format {
|
||
format_json_line(&raw)
|
||
} else {
|
||
raw
|
||
};
|
||
|
||
// Guard 2: post-format expansion.
|
||
if display_text.len() > MAX_WRAP_INPUT_LEN {
|
||
return 1;
|
||
}
|
||
|
||
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;
|
||
|
||
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() {
|
||
(self.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) {
|
||
use crossterm::event::KeyEventKind;
|
||
|
||
let should_handle = match key.kind {
|
||
KeyEventKind::Press => true,
|
||
KeyEventKind::Repeat => self.is_repeatable_key(&key),
|
||
KeyEventKind::Release => false,
|
||
};
|
||
|
||
if !should_handle {
|
||
return;
|
||
}
|
||
|
||
match self.mode {
|
||
AppMode::Normal => self.handle_normal_key(key),
|
||
AppMode::Settings => self.handle_settings_key(key),
|
||
}
|
||
}
|
||
|
||
/// Keys that should auto-repeat when held (scroll/navigation only).
|
||
fn is_repeatable_key(&self, key: &crossterm::event::KeyEvent) -> bool {
|
||
use crossterm::event::{KeyCode, KeyModifiers};
|
||
|
||
let plain = key.modifiers.is_empty();
|
||
let ctrl = key.modifiers == KeyModifiers::CONTROL;
|
||
|
||
match self.mode {
|
||
AppMode::Normal => {
|
||
(plain
|
||
&& matches!(
|
||
key.code,
|
||
KeyCode::Char('j')
|
||
| KeyCode::Down
|
||
| KeyCode::Char('k')
|
||
| KeyCode::Up
|
||
| KeyCode::PageDown
|
||
| KeyCode::PageUp
|
||
))
|
||
|| (ctrl
|
||
&& matches!(
|
||
key.code,
|
||
KeyCode::Char('d')
|
||
| KeyCode::Char('u')
|
||
| KeyCode::Char('f')
|
||
| KeyCode::Char('b')
|
||
))
|
||
}
|
||
AppMode::Settings => plain
|
||
&& matches!(
|
||
key.code,
|
||
KeyCode::Char('j')
|
||
| KeyCode::Down
|
||
| KeyCode::Char('k')
|
||
| KeyCode::Up
|
||
| KeyCode::Left
|
||
| KeyCode::Right
|
||
),
|
||
}
|
||
}
|
||
|
||
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;
|
||
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
|
||
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.v_offset = new_offset;
|
||
self.v_sub_offset = new_sub;
|
||
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.settings_error = None;
|
||
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.settings_error = None;
|
||
self.mode = AppMode::Normal;
|
||
}
|
||
KeyCode::Enter => {
|
||
let draft = self.settings_draft.clone();
|
||
match draft.save() {
|
||
Ok(()) => {
|
||
self.color_config = draft;
|
||
self.settings_error = None;
|
||
self.mode = AppMode::Normal;
|
||
}
|
||
Err(e) => {
|
||
self.settings_error =
|
||
Some(format!("Failed to save settings: {e}"));
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
if p == 0 { colors.len() - 1 } else { p - 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) {
|
||
self.settings_error = None;
|
||
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<String> {
|
||
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,
|
||
}
|
||
}
|
||
|
||
/// Compute the rebased offset pair (logical_line, sub_row) from the
|
||
/// current visual-row `v_offset`. Returns `(v_offset, v_sub_offset)`
|
||
/// suitable for the no-VHI fallback scrolling path.
|
||
///
|
||
/// MUST be called before borrowing `&mut self.loading_state` for
|
||
/// `invalidate_visual_height_index`, because it reads VHI through
|
||
/// `&self`.
|
||
fn rebase_offset_for_invalidate(&self) -> (usize, usize) {
|
||
if self.get_visual_height_index().is_some() {
|
||
let top_visual = self.v_offset;
|
||
let top_line = self.visual_row_to_logical_row(top_visual);
|
||
let line_first_visual = self.cursor_to_first_visual_row(top_line);
|
||
let sub = top_visual.saturating_sub(line_first_visual);
|
||
(top_line, sub)
|
||
} else {
|
||
(self.v_offset, self.v_sub_offset)
|
||
}
|
||
}
|
||
|
||
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 {
|
||
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
|
||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||
reader.invalidate_visual_height_index();
|
||
reader.start_visual_height_rebuild(width, self.json_format);
|
||
}
|
||
self.v_offset = new_offset;
|
||
self.v_sub_offset = new_sub;
|
||
}
|
||
}
|
||
|
||
pub fn error_message(&self) -> Option<&str> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Error(msg) => Some(msg),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn loading_progress(&self) -> Option<f64> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Loading {
|
||
progress_percent, ..
|
||
} => Some(*progress_percent),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
pub fn estimated_lines(&self) -> Option<u64> {
|
||
match &self.loading_state {
|
||
AppLoadingState::Loading {
|
||
estimated_lines, ..
|
||
} => Some(*estimated_lines),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
pub fn set_error_state(&mut self, msg: impl Into<String>) {
|
||
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<FileEvent> = 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.
|
||
}
|
||
FileEvent::Removed => {
|
||
self.loading_state = AppLoadingState::Error("File has been deleted".into());
|
||
}
|
||
FileEvent::WatcherError { message: _ } => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn handle_file_appended(&mut self) {
|
||
let width = self.get_content_width();
|
||
let rebased = self.rebase_offset_for_invalidate();
|
||
match &mut self.loading_state {
|
||
AppLoadingState::Ready { reader } => {
|
||
let old_reader_line_count = reader.line_count();
|
||
let status = reader.update_for_append();
|
||
match status {
|
||
Ok(
|
||
log_viewer_core::io::file_reader::AppendStatus::Appended(_new_lines),
|
||
) => {
|
||
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 && old_line_count == old_reader_line_count {
|
||
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||
visual_height_index: Some(index),
|
||
reader: fr,
|
||
} = &mut reader.state
|
||
{
|
||
if old_line_count > 0 {
|
||
let last_old_line_text =
|
||
fr.get_line(old_line_count - 1).unwrap_or("");
|
||
let new_h = compute_line_visual_height(
|
||
last_old_line_text,
|
||
width,
|
||
self.json_format,
|
||
);
|
||
index.replace_last_line_height(new_h);
|
||
}
|
||
let mut new_heights =
|
||
Vec::with_capacity(new_line_count.saturating_sub(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 {
|
||
let (new_offset, new_sub) = rebased;
|
||
self.v_offset = new_offset;
|
||
self.v_sub_offset = new_sub;
|
||
reader.invalidate_visual_height_index();
|
||
reader.start_visual_height_rebuild(width, self.json_format);
|
||
}
|
||
|
||
self.viewport_cache.invalidate();
|
||
}
|
||
Ok(log_viewer_core::io::file_reader::AppendStatus::Reloaded) => {
|
||
let _ = reader.save_cache();
|
||
let (new_offset, _new_sub) = rebased;
|
||
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.v_offset = new_offset;
|
||
self.v_sub_offset = 0;
|
||
self.viewport_cache.invalidate();
|
||
self.clamp_v_offset();
|
||
}
|
||
Ok(log_viewer_core::io::file_reader::AppendStatus::Unchanged) | Err(_) => {}
|
||
}
|
||
}
|
||
AppLoadingState::Loading { .. } => {
|
||
self.reload_after_loading = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn reload_ready_reader(&mut self) {
|
||
let width = self.get_content_width();
|
||
let (new_offset, _new_sub) = self.rebase_offset_for_invalidate();
|
||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||
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.v_offset = new_offset;
|
||
self.v_sub_offset = 0;
|
||
self.viewport_cache.invalidate();
|
||
self.clamp_v_offset();
|
||
}
|
||
}
|
||
|
||
fn handle_file_truncated(&mut self) {
|
||
match &mut self.loading_state {
|
||
AppLoadingState::Ready { .. } => {
|
||
self.reload_ready_reader();
|
||
}
|
||
AppLoadingState::Loading { .. } => {
|
||
self.reload_after_loading = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|
||
}
|
||
// Recalibrate v_offset from logical → visual-row now that VHI is available.
|
||
// Must be outside the `reader` borrow above.
|
||
if self.get_visual_height_index().is_some() {
|
||
let logical_top = self.v_offset.min(self.total_lines().saturating_sub(1));
|
||
let sub = self.v_sub_offset;
|
||
let first_visual = self.cursor_to_first_visual_row(logical_top);
|
||
let line_height = self
|
||
.get_visual_height_index()
|
||
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
|
||
self.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
|
||
self.v_sub_offset = 0;
|
||
self.clamp_v_offset();
|
||
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.
|
||
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
|
||
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
|
||
reader.invalidate_visual_height_index();
|
||
}
|
||
self.v_offset = new_offset;
|
||
self.v_sub_offset = new_sub;
|
||
|
||
if self.reload_after_loading {
|
||
self.reload_after_loading = false;
|
||
self.reload_ready_reader();
|
||
}
|
||
}
|
||
IndexerMessage::Error { message, .. } => {
|
||
self.loading_state = AppLoadingState::Error(message);
|
||
self.reload_after_loading = false;
|
||
}
|
||
}
|
||
} else {
|
||
self.loading_state = AppLoadingState::Loading {
|
||
reader,
|
||
estimated_lines,
|
||
progress_percent,
|
||
};
|
||
}
|
||
} else {
|
||
self.loading_state = old_state;
|
||
}
|
||
}
|
||
}
|
||
|
||
const TRUNCATE_TAB_WIDTH: usize = 4;
|
||
|
||
fn truncate_to_columns(s: &str, max_cols: usize) -> String {
|
||
if max_cols == 0 || s.is_empty() {
|
||
return String::new();
|
||
}
|
||
|
||
let mut out = String::new();
|
||
let mut col = 0;
|
||
|
||
for ch in s.chars() {
|
||
if ch == '\t' {
|
||
let tab_stop = TRUNCATE_TAB_WIDTH - (col % TRUNCATE_TAB_WIDTH);
|
||
if col + tab_stop > max_cols {
|
||
break;
|
||
}
|
||
for _ in 0..tab_stop {
|
||
out.push(' ');
|
||
}
|
||
col += tab_stop;
|
||
} else {
|
||
let w = if ch.is_control() {
|
||
0
|
||
} else {
|
||
ch.width().unwrap_or(0)
|
||
};
|
||
if col + w > max_cols {
|
||
break;
|
||
}
|
||
out.push(ch);
|
||
col += w;
|
||
}
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
#[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_with_hash(path, &index, &data);
|
||
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<usize> = 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<Vec<String>> = 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<Vec<String>> = 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<Vec<String>> = 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");
|
||
|
||
// backward wrap: red (index 0) → white (index 7)
|
||
app.handle_key(make_key(crossterm::event::KeyCode::Left));
|
||
assert_eq!(app.settings_draft.error, "white");
|
||
|
||
// forward wrap: white (index 7) → red (index 0)
|
||
app.handle_key(make_key(crossterm::event::KeyCode::Right));
|
||
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_settings_save_failure_stays_in_settings() {
|
||
let mut app = App::new();
|
||
let original = app.color_config.clone();
|
||
|
||
let impossible_path = std::path::PathBuf::from("/nonexistent/deep/nested/config.toml");
|
||
enter_settings(&mut app);
|
||
app.handle_key(make_key(crossterm::event::KeyCode::Right));
|
||
assert_ne!(app.settings_draft.error, original.error);
|
||
|
||
let draft = app.settings_draft.clone();
|
||
let result = draft.save_to(&impossible_path);
|
||
assert!(result.is_err(), "Save to impossible path should fail");
|
||
|
||
let err = result.unwrap_err();
|
||
assert!(
|
||
err.to_string().contains("config") || err.to_string().contains("creating"),
|
||
"Error should mention config or creating directory"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_settings_error_cleared_on_edit() {
|
||
let mut app = App::new();
|
||
enter_settings(&mut app);
|
||
|
||
app.settings_error = Some("test error".to_string());
|
||
|
||
app.handle_key(make_key(crossterm::event::KeyCode::Right));
|
||
assert!(
|
||
app.settings_error.is_none(),
|
||
"Editing a setting should clear settings_error"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_settings_error_cleared_on_esc() {
|
||
let mut app = App::new();
|
||
enter_settings(&mut app);
|
||
|
||
app.settings_error = Some("test error".to_string());
|
||
app.handle_key(make_key(crossterm::event::KeyCode::Esc));
|
||
|
||
assert_eq!(app.mode, AppMode::Normal);
|
||
assert!(
|
||
app.settings_error.is_none(),
|
||
"Esc should clear settings_error"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_settings_error_cleared_on_enter_settings() {
|
||
let mut app = App::new();
|
||
enter_settings(&mut app);
|
||
app.settings_error = Some("stale error".to_string());
|
||
app.handle_key(make_key(crossterm::event::KeyCode::Esc));
|
||
assert!(app.settings_error.is_none());
|
||
|
||
enter_settings(&mut app);
|
||
assert!(
|
||
app.settings_error.is_none(),
|
||
"Entering settings should clear stale errors"
|
||
);
|
||
}
|
||
|
||
#[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<IndexerMessage>,
|
||
) {
|
||
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<String> = (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());
|
||
}
|
||
|
||
/// Regression test for issue #24:
|
||
/// ensure_viewport_cache must use the updated self.v_offset after params_changed
|
||
/// recalculates it, not a stale local captured before the change block.
|
||
#[test]
|
||
fn test_loading_viewport_cache_uses_updated_v_offset_on_params_changed() {
|
||
let content: String = (0..200).map(|i| format!("line{i}\n")).collect();
|
||
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");
|
||
|
||
app.content_height = 10;
|
||
app.ensure_viewport_cache(80);
|
||
|
||
app.v_offset = 90;
|
||
app.cursor_line = 100;
|
||
|
||
let new_width = 40;
|
||
app.ensure_viewport_cache(new_width);
|
||
|
||
let recomputed_offset = app.v_offset;
|
||
assert_ne!(
|
||
recomputed_offset, 90,
|
||
"v_offset should have been recalculated by params_changed block, still 90"
|
||
);
|
||
|
||
assert_eq!(
|
||
app.viewport_cache.logical_start, recomputed_offset.min(app.total_lines().saturating_sub(1)),
|
||
"logical_start should match the updated v_offset"
|
||
);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
fn install_vhi(app: &mut App, heights: &[usize]) {
|
||
let width = app.get_content_width();
|
||
let json_format = app.json_format;
|
||
let vhi = VisualHeightIndex::build(heights).with_params(json_format, width);
|
||
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::<Vec<_>>()
|
||
.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());
|
||
}
|
||
|
||
// ── H10 regression: file events during Loading state ──────────────
|
||
|
||
#[test]
|
||
fn test_append_during_loading_sets_reload_flag() {
|
||
let content: String = (0..50)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let (mut app, _tx) = app_in_loading_state(&path, 1);
|
||
assert!(app.is_loading());
|
||
assert!(!app.reload_after_loading);
|
||
|
||
app.handle_file_appended();
|
||
|
||
assert!(app.reload_after_loading,
|
||
"append during Loading should set reload_after_loading flag");
|
||
}));
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_during_loading_sets_reload_flag() {
|
||
let content: String = (0..50)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let (mut app, _tx) = app_in_loading_state(&path, 1);
|
||
assert!(app.is_loading());
|
||
|
||
app.handle_file_truncated();
|
||
|
||
assert!(app.reload_after_loading,
|
||
"truncate during Loading should set reload_after_loading flag");
|
||
}));
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_multiple_events_during_loading_collapse_to_single_reload() {
|
||
let content: String = (0..50)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let (mut app, tx) = app_in_loading_state(&path, 1);
|
||
|
||
app.handle_file_appended();
|
||
app.handle_file_truncated();
|
||
app.handle_file_appended();
|
||
|
||
assert!(app.reload_after_loading);
|
||
|
||
let updated_content: String = (0..60)
|
||
.map(|i| format!("updated line {}\n", i))
|
||
.collect();
|
||
std::fs::write(&path, &updated_content).unwrap();
|
||
let fr = file_reader_for(&path);
|
||
tx.send(IndexerMessage::Complete {
|
||
generation: 1,
|
||
reader: fr,
|
||
visual_height_index: None,
|
||
}).unwrap();
|
||
|
||
app.poll_background_indexer();
|
||
|
||
assert!(!app.is_loading(), "should be Ready after Complete");
|
||
assert!(!app.reload_after_loading, "flag should be cleared");
|
||
assert_eq!(app.total_lines(), 60,
|
||
"should show reloaded content, not stale indexer result");
|
||
}));
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_indexer_error_clears_reload_flag() {
|
||
let content: String = (0..10)
|
||
.map(|i| format!("line {}\n", i))
|
||
.collect();
|
||
let path = make_temp_file(&content);
|
||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let (mut app, tx) = app_in_loading_state(&path, 1);
|
||
|
||
app.handle_file_appended();
|
||
assert!(app.reload_after_loading);
|
||
|
||
tx.send(IndexerMessage::Error {
|
||
generation: 1,
|
||
message: "test error".into(),
|
||
}).unwrap();
|
||
|
||
app.poll_background_indexer();
|
||
|
||
assert!(matches!(app.loading_state, AppLoadingState::Error(_)));
|
||
assert!(!app.reload_after_loading,
|
||
"flag should be cleared on indexer error");
|
||
}));
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_append_no_trailing_newline_updates_last_line_height() {
|
||
let path = make_temp_file("abc");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
assert_eq!(app.total_lines(), 1);
|
||
|
||
app.content_width = 5;
|
||
install_vhi(&mut app, &[1usize]);
|
||
|
||
{
|
||
let vhi = app.get_visual_height_index().unwrap();
|
||
assert_eq!(vhi.visual_height_of_line(0), 1);
|
||
assert_eq!(vhi.total_visual_rows(), 1);
|
||
}
|
||
|
||
// Append content that extends line 0 (no trailing newline before)
|
||
// "abc" + "defgh\n" = "abcdefgh\n" → 8 chars in width 5 → wraps to 2 visual rows
|
||
// old_total=1, old_had_trailing=false → starts_new_line=false
|
||
// new_has_trailing=true, new_newlines=1 → added = 1-1 = 0
|
||
// Still 1 logical line, but line 0 text changed from "abc" to "abcdefgh"
|
||
{
|
||
use std::io::Write;
|
||
let mut f = std::fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&path)
|
||
.unwrap();
|
||
f.write_all(b"defgh\n").unwrap();
|
||
}
|
||
|
||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||
app.poll_file_watcher();
|
||
|
||
assert_eq!(app.total_lines(), 1,
|
||
"\"abcdefgh\\n\" has trailing newline → 1 logical line");
|
||
|
||
let vhi = app.get_visual_height_index().expect("VHI should still exist after append");
|
||
assert_eq!(vhi.visual_height_of_line(0), 2,
|
||
"line 0 height should be updated from 1 to 2 after extending 'abcdefgh' in width 5");
|
||
assert_eq!(vhi.total_visual_rows(), 2);
|
||
assert_eq!(vhi.cursor_to_first_visual_row(0), 0);
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
// ── Issue #23: KeyEventKind filtering ───────────────────────────
|
||
|
||
fn make_key_with_kind(
|
||
code: crossterm::event::KeyCode,
|
||
modifiers: crossterm::event::KeyModifiers,
|
||
kind: crossterm::event::KeyEventKind,
|
||
) -> crossterm::event::KeyEvent {
|
||
crossterm::event::KeyEvent::new_with_kind(code, modifiers, kind)
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_release_quit_ignored() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let mut app = App::new();
|
||
let release_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Release);
|
||
app.handle_key(release_q);
|
||
assert!(!app.should_quit, "Release+q must NOT quit");
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_release_scroll_ignored() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let path = make_temp_file("a\nb\nc\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
|
||
let release_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Release);
|
||
app.handle_key(release_j);
|
||
assert_eq!(app.cursor_line, 0, "Release+j must NOT scroll");
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_repeat_quit_ignored() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let mut app = App::new();
|
||
let repeat_q = make_key_with_kind(KeyCode::Char('q'), KeyModifiers::NONE, KeyEventKind::Repeat);
|
||
app.handle_key(repeat_q);
|
||
assert!(!app.should_quit, "Repeat+q must NOT quit");
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_repeat_j_passes() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let path = make_temp_file("a\nb\nc\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
|
||
let repeat_j = make_key_with_kind(KeyCode::Char('j'), KeyModifiers::NONE, KeyEventKind::Repeat);
|
||
app.handle_key(repeat_j);
|
||
assert_eq!(app.cursor_line, 1, "Repeat+j must scroll");
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_repeat_ctrl_d_passes() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let path = make_temp_file("a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
app.content_height = 5;
|
||
load_file_ready(&mut app, &path);
|
||
install_vhi(&mut app, &[1usize; 10]);
|
||
|
||
let repeat_ctrl_d = make_key_with_kind(
|
||
KeyCode::Char('d'), KeyModifiers::CONTROL, KeyEventKind::Repeat,
|
||
);
|
||
app.handle_key(repeat_ctrl_d);
|
||
assert!(app.cursor_line > 0, "Repeat+Ctrl+d must scroll half page");
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_repeat_ctrl_d_without_ctrl_ignored() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let path = make_temp_file("a\nb\nc\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
|
||
let repeat_plain_d = make_key_with_kind(
|
||
KeyCode::Char('d'), KeyModifiers::NONE, KeyEventKind::Repeat,
|
||
);
|
||
app.handle_key(repeat_plain_d);
|
||
assert_eq!(app.cursor_line, 0, "Repeat+plain d must NOT scroll");
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_repeat_g_ignored() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let path = make_temp_file("a\nb\nc\n");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
|
||
let repeat_g = make_key_with_kind(KeyCode::Char('g'), KeyModifiers::NONE, KeyEventKind::Repeat);
|
||
app.handle_key(repeat_g);
|
||
assert_eq!(app.cursor_line, 0, "Repeat+g must NOT jump");
|
||
assert!(app.last_g_press.is_none(), "Repeat+g must not set last_g_press");
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_settings_repeat_left_right_passes() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let mut app = App::new();
|
||
enter_settings(&mut app);
|
||
assert_eq!(app.mode, AppMode::Settings);
|
||
|
||
let repeat_right = make_key_with_kind(KeyCode::Right, KeyModifiers::NONE, KeyEventKind::Repeat);
|
||
app.handle_key(repeat_right);
|
||
app.handle_key(make_key(KeyCode::Enter));
|
||
assert_eq!(app.mode, AppMode::Normal, "Repeat Right in Settings should work then Enter closes");
|
||
}
|
||
|
||
#[test]
|
||
fn issue23_settings_repeat_enter_ignored() {
|
||
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
|
||
let mut app = App::new();
|
||
enter_settings(&mut app);
|
||
|
||
let repeat_enter = make_key_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat);
|
||
app.handle_key(repeat_enter);
|
||
assert_eq!(app.mode, AppMode::Settings, "Repeat+Enter must NOT close settings");
|
||
}
|
||
|
||
#[test]
|
||
fn test_append_no_trailing_newline_no_new_lines_only_height_change() {
|
||
let path = make_temp_file("abc");
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
assert_eq!(app.total_lines(), 1);
|
||
|
||
app.content_width = 5;
|
||
install_vhi(&mut app, &[1usize]);
|
||
|
||
// Append without adding any new line — just extends line 0
|
||
// "abc" + "def" = "abcdef" → 6 chars in width 5 → wraps to 2 visual rows, still 1 logical line
|
||
{
|
||
use std::io::Write;
|
||
let mut f = std::fs::OpenOptions::new()
|
||
.append(true)
|
||
.open(&path)
|
||
.unwrap();
|
||
f.write_all(b"def").unwrap();
|
||
}
|
||
|
||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||
app.poll_file_watcher();
|
||
|
||
assert_eq!(app.total_lines(), 1,
|
||
"no new logical line should be added");
|
||
|
||
let vhi = app.get_visual_height_index().expect("VHI should still exist");
|
||
assert_eq!(vhi.visual_height_of_line(0), 2,
|
||
"line 0 height should update even when no new lines added");
|
||
assert_eq!(vhi.total_visual_rows(), 2);
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
// ── Issue #25 regression tests ──────────────────────────────────
|
||
// Ready state without VHI must keep v_offset self-consistent.
|
||
|
||
#[test]
|
||
fn test_rebase_offset_converts_visual_to_logical_with_sub() {
|
||
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;
|
||
|
||
// 10 lines × 3 visual rows each = 30 visual rows
|
||
install_vhi(&mut app, &[3usize; 10]);
|
||
|
||
// v_offset=7 → visual row 7 is inside line2 (rows 6-8), sub=1
|
||
app.v_offset = 7;
|
||
app.v_sub_offset = 0;
|
||
|
||
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||
assert_eq!(logical, 2, "visual row 7 → logical line 2 (rows 6-8)");
|
||
assert_eq!(sub, 1, "visual row 7 is sub-row 1 within line 2");
|
||
|
||
// v_offset=0 → line 0, sub=0
|
||
app.v_offset = 0;
|
||
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||
assert_eq!(logical, 0);
|
||
assert_eq!(sub, 0);
|
||
|
||
// v_offset=5 → line1 (rows 3-5), sub=2
|
||
app.v_offset = 5;
|
||
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||
assert_eq!(logical, 1, "visual row 5 → logical line 1 (rows 3-5)");
|
||
assert_eq!(sub, 2);
|
||
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_rebase_offset_passthrough_when_no_vhi() {
|
||
let content = "line0\nline1\nline2\n";
|
||
let path = make_temp_file(content);
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
|
||
// No VHI installed — rebase should return current values unchanged
|
||
app.v_offset = 42;
|
||
app.v_sub_offset = 3;
|
||
let (logical, sub) = app.rebase_offset_for_invalidate();
|
||
assert_eq!(logical, 42);
|
||
assert_eq!(sub, 3);
|
||
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_tab_toggle_rebases_offset_before_invalidate() {
|
||
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;
|
||
app.content_width = 20;
|
||
|
||
install_vhi(&mut app, &[3usize; 10]);
|
||
|
||
// Scroll to visual row 7 → line2 sub-row 1
|
||
app.v_offset = 7;
|
||
app.cursor_line = 2;
|
||
app.v_sub_offset = 0;
|
||
|
||
// Simulate Tab toggle: rebase, then invalidate
|
||
let rebased = app.rebase_offset_for_invalidate();
|
||
let (new_offset, new_sub) = rebased;
|
||
app.v_offset = new_offset;
|
||
app.v_sub_offset = new_sub;
|
||
|
||
assert_eq!(app.v_offset, 2, "v_offset should be logical line 2 after rebase");
|
||
assert_eq!(app.v_sub_offset, 1, "v_sub_offset should preserve sub-row 1");
|
||
|
||
// Key invariant: v_offset is now a valid logical line number,
|
||
// not a stale visual-row offset that would cause a jump.
|
||
// Before the fix, v_offset would still be 7 (visual) treated as logical → jump.
|
||
assert!(
|
||
app.v_offset < app.total_lines(),
|
||
"v_offset ({}) must be a valid logical line index < {}",
|
||
app.v_offset, app.total_lines()
|
||
);
|
||
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_vhi_rebuild_recalibrates_from_viewport_top() {
|
||
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;
|
||
app.content_width = 20;
|
||
|
||
// Install VHI with varying heights
|
||
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
|
||
|
||
// Scroll to visual row 8 → line2 (rows 6-8), sub=2
|
||
app.v_offset = 8;
|
||
app.cursor_line = 2;
|
||
app.v_sub_offset = 0;
|
||
|
||
// Rebase (simulates invalidate)
|
||
let (logical_top, sub) = app.rebase_offset_for_invalidate();
|
||
assert_eq!(logical_top, 2);
|
||
assert_eq!(sub, 2);
|
||
|
||
// Simulate what happens: v_offset becomes logical, VHI cleared
|
||
app.v_offset = logical_top;
|
||
app.v_sub_offset = sub;
|
||
|
||
// Now simulate VHI rebuild completion — the recalibration block
|
||
// Install a fresh VHI (same heights, simulates rebuild result)
|
||
install_vhi(&mut app, &[2, 4, 3, 1, 2, 5, 1, 3, 2, 1]);
|
||
|
||
// The recalibration logic from poll_background_indexer
|
||
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
|
||
let sub = app.v_sub_offset;
|
||
let first_visual = app.cursor_to_first_visual_row(logical_top);
|
||
let line_height = app
|
||
.get_visual_height_index()
|
||
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
|
||
app.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
|
||
app.v_sub_offset = 0;
|
||
|
||
// line2 starts at visual row 6, sub was 2 → visual row 8
|
||
assert_eq!(app.v_offset, 8, "v_offset should map back to visual row 8");
|
||
assert_eq!(app.v_sub_offset, 0, "v_sub_offset should be 0 after recalibration");
|
||
|
||
// Scrolling should work normally with VHI
|
||
app.scroll_down_line();
|
||
assert_eq!(app.v_offset, 9, "scroll down should advance visual row");
|
||
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_vhi_rebuild_clamps_sub_to_new_line_height() {
|
||
let content = "line0\nline1\nline2\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;
|
||
app.content_width = 20;
|
||
|
||
// Line heights: line0=1, line1=5 (wraps), line2=1
|
||
install_vhi(&mut app, &[1, 5, 1]);
|
||
|
||
// Position at line1 sub-row 4 (last sub-row of 5-row line)
|
||
app.v_offset = 5;
|
||
app.cursor_line = 1;
|
||
app.v_sub_offset = 0;
|
||
|
||
// Rebase
|
||
let (logical_top, sub) = app.rebase_offset_for_invalidate();
|
||
assert_eq!(logical_top, 1, "visual row 5 → line 1 (rows 1-5)");
|
||
assert_eq!(sub, 4, "sub-row 4 within line 1");
|
||
|
||
app.v_offset = logical_top;
|
||
app.v_sub_offset = sub;
|
||
|
||
// Simulate VHI rebuild with SHRUNK line height (e.g., JSON toggle)
|
||
// line1 now only 2 visual rows instead of 5
|
||
install_vhi(&mut app, &[1, 2, 1]);
|
||
|
||
// Recalibration should clamp sub=4 to new height-1=1
|
||
let logical_top = app.v_offset.min(app.total_lines().saturating_sub(1));
|
||
let sub = app.v_sub_offset;
|
||
let first_visual = app.cursor_to_first_visual_row(logical_top);
|
||
let line_height = app
|
||
.get_visual_height_index()
|
||
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
|
||
let clamped_sub = sub.min(line_height.saturating_sub(1));
|
||
app.v_offset = first_visual.saturating_add(clamped_sub);
|
||
app.v_sub_offset = 0;
|
||
|
||
// line1 starts at visual row 1, clamped sub=1 → visual row 2
|
||
assert_eq!(app.v_offset, 2, "v_offset should be clamped to visual row 2 (line1 row 1 of 2)");
|
||
assert_eq!(app.v_sub_offset, 0);
|
||
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_loading_to_ready_rebases_visual_offset() {
|
||
let content = "line0\nline1\nline2\nline3\nline4\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: 2 rows per line
|
||
install_vhi(&mut app, &[2usize; 5]);
|
||
|
||
// Scroll to visual row 3 → line1 sub-row 1
|
||
app.v_offset = 3;
|
||
app.cursor_line = 1;
|
||
app.v_sub_offset = 0;
|
||
|
||
// Simulate Loading→Ready invalidation rebase
|
||
let (new_offset, new_sub) = app.rebase_offset_for_invalidate();
|
||
assert_eq!(new_offset, 1, "visual row 3 → line 1");
|
||
assert_eq!(new_sub, 1, "sub-row 1 within line 1");
|
||
|
||
// Apply rebased values and clear VHI (simulate invalidate)
|
||
app.v_offset = new_offset;
|
||
app.v_sub_offset = new_sub;
|
||
if let AppLoadingState::Ready { reader } = &mut app.loading_state {
|
||
reader.invalidate_visual_height_index();
|
||
}
|
||
|
||
// VHI is now None → scroll_down_line uses else branch (v_sub_offset path)
|
||
// "line1" at width=80 → compute_visual_height=1, sub=1+1 >= 1 → advance
|
||
app.scroll_down_line();
|
||
assert_eq!(app.v_offset, 2, "should advance to line 2 (line1 height=1, sub overflow)");
|
||
assert_eq!(app.v_sub_offset, 0);
|
||
|
||
cleanup(&path);
|
||
});
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_compute_line_entry_oversized_raw_guard() {
|
||
let long_line = "x".repeat(MAX_WRAP_INPUT_LEN + 1);
|
||
let path = make_temp_file(&format!("short\n{}\n", long_line));
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
assert!(app.is_loaded());
|
||
|
||
let entry = app.compute_line_entry(1, 80);
|
||
assert_eq!(entry.visual_height, 1, "oversized line should have height 1");
|
||
assert!(entry.level.is_none(), "oversized raw line should skip detect_level");
|
||
assert_eq!(entry.wrapped_rows.len(), 1);
|
||
assert!(
|
||
entry.wrapped_rows[0].len() <= 80,
|
||
"preview should be bounded to width"
|
||
);
|
||
|
||
let short_entry = app.compute_line_entry(0, 80);
|
||
assert_eq!(short_entry.visual_height, 1);
|
||
assert!(short_entry.level.is_none(), "'short' has no log level");
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_compute_visual_height_oversized_raw_guard() {
|
||
let long_line = "a".repeat(MAX_WRAP_INPUT_LEN + 100);
|
||
let path = make_temp_file(&format!("hi\n{}\n", long_line));
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
assert!(app.is_loaded());
|
||
|
||
assert_eq!(app.compute_visual_height(0, 80), 1, "normal line height=1");
|
||
assert_eq!(app.compute_visual_height(1, 80), 1, "oversized line height=1");
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_compute_line_entry_post_format_guard() {
|
||
// Build a JSON object whose raw form is just under MAX_WRAP_INPUT_LEN
|
||
// but whose pretty-printed form (with added spaces around ':') exceeds it.
|
||
let inner = "x".repeat(MAX_WRAP_INPUT_LEN - 11);
|
||
let json_line = format!(r#"{{"msg":"{}"}}"#, inner);
|
||
assert!(
|
||
json_line.len() < MAX_WRAP_INPUT_LEN,
|
||
"raw JSON should be under limit: got {}",
|
||
json_line.len()
|
||
);
|
||
|
||
let path = make_temp_file(&format!("{}\n", json_line));
|
||
let result = std::panic::catch_unwind(|| {
|
||
let mut app = App::new();
|
||
load_file_ready(&mut app, &path);
|
||
app.json_format = true;
|
||
|
||
let entry = app.compute_line_entry(0, 80);
|
||
assert_eq!(entry.visual_height, 1, "post-format oversized should have height 1");
|
||
assert_eq!(entry.wrapped_rows.len(), 1);
|
||
});
|
||
cleanup(&path);
|
||
assert!(result.is_ok());
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_to_columns_basic() {
|
||
assert_eq!(truncate_to_columns("hello world", 5), "hello");
|
||
assert_eq!(truncate_to_columns("hi", 80), "hi");
|
||
assert_eq!(truncate_to_columns("", 80), "");
|
||
assert_eq!(truncate_to_columns("abc", 0), "");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_to_columns_cjk() {
|
||
// Each CJK char is 2 columns wide
|
||
assert_eq!(truncate_to_columns("你好世界", 3), "你");
|
||
assert_eq!(truncate_to_columns("你好世界", 4), "你好");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_to_columns_tab() {
|
||
assert_eq!(truncate_to_columns("a\tb", 3), "a");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_tab_fits_fully() {
|
||
assert_eq!(truncate_to_columns("a\tb", 5), "a b");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_tab_exact_boundary() {
|
||
assert_eq!(truncate_to_columns("a\tb", 4), "a ");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_cjk_at_boundary() {
|
||
assert_eq!(truncate_to_columns("你好", 3), "你");
|
||
assert_eq!(truncate_to_columns("你好", 4), "你好");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_control_char() {
|
||
assert_eq!(truncate_to_columns("a\x07b", 5), "a\x07b");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_exact_width() {
|
||
assert_eq!(truncate_to_columns("abcde", 5), "abcde");
|
||
assert_eq!(truncate_to_columns("abcdef", 5), "abcde");
|
||
}
|
||
|
||
#[test]
|
||
fn test_truncate_only_tab() {
|
||
assert_eq!(truncate_to_columns("\t", 4), " ");
|
||
assert_eq!(truncate_to_columns("\t", 3), "");
|
||
}
|
||
} |