Skip to main content

bee_tui/components/
log_pane.rs

1//! Tabbed bottom-pane that replaces the previous single-stream
2//! `bee::http` tail. Six tabs split the log space along two axes:
3//!
4//! 1. **Bee severity tabs** — Errors / Warning / Info / Debug. Filled
5//!    by parsing the supervised Bee node's stdout (increment 3).
6//! 2. **Bee HTTP tab** — the served-request log line filtered out of
7//!    the same Bee stream (increment 4).
8//! 3. **bee::http tab** — bee-tui's *own* outbound calls (the legacy
9//!    `CommandLog` view). Kept as a tab because it's still the trust
10//!    anchor for every gauge in the cockpit.
11//!
12//! Increment 2 (this file) ships the UI scaffolding: tab state
13//! machine, ring buffers per tab, height-clamping resize, and
14//! state persistence for the operator's last height + active tab.
15//! The four Bee-fed tabs render an "(awaiting bee log...)" placeholder
16//! until the supervisor wires real entries through.
17
18use std::collections::VecDeque;
19
20use color_eyre::Result;
21use ratatui::{
22    Frame,
23    layout::{Constraint, Layout, Rect},
24    style::{Color, Modifier, Style},
25    text::{Line, Span},
26    widgets::{Block, Borders, Paragraph},
27};
28
29use super::Component;
30use crate::action::Action;
31use crate::log_capture::{CockpitCapture, CockpitEntry, LogCapture, LogEntry};
32use crate::state::{LOG_PANE_MAX_HEIGHT, LOG_PANE_MIN_HEIGHT};
33use crate::theme;
34
35/// Capacity of each Bee-side tab's ring buffer. Generous enough to
36/// catch a burst of errors while staying memory-cheap.
37const BEE_TAB_RING_CAPACITY: usize = 500;
38
39/// The six tabs, in display order. Order is `Errors → Debug` so the
40/// most operator-relevant severity is leftmost (matches reading
41/// direction).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum LogTab {
44    Errors,
45    Warning,
46    Info,
47    Debug,
48    BeeHttp,
49    SelfHttp,
50    /// Cockpit-internal events: anything bee-tui emits that isn't a
51    /// `bee::http` request — supervisor lifecycle, watch poll loops,
52    /// command-bar dispatches, etc.
53    Cockpit,
54}
55
56impl LogTab {
57    pub const ALL: [LogTab; 7] = [
58        LogTab::Errors,
59        LogTab::Warning,
60        LogTab::Info,
61        LogTab::Debug,
62        LogTab::BeeHttp,
63        LogTab::SelfHttp,
64        LogTab::Cockpit,
65    ];
66
67    /// Parse the kebab-case form persisted in `state.toml`. Unknown
68    /// strings → SelfHttp (the only tab guaranteed to have data
69    /// regardless of whether [bee] is configured).
70    pub fn from_kebab(s: &str) -> Self {
71        match s {
72            "errors" => Self::Errors,
73            "warning" => Self::Warning,
74            "info" => Self::Info,
75            "debug" => Self::Debug,
76            "bee-http" => Self::BeeHttp,
77            "self-http" => Self::SelfHttp,
78            "cockpit" => Self::Cockpit,
79            _ => Self::SelfHttp,
80        }
81    }
82
83    pub fn to_kebab(self) -> &'static str {
84        match self {
85            Self::Errors => "errors",
86            Self::Warning => "warning",
87            Self::Info => "info",
88            Self::Debug => "debug",
89            Self::BeeHttp => "bee-http",
90            Self::SelfHttp => "self-http",
91            Self::Cockpit => "cockpit",
92        }
93    }
94
95    /// Short label rendered in the tab strip.
96    pub fn label(self) -> &'static str {
97        match self {
98            Self::Errors => "Errors",
99            Self::Warning => "Warn",
100            Self::Info => "Info",
101            Self::Debug => "Debug",
102            Self::BeeHttp => "Bee HTTP",
103            Self::SelfHttp => "bee::http",
104            Self::Cockpit => "Cockpit",
105        }
106    }
107
108    /// Index into [`LogTab::ALL`].
109    fn index(self) -> usize {
110        Self::ALL.iter().position(|t| *t == self).unwrap_or(5)
111    }
112
113    fn from_index(i: usize) -> Self {
114        Self::ALL.get(i).copied().unwrap_or(Self::SelfHttp)
115    }
116}
117
118/// Single line on a Bee-side tab. The supervisor's log tailer
119/// (increment 3) builds these from parsed Bee log entries; for now
120/// the structure exists so the renderer + key handling can be
121/// validated end-to-end against synthetic inputs.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct BeeLogLine {
124    /// `time` field from the source log line, rendered verbatim.
125    pub timestamp: String,
126    /// `logger` field — usually `node/<subsystem>`.
127    pub logger: String,
128    /// `msg` field plus any extra key-value pairs the parser kept.
129    pub message: String,
130}
131
132/// Renderable row inside the active tab. Pure data so the renderer
133/// stays straightforward and snapshot tests can lock layout.
134pub enum LogRow<'a> {
135    Self_(&'a LogEntry),
136    Bee(&'a BeeLogLine),
137}
138
139/// Buffers feeding the Bee-side tabs. Each tab has its own ring
140/// buffer so a noisy debug stream never evicts a precious error line.
141#[derive(Debug, Default)]
142pub struct BeeLogBuffers {
143    pub errors: VecDeque<BeeLogLine>,
144    pub warning: VecDeque<BeeLogLine>,
145    pub info: VecDeque<BeeLogLine>,
146    pub debug: VecDeque<BeeLogLine>,
147    pub bee_http: VecDeque<BeeLogLine>,
148}
149
150impl BeeLogBuffers {
151    fn buffer_for(&self, tab: LogTab) -> Option<&VecDeque<BeeLogLine>> {
152        match tab {
153            LogTab::Errors => Some(&self.errors),
154            LogTab::Warning => Some(&self.warning),
155            LogTab::Info => Some(&self.info),
156            LogTab::Debug => Some(&self.debug),
157            LogTab::BeeHttp => Some(&self.bee_http),
158            LogTab::SelfHttp | LogTab::Cockpit => None,
159        }
160    }
161
162    /// Number of entries on a given tab. Used by the tab strip's
163    /// counter chips ("Errors 3" etc).
164    pub fn count(&self, tab: LogTab) -> usize {
165        self.buffer_for(tab).map(|b| b.len()).unwrap_or(0)
166    }
167}
168
169/// The component itself. Owns the tab state + ring buffers; reads
170/// the bee::http capture from a borrowed handle, same as the legacy
171/// CommandLog did.
172pub struct LogPane {
173    capture: Option<LogCapture>,
174    self_http_entries: Vec<LogEntry>,
175    cockpit_capture: Option<CockpitCapture>,
176    cockpit_entries: Vec<CockpitEntry>,
177    bee_buffers: BeeLogBuffers,
178    active_tab: LogTab,
179    /// Height in lines including the title strip + borders.
180    height: u16,
181    /// Set by [`spawn_active`] when bee-tui is the supervisor — toggles
182    /// placeholder text on the Bee-side tabs from "configure [bee]"
183    /// to "(awaiting first log line)".
184    spawn_active: bool,
185    /// Scroll offset for the active tab, in lines from the bottom.
186    /// 0 = auto-tail (default; latest entries at the bottom). When
187    /// non-zero, new entries arriving auto-bump the offset to keep
188    /// the visible window stable. Reset to 0 on tab switch.
189    scroll_offset: usize,
190    /// Horizontal scroll offset in characters. Bee log lines often
191    /// run past the pane width; this lets the operator pan right to
192    /// see the truncated tail. Reset on tab switch.
193    h_scroll_offset: u16,
194}
195
196impl LogPane {
197    pub fn new(capture: Option<LogCapture>, initial_tab: LogTab, initial_height: u16) -> Self {
198        Self {
199            capture,
200            self_http_entries: Vec::new(),
201            cockpit_capture: None,
202            cockpit_entries: Vec::new(),
203            bee_buffers: BeeLogBuffers::default(),
204            active_tab: initial_tab,
205            height: initial_height.clamp(LOG_PANE_MIN_HEIGHT, LOG_PANE_MAX_HEIGHT),
206            spawn_active: false,
207            scroll_offset: 0,
208            h_scroll_offset: 0,
209        }
210    }
211
212    /// Attach the cockpit-capture ring buffer so the Cockpit tab can
213    /// render events bee-tui itself emitted (everything that isn't
214    /// `bee::http`). Wired by [`App::new`] after
215    /// [`crate::logging::init`] has installed the capture.
216    pub fn set_cockpit_capture(&mut self, cap: CockpitCapture) {
217        self.cockpit_capture = Some(cap);
218    }
219
220    pub fn active_tab(&self) -> LogTab {
221        self.active_tab
222    }
223
224    pub fn height(&self) -> u16 {
225        self.height
226    }
227
228    /// Tell the pane bee-tui is acting as the supervisor (so the
229    /// placeholder changes from "configure [bee]" to "(awaiting first
230    /// log line)"). Called once at startup; cheap to call repeatedly.
231    pub fn set_spawn_active(&mut self, active: bool) {
232        self.spawn_active = active;
233    }
234
235    /// Cycle to the next tab (left → right, wrapping). Returns the
236    /// new active tab so callers can persist state without re-reading.
237    /// Resets the scroll offset — the new tab's content has nothing
238    /// to do with where we were on the old one.
239    pub fn next_tab(&mut self) -> LogTab {
240        let i = (self.active_tab.index() + 1) % LogTab::ALL.len();
241        self.active_tab = LogTab::from_index(i);
242        self.scroll_offset = 0;
243        self.h_scroll_offset = 0;
244        self.active_tab
245    }
246
247    /// Cycle to the previous tab (right → left, wrapping).
248    pub fn prev_tab(&mut self) -> LogTab {
249        let len = LogTab::ALL.len();
250        let i = (self.active_tab.index() + len - 1) % len;
251        self.active_tab = LogTab::from_index(i);
252        self.scroll_offset = 0;
253        self.h_scroll_offset = 0;
254        self.active_tab
255    }
256
257    /// Scroll the active tab up by `lines` (toward older entries).
258    /// Clamped at draw-time to the buffer length so the user can't
259    /// scroll past the top. `lines = 1` is the per-keystroke step;
260    /// callers can pass a larger value for page-scrolling.
261    pub fn scroll_up(&mut self, lines: usize) {
262        self.scroll_offset = self.scroll_offset.saturating_add(lines);
263    }
264
265    /// Scroll the active tab down by `lines` (toward newer entries /
266    /// the tail). Saturates at 0, which is the auto-tail state.
267    pub fn scroll_down(&mut self, lines: usize) {
268        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
269    }
270
271    /// Snap back to auto-tail mode (scroll_offset = 0). The pane
272    /// resumes following new entries as they arrive. Also resets
273    /// horizontal pan because operators usually want both axes
274    /// reset together when "going back to live."
275    pub fn resume_tail(&mut self) {
276        self.scroll_offset = 0;
277        self.h_scroll_offset = 0;
278    }
279
280    /// Pan the active tab right by `cols` characters. Bee log lines
281    /// often run past the pane width; ratatui truncates them at the
282    /// right edge by default so we add a horizontal scroll to let
283    /// operators read the tail.
284    pub fn scroll_right(&mut self, cols: u16) {
285        self.h_scroll_offset = self.h_scroll_offset.saturating_add(cols);
286    }
287
288    /// Pan the active tab left by `cols` characters. Saturates at 0
289    /// (the natural left edge).
290    pub fn scroll_left(&mut self, cols: u16) {
291        self.h_scroll_offset = self.h_scroll_offset.saturating_sub(cols);
292    }
293
294    /// Reset horizontal pan to the left edge without touching the
295    /// vertical scroll. Used when operators want the line start
296    /// back without leaving the historical window.
297    pub fn reset_h_scroll(&mut self) {
298        self.h_scroll_offset = 0;
299    }
300
301    /// Current horizontal scroll offset. Exposed for tests + the
302    /// title-strip indicator.
303    pub fn h_scroll_offset(&self) -> u16 {
304        self.h_scroll_offset
305    }
306
307    /// `true` when the pane is auto-tailing (the default state).
308    pub fn is_tailing(&self) -> bool {
309        self.scroll_offset == 0
310    }
311
312    /// Lines the pane is currently scrolled back from the tail.
313    /// Useful for rendering "[paused N]" indicators in the title.
314    pub fn scroll_offset(&self) -> usize {
315        self.scroll_offset
316    }
317
318    /// Grow the pane by one line. Returns the new height. No-op once
319    /// the cap is hit.
320    pub fn grow(&mut self) -> u16 {
321        self.height = (self.height + 1).min(LOG_PANE_MAX_HEIGHT);
322        self.height
323    }
324
325    /// Shrink the pane by one line. Returns the new height. No-op
326    /// once the floor is hit.
327    pub fn shrink(&mut self) -> u16 {
328        self.height = self.height.saturating_sub(1).max(LOG_PANE_MIN_HEIGHT);
329        self.height
330    }
331
332    /// Push a Bee log line to the appropriate tab. The supervisor's
333    /// log tailer calls this for each parsed line. Bounded — when
334    /// the ring is full the oldest entry is evicted.
335    ///
336    /// Scroll-stability: if this push lands on the *active* tab
337    /// AND we're currently scrolled back (not auto-tailing), bump
338    /// `scroll_offset` so the visible window stays anchored on the
339    /// same content rather than drifting upward as new lines push
340    /// the old ones up.
341    pub fn push_bee(&mut self, tab: LogTab, line: BeeLogLine) {
342        let buf = match tab {
343            LogTab::Errors => &mut self.bee_buffers.errors,
344            LogTab::Warning => &mut self.bee_buffers.warning,
345            LogTab::Info => &mut self.bee_buffers.info,
346            LogTab::Debug => &mut self.bee_buffers.debug,
347            LogTab::BeeHttp => &mut self.bee_buffers.bee_http,
348            LogTab::SelfHttp | LogTab::Cockpit => return, // capture-fed tabs
349        };
350        let was_full = buf.len() == BEE_TAB_RING_CAPACITY;
351        if was_full {
352            buf.pop_front();
353        }
354        buf.push_back(line);
355        // Stabilise the user's view if they're scrolled back on
356        // this same tab. When the ring is already full the eviction
357        // already shifted our content by 1, so the offset doesn't
358        // need to bump — the visible range stays in place.
359        if tab == self.active_tab && self.scroll_offset > 0 && !was_full {
360            self.scroll_offset = self.scroll_offset.saturating_add(1);
361        }
362    }
363
364    fn pull_self_http(&mut self) {
365        if let Some(c) = &self.capture {
366            let new = c.snapshot();
367            // Same stability logic as push_bee: when the operator
368            // is scrolled back on the SelfHttp tab and the capture
369            // grew by N entries, bump the offset by N so the visible
370            // range doesn't drift.
371            if self.active_tab == LogTab::SelfHttp && self.scroll_offset > 0 {
372                let delta = new.len().saturating_sub(self.self_http_entries.len());
373                if delta > 0 {
374                    self.scroll_offset = self.scroll_offset.saturating_add(delta);
375                }
376            }
377            self.self_http_entries = new;
378        }
379    }
380
381    fn pull_cockpit(&mut self) {
382        if let Some(c) = &self.cockpit_capture {
383            let new = c.snapshot();
384            if self.active_tab == LogTab::Cockpit && self.scroll_offset > 0 {
385                let delta = new.len().saturating_sub(self.cockpit_entries.len());
386                if delta > 0 {
387                    self.scroll_offset = self.scroll_offset.saturating_add(delta);
388                }
389            }
390            self.cockpit_entries = new;
391        }
392    }
393}
394
395impl Component for LogPane {
396    fn update(&mut self, action: Action) -> Result<Option<Action>> {
397        if matches!(action, Action::Tick) {
398            self.pull_self_http();
399            self.pull_cockpit();
400        }
401        Ok(None)
402    }
403
404    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
405        let t = theme::active();
406        let active = self.active_tab;
407
408        // Clamp the scroll offset against what the active tab can
409        // actually scroll. Pane content area excludes top + bottom
410        // borders; we approximate from the outer area here.
411        let content_h = (area.height as usize).saturating_sub(2);
412        let total_lines = self.active_tab_total_lines();
413        let max_offset = total_lines.saturating_sub(content_h);
414        if self.scroll_offset > max_offset {
415            self.scroll_offset = max_offset;
416        }
417
418        let block = Block::default().borders(Borders::ALL).title(tab_title_line(
419            active,
420            &self.bee_buffers,
421            self.scroll_offset,
422            self.h_scroll_offset,
423            t,
424        ));
425        let inner = block.inner(area);
426        frame.render_widget(block, area);
427
428        let content_area = Layout::vertical([Constraint::Min(0)])
429            .split(inner)
430            .first()
431            .copied()
432            .unwrap_or(inner);
433
434        let lines: Vec<Line> = match active {
435            LogTab::SelfHttp => render_self_http(&self.self_http_entries, t),
436            LogTab::Cockpit => render_cockpit(&self.cockpit_entries, t),
437            tab => render_bee_tab(&self.bee_buffers, tab, self.spawn_active, t),
438        };
439
440        // Pick the visible window: tail semantics + scroll offset.
441        // - tailing (offset = 0): show the last `render_h` lines.
442        // - scrolled back: show [end-render_h .. end), where
443        //   end = total - offset.
444        let render_h = content_area.height as usize;
445        let visible: Vec<Line> = if lines.len() > render_h {
446            let end = lines.len().saturating_sub(self.scroll_offset);
447            let start = end.saturating_sub(render_h);
448            lines.into_iter().skip(start).take(end - start).collect()
449        } else {
450            lines
451        };
452
453        // Vertical position is already encoded by the visible
454        // window slice; use ratatui's scroll() for the horizontal
455        // axis only. (Mixing scroll() with our slice-windowed
456        // visible vec works because the slice is what we want to
457        // render — scroll() merely shifts each rendered line left
458        // by `h_scroll_offset` columns.)
459        frame.render_widget(
460            Paragraph::new(visible).scroll((0, self.h_scroll_offset)),
461            content_area,
462        );
463        Ok(())
464    }
465}
466
467impl LogPane {
468    /// Number of payload lines the active tab currently has. Used
469    /// by `draw()` to clamp the scroll offset and by tests.
470    pub fn active_tab_total_lines(&self) -> usize {
471        match self.active_tab {
472            LogTab::SelfHttp => self.self_http_entries.len(),
473            LogTab::Cockpit => self.cockpit_entries.len(),
474            tab => self.bee_buffers.count(tab),
475        }
476    }
477}
478
479/// Build the `[Errors 3] [Warn 0] [Info 247] [Debug 1.2k] [Bee HTTP] [bee::http]`
480/// title strip with the active tab highlighted and counters from the
481/// per-tab buffers. Counters above 999 collapse to `1.2k` style so
482/// the strip fits an 80-column terminal.
483fn tab_title_line<'a>(
484    active: LogTab,
485    bufs: &BeeLogBuffers,
486    scroll_offset: usize,
487    h_scroll_offset: u16,
488    t: &theme::Theme,
489) -> Line<'a> {
490    let mut spans: Vec<Span> = Vec::with_capacity(LogTab::ALL.len() * 2 + 2);
491    spans.push(Span::raw(" "));
492    for tab in LogTab::ALL {
493        let count = bufs.count(tab);
494        // SelfHttp + Cockpit don't have BeeLogBuffer counts (their
495        // payload comes from the in-process capture buffers, not the
496        // supervisor's tail), so render the label without a count.
497        let label = if count == 0 || matches!(tab, LogTab::SelfHttp | LogTab::Cockpit) {
498            format!(" {} ", tab.label())
499        } else {
500            format!(" {} {} ", tab.label(), human_count(count))
501        };
502        let style = if tab == active {
503            Style::default()
504                .fg(t.tab_active_fg)
505                .bg(t.tab_active_bg)
506                .add_modifier(Modifier::BOLD)
507        } else {
508            tab_severity_color(tab, t)
509        };
510        spans.push(Span::styled(label, style));
511        spans.push(Span::raw(" "));
512    }
513    // "paused N ↑" indicator when the operator has scrolled back.
514    // Bright warn-yellow so it's impossible to miss that the pane
515    // is no longer auto-tailing.
516    if scroll_offset > 0 {
517        spans.push(Span::styled(
518            format!(" paused {scroll_offset} ↑ "),
519            Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
520        ));
521    }
522    if h_scroll_offset > 0 {
523        // Sibling indicator to "paused N ↑" — surfaces horizontal
524        // pan state so an operator who walked away and came back
525        // sees why their log lines look chopped on the left.
526        spans.push(Span::styled(
527            format!(" → {h_scroll_offset} "),
528            Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
529        ));
530    }
531    Line::from(spans)
532}
533
534/// Subtle per-tab tint on the inactive label so Errors stand out
535/// even when the operator is on a different tab.
536fn tab_severity_color(tab: LogTab, t: &theme::Theme) -> Style {
537    match tab {
538        LogTab::Errors => Style::default().fg(t.fail),
539        LogTab::Warning => Style::default().fg(t.warn),
540        LogTab::Info => Style::default().fg(t.info),
541        LogTab::Debug => Style::default().fg(t.dim),
542        LogTab::BeeHttp => Style::default().fg(t.accent),
543        LogTab::SelfHttp => Style::default().fg(t.dim),
544        LogTab::Cockpit => Style::default().fg(t.accent),
545    }
546}
547
548fn human_count(n: usize) -> String {
549    if n < 1_000 {
550        n.to_string()
551    } else if n < 1_000_000 {
552        format!("{:.1}k", n as f64 / 1000.0)
553    } else {
554        format!("{:.1}m", n as f64 / 1_000_000.0)
555    }
556}
557
558fn render_cockpit<'a>(entries: &'a [CockpitEntry], t: &theme::Theme) -> Vec<Line<'a>> {
559    if entries.is_empty() {
560        return vec![Line::from(Span::styled(
561            "  (no cockpit-internal events captured yet)",
562            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
563        ))];
564    }
565    entries.iter().map(|e| cockpit_line(e, t)).collect()
566}
567
568fn cockpit_line<'a>(e: &'a CockpitEntry, t: &theme::Theme) -> Line<'a> {
569    let level_style = match e.level.as_str() {
570        "ERROR" => Style::default().fg(t.fail).add_modifier(Modifier::BOLD),
571        "WARN" => Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
572        "INFO" => Style::default().fg(t.info),
573        _ => Style::default().fg(t.dim),
574    };
575    Line::from(vec![
576        Span::styled(format!("{} ", e.ts), Style::default().fg(t.dim)),
577        Span::styled(format!("{:<5}", e.level), level_style),
578        Span::raw(" "),
579        Span::styled(
580            format!("{:<22}", trim_target(&e.target)),
581            Style::default().fg(t.accent),
582        ),
583        Span::raw("  "),
584        Span::raw(e.message.clone()),
585    ])
586}
587
588/// Drop the leading `bee_tui::` prefix on cockpit-event targets so
589/// the rendered line stays under 80 columns. `bee_tui::watch::peers`
590/// → `watch::peers`.
591fn trim_target(target: &str) -> &str {
592    target.strip_prefix("bee_tui::").unwrap_or(target)
593}
594
595fn render_self_http<'a>(entries: &'a [LogEntry], t: &theme::Theme) -> Vec<Line<'a>> {
596    if entries.is_empty() {
597        return vec![Line::from(Span::styled(
598            "  (waiting for first request…)",
599            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
600        ))];
601    }
602    entries.iter().map(|e| self_http_line(e, t)).collect()
603}
604
605fn render_bee_tab<'a>(
606    bufs: &'a BeeLogBuffers,
607    tab: LogTab,
608    spawn_active: bool,
609    t: &theme::Theme,
610) -> Vec<Line<'a>> {
611    let buf = match bufs.buffer_for(tab) {
612        Some(b) => b,
613        None => return Vec::new(),
614    };
615    if buf.is_empty() {
616        let msg = if spawn_active {
617            "  (awaiting bee log entries on this severity…)"
618        } else {
619            "  (no bee child — set [bee] in config or pass --bee-bin / --bee-config)"
620        };
621        return vec![Line::from(Span::styled(
622            msg,
623            Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
624        ))];
625    }
626    buf.iter()
627        .map(|line| {
628            Line::from(vec![
629                Span::styled(format!("{} ", line.timestamp), Style::default().fg(t.dim)),
630                Span::styled(
631                    format!("{:<22}", line.logger),
632                    Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
633                ),
634                Span::raw("  "),
635                Span::raw(line.message.clone()),
636            ])
637        })
638        .collect()
639}
640
641fn self_http_line<'a>(e: &'a LogEntry, t: &theme::Theme) -> Line<'a> {
642    let status_style = match e.status {
643        Some(s) if (200..300).contains(&s) => Style::default().fg(t.pass),
644        Some(s) if (300..400).contains(&s) => Style::default().fg(t.info),
645        Some(s) if (400..500).contains(&s) => Style::default().fg(t.warn),
646        Some(_) => Style::default().fg(t.fail),
647        None => Style::default().fg(t.dim),
648    };
649    let method_style = Style::default()
650        .fg(method_color(&e.method))
651        .add_modifier(Modifier::BOLD);
652    let elapsed = e
653        .elapsed_ms
654        .map(|ms| format!("{ms:>4}ms"))
655        .unwrap_or_else(|| "    —".into());
656    let path = path_only(&e.url);
657    Line::from(vec![
658        Span::styled(format!("{} ", e.ts), Style::default().fg(t.dim)),
659        Span::styled(format!("{:<5}", e.method), method_style),
660        Span::raw(" "),
661        Span::raw(path),
662        Span::raw("  "),
663        Span::styled(
664            e.status
665                .map(|s| s.to_string())
666                .unwrap_or_else(|| "—".into()),
667            status_style,
668        ),
669        Span::raw("  "),
670        Span::styled(elapsed, Style::default().fg(t.dim)),
671    ])
672}
673
674/// Per-method colour, lazygit-style. Same palette as the legacy
675/// CommandLog so the bee::http tab keeps its identity after the
676/// move into the tabbed pane.
677fn method_color(method: &str) -> Color {
678    match method {
679        "GET" => Color::Blue,
680        "POST" => Color::Green,
681        "PUT" => Color::Yellow,
682        "DELETE" => Color::Red,
683        "PATCH" => Color::Magenta,
684        "HEAD" => Color::Cyan,
685        _ => Color::White,
686    }
687}
688
689/// Drop scheme + host from the URL so the tail stays readable on
690/// 80-col terminals. `http://localhost:1633/health` → `/health`.
691/// Matches the legacy CommandLog implementation; tests live here too.
692fn path_only(url: &str) -> String {
693    if let Some(rest) = url.split_once("//").and_then(|(_, r)| r.split_once('/')) {
694        format!("/{}", rest.1)
695    } else {
696        url.to_string()
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn next_tab_wraps() {
706        let mut pane = LogPane::new(None, LogTab::Errors, 10);
707        for expected in [
708            LogTab::Warning,
709            LogTab::Info,
710            LogTab::Debug,
711            LogTab::BeeHttp,
712            LogTab::SelfHttp,
713            LogTab::Cockpit,
714            LogTab::Errors,
715        ] {
716            assert_eq!(pane.next_tab(), expected);
717        }
718    }
719
720    #[test]
721    fn prev_tab_wraps() {
722        let mut pane = LogPane::new(None, LogTab::Errors, 10);
723        for expected in [
724            LogTab::Cockpit,
725            LogTab::SelfHttp,
726            LogTab::BeeHttp,
727            LogTab::Debug,
728            LogTab::Info,
729            LogTab::Warning,
730            LogTab::Errors,
731        ] {
732            assert_eq!(pane.prev_tab(), expected);
733        }
734    }
735
736    #[test]
737    fn grow_clamps_at_max() {
738        let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MAX_HEIGHT - 1);
739        pane.grow();
740        assert_eq!(pane.height(), LOG_PANE_MAX_HEIGHT);
741        // Further grows are no-ops.
742        pane.grow();
743        pane.grow();
744        assert_eq!(pane.height(), LOG_PANE_MAX_HEIGHT);
745    }
746
747    #[test]
748    fn shrink_clamps_at_min() {
749        let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT + 1);
750        pane.shrink();
751        assert_eq!(pane.height(), LOG_PANE_MIN_HEIGHT);
752        pane.shrink();
753        pane.shrink();
754        assert_eq!(pane.height(), LOG_PANE_MIN_HEIGHT);
755    }
756
757    #[test]
758    fn fresh_pane_is_tailing() {
759        let pane = LogPane::new(None, LogTab::Errors, 10);
760        assert!(pane.is_tailing());
761        assert_eq!(pane.scroll_offset(), 0);
762    }
763
764    #[test]
765    fn scroll_up_disables_tail_and_remembers_offset() {
766        let mut pane = LogPane::new(None, LogTab::Errors, 10);
767        pane.scroll_up(3);
768        assert!(!pane.is_tailing());
769        assert_eq!(pane.scroll_offset(), 3);
770        pane.scroll_up(2);
771        assert_eq!(pane.scroll_offset(), 5);
772    }
773
774    #[test]
775    fn scroll_down_eventually_resumes_tail() {
776        let mut pane = LogPane::new(None, LogTab::Errors, 10);
777        pane.scroll_up(5);
778        pane.scroll_down(2);
779        assert_eq!(pane.scroll_offset(), 3);
780        // Saturating-sub: scrolling down past 0 snaps to tail.
781        pane.scroll_down(100);
782        assert_eq!(pane.scroll_offset(), 0);
783        assert!(pane.is_tailing());
784    }
785
786    #[test]
787    fn resume_tail_resets_offset() {
788        let mut pane = LogPane::new(None, LogTab::Errors, 10);
789        pane.scroll_up(7);
790        pane.resume_tail();
791        assert!(pane.is_tailing());
792    }
793
794    #[test]
795    fn tab_switch_resets_scroll_offset() {
796        // A scroll offset on tab A makes no sense on tab B — different
797        // ring buffer, different content. Reset on switch.
798        let mut pane = LogPane::new(None, LogTab::Errors, 10);
799        pane.scroll_up(4);
800        pane.next_tab();
801        assert_eq!(pane.scroll_offset(), 0);
802        assert!(pane.is_tailing());
803        // Same on prev_tab.
804        pane.scroll_up(4);
805        pane.prev_tab();
806        assert_eq!(pane.scroll_offset(), 0);
807    }
808
809    #[test]
810    fn push_bee_bumps_offset_for_active_tab_when_scrolled() {
811        // Scroll-back stability: when the operator is scrolled up
812        // and a new entry lands on the same tab, the offset bumps
813        // so the visible window stays anchored on the same content.
814        let mut pane = LogPane::new(None, LogTab::Errors, 10);
815        pane.push_bee(LogTab::Errors, line("err1"));
816        pane.push_bee(LogTab::Errors, line("err2"));
817        pane.scroll_up(2);
818        pane.push_bee(LogTab::Errors, line("err3"));
819        // Offset went from 2 → 3 to compensate for the new entry
820        // shifting the window's relative position.
821        assert_eq!(pane.scroll_offset(), 3);
822    }
823
824    #[test]
825    fn push_bee_doesnt_bump_offset_when_tailing() {
826        // While tailing (offset = 0) the pane should keep tailing
827        // without spuriously paging into "paused" mode.
828        let mut pane = LogPane::new(None, LogTab::Errors, 10);
829        for i in 0..5 {
830            pane.push_bee(LogTab::Errors, line(&format!("e{i}")));
831        }
832        assert_eq!(pane.scroll_offset(), 0);
833        assert!(pane.is_tailing());
834    }
835
836    #[test]
837    fn push_bee_doesnt_bump_offset_for_inactive_tab() {
838        // Activity on a different tab shouldn't move the operator's
839        // anchor on the one they're reading.
840        let mut pane = LogPane::new(None, LogTab::Errors, 10);
841        pane.push_bee(LogTab::Errors, line("err1"));
842        pane.scroll_up(1);
843        let before = pane.scroll_offset();
844        pane.push_bee(LogTab::Debug, line("dbg1"));
845        assert_eq!(pane.scroll_offset(), before);
846    }
847
848    fn line(msg: &str) -> BeeLogLine {
849        BeeLogLine {
850            timestamp: "t".into(),
851            logger: "node/test".into(),
852            message: msg.into(),
853        }
854    }
855
856    #[test]
857    fn ring_capacity_is_enforced() {
858        let mut pane = LogPane::new(None, LogTab::Debug, 10);
859        // Push 600 entries — the ring should keep the most recent 500.
860        for i in 0..(BEE_TAB_RING_CAPACITY + 100) {
861            pane.push_bee(
862                LogTab::Debug,
863                BeeLogLine {
864                    timestamp: format!("t{i}"),
865                    logger: "node/test".into(),
866                    message: format!("msg {i}"),
867                },
868            );
869        }
870        assert_eq!(pane.bee_buffers.debug.len(), BEE_TAB_RING_CAPACITY);
871        assert_eq!(pane.bee_buffers.debug.front().unwrap().timestamp, "t100");
872        assert_eq!(
873            pane.bee_buffers.debug.back().unwrap().timestamp,
874            format!("t{}", BEE_TAB_RING_CAPACITY + 99)
875        );
876    }
877
878    #[test]
879    fn push_bee_to_self_http_is_noop() {
880        // Defensive: only the bee-side severities have buffers; the
881        // SelfHttp tab is fed by the LogCapture. push_bee on SelfHttp
882        // must silently drop, not panic.
883        let mut pane = LogPane::new(None, LogTab::SelfHttp, 10);
884        pane.push_bee(
885            LogTab::SelfHttp,
886            BeeLogLine {
887                timestamp: "t".into(),
888                logger: "x".into(),
889                message: "m".into(),
890            },
891        );
892        for tab in LogTab::ALL {
893            assert_eq!(pane.bee_buffers.count(tab), 0, "tab {tab:?} got an entry");
894        }
895    }
896
897    #[test]
898    fn human_count_formats_thousands() {
899        assert_eq!(human_count(0), "0");
900        assert_eq!(human_count(42), "42");
901        assert_eq!(human_count(999), "999");
902        assert_eq!(human_count(1000), "1.0k");
903        assert_eq!(human_count(1234), "1.2k");
904        assert_eq!(human_count(999_999), "1000.0k");
905        assert_eq!(human_count(1_000_000), "1.0m");
906    }
907
908    #[test]
909    fn from_kebab_unknown_falls_back_to_self_http() {
910        // Defensive: a hand-edited state.toml with a future tab name
911        // shouldn't crash startup, just silently snap to a known good.
912        assert_eq!(LogTab::from_kebab("future-tab"), LogTab::SelfHttp);
913        assert_eq!(LogTab::from_kebab(""), LogTab::SelfHttp);
914    }
915
916    #[test]
917    fn kebab_round_trips() {
918        for tab in LogTab::ALL {
919            assert_eq!(LogTab::from_kebab(tab.to_kebab()), tab);
920        }
921    }
922
923    #[test]
924    fn path_only_strips_scheme_and_host() {
925        assert_eq!(path_only("http://localhost:1633/status"), "/status");
926        assert_eq!(
927            path_only("https://bee.example.com:1633/stamps/abc"),
928            "/stamps/abc"
929        );
930    }
931
932    #[test]
933    fn path_only_handles_root_only() {
934        assert_eq!(path_only("http://localhost:1633"), "http://localhost:1633");
935    }
936
937    #[test]
938    fn h_scroll_starts_at_zero() {
939        let pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
940        assert_eq!(pane.h_scroll_offset(), 0);
941    }
942
943    #[test]
944    fn scroll_right_then_left_returns_to_zero() {
945        let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
946        pane.scroll_right(8);
947        pane.scroll_right(8);
948        assert_eq!(pane.h_scroll_offset(), 16);
949        pane.scroll_left(16);
950        assert_eq!(pane.h_scroll_offset(), 0);
951    }
952
953    #[test]
954    fn scroll_left_saturates_at_zero() {
955        let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
956        pane.scroll_left(100);
957        assert_eq!(pane.h_scroll_offset(), 0);
958    }
959
960    #[test]
961    fn switching_tabs_resets_h_scroll() {
962        let mut pane = LogPane::new(None, LogTab::Errors, LOG_PANE_MIN_HEIGHT);
963        pane.scroll_right(40);
964        assert_eq!(pane.h_scroll_offset(), 40);
965        pane.next_tab();
966        assert_eq!(pane.h_scroll_offset(), 0);
967        pane.scroll_right(20);
968        pane.prev_tab();
969        assert_eq!(pane.h_scroll_offset(), 0);
970    }
971
972    #[test]
973    fn resume_tail_resets_both_axes() {
974        let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
975        pane.scroll_up(5);
976        pane.scroll_right(24);
977        pane.resume_tail();
978        assert_eq!(pane.scroll_offset(), 0);
979        assert_eq!(pane.h_scroll_offset(), 0);
980    }
981
982    #[test]
983    fn reset_h_scroll_only_touches_horizontal() {
984        let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
985        pane.scroll_up(7);
986        pane.scroll_right(16);
987        pane.reset_h_scroll();
988        assert_eq!(pane.scroll_offset(), 7);
989        assert_eq!(pane.h_scroll_offset(), 0);
990    }
991}