Skip to main content

mockforge_tui/screens/
import.rs

1//! Import history viewer screen with clear action.
2
3use 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        // Confirm dialog takes priority.
56        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        // Status message bar.
171        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        // Handle pending clear.
195        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        // On-demand fetch (first load + manual refresh).
220        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        // Check for clear result.
249        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                // Force refresh.
256                self.last_fetch = None;
257                return;
258            }
259        }
260
261        // Normal history data.
262        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        // Does not go below 0.
386        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}