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