Compare commits
4 Commits
fix/m20-ap
...
d37ed6df68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37ed6df68 | ||
|
|
b58d66f2aa | ||
|
|
d4679a7543 | ||
|
|
8844e58cb4 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2333,6 +2333,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"toml",
|
"toml",
|
||||||
|
"unicode-width",
|
||||||
"xxhash-rust",
|
"xxhash-rust",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ textwrap = "0.16"
|
|||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
xxhash-rust = { version = "0.8", features = ["xxh3"] }
|
||||||
bincode = "1"
|
bincode = "1"
|
||||||
|
unicode-width = "0.2"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ memmap2.workspace = true
|
|||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
xxhash-rust.workspace = true
|
xxhash-rust.workspace = true
|
||||||
bincode.workspace = true
|
bincode.workspace = true
|
||||||
|
unicode-width.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta.workspace = true
|
insta.workspace = true
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ pub mod cache_util;
|
|||||||
pub mod index_cache;
|
pub mod index_cache;
|
||||||
pub mod line_sampler;
|
pub mod line_sampler;
|
||||||
pub mod progressive_reader;
|
pub mod progressive_reader;
|
||||||
|
pub mod read_cache;
|
||||||
pub mod wrap;
|
pub mod wrap;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ impl VisualHeightIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn with_params(mut self, json_format: bool, terminal_width: usize) -> Self {
|
pub fn with_params(mut self, json_format: bool, terminal_width: usize) -> Self {
|
||||||
self.json_format = json_format;
|
self.json_format = json_format;
|
||||||
self.terminal_width = terminal_width;
|
self.terminal_width = terminal_width;
|
||||||
self
|
self
|
||||||
@@ -159,6 +159,31 @@ impl VisualHeightIndex {
|
|||||||
self.total_visual_rows += h as u64;
|
self.total_visual_rows += h as u64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the visual height of the last logical line. O(1).
|
||||||
|
///
|
||||||
|
/// Must be called **before** `extend_from_heights` so that the last line
|
||||||
|
/// index still refers to the pre-extension line.
|
||||||
|
pub fn replace_last_line_height(&mut self, new_height: usize) {
|
||||||
|
let n = self.prefix_sums.len();
|
||||||
|
if n < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let last_line = n - 2;
|
||||||
|
let old_height = self.prefix_sums[last_line + 1] - self.prefix_sums[last_line];
|
||||||
|
let new_height = new_height as u64;
|
||||||
|
if new_height == old_height {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta = new_height.abs_diff(old_height);
|
||||||
|
if new_height > old_height {
|
||||||
|
self.prefix_sums[last_line + 1] += delta;
|
||||||
|
self.total_visual_rows += delta;
|
||||||
|
} else {
|
||||||
|
self.prefix_sums[last_line + 1] -= delta;
|
||||||
|
self.total_visual_rows -= delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── VisualHeightRebuildResult ────────────────────────────────────────────────
|
// ─── VisualHeightRebuildResult ────────────────────────────────────────────────
|
||||||
@@ -1339,6 +1364,85 @@ mod tests {
|
|||||||
assert_eq!(idx.total_visual_rows(), 6);
|
assert_eq!(idx.total_visual_rows(), 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_last_line_height_increase() {
|
||||||
|
let heights = [2, 3];
|
||||||
|
let mut idx = VisualHeightIndex::build(&heights);
|
||||||
|
assert_eq!(idx.total_visual_rows(), 5);
|
||||||
|
assert_eq!(idx.visual_height_of_line(1), 3);
|
||||||
|
|
||||||
|
idx.replace_last_line_height(7);
|
||||||
|
|
||||||
|
assert_eq!(idx.visual_height_of_line(0), 2);
|
||||||
|
assert_eq!(idx.visual_height_of_line(1), 7);
|
||||||
|
assert_eq!(idx.total_visual_rows(), 9);
|
||||||
|
assert_eq!(idx.cursor_to_first_visual_row(0), 0);
|
||||||
|
assert_eq!(idx.cursor_to_first_visual_row(1), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_last_line_height_decrease() {
|
||||||
|
let heights = [2, 5];
|
||||||
|
let mut idx = VisualHeightIndex::build(&heights);
|
||||||
|
|
||||||
|
idx.replace_last_line_height(1);
|
||||||
|
|
||||||
|
assert_eq!(idx.visual_height_of_line(0), 2);
|
||||||
|
assert_eq!(idx.visual_height_of_line(1), 1);
|
||||||
|
assert_eq!(idx.total_visual_rows(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_last_line_height_same_is_noop() {
|
||||||
|
let heights = [2, 3];
|
||||||
|
let mut idx = VisualHeightIndex::build(&heights);
|
||||||
|
let total_before = idx.total_visual_rows();
|
||||||
|
|
||||||
|
idx.replace_last_line_height(3);
|
||||||
|
|
||||||
|
assert_eq!(idx.total_visual_rows(), total_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_last_line_height_then_extend() {
|
||||||
|
let heights = [2, 1];
|
||||||
|
let mut idx = VisualHeightIndex::build(&heights);
|
||||||
|
assert_eq!(idx.total_visual_rows(), 3);
|
||||||
|
|
||||||
|
idx.replace_last_line_height(4);
|
||||||
|
idx.extend_from_heights(&[3]);
|
||||||
|
|
||||||
|
assert_eq!(idx.line_count(), 3);
|
||||||
|
assert_eq!(idx.visual_height_of_line(0), 2);
|
||||||
|
assert_eq!(idx.visual_height_of_line(1), 4);
|
||||||
|
assert_eq!(idx.visual_height_of_line(2), 3);
|
||||||
|
assert_eq!(idx.total_visual_rows(), 9);
|
||||||
|
assert_eq!(idx.cursor_to_first_visual_row(0), 0);
|
||||||
|
assert_eq!(idx.cursor_to_first_visual_row(1), 2);
|
||||||
|
assert_eq!(idx.cursor_to_first_visual_row(2), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_last_line_height_single_line() {
|
||||||
|
let heights = [5];
|
||||||
|
let mut idx = VisualHeightIndex::build(&heights);
|
||||||
|
|
||||||
|
idx.replace_last_line_height(2);
|
||||||
|
|
||||||
|
assert_eq!(idx.visual_height_of_line(0), 2);
|
||||||
|
assert_eq!(idx.total_visual_rows(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_replace_last_line_height_empty_index() {
|
||||||
|
let heights: [usize; 0] = [];
|
||||||
|
let mut idx = VisualHeightIndex::build(&heights);
|
||||||
|
|
||||||
|
idx.replace_last_line_height(5);
|
||||||
|
|
||||||
|
assert_eq!(idx.total_visual_rows(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_spawn_indexer_file_truncated_during_scan() {
|
fn test_spawn_indexer_file_truncated_during_scan() {
|
||||||
let mut content = Vec::new();
|
let mut content = Vec::new();
|
||||||
|
|||||||
@@ -56,9 +56,15 @@ impl LruReadCache {
|
|||||||
/// on a hit, or fills a cache slot on a miss. Cross-block reads go through
|
/// on a hit, or fills a cache slot on a miss. Cross-block reads go through
|
||||||
/// the spill buffer and are not cached.
|
/// the spill buffer and are not cached.
|
||||||
pub fn get(&mut self, file: &File, offset: u64, len: usize) -> io::Result<&[u8]> {
|
pub fn get(&mut self, file: &File, offset: u64, len: usize) -> io::Result<&[u8]> {
|
||||||
|
if len == 0 {
|
||||||
|
return Ok(&[]);
|
||||||
|
}
|
||||||
|
|
||||||
let aligned_key = offset & !(BLOCK_ALIGN as u64 - 1);
|
let aligned_key = offset & !(BLOCK_ALIGN as u64 - 1);
|
||||||
let request_end = offset.saturating_add(len as u64);
|
let request_end = offset.checked_add(len as u64).ok_or_else(|| {
|
||||||
let block_end = aligned_key + BLOCK_ALIGN as u64;
|
io::Error::new(io::ErrorKind::InvalidInput, "read range overflows u64")
|
||||||
|
})?;
|
||||||
|
let block_end = aligned_key.saturating_add(BLOCK_ALIGN as u64);
|
||||||
|
|
||||||
if request_end > block_end {
|
if request_end > block_end {
|
||||||
self.spill_buf.resize(len, 0);
|
self.spill_buf.resize(len, 0);
|
||||||
@@ -74,7 +80,8 @@ impl LruReadCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hit_idx = self.slots.iter().position(|slot| {
|
let hit_idx = self.slots.iter().position(|slot| {
|
||||||
slot.block_offset == aligned_key && request_end <= slot.block_offset + slot.len as u64
|
let slot_end = slot.block_offset.saturating_add(slot.len as u64);
|
||||||
|
slot.len > 0 && slot.block_offset == aligned_key && request_end <= slot_end
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(idx) = hit_idx {
|
if let Some(idx) = hit_idx {
|
||||||
@@ -96,8 +103,8 @@ impl LruReadCache {
|
|||||||
let slot = &mut self.slots[evict_idx];
|
let slot = &mut self.slots[evict_idx];
|
||||||
let bytes_read = file.read_at(&mut slot.buf, aligned_key)?;
|
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(&[])).
|
// Non-empty reads that return 0 are EOF. Zero-length reads are handled above
|
||||||
// No callers pass len == 0, so this is a safe semantic change.
|
// as a successful no-op.
|
||||||
if bytes_read == 0 {
|
if bytes_read == 0 {
|
||||||
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read 0 bytes"));
|
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "read 0 bytes"));
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,8 @@ impl LruReadCache {
|
|||||||
slot.last_access = self.tick;
|
slot.last_access = self.tick;
|
||||||
self.tick += 1;
|
self.tick += 1;
|
||||||
|
|
||||||
if request_end > aligned_key + bytes_read as u64 {
|
let bytes_end = aligned_key.saturating_add(bytes_read as u64);
|
||||||
|
if request_end > bytes_end {
|
||||||
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read"));
|
return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "short read"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +126,9 @@ impl LruReadCache {
|
|||||||
/// Invalidate all cache slots and the spill buffer.
|
/// Invalidate all cache slots and the spill buffer.
|
||||||
pub fn clear(&mut self) {
|
pub fn clear(&mut self) {
|
||||||
for slot in &mut self.slots {
|
for slot in &mut self.slots {
|
||||||
|
slot.block_offset = 0;
|
||||||
slot.len = 0;
|
slot.len = 0;
|
||||||
|
slot.last_access = 0;
|
||||||
}
|
}
|
||||||
self.spill_len = 0;
|
self.spill_len = 0;
|
||||||
}
|
}
|
||||||
@@ -314,9 +324,11 @@ mod tests {
|
|||||||
|
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
|
||||||
// All slots should have len == 0.
|
// All slots should be fully reset.
|
||||||
for slot in &cache.slots {
|
for slot in &cache.slots {
|
||||||
|
assert_eq!(slot.block_offset, 0);
|
||||||
assert_eq!(slot.len, 0);
|
assert_eq!(slot.len, 0);
|
||||||
|
assert_eq!(slot.last_access, 0);
|
||||||
}
|
}
|
||||||
assert_eq!(cache.spill_len, 0);
|
assert_eq!(cache.spill_len, 0);
|
||||||
|
|
||||||
@@ -432,4 +444,52 @@ mod tests {
|
|||||||
assert_eq!(&line2[..4090], &[b'B'; 4090]);
|
assert_eq!(&line2[..4090], &[b'B'; 4090]);
|
||||||
assert_eq!(line2[4090], b'\n');
|
assert_eq!(line2[4090], b'\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_len_read_is_noop_on_fresh_cache() {
|
||||||
|
let f = make_file(b"");
|
||||||
|
let file = File::open(f.path()).unwrap();
|
||||||
|
let mut cache = ReadCache::new();
|
||||||
|
|
||||||
|
let result = cache.get(&file, 0, 0).unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
assert_eq!(cache.tick, 0);
|
||||||
|
assert!(cache.slots.iter().all(|s| s.len == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_len_read_is_noop_on_populated_cache() {
|
||||||
|
let f = make_file(b"abc");
|
||||||
|
let file = File::open(f.path()).unwrap();
|
||||||
|
let mut cache = ReadCache::new();
|
||||||
|
|
||||||
|
cache.get(&file, 0, 1).unwrap();
|
||||||
|
let tick_before = cache.tick;
|
||||||
|
|
||||||
|
let result = cache.get(&file, 0, 0).unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
assert_eq!(cache.tick, tick_before);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_len_read_at_max_offset_is_ok() {
|
||||||
|
let f = make_file(b"");
|
||||||
|
let file = File::open(f.path()).unwrap();
|
||||||
|
let mut cache = ReadCache::new();
|
||||||
|
|
||||||
|
let result = cache.get(&file, u64::MAX, 0).unwrap();
|
||||||
|
assert!(result.is_empty());
|
||||||
|
assert_eq!(cache.tick, 0);
|
||||||
|
assert!(cache.slots.iter().all(|s| s.len == 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nonzero_read_range_overflow_returns_invalid_input() {
|
||||||
|
let f = make_file(b"abc");
|
||||||
|
let file = File::open(f.path()).unwrap();
|
||||||
|
let mut cache = ReadCache::new();
|
||||||
|
|
||||||
|
let err = cache.get(&file, u64::MAX, 1).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
/// Lines exceeding this are returned as-is to avoid pathological cases.
|
/// Lines exceeding this are returned as-is to avoid pathological cases.
|
||||||
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
|
pub const MAX_WRAP_INPUT_LEN: usize = 10 * 1024 * 1024;
|
||||||
|
|
||||||
/// Split a line into chunks of exactly `width` characters (display columns).
|
/// Split a line into chunks of exactly `width` display columns.
|
||||||
/// For a log viewer, we want character-level wrapping, not word-level.
|
/// For a log viewer, we want character-level wrapping, not word-level.
|
||||||
|
/// Uses `unicode-width` for correct CJK/emoji/zero-width handling.
|
||||||
pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
||||||
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
if width == 0 {
|
if width == 0 {
|
||||||
return vec![String::new()];
|
return vec![String::new()];
|
||||||
}
|
}
|
||||||
@@ -15,7 +18,15 @@ pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
|||||||
let mut row = String::new();
|
let mut row = String::new();
|
||||||
let mut col = 0;
|
let mut col = 0;
|
||||||
for ch in line.chars() {
|
for ch in line.chars() {
|
||||||
let w = if ch == '\t' { 4 } else { 1 };
|
let w = if ch == '\t' {
|
||||||
|
4
|
||||||
|
} else if ch.is_control() {
|
||||||
|
// Control characters (except tab): width 0, still pushed to preserve content.
|
||||||
|
// Visible rendering is the caller's responsibility.
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
ch.width().unwrap_or(0)
|
||||||
|
};
|
||||||
if col + w > width && !row.is_empty() {
|
if col + w > width && !row.is_empty() {
|
||||||
result.push(std::mem::take(&mut row));
|
result.push(std::mem::take(&mut row));
|
||||||
col = 0;
|
col = 0;
|
||||||
@@ -132,4 +143,48 @@ mod tests {
|
|||||||
fn test_max_wrap_input_len_constant() {
|
fn test_max_wrap_input_len_constant() {
|
||||||
assert_eq!(MAX_WRAP_INPUT_LEN, 10 * 1024 * 1024);
|
assert_eq!(MAX_WRAP_INPUT_LEN, 10 * 1024 * 1024);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_cjk_chars() {
|
||||||
|
let result = wrap_line_chars("你好", 3);
|
||||||
|
assert_eq!(result, vec!["你", "好"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_cjk_ascii_mixed() {
|
||||||
|
let result = wrap_line_chars("a你好", 4);
|
||||||
|
assert_eq!(result, vec!["a你", "好"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_zero_width_char() {
|
||||||
|
let result = wrap_line_chars("a\u{200B}b", 2);
|
||||||
|
assert_eq!(result, vec!["a\u{200B}b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_emoji() {
|
||||||
|
let result = wrap_line_chars("😀a", 3);
|
||||||
|
assert_eq!(result, vec!["😀a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_emoji_exact_wrap() {
|
||||||
|
let result = wrap_line_chars("😀a", 2);
|
||||||
|
assert_eq!(result, vec!["😀", "a"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_combining_mark() {
|
||||||
|
// Scalar-width wrapping: combining mark (width 0) stays with next base char,
|
||||||
|
// not the preceding one, because the base char already triggered a flush.
|
||||||
|
let result = wrap_line_chars("a\u{0301}b", 1);
|
||||||
|
assert_eq!(result, vec!["a", "\u{0301}b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrap_cjk_width_one() {
|
||||||
|
let result = wrap_line_chars("你好", 1);
|
||||||
|
assert_eq!(result, vec!["你", "好"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -865,6 +865,7 @@ impl App {
|
|||||||
let width = self.get_content_width();
|
let width = self.get_content_width();
|
||||||
match &mut self.loading_state {
|
match &mut self.loading_state {
|
||||||
AppLoadingState::Ready { reader } => {
|
AppLoadingState::Ready { reader } => {
|
||||||
|
let old_reader_line_count = reader.line_count();
|
||||||
let status = reader.update_for_append();
|
let status = reader.update_for_append();
|
||||||
match status {
|
match status {
|
||||||
Ok(
|
Ok(
|
||||||
@@ -883,13 +884,24 @@ impl App {
|
|||||||
};
|
};
|
||||||
let new_line_count = reader.line_count();
|
let new_line_count = reader.line_count();
|
||||||
|
|
||||||
if can_extend && new_line_count > old_line_count {
|
if can_extend && old_line_count == old_reader_line_count {
|
||||||
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||||||
visual_height_index: Some(index),
|
visual_height_index: Some(index),
|
||||||
reader: fr,
|
reader: fr,
|
||||||
} = &mut reader.state
|
} = &mut reader.state
|
||||||
{
|
{
|
||||||
let mut new_heights = Vec::with_capacity(new_line_count - old_line_count);
|
if old_line_count > 0 {
|
||||||
|
let last_old_line_text =
|
||||||
|
fr.get_line(old_line_count - 1).unwrap_or("");
|
||||||
|
let new_h = compute_line_visual_height(
|
||||||
|
last_old_line_text,
|
||||||
|
width,
|
||||||
|
self.json_format,
|
||||||
|
);
|
||||||
|
index.replace_last_line_height(new_h);
|
||||||
|
}
|
||||||
|
let mut new_heights =
|
||||||
|
Vec::with_capacity(new_line_count.saturating_sub(old_line_count));
|
||||||
for i in old_line_count..new_line_count {
|
for i in old_line_count..new_line_count {
|
||||||
let line_text = fr.get_line(i).unwrap_or("");
|
let line_text = fr.get_line(i).unwrap_or("");
|
||||||
new_heights.push(compute_line_visual_height(
|
new_heights.push(compute_line_visual_height(
|
||||||
@@ -2473,7 +2485,9 @@ plain text line
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn install_vhi(app: &mut App, heights: &[usize]) {
|
fn install_vhi(app: &mut App, heights: &[usize]) {
|
||||||
let vhi = VisualHeightIndex::build(heights);
|
let width = app.get_content_width();
|
||||||
|
let json_format = app.json_format;
|
||||||
|
let vhi = VisualHeightIndex::build(heights).with_params(json_format, width);
|
||||||
if let AppLoadingState::Ready { reader } = &mut app.loading_state {
|
if let AppLoadingState::Ready { reader } = &mut app.loading_state {
|
||||||
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
|
||||||
visual_height_index,
|
visual_height_index,
|
||||||
@@ -2800,4 +2814,88 @@ plain text line
|
|||||||
cleanup(&path);
|
cleanup(&path);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_append_no_trailing_newline_updates_last_line_height() {
|
||||||
|
let path = make_temp_file("abc");
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
assert_eq!(app.total_lines(), 1);
|
||||||
|
|
||||||
|
app.content_width = 5;
|
||||||
|
install_vhi(&mut app, &[1usize]);
|
||||||
|
|
||||||
|
{
|
||||||
|
let vhi = app.get_visual_height_index().unwrap();
|
||||||
|
assert_eq!(vhi.visual_height_of_line(0), 1);
|
||||||
|
assert_eq!(vhi.total_visual_rows(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append content that extends line 0 (no trailing newline before)
|
||||||
|
// "abc" + "defgh\n" = "abcdefgh\n" → 8 chars in width 5 → wraps to 2 visual rows
|
||||||
|
// old_total=1, old_had_trailing=false → starts_new_line=false
|
||||||
|
// new_has_trailing=true, new_newlines=1 → added = 1-1 = 0
|
||||||
|
// Still 1 logical line, but line 0 text changed from "abc" to "abcdefgh"
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
f.write_all(b"defgh\n").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
app.poll_file_watcher();
|
||||||
|
|
||||||
|
assert_eq!(app.total_lines(), 1,
|
||||||
|
"\"abcdefgh\\n\" has trailing newline → 1 logical line");
|
||||||
|
|
||||||
|
let vhi = app.get_visual_height_index().expect("VHI should still exist after append");
|
||||||
|
assert_eq!(vhi.visual_height_of_line(0), 2,
|
||||||
|
"line 0 height should be updated from 1 to 2 after extending 'abcdefgh' in width 5");
|
||||||
|
assert_eq!(vhi.total_visual_rows(), 2);
|
||||||
|
assert_eq!(vhi.cursor_to_first_visual_row(0), 0);
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_append_no_trailing_newline_no_new_lines_only_height_change() {
|
||||||
|
let path = make_temp_file("abc");
|
||||||
|
let result = std::panic::catch_unwind(|| {
|
||||||
|
let mut app = App::new();
|
||||||
|
load_file_ready(&mut app, &path);
|
||||||
|
assert_eq!(app.total_lines(), 1);
|
||||||
|
|
||||||
|
app.content_width = 5;
|
||||||
|
install_vhi(&mut app, &[1usize]);
|
||||||
|
|
||||||
|
// Append without adding any new line — just extends line 0
|
||||||
|
// "abc" + "def" = "abcdef" → 6 chars in width 5 → wraps to 2 visual rows, still 1 logical line
|
||||||
|
{
|
||||||
|
use std::io::Write;
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.unwrap();
|
||||||
|
f.write_all(b"def").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
app.poll_file_watcher();
|
||||||
|
|
||||||
|
assert_eq!(app.total_lines(), 1,
|
||||||
|
"no new logical line should be added");
|
||||||
|
|
||||||
|
let vhi = app.get_visual_height_index().expect("VHI should still exist");
|
||||||
|
assert_eq!(vhi.visual_height_of_line(0), 2,
|
||||||
|
"line 0 height should update even when no new lines added");
|
||||||
|
assert_eq!(vhi.total_visual_rows(), 2);
|
||||||
|
cleanup(&path);
|
||||||
|
});
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user