10 Commits
Author SHA1 Message Date
dailz b3256b2917 fix: audit fixes for 4 medium-severity bugs: index_cache tail hash, read_cache doc, mutex poison recovery, Remove event handling 2026-05-10 17:03:07 +08:00
dailz fb23e4c7cb fix(tui): smooth visual-row scrolling during Loading state
During Loading state (before VHI is built), j/k used to jump by logical
line, visually skipping multiple wrapped rows. Now uses v_sub_offset to
track position within a wrapped line, enabling smooth 1-visual-row scroll.

- Add v_sub_offset field to App for sub-line visual position tracking
- scroll_down/up_line else branch: advance v_sub_offset, wrap to next line
- ensure_viewport_cache Loading path: pass v_sub_offset as offset_in_line
- ensure_cursor_visible: skip during Loading (scroll functions manage it)
- Reset v_sub_offset on Loading→Ready, scroll_to_top, scroll_to_bottom
- Add 3 tests for Loading-state sub-offset scrolling behavior
2026-04-24 19:04:30 +08:00
dailz 8c5a838db0 fix(tui): j/k scroll by visual row instead of logical line
When a JSON line wraps to many visual rows (e.g. 73 rows), pressing j
would skip the entire logical line, making wrapped content unreadable.
Now j/k scroll by 1 visual row when a VisualHeightIndex is available,
with cursor tracking the viewport center. Falls back to logical-line
scroll in Loading/no-index modes or when all content fits the viewport.

Adds 4 tests: visual scroll down, visual scroll up, small-file fallback,
j/k roundtrip.
2026-04-24 07:41:30 +08:00
dailz 81cc72bd84 test(tui): add Loading + JSON expansion unit tests 2026-04-14 17:38:35 +08:00
dailz 06b2a39816 fix(tui): post-check cursor visibility in viewport during Loading 2026-04-14 17:09:50 +08:00
dailz 0941092b07 feat(tui): enable JSON expansion during Loading state 2026-04-14 16:52:17 +08:00
dailzandSisyphus 4ec3eb7cee fix(core): cap incremental scan in get_line() to prevent O(N) blocking
Add SCAN_AHEAD_LIMIT (10000 lines) to get_line() in Sampling state. Without this, jumping to end-of-file (G) during progressive loading would scan the entire file byte-by-byte on the main thread, blocking the UI and consuming excessive memory. Lines beyond the scanned region + limit now return None, which the TUI renders as empty.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-14 09:58:20 +08:00
dailzandSisyphus 05491304bf fix(tui): allow free scrolling during progressive loading
total_lines() was returning sampled_line_count() (only ~300 lines from the initial 64KB scan) during the Loading state, capping the scroll range. Use estimated_lines instead so the user can scroll to any position while indexing runs in the background. get_line() already supports incremental forward scanning on demand.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-14 09:36:03 +08:00
dailzandSisyphus a03af7e74e feat(core): implement FileWatcher for live file tailing
Complete FileWatcher implementation using notify 8.x crate with get_inode() for cross-platform file identity. Support append detection for incremental index updates and truncate detection for full reloads.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-14 09:07:27 +08:00
dailzandSisyphus bab3d9078a feat(tui): replace O(N) scanning with progressive loading and VisualHeightIndex
Replace synchronous file loading with AppLoadingState state machine (Empty/Loading/Ready/Error) for instant interactivity. Add ViewportCache for on-demand viewport computation, replacing global wrap/level caches. Integrate background indexer polling and file watcher events into the TUI event loop. Add loading UI with progress percentage, estimated line numbers with ~ prefix, and error state display. Eliminate all O(N) linear scans using VisualHeightIndex binary search.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-14 09:07:18 +08:00
8 changed files with 2458 additions and 276 deletions
+7 -6
View File
@@ -86,11 +86,12 @@ fn compute_file_hash(file_path: &Path) -> std::io::Result<u64> {
let mut tf = std::io::BufReader::new(file);
tf.read_exact(&mut tail)?;
} else {
// File is small enough that head already covers everything;
// tail overlaps with head — just take the last tail_size bytes
let start = head.len().saturating_sub(tail_size);
tail = head[start..].to_vec();
tail.resize(tail_size, 0);
// File fits within head+tail window. Seek to read the real tail
// for correctness — approximating from head misses bytes beyond head_size.
let tail_start = file_size.saturating_sub(tail_size as u64);
let mut file = std::fs::File::open(file_path)?;
std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(tail_start))?;
file.read_exact(&mut tail)?;
}
let mut hasher_state = xxhash_rust::xxh3::Xxh3::new();
@@ -242,4 +243,4 @@ mod tests {
assert_ne!(h1, h2, "hash should change when content changes");
}
}
}
+11 -15
View File
@@ -389,6 +389,11 @@ pub fn spawn_visual_height_rebuild(
/// Maximum bytes to scan during initial open for the Sampling state.
const INITIAL_SCAN_BYTES: usize = 64 * 1024;
/// Maximum number of additional lines to scan beyond what's already cached
/// in a single `get_line()` call. Prevents O(N) blocking when the user
/// jumps far ahead (e.g. `G` to end-of-file) during the Loading state.
const SCAN_AHEAD_LIMIT: usize = 10_000;
pub struct ProgressiveFileReader {
path: PathBuf,
pub state: ReaderState,
@@ -512,32 +517,23 @@ impl ProgressiveFileReader {
let mut newlines = scanned_newlines.borrow_mut();
let mut up_to = scanned_up_to.borrow_mut();
// We need `idx + 1` newlines to have `idx` lines available.
// Line i starts after the i-th newline (or at byte 0 for line 0)
// and ends at the (i+1)-th newline (or end of file).
// So to return line `idx`, we need at least `idx + 1` newline positions
// (the idx-th newline marks end of line idx-1/start of line idx,
// and the (idx+1)-th newline marks end of line idx).
// Actually: line 0 starts at byte 0, ends at newline[0].
// line 1 starts at newline[0]+1, ends at newline[1].
// line i starts at newline[i-1]+1, ends at newline[i].
// So to serve line idx, we need newline positions up to index idx.
let scan_limit = newlines.len() + SCAN_AHEAD_LIMIT;
// Extend scan if needed
while newlines.len() <= idx && *up_to < mmap_data.len() {
// Extend scan if needed, but stop at scan_limit to avoid O(N) blocking
while newlines.len() <= idx
&& newlines.len() < scan_limit
&& *up_to < mmap_data.len()
{
let remaining = &mmap_data[*up_to..];
if let Some(rel_pos) = memchr::memchr(b'\n', remaining) {
newlines.push(*up_to + rel_pos);
*up_to += rel_pos + 1;
} else {
// No more newlines; rest of file is the last line
*up_to = mmap_data.len();
break;
}
}
// Check if line idx is beyond what's available
// If idx == newlines.len(), it's the last line (after last newline, no trailing \n)
if idx > newlines.len() {
return None;
}
+435
View File
@@ -0,0 +1,435 @@
// NOTE: This module is implemented and tested but not yet integrated into
// the production read path (FileReader uses mmap for line access).
// It is kept as a building block for a future pread-based reader that
// would avoid the SIGBUS risk of mmap.
// ─── read_cache.rs ─────────────────────────────────────────────────────────
// 16-slot LRU read cache with 4KB page-aligned keys.
// Reduces syscalls by caching recently-read 4KB blocks.
// Cross-block reads are handled via a spill buffer (not cached).
// ──────────────────────────────────────────────────────────────────────────
use std::fs::File;
use std::io;
use std::os::unix::fs::FileExt;
const LRU_SLOTS: usize = 16;
pub const BLOCK_ALIGN: usize = 4096;
struct CacheSlot {
buf: Vec<u8>,
block_offset: u64,
len: usize,
last_access: u64,
}
pub struct LruReadCache {
slots: [CacheSlot; LRU_SLOTS],
spill_buf: Vec<u8>,
spill_len: usize,
tick: u64,
}
pub type ReadCache = LruReadCache;
impl Default for LruReadCache {
fn default() -> Self {
Self {
slots: std::array::from_fn(|_| CacheSlot {
buf: vec![0u8; BLOCK_ALIGN],
block_offset: 0,
len: 0,
last_access: 0,
}),
spill_buf: Vec::new(),
spill_len: 0,
tick: 0,
}
}
}
impl LruReadCache {
pub fn new() -> Self {
Self::default()
}
/// Read `len` bytes starting at `offset`. Returns a slice into the cache
/// on a hit, or fills a cache slot on a miss. Cross-block reads go through
/// the spill buffer and are not cached.
pub fn get(&mut self, file: &File, offset: u64, len: usize) -> io::Result<&[u8]> {
let aligned_key = offset & !(BLOCK_ALIGN as u64 - 1);
let request_end = offset.saturating_add(len as u64);
let block_end = aligned_key + BLOCK_ALIGN as u64;
if request_end > block_end {
self.spill_buf.resize(len, 0);
let bytes_read = file.read_at(&mut self.spill_buf[..len], offset)?;
if bytes_read == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read 0 bytes"));
}
if bytes_read < len {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read"));
}
self.spill_len = len;
return Ok(&self.spill_buf[..len]);
}
let hit_idx = self.slots.iter().position(|slot| {
slot.block_offset == aligned_key && request_end <= slot.block_offset + slot.len as u64
});
if let Some(idx) = hit_idx {
self.slots[idx].last_access = self.tick;
self.tick += 1;
let start = (offset - self.slots[idx].block_offset) as usize;
return Ok(&self.slots[idx].buf[start..start + len]);
}
let mut evict_idx = 0;
let mut min_access = self.slots[0].last_access;
for (i, slot) in self.slots.iter().enumerate() {
if slot.last_access < min_access {
min_access = slot.last_access;
evict_idx = i;
}
}
let slot = &mut self.slots[evict_idx];
let bytes_read = file.read_at(&mut slot.buf, aligned_key)?;
// Note: get(file, 0, 0) on an empty file now returns Err (old code returned Ok(&[])).
// No callers pass len == 0, so this is a safe semantic change.
if bytes_read == 0 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read 0 bytes"));
}
slot.block_offset = aligned_key;
slot.len = bytes_read;
slot.last_access = self.tick;
self.tick += 1;
if request_end > aligned_key + bytes_read as u64 {
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read"));
}
let start = (offset - slot.block_offset) as usize;
Ok(&slot.buf[start..start + len])
}
/// Invalidate all cache slots and the spill buffer.
pub fn clear(&mut self) {
for slot in &mut self.slots {
slot.len = 0;
}
self.spill_len = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
fn make_file(data: &[u8]) -> NamedTempFile {
let mut f = NamedTempFile::new().unwrap();
f.write_all(data).unwrap();
f.flush().unwrap();
f
}
#[test]
fn cache_hit_returns_same_data() {
// Read the same range twice — second read should be a cache hit.
let f = make_file(b"Hello, World! This is a test of the cache.");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let first = cache.get(&file, 0, 13).unwrap().to_vec();
let second = cache.get(&file, 0, 13).unwrap().to_vec();
assert_eq!(first, second);
assert_eq!(&first, b"Hello, World!");
}
#[test]
fn cache_miss_reads_correct_data() {
// Two non-overlapping ranges — both must be misses but return correct data.
let data = b"0123456789ABCDEFGHIJ";
let f = make_file(data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let a = cache.get(&file, 0, 10).unwrap().to_vec();
assert_eq!(&a, b"0123456789");
let b = cache.get(&file, 10, 10).unwrap().to_vec();
assert_eq!(&b, b"ABCDEFGHIJ");
}
#[test]
fn cross_block_read_uses_spill_buffer() {
// Read spanning a 4KB block boundary uses spill buffer, not cache.
let data = vec![0xABu8; 8192];
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
// First, cache block 0.
let a = cache.get(&file, 0, 100).unwrap().to_vec();
assert_eq!(a, vec![0xABu8; 100]);
// Now read spanning the boundary: offset=4000, len=200 spans [0,4096) and [4096,8192).
let b = cache.get(&file, 4000, 200).unwrap().to_vec();
assert_eq!(b, vec![0xABu8; 200]);
}
#[test]
fn empty_file_read_fails() {
let f = make_file(b"");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
// Reading 1 byte from empty file should fail.
let result = cache.get(&file, 0, 1);
assert!(result.is_err());
}
#[test]
fn clear_invalidates_cache() {
let data = b"original data here";
let f = make_file(data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
// Populate cache.
let first = cache.get(&file, 0, 10).unwrap().to_vec();
assert_eq!(&first, b"original d");
// Invalidate.
cache.clear();
// After clear, reading same range should still work (re-reads from file).
let after = cache.get(&file, 0, 10).unwrap().to_vec();
assert_eq!(&after, b"original d");
}
// ─── New LRU-specific tests ───────────────────────────────────────────
#[test]
fn lru_multi_block_hit() {
// Read 3 different aligned blocks, verify re-reading each hits cache.
let data = vec![0u8; BLOCK_ALIGN * 4];
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
// Write distinct patterns to each block.
drop(file);
{
use std::io::Seek;
let mut f2 = std::fs::OpenOptions::new()
.write(true)
.open(f.path())
.unwrap();
f2.seek(std::io::SeekFrom::Start(0)).unwrap();
f2.write_all(&[1u8; BLOCK_ALIGN]).unwrap();
f2.seek(std::io::SeekFrom::Start(BLOCK_ALIGN as u64))
.unwrap();
f2.write_all(&[2u8; BLOCK_ALIGN]).unwrap();
f2.seek(std::io::SeekFrom::Start((BLOCK_ALIGN * 2) as u64))
.unwrap();
f2.write_all(&[3u8; BLOCK_ALIGN]).unwrap();
}
let file = File::open(f.path()).unwrap();
let block0 = cache.get(&file, 0, 16).unwrap().to_vec();
let block1 = cache.get(&file, BLOCK_ALIGN as u64, 16).unwrap().to_vec();
let block2 = cache
.get(&file, (BLOCK_ALIGN * 2) as u64, 16)
.unwrap()
.to_vec();
assert_eq!(block0, vec![1u8; 16]);
assert_eq!(block1, vec![2u8; 16]);
assert_eq!(block2, vec![3u8; 16]);
// Re-read — should hit cache and return same data.
let block0_again = cache.get(&file, 0, 16).unwrap().to_vec();
let block1_again = cache.get(&file, BLOCK_ALIGN as u64, 16).unwrap().to_vec();
let block2_again = cache
.get(&file, (BLOCK_ALIGN * 2) as u64, 16)
.unwrap()
.to_vec();
assert_eq!(block0_again, block0);
assert_eq!(block1_again, block1);
assert_eq!(block2_again, block2);
}
#[test]
fn lru_eviction_order() {
// Fill all 16 slots, re-access slot 0, add 17th block,
// verify slot at offset 4096 (slot 1) evicted, not slot 0.
let data = vec![0u8; BLOCK_ALIGN * 20];
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
// Fill all 16 slots with blocks 0..16.
for i in 0..16u64 {
cache.get(&file, i * BLOCK_ALIGN as u64, 1).unwrap();
}
// Re-access block 0 so it's not the LRU.
cache.get(&file, 0, 1).unwrap();
// Add 17th block — should evict block 1 (offset 4096), which is the oldest
// since block 0 was re-accessed.
cache.get(&file, 16 * BLOCK_ALIGN as u64, 1).unwrap();
// Reading block 1 (offset 4096) should be a miss (evicted).
// We verify by checking the cache slots: block 1 should not be cached.
let has_block1 = cache
.slots
.iter()
.any(|s| s.block_offset == BLOCK_ALIGN as u64 && s.len > 0);
assert!(!has_block1, "block 1 should have been evicted");
// Block 0 should still be cached.
let has_block0 = cache.slots.iter().any(|s| s.block_offset == 0 && s.len > 0);
assert!(has_block0, "block 0 should still be cached");
}
#[test]
fn lru_clear_all_slots() {
// Fill 3+ slots, call clear(), verify subsequent reads all miss.
let data = vec![0x42u8; BLOCK_ALIGN * 4];
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
// Fill 3 slots.
cache.get(&file, 0, 1).unwrap();
cache.get(&file, BLOCK_ALIGN as u64, 1).unwrap();
cache.get(&file, (BLOCK_ALIGN * 2) as u64, 1).unwrap();
cache.clear();
// All slots should have len == 0.
for slot in &cache.slots {
assert_eq!(slot.len, 0);
}
assert_eq!(cache.spill_len, 0);
// Re-read should still work (reads from file).
let val = cache.get(&file, 0, 1).unwrap();
assert_eq!(val[0], 0x42);
}
#[test]
fn lru_aligned_keys() {
// offset=100 and offset=200 both align to block 0 — should hit same slot.
let data = vec![0xEEu8; BLOCK_ALIGN];
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let a = cache.get(&file, 100, 10).unwrap().to_vec();
assert_eq!(a, vec![0xEEu8; 10]);
let b = cache.get(&file, 200, 10).unwrap().to_vec();
assert_eq!(b, vec![0xEEu8; 10]);
// Both should be served from the same cache slot (block 0).
let slot_count = cache
.slots
.iter()
.filter(|s| s.block_offset == 0 && s.len > 0)
.count();
assert_eq!(slot_count, 1, "only one slot should hold block 0");
}
#[test]
fn lru_cross_block_uses_spill_buffer() {
// File [0xAA×4096, 0xBB×4096], get(file, 4090, 20) → [0xAA×6, 0xBB×14].
let mut data = vec![0xAAu8; BLOCK_ALIGN];
data.extend_from_slice(&vec![0xBBu8; BLOCK_ALIGN]);
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let result = cache.get(&file, 4090, 20).unwrap().to_vec();
assert_eq!(&result[..6], &[0xAAu8; 6]);
assert_eq!(&result[6..], &[0xBBu8; 14]);
// Verify no cache slot holds block 0 or block 1.
for slot in &cache.slots {
assert!(
slot.len == 0,
"cross-block data should not be cached in slots"
);
}
}
#[test]
fn lru_partial_last_block() {
// 5000 byte file, get(file, 4096, 904) reads last 904 bytes,
// then get(file, 4096, 100) hits cache.
let data = vec![0x77u8; 5000];
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let first = cache.get(&file, 4096, 904).unwrap().to_vec();
assert_eq!(first.len(), 904);
assert_eq!(first, vec![0x77u8; 904]);
// Second read of 100 bytes from same block should hit cache.
let second = cache.get(&file, 4096, 100).unwrap().to_vec();
assert_eq!(second.len(), 100);
assert_eq!(second, vec![0x77u8; 100]);
}
#[test]
fn lru_short_file_overread() {
// 1 byte file, get(file, 0, 100) → Err (not panic).
let f = make_file(b"X");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let result = cache.get(&file, 0, 100);
assert!(result.is_err());
}
#[test]
fn lru_empty_file_returns_error() {
// Empty file, get(file, 0, 1) → Err.
let f = make_file(b"");
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
let result = cache.get(&file, 0, 1);
assert!(result.is_err());
}
#[test]
fn long_line_spans_multiple_blocks() {
// Create file with line >4KB: b'A'×4090 + "\n" + b'B'×4090 + "\n"
let mut data = vec![b'A'; 4090];
data.push(b'\n');
data.extend_from_slice(&vec![b'B'; 4090]);
data.push(b'\n');
let f = make_file(&data);
let file = File::open(f.path()).unwrap();
let mut cache = ReadCache::new();
// Read the first line: offset 0, len 4091 (fits in block 0 since 4091 <= 4096).
let line1 = cache.get(&file, 0, 4091).unwrap().to_vec();
assert_eq!(&line1[..4090], &[b'A'; 4090]);
assert_eq!(line1[4090], b'\n');
// Read the second line: offset 4091, len 4091 (starts in block 0, ends in block 1 — cross-block).
let line2 = cache.get(&file, 4091, 4091).unwrap().to_vec();
assert_eq!(&line2[..4090], &[b'B'; 4090]);
assert_eq!(line2[4090], b'\n');
}
}
+235 -1
View File
@@ -1,2 +1,236 @@
pub struct FileWatcher {/* TODO */}
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crossbeam_channel::{bounded, Receiver, Sender};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use crate::error::Result;
// ─── FileEvent ──────────────────────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileEvent {
Appended { new_size: u64 },
Truncated { new_size: u64 },
Rotated { new_inode: u64 },
Removed,
}
// ─── get_inode ──────────────────────────────────────────────────────────────
#[cfg(unix)]
fn get_inode(path: &Path) -> std::io::Result<u64> {
use std::os::unix::fs::MetadataExt;
Ok(std::fs::metadata(path)?.ino())
}
#[cfg(not(unix))]
fn get_inode(_path: &Path) -> std::io::Result<u64> {
Ok(0) // rotation detection not supported on non-Unix
}
// ─── WatchState ─────────────────────────────────────────────────────────────
struct WatchState {
last_size: u64,
last_inode: u64,
}
// ─── FileWatcher ────────────────────────────────────────────────────────────
pub struct FileWatcher {
rx: Receiver<FileEvent>,
_watcher: RecommendedWatcher,
}
impl FileWatcher {
pub fn watch(path: &Path) -> Result<Self> {
let (tx, rx): (Sender<FileEvent>, Receiver<FileEvent>) = bounded(100);
let initial_size = std::fs::metadata(path)?.len();
let initial_inode = get_inode(path).unwrap_or(0);
let state = Arc::new(Mutex::new(WatchState {
last_size: initial_size,
last_inode: initial_inode,
}));
let watch_path: PathBuf = path.to_path_buf();
let mut watcher =
notify::recommended_watcher(move |res: std::result::Result<Event, notify::Error>| {
let event = match res {
Ok(e) => e,
Err(_) => return,
};
match event.kind {
EventKind::Modify(_) | EventKind::Create(_) | EventKind::Any => {}
EventKind::Remove(_) => {
let _ = tx.send(FileEvent::Removed);
return;
}
_ => return,
}
if !event.paths.iter().any(|p| p == &watch_path) {
return;
}
let current_inode = get_inode(&watch_path).unwrap_or(0);
let current_size = std::fs::metadata(&watch_path).map(|m| m.len()).unwrap_or(0);
let mut st = state.lock().unwrap_or_else(|poison| {
// Recover from poisoned mutex — state only tracks last_size
// and last_inode for event dedup. Stale values at worst
// cause a duplicate event, which is harmless.
poison.into_inner()
});
if current_inode != 0 && st.last_inode != 0 && current_inode != st.last_inode {
let _ = tx.send(FileEvent::Rotated {
new_inode: current_inode,
});
st.last_inode = current_inode;
st.last_size = current_size;
} else if current_size > st.last_size {
let _ = tx.send(FileEvent::Appended {
new_size: current_size,
});
st.last_size = current_size;
st.last_inode = current_inode;
} else if current_size < st.last_size {
let _ = tx.send(FileEvent::Truncated {
new_size: current_size,
});
st.last_size = current_size;
st.last_inode = current_inode;
}
})?;
watcher.watch(path, RecursiveMode::NonRecursive)?;
Ok(Self {
rx,
_watcher: watcher,
})
}
pub fn try_recv(&self) -> Option<FileEvent> {
self.rx.try_recv().ok()
}
}
// ─── SmartFollow ────────────────────────────────────────────────────────────
pub struct SmartFollow {/* TODO */}
// ─── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::thread;
use std::time::Duration;
fn collect_events(watcher: &FileWatcher, timeout_ms: u64) -> Vec<FileEvent> {
let mut events = Vec::new();
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
while std::time::Instant::now() < deadline {
if let Some(e) = watcher.try_recv() {
events.push(e);
}
thread::sleep(Duration::from_millis(50));
}
// final drain
while let Some(e) = watcher.try_recv() {
events.push(e);
}
events
}
#[test]
fn test_watcher_append() {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().join("test.log");
std::fs::write(&path, b"hello\n").expect("write initial");
let watcher = FileWatcher::watch(&path).expect("start watcher");
{
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.expect("open for append");
f.write_all(b"world\n").expect("append data");
}
let events = collect_events(&watcher, 1000);
let appended: Vec<&FileEvent> = events
.iter()
.filter(|e| matches!(e, FileEvent::Appended { .. }))
.collect();
assert!(
!appended.is_empty(),
"should detect append event, got: {events:?}"
);
}
#[test]
fn test_watcher_truncate() {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().join("test.log");
std::fs::write(&path, b"hello world this is a long line\n").expect("write initial");
let watcher = FileWatcher::watch(&path).expect("start watcher");
// File::create truncates to 0 bytes
let _ = std::fs::File::create(&path).expect("truncate file");
let events = collect_events(&watcher, 1000);
let truncated: Vec<&FileEvent> = events
.iter()
.filter(|e| matches!(e, FileEvent::Truncated { .. }))
.collect();
assert!(
!truncated.is_empty(),
"should detect truncate event, got: {events:?}"
);
}
#[test]
fn test_watcher_idle() {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().join("idle.log");
std::fs::write(&path, b"static content\n").expect("write");
let watcher = FileWatcher::watch(&path).expect("start watcher");
thread::sleep(Duration::from_millis(300));
assert_eq!(watcher.try_recv(), None, "no events on idle file");
}
#[test]
fn test_get_inode() {
let dir = tempfile::tempdir().expect("create temp dir");
let path = dir.path().join("inode_test.txt");
std::fs::write(&path, b"test").expect("write file");
let inode = get_inode(&path).expect("get inode");
#[cfg(unix)]
assert!(inode > 0, "inode should be positive on Unix");
#[cfg(not(unix))]
assert_eq!(inode, 0, "inode should be 0 on non-Unix");
}
#[test]
fn test_file_event_equality() {
let a = FileEvent::Appended { new_size: 100 };
let b = FileEvent::Appended { new_size: 100 };
assert_eq!(a, b);
let c = FileEvent::Truncated { new_size: 0 };
assert_ne!(a, c);
let d = FileEvent::Rotated { new_inode: 42 };
assert_ne!(a, d);
}
}
+1
View File
@@ -10,6 +10,7 @@ clap.workspace = true
anyhow.workspace = true
log-viewer-core.workspace = true
serde_json.workspace = true
crossbeam-channel.workspace = true
[dev-dependencies]
tempfile = { workspace = true }
+1542 -218
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -31,6 +31,8 @@ fn main() -> anyhow::Result<()> {
}
while !app.should_quit {
app.poll_background_indexer();
app.poll_file_watcher();
terminal.draw(|frame| ui::render(frame, &mut app))?;
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
match crossterm::event::read()? {
+225 -36
View File
@@ -1,4 +1,4 @@
use ratatui::style::{Color, Style};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::app::{App, AppMode};
@@ -42,6 +42,10 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
// ── Title bar ──────────────────────────────────────────────────
let title_text = if app.mode == AppMode::Settings {
" Color Settings".to_string()
} else if app.is_loading() {
let name = app.file_name().unwrap_or("unknown");
let pct = app.loading_progress().map_or(0, |p| p as usize);
format!(" {} [Loading... {}%]", name, pct)
} else if app.is_loaded() {
let name = app.file_name().unwrap_or("unknown");
let cursor_display = if app.total_lines() == 0 {
@@ -61,6 +65,22 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
// ── Content area ───────────────────────────────────────────────
if app.mode == AppMode::Settings {
render_settings(frame, app, outer[1]);
} else if app.is_error() {
let msg = app.error_message().unwrap_or_default();
let error_lines = vec![
Line::from(""),
Line::from(""),
Line::styled(
format!(" Error: {}", msg),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Line::from(""),
Line::styled(" Press q to quit", Style::default().fg(Color::DarkGray)),
];
frame.render_widget(Paragraph::new(error_lines).centered(), outer[1]);
} else if app.is_loading() {
// Show content from sampling during loading
render_content(frame, app, outer[1]);
} else if !app.is_loaded() {
frame.render_widget(Paragraph::new(" No file loaded"), outer[1]);
} else {
@@ -70,6 +90,30 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
// ── Status bar ─────────────────────────────────────────────────
let status_text = if app.mode == AppMode::Settings {
" j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel"
} else if app.is_error() {
" Press q to quit"
} else if app.is_loading() {
let pct = app.loading_progress().map_or(0, |p| p as usize);
let est = app
.estimated_lines()
.map_or("?".to_string(), |e| format!("~{}", e));
let name = app.file_name().unwrap_or("unknown");
let status = format!(
" Indexing... {}% | {} lines | {} | j/k:scroll q:quit",
pct, est, name
);
frame.render_widget(
Paragraph::new(status).style(Style::default().fg(Color::Yellow)),
outer[2],
);
return;
} else if app.is_loaded() {
let name = app.file_name().unwrap_or("unknown");
let total = app.total_lines();
let cursor_display = if total == 0 { 0 } else { app.cursor_line + 1 };
let status = format!(" {} [{}/{}] | j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit", name, cursor_display, total);
frame.render_widget(Paragraph::new(status), outer[2]);
return;
} else {
" j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit"
};
@@ -165,8 +209,11 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
} else {
0
};
let is_loading = app.is_loading();
let gutter_prefix_extra = if is_loading { 1 } else { 0 };
let gutter_width = if total_lines > 0 {
line_num_width + 1 + 1
line_num_width + gutter_prefix_extra + 1 + 1
} else {
0
};
@@ -177,62 +224,73 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
return;
}
app.recompute_wrap_cache(actual_content_width);
let mut visual_acc: usize = 0;
let mut start_logical: usize = 0;
let mut offset_in_line: usize = 0;
let v_offset = app.v_offset;
for (i, &h) in app.visual_heights.iter().enumerate() {
if visual_acc.saturating_add(h) > v_offset {
start_logical = i;
offset_in_line = v_offset.saturating_sub(visual_acc);
break;
}
visual_acc += h;
if i == app.visual_heights.len() - 1 {
start_logical = i;
offset_in_line = 0;
}
}
let (start_logical, offset_in_line) = app.ensure_viewport_cache(actual_content_width);
let mut lines: Vec<Line> = Vec::new();
let mut current_visual_offset: usize = 0;
let available_rows = content_height;
for logical_line in start_logical..total_lines {
let wrapped = &app.wrap_cache[logical_line];
let gutter_style = if is_loading {
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM)
} else {
Style::default().fg(Color::DarkGray)
};
for (entry_idx, entry) in app.viewport_cache.entries.iter().enumerate() {
let logical_line = app.viewport_cache.logical_start + entry_idx;
let start_row = if logical_line == start_logical {
offset_in_line
} else {
0
};
for (visual_row, text) in wrapped.iter().enumerate().skip(start_row) {
for (visual_row, text) in entry.wrapped_rows.iter().enumerate().skip(start_row) {
if current_visual_offset >= available_rows {
break;
}
let is_cursor = logical_line == app.cursor_line;
let level = app.level_cache.get(logical_line).and_then(|l| l.as_ref());
let level = entry.level.as_ref();
let bg_color = if is_cursor {
Color::DarkGray
} else {
Color::Reset
};
let level_fg = level_fg(level, &app.color_config).unwrap_or(Color::White);
let gutter_text = if visual_row == 0 {
format!(
"{:>width$} \u{2502}",
logical_line + 1,
width = line_num_width
)
if is_loading {
format!(
"~{:>width$} \u{2502}",
logical_line + 1,
width = line_num_width
)
} else {
format!(
"{:>width$} \u{2502}",
logical_line + 1,
width = line_num_width
)
}
} else if is_loading {
format!(" {:width$} \u{2502}", "", width = line_num_width)
} else {
format!("{:width$} \u{2502}", "", width = line_num_width)
};
lines.push(build_line_spans(
gutter_text,
text.clone(),
is_cursor,
level,
&app.color_config,
));
let effective_gutter_style = if is_cursor {
gutter_style.bg(bg_color)
} else {
gutter_style
};
lines.push(Line::from(vec![
Span::styled(gutter_text, effective_gutter_style),
Span::styled(text.clone(), Style::default().fg(level_fg).bg(bg_color)),
]));
current_visual_offset += 1;
}
@@ -304,4 +362,135 @@ mod tests {
assert_eq!(line.spans[0].style.bg, Some(Color::Reset));
assert_eq!(line.spans[1].style.bg, Some(Color::Reset));
}
fn render_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer {
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal.draw(|frame| render(frame, app)).unwrap();
terminal.backend().buffer().clone()
}
fn make_temp_file(content: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir();
let name = format!("log_viewer_ui_test_{}_{}", std::process::id(), id);
let path = dir.join(name);
std::fs::write(&path, content).unwrap();
path
}
#[test]
fn test_render_error_state() {
let mut app = App::new();
app.set_error_state("file not found");
let buf = render_to_buffer(&mut app, 80, 24);
let mut found_error = false;
let mut found_quit = false;
for row in 0..23 {
let content: String = (0..80)
.map(|c| buf.cell((c, row)).unwrap().symbol().to_string())
.collect();
if content.contains("Error") && content.contains("file not found") {
found_error = true;
}
if content.contains("Press q to quit") {
found_quit = true;
}
}
assert!(found_error, "content area should show error message");
assert!(found_quit, "content area should show quit hint");
}
#[test]
fn test_render_loading_state_status_bar() {
let path = make_temp_file("line1\nline2\nline3\nline4\nline5\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
app.load_file(path.to_str().unwrap()).unwrap();
if app.is_loading() {
let buf = render_to_buffer(&mut app, 80, 24);
let status: String = (0..80)
.map(|c| buf.cell((c, 23)).unwrap().symbol().to_string())
.collect();
assert!(
status.contains("Indexing"),
"status bar should show 'Indexing', got: {}",
status
);
assert!(
status.contains("%"),
"status bar should show percentage, got: {}",
status
);
assert!(
status.contains("~"),
"status bar should show ~ for estimated lines, got: {}",
status
);
}
});
let _ = std::fs::remove_file(&path);
assert!(result.is_ok());
}
#[test]
fn test_render_loading_gutter_tilde_prefix() {
let path = make_temp_file("line1\nline2\nline3\n");
let result = std::panic::catch_unwind(|| {
let mut app = App::new();
app.load_file(path.to_str().unwrap()).unwrap();
if app.is_loading() {
let buf = render_to_buffer(&mut app, 80, 24);
let first_line: String = (0..10)
.map(|c| buf.cell((c, 1)).unwrap().symbol().to_string())
.collect();
assert!(
first_line.contains("~"),
"loading state gutter should have ~ prefix, got: {}",
first_line
);
}
});
let _ = std::fs::remove_file(&path);
assert!(result.is_ok());
}
#[test]
fn test_render_ready_state_status_bar() {
let path = make_temp_file("alpha\nbeta\ngamma\n");
let result = std::panic::catch_unwind(|| {
let data = std::fs::read(&path).unwrap();
let index = log_viewer_core::io::line_index::LineIndex::from_bytes(&data);
let _ = log_viewer_core::io::index_cache::IndexCache::save(&path, &index);
let mut app = App::new();
app.load_file(path.to_str().unwrap()).unwrap();
assert!(
app.is_loaded() && !app.is_loading(),
"should be in Ready state with cache hit"
);
let buf = render_to_buffer(&mut app, 80, 24);
let status: String = (0..80)
.map(|c| buf.cell((c, 23)).unwrap().symbol().to_string())
.collect();
assert!(
status.contains("1/") || status.contains("1/3"),
"status bar should show cursor position, got: {}",
status
);
});
let _ = std::fs::remove_file(&path);
assert!(result.is_ok());
}
}