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()
100                .fg(t.accent)
101                .add_modifier(Modifier::BOLD),
102        ));
103
104        let widget = if lines.is_empty() {
105            Paragraph::new(Line::from(Span::styled(
106                "  (waiting for first request…)",
107                Style::default()
108                    .fg(t.dim)
109                    .add_modifier(Modifier::ITALIC),
110            )))
111            .block(block)
112        } else {
113            Paragraph::new(lines).block(block)
114        };
115        frame.render_widget(widget, area);
116        Ok(())
117    }
118}
119
120/// Per-method colour, lazygit-style.
121fn method_color(method: &str) -> Color {
122    match method {
123        "GET" => Color::Blue,
124        "POST" => Color::Green,
125        "PUT" => Color::Yellow,
126        "DELETE" => Color::Red,
127        "PATCH" => Color::Magenta,
128        "HEAD" => Color::Cyan,
129        _ => Color::White,
130    }
131}
132
133/// Drop scheme + host from the URL so the tail stays readable on
134/// 80-col terminals. `http://localhost:1633/health` → `/health`.
135fn path_only(url: &str) -> String {
136    if let Some(rest) = url.split_once("//").and_then(|(_, r)| r.split_once('/')) {
137        format!("/{}", rest.1)
138    } else {
139        url.to_string()
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn path_only_strips_scheme_and_host() {
149        assert_eq!(
150            path_only("http://localhost:1633/status"),
151            "/status".to_string()
152        );
153        assert_eq!(
154            path_only("https://bee.example.com:1633/stamps/abc"),
155            "/stamps/abc".to_string()
156        );
157    }
158
159    #[test]
160    fn path_only_handles_root_only() {
161        // No path component after host — return the URL unchanged.
162        assert_eq!(path_only("http://localhost:1633"), "http://localhost:1633");
163    }
164}