Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions crates/fmtview-core/src/viewer/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
11 changes: 11 additions & 0 deletions crates/fmtview-core/src/viewer/file/input/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions crates/fmtview-core/tests/record_timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down