Compare commits
10
Commits
| 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);
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
// ─── 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
|
||||
log-viewer-core.workspace = true
|
||||
serde_json.workspace = true
|
||||
crossbeam-channel.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
+1542
-218
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user