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