1use std::time::Instant;
4
5use crossterm::event::{KeyCode, KeyEvent};
6use ratatui::{
7 layout::{Constraint, Layout, Rect},
8 style::Style,
9 text::{Line, Span},
10 widgets::{Block, Borders, Paragraph},
11 Frame,
12};
13use tokio::sync::mpsc;
14
15use crate::api::client::MockForgeClient;
16use crate::event::Event;
17use crate::screens::Screen;
18use crate::theme::Theme;
19use crate::widgets::confirm::ConfirmDialog;
20
21pub struct ImportScreen {
22 data: Option<serde_json::Value>,
23 error: Option<String>,
24 last_fetch: Option<Instant>,
25 scroll_offset: usize,
26 confirm: ConfirmDialog,
27 pending_clear: bool,
28 status_message: Option<(bool, String)>,
29}
30
31impl ImportScreen {
32 pub fn new() -> Self {
33 Self {
34 data: None,
35 error: None,
36 last_fetch: None,
37 scroll_offset: 0,
38 confirm: ConfirmDialog::new(),
39 pending_clear: false,
40 status_message: None,
41 }
42 }
43
44 fn entry_count(&self) -> usize {
45 self.data.as_ref().and_then(|d| d.as_array()).map_or(0, |a| a.len())
46 }
47}
48
49impl Screen for ImportScreen {
50 fn title(&self) -> &str {
51 "Import"
52 }
53
54 fn handle_key(&mut self, key: KeyEvent) -> bool {
55 if self.confirm.visible {
57 if let Some(confirmed) = self.confirm.handle_key(key) {
58 if confirmed {
59 self.pending_clear = true;
60 }
61 return true;
62 }
63 return true;
64 }
65
66 match key.code {
67 KeyCode::Char('r') => {
68 self.last_fetch = None;
69 true
70 }
71 KeyCode::Char('c') => {
72 if self.entry_count() > 0 {
73 self.confirm.show(
74 "Clear History",
75 format!("Clear all {} import entries?", self.entry_count()),
76 );
77 }
78 true
79 }
80 KeyCode::Char('j') | KeyCode::Down => {
81 self.scroll_offset = self.scroll_offset.saturating_add(1);
82 true
83 }
84 KeyCode::Char('k') | KeyCode::Up => {
85 self.scroll_offset = self.scroll_offset.saturating_sub(1);
86 true
87 }
88 KeyCode::Char('g') => {
89 self.scroll_offset = 0;
90 true
91 }
92 KeyCode::Char('G') => {
93 self.scroll_offset = self.entry_count().saturating_sub(1);
94 true
95 }
96 _ => false,
97 }
98 }
99
100 fn render(&self, frame: &mut Frame, area: Rect) {
101 let Some(ref data) = self.data else {
102 let loading = Paragraph::new("Loading import history...").style(Theme::dim()).block(
103 Block::default()
104 .title(" Import ")
105 .borders(Borders::ALL)
106 .border_style(Theme::dim()),
107 );
108 frame.render_widget(loading, area);
109 self.confirm.render(frame);
110 return;
111 };
112
113 let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area);
114
115 let block = Block::default()
116 .title(format!(" Import History ({}) ", self.entry_count()))
117 .title_style(Theme::title())
118 .borders(Borders::ALL)
119 .border_style(Theme::dim())
120 .style(Theme::surface());
121
122 let mut lines = Vec::new();
123
124 if let Some(entries) = data.as_array() {
125 for entry in entries.iter().skip(self.scroll_offset) {
126 let summary = entry
127 .as_object()
128 .map(|obj| {
129 let source =
130 obj.get("source").and_then(|v| v.as_str()).unwrap_or("unknown");
131 let status = obj.get("status").and_then(|v| v.as_str()).unwrap_or("--");
132 let timestamp =
133 obj.get("timestamp").and_then(|v| v.as_str()).unwrap_or("--");
134 let status_style = match status {
135 "success" | "ok" => Theme::success(),
136 "failed" | "error" => Theme::error(),
137 _ => Style::default().fg(Theme::FG),
138 };
139 vec![
140 Span::styled(format!(" {timestamp} "), Theme::dim()),
141 Span::styled(format!("{source:<20} "), Style::default().fg(Theme::FG)),
142 Span::styled(status.to_string(), status_style),
143 ]
144 })
145 .unwrap_or_else(|| {
146 vec![Span::styled(
147 format!(" {entry}"),
148 Style::default().fg(Theme::FG),
149 )]
150 });
151 lines.push(Line::from(summary));
152 }
153 } else {
154 let formatted = serde_json::to_string_pretty(data).unwrap_or_default();
155 for line in formatted.lines() {
156 lines.push(Line::from(Span::styled(
157 format!(" {line}"),
158 Style::default().fg(Theme::FG),
159 )));
160 }
161 }
162
163 if lines.is_empty() {
164 lines.push(Line::from(Span::styled(" No import history", Theme::dim())));
165 }
166
167 let paragraph = Paragraph::new(lines).block(block);
168 frame.render_widget(paragraph, chunks[0]);
169
170 let msg_line = if let Some((success, ref msg)) = self.status_message {
172 let style = if success {
173 Theme::success()
174 } else {
175 Theme::error()
176 };
177 Line::from(vec![
178 Span::styled(if success { " OK: " } else { " ERR: " }, style),
179 Span::styled(msg.as_str(), Theme::base()),
180 ])
181 } else {
182 Line::from(Span::styled(" Ready", Theme::dim()))
183 };
184 let msg_block = Block::default()
185 .borders(Borders::ALL)
186 .border_style(Theme::dim())
187 .style(Theme::surface());
188 frame.render_widget(Paragraph::new(msg_line).block(msg_block), chunks[1]);
189
190 self.confirm.render(frame);
191 }
192
193 fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
194 if self.pending_clear {
196 self.pending_clear = false;
197 let client = client.clone();
198 let tx = tx.clone();
199 tokio::spawn(async move {
200 let result = match client.clear_import_history().await {
201 Ok(msg) => serde_json::json!({
202 "type": "clear_result",
203 "success": true,
204 "message": if msg.is_empty() { "History cleared".to_string() } else { msg },
205 }),
206 Err(e) => serde_json::json!({
207 "type": "clear_result",
208 "success": false,
209 "message": e.to_string(),
210 }),
211 };
212 let _ = tx.send(Event::Data {
213 screen: "import",
214 payload: serde_json::to_string(&result).unwrap_or_default(),
215 });
216 });
217 }
218
219 let should_fetch = self.last_fetch.is_none();
221 if !should_fetch {
222 return;
223 }
224 self.last_fetch = Some(Instant::now());
225
226 let client = client.clone();
227 let tx = tx.clone();
228 tokio::spawn(async move {
229 match client.get_import_history().await {
230 Ok(data) => {
231 let json = serde_json::to_string(&data).unwrap_or_default();
232 let _ = tx.send(Event::Data {
233 screen: "import",
234 payload: json,
235 });
236 }
237 Err(e) => {
238 let _ = tx.send(Event::ApiError {
239 screen: "import",
240 message: e.to_string(),
241 });
242 }
243 }
244 });
245 }
246
247 fn on_data(&mut self, payload: &str) {
248 if let Ok(val) = serde_json::from_str::<serde_json::Value>(payload) {
250 if val.get("type").and_then(|v| v.as_str()) == Some("clear_result") {
251 let success = val.get("success").and_then(|v| v.as_bool()).unwrap_or(false);
252 let message =
253 val.get("message").and_then(|v| v.as_str()).unwrap_or("done").to_string();
254 self.status_message = Some((success, message));
255 self.last_fetch = None;
257 return;
258 }
259 }
260
261 match serde_json::from_str::<serde_json::Value>(payload) {
263 Ok(data) => {
264 self.data = Some(data);
265 self.error = None;
266 }
267 Err(e) => {
268 self.error = Some(format!("Parse error: {e}"));
269 }
270 }
271 }
272
273 fn on_error(&mut self, message: &str) {
274 self.error = Some(message.to_string());
275 }
276
277 fn error(&self) -> Option<&str> {
278 self.error.as_deref()
279 }
280
281 fn force_refresh(&mut self) {
282 self.last_fetch = None;
283 }
284
285 fn status_hint(&self) -> &str {
286 "r:refresh c:clear j/k:scroll g/G:top/bottom"
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use crossterm::event::{KeyEventKind, KeyEventState, KeyModifiers};
294
295 fn key(code: KeyCode) -> KeyEvent {
296 KeyEvent {
297 code,
298 modifiers: KeyModifiers::NONE,
299 kind: KeyEventKind::Press,
300 state: KeyEventState::NONE,
301 }
302 }
303
304 #[test]
305 fn new_creates_empty_screen() {
306 let s = ImportScreen::new();
307 assert!(s.data.is_none());
308 assert!(!s.pending_clear);
309 assert!(s.status_message.is_none());
310 assert_eq!(s.scroll_offset, 0);
311 }
312
313 #[test]
314 fn on_data_parses_array() {
315 let mut s = ImportScreen::new();
316 s.on_data(r#"[{"source":"postman","status":"success","timestamp":"2025-01-01"}]"#);
317 assert!(s.data.is_some());
318 assert_eq!(s.entry_count(), 1);
319 }
320
321 #[test]
322 fn r_key_forces_refresh() {
323 let mut s = ImportScreen::new();
324 s.last_fetch = Some(Instant::now());
325 s.handle_key(key(KeyCode::Char('r')));
326 assert!(s.last_fetch.is_none());
327 }
328
329 #[test]
330 fn c_key_on_empty_does_not_show_confirm() {
331 let mut s = ImportScreen::new();
332 s.on_data("[]");
333 s.handle_key(key(KeyCode::Char('c')));
334 assert!(!s.confirm.visible);
335 }
336
337 #[test]
338 fn c_key_with_entries_shows_confirm() {
339 let mut s = ImportScreen::new();
340 s.on_data(r#"[{"source":"postman","status":"success","timestamp":"2025-01-01"}]"#);
341 s.handle_key(key(KeyCode::Char('c')));
342 assert!(s.confirm.visible);
343 }
344
345 #[test]
346 fn confirm_yes_sets_pending_clear() {
347 let mut s = ImportScreen::new();
348 s.on_data(r#"[{"source":"postman","status":"success","timestamp":"2025-01-01"}]"#);
349 s.handle_key(key(KeyCode::Char('c')));
350 s.handle_key(key(KeyCode::Char('y')));
351 assert!(s.pending_clear);
352 }
353
354 #[test]
355 fn confirm_no_does_not_clear() {
356 let mut s = ImportScreen::new();
357 s.on_data(r#"[{"source":"postman","status":"success","timestamp":"2025-01-01"}]"#);
358 s.handle_key(key(KeyCode::Char('c')));
359 s.handle_key(key(KeyCode::Char('n')));
360 assert!(!s.pending_clear);
361 }
362
363 #[test]
364 fn clear_result_sets_status_message() {
365 let mut s = ImportScreen::new();
366 let result = serde_json::json!({
367 "type": "clear_result",
368 "success": true,
369 "message": "History cleared",
370 });
371 s.on_data(&serde_json::to_string(&result).unwrap());
372 assert!(s.status_message.is_some());
373 let (success, msg) = s.status_message.as_ref().unwrap();
374 assert!(success);
375 assert_eq!(msg, "History cleared");
376 }
377
378 #[test]
379 fn j_k_scroll() {
380 let mut s = ImportScreen::new();
381 s.handle_key(key(KeyCode::Char('j')));
382 assert_eq!(s.scroll_offset, 1);
383 s.handle_key(key(KeyCode::Char('k')));
384 assert_eq!(s.scroll_offset, 0);
385 s.handle_key(key(KeyCode::Char('k')));
387 assert_eq!(s.scroll_offset, 0);
388 }
389
390 #[test]
391 fn status_hint_shows_controls() {
392 let s = ImportScreen::new();
393 assert!(s.status_hint().contains("clear"));
394 assert!(s.status_hint().contains("refresh"));
395 }
396
397 #[test]
398 fn force_refresh_clears_last_fetch() {
399 let mut s = ImportScreen::new();
400 s.last_fetch = Some(Instant::now());
401 s.force_refresh();
402 assert!(s.last_fetch.is_none());
403 }
404}