Skip to main content

mockforge_tui/screens/
plugins.rs

1//! Plugin list table screen.
2
3use std::time::Instant;
4
5use crossterm::event::KeyEvent;
6use ratatui::{
7    layout::{Constraint, 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::PluginInfo;
15use crate::event::Event;
16use crate::screens::Screen;
17use crate::theme::Theme;
18use crate::widgets::table::TableState;
19
20const FETCH_INTERVAL: u64 = 60;
21
22pub struct PluginsScreen {
23    data: Option<serde_json::Value>,
24    plugins: Vec<PluginInfo>,
25    table: TableState,
26    error: Option<String>,
27    last_fetch: Option<Instant>,
28}
29
30impl PluginsScreen {
31    pub fn new() -> Self {
32        Self {
33            data: None,
34            plugins: Vec::new(),
35            table: TableState::new(),
36            error: None,
37            last_fetch: None,
38        }
39    }
40}
41
42impl Screen for PluginsScreen {
43    fn title(&self) -> &str {
44        "Plugins"
45    }
46
47    fn handle_key(&mut self, key: KeyEvent) -> bool {
48        self.table.handle_key(key)
49    }
50
51    fn render(&self, frame: &mut Frame, area: Rect) {
52        if self.data.is_none() {
53            let loading = Paragraph::new("Loading plugins...").style(Theme::dim()).block(
54                Block::default()
55                    .title(" Plugins ")
56                    .borders(Borders::ALL)
57                    .border_style(Theme::dim()),
58            );
59            frame.render_widget(loading, area);
60            return;
61        }
62
63        let header = Row::new(vec![
64            Cell::from("ID").style(Theme::dim()),
65            Cell::from("Name").style(Theme::dim()),
66            Cell::from("Version").style(Theme::dim()),
67            Cell::from("Status").style(Theme::dim()),
68            Cell::from("Healthy").style(Theme::dim()),
69        ])
70        .height(1);
71
72        let rows: Vec<Row> = self
73            .plugins
74            .iter()
75            .skip(self.table.offset)
76            .take(self.table.visible_height)
77            .map(|plugin| {
78                let healthy_style = if plugin.healthy {
79                    Theme::success()
80                } else {
81                    Theme::error()
82                };
83                Row::new(vec![
84                    Cell::from(plugin.id.clone()),
85                    Cell::from(plugin.name.clone()),
86                    Cell::from(plugin.version.clone()),
87                    Cell::from(plugin.status.clone()),
88                    Cell::from(if plugin.healthy { "yes" } else { "no" }).style(healthy_style),
89                ])
90            })
91            .collect();
92
93        let widths = [
94            Constraint::Length(12),
95            Constraint::Length(20),
96            Constraint::Length(10),
97            Constraint::Length(12),
98            Constraint::Length(8),
99        ];
100
101        let table = Table::new(rows, widths)
102            .header(header)
103            .row_highlight_style(Theme::highlight())
104            .block(
105                Block::default()
106                    .title(format!(" Plugins ({}) ", self.plugins.len()))
107                    .title_style(Theme::title())
108                    .borders(Borders::ALL)
109                    .border_style(Theme::dim())
110                    .style(Theme::surface()),
111            );
112
113        let mut table_state = self.table.to_ratatui_state();
114        frame.render_stateful_widget(table, area, &mut table_state);
115    }
116
117    fn tick(&mut self, client: &MockForgeClient, tx: &mpsc::UnboundedSender<Event>) {
118        let should_fetch =
119            self.last_fetch.map_or(true, |t| t.elapsed().as_secs() >= FETCH_INTERVAL);
120        if !should_fetch {
121            return;
122        }
123        self.last_fetch = Some(Instant::now());
124
125        let client = client.clone();
126        let tx = tx.clone();
127        tokio::spawn(async move {
128            match client.get_plugins().await {
129                Ok(data) => {
130                    let json = serde_json::json!(data
131                        .iter()
132                        .map(|p| serde_json::json!({
133                            "id": p.id,
134                            "name": p.name,
135                            "version": p.version,
136                            "status": p.status,
137                            "healthy": p.healthy,
138                            "description": p.description,
139                            "author": p.author,
140                        }))
141                        .collect::<Vec<_>>());
142                    let payload = serde_json::to_string(&json).unwrap_or_default();
143                    let _ = tx.send(Event::Data {
144                        screen: "plugins",
145                        payload,
146                    });
147                }
148                Err(e) => {
149                    let _ = tx.send(Event::ApiError {
150                        screen: "plugins",
151                        message: e.to_string(),
152                    });
153                }
154            }
155        });
156    }
157
158    fn on_data(&mut self, payload: &str) {
159        match serde_json::from_str::<Vec<PluginInfo>>(payload) {
160            Ok(plugins) => {
161                self.table.set_total(plugins.len());
162                self.plugins = plugins;
163                self.data = serde_json::from_str(payload).ok();
164                self.error = None;
165            }
166            Err(e) => {
167                self.error = Some(format!("Parse error: {e}"));
168            }
169        }
170    }
171
172    fn on_error(&mut self, message: &str) {
173        self.error = Some(message.to_string());
174    }
175
176    fn error(&self) -> Option<&str> {
177        self.error.as_deref()
178    }
179
180    fn force_refresh(&mut self) {
181        self.last_fetch = None;
182    }
183
184    fn status_hint(&self) -> &str {
185        "j/k:navigate  g/G:top/bottom"
186    }
187}