Skip to main content

bee_tui/components/
feed_timeline.rs

1//! S14 Feed Timeline screen. Renders the [`Timeline`] produced by
2//! [`crate::feed_timeline::walk`] as a scrollable table with cursor
3//! plus selection-detail line. Loading state is driven by
4//! `:feed-timeline <owner> <topic>` (which spawns the walk and pushes
5//! the result through an mpsc channel into `App`'s tick handler —
6//! same shape as the durability_tx → S13 Watchlist plumbing).
7
8use std::any::Any;
9use std::time::SystemTime;
10
11use color_eyre::Result;
12use crossterm::event::{KeyCode, KeyEvent};
13use ratatui::{
14    Frame,
15    layout::{Constraint, Layout, Rect},
16    style::{Color, Modifier, Style},
17    text::{Line, Span},
18    widgets::{Block, Borders, Paragraph},
19};
20
21use super::Component;
22use crate::action::Action;
23use crate::feed_timeline::{Timeline, TimelineEntry, format_age_secs};
24use crate::theme;
25
26/// Screen state. The walk happens in a background task; results
27/// land here via `set_loading` → `set_timeline` / `set_error`.
28pub struct FeedTimeline {
29    /// `Some(timeline)` once the walk completes successfully.
30    timeline: Option<Timeline>,
31    /// `Some(message)` when the walk errored. Mutually exclusive
32    /// with `timeline` — a fresh walk clears both.
33    error: Option<String>,
34    /// `true` while a walk is in flight. Drives the spinner glyph
35    /// in the header.
36    loading: bool,
37    /// Header strip set when the verb kicks off a walk so the
38    /// screen shows the operator-supplied owner/topic immediately,
39    /// even before the latest-index probe completes.
40    pending_label: Option<String>,
41    /// Cursor row in the entries list.
42    selected: usize,
43}
44
45impl Default for FeedTimeline {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl FeedTimeline {
52    pub fn new() -> Self {
53        Self {
54            timeline: None,
55            error: None,
56            loading: false,
57            pending_label: None,
58            selected: 0,
59        }
60    }
61
62    /// Called by `App` when `:feed-timeline <owner> <topic>` kicks
63    /// off a walk. Clears prior state so the operator doesn't see
64    /// stale entries while the new walk is in flight.
65    pub fn set_loading(&mut self, label: impl Into<String>) {
66        self.timeline = None;
67        self.error = None;
68        self.loading = true;
69        self.pending_label = Some(label.into());
70        self.selected = 0;
71    }
72
73    /// Walk completed cleanly — replace the displayed entries.
74    pub fn set_timeline(&mut self, t: Timeline) {
75        self.loading = false;
76        self.error = None;
77        self.timeline = Some(t);
78        self.selected = 0;
79    }
80
81    /// Walk failed — surface the operator-facing reason.
82    pub fn set_error(&mut self, msg: impl Into<String>) {
83        self.loading = false;
84        self.timeline = None;
85        self.error = Some(msg.into());
86    }
87
88    /// Reference under the cursor — used by future "c=copy" /
89    /// "Enter=inspect" key bindings. Returns `None` when there is no
90    /// timeline, no entries, or the selected row has no reference.
91    pub fn selected_reference(&self) -> Option<&str> {
92        self.timeline
93            .as_ref()
94            .and_then(|t| t.entries.get(self.selected))
95            .and_then(|e| e.reference_hex.as_deref())
96    }
97}
98
99impl Component for FeedTimeline {
100    fn as_any_mut(&mut self) -> Option<&mut dyn Any> {
101        Some(self)
102    }
103
104    fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
105        let len = self.timeline.as_ref().map(|t| t.entries.len()).unwrap_or(0);
106        match key.code {
107            KeyCode::Up | KeyCode::Char('k') => {
108                self.selected = self.selected.saturating_sub(1);
109            }
110            KeyCode::Down | KeyCode::Char('j') if len > 0 && self.selected + 1 < len => {
111                self.selected += 1;
112            }
113            KeyCode::PageUp => {
114                self.selected = self.selected.saturating_sub(10);
115            }
116            KeyCode::PageDown if len > 0 => {
117                self.selected = (self.selected + 10).min(len.saturating_sub(1));
118            }
119            _ => {}
120        }
121        Ok(None)
122    }
123
124    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
125        let t = theme::active();
126        let chunks = Layout::vertical([
127            Constraint::Length(2),
128            Constraint::Min(0),
129            Constraint::Length(1),
130            Constraint::Length(1),
131        ])
132        .split(area);
133
134        // Header
135        let mut header_line = vec![Span::styled(
136            "FEED TIMELINE",
137            Style::default().add_modifier(Modifier::BOLD),
138        )];
139        if self.loading {
140            header_line.push(Span::raw("  "));
141            header_line.push(Span::styled(
142                format!("{} loading…", theme::spinner_glyph()),
143                Style::default().fg(t.dim),
144            ));
145            if let Some(label) = &self.pending_label {
146                header_line.push(Span::raw("  "));
147                header_line.push(Span::styled(label.clone(), Style::default().fg(t.dim)));
148            }
149        } else if let Some(tm) = &self.timeline {
150            header_line.push(Span::raw(format!(
151                "  owner=0x{}  topic={}  latest=idx{}  · {} entries",
152                short_hex(&tm.owner_hex, 12),
153                short_hex(&tm.topic_hex, 8),
154                tm.latest_index,
155                tm.entries.len(),
156            )));
157        } else if let Some(e) = &self.error {
158            header_line.push(Span::raw("  "));
159            header_line.push(Span::styled(e.clone(), Style::default().fg(t.fail)));
160        } else {
161            header_line.push(Span::raw("  "));
162            header_line.push(Span::styled(
163                "type :feed-timeline <owner> <topic> [N] to load",
164                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
165            ));
166        }
167        frame.render_widget(
168            Paragraph::new(Line::from(header_line))
169                .block(Block::default().borders(Borders::BOTTOM)),
170            chunks[0],
171        );
172
173        // Body — table of entries.
174        let now = SystemTime::now()
175            .duration_since(SystemTime::UNIX_EPOCH)
176            .map(|d| d.as_secs())
177            .unwrap_or(0);
178        let mut body_lines: Vec<Line> = Vec::new();
179        body_lines.push(Line::from(Span::styled(
180            "  INDEX     AGE      SIZE   TYPE      REF / ERROR",
181            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
182        )));
183        match &self.timeline {
184            Some(tm) if !tm.entries.is_empty() => {
185                for (i, e) in tm.entries.iter().enumerate() {
186                    body_lines.push(render_row(e, now, i == self.selected, t));
187                }
188            }
189            Some(_) => {
190                body_lines.push(Line::from(Span::styled(
191                    "  (no entries — feed exists but every fetch errored)",
192                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
193                )));
194            }
195            None if self.loading => { /* spinner already in header */ }
196            None => {}
197        }
198        frame.render_widget(Paragraph::new(body_lines), chunks[1]);
199
200        // Selected-line detail (full reference when present).
201        let detail = match self.selected_entry() {
202            Some(e) if e.reference_hex.is_some() => {
203                format!("  selected: ref={}", e.reference_hex.as_deref().unwrap())
204            }
205            Some(e) => format!(
206                "  selected: index={} · payload={}B · ts={}",
207                e.index,
208                e.payload_bytes,
209                e.timestamp_unix
210                    .map(|t| t.to_string())
211                    .unwrap_or_else(|| "?".into()),
212            ),
213            None => String::new(),
214        };
215        frame.render_widget(
216            Paragraph::new(Line::from(Span::styled(detail, Style::default().fg(t.dim)))),
217            chunks[2],
218        );
219
220        // Footer — keymap.
221        frame.render_widget(
222            Paragraph::new(Line::from(vec![
223                Span::styled(
224                    " ↑↓/jk ",
225                    Style::default().fg(Color::Black).bg(Color::White),
226                ),
227                Span::raw(" select  "),
228                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
229                Span::raw(" switch screen  "),
230                Span::styled(" : ", Style::default().fg(Color::Black).bg(Color::White)),
231                Span::raw(" command  "),
232                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
233                Span::raw(" quit "),
234            ])),
235            chunks[3],
236        );
237        Ok(())
238    }
239}
240
241impl FeedTimeline {
242    fn selected_entry(&self) -> Option<&TimelineEntry> {
243        self.timeline
244            .as_ref()
245            .and_then(|t| t.entries.get(self.selected))
246    }
247}
248
249fn render_row<'a>(e: &'a TimelineEntry, now: u64, is_selected: bool, t: &theme::Theme) -> Line<'a> {
250    let age = e
251        .timestamp_unix
252        .map(|ts| format_age_secs(now.saturating_sub(ts)))
253        .unwrap_or_else(|| "—".to_string());
254    let kind = if e.error.is_some() {
255        "miss"
256    } else if e.reference_hex.is_some() {
257        "ref"
258    } else {
259        "raw"
260    };
261    let body = match (&e.error, &e.reference_hex) {
262        (Some(err), _) => format!("[{err}]"),
263        (_, Some(r)) => short_hex(r, 12),
264        (_, None) => format!("payload {}B", e.payload_bytes.saturating_sub(8)),
265    };
266    let row_style = if is_selected {
267        Style::default().add_modifier(Modifier::REVERSED)
268    } else if e.error.is_some() {
269        Style::default().fg(t.dim)
270    } else {
271        Style::default()
272    };
273    Line::from(vec![Span::styled(
274        format!(
275            "  {:>6}  {:>10}  {:>4}  {:<8}  {body}",
276            e.index, age, e.payload_bytes, kind,
277        ),
278        row_style,
279    )])
280}
281
282fn short_hex(hex: &str, len: usize) -> String {
283    let s = hex.trim_start_matches("0x");
284    if s.len() > len {
285        format!("{}…", &s[..len])
286    } else {
287        s.to_string()
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::feed_timeline::{Timeline, TimelineEntry};
295
296    fn entry(index: u64, ref_hex: Option<&str>, error: Option<&str>) -> TimelineEntry {
297        TimelineEntry {
298            index,
299            timestamp_unix: Some(1_700_000_000),
300            payload_bytes: 40,
301            reference_hex: ref_hex.map(String::from),
302            error: error.map(String::from),
303        }
304    }
305
306    fn timeline(entries: Vec<TimelineEntry>) -> Timeline {
307        Timeline {
308            owner_hex: "1234567890abcdef1234567890abcdef12345678".into(),
309            topic_hex: "a".repeat(64),
310            latest_index: entries.first().map(|e| e.index).unwrap_or(0),
311            index_next: entries.first().map(|e| e.index + 1).unwrap_or(0),
312            entries,
313            reached_requested: true,
314        }
315    }
316
317    #[test]
318    fn new_screen_has_no_timeline_no_error() {
319        let s = FeedTimeline::new();
320        assert!(s.timeline.is_none());
321        assert!(s.error.is_none());
322        assert!(!s.loading);
323    }
324
325    #[test]
326    fn set_loading_clears_prior_state() {
327        let mut s = FeedTimeline::new();
328        s.set_timeline(timeline(vec![entry(0, None, None)]));
329        s.set_loading("owner=abc topic=xyz");
330        assert!(s.timeline.is_none());
331        assert!(s.error.is_none());
332        assert!(s.loading);
333        assert_eq!(s.selected, 0);
334    }
335
336    #[test]
337    fn set_error_clears_loading() {
338        let mut s = FeedTimeline::new();
339        s.set_loading("owner=abc topic=xyz");
340        s.set_error("/feeds/.../ failed: HTTP 500");
341        assert!(!s.loading);
342        assert!(s.error.is_some());
343        assert!(s.timeline.is_none());
344    }
345
346    #[test]
347    fn set_timeline_clears_loading() {
348        let mut s = FeedTimeline::new();
349        s.set_loading("owner=abc topic=xyz");
350        s.set_timeline(timeline(vec![entry(0, Some(&"a".repeat(64)), None)]));
351        assert!(!s.loading);
352        assert!(s.timeline.is_some());
353        assert!(s.error.is_none());
354    }
355
356    #[test]
357    fn selected_reference_returns_cursor_ref() {
358        let mut s = FeedTimeline::new();
359        s.set_timeline(timeline(vec![
360            entry(2, Some(&"a".repeat(64)), None),
361            entry(1, None, None),
362            entry(0, Some(&"b".repeat(64)), None),
363        ]));
364        assert_eq!(s.selected_reference(), Some("a".repeat(64).as_str()));
365        s.selected = 2;
366        assert_eq!(s.selected_reference(), Some("b".repeat(64).as_str()));
367        // Row 1 has no reference (raw payload).
368        s.selected = 1;
369        assert!(s.selected_reference().is_none());
370    }
371
372    #[test]
373    fn keypress_does_not_advance_past_last_row() {
374        let mut s = FeedTimeline::new();
375        s.set_timeline(timeline(vec![entry(1, None, None), entry(0, None, None)]));
376        // Press Down twice — should clamp at 1.
377        for _ in 0..5 {
378            s.handle_key_event(KeyEvent::from(KeyCode::Down)).unwrap();
379        }
380        assert_eq!(s.selected, 1);
381    }
382}