Skip to main content

bee_tui/components/
command_log.rs

1//! S10 — Command-log pane (`docs/PLAN.md` § 8.S10).
2//!
3//! Subscribes to the process-wide [`crate::log_capture::LogCapture`]
4//! installed by [`crate::logging::init`] and renders the most recent
5//! `bee::http` events in a lazygit-style append-only tail. This is
6//! the cockpit's trust anchor and live tutorial — operators see the
7//! actual HTTP request being made for every gauge they're watching.
8
9use color_eyre::Result;
10use ratatui::{
11    Frame,
12    layout::Rect,
13    style::{Color, Modifier, Style},
14    text::{Line, Span},
15    widgets::{Block, Borders, Paragraph},
16};
17
18use super::Component;
19use crate::action::Action;
20use crate::log_capture::{LogCapture, LogEntry};
21
22/// S10 component. Cheap to construct — the actual buffer is the
23/// shared [`LogCapture`] handle.
24pub struct CommandLog {
25    capture: Option<LogCapture>,
26    entries: Vec<LogEntry>,
27}
28
29impl CommandLog {
30    pub fn new(capture: Option<LogCapture>) -> Self {
31        Self {
32            capture,
33            entries: Vec::new(),
34        }
35    }
36
37    fn pull_latest(&mut self) {
38        if let Some(c) = &self.capture {
39            self.entries = c.snapshot();
40        }
41    }
42}
43
44impl Component for CommandLog {
45    fn update(&mut self, action: Action) -> Result<Option<Action>> {
46        if matches!(action, Action::Tick) {
47            self.pull_latest();
48        }
49        Ok(None)
50    }
51
52    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
53        // Render the last (area.height - 2) entries — minus 2 for
54        // the top + bottom borders of the framing block. Newest at
55        // the bottom (append-only tail).
56        let inner_h = area.height.saturating_sub(2) as usize;
57        let total = self.entries.len();
58        let start = total.saturating_sub(inner_h);
59        let lines: Vec<Line> = self.entries[start..]
60            .iter()
61            .map(|e| {
62                let status_style = match e.status {
63                    Some(s) if (200..300).contains(&s) => Style::default().fg(Color::Green),
64                    Some(s) if (300..400).contains(&s) => Style::default().fg(Color::Cyan),
65                    Some(s) if (400..500).contains(&s) => Style::default().fg(Color::Yellow),
66                    Some(_) => Style::default().fg(Color::Red),
67                    None => Style::default().fg(Color::DarkGray),
68                };
69                let method_style = Style::default()
70                    .fg(method_color(&e.method))
71                    .add_modifier(Modifier::BOLD);
72                let elapsed = e
73                    .elapsed_ms
74                    .map(|ms| format!("{ms:>4}ms"))
75                    .unwrap_or_else(|| "    —".into());
76                let path = path_only(&e.url);
77                Line::from(vec![
78                    Span::styled(format!("{} ", e.ts), Style::default().fg(Color::DarkGray)),
79                    Span::styled(format!("{:<5}", e.method), method_style),
80                    Span::raw(" "),
81                    Span::raw(path),
82                    Span::raw("  "),
83                    Span::styled(
84                        e.status
85                            .map(|s| s.to_string())
86                            .unwrap_or_else(|| "—".into()),
87                        status_style,
88                    ),
89                    Span::raw("  "),
90                    Span::styled(elapsed, Style::default().fg(Color::DarkGray)),
91                ])
92            })
93            .collect();
94
95        let block = Block::default().borders(Borders::ALL).title(Span::styled(
96            " bee::http ",
97            Style::default()
98                .fg(Color::Yellow)
99                .add_modifier(Modifier::BOLD),
100        ));
101
102        let widget = if lines.is_empty() {
103            Paragraph::new(Line::from(Span::styled(
104                "  (waiting for first request…)",
105                Style::default()
106                    .fg(Color::DarkGray)
107                    .add_modifier(Modifier::ITALIC),
108            )))
109            .block(block)
110        } else {
111            Paragraph::new(lines).block(block)
112        };
113        frame.render_widget(widget, area);
114        Ok(())
115    }
116}
117
118/// Per-method colour, lazygit-style.
119fn method_color(method: &str) -> Color {
120    match method {
121        "GET" => Color::Blue,
122        "POST" => Color::Green,
123        "PUT" => Color::Yellow,
124        "DELETE" => Color::Red,
125        "PATCH" => Color::Magenta,
126        "HEAD" => Color::Cyan,
127        _ => Color::White,
128    }
129}
130
131/// Drop scheme + host from the URL so the tail stays readable on
132/// 80-col terminals. `http://localhost:1633/health` → `/health`.
133fn path_only(url: &str) -> String {
134    if let Some(rest) = url.split_once("//").and_then(|(_, r)| r.split_once('/')) {
135        format!("/{}", rest.1)
136    } else {
137        url.to_string()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn path_only_strips_scheme_and_host() {
147        assert_eq!(
148            path_only("http://localhost:1633/status"),
149            "/status".to_string()
150        );
151        assert_eq!(
152            path_only("https://bee.example.com:1633/stamps/abc"),
153            "/stamps/abc".to_string()
154        );
155    }
156
157    #[test]
158    fn path_only_handles_root_only() {
159        // No path component after host — return the URL unchanged.
160        assert_eq!(path_only("http://localhost:1633"), "http://localhost:1633");
161    }
162}