7 Commits
Author SHA1 Message Date
dailzandSisyphus c9aa50f932 docs: refresh AGENTS.md for app/ module split and updated test counts
CI / ci (windows-latest) (push) Has been cancelled
CI / ci (ubuntu-latest) (push) Failing after 49m21s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-09 13:58:38 +08:00
dailzandSisyphus 9854ef13b6 refactor(tui): split remaining app.rs into per-concern submodules
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-09 13:53:36 +08:00
dailzandSisyphus 58ac4a65c7 refactor(tui): split app query and viewport cache helpers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-30 16:55:34 +08:00
dailzandSisyphus 0910d4e85f docs: clarify benchmark test file setup
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-30 16:54:46 +08:00
dailz ef26481976 style: apply rustfmt across bench and core crates
CI / ci (ubuntu-latest) (push) Has been cancelled
CI / ci (windows-latest) (push) Has been cancelled
Pure formatting pass - no semantic changes. Mostly long-line reflow,
import statement reordering (alphabetical), and trailing-newline fixes
that had been accumulating in the working tree.

Affected files:
* crates/bench/src/{data_gen,main,mmap_reader,pread_reader,report,runner}.rs
* crates/bench/src/suites/{concurrent,growth,jump,memory,render,rotation,startup}.rs
* crates/core/src/io/{file_reader,index_cache,read_cache}.rs
* crates/core/src/{types.rs, watcher/file_watcher.rs}
2026-06-22 16:38:28 +08:00
dailz c4ba196016 chore: fix CI branch trigger and add project READMEs
CI / ci (ubuntu-latest) (push) Has been cancelled
CI / ci (windows-latest) (push) Has been cancelled
* ci.yml: trigger on pushes/PRs to master instead of main. The repo's
  default branch is master, so the previous config never fired and CI
  silently passed on every commit.

* Add README.md (English) and README-zh.md (Chinese), the bilingual
  project documentation referenced by AGENTS.md.
2026-06-22 16:32:52 +08:00
dailz ec5a163f05 chore: expand .gitignore for per-crate target dirs and local artifacts
* crates/*/target/ - per-crate build output (root /target/ was already
  covered, but workspace members' own target/ dirs were leaking through)
* .omo/ .sisyphus/ - local agent tooling state, not project content
* benchmark-report.md - output of cargo run -p log-viewer-bench,
  regenerated per run so should not be tracked
2026-06-22 15:14:52 +08:00
33 changed files with 1893 additions and 1475 deletions
+2 -2
View File
@@ -2,9 +2,9 @@ name: CI
on: on:
push: push:
branches: [main] branches: [master]
pull_request: pull_request:
branches: [main] branches: [master]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
+8
View File
@@ -1,5 +1,13 @@
/target/ /target/
crates/*/target/
*.swp *.swp
*.swo *.swo
.idea/ .idea/
.vscode/ .vscode/
# Agent tooling state
.omo/
.sisyphus/
# Benchmark output
benchmark-report.md
+11 -3
View File
@@ -76,7 +76,15 @@ All ten modules are declared `pub` in `lib.rs` and re-exported as `log_viewer_co
## TUI Architecture ## TUI Architecture
- **`main.rs`** — clap CLI (`files: Vec<String>`), `TerminalGuard` RAII for raw mode + alternate screen. **CRITICAL**: `Drop` does not run on `std::process::exit` or `panic = "abort"` — never call `process::exit` inside the guarded scope; return `Err` and use `?` instead. Event loop: poll indexer → poll watcher → draw → poll keys, 100ms timeout. - **`main.rs`** — clap CLI (`files: Vec<String>`), `TerminalGuard` RAII for raw mode + alternate screen. **CRITICAL**: `Drop` does not run on `std::process::exit` or `panic = "abort"` — never call `process::exit` inside the guarded scope; return `Err` and use `?` instead. Event loop: poll indexer → poll watcher → draw → poll keys, 100ms timeout.
- **`app.rs`** — `App` struct (~4000 lines including ~135 inline tests, test mod starts at `#[cfg(test)] mod tests`). `AppLoadingState: Empty | Loading { reader, estimated_lines, progress_percent } | Ready { reader } | Error(String)`. `AppMode: Normal | Settings`. Viewport cache for scroll. All key handling. - **`app/`** — module directory (split from the former monolithic `app.rs`). `mod.rs` (~3000 lines, inline `tests` mod at `#[cfg(test)] mod tests`) holds the `App` struct, `AppLoadingState: Empty | Loading { reader, estimated_lines, progress_percent } | Ready { reader } | Error(String)`, `AppMode: Normal | Settings`, `ViewportRenderRow`, and the main impl blocks. Per-concern helpers are split into submodules:
- `input.rs``handle_key` + Normal-mode key dispatch
- `settings.rs` — Settings-mode key dispatch (←/→ color cycle, j/k level select, Enter/Esc)
- `scroll.rs` — line/half/full-page scroll + VHI-aware sub-offset walk
- `viewport.rs` — viewport computation helpers
- `viewport_cache.rs` — on-demand viewport-sized render cache
- `loading.rs` — loading-state polling/progress helpers
- `watcher.rs` — file-watcher event polling + reload/reindex glue
- `query.rs` — query helpers
- **`ui.rs`** — ratatui rendering: title bar, content area, status bar, settings popup - **`ui.rs`** — ratatui rendering: title bar, content area, status bar, settings popup
- **`color.rs`** — `LogLevel` → ratatui `Color` via `ColorConfig` - **`color.rs`** — `LogLevel` → ratatui `Color` via `ColorConfig`
@@ -88,9 +96,9 @@ j/k scroll, Ctrl+d/u half-page, Ctrl+f/b full-page, G/gg jump end/top, Tab toggl
All tests are inline (`#[cfg(test)] mod tests` blocks). No `tests/` directories, no integration tests, no async tests. All tests are inline (`#[cfg(test)] mod tests` blocks). No `tests/` directories, no integration tests, no async tests.
- **~450 tests total**: core 251, tui 136, bench 63, gui 0 - **~470 tests total**: core 262, tui 142, bench 63, gui 0
- Temp-file helpers vary per module — check the local `tests` mod before assuming an API: - Temp-file helpers vary per module — check the local `tests` mod before assuming an API:
- `make_temp_file(content) -> PathBuf``tui/app.rs`, `tui/ui.rs` - `make_temp_file(content) -> PathBuf``tui/app/mod.rs`, `tui/ui.rs`
- `make_test_file(lines) -> NamedTempFile``core/io/index_cache.rs` - `make_test_file(lines) -> NamedTempFile``core/io/index_cache.rs`
- `make_file(data) -> NamedTempFile``core/io/read_cache.rs` - `make_file(data) -> NamedTempFile``core/io/read_cache.rs`
- `struct TempFile { ... }``core/io/line_sampler.rs` - `struct TempFile { ... }``core/io/line_sampler.rs`
+109
View File
@@ -0,0 +1,109 @@
# logViewer
**[English](README.md)**
用 Rust 构建的高性能终端日志查看器,专为处理超大日志文件(GB 级别)而设计,内存占用极低。
基于 mmap 内存映射、稀疏行索引和后台渐进式加载,打开文件几乎瞬时完成——即使是 5GB 以上的日志文件,也能在索引完成前就开始滚动浏览。
## 特性
- **瞬时打开** — mmap 读取 + 后台渐进式索引,索引未完成即可开始浏览
- **超大文件支持** — 稀疏行索引(每 256 行采样一次),5GB 文件仅占用约 8MB 内存
- **实时文件追踪** — 通过 `notify` 监控文件追加、截断和日志轮转
- **JSON 日志解析** — 支持 NDJSON 格式,自动处理 BOM、检测重复键,可切换格式化显示
- **Unicode 感知的换行** — 正确处理中文、emoji 和制表符的行宽计算
- **持久化索引缓存** — 行索引保存到磁盘,使用 xxh3 哈希校验内容变更,再次打开几乎无需等待
- **Vim 风格快捷键** — 终端用户无学习成本
- **可自定义配色** — 每个日志级别独立配色,通过 TOML 配置文件管理,TUI 内直接调整
## 快速开始
```bash
# 直接编译运行
cargo run -p log-viewer-tui -- path/to/logfile.log
# 或先编译再运行
cargo build --release -p log-viewer-tui
./target/release/log-viewer-tui path/to/logfile.log
```
需要 Rust 1.92 及以上版本(见 `rust-toolchain.toml`)。
## 快捷键
| 按键 | 操作 |
|------|------|
| `j` / `↓` | 向下滚动一行 |
| `k` / `↑` | 向上滚动一行 |
| `Ctrl+d` | 向下滚动半页 |
| `Ctrl+u` | 向上滚动半页 |
| `Ctrl+f` / `PgDn` | 向下滚动一页 |
| `Ctrl+b` / `PgUp` | 向上滚动一页 |
| `G` / `End` | 跳转到文件末尾 |
| `gg` / `Home` | 跳转到文件开头 |
| `Tab` | 切换 JSON 格式化显示 |
| `S` | 打开配色设置 |
| `q` / `Esc` | 退出 |
设置面板:`j`/`k` 选择日志级别,`←`/`→` 切换颜色,`Enter` 保存,`Esc` 取消。
## 项目结构
```
crates/core — 核心库:I/O、解析、数据类型、配置、文件监控
crates/tui — 终端界面(ratatui + crossterm
crates/gui — 图形界面(egui + eframe)— 占位模块,尚未实现
crates/bench — mmap 与 pread 性能对比基准测试
```
## 开发
```bash
# 检查、测试、代码规范
cargo check --workspace
cargo test --workspace
cargo fmt --check --all
cargo clippy --workspace -- -D warnings
# 运行基准测试(需要先生成约 5GB 测试文件)
mkdir -p /tmp/test-logviewer
dd if=/dev/urandom of=/tmp/test-logviewer/extreme.log bs=1M count=5000
cargo run -p log-viewer-bench
cargo run -p log-viewer-bench -- --quick --suites startup,render --output results.md
# 单独测试某个 crate
cargo test -p log-viewer-core
cargo test -p log-viewer-tui
```
### CI
CI 在 ubuntu-latest 和 windows-latest 上运行以下检查:
1. `cargo fmt --check --all`
2. `cargo check --workspace`
3. `cargo test --workspace`
4. `cargo clippy --workspace -- -D warnings`
无自定义 rustfmt 或 clippy 配置——使用工具链默认值。
## 架构
核心库 `log-viewer-core` 负责所有 I/O、解析和数据类型。关键设计决策:
- **稀疏行索引** — 每 256 行采样一次,使用 memchr SIMD 加速。100 万行的文件仅产生约 32KB 的索引,而非 8MB。
- **渐进式加载** — `ProgressiveFileReader` 先读取文件头尾进行快速行数估算,再通过 crossbeam-channel 在后台线程构建完整索引。
- **mmap 与 TOCTOU 防护** — mmap 映射后进行 stat 校验以检测文件变更。`read_cache` 模块为未来基于 pread 的实现预留,可彻底消除 SIGBUS 风险。
- **持久化缓存** — 行索引序列化到磁盘,使用 xxh3 内容哈希进行失效校验,通过临时文件实现原子写入。
- **视觉高度索引** — 基于换行后行高的前缀和数组,支持 O(log n) 的滚动定位,适用于长行换行场景。
计划中但尚未实现的功能:过滤、书签、会话管理、搜索引擎(stub 模块已创建)。
## 基准测试
自研基准测试框架,对比 mmap 与 pread 两种后端在 7 个测试场景(启动、渲染、跳转、内存、增长、轮转、并发)下的表现。使用挂钟计时和 `/proc/self/` 的 RSS 及页错误指标。结果以 Markdown 表格形式输出到 `benchmark-report.md`
基准测试框架要求 `/tmp/test-logviewer/extreme.log` 已经存在。它不会自动生成主用的 5GB 测试文件;请先使用上方的 `dd` 命令创建。
基准测试二进制包含约 75 个单元测试,覆盖读取器后端和指标采集逻辑。
+108
View File
@@ -0,0 +1,108 @@
# logViewer
**[中文文档](README-zh.md)**
A high-performance terminal log file viewer built in Rust, designed to handle multi-gigabyte files with minimal memory overhead.
Uses memory-mapped I/O with a sparse line index and progressive background loading to open files instantly — even 5GB+ logs scroll smoothly from the first keystroke.
## Features
- **Instant file open** — mmap-backed reader with background progressive indexing; start scrolling before indexing finishes
- **Handles huge files** — sparse line index (1 entry per 256 lines) keeps memory usage at ~8MB even for 5GB files
- **Live file tracking** — watches for appends, truncations, and log rotation via `notify`
- **JSON log support** — NDJSON parsing with BOM handling, duplicate key detection, and toggleable pretty-printing
- **Unicode-aware wrapping** — correct line wrapping for CJK, emoji, and tabs
- **Persistent index cache** — line indexes saved to disk with xxh3 content hashing; re-opens are near-instant
- **Vim-like keybindings** — familiar navigation for terminal users
- **Customizable colors** — per-log-level colors via TOML config, adjustable from within the TUI
## Quick Start
```bash
# Build and run
cargo run -p log-viewer-tui -- path/to/logfile.log
# Or build first
cargo build --release -p log-viewer-tui
./target/release/log-viewer-tui path/to/logfile.log
```
Requires Rust 1.92+ (see `rust-toolchain.toml`).
## Keybindings
| Key | Action |
|-----|--------|
| `j` / `↓` | Scroll down one line |
| `k` / `↑` | Scroll up one line |
| `Ctrl+d` | Scroll down half page |
| `Ctrl+u` | Scroll up half page |
| `Ctrl+f` / `PgDn` | Scroll down full page |
| `Ctrl+b` / `PgUp` | Scroll up full page |
| `G` / `End` | Jump to end of file |
| `gg` / `Home` | Jump to start of file |
| `Tab` | Toggle JSON pretty-printing |
| `S` | Open color settings |
| `q` / `Esc` | Quit |
Settings panel: use `j`/`k` to select a log level, `←`/`→` to cycle colors, `Enter` to save, `Esc` to cancel.
## Workspace Structure
```
crates/core — Shared library: I/O, parsing, types, config, file watching
crates/tui — Terminal UI (ratatui + crossterm)
crates/gui — GUI (egui + eframe) — placeholder, not yet functional
crates/bench — mmap vs pread benchmark harness
```
## Development
```bash
# Check, test, lint
cargo check --workspace
cargo test --workspace
cargo fmt --check --all
cargo clippy --workspace -- -D warnings
# Run benchmarks (requires a pre-generated ~5GB test file)
mkdir -p /tmp/test-logviewer
dd if=/dev/urandom of=/tmp/test-logviewer/extreme.log bs=1M count=5000
cargo run -p log-viewer-bench
cargo run -p log-viewer-bench -- --quick --suites startup,render --output results.md
# Test a single crate
cargo test -p log-viewer-core
cargo test -p log-viewer-tui
```
### CI
The CI gate runs on ubuntu-latest and windows-latest:
1. `cargo fmt --check --all`
2. `cargo check --workspace`
3. `cargo test --workspace`
4. `cargo clippy --workspace -- -D warnings`
No custom rustfmt or clippy config — uses toolchain defaults.
## Architecture
The core library (`log-viewer-core`) owns all I/O, parsing, and data types. Key design decisions:
- **Sparse line index** — samples every 256 lines using memchr SIMD acceleration. A 1M-line file produces a ~32KB index instead of ~8MB.
- **Progressive loading** — `ProgressiveFileReader` starts with a quick head+tail sample for instant line estimates, then builds the full index in a background thread via crossbeam-channel.
- **mmap with TOCTOU mitigation** — post-mmap stat check to detect file changes. `read_cache` module exists as a future pread-based alternative to eliminate SIGBUS risk entirely.
- **Persistent cache** — line indexes are serialized to disk with xxh3 content hashing for invalidation. Atomic writes via temp files.
- **Visual height index** — prefix-sum over wrapped-line heights, enabling O(log n) scroll-to-line mapping for long wrapped lines.
Planned but not yet built: filtering, bookmarks, sessions, search engine (stub modules exist).
## Benchmarks
Custom harness comparing mmap vs pread backends across 7 suites (startup, render, jump, memory, growth, rotation, concurrent). Uses wall-clock timing with `/proc/self/` RSS and page fault metrics. Results are written as markdown tables to `benchmark-report.md`.
The harness expects `/tmp/test-logviewer/extreme.log` to already exist. It does not generate the primary 5GB test file automatically; create it first with the `dd` command shown above.
The benchmark binary includes ~75 unit tests for the reader backends and metrics collection.
+2 -4
View File
@@ -84,10 +84,8 @@ pub fn generate_growable_file(dir: &Path) -> std::io::Result<PathBuf> {
/// Append `count` lines to the file /// Append `count` lines to the file
pub fn append_lines(path: &Path, count: usize) -> std::io::Result<()> { pub fn append_lines(path: &Path, count: usize) -> std::io::Result<()> {
let existing_lines = count_existing_lines(path)?; let existing_lines = count_existing_lines(path)?;
let mut file = BufWriter::with_capacity( let mut file =
64 * 1024, BufWriter::with_capacity(64 * 1024, fs::OpenOptions::new().append(true).open(path)?);
fs::OpenOptions::new().append(true).open(path)?,
);
for i in 0..count { for i in 0..count {
writeln!( writeln!(
file, file,
+4 -2
View File
@@ -27,8 +27,10 @@ fn main() {
let suites = match args.suites { let suites = match args.suites {
Some(names) => { Some(names) => {
let parsed: Result<Vec<_>, _> = let parsed: Result<Vec<_>, _> = names
names.iter().map(|s| s.parse::<log_viewer_bench::runner::Suite>()).collect(); .iter()
.map(|s| s.parse::<log_viewer_bench::runner::Suite>())
.collect();
match parsed { match parsed {
Ok(s) => Some(s), Ok(s) => Some(s),
Err(e) => { Err(e) => {
+9 -9
View File
@@ -7,14 +7,14 @@
use std::fs::File; use std::fs::File;
use std::io::BufReader; use std::io::BufReader;
use std::path::Path; use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering};
use std::sync::Once; use std::sync::Once;
use std::sync::atomic::{AtomicBool, AtomicPtr, AtomicU8, Ordering};
use memmap2::{Advice, Mmap, MmapOptions, RemapOptions}; use memmap2::{Advice, Mmap, MmapOptions, RemapOptions};
use nix::sys::signal::{sigaction, SaFlags, SigAction, SigHandler, SigSet, Signal}; use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
use crate::line_index::LineIndex;
use crate::FileReaderBackend; use crate::FileReaderBackend;
use crate::line_index::LineIndex;
// ─── SIGBUS Handler ────────────────────────────────────────────────────────── // ─── SIGBUS Handler ──────────────────────────────────────────────────────────
// //
@@ -35,7 +35,10 @@ const HANDLER_NONE: u8 = 0;
const HANDLER_DEFAULT: u8 = 1; const HANDLER_DEFAULT: u8 = 1;
const HANDLER_IGNORE: u8 = 2; const HANDLER_IGNORE: u8 = 2;
const HANDLER_PLAIN: u8 = 3; // extern "C" fn(c_int) const HANDLER_PLAIN: u8 = 3; // extern "C" fn(c_int)
#[allow(clippy::unseparated_literal_suffix, reason = "clarity: this is the SA_SIGACTION variant")] #[allow(
clippy::unseparated_literal_suffix,
reason = "clarity: this is the SA_SIGACTION variant"
)]
const HANDLER_SIGACTION: u8 = 4; // extern "C" fn(c_int, *mut siginfo_t, *mut c_void) const HANDLER_SIGACTION: u8 = 4; // extern "C" fn(c_int, *mut siginfo_t, *mut c_void)
/// Old SIGBUS handler type — raw atomic, async-signal-safe to read. /// Old SIGBUS handler type — raw atomic, async-signal-safe to read.
@@ -107,11 +110,8 @@ extern "C" fn sigbus_handler(
HANDLER_SIGACTION => { HANDLER_SIGACTION => {
let ptr = OLD_HANDLER_PTR.load(Ordering::Acquire); let ptr = OLD_HANDLER_PTR.load(Ordering::Acquire);
if !ptr.is_null() { if !ptr.is_null() {
let f: extern "C" fn( let f: extern "C" fn(libc::c_int, *mut libc::siginfo_t, *mut std::ffi::c_void) =
libc::c_int, unsafe { std::mem::transmute(ptr) };
*mut libc::siginfo_t,
*mut std::ffi::c_void,
) = unsafe { std::mem::transmute(ptr) };
f(sig, info, ctx); f(sig, info, ctx);
} else { } else {
unsafe { libc::_exit(128 + sig) }; unsafe { libc::_exit(128 + sig) };
+10 -3
View File
@@ -15,8 +15,8 @@ use std::os::unix::fs::FileExt;
use std::os::unix::io::AsRawFd; use std::os::unix::io::AsRawFd;
use std::path::Path; use std::path::Path;
use crate::line_index::LineIndex;
use crate::FileReaderBackend; use crate::FileReaderBackend;
use crate::line_index::LineIndex;
const BLOCK_SIZE: usize = 256; const BLOCK_SIZE: usize = 256;
const CACHE_CHUNK: usize = 4096; const CACHE_CHUNK: usize = 4096;
@@ -478,12 +478,19 @@ mod tests {
let mut reader = PreadReaderPlain::open(&path).unwrap(); let mut reader = PreadReaderPlain::open(&path).unwrap();
assert_eq!(reader.total_lines(), 3); assert_eq!(reader.total_lines(), 3);
assert_eq!(reader.get_line(0), Some("alpha".to_owned())); assert_eq!(reader.get_line(0), Some("alpha".to_owned()));
assert_eq!(reader.get_line(3), None, "should be out of bounds before append"); assert_eq!(
reader.get_line(3),
None,
"should be out of bounds before append"
);
// Phase 2: append 2 more lines // Phase 2: append 2 more lines
{ {
use std::io::Write as _; use std::io::Write as _;
let mut f = std::fs::OpenOptions::new().append(true).open(&path).unwrap(); let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(b"delta\nepsilon\n").unwrap(); f.write_all(b"delta\nepsilon\n").unwrap();
} }
+47 -9
View File
@@ -145,8 +145,7 @@ pub fn format_report(results: &[BenchmarkResult]) -> String {
let mut mem_rows: Vec<&BenchmarkResult> = category_results.to_vec(); let mut mem_rows: Vec<&BenchmarkResult> = category_results.to_vec();
mem_rows.sort_by(|a, b| { mem_rows.sort_by(|a, b| {
(&a.test_name, &a.backend, &a.variant) (&a.test_name, &a.backend, &a.variant).cmp(&(&b.test_name, &b.backend, &b.variant))
.cmp(&(&b.test_name, &b.backend, &b.variant))
}); });
for r in mem_rows { for r in mem_rows {
let variant_label = format!("{} ({})", r.backend, r.variant); let variant_label = format!("{} ({})", r.backend, r.variant);
@@ -243,21 +242,60 @@ mod tests {
#[test] #[test]
fn report_ordering_independent_of_input_order() { fn report_ordering_independent_of_input_order() {
let set_a = vec![ let set_a = vec![
make_result("sequential", "read_1mb", "pread", "default", vec![100, 110, 105]), make_result(
make_result("sequential", "read_1mb", "mmap", "default", vec![80, 85, 90]), "sequential",
make_result("sequential", "read_4kb", "pread", "default", vec![10, 12, 11]), "read_1mb",
"pread",
"default",
vec![100, 110, 105],
),
make_result(
"sequential",
"read_1mb",
"mmap",
"default",
vec![80, 85, 90],
),
make_result(
"sequential",
"read_4kb",
"pread",
"default",
vec![10, 12, 11],
),
make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]), make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]),
]; ];
let set_b = vec![ let set_b = vec![
make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]), make_result("sequential", "read_4kb", "mmap", "default", vec![8, 9, 7]),
make_result("sequential", "read_1mb", "mmap", "default", vec![80, 85, 90]), make_result(
make_result("sequential", "read_4kb", "pread", "default", vec![10, 12, 11]), "sequential",
make_result("sequential", "read_1mb", "pread", "default", vec![100, 110, 105]), "read_1mb",
"mmap",
"default",
vec![80, 85, 90],
),
make_result(
"sequential",
"read_4kb",
"pread",
"default",
vec![10, 12, 11],
),
make_result(
"sequential",
"read_1mb",
"pread",
"default",
vec![100, 110, 105],
),
]; ];
let report_a = format_report(&set_a); let report_a = format_report(&set_a);
let report_b = format_report(&set_b); let report_b = format_report(&set_b);
assert_eq!(report_a, report_b, "Reports must be identical regardless of input order"); assert_eq!(
report_a, report_b,
"Reports must be identical regardless of input order"
);
} }
} }
+5 -1
View File
@@ -154,7 +154,11 @@ mod tests {
("concurrent", Suite::Concurrent), ("concurrent", Suite::Concurrent),
]; ];
for (s, expected_suite) in expected { for (s, expected_suite) in expected {
assert_eq!(Suite::from_str(s).unwrap(), expected_suite, "failed to parse '{s}'"); assert_eq!(
Suite::from_str(s).unwrap(),
expected_suite,
"failed to parse '{s}'"
);
} }
} }
+1 -1
View File
@@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::FileReaderBackend;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{ use crate::mmap_reader::{
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom, MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
@@ -8,7 +9,6 @@ use crate::mmap_reader::{
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential}; use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
use crate::runner::BenchConfig; use crate::runner::BenchConfig;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use crate::FileReaderBackend;
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
+1 -1
View File
@@ -1,12 +1,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::FileReaderBackend;
use crate::data_gen; use crate::data_gen;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::MmapReaderPlain; use crate::mmap_reader::MmapReaderPlain;
use crate::pread_reader::PreadReaderPlain; use crate::pread_reader::PreadReaderPlain;
use crate::runner::BenchConfig; use crate::runner::BenchConfig;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use crate::FileReaderBackend;
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
+1 -1
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use super::FRAME_LINES; use super::FRAME_LINES;
use crate::FileReaderBackend;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{ use crate::mmap_reader::{
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom, MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
@@ -9,7 +10,6 @@ use crate::mmap_reader::{
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential}; use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
use crate::runner::BenchConfig; use crate::runner::BenchConfig;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use crate::FileReaderBackend;
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
+1 -1
View File
@@ -1,5 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::FileReaderBackend;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{ use crate::mmap_reader::{
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom, MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
@@ -8,7 +9,6 @@ use crate::mmap_reader::{
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential}; use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
use crate::runner::BenchConfig; use crate::runner::BenchConfig;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use crate::FileReaderBackend;
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
+14 -4
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use super::FRAME_LINES; use super::FRAME_LINES;
use crate::FileReaderBackend;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{ use crate::mmap_reader::{
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom, MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
@@ -9,7 +10,6 @@ use crate::mmap_reader::{
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential}; use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
use crate::runner::BenchConfig; use crate::runner::BenchConfig;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use crate::FileReaderBackend;
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
@@ -248,7 +248,11 @@ mod tests {
assert!( assert!(
!ranges_overlap(pos[i].1, pos[j].1), !ranges_overlap(pos[i].1, pos[j].1),
"overlap: {:?} @ {} vs {:?} @ {} (total={})", "overlap: {:?} @ {} vs {:?} @ {} (total={})",
pos[i].0, pos[i].1, pos[j].0, pos[j].1, total pos[i].0,
pos[i].1,
pos[j].0,
pos[j].1,
total
); );
} }
} }
@@ -269,7 +273,10 @@ mod tests {
assert!( assert!(
!ranges_overlap(pos[i].1, pos[j].1), !ranges_overlap(pos[i].1, pos[j].1),
"overlap at total=104: {:?} @ {} vs {:?} @ {}", "overlap at total=104: {:?} @ {} vs {:?} @ {}",
pos[i].0, pos[i].1, pos[j].0, pos[j].1 pos[i].0,
pos[i].1,
pos[j].0,
pos[j].1
); );
} }
} }
@@ -283,7 +290,10 @@ mod tests {
assert!( assert!(
!ranges_overlap(pos[i].1, pos[j].1), !ranges_overlap(pos[i].1, pos[j].1),
"overlap at total=105: {:?} @ {} vs {:?} @ {}", "overlap at total=105: {:?} @ {} vs {:?} @ {}",
pos[i].0, pos[i].1, pos[j].0, pos[j].1 pos[i].0,
pos[i].1,
pos[j].0,
pos[j].1
); );
} }
} }
+1 -1
View File
@@ -1,12 +1,12 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::FileReaderBackend;
use crate::data_gen; use crate::data_gen;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{self, MmapReaderPlain}; use crate::mmap_reader::{self, MmapReaderPlain};
use crate::pread_reader::PreadReaderPlain; use crate::pread_reader::PreadReaderPlain;
use crate::runner::BenchConfig; use crate::runner::BenchConfig;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use crate::FileReaderBackend;
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
+1 -1
View File
@@ -1,6 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use crate::FileReaderBackend;
use crate::metrics::MetricsCollector; use crate::metrics::MetricsCollector;
use crate::mmap_reader::{ use crate::mmap_reader::{
MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom, MmapReaderPhaseAware, MmapReaderPlain, MmapReaderPopulate, MmapReaderRandom,
@@ -9,7 +10,6 @@ use crate::mmap_reader::{
use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential}; use crate::pread_reader::{PreadReaderPlain, PreadReaderRandom, PreadReaderSequential};
use crate::runner::BenchConfig; use crate::runner::BenchConfig;
use crate::types::BenchmarkResult; use crate::types::BenchmarkResult;
use crate::FileReaderBackend;
pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> { pub fn run(config: &BenchConfig) -> Vec<BenchmarkResult> {
let mut results = Vec::new(); let mut results = Vec::new();
+7 -3
View File
@@ -33,8 +33,8 @@ impl FileReader {
} else { } else {
// SAFETY: 使用只读 Mmap(非 MmapMut),文件以只读方式打开。 // SAFETY: 使用只读 Mmap(非 MmapMut),文件以只读方式打开。
// memmap2 内部持有文件描述符,确保 mmap 期间文件不会被关闭。 // memmap2 内部持有文件描述符,确保 mmap 期间文件不会被关闭。
let m = unsafe { memmap2::Mmap::map(&file) } let m =
.map_err(|e| CoreError::Mmap(e.to_string()))?; unsafe { memmap2::Mmap::map(&file) }.map_err(|e| CoreError::Mmap(e.to_string()))?;
// Layer 3: mmap 后立即 stat 同一 fd,检测截断(TOCTOU 缓解,非安全证明) // Layer 3: mmap 后立即 stat 同一 fd,检测截断(TOCTOU 缓解,非安全证明)
let current_size = file.metadata()?.len(); let current_size = file.metadata()?.len();
@@ -507,7 +507,11 @@ mod tests {
reader.reload().unwrap(); reader.reload().unwrap();
assert_eq!(reader.line_count(), 1); assert_eq!(reader.line_count(), 1);
assert_eq!(reader.get_line(0), Some("new")); assert_eq!(reader.get_line(0), Some("new"));
assert_eq!(reader.get_line(1), None, "old line should not be accessible"); assert_eq!(
reader.get_line(1),
None,
"old line should not be accessible"
);
} }
#[test] #[test]
+7 -7
View File
@@ -1,7 +1,7 @@
use std::io::{Read as _, Write as _}; use std::io::{Read as _, Write as _};
use std::path::Path; use std::path::Path;
use crate::io::cache_util::{cache_path, CACHE_VERSION}; use crate::io::cache_util::{CACHE_VERSION, cache_path};
use crate::io::line_index::LineIndex; use crate::io::line_index::LineIndex;
pub struct IndexCache; pub struct IndexCache;
@@ -47,11 +47,7 @@ impl IndexCache {
/// ///
/// The file hash is derived from `data` (the same byte slice used to build the index), /// The file hash is derived from `data` (the same byte slice used to build the index),
/// avoiding TOCTOU issues from re-reading the file from disk. /// avoiding TOCTOU issues from re-reading the file from disk.
pub fn save_with_hash( pub fn save_with_hash(file_path: &Path, index: &LineIndex, data: &[u8]) -> std::io::Result<()> {
file_path: &Path,
index: &LineIndex,
data: &[u8],
) -> std::io::Result<()> {
let dest = cache_path(file_path).ok_or_else(|| { let dest = cache_path(file_path).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "cannot determine cache path") std::io::Error::new(std::io::ErrorKind::NotFound, "cannot determine cache path")
})?; })?;
@@ -382,6 +378,10 @@ mod tests {
} }
let final_data = std::fs::read(&dest).expect("dest file should exist"); let final_data = std::fs::read(&dest).expect("dest file should exist");
assert_eq!(final_data.len(), 64 * 1024, "final file must be exactly one payload"); assert_eq!(
final_data.len(),
64 * 1024,
"final file must be exactly one payload"
);
} }
} }
+1 -4
View File
@@ -324,10 +324,7 @@ mod tests {
Ok(LogLevel::Unknown("CUSTOM".into())) Ok(LogLevel::Unknown("CUSTOM".into()))
); );
// Pure whitespace becomes Unknown(""). // Pure whitespace becomes Unknown("").
assert_eq!( assert_eq!(" ".parse::<LogLevel>(), Ok(LogLevel::Unknown("".into())));
" ".parse::<LogLevel>(),
Ok(LogLevel::Unknown("".into()))
);
// Internal whitespace is NOT collapsed. // Internal whitespace is NOT collapsed.
assert_eq!( assert_eq!(
"W ARN".parse::<LogLevel>(), "W ARN".parse::<LogLevel>(),
+1 -1
View File
@@ -1,7 +1,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use crossbeam_channel::{bounded, Receiver, Sender}; use crossbeam_channel::{Receiver, Sender, bounded};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use crate::error::Result; use crate::error::Result;
+138
View File
@@ -0,0 +1,138 @@
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use super::{App, AppMode};
impl App {
pub fn handle_key(&mut self, key: KeyEvent) {
let should_handle = match key.kind {
KeyEventKind::Press => true,
KeyEventKind::Repeat => self.is_repeatable_key(&key),
KeyEventKind::Release => false,
};
if !should_handle {
return;
}
match self.mode {
AppMode::Normal => self.handle_normal_key(key),
AppMode::Settings => self.handle_settings_key(key),
}
}
/// Keys that should auto-repeat when held (scroll/navigation only).
fn is_repeatable_key(&self, key: &KeyEvent) -> bool {
let plain = key.modifiers.is_empty();
let ctrl = key.modifiers == KeyModifiers::CONTROL;
match self.mode {
AppMode::Normal => {
(plain
&& matches!(
key.code,
KeyCode::Char('j')
| KeyCode::Down
| KeyCode::Char('k')
| KeyCode::Up
| KeyCode::PageDown
| KeyCode::PageUp
))
|| (ctrl
&& matches!(
key.code,
KeyCode::Char('d')
| KeyCode::Char('u')
| KeyCode::Char('f')
| KeyCode::Char('b')
))
}
AppMode::Settings => {
plain
&& matches!(
key.code,
KeyCode::Char('j')
| KeyCode::Down
| KeyCode::Char('k')
| KeyCode::Up
| KeyCode::Left
| KeyCode::Right
)
}
}
}
fn handle_normal_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.should_quit = true;
self.last_g_press = None;
}
KeyCode::Char('j') | KeyCode::Down => {
self.scroll_down_line();
self.last_g_press = None;
}
KeyCode::Char('k') | KeyCode::Up => {
self.scroll_up_line();
self.last_g_press = None;
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_down_half_page();
self.last_g_press = None;
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_up_half_page();
self.last_g_press = None;
}
KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_down_page();
self.last_g_press = None;
}
KeyCode::PageDown => {
self.scroll_down_page();
self.last_g_press = None;
}
KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_up_page();
self.last_g_press = None;
}
KeyCode::PageUp => {
self.scroll_up_page();
self.last_g_press = None;
}
KeyCode::Char('G') | KeyCode::End => {
self.scroll_to_bottom();
self.last_g_press = None;
}
KeyCode::Char('g') => {
if let Some(instant) = self.last_g_press
&& instant.elapsed().as_millis() < 500
{
self.scroll_to_top();
self.last_g_press = None;
return;
}
self.last_g_press = Some(Instant::now());
}
KeyCode::Home => {
self.scroll_to_top();
self.last_g_press = None;
}
KeyCode::Tab => {
self.toggle_json_format();
self.last_g_press = None;
}
KeyCode::Char('s') | KeyCode::Char('S')
if !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
self.settings_draft = self.color_config.clone();
self.settings_error = None;
self.mode = AppMode::Settings;
}
_ => {
self.last_g_press = None;
}
}
}
}
+169
View File
@@ -0,0 +1,169 @@
use std::path::Path;
use log_viewer_core::io::progressive_reader::{
IndexerMessage, ProgressiveFileReader, spawn_indexer,
};
use log_viewer_core::watcher::file_watcher::FileWatcher;
use super::{App, AppLoadingState};
impl App {
pub fn load_file(&mut self, path: &str) -> anyhow::Result<()> {
// ── Phase 1: Pure computation, no mutation of self ──────────
// If any step fails and returns ?, self remains completely untouched,
// preserving the old file's watcher, loading_state, and file_path.
let mut pfr =
ProgressiveFileReader::open(Path::new(path)).map_err(|e| anyhow::anyhow!("{e}"))?;
let new_loading_state = if pfr.is_sampling() {
// Cache miss: spawn background indexer
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
let generation = pfr.generation();
let indexer_rx =
spawn_indexer(pfr.path().to_path_buf(), generation, 80, false, cancel_rx);
pfr = ProgressiveFileReader::with_channels(
Path::new(path),
cancel_tx,
indexer_rx,
generation,
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
let estimated = pfr.line_count() as u64;
AppLoadingState::Loading {
reader: pfr,
estimated_lines: estimated,
progress_percent: 0.0,
}
} else {
// Cache hit: Ready state
AppLoadingState::Ready { reader: pfr }
};
let new_watcher = FileWatcher::watch(Path::new(path)).ok();
// ── Phase 2: Commit — swap self to new state ───────────────
// SAFETY: Do NOT add any fallible operations (with ?) below this point.
// The old file_watcher and loading_state are dropped here, cancelling
// any background indexer for the previous file.
self.file_watcher = new_watcher;
self.loading_state = new_loading_state;
self.file_path = Some(path.to_string());
// Reset UI state for the new file
self.cursor_line = 0;
self.v_offset = 0;
self.v_sub_offset = 0;
self.cursor_sub_offset = 0;
self.viewport_cache.invalidate();
self.last_g_press = None;
self.json_format = false;
self.mode = super::AppMode::Normal;
self.reload_after_loading = false;
Ok(())
}
/// Poll the background indexer for progress/completion.
/// Transitions Loading → Ready when indexing completes.
/// Must be called every frame in the event loop.
pub fn poll_background_indexer(&mut self) {
// Poll visual height rebuild (Ready state only).
// Recalibrate v_offset only when VHI actually changed.
if let AppLoadingState::Ready { reader } = &mut self.loading_state
&& let Some(index) = reader.poll_visual_height_rebuild()
{
if let log_viewer_core::io::progressive_reader::ReaderState::Ready {
visual_height_index,
..
} = &mut reader.state
{
*visual_height_index = Some(index);
}
let logical_top = self.v_offset.min(self.total_lines().saturating_sub(1));
let sub = self.v_sub_offset;
let first_visual = self.cursor_to_first_visual_row(logical_top);
let line_height = self
.get_visual_height_index()
.map_or(1, |idx| idx.visual_height_of_line(logical_top));
self.v_offset = first_visual.saturating_add(sub.min(line_height.saturating_sub(1)));
self.v_sub_offset = 0;
self.clamp_cursor_sub_offset_no_vhi();
self.clamp_v_offset();
self.viewport_cache.invalidate();
}
// Poll main indexer (Loading state)
let old_state = std::mem::replace(&mut self.loading_state, AppLoadingState::Empty);
if let AppLoadingState::Loading {
mut reader,
estimated_lines,
mut progress_percent,
} = old_state
{
if let Some(msg) = reader.poll_indexer() {
match msg {
IndexerMessage::Progress { percent, .. } => {
progress_percent = percent;
self.loading_state = AppLoadingState::Loading {
reader,
estimated_lines,
progress_percent,
};
}
IndexerMessage::Complete {
reader: fr,
visual_height_index,
..
} => {
let saved_cursor = self.cursor_line;
reader.set_ready(fr, visual_height_index);
self.loading_state = AppLoadingState::Ready { reader };
self.viewport_cache.invalidate();
// Clamp cursor if exact count < estimated
self.cursor_line = saved_cursor.min(self.total_lines().saturating_sub(1));
// Loading uses 1:1 logical-line offsets; Ready uses visual-row
// offsets derived from the prefix-sum index. Recompute v_offset
// so the same logical line stays visible (falls back to 1:1 when
// the index is absent, which is the case right after invalidate).
self.v_offset = self.cursor_to_first_visual_row(self.cursor_line);
self.clamp_v_offset();
self.v_sub_offset = 0;
self.cursor_sub_offset = 0;
// Gutter width changes (~N → N) shift content_width, so any
// VisualHeightIndex built with the old width is stale.
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index();
}
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
self.cursor_sub_offset = 0;
if self.reload_after_loading {
self.reload_after_loading = false;
self.reload_ready_reader();
}
}
IndexerMessage::Error { message, .. } => {
self.loading_state = AppLoadingState::Error(message);
self.reload_after_loading = false;
}
}
} else {
self.loading_state = AppLoadingState::Loading {
reader,
estimated_lines,
progress_percent,
};
}
} else {
self.loading_state = old_state;
}
}
}
File diff suppressed because it is too large Load Diff
+209
View File
@@ -0,0 +1,209 @@
use log_viewer_core::config::ColorConfig;
use log_viewer_core::io::progressive_reader::VisualHeightIndex;
use super::viewport_cache::gutter_width_for;
use super::{App, AppLoadingState, AppMode, ViewportRenderRow};
impl App {
#[allow(dead_code)]
pub fn get_line(&self, idx: usize) -> Option<String> {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.get_line(idx),
AppLoadingState::Loading { reader, .. } => reader.get_line(idx),
_ => None,
}
}
#[allow(dead_code)]
pub fn file_name(&self) -> Option<&str> {
self.file_path
.as_ref()
.and_then(|p| std::path::Path::new(p).file_name().and_then(|n| n.to_str()))
}
pub fn total_lines(&self) -> usize {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.line_count(),
AppLoadingState::Loading {
reader,
estimated_lines,
..
} => {
// Use estimated total lines (not sampled_line_count) so the user can
// scroll freely during indexing. get_line() incrementally scans
// forward on demand, so lines beyond the initial 64KB are still
// accessible. The .max() guards against under-estimates.
(*estimated_lines as usize).max(reader.sampled_line_count())
}
_ => 0,
}
}
pub fn is_loaded(&self) -> bool {
matches!(
self.loading_state,
AppLoadingState::Ready { .. } | AppLoadingState::Loading { .. }
)
}
pub fn is_loading(&self) -> bool {
matches!(self.loading_state, AppLoadingState::Loading { .. })
}
pub fn is_error(&self) -> bool {
matches!(self.loading_state, AppLoadingState::Error(_))
}
pub(super) fn get_visual_height_index(&self) -> Option<&VisualHeightIndex> {
match &self.loading_state {
AppLoadingState::Ready { reader } => match &reader.state {
log_viewer_core::io::progressive_reader::ReaderState::Ready {
visual_height_index,
..
} => visual_height_index.as_ref(),
_ => None,
},
_ => None,
}
}
pub fn error_message(&self) -> Option<&str> {
match &self.loading_state {
AppLoadingState::Error(msg) => Some(msg),
_ => None,
}
}
pub fn loading_progress(&self) -> Option<f64> {
match &self.loading_state {
AppLoadingState::Loading {
progress_percent, ..
} => Some(*progress_percent),
_ => None,
}
}
pub fn estimated_lines(&self) -> Option<u64> {
match &self.loading_state {
AppLoadingState::Loading {
estimated_lines, ..
} => Some(*estimated_lines),
_ => None,
}
}
pub(crate) fn mode(&self) -> AppMode {
self.mode
}
pub(crate) fn settings_error(&self) -> Option<&str> {
self.settings_error.as_deref()
}
pub(crate) fn settings_draft(&self) -> &ColorConfig {
&self.settings_draft
}
pub(crate) fn settings_cursor(&self) -> usize {
self.settings_cursor
}
pub(crate) fn color_config(&self) -> &ColorConfig {
&self.color_config
}
pub(crate) fn cursor_line(&self) -> usize {
self.cursor_line
}
pub(crate) fn cursor_sub_offset(&self) -> usize {
self.cursor_sub_offset
}
#[allow(dead_code)]
pub(crate) fn content_width(&self) -> u16 {
self.content_width
}
#[allow(dead_code)]
pub(crate) fn content_height(&self) -> u16 {
self.content_height
}
pub(crate) fn set_content_area(&mut self, width: u16, height: u16) {
self.content_width = width;
self.content_height = height;
}
pub(crate) fn viewport_rows(
&self,
start_logical: usize,
offset_in_line: usize,
available_rows: usize,
) -> Vec<ViewportRenderRow<'_>> {
let mut rows = Vec::new();
for (entry_idx, entry) in self.viewport_cache.entries.iter().enumerate() {
let logical_line = self.viewport_cache.logical_start + entry_idx;
let start_row = if logical_line == start_logical {
offset_in_line
} else {
0
};
for (visual_row, text) in entry.wrapped_rows.iter().enumerate().skip(start_row) {
if rows.len() >= available_rows {
return rows;
}
rows.push(ViewportRenderRow {
logical_line,
visual_row,
text,
level: entry.level.as_ref(),
});
}
}
rows
}
#[cfg(test)]
pub(crate) fn enter_settings_mode_for_test(&mut self) {
self.mode = AppMode::Settings;
self.settings_draft = self.color_config.clone();
}
#[cfg(test)]
pub(crate) fn set_cursor_for_test(&mut self, line: usize, sub_offset: usize) {
self.cursor_line = line;
self.cursor_sub_offset = sub_offset;
}
#[cfg(test)]
pub fn set_error_state(&mut self, msg: impl Into<String>) {
self.loading_state = AppLoadingState::Error(msg.into());
}
#[allow(dead_code)]
pub fn file_size(&self) -> u64 {
match &self.loading_state {
AppLoadingState::Ready { reader } => reader.reader().map_or(0, |r| r.file_size()),
AppLoadingState::Loading { reader, .. } => reader.reader().map_or(0, |r| r.file_size()),
_ => 0,
}
}
/// MUST match the renderer's `gutter_width` formula in `ui.rs::render_content`.
pub(crate) fn gutter_width(&self) -> usize {
gutter_width_for(self.total_lines(), self.is_loading())
}
pub(super) fn get_content_width(&self) -> usize {
let total = if self.content_width > 0 {
self.content_width as usize
} else {
80
};
total.saturating_sub(gutter_width_for(self.total_lines(), self.is_loading()))
}
}
+359
View File
@@ -0,0 +1,359 @@
use super::App;
impl App {
fn line_visual_height(&self, line: usize, width: usize) -> usize {
if line >= self.total_lines() {
return 1;
}
self.compute_visual_height(line, width).max(1)
}
fn advance_visual_pos(
&self,
mut line: usize,
mut sub: usize,
mut n: usize,
width: usize,
) -> (usize, usize) {
let last = self.total_lines().saturating_sub(1);
while n > 0 && line < last {
let h = self.line_visual_height(line, width);
let remaining_in_line = h.saturating_sub(sub);
if n < remaining_in_line {
sub += n;
return (line, sub);
}
n -= remaining_in_line;
line += 1;
sub = 0;
}
if line >= last {
let h = self.line_visual_height(last, width);
sub = (sub + n).min(h.saturating_sub(1));
line = last;
}
(line, sub)
}
fn retreat_visual_pos(
&self,
mut line: usize,
mut sub: usize,
mut n: usize,
width: usize,
) -> (usize, usize) {
while n > 0 {
if sub >= n {
sub -= n;
return (line, sub);
}
n -= sub + 1;
if line == 0 {
return (0, 0);
}
line -= 1;
sub = self.line_visual_height(line, width).saturating_sub(1);
}
(line, sub)
}
pub(super) fn clamp_cursor_sub_offset_no_vhi(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
let width = self.get_content_width();
if width == 0 {
return;
}
let h = self.line_visual_height(self.cursor_line, width);
if self.cursor_sub_offset >= h {
self.cursor_sub_offset = h.saturating_sub(1);
}
}
pub(super) fn ensure_cursor_visible_no_vhi(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 || self.content_height == 0 {
return;
}
let width = self.get_content_width();
if width == 0 {
return;
}
let content_h = self.content_height as usize;
let cur_line = self.cursor_line;
let cur_sub = self.cursor_sub_offset;
let v_top_line = self.v_offset;
let v_top_sub = self.v_sub_offset;
if cur_line < v_top_line || (cur_line == v_top_line && cur_sub < v_top_sub) {
self.v_offset = cur_line;
self.v_sub_offset = cur_sub;
return;
}
let mut line = v_top_line;
let mut sub = v_top_sub;
let mut walked = 0usize;
let total = self.total_lines();
while walked < content_h {
if line == cur_line && sub == cur_sub {
return;
}
let h = self.line_visual_height(line, width);
if sub + 1 < h {
sub += 1;
} else if line + 1 < total {
line += 1;
sub = 0;
} else {
return;
}
walked += 1;
}
let target_distance = content_h.saturating_sub(1);
let (new_line, new_sub) =
self.retreat_visual_pos(cur_line, cur_sub, target_distance, width);
self.v_offset = new_line;
self.v_sub_offset = new_sub;
}
pub fn scroll_down_line(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
if let Some(index) = self.get_visual_height_index() {
let last = self.total_lines().saturating_sub(1);
let current_height = index.visual_height_of_line(self.cursor_line);
if self.cursor_sub_offset + 1 < current_height {
self.cursor_sub_offset += 1;
} else if self.cursor_line < last {
self.cursor_line += 1;
self.cursor_sub_offset = 0;
}
self.ensure_cursor_visible();
} else {
let width = self.get_content_width();
if width > 0 && self.total_lines() > 0 {
let (new_line, new_sub) =
self.advance_visual_pos(self.cursor_line, self.cursor_sub_offset, 1, width);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible_no_vhi();
}
}
}
pub fn scroll_up_line(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
if let Some(index) = self.get_visual_height_index() {
if self.cursor_sub_offset > 0 {
self.cursor_sub_offset -= 1;
} else if self.cursor_line > 0 {
let previous_line = self.cursor_line - 1;
let previous_line_last_sub =
index.visual_height_of_line(previous_line).saturating_sub(1);
self.cursor_line = previous_line;
self.cursor_sub_offset = previous_line_last_sub;
}
self.ensure_cursor_visible();
} else {
let width = self.get_content_width();
if width > 0 {
let (new_line, new_sub) =
self.retreat_visual_pos(self.cursor_line, self.cursor_sub_offset, 1, width);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible_no_vhi();
}
}
}
pub fn scroll_down_half_page(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
let half = self.content_height as usize / 2;
if let Some(index) = self.get_visual_height_index() {
let total_vr = index.total_visual_rows();
if total_vr == 0 {
return;
}
let cur_vr = self.cursor_visual_row();
let target_vr = cur_vr.saturating_add(half as u64).min(total_vr - 1);
let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible();
} else {
let width = self.get_content_width();
if width > 0 && half > 0 {
let (new_line, new_sub) =
self.advance_visual_pos(self.cursor_line, self.cursor_sub_offset, half, width);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible_no_vhi();
}
}
}
pub fn scroll_up_half_page(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
let half = self.content_height as usize / 2;
if let Some(index) = self.get_visual_height_index() {
let total_vr = index.total_visual_rows();
if total_vr == 0 {
return;
}
let cur_vr = self.cursor_visual_row();
let target_vr = cur_vr.saturating_sub(half as u64);
let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible();
} else {
let width = self.get_content_width();
if width > 0 && half > 0 {
let (new_line, new_sub) =
self.retreat_visual_pos(self.cursor_line, self.cursor_sub_offset, half, width);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible_no_vhi();
}
}
}
pub fn scroll_down_page(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
let page = self.content_height as usize;
if let Some(index) = self.get_visual_height_index() {
let total_vr = index.total_visual_rows();
if total_vr == 0 {
return;
}
let cur_vr = self.cursor_visual_row();
let target_vr = cur_vr.saturating_add(page as u64).min(total_vr - 1);
let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible();
} else {
let width = self.get_content_width();
if width > 0 && page > 0 {
let (new_line, new_sub) =
self.advance_visual_pos(self.cursor_line, self.cursor_sub_offset, page, width);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible_no_vhi();
}
}
}
pub fn scroll_up_page(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
let page = self.content_height as usize;
if let Some(index) = self.get_visual_height_index() {
let total_vr = index.total_visual_rows();
if total_vr == 0 {
return;
}
let cur_vr = self.cursor_visual_row();
let target_vr = cur_vr.saturating_sub(page as u64);
let (new_line, new_sub) = index.visual_row_to_logical_row_with_offset(target_vr);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible();
} else {
let width = self.get_content_width();
if width > 0 && page > 0 {
let (new_line, new_sub) =
self.retreat_visual_pos(self.cursor_line, self.cursor_sub_offset, page, width);
self.cursor_line = new_line;
self.cursor_sub_offset = new_sub;
self.ensure_cursor_visible_no_vhi();
}
}
}
pub fn scroll_to_top(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
self.cursor_line = 0;
self.v_offset = 0;
self.v_sub_offset = 0;
self.cursor_sub_offset = 0;
}
pub fn scroll_to_bottom(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 {
return;
}
self.cursor_line = self.total_lines().saturating_sub(1);
if let Some(idx) = self.get_visual_height_index() {
self.cursor_sub_offset = idx
.visual_height_of_line(self.cursor_line)
.saturating_sub(1);
self.v_sub_offset = 0;
self.ensure_cursor_visible();
self.clamp_v_offset();
} else {
let width = self.get_content_width();
self.cursor_sub_offset = if width > 0 {
self.line_visual_height(self.cursor_line, width)
.saturating_sub(1)
} else {
0
};
self.ensure_cursor_visible_no_vhi();
}
}
pub(super) fn ensure_cursor_visible(&mut self) {
if !self.is_loaded() || self.total_lines() == 0 || self.content_height == 0 {
return;
}
if self.is_loading() {
return;
}
let cursor_visual = self.cursor_visual_row() as usize;
let content_h = self.content_height as usize;
if cursor_visual < self.v_offset {
self.v_offset = cursor_visual;
} else if cursor_visual >= self.v_offset.saturating_add(content_h) {
self.v_offset = cursor_visual.saturating_sub(content_h).saturating_add(1);
}
self.clamp_v_offset();
}
pub(super) fn clamp_v_offset(&mut self) {
let max_offset = self
.total_visual_rows()
.saturating_sub(self.content_height as usize);
self.v_offset = self.v_offset.min(max_offset);
}
fn cursor_visual_row(&self) -> u64 {
if let Some(index) = self.get_visual_height_index() {
let first = index.cursor_to_first_visual_row(self.cursor_line);
let height = index.visual_height_of_line(self.cursor_line);
let max_sub = height.saturating_sub(1);
let sub = self.cursor_sub_offset.min(max_sub) as u64;
first + sub
} else {
self.cursor_line as u64
}
}
}
+98
View File
@@ -0,0 +1,98 @@
use crossterm::event::{KeyCode, KeyEvent};
use crate::color::AVAILABLE_COLORS;
use super::{App, AppMode};
impl App {
pub(super) fn handle_settings_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.settings_error = None;
self.mode = AppMode::Normal;
}
KeyCode::Enter => {
let draft = self.settings_draft.clone();
match draft.save() {
Ok(()) => {
self.color_config = draft;
self.settings_error = None;
self.mode = AppMode::Normal;
}
Err(e) => {
self.settings_error = Some(format!("Failed to save settings: {e}"));
}
}
}
KeyCode::Char('j') | KeyCode::Down => {
if self.settings_cursor < 5 {
self.settings_cursor += 1;
}
}
KeyCode::Char('k') | KeyCode::Up => {
self.settings_cursor = self.settings_cursor.saturating_sub(1);
}
KeyCode::Left => {
self.cycle_color(self.settings_cursor, false);
}
KeyCode::Right => {
self.cycle_color(self.settings_cursor, true);
}
KeyCode::Char(c) if ('1'..='8').contains(&c) => {
let idx = (c as usize) - ('1' as usize);
self.set_color(self.settings_cursor, AVAILABLE_COLORS[idx]);
}
_ => {}
}
}
fn cycle_color(&mut self, level_idx: usize, forward: bool) {
let current = self.get_settings_color(level_idx).to_string();
let colors = AVAILABLE_COLORS;
let pos = colors.iter().position(|&c| c == current);
let new_pos = match pos {
Some(p) => {
if forward {
(p + 1) % colors.len()
} else if p == 0 {
colors.len() - 1
} else {
p - 1
}
}
None => {
if forward {
0
} else {
colors.len() - 1
}
}
};
self.set_color(level_idx, colors[new_pos]);
}
fn get_settings_color(&self, level_idx: usize) -> &str {
match level_idx {
0 => &self.settings_draft.error,
1 => &self.settings_draft.warn,
2 => &self.settings_draft.info,
3 => &self.settings_draft.debug,
4 => &self.settings_draft.trace,
5 => &self.settings_draft.unknown,
_ => "white",
}
}
fn set_color(&mut self, level_idx: usize, color_name: &str) {
self.settings_error = None;
match level_idx {
0 => self.settings_draft.error = color_name.to_string(),
1 => self.settings_draft.warn = color_name.to_string(),
2 => self.settings_draft.info = color_name.to_string(),
3 => self.settings_draft.debug = color_name.to_string(),
4 => self.settings_draft.trace = color_name.to_string(),
5 => self.settings_draft.unknown = color_name.to_string(),
_ => {}
}
}
}
+222
View File
@@ -0,0 +1,222 @@
use std::borrow::Cow;
use log_viewer_core::io::wrap::{MAX_WRAP_INPUT_LEN, format_json_line, wrap_line_chars};
use super::viewport_cache::{ViewportEntry, truncate_to_columns};
use super::{App, AppLoadingState};
impl App {
/// Compute a single line's viewport entry (wrapped rows + level + height).
pub(super) fn compute_line_entry(&self, line: usize, width: usize) -> ViewportEntry {
let raw = self.get_line(line).unwrap_or_default();
// Guard 1: oversized raw input — skip detect_level and JSON formatting
// to avoid O(n) parsing overhead on huge lines.
if raw.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&raw, width)],
level: None,
visual_height: 1,
};
}
let level = log_viewer_core::parser::level::detect_level(&raw);
let display_text: Cow<'_, str> = if self.json_format {
format_json_line(&raw)
} else {
Cow::Borrowed(raw.as_str())
};
// Guard 2: JSON pretty-printing may expand a line beyond the limit.
if display_text.len() > MAX_WRAP_INPUT_LEN {
return ViewportEntry {
wrapped_rows: vec![truncate_to_columns(&display_text, width)],
level,
visual_height: 1,
};
}
let mut wrapped = Vec::new();
for sub_line in display_text.split('\n') {
wrapped.extend(wrap_line_chars(sub_line, width));
}
let visual_height = wrapped.len().max(1);
ViewportEntry {
wrapped_rows: wrapped,
level,
visual_height,
}
}
/// Compute visual height for a single line without storing it.
pub(super) fn compute_visual_height(&self, line: usize, width: usize) -> usize {
let raw = self.get_line(line).unwrap_or_default();
// Guard 1: oversized raw input.
if raw.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let display_text: Cow<'_, str> = if self.json_format {
format_json_line(&raw)
} else {
Cow::Borrowed(raw.as_str())
};
if display_text.len() > MAX_WRAP_INPUT_LEN {
return 1;
}
let mut height = 0;
for sub_line in display_text.split('\n') {
height += wrap_line_chars(sub_line, width).len();
}
height.max(1)
}
/// Find (logical_line, offset_in_line) for a given visual row offset.
fn find_logical_line_at_visual_row(&self, visual_row: usize, _width: usize) -> (usize, usize) {
if let Some(index) = self.get_visual_height_index() {
return index.visual_row_to_logical_row_with_offset(visual_row as u64);
}
(visual_row.min(self.total_lines().saturating_sub(1)), 0)
}
/// Ensure the viewport cache covers the visible range.
/// Returns (start_logical, offset_in_line) for rendering.
pub(crate) fn ensure_viewport_cache(&mut self, width: usize) -> (usize, usize) {
let viewport_height = self.content_height as usize;
if !self.is_loaded() || width == 0 || viewport_height == 0 {
return (0, 0);
}
let params_changed = self.viewport_cache.needs_recompute(width, self.json_format);
if params_changed {
self.viewport_cache.invalidate();
self.viewport_cache.width = width;
self.viewport_cache.set_json_format(self.json_format);
self.ensure_visual_height_index(width);
if self.get_visual_height_index().is_some() {
self.ensure_cursor_visible();
} else {
self.ensure_cursor_visible_no_vhi();
}
}
// Find start logical line from v_offset
let (start_logical, offset_in_line) = if self.is_loading() {
(
self.v_offset.min(self.total_lines().saturating_sub(1)),
self.v_sub_offset,
)
} else {
self.find_logical_line_at_visual_row(self.v_offset, width)
};
// Compute viewport entries
self.viewport_cache.entries.clear();
self.viewport_cache.logical_start = start_logical;
let total = self.total_lines();
let mut rows_remaining = viewport_height + offset_in_line;
for line_idx in start_logical..total {
if rows_remaining == 0 {
break;
}
let entry = self.compute_line_entry(line_idx, width);
rows_remaining = rows_remaining.saturating_sub(entry.visual_height);
self.viewport_cache.entries.push(entry);
}
(start_logical, offset_in_line)
}
/// Compute total visual rows (cached, lazily evaluated).
/// Returns `total_lines` for sampling mode (1:1 mapping).
pub(super) fn total_visual_rows(&mut self) -> usize {
if self.is_loading() {
return self.total_lines();
}
if let Some(index) = self.get_visual_height_index() {
return index.total_visual_rows() as usize;
}
self.total_lines()
}
pub(super) fn cursor_to_first_visual_row(&self, line: usize) -> usize {
if self.is_loading() {
return line;
}
if let Some(index) = self.get_visual_height_index() {
return index.cursor_to_first_visual_row(line) as usize;
}
line
}
pub(super) fn visual_row_to_logical_row(&self, visual_row: usize) -> usize {
if self.is_loading() {
return visual_row.min(self.total_lines().saturating_sub(1));
}
if let Some(index) = self.get_visual_height_index() {
return index.visual_row_to_logical_row(visual_row as u64);
}
visual_row.min(self.total_lines().saturating_sub(1))
}
/// Compute the rebased offset pair (logical_line, sub_row) from the
/// current visual-row `v_offset`. Returns `(v_offset, v_sub_offset)`
/// suitable for the no-VHI fallback scrolling path.
///
/// MUST be called before borrowing `&mut self.loading_state` for
/// `invalidate_visual_height_index`, because it reads VHI through
/// `&self`.
pub(super) fn rebase_offset_for_invalidate(&self) -> (usize, usize) {
if self.get_visual_height_index().is_some() {
let top_visual = self.v_offset;
let top_line = self.visual_row_to_logical_row(top_visual);
let line_first_visual = self.cursor_to_first_visual_row(top_line);
let sub = top_visual.saturating_sub(line_first_visual);
(top_line, sub)
} else {
(self.v_offset, self.v_sub_offset)
}
}
pub(super) fn ensure_visual_height_index(&mut self, width: usize) {
let needs_rebuild = match self.get_visual_height_index() {
Some(idx) => !idx.is_valid_for(self.json_format, width),
None => true,
};
if needs_rebuild {
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format);
}
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
self.clamp_cursor_sub_offset_no_vhi();
}
}
pub(super) fn toggle_json_format(&mut self) {
self.json_format = !self.json_format;
self.viewport_cache.invalidate();
let width = self.viewport_cache.width;
let (new_offset, new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
reader.invalidate_visual_height_index();
if width > 0 {
reader.start_visual_height_rebuild(width, self.json_format);
}
}
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
self.clamp_cursor_sub_offset_no_vhi();
}
}
+99
View File
@@ -0,0 +1,99 @@
use log_viewer_core::types::LogLevel;
use unicode_width::UnicodeWidthChar;
pub(super) struct ViewportEntry {
pub(super) wrapped_rows: Vec<String>,
pub(super) level: Option<LogLevel>,
pub(super) visual_height: usize,
}
pub(super) struct ViewportCache {
pub(super) entries: Vec<ViewportEntry>,
pub(super) logical_start: usize,
pub(super) width: usize,
json_format: bool,
pub(super) cached_total_visual_rows: Option<usize>,
}
impl ViewportCache {
pub(super) fn new() -> Self {
Self {
entries: Vec::new(),
logical_start: 0,
width: 0,
json_format: false,
cached_total_visual_rows: None,
}
}
pub(super) fn invalidate(&mut self) {
self.entries.clear();
self.logical_start = 0;
self.width = 0;
self.cached_total_visual_rows = None;
}
pub(super) fn needs_recompute(&self, width: usize, json_format: bool) -> bool {
self.width != width || self.json_format != json_format
}
pub(super) fn set_json_format(&mut self, json_format: bool) {
self.json_format = json_format;
}
#[allow(dead_code)]
pub(super) fn get_entry(&self, logical_line: usize) -> Option<&ViewportEntry> {
if logical_line >= self.logical_start {
let idx = logical_line - self.logical_start;
self.entries.get(idx)
} else {
None
}
}
}
pub(super) const TRUNCATE_TAB_WIDTH: usize = 4;
pub(super) fn gutter_width_for(total_lines: usize, is_loading: bool) -> usize {
if total_lines == 0 {
return 0;
}
let line_num_width = total_lines.to_string().len();
let loading_extra = if is_loading { 1 } else { 0 };
line_num_width + loading_extra + 1 + 1
}
pub(super) fn truncate_to_columns(s: &str, max_cols: usize) -> String {
if max_cols == 0 || s.is_empty() {
return String::new();
}
let mut out = String::new();
let mut col = 0;
for ch in s.chars() {
if ch == '\t' {
let tab_stop = TRUNCATE_TAB_WIDTH - (col % TRUNCATE_TAB_WIDTH);
if col + tab_stop > max_cols {
break;
}
for _ in 0..tab_stop {
out.push(' ');
}
col += tab_stop;
} else {
let w = if ch.is_control() {
0
} else {
ch.width().unwrap_or(0)
};
if col + w > max_cols {
break;
}
out.push(ch);
col += w;
}
}
out
}
+162
View File
@@ -0,0 +1,162 @@
use log_viewer_core::io::file_reader::AppendStatus;
use log_viewer_core::io::progressive_reader::{ReaderState, compute_line_visual_height};
use log_viewer_core::watcher::file_watcher::FileEvent;
use super::viewport_cache::gutter_width_for;
use super::{App, AppLoadingState};
impl App {
pub fn poll_file_watcher(&mut self) {
let events: Vec<FileEvent> = match &mut self.file_watcher {
Some(w) => std::iter::from_fn(|| w.try_recv()).collect(),
None => return,
};
for event in events {
match event {
FileEvent::Appended { new_size: _ } => {
self.handle_file_appended();
}
FileEvent::Truncated { new_size: _ } => {
self.handle_file_truncated();
}
FileEvent::Rotated { new_inode: _ } => {
// Don't auto-switch; old content preserved.
// User can reload manually if desired.
}
FileEvent::Removed => {
self.loading_state = AppLoadingState::Error("File has been deleted".into());
}
FileEvent::WatcherError { message: _ } => {}
}
}
}
pub(super) fn handle_file_appended(&mut self) {
let rebased = self.rebase_offset_for_invalidate();
match &mut self.loading_state {
AppLoadingState::Ready { reader } => {
let old_reader_line_count = reader.line_count();
let status = reader.update_for_append();
let width = {
let total = if self.content_width > 0 {
self.content_width as usize
} else {
80
};
total.saturating_sub(gutter_width_for(reader.line_count(), false))
};
match status {
Ok(AppendStatus::Appended(_new_lines)) => {
let _ = reader.save_cache();
let (old_line_count, can_extend) = {
match &reader.state {
ReaderState::Ready {
visual_height_index: Some(idx),
..
} => (idx.line_count(), idx.is_valid_for(self.json_format, width)),
_ => (0, false),
}
};
let new_line_count = reader.line_count();
if can_extend && old_line_count == old_reader_line_count {
if let ReaderState::Ready {
visual_height_index: Some(index),
reader: fr,
} = &mut reader.state
{
if old_line_count > 0 {
let last_old_line_text =
fr.get_line(old_line_count - 1).unwrap_or("");
let new_h = compute_line_visual_height(
last_old_line_text,
width,
self.json_format,
);
index.replace_last_line_height(new_h);
}
let mut new_heights = Vec::with_capacity(
new_line_count.saturating_sub(old_line_count),
);
for i in old_line_count..new_line_count {
let line_text = fr.get_line(i).unwrap_or("");
new_heights.push(compute_line_visual_height(
line_text,
width,
self.json_format,
));
}
index.extend_from_heights(&new_heights);
}
} else {
let (new_offset, new_sub) = rebased;
self.v_offset = new_offset;
self.v_sub_offset = new_sub;
self.cursor_sub_offset = 0;
reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format);
}
self.viewport_cache.invalidate();
}
Ok(AppendStatus::Reloaded) => {
let _ = reader.save_cache();
let (new_offset, _new_sub) = rebased;
reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format);
self.cursor_line =
self.cursor_line.min(self.total_lines().saturating_sub(1));
self.v_offset = new_offset;
self.v_sub_offset = 0;
self.cursor_sub_offset = 0;
self.viewport_cache.invalidate();
self.clamp_v_offset();
}
Ok(AppendStatus::Unchanged) | Err(_) => {}
}
}
AppLoadingState::Loading { .. } => {
self.reload_after_loading = true;
}
_ => {}
}
}
pub(super) fn reload_ready_reader(&mut self) {
let (new_offset, _new_sub) = self.rebase_offset_for_invalidate();
if let AppLoadingState::Ready { reader } = &mut self.loading_state {
let _ = reader.reload();
let width = {
let total = if self.content_width > 0 {
self.content_width as usize
} else {
80
};
total.saturating_sub(gutter_width_for(reader.line_count(), false))
};
let _ = reader.save_cache();
reader.invalidate_visual_height_index();
reader.start_visual_height_rebuild(width, self.json_format);
self.cursor_line = self.cursor_line.min(self.total_lines().saturating_sub(1));
self.v_offset = new_offset;
self.v_sub_offset = 0;
self.clamp_cursor_sub_offset_no_vhi();
self.viewport_cache.invalidate();
self.clamp_v_offset();
}
}
pub(super) fn handle_file_truncated(&mut self) {
match &mut self.loading_state {
AppLoadingState::Ready { .. } => {
self.reload_ready_reader();
}
AppLoadingState::Loading { .. } => {
self.reload_after_loading = true;
}
_ => {}
}
}
}
+59 -78
View File
@@ -30,7 +30,7 @@ pub(crate) fn build_line_spans(
} }
fn is_cursor_visual_row(app: &App, logical_line: usize, visual_row: usize) -> bool { fn is_cursor_visual_row(app: &App, logical_line: usize, visual_row: usize) -> bool {
logical_line == app.cursor_line && visual_row == app.cursor_sub_offset logical_line == app.cursor_line() && visual_row == app.cursor_sub_offset()
} }
pub fn render(frame: &mut ratatui::Frame, app: &mut App) { pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
@@ -45,7 +45,7 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
.split(frame.area()); .split(frame.area());
// ── 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() { } else if app.is_loading() {
let name = app.file_name().unwrap_or("unknown"); let name = app.file_name().unwrap_or("unknown");
@@ -56,7 +56,7 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
let cursor_display = if app.total_lines() == 0 { let cursor_display = if app.total_lines() == 0 {
0 0
} else { } else {
app.cursor_line + 1 app.cursor_line() + 1
}; };
format!(" {} [{}/{}]", name, cursor_display, app.total_lines()) format!(" {} [{}/{}]", name, cursor_display, app.total_lines())
} else { } else {
@@ -68,7 +68,7 @@ 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() { } else if app.is_error() {
let msg = app.error_message().unwrap_or_default(); let msg = app.error_message().unwrap_or_default();
@@ -93,10 +93,10 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
} }
// ── Status bar ───────────────────────────────────────────────── // ── Status bar ─────────────────────────────────────────────────
if app.mode == AppMode::Settings { if app.mode() == AppMode::Settings {
if let Some(ref err) = app.settings_error { if let Some(err) = app.settings_error() {
frame.render_widget( frame.render_widget(
Paragraph::new(err.as_str()).style(Style::default().fg(Color::Red)), Paragraph::new(err).style(Style::default().fg(Color::Red)),
outer[2], outer[2],
); );
} else { } else {
@@ -127,7 +127,7 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
} 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 total = app.total_lines(); let total = app.total_lines();
let cursor_display = if total == 0 { 0 } else { app.cursor_line + 1 }; let cursor_display = if total == 0 { 0 } else { app.cursor_line() + 1 };
let status = format!( let status = format!(
" {} [{}/{}] | 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",
name, cursor_display, total name, cursor_display, total
@@ -162,18 +162,19 @@ pub fn render_settings(frame: &mut ratatui::Frame, app: &mut App, area: ratatui:
let inner = block.inner(popup); let inner = block.inner(popup);
frame.render_widget(block, popup); frame.render_widget(block, popup);
let settings_draft = app.settings_draft();
let levels = [ let levels = [
("ERROR", &app.settings_draft.error), ("ERROR", &settings_draft.error),
("WARN", &app.settings_draft.warn), ("WARN", &settings_draft.warn),
("INFO", &app.settings_draft.info), ("INFO", &settings_draft.info),
("DEBUG", &app.settings_draft.debug), ("DEBUG", &settings_draft.debug),
("TRACE", &app.settings_draft.trace), ("TRACE", &settings_draft.trace),
("UNKNOWN", &app.settings_draft.unknown), ("UNKNOWN", &settings_draft.unknown),
]; ];
let mut lines = Vec::new(); let mut lines = Vec::new();
for (i, (level_name, color_name)) in levels.iter().enumerate() { for (i, (level_name, color_name)) in levels.iter().enumerate() {
let is_selected = i == app.settings_cursor; let is_selected = i == app.settings_cursor();
let cursor_marker = if is_selected { "" } else { " " }; let cursor_marker = if is_selected { "" } else { " " };
let preview_color = color_name.parse::<Color>().unwrap_or(Color::White); let preview_color = color_name.parse::<Color>().unwrap_or(Color::White);
@@ -224,8 +225,7 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
let content_width = area.width as usize; let content_width = area.width as usize;
let content_height = area.height as usize; let content_height = area.height as usize;
app.content_height = area.height; app.set_content_area(area.width, area.height);
app.content_width = area.width;
let total_lines = app.total_lines(); let total_lines = app.total_lines();
let line_num_width = if total_lines > 0 { let line_num_width = if total_lines > 0 {
@@ -245,7 +245,6 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
let (start_logical, offset_in_line) = app.ensure_viewport_cache(actual_content_width); let (start_logical, offset_in_line) = app.ensure_viewport_cache(actual_content_width);
let mut lines: Vec<Line> = Vec::new(); let mut lines: Vec<Line> = Vec::new();
let mut current_visual_offset: usize = 0;
let available_rows = content_height; let available_rows = content_height;
let gutter_style = if is_loading { let gutter_style = if is_loading {
@@ -256,66 +255,50 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
Style::default().fg(Color::DarkGray) Style::default().fg(Color::DarkGray)
}; };
for (entry_idx, entry) in app.viewport_cache.entries.iter().enumerate() { for row in app.viewport_rows(start_logical, offset_in_line, available_rows) {
let logical_line = app.viewport_cache.logical_start + entry_idx; let is_cursor = is_cursor_visual_row(app, row.logical_line, row.visual_row);
let start_row = if logical_line == start_logical { let level = row.level;
offset_in_line
let bg_color = if is_cursor {
Color::DarkGray
} else { } else {
0 Color::Reset
};
let level_fg = level_fg(level, app.color_config()).unwrap_or(Color::White);
let gutter_text = if row.visual_row == 0 {
if is_loading {
format!(
"~{:>width$} \u{2502}",
row.logical_line + 1,
width = line_num_width
)
} else {
format!(
"{:>width$} \u{2502}",
row.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)
}; };
for (visual_row, text) in entry.wrapped_rows.iter().enumerate().skip(start_row) { let effective_gutter_style = if is_cursor {
if current_visual_offset >= available_rows { gutter_style.bg(bg_color)
break; } else {
} gutter_style
};
let is_cursor = is_cursor_visual_row(app, logical_line, visual_row); lines.push(Line::from(vec![
let level = entry.level.as_ref(); Span::styled(gutter_text, effective_gutter_style),
Span::styled(
let bg_color = if is_cursor { row.text.to_string(),
Color::DarkGray Style::default().fg(level_fg).bg(bg_color),
} else { ),
Color::Reset ]));
};
let level_fg = level_fg(level, &app.color_config).unwrap_or(Color::White);
let gutter_text = if visual_row == 0 {
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)
};
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;
}
if current_visual_offset >= available_rows {
break;
}
} }
while lines.len() < available_rows { while lines.len() < available_rows {
@@ -384,8 +367,7 @@ mod tests {
#[test] #[test]
fn test_cursor_highlight_predicate_uses_cursor_sub_offset() { fn test_cursor_highlight_predicate_uses_cursor_sub_offset() {
let mut app = App::new(); let mut app = App::new();
app.cursor_line = 4; app.set_cursor_for_test(4, 2);
app.cursor_sub_offset = 2;
assert!(!is_cursor_visual_row(&app, 4, 0)); assert!(!is_cursor_visual_row(&app, 4, 0));
assert!(!is_cursor_visual_row(&app, 3, 2)); assert!(!is_cursor_visual_row(&app, 3, 2));
@@ -532,8 +514,7 @@ mod tests {
width: u16, width: u16,
height: u16, height: u16,
) -> ratatui::buffer::Buffer { ) -> ratatui::buffer::Buffer {
app.mode = crate::app::AppMode::Settings; app.enter_settings_mode_for_test();
app.settings_draft = app.color_config.clone();
render_to_buffer(app, width, height) render_to_buffer(app, width, height)
} }