Skip to main content

chronicle/tui/
app.rs

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; // receiver dropped
67                        }
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 confirmation dialog is active, show it in the detail panel
116        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        // Handle confirmation dialog first
177        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                            // Refresh events to show the checkpoint
189                            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}