Compare commits
10
Commits
f9f451e0d6
...
b3256b2917
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3256b2917 | ||
|
|
fb23e4c7cb | ||
|
|
8c5a838db0 | ||
|
|
81cc72bd84 | ||
|
|
06b2a39816 | ||
|
|
0941092b07 | ||
|
|
4ec3eb7cee | ||
|
|
05491304bf | ||
|
|
a03af7e74e | ||
|
|
bab3d9078a |
@@ -86,11 +86,12 @@ fn compute_file_hash(file_path: &Path) -> std::io::Result<u64> {
|
|||||||
let mut tf = std::io::BufReader::new(file);
|
let mut tf = std::io::BufReader::new(file);
|
||||||
tf.read_exact(&mut tail)?;
|
tf.read_exact(&mut tail)?;
|
||||||
} else {
|
} else {
|
||||||
// File is small enough that head already covers everything;
|
// File fits within head+tail window. Seek to read the real tail
|
||||||
// tail overlaps with head — just take the last tail_size bytes
|
// for correctness — approximating from head misses bytes beyond head_size.
|
||||||
let start = head.len().saturating_sub(tail_size);
|
let tail_start = file_size.saturating_sub(tail_size as u64);
|
||||||
tail = head[start..].to_vec();
|
let mut file = std::fs::File::open(file_path)?;
|
||||||
tail.resize(tail_size, 0);
|
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();
|
let mut hasher_state = xxhash_rust::xxh3::Xxh3::new();
|
||||||
|
|||||||
@@ -389,6 +389,11 @@ pub fn spawn_visual_height_rebuild(
|
|||||||
/// Maximum bytes to scan during initial open for the Sampling state.
|
/// Maximum bytes to scan during initial open for the Sampling state.
|
||||||
const INITIAL_SCAN_BYTES: usize = 64 * 1024;
|
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 {
|
pub struct ProgressiveFileReader {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
pub state: ReaderState,
|
pub state: ReaderState,
|
||||||
@@ -512,32 +517,23 @@ impl ProgressiveFileReader {
|
|||||||
let mut newlines = scanned_newlines.borrow_mut();
|
let mut newlines = scanned_newlines.borrow_mut();
|
||||||
let mut up_to = scanned_up_to.borrow_mut();
|
let mut up_to = scanned_up_to.borrow_mut();
|
||||||
|
|
||||||
// We need `idx + 1` newlines to have `idx` lines available.
|
let scan_limit = newlines.len() + SCAN_AHEAD_LIMIT;
|
||||||
// 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.
|
|
||||||
|
|
||||||
// Extend scan if needed
|
// Extend scan if needed, but stop at scan_limit to avoid O(N) blocking
|
||||||
while newlines.len() <= idx && *up_to < mmap_data.len() {
|
while newlines.len() <= idx
|
||||||
|
&& newlines.len() < scan_limit
|
||||||
|
&& *up_to < mmap_data.len()
|
||||||
|
{
|
||||||
let remaining = &mmap_data[*up_to..];
|
let remaining = &mmap_data[*up_to..];
|
||||||
if let Some(rel_pos) = memchr::memchr(b'\n', remaining) {
|
if let Some(rel_pos) = memchr::memchr(b'\n', remaining) {
|
||||||
newlines.push(*up_to + rel_pos);
|
newlines.push(*up_to + rel_pos);
|
||||||
*up_to += rel_pos + 1;
|
*up_to += rel_pos + 1;
|
||||||
} else {
|
} else {
|
||||||
// No more newlines; rest of file is the last line
|
|
||||||
*up_to = mmap_data.len();
|
*up_to = mmap_data.len();
|
||||||
break;
|
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() {
|
if idx > newlines.len() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 */}
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ clap.workspace = true
|
|||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
log-viewer-core.workspace = true
|
log-viewer-core.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
crossbeam-channel.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
+1541
-217
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,8 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
while !app.should_quit {
|
while !app.should_quit {
|
||||||
|
app.poll_background_indexer();
|
||||||
|
app.poll_file_watcher();
|
||||||
terminal.draw(|frame| ui::render(frame, &mut app))?;
|
terminal.draw(|frame| ui::render(frame, &mut app))?;
|
||||||
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
|
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
|
||||||
match crossterm::event::read()? {
|
match crossterm::event::read()? {
|
||||||
|
|||||||
+225
-36
@@ -1,4 +1,4 @@
|
|||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
|
|
||||||
use crate::app::{App, AppMode};
|
use crate::app::{App, AppMode};
|
||||||
@@ -42,6 +42,10 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
|||||||
// ── Title bar ──────────────────────────────────────────────────
|
// ── Title bar ──────────────────────────────────────────────────
|
||||||
let title_text = if app.mode == AppMode::Settings {
|
let title_text = if app.mode == AppMode::Settings {
|
||||||
" Color Settings".to_string()
|
" 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() {
|
} else if app.is_loaded() {
|
||||||
let name = app.file_name().unwrap_or("unknown");
|
let name = app.file_name().unwrap_or("unknown");
|
||||||
let cursor_display = if app.total_lines() == 0 {
|
let cursor_display = if app.total_lines() == 0 {
|
||||||
@@ -61,6 +65,22 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
|||||||
// ── Content area ───────────────────────────────────────────────
|
// ── Content area ───────────────────────────────────────────────
|
||||||
if app.mode == AppMode::Settings {
|
if app.mode == AppMode::Settings {
|
||||||
render_settings(frame, app, outer[1]);
|
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() {
|
} else if !app.is_loaded() {
|
||||||
frame.render_widget(Paragraph::new(" No file loaded"), outer[1]);
|
frame.render_widget(Paragraph::new(" No file loaded"), outer[1]);
|
||||||
} else {
|
} else {
|
||||||
@@ -70,6 +90,30 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
|||||||
// ── Status bar ─────────────────────────────────────────────────
|
// ── Status bar ─────────────────────────────────────────────────
|
||||||
let status_text = if app.mode == AppMode::Settings {
|
let status_text = if app.mode == AppMode::Settings {
|
||||||
" j/k:navigate ←/→:change 1-8:jump Enter:save Esc:cancel"
|
" 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 {
|
} else {
|
||||||
" j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit"
|
" 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 {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let is_loading = app.is_loading();
|
||||||
|
let gutter_prefix_extra = if is_loading { 1 } else { 0 };
|
||||||
let gutter_width = if total_lines > 0 {
|
let gutter_width = if total_lines > 0 {
|
||||||
line_num_width + 1 + 1
|
line_num_width + gutter_prefix_extra + 1 + 1
|
||||||
} else {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -177,62 +224,73 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.recompute_wrap_cache(actual_content_width);
|
let (start_logical, offset_in_line) = app.ensure_viewport_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 mut lines: Vec<Line> = Vec::new();
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
let mut current_visual_offset: usize = 0;
|
let mut current_visual_offset: usize = 0;
|
||||||
let available_rows = content_height;
|
let available_rows = content_height;
|
||||||
|
|
||||||
for logical_line in start_logical..total_lines {
|
let gutter_style = if is_loading {
|
||||||
let wrapped = &app.wrap_cache[logical_line];
|
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 {
|
let start_row = if logical_line == start_logical {
|
||||||
offset_in_line
|
offset_in_line
|
||||||
} else {
|
} else {
|
||||||
0
|
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 {
|
if current_visual_offset >= available_rows {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_cursor = logical_line == app.cursor_line;
|
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 {
|
let gutter_text = if visual_row == 0 {
|
||||||
format!(
|
if is_loading {
|
||||||
"{:>width$} \u{2502}",
|
format!(
|
||||||
logical_line + 1,
|
"~{:>width$} \u{2502}",
|
||||||
width = line_num_width
|
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 {
|
} else {
|
||||||
format!("{:width$} \u{2502}", "", width = line_num_width)
|
format!("{:width$} \u{2502}", "", width = line_num_width)
|
||||||
};
|
};
|
||||||
|
|
||||||
lines.push(build_line_spans(
|
let effective_gutter_style = if is_cursor {
|
||||||
gutter_text,
|
gutter_style.bg(bg_color)
|
||||||
text.clone(),
|
} else {
|
||||||
is_cursor,
|
gutter_style
|
||||||
level,
|
};
|
||||||
&app.color_config,
|
|
||||||
));
|
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;
|
current_visual_offset += 1;
|
||||||
}
|
}
|
||||||
@@ -304,4 +362,135 @@ mod tests {
|
|||||||
assert_eq!(line.spans[0].style.bg, Some(Color::Reset));
|
assert_eq!(line.spans[0].style.bg, Some(Color::Reset));
|
||||||
assert_eq!(line.spans[1].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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user