Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ for GitHub Release notes, so every published version must have a matching

## [Unreleased]

### Added

- Expose `fmtview::view` as a narrow public embedding facade for interactive
JSONL record timelines. It re-exports the backend-neutral `RecordTimeline`
contract and result alias, accepts snapshot/follow options, and keeps
crossterm polling, terminal lifecycle, and frame commit private.

### Changed

- Separate tail-first lazy history from live follow refresh. Embedded snapshot
timelines still load older records on demand without calling `refresh` or
showing follow controls, while existing follow timelines retain append,
detach, pause, and reset behavior.

## [0.6.0] - 2026-07-22

### Added
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,43 @@ 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.

## Embedding a Record Timeline

Applications with their own committed-record storage can reuse the interactive
viewer through `fmtview::view` without implementing terminal setup or depending
on `fmtview-core` directly:

```rust
use fmtview::view::{self, RecordTimeline, Result, ViewOptions};

fn inspect(source: Box<dyn RecordTimeline>, follow: bool) -> Result<()> {
let mut options = ViewOptions::default();
options.follow = follow;
view::run(source, options)
}
```

The facade re-exports `RecordTimeline` and all of its record, read, refresh,
reset, snapshot, limit, and result types. A downstream source can therefore
list only `fmtview` in `Cargo.toml`; the `view::Result` alias also avoids a
direct `anyhow` dependency. Each committed source record is interpreted as one
JSONL record, and the source keeps responsibility for never yielding an
incomplete logical commit.

`ViewOptions::default()` opens a stable tail-first snapshot: older records load
lazily, but a live source is not refreshed and no follow controls are shown.
Set `follow = true` to refresh committed appends and enable attached, detached,
and paused follow state. Both modes keep search, structure/tool navigation, raw
record snapshots, media collapsing, and exact source bytes in the shared core.
The call requires an interactive stdout terminal; fmtview owns raw mode,
crossterm polling, alternate-screen entry/exit, mouse capture, frame commit,
and cleanup for the duration of `view::run`.

See [`examples/embed-timeline.rs`](examples/embed-timeline.rs) for a complete
source implementation. The facade follows the `fmtview` package's SemVer. While
the package is pre-1.0, pin the compatible minor line you tested and review
minor-version upgrades before adopting them.

Format a literal string:

```sh
Expand Down
6 changes: 6 additions & 0 deletions crates/fmtview-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ The core viewer owns viewport-anchor preservation and follow state through
backend-neutral events, including `ViewerCommand::FollowTail` and
`ViewerCommand::ToggleFollowTail`.

`RecordTimelineViewFile::new` retains live follow behavior.
`RecordTimelineViewFile::snapshot` uses the same bounded tail-first and lazy
older-loading path without refreshing newer records or enabling follow state.
The root package's `fmtview::view` facade selects between those modes and owns
the terminal lifecycle for embedders that do not need to drive core headlessly.

Record-backed views can expose exact raw-record snapshots through bounded
virtual lines. Lazy JSONL records are copied once at ingest into an immutable
raw spool, so opening raw mode is an offset lookup and source replacement
Expand Down
39 changes: 39 additions & 0 deletions crates/fmtview-core/src/load/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ const RESET_COMPARE_CHUNK_BYTES: usize = 16 * 1024;
/// overlap detection live in a separate on-disk spool.
pub struct RecordTimelineViewFile {
label: String,
follow: bool,
state: RefCell<TimelineViewState>,
}

impl RecordTimelineViewFile {
/// Open a live timeline at its current tail and refresh it while viewed.
pub fn new(timeline: Box<dyn RecordTimeline>, options: FormatOptions) -> Result<Self> {
Self::with_initial_limit(
timeline,
Expand All @@ -44,16 +46,49 @@ impl RecordTimelineViewFile {
)
}

/// Open a timeline at its current tail without refreshing newer records.
///
/// Older records remain available through bounded lazy loads. This is
/// useful when an embedder wants a stable snapshot of a source that may
/// continue growing in the background.
pub fn snapshot(timeline: Box<dyn RecordTimeline>, options: FormatOptions) -> Result<Self> {
Self::snapshot_with_initial_limit(
timeline,
options,
RecordLoadLimit::new(INITIAL_TAIL_RECORDS, INITIAL_TAIL_BYTES),
)
}

/// Open a live timeline with an explicit initial tail-load limit.
pub fn with_initial_limit(
timeline: Box<dyn RecordTimeline>,
options: FormatOptions,
limit: RecordLoadLimit,
) -> Result<Self> {
Self::with_initial_limit_and_follow(timeline, options, limit, true)
}

/// Open a non-refreshing timeline snapshot with an explicit initial limit.
pub fn snapshot_with_initial_limit(
timeline: Box<dyn RecordTimeline>,
options: FormatOptions,
limit: RecordLoadLimit,
) -> Result<Self> {
Self::with_initial_limit_and_follow(timeline, options, limit, false)
}

fn with_initial_limit_and_follow(
timeline: Box<dyn RecordTimeline>,
options: FormatOptions,
limit: RecordLoadLimit,
follow: bool,
) -> Result<Self> {
let label = timeline.label().to_owned();
let mut state = TimelineViewState::new(timeline, options)?;
state.load_older(limit)?;
Ok(Self {
label,
follow,
state: RefCell::new(state),
})
}
Expand Down Expand Up @@ -107,6 +142,10 @@ impl ViewFile for RecordTimelineViewFile {
}

fn is_follow_source(&self) -> bool {
self.follow
}

fn starts_at_tail(&self) -> bool {
true
}

Expand Down
4 changes: 4 additions & 0 deletions crates/fmtview-core/src/load/view_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ pub trait ViewFile {
fn is_follow_source(&self) -> bool {
false
}
/// Whether the initially loaded window is anchored at the newer boundary.
fn starts_at_tail(&self) -> bool {
false
}
fn has_older_records(&self) -> bool {
false
}
Expand Down
60 changes: 48 additions & 12 deletions crates/fmtview-core/src/viewer/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,15 @@ struct RawRecordOverlay {
impl FileViewer {
pub fn new(file: Box<dyn ViewFile>, mode: FormatKind, notice: Option<String>) -> Self {
let mut state = ViewState::default();
if file.is_follow_source() {
state.follow = Some(FollowState::Following);
if file.starts_at_tail() {
state.viewport_at_tail = true;
state.preserve_tail_on_next_draw = true;
state.older_preload_armed = false;
set_file_end(&mut state, file.line_count());
}
if file.is_follow_source() {
state.follow = Some(FollowState::Following);
}
state.raw_record_available = file.supports_raw_records();
if let Some(message) = notice {
state.set_notice(message, Instant::now(), NOTICE_DURATION);
Expand Down Expand Up @@ -112,10 +116,26 @@ impl FileViewer {
.as_ref()
.is_some_and(|task| task.direction == StructureDirection::Backward)
{
let awaiting_older = self
.state
.structure_task
.as_ref()
.is_some_and(|task| task.awaiting_older);
let change = self
.file
.load_older_records(LAZY_PRELOAD_RECORDS, TIMELINE_PRELOAD_BYTES)?;
let inserted_lines = change.inserted_lines;
dirty |= self.apply_file_change(change);
if awaiting_older {
if let Some(task) = self.state.structure_task.as_mut() {
task.next_line = if inserted_lines > 0 {
task.next_line.saturating_sub(1)
} else {
usize::MAX
};
task.awaiting_older = false;
}
}
}
if self.file.has_older_records()
&& self
Expand Down Expand Up @@ -170,19 +190,22 @@ impl FileViewer {
}

pub fn preload(&mut self) -> Result<bool> {
if !self.file.is_follow_source() {
return self.file.preload(
let mut dirty = if self.file.is_follow_source() {
let refresh = self
.file
.refresh_records(LAZY_PRELOAD_RECORDS, TIMELINE_PRELOAD_BYTES)?;
self.apply_file_change(refresh)
} else {
self.file.preload(
LAZY_PRELOAD_LINES,
LAZY_PRELOAD_RECORDS,
LAZY_PRELOAD_BUDGET,
);
}

let refresh = self
.file
.refresh_records(LAZY_PRELOAD_RECORDS, TIMELINE_PRELOAD_BYTES)?;
let mut dirty = self.apply_file_change(refresh);
if self.state.top <= TIMELINE_OLDER_THRESHOLD_LINES && self.file.has_older_records() {
)?
};
if self.state.older_preload_armed
&& self.state.top <= TIMELINE_OLDER_THRESHOLD_LINES
&& self.file.has_older_records()
{
let older = self
.file
.load_older_records(LAZY_PRELOAD_RECORDS, TIMELINE_PRELOAD_BYTES)?;
Expand Down Expand Up @@ -230,6 +253,7 @@ impl FileViewer {
&mut raw.state,
raw.file.line_count(),
true,
false,
page,
);
}
Expand Down Expand Up @@ -269,8 +293,18 @@ impl FileViewer {
&mut self.state,
self.file.line_count(),
self.file.at_newer_boundary(),
self.file.has_older_records(),
page,
);
if self.file.starts_at_tail() && !had_active_prompt {
if event_moves_away_from_tail(event, self.state.wrap)
&& (action.dirty || self.file.has_older_records())
{
self.state.older_preload_armed = true;
} else if event_forces_tail(event) {
self.state.older_preload_armed = false;
}
}
if self.file.is_follow_source() && !had_active_prompt {
if action.dirty && event_moves_away_from_tail(event, self.state.wrap) {
if self.state.follow == Some(FollowState::Following) {
Expand Down Expand Up @@ -298,6 +332,7 @@ impl FileViewer {
if !action.dirty || self.state.top >= self.file.line_count().saturating_sub(1) {
self.state.follow = Some(FollowState::Following);
self.state.viewport_at_tail = true;
self.state.older_preload_armed = false;
self.state.follow_reattach_pending = false;
set_file_end(&mut self.state, self.file.line_count());
action.dirty = true;
Expand Down Expand Up @@ -398,6 +433,7 @@ impl FileViewer {
fn enable_follow_tail(&mut self) -> ViewerAction {
self.state.follow = Some(FollowState::Following);
self.state.viewport_at_tail = true;
self.state.older_preload_armed = false;
self.state.follow_reattach_pending = false;
set_file_end(&mut self.state, self.file.line_count());
ViewerAction {
Expand Down
26 changes: 18 additions & 8 deletions crates/fmtview-core/src/viewer/file/input/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::viewer::{InputEvent, KeyCode, KeyModifiers, MouseEventKind, ViewerAct

use crate::viewer::file::{MOUSE_HORIZONTAL_COLUMNS, MOUSE_SCROLL_LINES};

use super::super::structure::{StructureDirection, start_structure_navigation};
use super::super::structure::{StructureDirection, start_structure_navigation_with_older};
use super::{
jump::{handle_jump_input_key, push_jump_digit},
keys::{accepts_jump_digit, plain_key},
Expand All @@ -24,20 +24,27 @@ pub(in crate::viewer) fn handle_event(
line_count: usize,
page: usize,
) -> ViewerAction {
handle_event_with_count(event, state, line_count, true, page)
handle_event_with_count(event, state, line_count, true, false, page)
}

pub(in crate::viewer) fn handle_event_with_count(
event: InputEvent,
state: &mut ViewState,
line_count: usize,
line_count_exact: bool,
has_older_records: bool,
page: usize,
) -> ViewerAction {
match event {
InputEvent::Key { code, modifiers } => {
handle_key_event_with_count(code, modifiers, state, line_count, line_count_exact, page)
}
InputEvent::Key { code, modifiers } => handle_key_event_with_count(
code,
modifiers,
state,
line_count,
line_count_exact,
has_older_records,
page,
),
InputEvent::Mouse { kind, modifiers } if !state.has_active_prompt() => {
handle_mouse_event(kind, modifiers, state, line_count)
}
Expand All @@ -59,7 +66,7 @@ pub(in crate::viewer) fn handle_key_event(
line_count: usize,
page: usize,
) -> ViewerAction {
handle_key_event_with_count(code, modifiers, state, line_count, true, page)
handle_key_event_with_count(code, modifiers, state, line_count, true, false, page)
}

pub(in crate::viewer) fn handle_key_event_with_count(
Expand All @@ -68,6 +75,7 @@ pub(in crate::viewer) fn handle_key_event_with_count(
state: &mut ViewState,
line_count: usize,
line_count_exact: bool,
has_older_records: bool,
page: usize,
) -> ViewerAction {
if matches!(code, KeyCode::Char('c')) && modifiers.contains(KeyModifiers::CONTROL) {
Expand Down Expand Up @@ -103,16 +111,18 @@ pub(in crate::viewer) fn handle_key_event_with_count(
KeyCode::Char('N') if plain_key(modifiers) => {
start_repeat_search(state, line_count, SearchDirection::Backward)
}
KeyCode::Char(']') if plain_key(modifiers) => start_structure_navigation(
KeyCode::Char(']') if plain_key(modifiers) => start_structure_navigation_with_older(
state,
line_count,
line_count_exact,
has_older_records,
StructureDirection::Forward,
),
KeyCode::Char('[') if plain_key(modifiers) => start_structure_navigation(
KeyCode::Char('[') if plain_key(modifiers) => start_structure_navigation_with_older(
state,
line_count,
line_count_exact,
has_older_records,
StructureDirection::Backward,
),
KeyCode::Enter => false,
Expand Down
2 changes: 2 additions & 0 deletions crates/fmtview-core/src/viewer/file/input/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub(in crate::viewer) struct ViewState {
pub(in crate::viewer) tool_target: Option<usize>,
pub(in crate::viewer) viewport_at_tail: bool,
pub(in crate::viewer) preserve_tail_on_next_draw: bool,
pub(in crate::viewer) older_preload_armed: bool,
pub(in crate::viewer) follow: Option<FollowState>,
pub(in crate::viewer) follow_reattach_pending: bool,
pub(in crate::viewer) mouse_capture: bool,
Expand Down Expand Up @@ -90,6 +91,7 @@ impl Default for ViewState {
tool_target: None,
viewport_at_tail: false,
preserve_tail_on_next_draw: false,
older_preload_armed: true,
follow: None,
follow_reattach_pending: false,
mouse_capture: true,
Expand Down
Loading