diff --git a/CHANGELOG.md b/CHANGELOG.md index 876172e..424162d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 1041248..41c4d74 100644 --- a/README.md +++ b/README.md @@ -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, 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 diff --git a/crates/fmtview-core/README.md b/crates/fmtview-core/README.md index 0453cf0..1bfd3ff 100644 --- a/crates/fmtview-core/README.md +++ b/crates/fmtview-core/README.md @@ -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 diff --git a/crates/fmtview-core/src/load/timeline.rs b/crates/fmtview-core/src/load/timeline.rs index ce47b7d..d7dfde5 100644 --- a/crates/fmtview-core/src/load/timeline.rs +++ b/crates/fmtview-core/src/load/timeline.rs @@ -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, } impl RecordTimelineViewFile { + /// Open a live timeline at its current tail and refresh it while viewed. pub fn new(timeline: Box, options: FormatOptions) -> Result { Self::with_initial_limit( timeline, @@ -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, options: FormatOptions) -> Result { + 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, options: FormatOptions, limit: RecordLoadLimit, + ) -> Result { + 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, + options: FormatOptions, + limit: RecordLoadLimit, + ) -> Result { + Self::with_initial_limit_and_follow(timeline, options, limit, false) + } + + fn with_initial_limit_and_follow( + timeline: Box, + options: FormatOptions, + limit: RecordLoadLimit, + follow: bool, ) -> Result { 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), }) } @@ -107,6 +142,10 @@ impl ViewFile for RecordTimelineViewFile { } fn is_follow_source(&self) -> bool { + self.follow + } + + fn starts_at_tail(&self) -> bool { true } diff --git a/crates/fmtview-core/src/load/view_file.rs b/crates/fmtview-core/src/load/view_file.rs index 4e1a0c4..273f508 100644 --- a/crates/fmtview-core/src/load/view_file.rs +++ b/crates/fmtview-core/src/load/view_file.rs @@ -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 } diff --git a/crates/fmtview-core/src/viewer/file.rs b/crates/fmtview-core/src/viewer/file.rs index 0b2d2a9..f1b06cb 100644 --- a/crates/fmtview-core/src/viewer/file.rs +++ b/crates/fmtview-core/src/viewer/file.rs @@ -80,11 +80,15 @@ struct RawRecordOverlay { impl FileViewer { pub fn new(file: Box, mode: FormatKind, notice: Option) -> 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); @@ -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 @@ -170,19 +190,22 @@ impl FileViewer { } pub fn preload(&mut self) -> Result { - 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)?; @@ -230,6 +253,7 @@ impl FileViewer { &mut raw.state, raw.file.line_count(), true, + false, page, ); } @@ -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) { @@ -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; @@ -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 { diff --git a/crates/fmtview-core/src/viewer/file/input/events.rs b/crates/fmtview-core/src/viewer/file/input/events.rs index 8979a42..20964c7 100644 --- a/crates/fmtview-core/src/viewer/file/input/events.rs +++ b/crates/fmtview-core/src/viewer/file/input/events.rs @@ -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}, @@ -24,7 +24,7 @@ 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( @@ -32,12 +32,19 @@ pub(in crate::viewer) fn handle_event_with_count( 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) } @@ -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( @@ -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) { @@ -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, diff --git a/crates/fmtview-core/src/viewer/file/input/state.rs b/crates/fmtview-core/src/viewer/file/input/state.rs index 408163b..f652bfd 100644 --- a/crates/fmtview-core/src/viewer/file/input/state.rs +++ b/crates/fmtview-core/src/viewer/file/input/state.rs @@ -53,6 +53,7 @@ pub(in crate::viewer) struct ViewState { pub(in crate::viewer) tool_target: Option, 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, pub(in crate::viewer) follow_reattach_pending: bool, pub(in crate::viewer) mouse_capture: bool, @@ -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, diff --git a/crates/fmtview-core/src/viewer/file/structure.rs b/crates/fmtview-core/src/viewer/file/structure.rs index 15cbcc3..9124c6c 100644 --- a/crates/fmtview-core/src/viewer/file/structure.rs +++ b/crates/fmtview-core/src/viewer/file/structure.rs @@ -21,6 +21,7 @@ pub(in crate::viewer) struct StructureTask { pub(super) direction: StructureDirection, pub(super) next_line: usize, pub(super) viewport: Option, + pub(super) awaiting_older: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -43,11 +44,22 @@ impl StructureViewport { } } +#[cfg(test)] pub(in crate::viewer) fn start_structure_navigation( state: &mut ViewState, line_count: usize, line_count_exact: bool, direction: StructureDirection, +) -> bool { + start_structure_navigation_with_older(state, line_count, line_count_exact, false, direction) +} + +pub(in crate::viewer) fn start_structure_navigation_with_older( + state: &mut ViewState, + line_count: usize, + line_count_exact: bool, + has_older_records: bool, + direction: StructureDirection, ) -> bool { state.clear_tool_navigation(); state.structure_task = None; @@ -60,10 +72,17 @@ pub(in crate::viewer) fn start_structure_navigation( } let anchor = state.structure_cursor.unwrap_or(state.top); - let Some(next_line) = structure_start_line(anchor, line_count, line_count_exact, direction) - else { - set_no_block_message(state, direction); - return true; + let awaiting_older = + direction == StructureDirection::Backward && anchor == 0 && has_older_records; + let next_line = if awaiting_older { + 0 + } else { + let Some(next_line) = structure_start_line(anchor, line_count, line_count_exact, direction) + else { + set_no_block_message(state, direction); + return true; + }; + next_line }; state.clear_footer_message(); @@ -74,6 +93,7 @@ pub(in crate::viewer) fn start_structure_navigation( direction, next_line, viewport, + awaiting_older, }); true } diff --git a/crates/fmtview-core/src/viewer/tests/navigation.rs b/crates/fmtview-core/src/viewer/tests/navigation.rs index d705241..61eb80f 100644 --- a/crates/fmtview-core/src/viewer/tests/navigation.rs +++ b/crates/fmtview-core/src/viewer/tests/navigation.rs @@ -386,8 +386,15 @@ fn line_jump_on_incomplete_lazy_file_can_target_unloaded_line() { let mut state = ViewState::default(); handle_key_event(KeyCode::Char('6'), KeyModifiers::NONE, &mut state, 1, 10); - let action = - handle_key_event_with_count(KeyCode::Enter, KeyModifiers::NONE, &mut state, 1, false, 10); + let action = handle_key_event_with_count( + KeyCode::Enter, + KeyModifiers::NONE, + &mut state, + 1, + false, + false, + 10, + ); assert!(action.dirty); assert_eq!(state.top, 5); diff --git a/crates/fmtview-core/src/viewer/tests/structure/interaction.rs b/crates/fmtview-core/src/viewer/tests/structure/interaction.rs index d5088f3..4c2cb85 100644 --- a/crates/fmtview-core/src/viewer/tests/structure/interaction.rs +++ b/crates/fmtview-core/src/viewer/tests/structure/interaction.rs @@ -20,6 +20,22 @@ fn bracket_keys_start_structure_navigation_tasks() { ); } +#[test] +fn backward_structure_at_lazy_top_waits_for_older_records() { + let mut state = ViewState::default(); + + assert!(start_structure_navigation_with_older( + &mut state, + 1, + true, + true, + StructureDirection::Backward, + )); + + assert!(state.structure_task.is_some()); + assert!(state.footer_message.is_none()); +} + #[test] fn manual_scroll_resets_structure_repeat_anchor() { let file = indexed_file(&["{", r#" "a": {"#, " },", r#" "b": {"#, " }", "}"]); diff --git a/crates/fmtview-core/tests/record_timeline.rs b/crates/fmtview-core/tests/record_timeline.rs index 765e039..e54f2b9 100644 --- a/crates/fmtview-core/tests/record_timeline.rs +++ b/crates/fmtview-core/tests/record_timeline.rs @@ -42,6 +42,223 @@ fn fake_timeline_distinguishes_pending_from_terminal_end() { ); } +#[test] +fn snapshot_timeline_lazily_loads_older_without_refresh_or_follow_controls() { + let (handle, timeline) = fake_timeline((0..130).map(record)); + let file = RecordTimelineViewFile::snapshot_with_initial_limit( + Box::new(timeline), + JSONL, + RecordLoadLimit::new(2, 4096), + ) + .unwrap(); + let mut viewer = FileViewer::new(Box::new(file), FormatKind::Jsonl, None); + let size = Size::new(60, 8); + + let tail = viewer.render(size, None).unwrap(); + assert!(frame_text(tail).contains("record-129")); + assert!( + !viewer + .render(size, None) + .unwrap() + .footer_text + .contains("follow:") + ); + + handle.append(record(130)); + assert!(!viewer.preload().unwrap()); + assert_eq!(handle.refresh_calls(), 0); + viewer.handle_event(key(KeyCode::Char('g')), FileViewer::page_for_size(size)); + assert!(viewer.preload().unwrap()); + viewer.handle_event(key(KeyCode::Char('g')), FileViewer::page_for_size(size)); + let first_older_batch = frame_text(viewer.render(size, None).unwrap()); + assert!( + first_older_batch.contains("record-64"), + "{first_older_batch}" + ); + + assert!(viewer.preload().unwrap()); + assert_eq!(handle.refresh_calls(), 0); + viewer.handle_event(key(KeyCode::Char('g')), FileViewer::page_for_size(size)); + let source_start = frame_text(viewer.render(size, None).unwrap()); + assert!(source_start.contains("record-0"), "{source_start}"); + + viewer.handle_event(key(KeyCode::Char('f')), FileViewer::page_for_size(size)); + let after_f = viewer.render(size, None).unwrap(); + assert!(!after_f.footer_text.contains("follow:")); + assert_eq!(handle.refresh_calls(), 0); +} + +#[test] +fn snapshot_opens_at_tail_without_eagerly_loading_older_records() { + let (handle, timeline) = fake_timeline((0..130).map(record)); + let file = RecordTimelineViewFile::snapshot_with_initial_limit( + Box::new(timeline), + JSONL, + RecordLoadLimit::new(8, 4096), + ) + .unwrap(); + let mut viewer = FileViewer::new(Box::new(file), FormatKind::Jsonl, None); + let size = Size::new(60, 8); + + let tail = viewer.render(size, None).unwrap(); + let tail_text = frame_text(tail); + assert!(tail_text.contains("record-129"), "{tail_text}"); + assert!(!tail_text.contains("record-122"), "{tail_text}"); + let repeated_tail = frame_text(viewer.render(size, None).unwrap()); + assert!(repeated_tail.contains("record-129"), "{repeated_tail}"); + assert!(!viewer.preload().unwrap()); + assert_eq!(handle.older_calls(), 1); + assert_eq!(handle.refresh_calls(), 0); + + viewer.handle_event(key(KeyCode::PageUp), FileViewer::page_for_size(size)); + assert!(viewer.preload().unwrap()); + assert_eq!(handle.older_calls(), 2); + + viewer.handle_event(key(KeyCode::Char('G')), FileViewer::page_for_size(size)); + let returned_tail = frame_text(viewer.render(size, None).unwrap()); + assert!(returned_tail.contains("record-129"), "{returned_tail}"); + assert!(!viewer.preload().unwrap()); + assert_eq!(handle.older_calls(), 2); +} + +#[test] +fn snapshot_backward_intent_loads_older_when_the_tail_batch_already_fits() { + let (handle, timeline) = fake_timeline((0..10).map(record)); + let file = RecordTimelineViewFile::snapshot_with_initial_limit( + Box::new(timeline), + JSONL, + RecordLoadLimit::new(1, 4096), + ) + .unwrap(); + let mut viewer = FileViewer::new(Box::new(file), FormatKind::Jsonl, None); + let size = Size::new(60, 12); + + let tail = frame_text(viewer.render(size, None).unwrap()); + assert!(tail.contains("record-9"), "{tail}"); + assert!(!viewer.preload().unwrap()); + assert_eq!(handle.older_calls(), 1); + + let action = viewer.handle_event(key(KeyCode::PageUp), FileViewer::page_for_size(size)); + assert!(!action.dirty); + assert!(viewer.preload().unwrap()); + assert_eq!(handle.older_calls(), 2); +} + +#[test] +fn snapshot_search_and_structure_load_older_without_refreshing() { + let (search_handle, search_timeline) = fake_timeline((0..80).map(record)); + let search_file = RecordTimelineViewFile::snapshot_with_initial_limit( + Box::new(search_timeline), + JSONL, + RecordLoadLimit::new(1, 4096), + ) + .unwrap(); + let mut search_viewer = FileViewer::new(Box::new(search_file), FormatKind::Jsonl, None); + let size = Size::new(60, 8); + + enter_search(&mut search_viewer, size, "record-0"); + advance_until_idle(&mut search_viewer); + let search_frame = frame_text(search_viewer.render(size, None).unwrap()); + assert!(search_frame.contains("record-0"), "{search_frame}"); + assert_eq!(search_handle.refresh_calls(), 0); + + let (structure_handle, structure_timeline) = fake_timeline((0..80).map(record)); + let structure_file = RecordTimelineViewFile::snapshot_with_initial_limit( + Box::new(structure_timeline), + JSONL, + RecordLoadLimit::new(1, 4096), + ) + .unwrap(); + let mut structure_viewer = FileViewer::new(Box::new(structure_file), FormatKind::Jsonl, None); + structure_viewer.render(size, None).unwrap(); + structure_viewer.handle_event(key(KeyCode::Char('[')), FileViewer::page_for_size(size)); + advance_until_idle(&mut structure_viewer); + let structure_frame = frame_text(structure_viewer.render(size, None).unwrap()); + assert!(structure_frame.contains("record-78"), "{structure_frame}"); + assert!(structure_handle.older_calls() > 1); + assert_eq!(structure_handle.refresh_calls(), 0); +} + +#[test] +fn follow_structure_navigation_can_cross_the_lazy_older_boundary() { + let (handle, timeline) = fake_timeline((0..80).map(record)); + let file = RecordTimelineViewFile::with_initial_limit( + Box::new(timeline), + JSONL, + RecordLoadLimit::new(1, 4096), + ) + .unwrap(); + let mut viewer = FileViewer::new(Box::new(file), FormatKind::Jsonl, None); + let size = Size::new(60, 8); + + for _ in 0..2 { + viewer.handle_event(key(KeyCode::Char('[')), FileViewer::page_for_size(size)); + advance_until_idle(&mut viewer); + } + let frame = viewer.render(size, None).unwrap(); + let footer = frame.footer_text.clone(); + let text = frame_text(frame); + assert!(text.contains("record-78"), "{text}"); + assert!(footer.contains("follow:"), "{footer}"); + assert!(handle.older_calls() > 1); +} + +#[test] +fn exhausted_snapshot_reports_no_previous_structure_without_rearming() { + let (handle, timeline) = fake_timeline([record(0)]); + let file = RecordTimelineViewFile::snapshot_with_initial_limit( + Box::new(timeline), + JSONL, + RecordLoadLimit::new(1, 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::Char('[')), FileViewer::page_for_size(size)); + assert!(!viewer.needs_immediate_advance()); + assert!(!viewer.advance(Instant::now()).unwrap()); + let frame = viewer.render(size, None).unwrap(); + assert!(frame.footer_text.contains("no previous structure")); + assert_eq!(handle.older_calls(), 1); + assert_eq!(handle.refresh_calls(), 0); +} + +#[test] +fn live_timeline_constructor_still_refreshes_and_exposes_follow_controls() { + let (handle, timeline) = fake_timeline([record(0)]); + let file = RecordTimelineViewFile::with_initial_limit( + Box::new(timeline), + JSONL, + RecordLoadLimit::new(2, 4096), + ) + .unwrap(); + let mut viewer = FileViewer::new(Box::new(file), FormatKind::Jsonl, None); + let size = Size::new(60, 8); + + assert!( + viewer + .render(size, None) + .unwrap() + .footer_text + .contains("follow:on") + ); + handle.append(record(1)); + assert!(viewer.preload().unwrap()); + assert_eq!(handle.refresh_calls(), 1); + assert!(frame_text(viewer.render(size, None).unwrap()).contains("record-1")); + + viewer.handle_event(key(KeyCode::Char('f')), FileViewer::page_for_size(size)); + assert!( + viewer + .render(size, None) + .unwrap() + .footer_text + .contains("follow:off") + ); +} + #[test] fn raw_record_mapping_survives_older_prepend() { let (_handle, timeline) = fake_timeline((0..6).map(record)); @@ -440,15 +657,17 @@ fn prepending_older_records_preserves_the_viewport_anchor() { .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::PageUp), FileViewer::page_for_size(size)); let before = viewer.render(size, None).unwrap(); let before_top = before.position.top; let before_text = frame_text(before); - assert!(before_text.contains("record-99"), "{before_text:?}"); + assert!(before_text.contains("record-98"), "{before_text:?}"); assert!(viewer.preload().unwrap()); let after = viewer.render(size, None).unwrap(); assert!(after.position.top > before_top); - assert!(frame_text(after).contains("record-99")); + assert!(frame_text(after).contains("record-98")); } #[test] @@ -1336,6 +1555,14 @@ impl FakeHandle { fn fail_next_prefix_probe(&self) { self.state.borrow_mut().probe_failures += 1; } + + fn refresh_calls(&self) -> usize { + self.state.borrow().refresh_calls + } + + fn older_calls(&self) -> usize { + self.state.borrow().older_calls + } } struct FakeState { @@ -1343,6 +1570,8 @@ struct FakeState { generation: u64, terminal: bool, probe_failures: usize, + refresh_calls: usize, + older_calls: usize, } struct FakeTimeline { @@ -1358,6 +1587,8 @@ fn fake_timeline(records: impl IntoIterator>) -> (FakeHandle, Fak generation: 1, terminal: false, probe_failures: 0, + refresh_calls: 0, + older_calls: 0, })); let len = state.borrow().records.len(); ( @@ -1430,6 +1661,7 @@ impl RecordTimeline for FakeTimeline { } fn load_older(&mut self, limit: RecordLoadLimit) -> anyhow::Result { + self.state.borrow_mut().older_calls += 1; if self.older_cursor == 0 { return Ok(TimelineRead::End); } @@ -1497,6 +1729,7 @@ impl RecordTimeline for FakeTimeline { } fn refresh(&mut self) -> anyhow::Result { + self.state.borrow_mut().refresh_calls += 1; let state = self.state.borrow(); if state.generation != self.generation { self.generation = state.generation; diff --git a/docs/INDEX.md b/docs/INDEX.md index b16c43c..ffb3df1 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -33,8 +33,11 @@ Read these when the task matches: - `crates/fmtview-core/Cargo.toml` is the publishable headless engine package. It is versioned with the root package and has no crossterm dependency. - `src/bin/fmtview.rs` is the thin binary entry point. -- `src/lib.rs` exposes only application-layer modules used by the binary and - black-box tests. +- `src/lib.rs` exposes the CLI entry point plus the narrow `fmtview::view` + embedding facade. +- `src/view.rs` re-exports the backend-neutral record timeline contract and + turns a caller-provided timeline plus snapshot/follow options into the + private terminal runner. - `crates/fmtview-core/src/lib.rs` is the library boundary for profiles, loading, viewer/diff engines, backend-neutral input, and render frames. diff --git a/docs/architecture.md b/docs/architecture.md index 61023b6..31f4c9a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,6 +34,7 @@ The workspace has a one-way application-to-engine dependency: ```text fmtview application package clap CLI, stdin/file/literal assembly, redirected stdout + public fmtview::view record-timeline embedding facade crossterm event adapter and terminal lifecycle raw mode, alternate screen, mouse capture, polling, frame commit, cleanup | @@ -58,6 +59,14 @@ This is a product boundary, not only a package boundary. Core tests render file and diff frames, drive search and navigation, and paint frames into an in-memory ratatui buffer without raw mode, a PTY, or a terminal backend. +The application package exposes one narrow embedding entry point: +`fmtview::view::run(Box, ViewOptions)`. The facade +re-exports the complete `RecordTimeline` vocabulary and a result alias, fixes +the display profile to JSONL, constructs a `RecordTimelineViewFile`, and then +hands it to the same private crossterm runner used by the CLI. No crossterm or +ratatui backend type appears in the public signature. Snapshot mode is the +default; callers opt into live refresh and follow controls explicitly. + `TypeProfile` selects the format package and shared runtime behavior for the current input. It answers four questions: @@ -358,6 +367,15 @@ checkpoint-committed producer can implement the same trait by mapping its own opaque stable ordering into epoch/offset identities; it needs no file methods, poll cadence, checkpoint storage rule, or terminal backend type. +Tail-first loading and live following are separate capabilities. A snapshot +timeline still starts at the committed tail and loads older records in bounded +batches near the top or while backward search/structure navigation advances, +but it never calls `refresh` and does not expose follow footer state or `f` +controls. A follow timeline uses the same older-loading path plus periodic +refresh, newer batches, reset reconciliation, and attached/detached/paused +state. Existing `RecordTimelineViewFile::new` callers retain follow behavior; +`RecordTimelineViewFile::snapshot` selects the non-refreshing mode. + Record-backed `ViewFile` implementations can expose an exact raw-record seam. The seam retains source or raw-spool offsets and `u64` lengths, then presents a snapshot through nominal 32 KiB virtual-line chunks with up to a three-byte @@ -391,8 +409,9 @@ checkpoint source can use an independent decoder cursor. The root package only decides when to call core preload/refresh work, maps `f` and other crossterm events into `InputEvent`/`ViewerCommand`, and commits the -returned frame. Poll cadence, raw mode, terminal cleanup, and TTY detection do -not enter `fmtview-core`. +returned frame. `fmtview::view` additionally selects snapshot versus follow for +an embedded timeline before entering that private runner. Poll cadence, raw +mode, terminal cleanup, and TTY detection do not enter `fmtview-core`. ## Load Plans diff --git a/examples/embed-timeline.rs b/examples/embed-timeline.rs new file mode 100644 index 0000000..aeb7c73 --- /dev/null +++ b/examples/embed-timeline.rs @@ -0,0 +1,141 @@ +use fmtview::view::{ + self, RecordId, RecordLoadLimit, RecordTimeline, Result, TimelineRead, TimelineReadNext, + TimelineRecord, TimelineRefresh, TimelineSnapshot, ViewOptions, +}; + +struct DemoTimeline { + records: Vec, + older_cursor: usize, + newer_cursor: usize, + end_offset: u64, +} + +impl DemoTimeline { + fn new(records: impl IntoIterator>) -> Self { + let mut offset = 0_u64; + let records = records + .into_iter() + .map(|raw| { + let start_offset = offset; + offset += raw.len() as u64; + TimelineRecord { + id: RecordId { + epoch: 1, + start_offset, + end_offset: offset, + }, + raw, + } + }) + .collect::>(); + let cursor = records.len(); + Self { + records, + older_cursor: cursor, + newer_cursor: cursor, + end_offset: offset, + } + } + + fn snapshot_value(&self) -> TimelineSnapshot { + TimelineSnapshot { + epoch: 1, + committed_end: self.end_offset, + observed_end: self.end_offset, + pending_bytes: 0, + } + } + + fn forward_batch(&self, start: usize, limit: RecordLoadLimit) -> Vec { + let mut bytes = 0_usize; + self.records[start..] + .iter() + .take(limit.max_records.max(1)) + .take_while(|record| { + if bytes > 0 && bytes.saturating_add(record.raw.len()) > limit.max_bytes.max(1) { + return false; + } + bytes = bytes.saturating_add(record.raw.len()); + true + }) + .cloned() + .collect() + } +} + +impl RecordTimeline for DemoTimeline { + fn label(&self) -> &str { + "embedded conversation" + } + + fn snapshot(&self) -> TimelineSnapshot { + self.snapshot_value() + } + + fn probe_prefix(&mut self, limit: RecordLoadLimit) -> Result { + let records = self.forward_batch(0, limit); + if records.is_empty() { + return Ok(TimelineRead::End); + } + let next = if records.len() == self.records.len() { + TimelineReadNext::End + } else { + TimelineReadNext::More + }; + Ok(TimelineRead::Records { records, next }) + } + + fn load_older(&mut self, limit: RecordLoadLimit) -> Result { + if self.older_cursor == 0 { + return Ok(TimelineRead::End); + } + + let mut start = self.older_cursor; + let mut bytes = 0_usize; + let mut count = 0_usize; + while start > 0 && count < limit.max_records.max(1) { + let next = &self.records[start - 1]; + if count > 0 && bytes.saturating_add(next.raw.len()) > limit.max_bytes.max(1) { + break; + } + start -= 1; + count += 1; + bytes = bytes.saturating_add(next.raw.len()); + } + let records = self.records[start..self.older_cursor].to_vec(); + self.older_cursor = start; + let next = if start == 0 { + TimelineReadNext::End + } else { + TimelineReadNext::More + }; + Ok(TimelineRead::Records { records, next }) + } + + fn load_newer(&mut self, limit: RecordLoadLimit) -> Result { + if self.newer_cursor == self.records.len() { + return Ok(TimelineRead::End); + } + let records = self.forward_batch(self.newer_cursor, limit); + self.newer_cursor += records.len(); + let next = if self.newer_cursor == self.records.len() { + TimelineReadNext::End + } else { + TimelineReadNext::More + }; + Ok(TimelineRead::Records { records, next }) + } + + fn refresh(&mut self) -> Result { + Ok(TimelineRefresh::End(self.snapshot_value())) + } +} + +fn main() -> Result<()> { + let timeline = DemoTimeline::new([ + b"{\"ref\":\"m1\",\"role\":\"user\",\"content\":\"run the tests\"}\n".to_vec(), + b"{\"ref\":\"m2\",\"role\":\"assistant\",\"content\":[{\"type\":\"tool_call\",\"id\":\"call_1\",\"name\":\"bash\",\"arguments\":\"{\\\"cmd\\\":\\\"cargo test\\\"}\"}]}\n".to_vec(), + b"{\"ref\":\"m3\",\"role\":\"tool\",\"content\":[{\"type\":\"tool_result\",\"call_id\":\"call_1\",\"content\":\"ok\"}]}\n".to_vec(), + ]); + view::run(Box::new(timeline), ViewOptions::default()) +} diff --git a/src/lib.rs b/src/lib.rs index 0eed415..0ef8480 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod cli; +pub mod view; mod input; mod shell_alias; diff --git a/src/view.rs b/src/view.rs new file mode 100644 index 0000000..4cc1fba --- /dev/null +++ b/src/view.rs @@ -0,0 +1,206 @@ +//! Embed fmtview's interactive JSONL timeline viewer in another application. +//! +//! The facade keeps crossterm events and terminal lifecycle private while +//! exposing the backend-neutral record source contract used by the viewer. +//! A source starts with both directional cursors at its committed tail. +//! +//! ```no_run +//! use fmtview::view::{ +//! self, RecordLoadLimit, RecordTimeline, Result, TimelineRead, TimelineRefresh, +//! TimelineSnapshot, ViewOptions, +//! }; +//! +//! struct EmptyTimeline; +//! +//! impl RecordTimeline for EmptyTimeline { +//! fn label(&self) -> &str { "embedded run" } +//! fn snapshot(&self) -> TimelineSnapshot { +//! TimelineSnapshot { +//! epoch: 1, +//! committed_end: 0, +//! observed_end: 0, +//! pending_bytes: 0, +//! } +//! } +//! fn probe_prefix(&mut self, _: RecordLoadLimit) -> Result { +//! Ok(TimelineRead::End) +//! } +//! fn load_older(&mut self, _: RecordLoadLimit) -> Result { +//! Ok(TimelineRead::End) +//! } +//! fn load_newer(&mut self, _: RecordLoadLimit) -> Result { +//! Ok(TimelineRead::End) +//! } +//! fn refresh(&mut self) -> Result { +//! Ok(TimelineRefresh::End(self.snapshot())) +//! } +//! } +//! +//! fn main() -> Result<()> { +//! let mut options = ViewOptions::default(); +//! options.follow = true; +//! view::run(Box::new(EmptyTimeline), options) +//! } +//! ``` + +use std::io::{self, IsTerminal}; + +use anyhow::bail; +use fmtview_core::{FormatKind, FormatOptions, RecordTimelineViewFile, ViewFile}; + +pub use fmtview_core::{ + RecordId, RecordLoadLimit, RecordTimeline, TimelineRead, TimelineReadNext, TimelineRecord, + TimelineRefresh, TimelineResetReason, TimelineSnapshot, +}; + +/// Result type used by the embedding facade and [`RecordTimeline`] methods. +/// +/// Re-exporting the error type through this alias lets a downstream source +/// implementation depend on `fmtview` without also naming `anyhow` directly. +pub type Result = anyhow::Result; + +/// Options for an embedded JSONL record-timeline viewer. +/// +/// Snapshot mode is the default. It opens at the current committed tail and +/// lazily loads older records without refreshing newer records or exposing +/// follow controls. Set [`Self::follow`] to continuously refresh the source. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ViewOptions { + /// Number of spaces used when formatting each JSONL record. + pub indent: usize, + /// Refresh newer records and enable attached/detached/paused follow state. + pub follow: bool, +} + +impl Default for ViewOptions { + fn default() -> Self { + Self { + indent: 2, + follow: false, + } + } +} + +/// Run the interactive terminal viewer for a backend-neutral record timeline. +/// +/// Each yielded record is interpreted as one exact JSONL source record. The +/// source decides which records are committed; fmtview owns bounded tail-first +/// loading, formatting and raw spools, search/navigation, follow state, the +/// crossterm event loop, and terminal cleanup. +pub fn run(source: Box, options: ViewOptions) -> Result<()> { + validate_options(&options)?; + require_interactive_stdout(io::stdout().is_terminal())?; + + let file = open_timeline(source, &options)?; + crate::viewer::run(file, FormatKind::Jsonl, None) +} + +fn require_interactive_stdout(is_terminal: bool) -> Result<()> { + if !is_terminal { + bail!("embedded fmtview requires an interactive terminal on stdout"); + } + Ok(()) +} + +fn validate_options(options: &ViewOptions) -> Result<()> { + if options.indent == 0 || options.indent > 16 { + bail!("view indent must be between 1 and 16"); + } + Ok(()) +} + +fn open_timeline( + source: Box, + options: &ViewOptions, +) -> Result> { + let format = FormatOptions { + kind: FormatKind::Jsonl, + indent: options.indent, + }; + let file = if options.follow { + RecordTimelineViewFile::new(source, format)? + } else { + RecordTimelineViewFile::snapshot(source, format)? + }; + Ok(Box::new(file)) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct EmptyTimeline; + + impl RecordTimeline for EmptyTimeline { + fn label(&self) -> &str { + "empty" + } + + fn snapshot(&self) -> TimelineSnapshot { + TimelineSnapshot { + epoch: 1, + committed_end: 0, + observed_end: 0, + pending_bytes: 0, + } + } + + fn probe_prefix(&mut self, _: RecordLoadLimit) -> Result { + Ok(TimelineRead::End) + } + + fn load_older(&mut self, _: RecordLoadLimit) -> Result { + Ok(TimelineRead::End) + } + + fn load_newer(&mut self, _: RecordLoadLimit) -> Result { + Ok(TimelineRead::End) + } + + fn refresh(&mut self) -> Result { + Ok(TimelineRefresh::End(self.snapshot())) + } + } + + #[test] + fn defaults_to_a_non_refreshing_snapshot() { + let options = ViewOptions::default(); + let file = open_timeline(Box::new(EmptyTimeline), &options).unwrap(); + + assert_eq!(options.indent, 2); + assert!(!options.follow); + assert!(!file.is_follow_source()); + } + + #[test] + fn follow_option_selects_live_refresh_behavior() { + let options = ViewOptions { + follow: true, + ..ViewOptions::default() + }; + let file = open_timeline(Box::new(EmptyTimeline), &options).unwrap(); + + assert!(file.is_follow_source()); + } + + #[test] + fn rejects_invalid_jsonl_indent_before_entering_the_terminal() { + let options = ViewOptions { + indent: 0, + ..ViewOptions::default() + }; + + assert!(validate_options(&options).is_err()); + } + + #[test] + fn rejects_redirected_stdout_before_entering_the_terminal() { + let error = require_interactive_stdout(false).unwrap_err(); + + assert_eq!( + error.to_string(), + "embedded fmtview requires an interactive terminal on stdout" + ); + } +} diff --git a/tests/embedding_api.rs b/tests/embedding_api.rs new file mode 100644 index 0000000..0c44484 --- /dev/null +++ b/tests/embedding_api.rs @@ -0,0 +1,80 @@ +use std::{fs, process::Command}; + +#[test] +fn downstream_consumer_needs_only_the_fmtview_dependency() { + let project = tempfile::tempdir().unwrap(); + fs::create_dir(project.path().join("src")).unwrap(); + let fmtview_path = env!("CARGO_MANIFEST_DIR").replace('\\', "\\\\"); + fs::write( + project.path().join("Cargo.toml"), + format!( + r#"[package] +name = "fmtview-downstream-check" +version = "0.0.0" +edition = "2024" + +[workspace] + +[dependencies] +fmtview = {{ path = "{fmtview_path}" }} +"#, + ), + ) + .unwrap(); + fs::write( + project.path().join("src/main.rs"), + r#"use fmtview::view::{ + self, RecordLoadLimit, RecordTimeline, Result, TimelineRead, TimelineRefresh, + TimelineSnapshot, ViewOptions, +}; + +struct Source; + +impl RecordTimeline for Source { + fn label(&self) -> &str { "downstream" } + fn snapshot(&self) -> TimelineSnapshot { + TimelineSnapshot { epoch: 1, committed_end: 0, observed_end: 0, pending_bytes: 0 } + } + fn probe_prefix(&mut self, _: RecordLoadLimit) -> Result { + Ok(TimelineRead::End) + } + fn load_older(&mut self, _: RecordLoadLimit) -> Result { + Ok(TimelineRead::End) + } + fn load_newer(&mut self, _: RecordLoadLimit) -> Result { + Ok(TimelineRead::End) + } + fn refresh(&mut self) -> Result { + Ok(TimelineRefresh::End(self.snapshot())) + } +} + +fn main() { + let source: Box = Box::new(Source); + let options = ViewOptions::default(); + let _call: fn(Box, ViewOptions) -> Result<()> = view::run; + drop((source, options)); +} +"#, + ) + .unwrap(); + fs::copy( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.lock"), + project.path().join("Cargo.lock"), + ) + .unwrap(); + + let output = Command::new(env!("CARGO")) + .args(["check", "--offline", "--quiet"]) + .current_dir(project.path()) + .env("CARGO_TARGET_DIR", project.path().join("target")) + .output() + .unwrap(); + + assert!( + output.status.success(), + "downstream cargo check failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +}