From d564d33da89dc57434629d39d3a2c9268b6b822b Mon Sep 17 00:00:00 2001 From: Blacknon Date: Tue, 19 May 2026 23:49:59 +0900 Subject: [PATCH 01/11] update. --- CHANGELOG.md | 17 + Cargo.lock | 3 +- Cargo.toml | 3 +- README.md | 15 +- src/app/config.rs | 2 +- src/app/history_ops.rs | 70 ++- src/app/mod.rs | 14 + src/app/runtime.rs | 55 +- src/app/state.rs | 57 +- src/app/tests.rs | 81 +++ src/app/view.rs | 12 +- src/batch/tests.rs | 6 + src/cli.rs | 58 +- src/history.rs | 12 +- src/logging.rs | 1140 +++++++++++++++++++++++++++++++++++++++- src/main.rs | 154 +++++- src/runner/mod.rs | 24 +- src/runner/pipe.rs | 229 ++++++++ src/runner/pty.rs | 4 +- src/screen.rs | 297 ++++++++++- src/screenshot.rs | 12 +- src/ui/watch.rs | 16 +- 22 files changed, 2203 insertions(+), 78 deletions(-) create mode 100644 src/runner/pipe.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8a1c0..ec50df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 0.1.5 + +- Add `--record-stdin` with fixed-size terminal parsing so `twatch` can act as a tmux `pipe-pane` recording backend +- Add `--size WIDTH,HEIGHT` for stdin recording and document the tmux-oriented JSONL recording flow +- Store `--record-stdin` traces as periodic checkpoints plus deltas instead of writing a full snapshot on every frame +- Compact in-memory cell symbols, compress delta history when `-C` is enabled, and raise the default checkpoint interval to reduce long-running memory pressure +- Add transparent `.jsonl.gz` log read/write support and make the tmux plugin default to `archive` compression so active pane replay stays faster +- Compress large snapshot and delta payloads inside JSONL records so plain `.jsonl` traces also shrink without hiding frame metadata +- Open replay after preloading the first 256 frames and continue loading the rest in the background to reduce large-log startup delay +- Spill older active `--record-stdin` frames into a compact sidecar during long-running sessions so live `.jsonl` logs grow more slowly +- Add tunable `--record-stdin-spill-every` and `--record-stdin-spill-retain` controls, and store deltas in a more compact run/style-table form to shrink active tmux logs further +- Treat `-L 0` as unlimited history retention so tmux replay can avoid trimming large recordings on startup +- Write active JSONL records in a more compact array-based format while keeping backward-compatible readers for older object-based logs + +This release adds the first backend-oriented building block for tmux integration. +It lets `twatch` record terminal output from stdin into replayable JSONL traces without spawning a child PTY. + ## 0.1.4 - Add customizable `twatch` keymaps with `-K/--keymap KEY=ACTION`, including pane-specific navigation, snapshot actions, pause control, and app-input mode switching diff --git a/Cargo.lock b/Cargo.lock index 5ff0ef6..0ca3a97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1660,9 +1660,10 @@ dependencies = [ [[package]] name = "twatch" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", + "base64", "clap", "crossterm 0.28.1", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 4745ebe..d35058f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "twatch" -version = "0.1.4" +version = "0.1.5" edition = "2024" description = "twatch - record, rewind, and diff terminal UI screens." license = "MIT" @@ -12,6 +12,7 @@ homepage = "https://github.com/blacknon/twatch" [dependencies] anyhow = "1.0" +base64 = "0.22" clap = { version = "4.5", features = ["derive"] } crossterm = "0.28" flate2 = "1.1" diff --git a/README.md b/README.md index 9095cb2..042115d 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,10 @@ Options: --replay Replay a saved JSONL trace in read-only mode + --record-stdin + Record terminal output from stdin without spawning a child PTY + --size + Use a fixed terminal size for stdin recording --screenshot-dir [default: /tmp] --screenshot-format @@ -116,7 +120,7 @@ Options: -L, --limit [default: 500] --checkpoint-interval - [default: 12] + [default: 120] --debug Show debug diagnostics in the interactive UI -h, --help @@ -313,6 +317,15 @@ This is useful for debugging long-running screens outside the live UI. twatch --logfile ./twatch.jsonl htop ``` +### Stdin recorder + +Record terminal output from stdin into a JSONL trace without launching a child PTY. +This is the backend intended for tmux `pipe-pane` style integrations. + +```bash +tmux pipe-pane -o "twatch --record-stdin --size '#{pane_width},#{pane_height}' --logfile '$HOME/.local/state/twatch/tmux/pane-12.jsonl'" +``` + ### Replay mode Open a saved JSONL trace without launching a child PTY. diff --git a/src/app/config.rs b/src/app/config.rs index 8f2a5e3..e5d0617 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -59,7 +59,7 @@ impl AppConfig { Ok(Self { child_pause_supported, diff_mode: cli.differences.into(), - history_limit: cli.limit.max(1), + history_limit: cli.limit, checkpoint_interval: cli.checkpoint_interval.max(1), compress: cli.compress, logfile: cli.replay.clone().or(cli.logfile.clone()), diff --git a/src/app/history_ops.rs b/src/app/history_ops.rs index 65d8361..33cf13c 100644 --- a/src/app/history_ops.rs +++ b/src/app/history_ops.rs @@ -3,10 +3,11 @@ // that can be found in the LICENSE file. use anyhow::Result; +use std::path::Path; use super::{App, FocusPane}; use crate::history::{HistoryMetadata, HistoryStore}; -use crate::logging::{LogRecord, append_record, load_records}; +use crate::logging::{LogRecord, LogRecordStream, append_delta_record, load_records}; impl App { pub(super) fn delete_selected_history(&mut self) -> Result<()> { @@ -68,6 +69,9 @@ impl App { } pub(super) fn trim_history(&mut self) -> Result<()> { + if self.limit == 0 { + return Ok(()); + } let total = self.history.len(); if total <= self.limit { return Ok(()); @@ -107,7 +111,47 @@ impl App { return Ok(()); }; - for record in load_records(path)? { + self.apply_loaded_records(load_records(path)?)?; + + Ok(()) + } + + pub(super) fn load_history_from_replay_log_prefetch(&mut self) -> Result<()> { + let Some(path) = &self.logfile else { + return Ok(()); + }; + if !Path::new(path).exists() { + return Ok(()); + } + + let mut stream = LogRecordStream::open(path)?; + let mut loaded = Vec::new(); + + while loaded.len() < super::state::REPLAY_INITIAL_LOAD_FRAMES { + let Some(record) = stream.next_record()? else { + break; + }; + loaded.push(record); + } + + self.apply_loaded_records(loaded)?; + + if stream.has_more()? { + self.replay_loader = Some(stream); + self.replay_loading = true; + self.ui.status_message = Some(format!( + "replay loading: {} frames ready, more loading in background", + self.loaded_frame_count() + )); + } + + Ok(()) + } + + pub(super) fn apply_loaded_records(&mut self, records: Vec) -> Result<()> { + let was_follow_latest = self.follow_latest; + + for record in records { let (snapshot, metadata, changed) = record.into_parts(); self.archive_current_snapshot()?; let metadata = self.complete_metadata( @@ -120,19 +164,25 @@ impl App { self.set_current_snapshot(snapshot, metadata); } - if self.metadata.len() > self.limit { + if self.limit > 0 && self.metadata.len() > self.limit { self.trim_history()?; } if self.current_snapshot.is_some() { - self.selected_index = self.history.len().saturating_sub(1); - self.follow_latest = true; + if was_follow_latest { + self.selected_index = self.history.len().saturating_sub(1); + self.follow_latest = true; + } self.rebuild_filter()?; } Ok(()) } + pub(super) fn loaded_frame_count(&self) -> usize { + self.metadata.len() + usize::from(self.current_snapshot.is_some()) + } + pub(super) fn append_log_record(&self) -> Result<()> { if self.replay_mode { return Ok(()); @@ -147,7 +197,13 @@ impl App { return Ok(()); }; - append_record( + let previous_snapshot = if self.history.is_empty() { + None + } else { + self.history.snapshot(self.history.len() - 1)? + }; + + append_delta_record( path, &LogRecord { label: metadata.label.clone(), @@ -166,6 +222,8 @@ impl App { resize_source: metadata.resize_source.clone(), snapshot: snapshot.clone(), }, + previous_snapshot.as_ref(), + self.checkpoint_interval as u64, ) } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 834af31..b6af243 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -5,6 +5,7 @@ use std::cell::RefCell; use std::collections::VecDeque; use std::path::PathBuf; +use std::sync::mpsc; use std::time::{Duration, Instant}; use crate::aftercommand::AfterCommandRuntime; @@ -13,6 +14,7 @@ use crate::cli::{DiffModeArg, ScreenshotFormatArg}; use crate::history::HistoryMetadata; use crate::history::HistoryStore; use crate::keymap::KeyBinding; +use crate::logging::{LogRecord, LogRecordStream}; use crate::runner::FrameSource; use crate::screen::ScreenSnapshot; use crate::screenshot::ScreenshotFormat; @@ -35,6 +37,15 @@ enum AppEvent { Terminal(crossterm::event::Event), SourceUpdated, SourceClosed(Option), + ReplayRecordsLoaded(Vec), + ReplayLoadFinished, + ReplayLoadFailed(String), +} + +enum ReplayLoadMessage { + Records(Vec), + Finished, + Failed(String), } enum LoopControl { @@ -318,6 +329,9 @@ pub struct App { keymap: Vec, child_bindings: Vec, source: Box, + replay_loader: Option, + replay_loader_rx: Option>, + replay_loading: bool, last_mouse_input: Option, last_mouse_scroll_input: Option, pending_mouse_escape: Option, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index f16b04d..d9b70a4 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -11,7 +11,7 @@ use anyhow::Result; use crossterm::event::{self, Event}; use ratatui::DefaultTerminal; -use super::{App, AppEvent, LoopControl}; +use super::{App, AppEvent, LoopControl, ReplayLoadMessage}; use crate::runner::SourceEvent; use crate::ui; @@ -29,6 +29,25 @@ impl App { } }); + self.start_replay_loader(); + if let Some(replay_rx) = self.replay_loader_rx.take() { + let replay_tx = tx.clone(); + std::thread::spawn(move || { + while let Ok(event) = replay_rx.recv() { + let app_event = match event { + ReplayLoadMessage::Records(records) => { + AppEvent::ReplayRecordsLoaded(records) + } + ReplayLoadMessage::Finished => AppEvent::ReplayLoadFinished, + ReplayLoadMessage::Failed(err) => AppEvent::ReplayLoadFailed(err), + }; + if replay_tx.send(app_event).is_err() { + break; + } + } + }); + } + if let Some(update_rx) = self.source.take_update_receiver() { let update_tx = tx.clone(); let pending_source_update = Arc::new(AtomicBool::new(false)); @@ -139,6 +158,23 @@ impl App { } break; } + Ok(AppEvent::ReplayRecordsLoaded(records)) => { + self.apply_loaded_records(records)?; + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFinished) => { + self.replay_loading = false; + self.ui.status_message = Some(format!( + "replay loaded: {} frames", + self.loaded_frame_count() + )); + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFailed(err)) => { + self.replay_loading = false; + self.ui.status_message = Some(format!("replay load failed: {err}")); + needs_redraw = true; + } Err(RecvTimeoutError::Timeout) => { if !self.paused && self.source.has_pending_update() { if let Some(flag) = &pending_source_update { @@ -159,6 +195,23 @@ impl App { LoopControl::Break => break, }, Ok(AppEvent::SourceUpdated) | Ok(AppEvent::SourceClosed(_)) => {} + Ok(AppEvent::ReplayRecordsLoaded(records)) => { + self.apply_loaded_records(records)?; + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFinished) => { + self.replay_loading = false; + self.ui.status_message = Some(format!( + "replay loaded: {} frames", + self.loaded_frame_count() + )); + needs_redraw = true; + } + Ok(AppEvent::ReplayLoadFailed(err)) => { + self.replay_loading = false; + self.ui.status_message = Some(format!("replay load failed: {err}")); + needs_redraw = true; + } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } diff --git a/src/app/state.rs b/src/app/state.rs index d2ca8e9..b88fdd1 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -3,13 +3,16 @@ // that can be found in the LICENSE file. use anyhow::Result; +use std::sync::mpsc; use super::config::AppConfig; -use super::{App, AppHistoryMetadata, FilterMode, InputMode}; +use super::{App, AppHistoryMetadata, FilterMode, InputMode, ReplayLoadMessage}; use crate::cli::Cli; use crate::history::HistoryStore; use crate::runner::FrameSource; +pub(super) const REPLAY_INITIAL_LOAD_FRAMES: usize = 256; + impl App { pub fn new(cli: &Cli, source: Box) -> Result { let config = AppConfig::from_cli(cli, source.supports_child_pause())?; @@ -46,6 +49,9 @@ impl App { keymap: config.keymap, child_bindings: config.child_bindings, source, + replay_loader: None, + replay_loader_rx: None, + replay_loading: false, last_mouse_input: None, last_mouse_scroll_input: None, pending_mouse_escape: None, @@ -57,10 +63,51 @@ impl App { ui: super::UiState::new(), }; - app.load_history_from_log()?; + if config.replay_mode { + app.load_history_from_replay_log_prefetch()?; + } else { + app.load_history_from_log()?; + } Ok(app) } + pub(super) fn start_replay_loader(&mut self) { + let Some(mut loader) = self.replay_loader.take() else { + return; + }; + let (tx, rx) = mpsc::channel(); + self.replay_loader_rx = Some(rx); + self.replay_loading = true; + std::thread::spawn(move || { + const CHUNK_SIZE: usize = 256; + loop { + let mut chunk = Vec::with_capacity(CHUNK_SIZE); + for _ in 0..CHUNK_SIZE { + match loader.next_record() { + Ok(Some(record)) => chunk.push(record), + Ok(None) => { + if !chunk.is_empty() + && tx.send(ReplayLoadMessage::Records(chunk)).is_err() + { + return; + } + let _ = tx.send(ReplayLoadMessage::Finished); + return; + } + Err(err) => { + let _ = tx.send(ReplayLoadMessage::Failed(err.to_string())); + return; + } + } + } + + if tx.send(ReplayLoadMessage::Records(chunk)).is_err() { + return; + } + } + }); + } + pub(super) fn command_display_from_cli(cli: &Cli) -> String { if let Some(path) = &cli.replay { format!("replay: {path}") @@ -134,10 +181,16 @@ impl App { } pub(super) fn trim_slack(&self) -> usize { + if self.limit == 0 { + return 0; + } self.limit.min(self.checkpoint_interval.max(32)) } pub(super) fn trim_trigger_len(&self) -> usize { + if self.limit == 0 { + return usize::MAX; + } self.limit.saturating_add(self.trim_slack()) } diff --git a/src/app/tests.rs b/src/app/tests.rs index ae9af7c..7d1b3ed 100644 --- a/src/app/tests.rs +++ b/src/app/tests.rs @@ -614,6 +614,27 @@ fn history_overlay_row_selection_accounts_for_window_offset() { } } +#[test] +fn limit_zero_keeps_full_history_visible() { + let mut cli = test_cli(); + cli.limit = 0; + let mut app = App::new( + &cli, + Box::new(MockSource::new( + (0..80).map(|i| frame(&format!("{i:04}"), &["x"])).collect(), + )), + ) + .unwrap(); + + for _ in 0..80 { + app.capture(20, 5).unwrap(); + } + + assert_eq!(app.visible_history_start(), 0); + assert_eq!(app.visible_history_len(), app.history_len()); + assert!(app.history_len() >= 79); +} + #[test] fn mouse_passthrough_is_blocked_when_not_following_latest() { let source = MockSource::new(vec![frame("a", &["one"])]); @@ -1373,6 +1394,60 @@ fn replay_mode_loads_existing_log() { let _ = fs::remove_dir_all(dir); } +#[test] +fn replay_mode_prefetches_initial_frames_and_defers_rest() { + let mut cli = test_cli(); + let dir = unique_temp_dir("twatch-replay-prefetch"); + let path = dir.join("trace.jsonl"); + fs::create_dir_all(&dir).unwrap(); + cli.replay = Some(path.to_string_lossy().into_owned()); + + for index in 0..300u64 { + append_record( + cli.replay.as_deref().unwrap(), + &LogRecord { + label: format!("frame-{index}"), + changed: true, + timestamp_unix_ms: index + 1, + frame_seq: index + 1, + width: 20, + height: 5, + changed_cell_count: 1, + input_event_count_since_prev: 0, + resized: false, + resize_from_width: 0, + resize_from_height: 0, + resize_to_width: 0, + resize_to_height: 0, + resize_source: String::new(), + snapshot: ScreenSnapshot::from_text_lines(20, 5, &[&format!("line-{index}")]), + }, + ) + .unwrap(); + } + + let app = App::new( + &cli, + Box::new(MockSource::new(vec![frame("unused", &["x"])])), + ) + .unwrap(); + + assert_eq!( + app.metadata.len() + usize::from(app.current_snapshot.is_some()), + super::state::REPLAY_INITIAL_LOAD_FRAMES + ); + assert!(app.replay_loader.is_some()); + assert!(app.replay_loading); + assert_eq!(app.current_label(), Some("frame-255")); + assert_eq!( + app.ui.status_message.as_deref(), + Some("replay loading: 256 frames ready, more loading in background") + ); + + let _ = fs::remove_file(path); + let _ = fs::remove_dir_all(dir); +} + #[test] fn save_snapshot_uses_configured_directory_and_format() { let mut cli = test_cli(); @@ -1563,6 +1638,12 @@ fn test_cli() -> Cli { compress: false, logfile: None, replay: None, + pack_logfile: None, + pack_output: None, + record_stdin: false, + record_stdin_spill_every: 64, + record_stdin_spill_retain: 32, + size: None, screenshot_dir: "/tmp".to_string(), screenshot_format: ScreenshotFormatArg::Text, snapshot_on: None, diff --git a/src/app/view.rs b/src/app/view.rs index ad44907..11b0ea3 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -11,11 +11,19 @@ impl App { } pub fn visible_history_len(&self) -> usize { - self.history_len().min(self.limit) + if self.limit == 0 { + self.history_len() + } else { + self.history_len().min(self.limit) + } } pub(super) fn visible_history_start(&self) -> usize { - self.history_len().saturating_sub(self.limit) + if self.limit == 0 { + 0 + } else { + self.history_len().saturating_sub(self.limit) + } } pub fn selected_snapshot(&self) -> Option { diff --git a/src/batch/tests.rs b/src/batch/tests.rs index db20cbd..35a616c 100644 --- a/src/batch/tests.rs +++ b/src/batch/tests.rs @@ -82,6 +82,12 @@ fn test_cli() -> Cli { compress: false, logfile: None, replay: None, + pack_logfile: None, + pack_output: None, + record_stdin: false, + record_stdin_spill_every: 64, + record_stdin_spill_retain: 32, + size: None, screenshot_dir: "/tmp".to_string(), screenshot_format: ScreenshotFormatArg::Text, snapshot_on: None, diff --git a/src/cli.rs b/src/cli.rs index ea71e2e..b4689e5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -96,6 +96,43 @@ pub struct Cli { #[arg(long, help = "Replay a saved JSONL trace in read-only mode")] pub replay: Option, + #[arg( + long, + hide = true, + help = "Pack a saved log into compact archive format" + )] + pub pack_logfile: Option, + + #[arg(long, hide = true, help = "Write compact archive output to this path")] + pub pack_output: Option, + + #[arg( + long, + help = "Record terminal output from stdin without spawning a child PTY" + )] + pub record_stdin: bool, + + #[arg( + long, + default_value_t = 64, + help = "During stdin recording, spill older active log frames into a sidecar every N frames; use 0 to disable" + )] + pub record_stdin_spill_every: usize, + + #[arg( + long, + default_value_t = 32, + help = "During stdin recording, keep this many recent frames in the active JSONL before spilling older ones" + )] + pub record_stdin_spill_retain: usize, + + #[arg( + long, + value_name = "WIDTH,HEIGHT", + help = "Use a fixed terminal size for stdin recording" + )] + pub size: Option, + #[arg(long, default_value = "/tmp")] pub screenshot_dir: String, @@ -135,7 +172,7 @@ pub struct Cli { #[arg(short = 'L', long, default_value_t = 500)] pub limit: usize, - #[arg(long, default_value_t = 12)] + #[arg(long, default_value_t = 120)] pub checkpoint_interval: usize, #[arg(long, help = "Show debug diagnostics in the interactive UI")] @@ -263,8 +300,8 @@ impl fmt::Display for CropSpec { #[cfg(test)] mod tests { - use super::{CropSpec, DiffModeArg, SizeSpec, default_shell}; - use clap::ValueEnum; + use super::{Cli, CropSpec, DiffModeArg, SizeSpec, default_shell}; + use clap::{Parser, ValueEnum}; #[test] fn parses_size_spec() { @@ -307,4 +344,19 @@ mod tests { #[cfg(not(windows))] assert_eq!(default_shell(), "sh -c"); } + + #[test] + fn parses_record_stdin_size() { + let cli = Cli::parse_from(["twatch", "--record-stdin", "--size", "120,40"]); + assert!(cli.record_stdin); + assert_eq!( + cli.size, + Some(SizeSpec { + width: 120, + height: 40, + }) + ); + assert_eq!(cli.record_stdin_spill_every, 64); + assert_eq!(cli.record_stdin_spill_retain, 32); + } } diff --git a/src/history.rs b/src/history.rs index d27b5ef..132953a 100644 --- a/src/history.rs +++ b/src/history.rs @@ -268,8 +268,8 @@ impl FrameDelta { for y in 0..max_height { for x in 0..max_width { let idx = usize::from(y) * usize::from(after_width.max(1)) + usize::from(x); - let before_cell = before.cell(x, y).cloned().unwrap_or_default(); - let after_cell = after.cell(x, y).cloned().unwrap_or_default(); + let before_cell = before.cell(x, y).unwrap_or_default(); + let after_cell = after.cell(x, y).unwrap_or_default(); if before_cell != after_cell && x < after_width && y < after_height { changes.push((idx, after_cell)); @@ -297,10 +297,8 @@ fn store_checkpoint( store_payload(value, compress) } -fn store_delta(value: FrameDelta, _compress: bool) -> Result> { - // Deltas are already sparse, so compressing each one tends to waste CPU - // more than it saves memory. Keep them raw and only compress checkpoints. - store_payload(value, false) +fn store_delta(value: FrameDelta, compress: bool) -> Result> { + store_payload(value, compress) } fn store_payload(value: T, compress: bool) -> Result> @@ -406,7 +404,7 @@ mod tests { .unwrap(); assert_eq!(history.find_by_query("jobs").unwrap(), vec![0, 1]); - assert_eq!(history.stats().compressed_entries, 2); + assert_eq!(history.stats().compressed_entries, 3); } #[test] diff --git a/src/logging.rs b/src/logging.rs index 47292dd..c24a1ad 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -2,15 +2,25 @@ // Use of this source code is governed by an MIT license // that can be found in the LICENSE file. +use std::collections::VecDeque; use std::fs::{File, OpenOptions}; use std::io::{BufRead, BufReader, Write}; use std::path::Path; use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::read::MultiGzDecoder; +use flate2::write::GzEncoder; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; use crate::history::HistoryMetadata; -use crate::screen::ScreenSnapshot; +use crate::screen::{Cell, ScreenSnapshot, Style, Symbol}; +const LOG_INLINE_PAYLOAD_MIN_BYTES: usize = 96; +const COMPACT_ARCHIVE_EXTENSION: &str = ".twar"; +const ACTIVE_SPILL_EXTENSION: &str = ".spill.gz"; #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] @@ -32,6 +42,150 @@ pub struct LogRecord { pub snapshot: ScreenSnapshot, } +#[derive(Clone, Debug)] +struct StoredLogRecord { + label: String, + changed: bool, + timestamp_unix_ms: u64, + frame_seq: u64, + width: u16, + height: u16, + changed_cell_count: usize, + input_event_count_since_prev: usize, + resized: bool, + resize_from_width: u16, + resize_from_height: u16, + resize_to_width: u16, + resize_to_height: u16, + resize_source: String, + snapshot: Option>, + delta: Option>, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +struct LegacyStoredLogRecordSerde { + label: String, + changed: bool, + timestamp_unix_ms: u64, + frame_seq: u64, + width: u16, + height: u16, + changed_cell_count: usize, + input_event_count_since_prev: usize, + resized: bool, + resize_from_width: u16, + resize_from_height: u16, + resize_to_width: u16, + resize_to_height: u16, + resize_source: String, + snapshot: Option>, + delta: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct CompactStoredLogRecordSerde( + String, + bool, + u64, + u64, + u16, + u16, + usize, + usize, + bool, + u16, + u16, + u16, + u16, + String, + Option>, + Option>, +); + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +enum StoredLogRecordSerde { + Compact(CompactStoredLogRecordSerde), + Legacy(LegacyStoredLogRecordSerde), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct LogFrameDelta { + width: u16, + height: u16, + changes: Vec<(usize, Cell)>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +struct DeltaSerializableCell { + symbol: Symbol, + style_id: u32, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +struct DeltaRunSerde { + start: usize, + cells: Vec, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +struct CompactLogFrameDeltaSerde { + width: u16, + height: u16, + runs: Vec, + styles: Vec