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};
21
22pub 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 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
118fn 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
131fn 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 assert_eq!(path_only("http://localhost:1633"), "http://localhost:1633");
161 }
162}