From a89f29837fd45d58203fe3d948805db4a98194df Mon Sep 17 00:00:00 2001 From: "Xinyao (Morry) Niu" Date: Wed, 22 Jul 2026 15:22:43 +0800 Subject: [PATCH 1/2] fix(search): include appends in active scans Extend an in-flight search budget when follow refresh adds formatted lines and mark the match index inexact so records committed after search start are still considered. Add a boundary-focused forward-search regression. --- crates/fmtview-core/src/viewer/file.rs | 3 +++ .../src/viewer/file/input/state.rs | 11 +++++++++ crates/fmtview-core/tests/record_timeline.rs | 24 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/crates/fmtview-core/src/viewer/file.rs b/crates/fmtview-core/src/viewer/file.rs index f334374..703f0a0 100644 --- a/crates/fmtview-core/src/viewer/file.rs +++ b/crates/fmtview-core/src/viewer/file.rs @@ -283,6 +283,9 @@ impl FileViewer { self.state .shift_for_insert(change.inserted_at, change.inserted_lines); } + if change.appended_lines > 0 { + self.state.extend_for_append(change.appended_lines); + } if change.appended_lines > 0 && self.state.follow == Some(FollowState::Following) { set_file_end(&mut self.state, self.file.line_count()); } diff --git a/crates/fmtview-core/src/viewer/file/input/state.rs b/crates/fmtview-core/src/viewer/file/input/state.rs index 26eda53..a7f0a42 100644 --- a/crates/fmtview-core/src/viewer/file/input/state.rs +++ b/crates/fmtview-core/src/viewer/file/input/state.rs @@ -256,6 +256,17 @@ impl ViewState { self.search_match_ordinal = None; self.clear_tool_navigation(); } + pub(in crate::viewer) fn extend_for_append(&mut self, lines: usize) { + if lines == 0 { + return; + } + if let Some(task) = self.search_task.as_mut() { + task.remaining = task.remaining.saturating_add(lines); + } + if let Some(index) = self.search_index.as_mut() { + index.exact = false; + } + } } fn shift_index(value: &mut usize, at: usize, lines: usize) { diff --git a/crates/fmtview-core/tests/record_timeline.rs b/crates/fmtview-core/tests/record_timeline.rs index fa0edf0..dbf8a6b 100644 --- a/crates/fmtview-core/tests/record_timeline.rs +++ b/crates/fmtview-core/tests/record_timeline.rs @@ -581,6 +581,30 @@ fn repeated_forward_search_wraps_into_lazily_loaded_older_records() { assert!(wrapped.contains("needle-old"), "{wrapped}"); } +#[test] +fn active_forward_search_includes_newer_records_arriving_at_the_boundary() { + let (handle, timeline) = fake_timeline([record(0), record(1)]); + let file = RecordTimelineViewFile::with_initial_limit( + Box::new(timeline), + JSONL, + RecordLoadLimit::new(16, 4096), + ) + .unwrap(); + let mut viewer = FileViewer::new(Box::new(file), FormatKind::Jsonl, None); + let size = Size::new(60, 8); + viewer.render(size, None).unwrap(); + viewer.handle_event(key(KeyCode::Home), FileViewer::page_for_size(size)); + viewer.render(size, None).unwrap(); + enter_search(&mut viewer, size, "needle-after-search-started"); + + handle.append(b"{\"message\":\"needle-after-search-started\"}\n".to_vec()); + viewer.preload().unwrap(); + advance_until_idle(&mut viewer); + let frame = viewer.render(size, None).unwrap(); + + assert!(frame_text(frame).contains("needle-after-search-started")); +} + #[test] fn file_timeline_preserves_crlf_empty_records_and_ignores_incomplete_eof() { let mut temp = NamedTempFile::new().unwrap(); From d5905d8f4b1dfc3bf527d8ecda71f024bf87e77f Mon Sep 17 00:00:00 2001 From: "Xinyao (Morry) Niu" Date: Wed, 22 Jul 2026 15:34:39 +0800 Subject: [PATCH 2/2] docs: state follow scan limits precisely Distinguish fixed-size reverse-read buffers from total scan work when the EOF record is incomplete, and document the append-oriented rewrite detection boundary. Record the active-search append fix in the unreleased notes. --- CHANGELOG.md | 2 ++ README.md | 22 +++++++++++++++------- docs/architecture.md | 9 ++++++--- docs/performance.md | 7 +++++-- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47068ca..64c1e46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ for GitHub Release notes, so every published version must have a matching - Revalidate bounded pending tails before scanning appended bytes, so an in-place delimiter rewrite followed by growth is not missed, and publish newer-read cursor progress only after the complete batch succeeds. +- Keep an in-progress forward search open to committed records appended after + the search began, including a match that arrives at the previous tail. ## [0.5.4] - 2026-07-17 diff --git a/README.md b/README.md index 687dbf1..07f7d22 100644 --- a/README.md +++ b/README.md @@ -249,10 +249,12 @@ committed tail: fmtview --follow service.jsonl ``` -The first frame reverse-scans only enough source data to build the last -viewport; it does not format or index the file from the beginning. A final -record is committed only after its newline arrives, so a writer's partial EOF -record is never displayed early. While the viewport remains at the bottom, +The first frame reverse-scans from EOF to the newest committed record boundary; +it does not format or index earlier committed records from the beginning. The +scan work is proportional to the incomplete EOF suffix, so a newline-free file +is the intentional worst case. A final record is committed only after its +newline arrives, so a writer's partial EOF record is never displayed early. +While the viewport remains at the bottom, committed appends advance it automatically. Scrolling up changes the footer to `follow:detached`; scrolling back to the physical bottom reattaches. Press `f` to pause or resume follow explicitly. Search plus structure, chat-role, and tool @@ -261,7 +263,11 @@ after opening. Follow mode requires a real file and an interactive stdout terminal. Ordinary viewing and redirected output are unchanged; `--follow` never turns redirected -stdout into an endless stream. +stdout into an endless stream. The built-in file source is intended for +append-oriented logs and detects normal truncation, rotation, replacement, and +copytruncate signals. A producer that performs arbitrary in-place rewrites +without a distinguishable identity or metadata change should provide its own +`RecordTimeline` implementation. Format a literal string: @@ -537,9 +543,11 @@ rendered output in memory for browsing. - Record-like TTY previews, such as JSONL logs, use a lazy path: `fmtview` sniffs a small prefix to confirm that the input is independent records, then formats only the records needed for the visible window. -- Follow-mode JSONL opens from a bounded reverse tail scan. Refresh locates the +- Follow-mode JSONL opens from a chunked reverse tail scan. Refresh locates the newest committed delimiter from EOF, and record/byte budgets bound subsequent - older and newer loads; no persistent full-file index is required. + older and newer loads; no persistent full-file index is required. Total open + scan work is bounded by the incomplete EOF record rather than by the full + committed history. - Lazy preview writes transformed records into a temporary spool and keeps compact offsets, not formatted strings, in memory. The title shows `N+` lines while the session index is still incomplete and idle time extends the index. diff --git a/docs/architecture.md b/docs/architecture.md index 2385f6e..29f9b6b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -320,10 +320,13 @@ The contract has these invariants: fixed epoch boundary and cannot interleave ahead of stale old-epoch history. `FileRecordTimeline` implements the seam for growing newline-delimited files. -It locates committed EOF from bounded reverse chunks, never exposes an +It locates committed EOF with fixed-size reverse-read buffers, never exposes an incomplete final line, and lets bounded forward loads do the record work after -a refresh. Refresh validates bounded start/middle/end samples of committed -history independently of file timestamps, catching same-identity rewrites when +a refresh. The total initial scan is proportional to the incomplete EOF suffix, +so a newline-free file can require reading back to offset zero even though the +scanner's memory stays bounded. Refresh validates bounded start/middle/end +samples of committed history independently of file timestamps, catching +same-identity rewrites when one of those windows changes without indexing the whole file. Inode/device identity is used on Unix; portable fallbacks never treat file length as identity. diff --git a/docs/performance.md b/docs/performance.md index 90d3468..d5089be 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -135,8 +135,11 @@ Load metrics: Shape: `record-stream/huge-record`. - `timeline tail-first open+format` measures reverse tail discovery, formatting the initial bounded record batch, spooling/indexing it, and reading the last - viewport. Fixture generation is outside the timed region, and correctness - tests separately assert bounded source bytes with instrumentation. + viewport. Fixture generation is outside the timed region. Correctness tests + separately assert bounded work for a normally delimited million-record file; + a newline-free EOF suffix remains proportional to that single incomplete + record because no source can prove the absence of an earlier delimiter + without reading it. - `timeline older prepend+format` measures one bounded backward record load, formatting/spooling, and insertion into the session indexes. - `timeline append refresh+format` measures reverse committed-boundary refresh,