1use std::time::Duration;
4
5use anyhow::Result;
6use crossterm::event::{KeyCode, KeyModifiers};
7use ratatui::{
8 layout::{Alignment, Constraint, Layout, Rect},
9 style::Style,
10 text::{Line, Span},
11 widgets::Paragraph,
12 Frame,
13};
14
15use crate::api::client::MockForgeClient;
16use crate::config::TuiConfig;
17use crate::event::{Event, EventHandler};
18use crate::keybindings::{self, Action};
19use crate::screens::{self, Screen, ScreenId};
20use crate::theme::Theme;
21use crate::tui;
22use crate::widgets::command_palette::{CommandPalette, PaletteAction};
23use crate::widgets::{help, status_bar};
24
25pub struct App {
27 config: TuiConfig,
28 admin_url: String,
29 client: MockForgeClient,
30 screens: Vec<Box<dyn Screen>>,
31 active_tab: usize,
32 show_help: bool,
33 command_palette: CommandPalette,
34 connected: bool,
35 error_count: usize,
36 should_quit: bool,
37 tab_bar_y: u16,
39 last_health_check: std::time::Instant,
41}
42
43impl App {
44 pub fn new(config: TuiConfig, token: Option<String>) -> Self {
46 Theme::init(config.is_light_theme());
47
48 let client =
49 MockForgeClient::new(config.admin_url.clone(), token).expect("failed to build client");
50
51 let admin_url = config.admin_url.clone();
52 let initial_tab = config.last_tab.unwrap_or(0);
53
54 let screens: Vec<Box<dyn Screen>> = vec![
55 Box::new(screens::dashboard::DashboardScreen::new()),
56 Box::new(screens::logs::LogsScreen::new()),
57 Box::new(screens::routes::RoutesScreen::new()),
58 Box::new(screens::metrics::MetricsScreen::new()),
59 Box::new(screens::config::ConfigScreen::new()),
60 Box::new(screens::chaos::ChaosScreen::new()),
61 Box::new(screens::workspaces::WorkspacesScreen::new()),
62 Box::new(screens::plugins::PluginsScreen::new()),
63 Box::new(screens::fixtures::FixturesScreen::new()),
64 Box::new(screens::health::HealthScreen::new()),
65 Box::new(screens::smoke_tests::SmokeTestsScreen::new()),
66 Box::new(screens::time_travel::TimeTravelScreen::new()),
67 Box::new(screens::chains::ChainsScreen::new()),
68 Box::new(screens::verification::VerificationScreen::new()),
69 Box::new(screens::analytics::AnalyticsScreen::new()),
70 Box::new(screens::recorder::RecorderScreen::new()),
71 Box::new(screens::import::ImportScreen::new()),
72 Box::new(screens::audit::AuditScreen::new()),
73 Box::new(screens::world_state::WorldStateScreen::new()),
74 Box::new(screens::contract_diff::ContractDiffScreen::new()),
75 Box::new(screens::federation::FederationScreen::new()),
76 Box::new(screens::behavioral_cloning::BehavioralCloningScreen::new()),
77 ];
78
79 let active_tab = if initial_tab < screens.len() {
80 initial_tab
81 } else {
82 0
83 };
84
85 Self {
86 config,
87 admin_url,
88 client,
89 screens,
90 active_tab,
91 show_help: false,
92 command_palette: CommandPalette::new(),
93 connected: false,
94 error_count: 0,
95 should_quit: false,
96 tab_bar_y: 1,
97 last_health_check: std::time::Instant::now(),
98 }
99 }
100
101 pub async fn run(mut self) -> Result<()> {
103 let mut terminal = tui::init()?;
104 let tick_rate = Duration::from_millis(250);
105 let mut events = EventHandler::new(tick_rate);
106 let tx = events.sender();
107
108 self.connected = self.client.ping().await;
110
111 loop {
112 terminal.draw(|frame| self.render(frame))?;
114
115 let event = events.next().await?;
117
118 match event {
119 Event::Key(key) => {
120 if key.modifiers.contains(KeyModifiers::CONTROL)
122 && matches!(key.code, KeyCode::Char('c'))
123 {
124 self.should_quit = true;
125 } else if self.command_palette.visible {
126 if let Some(action) = self.command_palette.handle_key(key) {
127 self.execute_palette_action(action);
128 }
129 } else if self.show_help {
130 if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
131 self.show_help = false;
132 }
133 } else {
134 let consumed = self.screens[self.active_tab].handle_key(key);
136 if !consumed {
137 self.handle_global_key(key);
138 }
139 }
140 }
141 Event::Tick => {
142 self.screens[self.active_tab].tick(&self.client, &tx);
143
144 if self.last_health_check.elapsed() >= Duration::from_secs(10) {
146 self.last_health_check = std::time::Instant::now();
147 let client = self.client.clone();
148 let health_tx = tx.clone();
149 tokio::spawn(async move {
150 let ok = client.ping().await;
151 if ok {
153 let _ = health_tx.send(Event::Data {
154 screen: "_health_check",
155 payload: String::new(),
156 });
157 } else {
158 let _ = health_tx.send(Event::ApiError {
159 screen: "_health_check",
160 message: "Server unreachable".into(),
161 });
162 }
163 });
164 }
165 }
166 Event::Data { screen, payload } => {
167 self.route_data(screen, &payload);
168 }
169 Event::ApiError { screen, message } => {
170 self.error_count = (self.error_count + 1).min(999);
171 self.route_error(screen, &message);
172 }
173 Event::LogLine(line) => {
174 self.connected = true;
175 if let Some(logs) = self.screens.get_mut(1) {
176 logs.push_log_line(line);
177 }
178 }
179 Event::Resize(_, _) => {}
180 Event::Mouse(mouse) => {
181 self.handle_mouse(mouse);
182 }
183 }
184
185 if self.should_quit {
186 break;
187 }
188 }
189
190 self.config.last_tab = Some(self.active_tab);
192 let _ = self.config.save();
193
194 tui::restore()?;
195 Ok(())
196 }
197
198 fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) {
199 if matches!(key.code, KeyCode::Char(':')) {
201 self.command_palette.open();
202 return;
203 }
204
205 if let Some(action) = keybindings::resolve(key) {
206 match action {
207 Action::Quit => self.should_quit = true,
208 Action::ToggleHelp => self.show_help = !self.show_help,
209 Action::NextTab => {
210 self.active_tab = (self.active_tab + 1) % self.screens.len();
211 }
212 Action::PrevTab => {
213 self.active_tab = if self.active_tab == 0 {
214 self.screens.len() - 1
215 } else {
216 self.active_tab - 1
217 };
218 }
219 Action::JumpTab(idx) => {
220 if idx < self.screens.len() {
221 self.active_tab = idx;
222 }
223 }
224 Action::Refresh => {
225 self.screens[self.active_tab].force_refresh();
226 }
227 _ => {}
228 }
229 }
230 }
231
232 fn execute_palette_action(&mut self, action: PaletteAction) {
233 match action {
234 PaletteAction::GoToScreen(idx) => {
235 if idx < self.screens.len() {
236 self.active_tab = idx;
237 }
238 }
239 PaletteAction::Refresh => {
240 self.screens[self.active_tab].force_refresh();
241 }
242 PaletteAction::ToggleHelp => {
243 self.show_help = !self.show_help;
244 }
245 PaletteAction::Quit => {
246 self.should_quit = true;
247 }
248 }
249 }
250
251 fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
252 use crossterm::event::{MouseButton, MouseEventKind};
253
254 match mouse.kind {
255 MouseEventKind::Down(MouseButton::Left) => {
256 if mouse.row == self.tab_bar_y {
258 self.handle_tab_click(mouse.column);
259 }
260 }
261 MouseEventKind::ScrollUp => {
262 let key = crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
264 self.screens[self.active_tab].handle_key(key);
265 }
266 MouseEventKind::ScrollDown => {
267 let key = crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
268 self.screens[self.active_tab].handle_key(key);
269 }
270 _ => {}
271 }
272 }
273
274 fn handle_tab_click(&mut self, column: u16) {
275 let mut x: u16 = 0;
277 for (i, screen) in self.screens.iter().enumerate() {
278 let title_len = u16::try_from(screen.title().len()).unwrap_or(u16::MAX);
279 let label_len: u16 = if i <= 9 {
280 title_len.saturating_add(4)
282 } else {
283 title_len.saturating_add(3)
285 };
286 if column >= x && column < x.saturating_add(label_len) {
287 self.active_tab = i;
288 return;
289 }
290 x = x.saturating_add(label_len);
291 }
292 }
293
294 fn route_data(&mut self, screen_key: &str, payload: &str) {
295 self.connected = true;
296
297 if screen_key == "_health_check" {
299 return;
300 }
301
302 for (i, sid) in ScreenId::ALL.iter().enumerate() {
303 if sid.data_key() == screen_key {
304 if let Some(screen) = self.screens.get_mut(i) {
305 screen.on_data(payload);
306 }
307 return;
308 }
309 }
310 }
311
312 fn route_error(&mut self, screen_key: &str, message: &str) {
313 if screen_key == "_health_check" {
316 self.connected = false;
317 return;
318 }
319
320 for (i, sid) in ScreenId::ALL.iter().enumerate() {
321 if sid.data_key() == screen_key {
322 if let Some(screen) = self.screens.get_mut(i) {
323 screen.on_error(message);
324 }
325 return;
326 }
327 }
328 }
329
330 fn render(&self, frame: &mut Frame) {
331 let area = frame.area();
332
333 if area.width < 80 || area.height < 24 {
335 let msg = Paragraph::new(format!(
336 "Terminal too small ({}x{}). Minimum: 80x24. Please resize.",
337 area.width, area.height
338 ))
339 .style(Style::default().fg(Theme::RED))
340 .alignment(Alignment::Center);
341 let centered = Rect {
342 y: area.height / 2,
343 height: 1,
344 ..area
345 };
346 frame.render_widget(msg, centered);
347 return;
348 }
349
350 let chunks = Layout::vertical([
351 Constraint::Length(2), Constraint::Min(0), Constraint::Length(1), ])
355 .split(area);
356
357 self.render_header(frame, chunks[0]);
358
359 let content_area = if let Some(err) = self.screens[self.active_tab].error() {
362 let parts =
363 Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(chunks[1]);
364 let banner = Paragraph::new(format!(" Error: {err}"))
365 .style(Style::default().fg(Theme::RED).bg(Theme::OVERLAY));
366 frame.render_widget(banner, parts[0]);
367 parts[1]
368 } else {
369 chunks[1]
370 };
371
372 self.screens[self.active_tab].render(frame, content_area);
373
374 status_bar::render(
375 frame,
376 chunks[2],
377 self.connected,
378 self.screens[self.active_tab].status_hint(),
379 self.error_count,
380 &self.admin_url,
381 );
382
383 if self.show_help {
384 help::render(frame);
385 }
386
387 if self.command_palette.visible {
388 self.command_palette.render(frame);
389 }
390 }
391
392 fn render_header(&self, frame: &mut Frame, area: Rect) {
393 let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
394
395 let conn_status = if self.connected {
397 "Connected"
398 } else {
399 "Disconnected"
400 };
401 let conn_style = if self.connected {
402 Theme::success()
403 } else {
404 Theme::error()
405 };
406 let title = Line::from(vec![
407 Span::styled(" MockForge TUI ", Theme::title()),
408 Span::styled(format!("v{}", env!("CARGO_PKG_VERSION")), Theme::dim()),
409 Span::raw(" "),
410 Span::styled(conn_status, conn_style),
411 Span::styled(format!(" {}", self.admin_url), Theme::dim()),
412 ]);
413 frame.render_widget(Paragraph::new(title).style(Theme::surface()), chunks[0]);
414
415 let mut tab_spans = Vec::new();
417 for (i, screen) in self.screens.iter().enumerate() {
418 let style = if i == self.active_tab {
419 Theme::tab_active()
420 } else {
421 Theme::tab_inactive()
422 };
423 let label = if i < 9 {
424 format!(" {}:{} ", i + 1, screen.title())
425 } else if i == 9 {
426 format!(" 0:{} ", screen.title())
427 } else {
428 format!(" {} ", screen.title())
429 };
430 tab_spans.push(Span::styled(label, style));
431 tab_spans.push(Span::raw(" "));
432 }
433 let tabs = Line::from(tab_spans);
434 frame.render_widget(Paragraph::new(tabs).style(Theme::base()), chunks[1]);
435 }
436}