bee_tui/components/
command_log.rs1use 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
23pub 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 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
116fn 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
129fn 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 assert_eq!(path_only("http://localhost:1633"), "http://localhost:1633");
159 }
160}