mockforge_tui/screens/
plugins.rs1use 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}