use std::sync::Mutex;
use std::time::Instant;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PaneEvent {
pub ts_ms: u64,
pub bytes: Vec<u8>,
}
pub struct PaneRecording {
enabled: Mutex<RecordingState>,
}
struct RecordingState {
started_at: Option<Instant>,
events: std::collections::VecDeque<PaneEvent>,
max_events: usize,
cols: u16,
rows: u16,
}
impl Default for PaneRecording {
fn default() -> Self {
Self::new(50_000)
}
}
impl PaneRecording {
#[must_use]
pub fn new(max_events: usize) -> Self {
Self {
enabled: Mutex::new(RecordingState {
started_at: None,
events: std::collections::VecDeque::new(),
max_events,
cols: 80,
rows: 24,
}),
}
}
pub fn enable(&self, cols: u16, rows: u16) {
let mut g = self.enabled.lock().expect("recording state poisoned");
g.started_at = Some(Instant::now());
g.events.clear();
g.cols = cols;
g.rows = rows;
}
pub fn disable(&self) {
let mut g = self.enabled.lock().expect("recording state poisoned");
g.started_at = None;
}
#[must_use]
pub fn is_enabled(&self) -> bool {
self.enabled
.lock()
.expect("recording state poisoned")
.started_at
.is_some()
}
#[must_use]
pub fn event_count(&self) -> usize {
self.enabled
.lock()
.expect("recording state poisoned")
.events
.len()
}
pub fn push(&self, bytes: &[u8]) {
let mut g = self.enabled.lock().expect("recording state poisoned");
let Some(start) = g.started_at else {
return;
};
let ts_ms = start.elapsed().as_millis() as u64;
let cap = g.max_events;
if g.events.len() == cap {
g.events.pop_front();
}
g.events.push_back(PaneEvent {
ts_ms,
bytes: bytes.to_vec(),
});
}
pub fn to_cast_json(&self) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let g = self.enabled.lock().expect("recording state poisoned");
let header = serde_json::json!({
"version": 2,
"width": g.cols,
"height": g.rows,
"timestamp": SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
"env": {
"TERM": "xterm-256color",
"SHELL": std::env::var("SHELL").unwrap_or_default(),
},
});
let mut out = header.to_string();
out.push('\n');
for ev in &g.events {
let secs = (ev.ts_ms as f64) / 1000.0;
let chunk = String::from_utf8_lossy(&ev.bytes).into_owned();
let row = serde_json::Value::Array(vec![
serde_json::json!(secs),
serde_json::json!("o"),
serde_json::json!(chunk),
]);
out.push_str(&row.to_string());
out.push('\n');
}
out
}
pub fn read_around(&self, ts_ms: u64, limit: usize) -> Vec<PaneEvent> {
let g = self.enabled.lock().expect("recording state poisoned");
let cursor = g
.events
.iter()
.position(|e| e.ts_ms >= ts_ms)
.unwrap_or(g.events.len());
let start = cursor.saturating_sub(limit / 2);
let end = (start + limit).min(g.events.len());
g.events.range(start..end).cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_recording_drops_pushes() {
let r = PaneRecording::default();
r.push(b"hello");
assert_eq!(r.event_count(), 0);
}
#[test]
fn enable_then_push_captures_events() {
let r = PaneRecording::default();
r.enable(80, 24);
r.push(b"hello");
r.push(b" world");
assert_eq!(r.event_count(), 2);
}
#[test]
fn ring_buffer_caps_at_max() {
let r = PaneRecording::new(3);
r.enable(80, 24);
r.push(b"a");
r.push(b"b");
r.push(b"c");
r.push(b"d");
r.push(b"e");
assert_eq!(r.event_count(), 3);
}
#[test]
fn cast_export_has_header_plus_one_line_per_event() {
let r = PaneRecording::default();
r.enable(120, 40);
r.push(b"$ ls\n");
r.push(b"file1 file2\n");
let cast = r.to_cast_json();
let lines: Vec<&str> = cast.lines().collect();
assert_eq!(lines.len(), 3, "expected header + 2 events, got {cast}");
let header: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(header["version"], 2);
assert_eq!(header["width"], 120);
assert_eq!(header["height"], 40);
let ev: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
let arr = ev.as_array().unwrap();
assert_eq!(arr[1], "o");
assert_eq!(arr[2].as_str().unwrap(), "$ ls\n");
}
#[test]
fn disable_then_enable_clears_old_events() {
let r = PaneRecording::default();
r.enable(80, 24);
r.push(b"x");
assert_eq!(r.event_count(), 1);
r.disable();
r.enable(80, 24);
assert_eq!(r.event_count(), 0);
}
#[test]
fn read_around_returns_events_near_cursor() {
let r = PaneRecording::default();
r.enable(80, 24);
{
let mut g = r.enabled.lock().unwrap();
for i in 0..5u64 {
g.events.push_back(PaneEvent {
ts_ms: i * 1000,
bytes: vec![b'a' + i as u8],
});
}
}
let around = r.read_around(2500, 4);
assert_eq!(around.len(), 4);
assert_eq!(around[0].ts_ms, 1000);
assert_eq!(around[3].ts_ms, 4000);
}
}