1use std::collections::HashMap;
28use std::sync::Arc;
29
30use color_eyre::Result;
31use crossterm::event::{KeyCode, KeyEvent};
32use ratatui::{
33 Frame,
34 layout::{Constraint, Layout, Rect},
35 style::{Color, Modifier, Style},
36 text::{Line, Span},
37 widgets::{Block, Borders, Paragraph},
38};
39use tokio::sync::{mpsc, watch};
40
41use super::Component;
42use crate::action::Action;
43use crate::api::ApiClient;
44use crate::theme;
45use crate::watch::PinsSnapshot;
46
47use bee::api::PinIntegrity;
48use bee::swarm::Reference;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum CheckState {
54 Idle,
55 Checking,
56 Ok {
57 total: u64,
58 missing: u64,
59 invalid: u64,
60 },
61 Failed(String),
62}
63
64impl CheckState {
65 pub fn is_unhealthy(&self) -> bool {
69 matches!(self, Self::Ok { missing, invalid, .. } if *missing > 0 || *invalid > 0)
70 }
71 pub fn is_healthy(&self) -> bool {
73 matches!(
74 self,
75 Self::Ok {
76 missing: 0,
77 invalid: 0,
78 ..
79 }
80 )
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct PinRow {
89 pub reference: Reference,
90 pub reference_short: String,
91 pub check: CheckState,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum SortMode {
97 Reference,
100 BadFirst,
103 TotalChunks,
106}
107
108impl SortMode {
109 fn next(self) -> Self {
110 match self {
111 Self::Reference => Self::BadFirst,
112 Self::BadFirst => Self::TotalChunks,
113 Self::TotalChunks => Self::Reference,
114 }
115 }
116 fn label(self) -> &'static str {
117 match self {
118 Self::Reference => "ref order",
119 Self::BadFirst => "bad first",
120 Self::TotalChunks => "by size",
121 }
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct PinsView {
129 pub rows: Vec<PinRow>,
130 pub sort: SortMode,
131 pub total_pins: usize,
132 pub healthy: usize,
133 pub unhealthy: usize,
134 pub unchecked: usize,
135}
136
137type FetchResult = (Reference, std::result::Result<PinIntegrity, String>);
138
139pub struct Pins {
140 client: Arc<ApiClient>,
141 rx: watch::Receiver<PinsSnapshot>,
142 snapshot: PinsSnapshot,
143 checks: HashMap<Reference, CheckState>,
147 selected: usize,
148 scroll_offset: usize,
149 sort: SortMode,
150 fetch_tx: mpsc::UnboundedSender<FetchResult>,
151 fetch_rx: mpsc::UnboundedReceiver<FetchResult>,
152}
153
154impl Pins {
155 pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<PinsSnapshot>) -> Self {
156 let snapshot = rx.borrow().clone();
157 let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
158 Self {
159 client,
160 rx,
161 snapshot,
162 checks: HashMap::new(),
163 selected: 0,
164 scroll_offset: 0,
165 sort: SortMode::Reference,
166 fetch_tx,
167 fetch_rx,
168 }
169 }
170
171 fn pull_latest(&mut self) {
172 self.snapshot = self.rx.borrow().clone();
173 let n = self.snapshot.pins.len();
175 if n == 0 {
176 self.selected = 0;
177 } else if self.selected >= n {
178 self.selected = n - 1;
179 }
180 }
181
182 fn drain_fetches(&mut self) {
187 while let Ok((reference, result)) = self.fetch_rx.try_recv() {
188 let next = match result {
189 Ok(p) => CheckState::Ok {
190 total: p.total,
191 missing: p.missing,
192 invalid: p.invalid,
193 },
194 Err(e) => CheckState::Failed(e),
195 };
196 self.checks.insert(reference, next);
197 }
198 }
199
200 pub fn view_for(
202 snap: &PinsSnapshot,
203 checks: &HashMap<Reference, CheckState>,
204 sort: SortMode,
205 ) -> PinsView {
206 let mut rows: Vec<PinRow> = snap
207 .pins
208 .iter()
209 .map(|r| {
210 let check = checks.get(r).cloned().unwrap_or(CheckState::Idle);
211 PinRow {
212 reference: r.clone(),
213 reference_short: short_ref(&r.to_hex()),
214 check,
215 }
216 })
217 .collect();
218
219 match sort {
220 SortMode::Reference => {} SortMode::BadFirst => {
222 rows.sort_by_key(|r| match &r.check {
223 CheckState::Ok {
224 missing, invalid, ..
225 } if *missing > 0 || *invalid > 0 => 0,
226 CheckState::Failed(_) => 1,
227 CheckState::Idle => 2,
228 CheckState::Checking => 3,
229 CheckState::Ok { .. } => 4,
230 });
231 }
232 SortMode::TotalChunks => {
233 rows.sort_by_key(|r| match &r.check {
234 CheckState::Ok { total, .. } => std::cmp::Reverse(*total),
235 _ => std::cmp::Reverse(0),
236 });
237 }
238 }
239
240 let mut healthy = 0;
241 let mut unhealthy = 0;
242 let mut unchecked = 0;
243 for r in &rows {
244 if r.check.is_healthy() {
245 healthy += 1;
246 } else if r.check.is_unhealthy() {
247 unhealthy += 1;
248 } else if matches!(r.check, CheckState::Idle) {
249 unchecked += 1;
250 }
251 }
252
253 PinsView {
254 total_pins: rows.len(),
255 rows,
256 sort,
257 healthy,
258 unhealthy,
259 unchecked,
260 }
261 }
262
263 fn check_selected(&mut self) {
266 if self.snapshot.pins.is_empty() {
267 return;
268 }
269 let i = self.selected.min(self.snapshot.pins.len() - 1);
270 let reference = self.snapshot.pins[i].clone();
271 if matches!(self.checks.get(&reference), Some(CheckState::Checking)) {
272 return;
273 }
274 self.checks.insert(reference.clone(), CheckState::Checking);
275 let client = self.client.clone();
276 let tx = self.fetch_tx.clone();
277 let task_ref = reference.clone();
278 tokio::spawn(async move {
279 let r = client
280 .bee()
281 .api()
282 .check_pins(Some(&task_ref))
283 .await
284 .map_err(|e| e.to_string())
285 .and_then(|mut entries| {
286 entries
287 .pop()
288 .ok_or_else(|| "Bee returned no integrity entry".to_string())
289 });
290 let _ = tx.send((task_ref, r));
291 });
292 }
293
294 fn check_all(&mut self) {
299 let pending: Vec<Reference> = self
300 .snapshot
301 .pins
302 .iter()
303 .filter(|r| matches!(self.checks.get(*r), None | Some(CheckState::Idle)))
304 .cloned()
305 .collect();
306 for reference in pending {
307 self.checks.insert(reference.clone(), CheckState::Checking);
308 let client = self.client.clone();
309 let tx = self.fetch_tx.clone();
310 let task_ref = reference;
311 tokio::spawn(async move {
312 let r = client
313 .bee()
314 .api()
315 .check_pins(Some(&task_ref))
316 .await
317 .map_err(|e| e.to_string())
318 .and_then(|mut entries| {
319 entries
320 .pop()
321 .ok_or_else(|| "Bee returned no integrity entry".to_string())
322 });
323 let _ = tx.send((task_ref, r));
324 });
325 }
326 }
327}
328
329fn short_ref(hex: &str) -> String {
332 let trimmed = hex.trim_start_matches("0x");
333 if trimmed.len() > 14 {
334 format!("{}…{}", &trimmed[..8], &trimmed[trimmed.len() - 4..])
335 } else {
336 trimmed.to_string()
337 }
338}
339
340impl Component for Pins {
341 fn update(&mut self, action: Action) -> Result<Option<Action>> {
342 if matches!(action, Action::Tick) {
343 self.pull_latest();
344 self.drain_fetches();
345 }
346 Ok(None)
347 }
348
349 fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
350 match key.code {
351 KeyCode::Char('j') | KeyCode::Down => {
352 let n = self.snapshot.pins.len();
353 if n > 0 && self.selected + 1 < n {
354 self.selected += 1;
355 }
356 }
357 KeyCode::Char('k') | KeyCode::Up => {
358 self.selected = self.selected.saturating_sub(1);
359 }
360 KeyCode::Enter => {
361 self.check_selected();
362 }
363 KeyCode::Char('c') => {
364 self.check_all();
365 }
366 KeyCode::Char('s') => {
367 self.sort = self.sort.next();
368 }
369 _ => {}
370 }
371 Ok(None)
372 }
373
374 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
375 let chunks = Layout::vertical([
376 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), Constraint::Length(1), ])
381 .split(area);
382
383 let view = Self::view_for(&self.snapshot, &self.checks, self.sort);
384 let t = theme::active();
385
386 let header_l1 = Line::from(vec![
388 Span::styled("PINS", Style::default().add_modifier(Modifier::BOLD)),
389 Span::raw(format!(" {} pinned", view.total_pins)),
390 Span::raw(" "),
391 Span::styled(format!("✓ {}", view.healthy), Style::default().fg(t.pass)),
392 Span::raw(" "),
393 Span::styled(format!("✗ {}", view.unhealthy), Style::default().fg(t.fail)),
394 Span::raw(" "),
395 Span::styled(format!("? {}", view.unchecked), Style::default().fg(t.dim)),
396 Span::raw(" sort "),
397 Span::styled(view.sort.label(), Style::default().fg(t.info)),
398 ]);
399 let header_l2 = match &self.snapshot.last_error {
400 Some(err) => {
401 let (color, msg) = theme::classify_header_error(err);
402 Line::from(Span::styled(msg, Style::default().fg(color)))
403 }
404 None if !self.snapshot.is_loaded() => Line::from(Span::styled(
405 format!("{} loading…", theme::spinner_glyph()),
406 Style::default().fg(t.dim),
407 )),
408 None => Line::from(Span::styled(
409 " Press Enter to integrity-check the highlighted pin, c for all, s to re-sort.",
410 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
411 )),
412 };
413 frame.render_widget(
414 Paragraph::new(vec![header_l1, header_l2])
415 .block(Block::default().borders(Borders::BOTTOM)),
416 chunks[0],
417 );
418
419 let body = chunks[1];
421 let table_chunks =
422 Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(body);
423 frame.render_widget(
424 Paragraph::new(Line::from(Span::styled(
425 " REFERENCE TOTAL MISSING INVALID STATUS",
426 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
427 ))),
428 table_chunks[0],
429 );
430
431 if view.rows.is_empty() {
432 frame.render_widget(
433 Paragraph::new(Line::from(Span::styled(
434 " (no pinned references — pin one with `swarm-cli pin add`)",
435 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
436 ))),
437 table_chunks[1],
438 );
439 } else {
440 let rows_area = table_chunks[1];
441 let mut lines: Vec<Line> = Vec::with_capacity(view.rows.len() + 2);
442 for (i, r) in view.rows.iter().enumerate() {
443 let cursor = if i == self.selected {
444 format!("{} ", t.glyphs.cursor)
445 } else {
446 " ".to_string()
447 };
448 let (total, missing, invalid, status_text, status_color) = match &r.check {
449 CheckState::Idle => (
450 "—".to_string(),
451 "—".to_string(),
452 "—".to_string(),
453 "? unchecked".to_string(),
454 t.dim,
455 ),
456 CheckState::Checking => (
457 "—".to_string(),
458 "—".to_string(),
459 "—".to_string(),
460 format!("{} checking…", theme::spinner_glyph()),
461 t.info,
462 ),
463 CheckState::Ok {
464 total,
465 missing,
466 invalid,
467 } => {
468 let healthy = *missing == 0 && *invalid == 0;
469 (
470 total.to_string(),
471 missing.to_string(),
472 invalid.to_string(),
473 if healthy {
474 "✓ healthy".into()
475 } else {
476 "✗ degraded".into()
477 },
478 if healthy { t.pass } else { t.fail },
479 )
480 }
481 CheckState::Failed(err) => (
482 "—".to_string(),
483 "—".to_string(),
484 "—".to_string(),
485 format!("✗ check failed: {err}"),
486 t.fail,
487 ),
488 };
489 lines.push(Line::from(vec![
490 Span::styled(
491 cursor,
492 Style::default()
493 .fg(if i == self.selected { t.accent } else { t.dim })
494 .add_modifier(Modifier::BOLD),
495 ),
496 Span::styled(
497 format!("{:<18}", r.reference_short),
498 Style::default().add_modifier(Modifier::BOLD),
499 ),
500 Span::raw(format!("{total:>6} ")),
501 Span::raw(format!("{missing:>6} ")),
502 Span::raw(format!("{invalid:>6} ")),
503 Span::styled(
504 status_text,
505 Style::default()
506 .fg(status_color)
507 .add_modifier(Modifier::BOLD),
508 ),
509 ]));
510 }
511
512 let visible_rows = rows_area.height as usize;
515 self.scroll_offset = super::scroll::clamp_scroll(
516 self.selected,
517 self.scroll_offset,
518 visible_rows,
519 lines.len(),
520 );
521 frame.render_widget(
522 Paragraph::new(lines.clone()).scroll((self.scroll_offset as u16, 0)),
523 rows_area,
524 );
525 super::scroll::render_scrollbar(
526 frame,
527 rows_area,
528 self.scroll_offset,
529 visible_rows,
530 lines.len(),
531 );
532 }
533
534 if !view.rows.is_empty() {
538 let i = self.selected.min(view.rows.len() - 1);
539 let row = &view.rows[i];
540 let detail = Line::from(vec![
541 Span::styled(" selected: ", Style::default().fg(t.dim)),
542 Span::styled(row.reference.to_hex(), Style::default().fg(t.info)),
543 ]);
544 frame.render_widget(Paragraph::new(detail), chunks[2]);
545 }
546
547 let footer = Line::from(vec![
549 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
550 Span::raw(" switch screen "),
551 Span::styled(
552 " ↑↓/jk ",
553 Style::default().fg(Color::Black).bg(Color::White),
554 ),
555 Span::raw(" select "),
556 Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
557 Span::raw(" check pin "),
558 Span::styled(" c ", Style::default().fg(Color::Black).bg(Color::White)),
559 Span::raw(" check all "),
560 Span::styled(" s ", Style::default().fg(Color::Black).bg(Color::White)),
561 Span::raw(" sort "),
562 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
563 Span::raw(" help "),
564 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
565 Span::raw(" quit "),
566 ]);
567 frame.render_widget(Paragraph::new(footer), chunks[3]);
568
569 Ok(())
570 }
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 fn r(byte: u8) -> Reference {
578 Reference::new(&[byte; 32]).unwrap()
579 }
580
581 fn ok(total: u64, missing: u64, invalid: u64) -> CheckState {
582 CheckState::Ok {
583 total,
584 missing,
585 invalid,
586 }
587 }
588
589 #[test]
590 fn check_state_health_predicates() {
591 assert!(ok(10, 0, 0).is_healthy());
592 assert!(!ok(10, 0, 0).is_unhealthy());
593 assert!(ok(10, 1, 0).is_unhealthy());
594 assert!(ok(10, 0, 1).is_unhealthy());
595 assert!(!CheckState::Idle.is_healthy());
596 assert!(!CheckState::Idle.is_unhealthy());
597 assert!(!CheckState::Checking.is_unhealthy());
598 }
599
600 #[test]
601 fn view_for_empty_snapshot_renders_zero_counts() {
602 let snap = PinsSnapshot::default();
603 let view = Pins::view_for(&snap, &HashMap::new(), SortMode::Reference);
604 assert_eq!(view.total_pins, 0);
605 assert_eq!(view.healthy, 0);
606 assert_eq!(view.unhealthy, 0);
607 assert_eq!(view.unchecked, 0);
608 }
609
610 #[test]
611 fn view_for_counts_health_buckets() {
612 let snap = PinsSnapshot {
613 pins: vec![r(1), r(2), r(3), r(4)],
614 ..PinsSnapshot::default()
615 };
616 let mut checks = HashMap::new();
617 checks.insert(r(1), ok(100, 0, 0)); checks.insert(r(2), ok(100, 5, 0)); checks.insert(r(3), CheckState::Failed("nope".into())); let view = Pins::view_for(&snap, &checks, SortMode::Reference);
622 assert_eq!(view.total_pins, 4);
623 assert_eq!(view.healthy, 1);
624 assert_eq!(view.unhealthy, 1);
625 assert_eq!(view.unchecked, 1);
626 }
627
628 #[test]
629 fn view_for_default_sort_preserves_response_order() {
630 let snap = PinsSnapshot {
631 pins: vec![r(3), r(1), r(2)],
632 ..PinsSnapshot::default()
633 };
634 let view = Pins::view_for(&snap, &HashMap::new(), SortMode::Reference);
635 assert_eq!(view.rows[0].reference, r(3));
636 assert_eq!(view.rows[1].reference, r(1));
637 assert_eq!(view.rows[2].reference, r(2));
638 }
639
640 #[test]
641 fn view_for_bad_first_surfaces_unhealthy_then_failed_then_unchecked_then_healthy() {
642 let snap = PinsSnapshot {
643 pins: vec![r(1), r(2), r(3), r(4), r(5)],
644 ..PinsSnapshot::default()
645 };
646 let mut checks = HashMap::new();
647 checks.insert(r(1), ok(10, 0, 0)); checks.insert(r(2), ok(10, 1, 0)); checks.insert(r(3), CheckState::Failed("e".into())); checks.insert(r(4), CheckState::Checking); let view = Pins::view_for(&snap, &checks, SortMode::BadFirst);
653 let order: Vec<_> = view.rows.iter().map(|r| r.reference.clone()).collect();
654 assert_eq!(order, vec![r(2), r(3), r(5), r(4), r(1)]);
655 }
656
657 #[test]
658 fn view_for_total_chunks_sorts_descending_with_unchecked_last() {
659 let snap = PinsSnapshot {
660 pins: vec![r(1), r(2), r(3), r(4)],
661 ..PinsSnapshot::default()
662 };
663 let mut checks = HashMap::new();
664 checks.insert(r(1), ok(50, 0, 0));
665 checks.insert(r(2), ok(500, 0, 0));
666 checks.insert(r(3), ok(5, 0, 0));
667 let view = Pins::view_for(&snap, &checks, SortMode::TotalChunks);
669 let order: Vec<_> = view.rows.iter().map(|r| r.reference.clone()).collect();
670 assert_eq!(order, vec![r(2), r(1), r(3), r(4)]);
672 }
673
674 #[test]
675 fn sort_mode_cycles() {
676 assert_eq!(SortMode::Reference.next(), SortMode::BadFirst);
677 assert_eq!(SortMode::BadFirst.next(), SortMode::TotalChunks);
678 assert_eq!(SortMode::TotalChunks.next(), SortMode::Reference);
679 }
680
681 #[test]
682 fn short_ref_keeps_short_strings_intact() {
683 assert_eq!(short_ref("abcd"), "abcd");
684 assert_eq!(short_ref("0x1234"), "1234");
685 assert_eq!(
686 short_ref("aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"),
687 "aabbccdd…8899"
688 );
689 }
690}