/// Scrollback persistence -- saves/loads per-tab message history as JSONL files. /// /// History files live in `~/.nirc/history/`. Each file is named /// `.log` (e.g. `IRC_#nirc.log`). The first line is a /// comment bearing the original tab id (`# tab_id: IRC:#nirc`); subsequent /// lines are JSON-serialised `ChatMessage` objects (one per line). use crate::core::message::ChatMessage; use crate::core::protocol::ProtocolType; use std::fs; use std::io::{BufRead, BufWriter, Write}; use std::path::PathBuf; use tracing::{debug, warn}; fn history_dir() -> PathBuf { dirs::data_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("nirc") .join("history") } fn sanitise(tab_id: &str) -> String { tab_id.replace(':', "_").replace('/', "_").replace('\\', "_").replace('\0', "") } fn history_path(tab_id: &str) -> PathBuf { history_dir().join(format!("{}.log", sanitise(tab_id))) } fn ensure_dir() { let _ = fs::create_dir_all(history_dir()); } pub fn save_tab(tab_id: &str, messages: &[ChatMessage], max_scrollback: usize) { let start = messages.len().saturating_sub(max_scrollback); let to_save = &messages[start..]; if to_save.is_empty() { return; } ensure_dir(); let path = history_path(tab_id); match fs::File::create(&path) { Ok(file) => { let mut w = BufWriter::new(file); let _ = writeln!(w, "# tab_id: {}", tab_id); for msg in to_save { match serde_json::to_string(msg) { Ok(line) => { let _ = writeln!(w, "{}", line); } Err(e) => { warn!(%e, tab_id, "Failed to serialise message for history"); } } } let _ = w.flush(); debug!(path = %path.display(), count = to_save.len(), "Saved scrollback"); } Err(e) => { warn!(%e, path = %path.display(), "Failed to create history file"); } } } pub fn load_tab(path: &std::path::Path, max_scrollback: usize) -> Option<(String, Vec)> { let file = fs::File::open(path).ok()?; let reader = std::io::BufReader::new(file); let mut lines = reader.lines(); let header = lines.next().map(|r| r.ok()).flatten()?; let tab_id = header.strip_prefix("# tab_id: ")?.to_owned(); let mut messages: Vec = Vec::new(); for line_result in lines { let line = match line_result { Ok(l) => l, Err(e) => { warn!(%e, path = %path.display(), "Error reading history line"); continue; } }; let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with('#') { continue; } match serde_json::from_str::(trimmed) { Ok(msg) => messages.push(msg), Err(e) => { warn!(%e, path = %path.display(), "Failed to parse history line"); } } } if messages.len() > max_scrollback { let start = messages.len() - max_scrollback; messages = messages[start..].to_vec(); } debug!(path = %path.display(), tab_id = %tab_id, count = messages.len(), "Loaded scrollback"); Some((tab_id, messages)) } pub fn save_all(app: &crate::core::app::App, max_scrollback: usize) { for i in 0..app.tab_count() { if let Some(tab) = app.tab_at(i) { save_tab(&tab.id, tab.messages(), max_scrollback); } } } fn protocol_from_tag(tag: &str) -> Option { match tag { "IRC" => Some(ProtocolType::Irc), "Mtx" => Some(ProtocolType::Matrix), "ADC" => Some(ProtocolType::Adc), "P2P" => Some(ProtocolType::BitChat), "Dsc" => Some(ProtocolType::Discord), "Sto" => Some(ProtocolType::Stout), "Spc" => Some(ProtocolType::Spacebar), "Ner" => Some(ProtocolType::Nerimity), _ => None, } } pub fn load_all(max_scrollback: usize) -> Vec<(String, ProtocolType, String, Vec)> { let dir = history_dir(); if !dir.exists() { return Vec::new(); } let mut results = Vec::new(); if let Ok(entries) = fs::read_dir(&dir) { for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.extension().map(|ext| ext == "log").unwrap_or(false) { if let Some((tid, messages)) = load_tab(&path, max_scrollback) { // Clone to avoid borrow conflict (split_once borrows tid). let tid_clone = tid.clone(); if let Some((proto_tag, source)) = tid_clone.split_once(':') { if let Some(protocol) = protocol_from_tag(proto_tag) { results.push((tid, protocol, source.to_owned(), messages)); } } } } } } results }