mockforge_tui/screens/
contract_diff.rs1use 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 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 self.render_table(frame, chunks[0]);
117
118 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}