Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95f259a2bb | ||
|
|
e69b7af32a | ||
|
|
4421da35f4 | ||
|
|
e765f8967f | ||
|
|
967c11fea9 |
@@ -0,0 +1,114 @@
|
||||
# logViewer
|
||||
|
||||
High-performance log file viewer. Rust workspace with 4 crates, edition 2024, MSRV 1.92 (pinned in `rust-toolchain.toml`).
|
||||
|
||||
## Workspace Layout
|
||||
|
||||
```
|
||||
crates/core — log-viewer-core (library) — I/O, parsing, types, config, file watching
|
||||
crates/tui — log-viewer-tui (binary) — terminal UI (ratatui + crossterm)
|
||||
crates/gui — log-viewer-gui (binary) — GUI (egui + eframe) — STUB, not yet functional
|
||||
crates/bench — log-viewer-bench (binary) — mmap vs pread benchmark harness
|
||||
```
|
||||
|
||||
`core` owns all I/O, parsing, data types, and file watching. TUI/GUI consume core's public API. GUI is a placeholder with no core integration yet.
|
||||
|
||||
**`default-members = ["crates/core"]`** — bare `cargo run` / `cargo test` target *core*, not the TUI. Always pass `-p log-viewer-tui` (or `--workspace`) explicitly.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Run the TUI (primary interface)
|
||||
cargo run -p log-viewer-tui -- <log-file>
|
||||
|
||||
# Build/check/test/lint the whole workspace
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo fmt --check --all
|
||||
cargo clippy --workspace -- -D warnings
|
||||
|
||||
# Single crate
|
||||
cargo test -p log-viewer-core
|
||||
cargo test -p log-viewer-tui
|
||||
|
||||
# Benchmarks (test file is NOT auto-generated — see "Benchmarks" section below)
|
||||
cargo run -p log-viewer-bench
|
||||
cargo run -p log-viewer-bench -- --quick --suites startup,render --output results.md
|
||||
```
|
||||
|
||||
## CI Gate (must pass before merge)
|
||||
|
||||
Runs on ubuntu-latest + windows-latest (`.github/workflows/ci.yml`):
|
||||
1. `cargo fmt --check --all`
|
||||
2. `cargo check --workspace`
|
||||
3. `cargo test --workspace`
|
||||
4. `cargo clippy --workspace -- -D warnings`
|
||||
|
||||
Toolchain installed via `dtolnay/rust-toolchain@stable` reading `rust-toolchain.toml` (channel `1.92`). No rustfmt.toml or clippy.toml — uses toolchain defaults.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
All ten modules are declared `pub` in `lib.rs` and re-exported as `log_viewer_core::<module>`.
|
||||
|
||||
### Key Modules
|
||||
|
||||
- **`types`** — `LogLevel`, `LogEntry`, `SearchQuery`, `SearchFilter`, `Bookmark`, `FileSession`, `DuplicateKey`, `SearchResult`
|
||||
- **`error`** — `CoreError` (**12 variants** via thiserror: Io, Parse, Search, Index, Config, TomlSerialize, Encoding, Watch, Mmap, Cache, FileNotFound, Other) + `Result<T>` alias. `From` impls for `io::Error` / `serde_json::Error` / `toml::de::Error` / `toml::ser::Error` / `notify::Error` mean `?` works freely.
|
||||
- **`io::file_reader`** — mmap-backed `FileReader` with TOCTOU mitigation (post-mmap stat check), append/reload detection
|
||||
- **`io::progressive_reader`** — `ProgressiveFileReader` state machine (`ReaderState::Sampling { .. } | Ready { .. }`), background indexer via crossbeam-channel, `VisualHeightIndex` for wrapped-line scroll
|
||||
- **`io::line_index`** — Sparse index (every `BLOCK_SIZE = 256` lines, memchr SIMD). Serializable to disk.
|
||||
- **`io::index_cache`** — Persistent cache with xxh3 content hash, atomic writes via temp files
|
||||
- **`io::line_sampler`** / **`io::cache_util`** — shared helpers used by the index path
|
||||
- **`io::wrap`** — Unicode-aware line wrapping (CJK/emoji/tab), JSON pretty-printing; enforces `MAX_WRAP_INPUT_LEN`
|
||||
- **`io::read_cache`** — LRU read cache (not yet integrated into the live reader; future pread backend)
|
||||
- **`parser::json`** — NDJSON parser with BOM handling, duplicate key detection
|
||||
- **`parser::level`** — JSON-first level detection (`detect_level`); falls back to bounded keyword scan with word-boundary check on non-JSON lines
|
||||
- **`watcher::file_watcher`** — File event watcher (notify + crossbeam) with append/truncate/rotation detection
|
||||
- **`config`** — `ColorConfig` TOML load/save with per-field serde defaults
|
||||
|
||||
### Stubs (single-line TODOs — planned, not yet built)
|
||||
|
||||
- `filter::Filter`
|
||||
- `bookmark::BookmarkManager`
|
||||
- `session::SessionManager`
|
||||
- `search::engine::SearchEngine`
|
||||
|
||||
## 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.
|
||||
- **`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.
|
||||
- **`ui.rs`** — ratatui rendering: title bar, content area, status bar, settings popup
|
||||
- **`color.rs`** — `LogLevel` → ratatui `Color` via `ColorConfig`
|
||||
|
||||
### TUI Keybindings
|
||||
|
||||
j/k scroll, Ctrl+d/u half-page, Ctrl+f/b full-page, G/gg jump end/top, Tab toggle JSON formatting, S settings panel, q/Esc quit. Settings: j/k select level, ←/→ cycle colors, Enter save, Esc cancel.
|
||||
|
||||
## Testing
|
||||
|
||||
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
|
||||
- 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_test_file(lines) -> NamedTempFile` — `core/io/index_cache.rs`
|
||||
- `make_file(data) -> NamedTempFile` — `core/io/read_cache.rs`
|
||||
- `struct TempFile { ... }` — `core/io/line_sampler.rs`
|
||||
- `insta` is a declared dev-dependency but currently **unused** (no `insta::` calls anywhere in the workspace)
|
||||
- TUI render tests use ratatui `TestBackend` (see `tui/ui.rs`)
|
||||
- Bench crate has its own unit tests for mmap/pread backends, metrics, and data_gen helpers
|
||||
|
||||
## Benchmarks
|
||||
|
||||
Custom harness (not criterion). Wall-clock + `/proc/self/` RSS/page fault metrics. 7 suites: startup, render, jump, memory, growth, rotation, concurrent. Tests mmap (plain/sequential/random/populate/phase_aware) vs pread (plain/random/sequential) backends. Output: markdown tables in `benchmark-report.md`.
|
||||
|
||||
**The harness does NOT auto-generate the test file.** `main.rs` requires `/tmp/test-logviewer/extreme.log` to already exist; if missing it prints the `dd` command and exits. Generate it manually first:
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/test-logviewer
|
||||
dd if=/dev/urandom of=/tmp/test-logviewer/extreme.log bs=1M count=5000 # ~5GB
|
||||
```
|
||||
|
||||
(`crates/bench/src/data_gen.rs` has `generate_test_file` / `ensure_test_file` helpers and is used by the `growth` and `rotation` suites for per-run growable files, but it is **not** wired into `main.rs` for the primary test file.)
|
||||
|
||||
Note: `log-viewer-bench` depends directly on `memmap2` / `nix` / `libc` / `memchr` — it does **not** depend on `log-viewer-core`, so it can be built standalone with `cargo build -p log-viewer-bench`.
|
||||
@@ -158,7 +158,7 @@ impl LineIndex {
|
||||
|
||||
// If the junction falls on a block boundary, record the start offset
|
||||
// (analogous to from_bytes always pushing offset 0 for line 0).
|
||||
if starts_new_line && (old_total as usize) % BLOCK_SIZE == 0 {
|
||||
if starts_new_line && (old_total as usize).is_multiple_of(BLOCK_SIZE) {
|
||||
self.sampled_offsets.push(start_offset);
|
||||
}
|
||||
|
||||
@@ -212,15 +212,17 @@ impl LineIndex {
|
||||
self.total_lines as usize
|
||||
}
|
||||
|
||||
// ─── getter 方法 ────────────────────────────────────────────────────
|
||||
#[cfg(test)]
|
||||
pub(crate) fn sampled_offsets(&self) -> &[u64] {
|
||||
&self.sampled_offsets
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn total_lines(&self) -> u64 {
|
||||
self.total_lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_trailing_newline(&self) -> bool {
|
||||
self.has_trailing_newline
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::io::file_reader::{AppendStatus, FileReader};
|
||||
use crate::io::index_cache::IndexCache;
|
||||
use crate::io::line_index::LineIndex;
|
||||
use crate::io::line_sampler::sample_line_count;
|
||||
use crate::io::wrap::{format_json_line, wrap_line_chars, MAX_WRAP_INPUT_LEN};
|
||||
use crate::io::wrap::{MAX_WRAP_INPUT_LEN, format_json_line};
|
||||
|
||||
// ─── Cancel-aware channel helpers ────────────────────────────────────────────
|
||||
|
||||
@@ -235,29 +235,11 @@ pub fn compute_line_visual_height(
|
||||
fn compute_text_visual_height(text: &str, width: usize) -> usize {
|
||||
let mut height = 0;
|
||||
for sub_line in text.split('\n') {
|
||||
height += wrap_line_chars(sub_line, width).len();
|
||||
height += crate::io::wrap::wrap_line_count(sub_line, width);
|
||||
}
|
||||
height.max(1)
|
||||
}
|
||||
|
||||
fn compute_visual_heights(
|
||||
reader: &FileReader,
|
||||
terminal_width: usize,
|
||||
json_format: bool,
|
||||
) -> Vec<usize> {
|
||||
let line_count = reader.line_count();
|
||||
let mut visual_heights = Vec::with_capacity(line_count);
|
||||
for i in 0..line_count {
|
||||
let line_text = reader.get_line(i).unwrap_or("");
|
||||
visual_heights.push(compute_line_visual_height(
|
||||
line_text,
|
||||
terminal_width,
|
||||
json_format,
|
||||
));
|
||||
}
|
||||
visual_heights
|
||||
}
|
||||
|
||||
// ─── ReaderState ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub enum ReaderState {
|
||||
@@ -281,8 +263,8 @@ pub enum ReaderState {
|
||||
pub fn spawn_indexer(
|
||||
path: PathBuf,
|
||||
generation: u64,
|
||||
terminal_width: usize,
|
||||
json_format: bool,
|
||||
_terminal_width: usize,
|
||||
_json_format: bool,
|
||||
cancel_rx: crossbeam_channel::Receiver<()>,
|
||||
) -> crossbeam_channel::Receiver<IndexerMessage> {
|
||||
let (tx, rx) = crossbeam_channel::bounded(10);
|
||||
@@ -291,20 +273,28 @@ pub fn spawn_indexer(
|
||||
let file = match std::fs::File::open(&path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
send_cancelable(&tx, IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
}, &cancel_rx);
|
||||
send_cancelable(
|
||||
&tx,
|
||||
IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
},
|
||||
&cancel_rx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target_len = match file.metadata() {
|
||||
Ok(m) => m.len(),
|
||||
Err(e) => {
|
||||
send_cancelable(&tx, IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
}, &cancel_rx);
|
||||
send_cancelable(
|
||||
&tx,
|
||||
IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
},
|
||||
&cancel_rx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -321,10 +311,14 @@ pub fn spawn_indexer(
|
||||
let buf = match buf_reader.fill_buf() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
send_cancelable(&tx, IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
}, &cancel_rx);
|
||||
send_cancelable(
|
||||
&tx,
|
||||
IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
},
|
||||
&cancel_rx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -407,18 +401,26 @@ pub fn spawn_indexer(
|
||||
Ok(_) | Err(_) => None,
|
||||
},
|
||||
Err(e) => {
|
||||
send_cancelable(&tx, IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
}, &cancel_rx);
|
||||
send_cancelable(
|
||||
&tx,
|
||||
IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
},
|
||||
&cancel_rx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
send_cancelable(&tx, IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
}, &cancel_rx);
|
||||
send_cancelable(
|
||||
&tx,
|
||||
IndexerMessage::Error {
|
||||
generation,
|
||||
message: e.to_string(),
|
||||
},
|
||||
&cancel_rx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -430,18 +432,15 @@ pub fn spawn_indexer(
|
||||
|
||||
let reader = FileReader::from_parts(path, mmap, line_index);
|
||||
|
||||
let visual_height_index = if terminal_width > 0 {
|
||||
let visual_heights = compute_visual_heights(&reader, terminal_width, json_format);
|
||||
Some(VisualHeightIndex::build(&visual_heights).with_params(json_format, terminal_width))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
send_cancelable(&tx, IndexerMessage::Complete {
|
||||
generation,
|
||||
reader,
|
||||
visual_height_index,
|
||||
}, &cancel_rx);
|
||||
send_cancelable(
|
||||
&tx,
|
||||
IndexerMessage::Complete {
|
||||
generation,
|
||||
reader,
|
||||
visual_height_index: None,
|
||||
},
|
||||
&cancel_rx,
|
||||
);
|
||||
});
|
||||
|
||||
rx
|
||||
@@ -467,41 +466,67 @@ pub fn spawn_visual_height_rebuild(
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut reader = std::io::BufReader::with_capacity(64 * 1024, file);
|
||||
let mut visual_heights = Vec::with_capacity(line_index.line_count());
|
||||
let mut line_buf = Vec::new();
|
||||
let mmap = match unsafe { memmap2::Mmap::map(&file) } {
|
||||
Ok(m) => match file.metadata() {
|
||||
Ok(meta) if meta.len() >= m.len() as u64 => m,
|
||||
_ => return,
|
||||
},
|
||||
Err(_) => return,
|
||||
};
|
||||
let data = mmap.as_ref();
|
||||
let total_lines = line_index.line_count();
|
||||
let mut visual_heights: Vec<usize> = Vec::with_capacity(total_lines);
|
||||
|
||||
let mut line_start = 0usize;
|
||||
let mut bytes_since_cancel_check = 0usize;
|
||||
|
||||
loop {
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
return;
|
||||
if bytes_since_cancel_check >= 1_000_000 {
|
||||
bytes_since_cancel_check = 0;
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
line_buf.clear();
|
||||
match std::io::BufRead::read_until(&mut reader, b'\n', &mut line_buf) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {
|
||||
let line_text = std::str::from_utf8(&line_buf)
|
||||
.ok()
|
||||
.map(|s| s.trim_end_matches(['\r', '\n']))
|
||||
.unwrap_or("");
|
||||
visual_heights.push(compute_line_visual_height(
|
||||
line_text,
|
||||
terminal_width,
|
||||
json_format,
|
||||
));
|
||||
}
|
||||
Err(_) => return,
|
||||
let newline_rel = memchr::memchr(b'\n', &data[line_start..]);
|
||||
let line_end = match newline_rel {
|
||||
Some(p) => line_start + p,
|
||||
None => data.len(),
|
||||
};
|
||||
|
||||
let mut slice_end = line_end;
|
||||
if slice_end > line_start && data[slice_end - 1] == b'\r' {
|
||||
slice_end -= 1;
|
||||
}
|
||||
|
||||
let line_text = std::str::from_utf8(&data[line_start..slice_end]).unwrap_or("");
|
||||
visual_heights.push(compute_line_visual_height(
|
||||
line_text,
|
||||
terminal_width,
|
||||
json_format,
|
||||
));
|
||||
|
||||
bytes_since_cancel_check =
|
||||
bytes_since_cancel_check.saturating_add(line_end - line_start + 1);
|
||||
|
||||
match newline_rel {
|
||||
Some(_) => line_start = line_end + 1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
if visual_heights.len() != line_index.line_count() {
|
||||
if visual_heights.len() != total_lines {
|
||||
return;
|
||||
}
|
||||
|
||||
let index =
|
||||
VisualHeightIndex::build(&visual_heights).with_params(json_format, terminal_width);
|
||||
|
||||
send_cancelable(&tx, VisualHeightRebuildResult { generation, index }, &cancel_rx);
|
||||
send_cancelable(
|
||||
&tx,
|
||||
VisualHeightRebuildResult { generation, index },
|
||||
&cancel_rx,
|
||||
);
|
||||
});
|
||||
|
||||
rx
|
||||
@@ -1114,12 +1139,10 @@ mod tests {
|
||||
assert_eq!(reader.get_line(0), Some("line1"));
|
||||
assert_eq!(reader.get_line(1), Some("line2"));
|
||||
assert_eq!(reader.get_line(2), Some("line3"));
|
||||
assert!(visual_height_index.is_some());
|
||||
let idx = visual_height_index.unwrap();
|
||||
assert_eq!(idx.total_visual_rows(), 3);
|
||||
assert_eq!(idx.visual_row_to_logical_row(0), 0);
|
||||
assert_eq!(idx.visual_row_to_logical_row(1), 1);
|
||||
assert_eq!(idx.visual_row_to_logical_row(2), 2);
|
||||
assert!(
|
||||
visual_height_index.is_none(),
|
||||
"spawn_indexer no longer builds VHI inline; UI triggers rebuild post-layout"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Complete, got {:?}", other),
|
||||
}
|
||||
@@ -1320,12 +1343,15 @@ mod tests {
|
||||
match rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap() {
|
||||
IndexerMessage::Progress { .. } => continue,
|
||||
IndexerMessage::Complete {
|
||||
reader,
|
||||
visual_height_index,
|
||||
..
|
||||
} => {
|
||||
let idx = visual_height_index.expect("should have visual height index");
|
||||
assert_eq!(idx.visual_height_of_line(0), 1);
|
||||
assert_eq!(idx.visual_height_of_line(1), 1);
|
||||
assert_eq!(reader.line_count(), 2);
|
||||
assert!(
|
||||
visual_height_index.is_none(),
|
||||
"spawn_indexer no longer builds VHI inline; UI triggers rebuild post-layout"
|
||||
);
|
||||
break;
|
||||
}
|
||||
other => panic!("expected Complete, got {:?}", other),
|
||||
@@ -1515,13 +1541,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let (_cancel_tx, cancel_rx) = crossbeam_channel::bounded(1);
|
||||
let rx = spawn_visual_height_rebuild(
|
||||
f.path().to_path_buf(),
|
||||
1,
|
||||
80,
|
||||
false,
|
||||
cancel_rx,
|
||||
);
|
||||
let rx = spawn_visual_height_rebuild(f.path().to_path_buf(), 1, 80, false, cancel_rx);
|
||||
|
||||
let result = rx.recv_timeout(std::time::Duration::from_secs(5));
|
||||
match result {
|
||||
@@ -1604,7 +1624,10 @@ mod tests {
|
||||
Err(e) => panic!("recv error: {:?}", e),
|
||||
}
|
||||
}
|
||||
assert!(got_complete, "indexer should complete even when Progress fills channel");
|
||||
assert!(
|
||||
got_complete,
|
||||
"indexer should complete even when Progress fills channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+194
-12
@@ -1,3 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Maximum input length for wrap/format operations (10 MB).
|
||||
/// Callers should check against this constant before invoking `wrap_line_chars`
|
||||
/// to avoid pathological cases on oversized lines.
|
||||
@@ -69,21 +71,81 @@ pub fn wrap_line_chars(line: &str, width: usize) -> Vec<String> {
|
||||
result
|
||||
}
|
||||
|
||||
/// Format a line as pretty-printed JSON if it's a JSON Object.
|
||||
/// Returns the original line unchanged for non-JSON or non-Object content.
|
||||
pub fn format_json_line(line: &str) -> String {
|
||||
if line.trim().is_empty() {
|
||||
return String::new();
|
||||
/// Count wrapped rows for a line without allocating the wrapped strings.
|
||||
/// MUST produce the same count as `wrap_line_chars(line, width).len()`.
|
||||
pub fn wrap_line_count(line: &str, width: usize) -> usize {
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
if width == 0 || line.is_empty() {
|
||||
return 1;
|
||||
}
|
||||
// Quick pre-check: only try parsing if it starts with '{'
|
||||
if !line.trim_start().starts_with('{') {
|
||||
return line.to_string();
|
||||
let mut count = 0usize;
|
||||
let mut col = 0usize;
|
||||
let mut row_has_content = false;
|
||||
|
||||
for ch in line.chars() {
|
||||
if ch == '\t' {
|
||||
let tab_stop = TAB_WIDTH - (col % TAB_WIDTH);
|
||||
let mut remaining = tab_stop;
|
||||
while remaining > 0 {
|
||||
let avail = width.saturating_sub(col);
|
||||
let fill = remaining.min(avail);
|
||||
col += fill;
|
||||
remaining -= fill;
|
||||
if col >= width {
|
||||
count += 1;
|
||||
col = 0;
|
||||
row_has_content = false;
|
||||
} else {
|
||||
row_has_content = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let w = if ch.is_control() {
|
||||
0
|
||||
} else {
|
||||
ch.width().unwrap_or(0)
|
||||
};
|
||||
if col + w > width && row_has_content {
|
||||
count += 1;
|
||||
col = 0;
|
||||
}
|
||||
col += w;
|
||||
if col >= width {
|
||||
count += 1;
|
||||
col = 0;
|
||||
row_has_content = false;
|
||||
} else {
|
||||
row_has_content = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if row_has_content {
|
||||
count += 1;
|
||||
}
|
||||
if count == 0 {
|
||||
count = 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Format a line as pretty-printed JSON if it's a JSON Object.
|
||||
/// Returns the original line borrowed for non-JSON or non-Object content,
|
||||
/// only allocating when pretty-printing actually happens.
|
||||
pub fn format_json_line(line: &str) -> Cow<'_, str> {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Cow::Borrowed("");
|
||||
}
|
||||
if !trimmed.starts_with('{') {
|
||||
return Cow::Borrowed(line);
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(line) {
|
||||
Ok(value) if value.is_object() => {
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| line.to_string())
|
||||
}
|
||||
_ => line.to_string(),
|
||||
Ok(value) if value.is_object() => match serde_json::to_string_pretty(&value) {
|
||||
Ok(s) => Cow::Owned(s),
|
||||
Err(_) => Cow::Borrowed(line),
|
||||
},
|
||||
_ => Cow::Borrowed(line),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,4 +293,124 @@ mod tests {
|
||||
let result = wrap_line_chars("ab\t", 4);
|
||||
assert_eq!(result, vec!["ab "]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_empty() {
|
||||
assert_eq!(wrap_line_count("", 80), wrap_line_chars("", 80).len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_zero_width() {
|
||||
assert_eq!(
|
||||
wrap_line_count("hello", 0),
|
||||
wrap_line_chars("hello", 0).len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_ascii_short() {
|
||||
for width in 1..20 {
|
||||
assert_eq!(
|
||||
wrap_line_count("hello world", width),
|
||||
wrap_line_chars("hello world", width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_cjk() {
|
||||
for width in 1..10 {
|
||||
assert_eq!(
|
||||
wrap_line_count("你好世界", width),
|
||||
wrap_line_chars("你好世界", width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_emoji() {
|
||||
for width in 1..6 {
|
||||
assert_eq!(
|
||||
wrap_line_count("😀👨👩👧", width),
|
||||
wrap_line_chars("😀👨👩👧", width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_combining_mark() {
|
||||
for width in 1..6 {
|
||||
assert_eq!(
|
||||
wrap_line_count("a\u{0301}b\u{0301}c", width),
|
||||
wrap_line_chars("a\u{0301}b\u{0301}c", width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_tab_variants() {
|
||||
let inputs = ["\t", "a\tb", "ab\t", "\t\t", "abc\tb", "a\t\t\tb"];
|
||||
for input in inputs {
|
||||
for width in 1..9 {
|
||||
assert_eq!(
|
||||
wrap_line_count(input, width),
|
||||
wrap_line_chars(input, width).len(),
|
||||
"input={input:?} width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_mixed() {
|
||||
let input = "log: 你好 😀\tvalue=true [ERROR]";
|
||||
for width in 1..40 {
|
||||
assert_eq!(
|
||||
wrap_line_count(input, width),
|
||||
wrap_line_chars(input, width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_zero_width_chars() {
|
||||
let input = "a\u{200B}b\u{200C}c\u{200D}d";
|
||||
for width in 1..8 {
|
||||
assert_eq!(
|
||||
wrap_line_count(input, width),
|
||||
wrap_line_chars(input, width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_control_chars() {
|
||||
let input = "a\x07b\x1Fc";
|
||||
for width in 1..8 {
|
||||
assert_eq!(
|
||||
wrap_line_count(input, width),
|
||||
wrap_line_chars(input, width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_count_matches_chars_long_log_line() {
|
||||
let input = "2025-01-14T10:23:45.123Z [INFO] [auth] request handled status=200 \
|
||||
latency=0.234s endpoint=/api/v1/users user=user_42 request_id=req_abc123def456";
|
||||
for width in [10, 20, 40, 60, 80, 100, 120] {
|
||||
assert_eq!(
|
||||
wrap_line_count(input, width),
|
||||
wrap_line_chars(input, width).len(),
|
||||
"width={width}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ use std::collections::{HashMap, HashSet};
|
||||
// 默认的 serde_json::from_str::<HashMap<_, _>>() 遇到重复键时会采用"后者覆盖前者"(last-wins),
|
||||
// 前面的值被静默丢弃。这里我们通过自定义 Visitor 在反序列化过程中逐个观察 key-value 对,
|
||||
// 在保持 last-wins 行为的同时,将重复 key 的所有值记录到 DuplicateKey 中。
|
||||
use serde::de::{MapAccess, Visitor};
|
||||
use serde::Deserializer;
|
||||
use serde::de::{MapAccess, Visitor};
|
||||
|
||||
// serde_json::Value — 来自 serde_json 库(Rust 中最流行的 JSON 处理库)。
|
||||
// Value 是一个枚举类型,可以表示任意 JSON 值:
|
||||
@@ -83,7 +83,10 @@ pub fn detect_json_log(line: &str) -> bool {
|
||||
// 则匹配成功。_ 是通配符,表示"不关心对象里面的具体内容"。
|
||||
//
|
||||
// 如果匹配到 Ok(Value::Object(_)) 返回 true,否则返回 false。
|
||||
matches!(serde_json::from_str::<Value>(strip_bom(line)), Ok(Value::Object(_)))
|
||||
matches!(
|
||||
serde_json::from_str::<Value>(strip_bom(line)),
|
||||
Ok(Value::Object(_))
|
||||
)
|
||||
}
|
||||
|
||||
// ─── DuplicateKeyVisitor ──────────────────────────────────────────────────
|
||||
@@ -147,7 +150,7 @@ fn parse_json_object_with_duplicates(
|
||||
json: &str,
|
||||
) -> Option<(serde_json::Map<String, Value>, Vec<DuplicateKey>)> {
|
||||
let mut deserializer = serde_json::Deserializer::from_str(json);
|
||||
Some(deserializer.deserialize_map(DuplicateKeyVisitor).ok()?)
|
||||
deserializer.deserialize_map(DuplicateKeyVisitor).ok()
|
||||
}
|
||||
|
||||
// ─── take_string_field_from_map 辅助函数 ──────────────────────────────────
|
||||
@@ -189,7 +192,8 @@ pub fn parse_line(line: &str) -> Option<LogEntry> {
|
||||
let (mut obj, duplicate_keys) = parse_json_object_with_duplicates(line)?;
|
||||
|
||||
let raw_line = line.to_string();
|
||||
let timestamp = take_string_field_from_map(&mut obj, &["timestamp", "time", "ts", "@timestamp"]);
|
||||
let timestamp =
|
||||
take_string_field_from_map(&mut obj, &["timestamp", "time", "ts", "@timestamp"]);
|
||||
let level = take_string_field_from_map(&mut obj, &["level", "lvl", "severity"])
|
||||
.map(|s| s.parse::<LogLevel>().unwrap_or_else(|e| match e {}));
|
||||
|
||||
@@ -513,8 +517,14 @@ mod tests {
|
||||
assert_eq!(entry.duplicate_keys.len(), 1);
|
||||
assert_eq!(entry.duplicate_keys[0].key, "message");
|
||||
assert_eq!(entry.duplicate_keys[0].values.len(), 2);
|
||||
assert_eq!(entry.duplicate_keys[0].values[0], Value::String("first".into()));
|
||||
assert_eq!(entry.duplicate_keys[0].values[1], Value::String("second".into()));
|
||||
assert_eq!(
|
||||
entry.duplicate_keys[0].values[0],
|
||||
Value::String("first".into())
|
||||
);
|
||||
assert_eq!(
|
||||
entry.duplicate_keys[0].values[1],
|
||||
Value::String("second".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+995
-343
File diff suppressed because it is too large
Load Diff
+48
-19
@@ -6,6 +6,7 @@ use crate::color::level_fg;
|
||||
use log_viewer_core::config::ColorConfig;
|
||||
use log_viewer_core::types::LogLevel;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn build_line_spans(
|
||||
gutter_text: String,
|
||||
content_text: String,
|
||||
@@ -28,6 +29,10 @@ pub(crate) fn build_line_spans(
|
||||
])
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::widgets::Paragraph;
|
||||
@@ -123,7 +128,10 @@ pub fn render(frame: &mut ratatui::Frame, app: &mut App) {
|
||||
let name = app.file_name().unwrap_or("unknown");
|
||||
let total = app.total_lines();
|
||||
let cursor_display = if total == 0 { 0 } else { app.cursor_line + 1 };
|
||||
let status = format!(" {} [{}/{}] | j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit", name, cursor_display, total);
|
||||
let status = format!(
|
||||
" {} [{}/{}] | j/k:scroll d/u:half-page f/b:page G/gg:jump Tab:format S:settings q:quit",
|
||||
name, cursor_display, total
|
||||
);
|
||||
frame.render_widget(Paragraph::new(status), outer[2]);
|
||||
return;
|
||||
} else {
|
||||
@@ -142,8 +150,12 @@ pub fn render_settings(frame: &mut ratatui::Frame, app: &mut App, area: ratatui:
|
||||
|
||||
let popup_w = ((area.width as u32 * 4 / 5).max(40)).min(area.width as u32) as u16;
|
||||
let popup_h = ((area.height as u32 * 4 / 5).max(14)).min(area.height as u32) as u16;
|
||||
let popup_x = area.x.saturating_add(area.width.saturating_sub(popup_w) / 2);
|
||||
let popup_y = area.y.saturating_add(area.height.saturating_sub(popup_h) / 2);
|
||||
let popup_x = area
|
||||
.x
|
||||
.saturating_add(area.width.saturating_sub(popup_w) / 2);
|
||||
let popup_y = area
|
||||
.y
|
||||
.saturating_add(area.height.saturating_sub(popup_h) / 2);
|
||||
let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h);
|
||||
|
||||
let block = Block::new().borders(Borders::ALL).title(" Color Settings ");
|
||||
@@ -221,14 +233,8 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let is_loading = app.is_loading();
|
||||
let gutter_prefix_extra = if is_loading { 1 } else { 0 };
|
||||
let gutter_width = if total_lines > 0 {
|
||||
line_num_width + gutter_prefix_extra + 1 + 1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let gutter_width = app.gutter_width();
|
||||
|
||||
let actual_content_width = content_width.saturating_sub(gutter_width);
|
||||
|
||||
@@ -263,7 +269,7 @@ fn render_content(frame: &mut ratatui::Frame, app: &mut App, area: ratatui::layo
|
||||
break;
|
||||
}
|
||||
|
||||
let is_cursor = logical_line == app.cursor_line;
|
||||
let is_cursor = is_cursor_visual_row(app, logical_line, visual_row);
|
||||
let level = entry.level.as_ref();
|
||||
|
||||
let bg_color = if is_cursor {
|
||||
@@ -375,6 +381,17 @@ mod tests {
|
||||
assert_eq!(line.spans[1].style.bg, Some(Color::Reset));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cursor_highlight_predicate_uses_cursor_sub_offset() {
|
||||
let mut app = App::new();
|
||||
app.cursor_line = 4;
|
||||
app.cursor_sub_offset = 2;
|
||||
|
||||
assert!(!is_cursor_visual_row(&app, 4, 0));
|
||||
assert!(!is_cursor_visual_row(&app, 3, 2));
|
||||
assert!(is_cursor_visual_row(&app, 4, 2));
|
||||
}
|
||||
|
||||
fn render_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer {
|
||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
@@ -481,7 +498,8 @@ mod tests {
|
||||
let result = std::panic::catch_unwind(|| {
|
||||
let data = std::fs::read(&path).unwrap();
|
||||
let index = log_viewer_core::io::line_index::LineIndex::from_bytes(&data);
|
||||
let _ = log_viewer_core::io::index_cache::IndexCache::save_with_hash(&path, &index, &data);
|
||||
let _ =
|
||||
log_viewer_core::io::index_cache::IndexCache::save_with_hash(&path, &index, &data);
|
||||
|
||||
let mut app = App::new();
|
||||
app.load_file(path.to_str().unwrap()).unwrap();
|
||||
@@ -509,14 +527,22 @@ mod tests {
|
||||
// ── Issue #31: Settings popup area offset tests ────────────────
|
||||
|
||||
/// Helper: enter settings mode and render to buffer.
|
||||
fn render_settings_to_buffer(app: &mut App, width: u16, height: u16) -> ratatui::buffer::Buffer {
|
||||
fn render_settings_to_buffer(
|
||||
app: &mut App,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> ratatui::buffer::Buffer {
|
||||
app.mode = crate::app::AppMode::Settings;
|
||||
app.settings_draft = app.color_config.clone();
|
||||
render_to_buffer(app, width, height)
|
||||
}
|
||||
|
||||
/// Find the top-left corner of the popup border by scanning for '┌'.
|
||||
fn find_popup_top_left(buf: &ratatui::buffer::Buffer, width: u16, height: u16) -> Option<(u16, u16)> {
|
||||
fn find_popup_top_left(
|
||||
buf: &ratatui::buffer::Buffer,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Option<(u16, u16)> {
|
||||
for row in 0..height {
|
||||
for col in 0..width {
|
||||
if buf.cell((col, row)).unwrap().symbol() == "┌" {
|
||||
@@ -537,8 +563,8 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
let buf = render_settings_to_buffer(&mut app, 80, 24);
|
||||
|
||||
let (_px, py) = find_popup_top_left(&buf, 80, 24)
|
||||
.expect("popup border '┌' should be rendered");
|
||||
let (_px, py) =
|
||||
find_popup_top_left(&buf, 80, 24).expect("popup border '┌' should be rendered");
|
||||
|
||||
// outer[1].y == 1; the popup is centered inside a 22-row area,
|
||||
// so popup_y must be at least 1 (not 0).
|
||||
@@ -556,8 +582,8 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
let buf = render_settings_to_buffer(&mut app, 80, 24);
|
||||
|
||||
let (px, _py) = find_popup_top_left(&buf, 80, 24)
|
||||
.expect("popup border '┌' should be rendered");
|
||||
let (px, _py) =
|
||||
find_popup_top_left(&buf, 80, 24).expect("popup border '┌' should be rendered");
|
||||
|
||||
// popup_w = 80*4/5 = 64, centered: (80-64)/2 = 8
|
||||
assert_eq!(
|
||||
@@ -573,6 +599,9 @@ mod tests {
|
||||
let mut app = App::new();
|
||||
let _buf = render_settings_to_buffer(&mut app, 30, 10);
|
||||
}));
|
||||
assert!(result.is_ok(), "rendering settings in a small frame should not panic");
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"rendering settings in a small frame should not panic"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user