Skip to main content

mockforge_tui/screens/
contract_diff.rs

1//! Contract diff captures table screen.
2
3use std::time::Instant;
4
5use crossterm::event::{KeyCode, KeyEvent};
6use ratatui::{
7    layout::{Constraint, Layout, Rect},
8    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
9    Frame,
10};
11use tokio::sync::mpsc;
12
13use crate::api::client::MockForgeClient;
14use crate::api::models::ContractDiffCapture;
15use crate::event::Event;
16use crate::screens::Screen;
17use crate::theme::Theme;
18use crate::widgets::json_viewer;
19use crate::widgets::table::TableState;
20
21const FETCH_INTERVAL: u64 = 30;
22
23pub struct ContractDiffScreen {
24    data: Option<serde_json::Value>,
25    captures: Vec<ContractDiffCapture>,
26    table: TableState,
27    error: Option<String>,
28    last_fetch: Option<Instant>,
29    detail_open: bool,
30    detail_scroll: u16,
31}
32
33impl ContractDiffScreen {
34    pub fn new() -> Self {
35        Self {
36            data: None,
37            captures: Vec::new(),
38            table: TableState::new(),
39            error: None,
40            last_fetch: None,
41            detail_open: false,
42            detail_scroll: 0,
43        }
44    }
45
46    fn selected_capture_json(&self) -> Option<serde_json::Value> {
47        let capture = self.captures.get(self.table.selected)?;
48        Some(serde_json::json!({
49            "id": capture.id,
50            "path": capture.path,
51            "method": capture.method,
52            "diff_status": capture.diff_status,
53            "captured_at": capture.captured_at,
54        }))
55    }
56}
57
58impl Screen for ContractDiffScreen {
59    fn title(&self) -> &str {
60        "Contract Diff"
61    }
62
63    fn handle_key(&mut self, key: KeyEvent) -> bool {
64        if self.detail_open {
65            match key.code {
66                KeyCode::Esc => {
67                    self.detail_open = false;
68                    self.detail_scroll = 0;
69                    return true;
70                }
71                KeyCode::Char('j') | KeyCode::Down => {
72                    self.detail_scroll = self.detail_scroll.saturating_add(1);
73                    return true;
74                }
75                KeyCode::Char('k') | KeyCode::Up => {
76                    self.detail_scroll = self.detail_scroll.saturating_sub(1);
77                    return true;
78                }
79                _ => return true,
80            }
81        }
82
83        match key.code {
84            KeyCode::Enter => {
85                if !self.captures.is_empty() {
86                    self.detail_open = true;
87                    self.detail_scroll = 0;
88                }
89                true
90            }
91            _ => self.table.handle_key(key),
92        }
93    }
94
95    fn render(&self, frame: &mut Frame, area: Rect) {
96        if self.data.is_none() {
97            let loading =
98                Paragraph::new("Loading contract diff captures...").style(Theme::dim()).block(
99                    Block::default()
100                        .title(" Contract Diff ")
101                        .borders(Borders::ALL)
102                        .border_style(Theme::dim()),
103                );
104            frame.render_widget(loading, area);
105            return;
106        }
107
108        // Split: table on top, detail on bottom when open.
109        let chunks = if self.detail_open {
110            Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area)
111        } else {
112            Layout::vertical([Constraint::Min(0), Constraint::Length(0)]).split(area)
113        };
114
115        // Table pane.
116        self.render_table(frame, chunks[0]);
117
118        // Detail pane (when open).
119        if self.detail_open {
120            if let Some(json) = self.selected_capture_json() {
121                json_viewer::render_scrollable(
122                    frame,
123                    chunks[1],
124                    "Capture Detail",
125                    &json,
126                    self.detail_scroll,
127                );
128            }
129        }
130    }
131
132    fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
133        let should_fetch =
134            self.last_fetch.map_or(true, |t| t.elapsed().as_secs() >= FETCH_INTERVAL);
135        if !should_fetch {
136            return;
137        }
138        self.last_fetch = Some(Instant::now());
139
140        let client = client.clone();
141        let tx = tx.clone();
142        tokio::spawn(async move {
143            match client.get_contract_diff_captures().await {
144                Ok(data) => {
145                    let json = serde_json::json!(data
146                        .iter()
147                        .map(|c| serde_json::json!({
148                            "id": c.id,
149                            "path": c.path,
150                            "method": c.method,
151                            "diff_status": c.diff_status,
152                            "captured_at": c.captured_at,
153                        }))
154                        .collect::<Vec<_>>());
155                    let payload = serde_json::to_string(&json).unwrap_or_default();
156                    let _ = tx.send(Event::Data {
157                        screen: "contract_diff",
158                        payload,
159                    });
160                }
161                Err(e) => {
162                    let _ = tx.send(Event::ApiError {
163                        screen: "contract_diff",
164                        message: e.to_string(),
165                    });
166                }
167            }
168        });
169    }
170
171    fn on_data(&mut self, payload: &str) {
172        match serde_json::from_str::<Vec<ContractDiffCapture>>(payload) {
173            Ok(captures) => {
174                self.table.set_total(captures.len());
175                self.captures = captures;
176                self.data = serde_json::from_str(payload).ok();
177                self.error = None;
178            }
179            Err(e) => {
180                self.error = Some(format!("Parse error: {e}"));
181            }
182        }
183    }
184
185    fn on_error(&mut self, message: &str) {
186        self.error = Some(message.to_string());
187    }
188
189    fn error(&self) -> Option<&str> {
190        self.error.as_deref()
191    }
192
193    fn force_refresh(&mut self) {
194        self.last_fetch = None;
195    }
196
197    fn status_hint(&self) -> &str {
198        if self.detail_open {
199            "Esc:close  j/k:scroll"
200        } else {
201            "j/k:navigate  g/G:top/bottom  Enter:detail"
202        }
203    }
204}
205
206impl ContractDiffScreen {
207    fn render_table(&self, frame: &mut Frame, area: Rect) {
208        let header = Row::new(vec![
209            Cell::from("ID").style(Theme::dim()),
210            Cell::from("Path").style(Theme::dim()),
211            Cell::from("Method").style(Theme::dim()),
212            Cell::from("Diff Status").style(Theme::dim()),
213            Cell::from("Captured At").style(Theme::dim()),
214        ])
215        .height(1);
216
217        let rows: Vec<Row> = self
218            .captures
219            .iter()
220            .skip(self.table.offset)
221            .take(self.table.visible_height)
222            .map(|capture| {
223                let diff_style = match capture.diff_status.as_str() {
224                    "match" | "identical" => Theme::success(),
225                    "mismatch" | "changed" | "breaking" => Theme::error(),
226                    _ => Theme::dim(),
227                };
228                let captured_at = capture
229                    .captured_at
230                    .map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string())
231                    .unwrap_or_else(|| "--".to_string());
232                Row::new(vec![
233                    Cell::from(capture.id.clone()),
234                    Cell::from(capture.path.clone()),
235                    Cell::from(capture.method.clone()).style(Theme::http_method(&capture.method)),
236                    Cell::from(capture.diff_status.clone()).style(diff_style),
237                    Cell::from(captured_at),
238                ])
239            })
240            .collect();
241
242        let widths = [
243            Constraint::Length(12),
244            Constraint::Min(20),
245            Constraint::Length(8),
246            Constraint::Length(12),
247            Constraint::Length(20),
248        ];
249
250        let table = Table::new(rows, widths)
251            .header(header)
252            .row_highlight_style(Theme::highlight())
253            .block(
254                Block::default()
255                    .title(format!(" Contract Diff ({}) ", self.captures.len()))
256                    .title_style(Theme::title())
257                    .borders(Borders::ALL)
258                    .border_style(Theme::dim())
259                    .style(Theme::surface()),
260            );
261
262        let mut table_state = self.table.to_ratatui_state();
263        frame.render_stateful_widget(table, area, &mut table_state);
264    }
265}