Skip to main content

mockforge_tui/
app.rs

1//! App state machine and main event loop.
2
3use 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
25/// Top-level application state.
26pub struct App {
27    config: TuiConfig,
28    /// Round 37 — list of admin URLs the user can rotate through with
29    /// `Ctrl-]` / `Ctrl-[`. Always has at least one entry; `admin_url`
30    /// is its 0th element. When a user only passes one server (the
31    /// default), `servers.len() == 1` and the rotation key is a no-op
32    /// so the surface stays unchanged.
33    servers: Vec<String>,
34    /// Index into `servers` of the currently-active admin URL. The
35    /// header tab bar renders an indicator (`[2/3]`) when
36    /// `servers.len() > 1`; on a single server the indicator is hidden.
37    active_server: usize,
38    /// The active server's admin URL (mirrors `servers[active_server]`
39    /// to keep existing render code untouched).
40    admin_url: String,
41    client: MockForgeClient,
42    /// Optional auth token, threaded into each per-server client.
43    /// `MockForgeClient::new` panics on a malformed base URL, but our
44    /// inputs come from clap (already validated as `String`) so this is
45    /// safe in practice. Stored so a server swap rebuilds the client
46    /// with the same token.
47    token: Option<String>,
48    screens: Vec<Box<dyn Screen>>,
49    active_tab: usize,
50    show_help: bool,
51    command_palette: CommandPalette,
52    connected: bool,
53    error_count: usize,
54    should_quit: bool,
55    /// Y-offset of the tab bar for mouse click detection.
56    tab_bar_y: u16,
57    /// Last time we checked connectivity.
58    last_health_check: std::time::Instant,
59}
60
61impl App {
62    /// Create the app from a config and optional auth token.
63    pub fn new(config: TuiConfig, token: Option<String>) -> Self {
64        Theme::init(config.is_light_theme());
65
66        // Round 37 — resolve the server rotation list from config.
67        // `all_admin_urls()` always returns at least one URL (the
68        // primary `admin_url`), so subsequent indexing is safe.
69        let servers = config.all_admin_urls();
70        let active_server = 0;
71        let admin_url = servers[active_server].clone();
72
73        let client =
74            MockForgeClient::new(admin_url.clone(), token.clone()).expect("failed to build client");
75
76        let initial_tab = config.last_tab.unwrap_or(0);
77
78        let screens: Vec<Box<dyn Screen>> = vec![
79            Box::new(screens::dashboard::DashboardScreen::new()),
80            Box::new(screens::logs::LogsScreen::new()),
81            Box::new(screens::routes::RoutesScreen::new()),
82            Box::new(screens::metrics::MetricsScreen::new()),
83            Box::new(screens::config::ConfigScreen::new()),
84            Box::new(screens::chaos::ChaosScreen::new()),
85            Box::new(screens::workspaces::WorkspacesScreen::new()),
86            Box::new(screens::plugins::PluginsScreen::new()),
87            Box::new(screens::fixtures::FixturesScreen::new()),
88            Box::new(screens::health::HealthScreen::new()),
89            Box::new(screens::smoke_tests::SmokeTestsScreen::new()),
90            Box::new(screens::time_travel::TimeTravelScreen::new()),
91            Box::new(screens::chains::ChainsScreen::new()),
92            // Conformance is intentionally placed before Verification —
93            // Verification's `Tab` is consumed to cycle internal fields,
94            // so plain Tab nav gets stuck on it. Order must match
95            // `ScreenId::ALL`; the `app_screens_match_screen_id_all`
96            // test asserts the lengths align.
97            Box::new(screens::conformance::ConformanceScreen::new()),
98            Box::new(screens::verification::VerificationScreen::new()),
99            Box::new(screens::analytics::AnalyticsScreen::new()),
100            Box::new(screens::recorder::RecorderScreen::new()),
101            Box::new(screens::import::ImportScreen::new()),
102            Box::new(screens::audit::AuditScreen::new()),
103            Box::new(screens::world_state::WorldStateScreen::new()),
104            Box::new(screens::contract_diff::ContractDiffScreen::new()),
105            Box::new(screens::federation::FederationScreen::new()),
106            Box::new(screens::behavioral_cloning::BehavioralCloningScreen::new()),
107        ];
108
109        // Invariant: every entry in `ScreenId::ALL` must have a matching
110        // boxed Screen at the same index. The header renders tabs from
111        // `self.screens` and routing looks up `self.screens[i]` keyed off
112        // `ScreenId::ALL[i]` — if these go out of sync, a tab silently
113        // disappears (regression first hit in v0.3.145: Conformance was
114        // added to `ScreenId::ALL` but the Box was missed here).
115        debug_assert_eq!(
116            screens.len(),
117            ScreenId::ALL.len(),
118            "screens vec must match ScreenId::ALL length"
119        );
120
121        let active_tab = if initial_tab < screens.len() {
122            initial_tab
123        } else {
124            0
125        };
126
127        Self {
128            config,
129            servers,
130            active_server,
131            admin_url,
132            client,
133            token,
134            screens,
135            active_tab,
136            show_help: false,
137            command_palette: CommandPalette::new(),
138            connected: false,
139            error_count: 0,
140            should_quit: false,
141            tab_bar_y: 1,
142            last_health_check: std::time::Instant::now(),
143        }
144    }
145
146    /// Round 37 — cycle to the next configured admin server. No-op
147    /// when only one server is in the rotation. The screens are NOT
148    /// reset on switch: each screen's next `tick()` will re-fetch
149    /// from the new admin URL, so the user sees the prior server's
150    /// cached data for up to one refresh-interval, then fresh data.
151    /// `step` is `+1` for next, `-1` for previous; any non-zero step
152    /// works (rotation is modular).
153    fn rotate_server(&mut self, step: isize) {
154        let n = self.servers.len();
155        if n <= 1 {
156            return;
157        }
158        let next = (self.active_server as isize + step).rem_euclid(n as isize) as usize;
159        self.active_server = next;
160        self.admin_url = self.servers[next].clone();
161        // Rebuild the client; MockForgeClient is cheap to construct
162        // (just stores the URL + token + reqwest::Client) and we want
163        // the rest of the app to keep treating `self.client` as the
164        // single active client without holding a Vec.
165        if let Ok(new_client) = MockForgeClient::new(self.admin_url.clone(), self.token.clone()) {
166            self.client = new_client;
167        }
168        // Force a re-ping on the new server so the header indicator
169        // updates without waiting for the next health-check tick.
170        self.connected = false;
171        // Backdate the last health check so the next tick re-pings the
172        // new server immediately instead of waiting for the next
173        // scheduled interval.
174        let one_hour = Duration::from_secs(3600);
175        self.last_health_check = std::time::Instant::now()
176            .checked_sub(one_hour)
177            .unwrap_or_else(std::time::Instant::now);
178    }
179}
180
181#[cfg(test)]
182mod server_rotation_tests {
183    use super::*;
184
185    fn cfg_with(extras: &[&str]) -> TuiConfig {
186        TuiConfig {
187            admin_url: "http://primary:9080".into(),
188            extra_servers: extras.iter().map(|s| s.to_string()).collect(),
189            ..TuiConfig::default()
190        }
191    }
192
193    #[test]
194    fn all_admin_urls_keeps_primary_first_and_dedupes() {
195        let cfg = cfg_with(&["http://b:9080", "http://primary:9080", "http://c:9080"]);
196        let urls = cfg.all_admin_urls();
197        assert_eq!(urls, vec!["http://primary:9080", "http://b:9080", "http://c:9080"]);
198    }
199
200    #[test]
201    fn all_admin_urls_drops_empty_entries() {
202        let cfg = cfg_with(&["", "http://b:9080", ""]);
203        let urls = cfg.all_admin_urls();
204        assert_eq!(urls, vec!["http://primary:9080", "http://b:9080"]);
205    }
206
207    #[test]
208    fn rotate_server_cycles_in_both_directions() {
209        let cfg = cfg_with(&["http://b:9080", "http://c:9080"]);
210        let mut app = App::new(cfg, None);
211        assert_eq!(app.active_server, 0);
212        assert_eq!(app.admin_url, "http://primary:9080");
213
214        app.rotate_server(1);
215        assert_eq!(app.active_server, 1);
216        assert_eq!(app.admin_url, "http://b:9080");
217
218        app.rotate_server(1);
219        assert_eq!(app.active_server, 2);
220        assert_eq!(app.admin_url, "http://c:9080");
221
222        // Wrap forward.
223        app.rotate_server(1);
224        assert_eq!(app.active_server, 0);
225
226        // Wrap backward.
227        app.rotate_server(-1);
228        assert_eq!(app.active_server, 2);
229        assert_eq!(app.admin_url, "http://c:9080");
230    }
231
232    #[test]
233    fn rotate_server_is_noop_on_single_server() {
234        let cfg = cfg_with(&[]);
235        let mut app = App::new(cfg, None);
236        let before = app.admin_url.clone();
237        app.rotate_server(1);
238        assert_eq!(app.admin_url, before);
239        assert_eq!(app.active_server, 0);
240    }
241}
242
243impl App {
244    /// Run the terminal UI event loop.
245    pub async fn run(mut self) -> Result<()> {
246        let mut terminal = tui::init()?;
247        let tick_rate = Duration::from_millis(250);
248        let mut events = EventHandler::new(tick_rate);
249        let tx = events.sender();
250
251        // Initial connectivity check.
252        self.connected = self.client.ping().await;
253
254        loop {
255            // Render.
256            terminal.draw(|frame| self.render(frame))?;
257
258            // Wait for next event.
259            let event = events.next().await?;
260
261            match event {
262                Event::Key(key) => {
263                    // Ctrl+C always quits.
264                    if key.modifiers.contains(KeyModifiers::CONTROL)
265                        && matches!(key.code, KeyCode::Char('c'))
266                    {
267                        self.should_quit = true;
268                    } else if self.command_palette.visible {
269                        if let Some(action) = self.command_palette.handle_key(key) {
270                            self.execute_palette_action(action);
271                        }
272                    } else if self.show_help {
273                        if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
274                            self.show_help = false;
275                        }
276                    } else {
277                        // Try screen-specific handling first.
278                        let consumed = self.screens[self.active_tab].handle_key(key);
279                        if !consumed {
280                            self.handle_global_key(key);
281                        }
282                    }
283                }
284                Event::Tick => {
285                    self.screens[self.active_tab].tick(&self.client, &tx);
286
287                    // Periodic connectivity check every 10 seconds.
288                    if self.last_health_check.elapsed() >= Duration::from_secs(10) {
289                        self.last_health_check = std::time::Instant::now();
290                        let client = self.client.clone();
291                        let health_tx = tx.clone();
292                        tokio::spawn(async move {
293                            let ok = client.ping().await;
294                            // Use a special screen key for health check routing.
295                            if ok {
296                                let _ = health_tx.send(Event::Data {
297                                    screen: "_health_check",
298                                    payload: String::new(),
299                                });
300                            } else {
301                                let _ = health_tx.send(Event::ApiError {
302                                    screen: "_health_check",
303                                    message: "Server unreachable".into(),
304                                });
305                            }
306                        });
307                    }
308                }
309                Event::Data { screen, payload } => {
310                    self.route_data(screen, &payload);
311                }
312                Event::ApiError { screen, message } => {
313                    self.error_count = (self.error_count + 1).min(999);
314                    self.route_error(screen, &message);
315                }
316                Event::LogLine(line) => {
317                    self.connected = true;
318                    if let Some(logs) = self.screens.get_mut(1) {
319                        logs.push_log_line(line);
320                    }
321                }
322                Event::Resize(_, _) => {}
323                Event::Mouse(mouse) => {
324                    self.handle_mouse(mouse);
325                }
326            }
327
328            if self.should_quit {
329                break;
330            }
331        }
332
333        // Save last-used tab to config file (best-effort).
334        self.config.last_tab = Some(self.active_tab);
335        let _ = self.config.save();
336
337        tui::restore()?;
338        Ok(())
339    }
340
341    fn handle_global_key(&mut self, key: crossterm::event::KeyEvent) {
342        // `:` opens the command palette (not in keybindings since it's a modal trigger).
343        if matches!(key.code, KeyCode::Char(':')) {
344            self.command_palette.open();
345            return;
346        }
347
348        if let Some(action) = keybindings::resolve(key) {
349            match action {
350                Action::Quit => self.should_quit = true,
351                Action::ToggleHelp => self.show_help = !self.show_help,
352                Action::NextTab => {
353                    self.active_tab = (self.active_tab + 1) % self.screens.len();
354                }
355                Action::PrevTab => {
356                    self.active_tab = if self.active_tab == 0 {
357                        self.screens.len() - 1
358                    } else {
359                        self.active_tab - 1
360                    };
361                }
362                Action::JumpTab(idx) => {
363                    if idx < self.screens.len() {
364                        self.active_tab = idx;
365                    }
366                }
367                Action::Refresh => {
368                    self.screens[self.active_tab].force_refresh();
369                }
370                Action::NextServer => self.rotate_server(1),
371                Action::PrevServer => self.rotate_server(-1),
372                _ => {}
373            }
374        }
375    }
376
377    fn execute_palette_action(&mut self, action: PaletteAction) {
378        match action {
379            PaletteAction::GoToScreen(idx) => {
380                if idx < self.screens.len() {
381                    self.active_tab = idx;
382                }
383            }
384            PaletteAction::Refresh => {
385                self.screens[self.active_tab].force_refresh();
386            }
387            PaletteAction::ToggleHelp => {
388                self.show_help = !self.show_help;
389            }
390            PaletteAction::Quit => {
391                self.should_quit = true;
392            }
393        }
394    }
395
396    fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
397        use crossterm::event::{MouseButton, MouseEventKind};
398
399        match mouse.kind {
400            MouseEventKind::Down(MouseButton::Left) => {
401                // Check if click is on the tab bar row.
402                if mouse.row == self.tab_bar_y {
403                    self.handle_tab_click(mouse.column);
404                }
405            }
406            MouseEventKind::ScrollUp => {
407                // Forward as Up key to the active screen.
408                let key = crossterm::event::KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
409                self.screens[self.active_tab].handle_key(key);
410            }
411            MouseEventKind::ScrollDown => {
412                let key = crossterm::event::KeyEvent::new(KeyCode::Down, KeyModifiers::NONE);
413                self.screens[self.active_tab].handle_key(key);
414            }
415            _ => {}
416        }
417    }
418
419    fn handle_tab_click(&mut self, column: u16) {
420        // Calculate tab boundaries based on rendered tab labels.
421        let mut x: u16 = 0;
422        for (i, screen) in self.screens.iter().enumerate() {
423            let title_len = u16::try_from(screen.title().len()).unwrap_or(u16::MAX);
424            let label_len: u16 = if i <= 9 {
425                // " N:Title " + " " separator
426                title_len.saturating_add(4)
427            } else {
428                // " Title " + " "
429                title_len.saturating_add(3)
430            };
431            if column >= x && column < x.saturating_add(label_len) {
432                self.active_tab = i;
433                return;
434            }
435            x = x.saturating_add(label_len);
436        }
437    }
438
439    fn route_data(&mut self, screen_key: &str, payload: &str) {
440        self.connected = true;
441
442        // Internal health check — not routed to any screen.
443        if screen_key == "_health_check" {
444            return;
445        }
446
447        for (i, sid) in ScreenId::ALL.iter().enumerate() {
448            if sid.data_key() == screen_key {
449                if let Some(screen) = self.screens.get_mut(i) {
450                    screen.on_data(payload);
451                }
452                return;
453            }
454        }
455    }
456
457    fn route_error(&mut self, screen_key: &str, message: &str) {
458        // Internal health check failure — mark disconnected but don't
459        // propagate to any screen.
460        if screen_key == "_health_check" {
461            self.connected = false;
462            return;
463        }
464
465        for (i, sid) in ScreenId::ALL.iter().enumerate() {
466            if sid.data_key() == screen_key {
467                if let Some(screen) = self.screens.get_mut(i) {
468                    screen.on_error(message);
469                }
470                return;
471            }
472        }
473    }
474
475    fn render(&self, frame: &mut Frame) {
476        let area = frame.area();
477
478        // Minimum terminal size check (80x24).
479        if area.width < 80 || area.height < 24 {
480            let msg = Paragraph::new(format!(
481                "Terminal too small ({}x{}). Minimum: 80x24. Please resize.",
482                area.width, area.height
483            ))
484            .style(Style::default().fg(Theme::RED))
485            .alignment(Alignment::Center);
486            let centered = Rect {
487                y: area.height / 2,
488                height: 1,
489                ..area
490            };
491            frame.render_widget(msg, centered);
492            return;
493        }
494
495        let chunks = Layout::vertical([
496            Constraint::Length(2), // title bar + tabs
497            Constraint::Min(0),    // main content
498            Constraint::Length(1), // status bar
499        ])
500        .split(area);
501
502        self.render_header(frame, chunks[0]);
503
504        // Error banner: show a persistent 1-line banner when the active screen
505        // has an error, while still rendering data underneath.
506        let content_area = if let Some(err) = self.screens[self.active_tab].error() {
507            let parts =
508                Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(chunks[1]);
509            let banner = Paragraph::new(format!(" Error: {err}"))
510                .style(Style::default().fg(Theme::RED).bg(Theme::OVERLAY));
511            frame.render_widget(banner, parts[0]);
512            parts[1]
513        } else {
514            chunks[1]
515        };
516
517        self.screens[self.active_tab].render(frame, content_area);
518
519        status_bar::render(
520            frame,
521            chunks[2],
522            self.connected,
523            self.screens[self.active_tab].status_hint(),
524            self.error_count,
525            &self.admin_url,
526        );
527
528        if self.show_help {
529            help::render(frame);
530        }
531
532        if self.command_palette.visible {
533            self.command_palette.render(frame);
534        }
535    }
536
537    fn render_header(&self, frame: &mut Frame, area: Rect) {
538        let chunks = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).split(area);
539
540        // Title bar
541        let conn_status = if self.connected {
542            "Connected"
543        } else {
544            "Disconnected"
545        };
546        let conn_style = if self.connected {
547            Theme::success()
548        } else {
549            Theme::error()
550        };
551        // Round 37 — when more than one server is in the rotation,
552        // surface the active index (`[2/3]`) before the URL so the
553        // user always sees which server's data they are looking at.
554        // Single-server runs keep the original layout.
555        let server_indicator = if self.servers.len() > 1 {
556            format!("  [{}/{}] {}", self.active_server + 1, self.servers.len(), self.admin_url)
557        } else {
558            format!("  {}", self.admin_url)
559        };
560        let title = Line::from(vec![
561            Span::styled(" MockForge TUI ", Theme::title()),
562            Span::styled(format!("v{}", env!("CARGO_PKG_VERSION")), Theme::dim()),
563            Span::raw("  "),
564            Span::styled(conn_status, conn_style),
565            Span::styled(server_indicator, Theme::dim()),
566        ]);
567        frame.render_widget(Paragraph::new(title).style(Theme::surface()), chunks[0]);
568
569        // Tab bar
570        let mut tab_spans = Vec::new();
571        for (i, screen) in self.screens.iter().enumerate() {
572            let style = if i == self.active_tab {
573                Theme::tab_active()
574            } else {
575                Theme::tab_inactive()
576            };
577            let label = if i < 9 {
578                format!(" {}:{} ", i + 1, screen.title())
579            } else if i == 9 {
580                format!(" 0:{} ", screen.title())
581            } else {
582                format!(" {} ", screen.title())
583            };
584            tab_spans.push(Span::styled(label, style));
585            tab_spans.push(Span::raw(" "));
586        }
587        let tabs = Line::from(tab_spans);
588        frame.render_widget(Paragraph::new(tabs).style(Theme::base()), chunks[1]);
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::config::TuiConfig;
596
597    /// Regression test for v0.3.145 → v0.3.146 hotfix: every entry in
598    /// `ScreenId::ALL` must have a corresponding `Box<dyn Screen>` in
599    /// `App::new`. When they go out of sync, the missing tab silently
600    /// disappears from the header (`render_header` iterates `self.screens`
601    /// while routing iterates `ScreenId::ALL`).
602    #[test]
603    fn app_screens_match_screen_id_all() {
604        let app = App::new(TuiConfig::default(), None);
605        assert_eq!(
606            app.screens.len(),
607            ScreenId::ALL.len(),
608            "App::new must instantiate a Screen for every ScreenId::ALL entry"
609        );
610    }
611}