1use anyhow::Result;
2use crossterm::event::{self, Event as CrosstermEvent, KeyCode, KeyModifiers};
3use ratatui::prelude::*;
4use rusqlite::Connection;
5use std::io::BufRead;
6use std::path::Path;
7use std::time::Duration;
8
9use crate::db::models::Event;
10use crate::db::queries;
11use crate::tui::{detail, statusbar, timeline};
12
13pub struct App {
14 pub events: Vec<Event>,
15 pub selected_index: usize,
16 pub session_id: String,
17 pub should_quit: bool,
18 pub conn: Connection,
19 pub confirm_restore: Option<(i64, Vec<crate::restore::RestoreAction>)>,
20 pub status_message: Option<String>,
21 live_rx: Option<std::sync::mpsc::Receiver<Event>>,
22 new_events_count: usize,
23}
24
25impl App {
26 pub fn new(conn: Connection, session_id: String, chronicle_dir: Option<&Path>) -> Result<Self> {
27 let events = queries::list_events_for_session(&conn, &session_id)?;
28 let selected_index = events.len().saturating_sub(1);
29 let live_rx = chronicle_dir.and_then(|dir| Self::start_live_reader(dir, &session_id));
30 Ok(Self {
31 events,
32 selected_index,
33 session_id,
34 should_quit: false,
35 conn,
36 confirm_restore: None,
37 status_message: None,
38 live_rx,
39 new_events_count: 0,
40 })
41 }
42
43 fn start_live_reader(
44 chronicle_dir: &Path,
45 session_id: &str,
46 ) -> Option<std::sync::mpsc::Receiver<Event>> {
47 let sock_path = chronicle_dir.join("chronicle-live.sock");
48 let stream = std::os::unix::net::UnixStream::connect(&sock_path).ok()?;
49 stream
50 .set_read_timeout(Some(Duration::from_millis(500)))
51 .ok()?;
52 let (tx, rx) = std::sync::mpsc::channel();
53 let filter_session = session_id.to_string();
54 std::thread::spawn(move || {
55 let reader = std::io::BufReader::new(stream);
56 for line in reader.lines() {
57 let line = match line {
58 Ok(l) => l,
59 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
60 Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
61 Err(_) => break,
62 };
63 if let Ok(event) = serde_json::from_str::<Event>(&line) {
64 if event.session_id == filter_session {
65 if tx.send(event).is_err() {
66 break; }
68 }
69 }
70 }
71 });
72 Some(rx)
73 }
74
75 fn drain_live_events(&mut self) {
76 let rx = match &self.live_rx {
77 Some(rx) => rx,
78 None => return,
79 };
80 let at_bottom = self.selected_index + 1 >= self.events.len() || self.events.is_empty();
81 let mut received = false;
82 while let Ok(event) = rx.try_recv() {
83 self.events.push(event);
84 received = true;
85 }
86 if received {
87 if at_bottom {
88 self.selected_index = self.events.len().saturating_sub(1);
89 } else {
90 self.new_events_count = self.events.len().saturating_sub(self.selected_index + 1);
91 }
92 }
93 }
94
95 pub fn run(&mut self, terminal: &mut Terminal<impl Backend>) -> Result<()> {
96 while !self.should_quit {
97 terminal.draw(|frame| self.render(frame))?;
98 if event::poll(Duration::from_millis(100))? {
99 if let CrosstermEvent::Key(key) = event::read()? {
100 self.handle_key(key.code, key.modifiers);
101 }
102 }
103 self.drain_live_events();
104 }
105 Ok(())
106 }
107
108 fn render(&self, frame: &mut Frame) {
109 let status_height = if self.status_message.is_some() { 2 } else { 1 };
110 let layout = Layout::default()
111 .direction(Direction::Vertical)
112 .constraints([Constraint::Min(1), Constraint::Length(status_height)])
113 .split(frame.area());
114
115 if let Some((_event_id, ref actions)) = self.confirm_restore {
117 let main_area = Layout::default()
118 .direction(Direction::Horizontal)
119 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
120 .split(layout[0]);
121
122 timeline::render(frame, main_area[0], &self.events, self.selected_index);
123
124 let mut lines = vec!["Restore to this point? (y/n)".to_string(), String::new()];
125 for action in actions {
126 match action {
127 crate::restore::RestoreAction::Overwrite { path } => {
128 lines.push(format!(" OVERWRITE {path}"));
129 }
130 crate::restore::RestoreAction::Create { path } => {
131 lines.push(format!(" CREATE {path}"));
132 }
133 crate::restore::RestoreAction::Delete { path } => {
134 lines.push(format!(" DELETE {path}"));
135 }
136 }
137 }
138 let dialog = ratatui::widgets::Paragraph::new(lines.join("\n"))
139 .block(
140 ratatui::widgets::Block::default()
141 .title(" Confirm Restore ")
142 .borders(ratatui::widgets::Borders::ALL),
143 )
144 .style(Style::default().fg(Color::Yellow));
145 frame.render_widget(dialog, main_area[1]);
146 } else {
147 let main_area = Layout::default()
148 .direction(Direction::Horizontal)
149 .constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
150 .split(layout[0]);
151
152 timeline::render(frame, main_area[0], &self.events, self.selected_index);
153
154 let selected_event = self.events.get(self.selected_index);
155 let snapshots = selected_event
156 .map(|e| {
157 queries::get_snapshots_for_event(&self.conn, e.id).unwrap_or_default()
158 })
159 .unwrap_or_default();
160 detail::render(frame, main_area[1], selected_event, &snapshots);
161 }
162
163 statusbar::render(
164 frame,
165 layout[1],
166 &self.session_id,
167 self.events.len(),
168 self.status_message.as_deref(),
169 self.new_events_count,
170 );
171 }
172
173 fn handle_key(&mut self, code: KeyCode, _modifiers: KeyModifiers) {
174 self.status_message = None;
175
176 if let Some((event_id, _)) = self.confirm_restore.take() {
178 match code {
179 KeyCode::Char('y') | KeyCode::Char('Y') => {
180 match crate::restore::execute_restore(
181 &self.conn,
182 &self.session_id,
183 event_id,
184 ) {
185 Ok(()) => {
186 self.status_message =
187 Some("Restored. RestoreCheckpoint created.".into());
188 if let Ok(events) =
190 queries::list_events_for_session(&self.conn, &self.session_id)
191 {
192 self.events = events;
193 self.selected_index =
194 self.events.len().saturating_sub(1);
195 }
196 }
197 Err(e) => {
198 self.status_message =
199 Some(format!("Restore failed: {e}"));
200 }
201 }
202 }
203 _ => {
204 self.status_message = Some("Restore cancelled.".into());
205 }
206 }
207 return;
208 }
209
210 match code {
211 KeyCode::Char('q') | KeyCode::Esc => self.should_quit = true,
212 KeyCode::Up | KeyCode::Char('k') => {
213 self.selected_index = self.selected_index.saturating_sub(1);
214 }
215 KeyCode::Down | KeyCode::Char('j') => {
216 if self.selected_index + 1 < self.events.len() {
217 self.selected_index += 1;
218 if self.selected_index + 1 >= self.events.len() {
219 self.new_events_count = 0;
220 }
221 }
222 }
223 KeyCode::Home | KeyCode::Char('g') => {
224 self.selected_index = 0;
225 }
226 KeyCode::End | KeyCode::Char('G') => {
227 self.selected_index = self.events.len().saturating_sub(1);
228 self.new_events_count = 0;
229 }
230 KeyCode::Char('r') => {
231 if let Some(event) = self.events.get(self.selected_index) {
232 let event_id = event.id;
233 match crate::restore::restore_to_event(
234 &self.conn,
235 &self.session_id,
236 event_id,
237 ) {
238 Ok(actions) if !actions.is_empty() => {
239 self.confirm_restore = Some((event_id, actions));
240 }
241 Ok(_) => {
242 self.status_message = Some("Nothing to restore.".into());
243 }
244 Err(e) => {
245 self.status_message =
246 Some(format!("Restore plan failed: {e}"));
247 }
248 }
249 }
250 }
251 _ => {}
252 }
253 }
254}