1use 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
26pub struct FeedTimeline {
29 timeline: Option<Timeline>,
31 error: Option<String>,
34 loading: bool,
37 pending_label: Option<String>,
41 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 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 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 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 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 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 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 => { }
196 None => {}
197 }
198 frame.render_widget(Paragraph::new(body_lines), chunks[1]);
199
200 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 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 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 for _ in 0..5 {
378 s.handle_key_event(KeyEvent::from(KeyCode::Down)).unwrap();
379 }
380 assert_eq!(s.selected, 1);
381 }
382}