1use std::collections::{HashMap, HashSet};
39use std::sync::Arc;
40
41use bee::manifest::{MantarayNode, TYPE_EDGE, TYPE_VALUE};
42use bee::swarm::Reference;
43use color_eyre::Result;
44use crossterm::event::{KeyCode, KeyEvent};
45use ratatui::{
46 Frame,
47 layout::{Constraint, Layout, Rect},
48 style::{Color, Modifier, Style},
49 text::{Line, Span},
50 widgets::{Block, Borders, Paragraph},
51};
52use tokio::sync::mpsc;
53
54use super::Component;
55use crate::action::Action;
56use crate::api::ApiClient;
57use crate::manifest_walker;
58use crate::theme;
59
60#[derive(Debug, Clone)]
64pub enum NodeState {
65 Idle,
66 Loading,
67 Loaded(Box<MantarayNode>),
68 Error(String),
69}
70
71impl NodeState {
72 fn loaded(&self) -> Option<&MantarayNode> {
73 match self {
74 Self::Loaded(n) => Some(n),
75 _ => None,
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct TreeRow {
84 pub depth: u8,
86 pub label: String,
89 pub glyph: char,
93 pub has_children: bool,
95 pub self_addr_hex: Option<String>,
98 pub target_ref_hex: Option<String>,
100 pub content_type: Option<String>,
102 pub state_hint: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ManifestView {
111 pub root_ref_hex: Option<String>,
113 pub header: String,
116 pub rows: Vec<TreeRow>,
118}
119
120type FetchResult = (FetchTarget, Result<MantarayNode, String>);
121
122#[derive(Debug, Clone)]
123enum FetchTarget {
124 Root(Reference),
125 Fork([u8; 32]),
126}
127
128pub struct Manifest {
129 api: Arc<ApiClient>,
130 root_ref: Option<Reference>,
133 root: NodeState,
134 forks_loaded: HashMap<[u8; 32], NodeState>,
136 expanded: HashSet<[u8; 32]>,
138 selected: usize,
139 scroll_offset: usize,
140 fetch_tx: mpsc::UnboundedSender<FetchResult>,
141 fetch_rx: mpsc::UnboundedReceiver<FetchResult>,
142}
143
144impl Manifest {
145 pub fn new(api: Arc<ApiClient>) -> Self {
146 let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
147 Self {
148 api,
149 root_ref: None,
150 root: NodeState::Idle,
151 forks_loaded: HashMap::new(),
152 expanded: HashSet::new(),
153 selected: 0,
154 scroll_offset: 0,
155 fetch_tx,
156 fetch_rx,
157 }
158 }
159
160 pub fn load(&mut self, reference: Reference) {
164 self.root_ref = Some(reference.clone());
165 self.root = NodeState::Loading;
166 self.forks_loaded.clear();
167 self.expanded.clear();
168 self.selected = 0;
169 self.scroll_offset = 0;
170 let api = self.api.clone();
171 let tx = self.fetch_tx.clone();
172 let target_ref = reference.clone();
173 tokio::spawn(async move {
174 let r = manifest_walker::load_node(api, target_ref.clone()).await;
175 let _ = tx.send((FetchTarget::Root(target_ref), r));
176 });
177 }
178
179 fn drain_fetches(&mut self) {
180 while let Ok((target, result)) = self.fetch_rx.try_recv() {
181 let state = match result {
182 Ok(node) => NodeState::Loaded(Box::new(node)),
183 Err(e) => NodeState::Error(e),
184 };
185 match target {
186 FetchTarget::Root(r) => {
187 if Some(r) == self.root_ref {
188 self.root = state;
189 }
190 }
191 FetchTarget::Fork(addr) => {
192 self.forks_loaded.insert(addr, state);
193 }
194 }
195 }
196 }
197
198 pub fn view_for(
200 root_ref: Option<&Reference>,
201 root: &NodeState,
202 forks_loaded: &HashMap<[u8; 32], NodeState>,
203 expanded: &HashSet<[u8; 32]>,
204 ) -> ManifestView {
205 let header = build_header(root_ref, root);
206 let mut rows: Vec<TreeRow> = Vec::new();
207 if let Some(node) = root.loaded() {
208 walk_into_rows(node, 0, forks_loaded, expanded, &mut rows);
209 }
210 ManifestView {
211 root_ref_hex: root_ref.map(|r| r.to_hex()),
212 header,
213 rows,
214 }
215 }
216
217 fn cached_view(&self) -> ManifestView {
218 Self::view_for(
219 self.root_ref.as_ref(),
220 &self.root,
221 &self.forks_loaded,
222 &self.expanded,
223 )
224 }
225
226 fn select_up(&mut self) {
227 self.selected = self.selected.saturating_sub(1);
228 }
229
230 fn select_down(&mut self) {
231 let view = self.cached_view();
232 if !view.rows.is_empty() && self.selected + 1 < view.rows.len() {
233 self.selected += 1;
234 }
235 }
236
237 fn toggle_selected(&mut self) {
240 let view = self.cached_view();
241 if view.rows.is_empty() {
242 return;
243 }
244 let row = &view.rows[self.selected.min(view.rows.len() - 1)];
245 let Some(ref hex) = row.self_addr_hex else {
246 return;
247 };
248 let Ok(addr) = parse_hex_32(hex) else {
249 return;
250 };
251 if !row.has_children {
252 return;
253 }
254 if self.expanded.contains(&addr) {
255 self.expanded.remove(&addr);
256 return;
257 }
258 if matches!(self.forks_loaded.get(&addr), Some(NodeState::Loaded(_))) {
261 self.expanded.insert(addr);
262 return;
263 }
264 self.forks_loaded.insert(addr, NodeState::Loading);
265 let api = self.api.clone();
266 let tx = self.fetch_tx.clone();
267 tokio::spawn(async move {
268 let reference = match Reference::new(&addr) {
269 Ok(r) => r,
270 Err(e) => {
271 let _ = tx.send((
272 FetchTarget::Fork(addr),
273 Err(format!("invalid child reference: {e}")),
274 ));
275 return;
276 }
277 };
278 let r = manifest_walker::load_node(api, reference).await;
279 let _ = tx.send((FetchTarget::Fork(addr), r));
280 });
281 self.expanded.insert(addr);
284 }
285}
286
287impl Component for Manifest {
288 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
289 Some(self)
290 }
291
292 fn update(&mut self, action: Action) -> Result<Option<Action>> {
293 if matches!(action, Action::Tick) {
294 self.drain_fetches();
295 }
296 Ok(None)
297 }
298
299 fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
300 match key.code {
301 KeyCode::Up | KeyCode::Char('k') => self.select_up(),
302 KeyCode::Down | KeyCode::Char('j') => self.select_down(),
303 KeyCode::Enter => self.toggle_selected(),
304 _ => {}
305 }
306 Ok(None)
307 }
308
309 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
310 let t = theme::active();
311 let view = self.cached_view();
312 let chunks = Layout::vertical([
314 Constraint::Length(2),
315 Constraint::Min(0),
316 Constraint::Length(1),
317 Constraint::Length(1),
318 ])
319 .split(area);
320
321 let header_text = if view.rows.is_empty() {
323 view.header.clone()
324 } else {
325 format!(
326 "{}\n {}",
327 view.header,
328 view.root_ref_hex.clone().unwrap_or_default()
329 )
330 };
331 frame.render_widget(
332 Paragraph::new(header_text).block(Block::default().borders(Borders::BOTTOM)),
333 chunks[0],
334 );
335
336 let mut lines: Vec<Line> = Vec::with_capacity(view.rows.len() + 1);
338 if view.rows.is_empty() {
339 lines.push(Line::from(Span::styled(
340 " (no manifest loaded — type `:manifest <ref>` or `:inspect <ref>`)",
341 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
342 )));
343 } else {
344 if self.selected >= view.rows.len() {
346 self.selected = view.rows.len() - 1;
347 }
348 let body_h = chunks[1].height as usize;
349 if self.selected < self.scroll_offset {
350 self.scroll_offset = self.selected;
351 } else if self.selected >= self.scroll_offset + body_h.max(1) {
352 self.scroll_offset = self.selected + 1 - body_h.max(1);
353 }
354
355 for (i, row) in view.rows.iter().enumerate() {
356 if i < self.scroll_offset {
357 continue;
358 }
359 let is_cursor = i == self.selected;
360 let indent: String = " ".repeat(row.depth as usize + 1);
361 let label_style = if is_cursor {
362 Style::default().bg(t.tab_active_bg).fg(t.tab_active_fg)
363 } else {
364 Style::default()
365 };
366 let cursor_marker = if is_cursor { "▸ " } else { " " };
367 let mut spans = vec![
368 Span::styled(cursor_marker.to_string(), Style::default().fg(t.accent)),
369 Span::raw(indent),
370 Span::styled(row.glyph.to_string(), Style::default().fg(t.accent)),
371 Span::raw(" "),
372 Span::styled(row.label.clone(), label_style),
373 ];
374 if let Some(ct) = &row.content_type {
375 spans.push(Span::styled(
376 format!(" [{ct}]"),
377 Style::default().fg(t.info),
378 ));
379 }
380 if let Some(ref_hex) = &row.target_ref_hex {
381 spans.push(Span::styled(
382 format!(" → {}", short_hex(ref_hex, 8)),
383 Style::default().fg(t.dim),
384 ));
385 }
386 if let Some(hint) = &row.state_hint {
387 spans.push(Span::styled(
388 format!(" ({hint})"),
389 Style::default().fg(t.warn).add_modifier(Modifier::ITALIC),
390 ));
391 }
392 lines.push(Line::from(spans));
393 }
394 }
395 frame.render_widget(Paragraph::new(lines), chunks[1]);
396
397 if !view.rows.is_empty() {
399 let row = &view.rows[self.selected.min(view.rows.len() - 1)];
400 let detail = match (&row.target_ref_hex, &row.self_addr_hex) {
401 (Some(t_ref), _) => format!(" selected: target {t_ref}"),
402 (None, Some(s)) => format!(" selected: chunk {s}"),
403 _ => " (no copyable id on this row)".to_string(),
404 };
405 frame.render_widget(
406 Paragraph::new(Line::from(Span::styled(
407 detail,
408 Style::default().fg(t.dim),
409 ))),
410 chunks[2],
411 );
412 }
413
414 let footer = Line::from(vec![
416 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
417 Span::raw(" switch screen "),
418 Span::styled(
419 " ↑↓/jk ",
420 Style::default().fg(Color::Black).bg(Color::White),
421 ),
422 Span::raw(" select "),
423 Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
424 Span::raw(" expand/collapse "),
425 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
426 Span::raw(" help "),
427 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
428 Span::raw(" quit "),
429 ]);
430 frame.render_widget(Paragraph::new(footer), chunks[3]);
431 Ok(())
432 }
433}
434
435fn build_header(root_ref: Option<&Reference>, root: &NodeState) -> String {
438 match (root_ref, root) {
439 (None, _) => "no manifest loaded — type :manifest <ref> or :inspect <ref>".into(),
440 (Some(r), NodeState::Idle) => format!("ref {} — pending", short_hex(&r.to_hex(), 8)),
441 (Some(r), NodeState::Loading) => {
442 format!("ref {} — loading root chunk…", short_hex(&r.to_hex(), 8))
443 }
444 (Some(r), NodeState::Error(e)) => {
445 format!("ref {} — error: {}", short_hex(&r.to_hex(), 8), e)
446 }
447 (Some(r), NodeState::Loaded(node)) => {
448 let fork_count = node.forks.len();
449 let leaf_count = leaves_under(node);
450 format!(
451 "ref {} · {} fork{} · {} leaf node{}",
452 short_hex(&r.to_hex(), 8),
453 fork_count,
454 if fork_count == 1 { "" } else { "s" },
455 leaf_count,
456 if leaf_count == 1 { "" } else { "s" }
457 )
458 }
459 }
460}
461
462fn leaves_under(node: &MantarayNode) -> usize {
463 if node.forks.is_empty() {
464 return if node.is_null_target() { 0 } else { 1 };
466 }
467 node.forks
468 .values()
469 .map(|fork| {
470 let t = fork.node.determine_type();
471 (t & TYPE_VALUE != 0) as usize
472 })
473 .sum()
474}
475
476fn walk_into_rows(
477 node: &MantarayNode,
478 depth: u8,
479 forks_loaded: &HashMap<[u8; 32], NodeState>,
480 expanded: &HashSet<[u8; 32]>,
481 rows: &mut Vec<TreeRow>,
482) {
483 for fork in node.forks.values() {
484 let typ = fork.node.determine_type();
485 let has_children = (typ & TYPE_EDGE) != 0;
486 let has_target = (typ & TYPE_VALUE) != 0;
487 let self_addr = fork.node.self_address;
488 let target_ref_hex = if has_target && !fork.node.is_null_target() {
489 Some(hex_lower(&fork.node.target_address))
490 } else {
491 None
492 };
493 let content_type = fork
494 .node
495 .metadata
496 .as_ref()
497 .and_then(|m| m.get("Content-Type").or_else(|| m.get("content-type")))
498 .cloned();
499
500 let is_expanded = self_addr
501 .as_ref()
502 .map(|a| expanded.contains(a))
503 .unwrap_or(false);
504
505 let load_state = self_addr.as_ref().and_then(|a| forks_loaded.get(a));
506 let state_hint = match load_state {
507 Some(NodeState::Loading) => Some("loading…".to_string()),
508 Some(NodeState::Error(e)) => Some(format!("error: {e}")),
509 _ => None,
510 };
511 let glyph = if state_hint
512 .as_deref()
513 .map(|s| s.starts_with("loading"))
514 .unwrap_or(false)
515 {
516 '⌛'
517 } else if state_hint
518 .as_deref()
519 .map(|s| s.starts_with("error"))
520 .unwrap_or(false)
521 {
522 '✗'
523 } else if has_children && is_expanded {
524 '▼'
525 } else if has_children {
526 '▶'
527 } else {
528 '·'
529 };
530
531 rows.push(TreeRow {
532 depth,
533 label: prefix_to_label(&fork.prefix),
534 glyph,
535 has_children,
536 self_addr_hex: self_addr.map(|a| hex_lower(&a)),
537 target_ref_hex,
538 content_type,
539 state_hint,
540 });
541
542 if is_expanded {
544 if let Some(addr) = self_addr {
545 if let Some(NodeState::Loaded(child)) = forks_loaded.get(&addr) {
546 walk_into_rows(child, depth.saturating_add(1), forks_loaded, expanded, rows);
547 }
548 }
549 }
550 }
551}
552
553fn prefix_to_label(prefix: &[u8]) -> String {
554 if prefix.is_empty() {
555 return "(empty)".into();
556 }
557 if let Ok(s) = std::str::from_utf8(prefix) {
558 if s.chars().all(|c| !c.is_control()) {
559 return s.to_string();
560 }
561 }
562 hex_lower(prefix)
563}
564
565fn hex_lower(b: &[u8]) -> String {
566 let mut out = String::with_capacity(b.len() * 2);
567 for byte in b {
568 out.push_str(&format!("{byte:02x}"));
569 }
570 out
571}
572
573fn short_hex(s: &str, n: usize) -> String {
574 if s.len() <= n * 2 + 1 {
575 s.to_string()
576 } else {
577 format!("{}…{}", &s[..n], &s[s.len() - n..])
578 }
579}
580
581fn parse_hex_32(s: &str) -> std::result::Result<[u8; 32], String> {
582 let cleaned = s.trim().trim_start_matches("0x");
583 if cleaned.len() != 64 {
584 return Err(format!("expected 64 hex chars, got {}", cleaned.len()));
585 }
586 let mut out = [0u8; 32];
587 for i in 0..32 {
588 out[i] = u8::from_str_radix(&cleaned[2 * i..2 * i + 2], 16)
589 .map_err(|e| format!("hex: {e}"))?;
590 }
591 Ok(out)
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 fn empty_state() -> (
599 NodeState,
600 HashMap<[u8; 32], NodeState>,
601 HashSet<[u8; 32]>,
602 ) {
603 (NodeState::Idle, HashMap::new(), HashSet::new())
604 }
605
606 #[test]
607 fn header_explains_no_load_yet() {
608 let (root, loaded, expanded) = empty_state();
609 let view = Manifest::view_for(None, &root, &loaded, &expanded);
610 assert!(view.header.contains("no manifest loaded"), "{}", view.header);
611 assert!(view.rows.is_empty());
612 }
613
614 #[test]
615 fn header_explains_loading_state() {
616 let (_, loaded, expanded) = empty_state();
617 let root = NodeState::Loading;
618 let r = Reference::from_hex(&"0".repeat(64)).unwrap();
619 let view = Manifest::view_for(Some(&r), &root, &loaded, &expanded);
620 assert!(view.header.contains("loading"), "{}", view.header);
621 assert!(view.rows.is_empty());
622 }
623
624 #[test]
625 fn header_propagates_load_error() {
626 let (_, loaded, expanded) = empty_state();
627 let root = NodeState::Error("download_chunk: 404".into());
628 let r = Reference::from_hex(&"0".repeat(64)).unwrap();
629 let view = Manifest::view_for(Some(&r), &root, &loaded, &expanded);
630 assert!(view.header.contains("error"), "{}", view.header);
631 assert!(view.header.contains("404"), "{}", view.header);
632 }
633
634 #[test]
635 fn prefix_to_label_renders_utf8_when_possible() {
636 assert_eq!(prefix_to_label(b"index.html"), "index.html");
637 assert_eq!(prefix_to_label(&[]), "(empty)");
638 assert_eq!(prefix_to_label(&[0x00, 0x01, 0xff]), "0001ff");
640 }
641
642 #[test]
643 fn short_hex_keeps_short_strings_intact() {
644 assert_eq!(short_hex("abcd", 4), "abcd");
645 let long = "a".repeat(64);
646 let s = short_hex(&long, 8);
647 assert!(s.contains('…'));
648 assert_eq!(s.chars().filter(|c| *c == 'a').count(), 16);
649 }
650
651 #[test]
652 fn parse_hex_32_round_trip() {
653 let s = "ab".repeat(32);
654 let arr = parse_hex_32(&s).unwrap();
655 assert_eq!(arr[0], 0xab);
656 assert_eq!(arr[31], 0xab);
657 assert!(parse_hex_32(&"a".repeat(63)).is_err());
658 assert!(parse_hex_32("0xABABA").is_err());
659 }
660
661 #[test]
662 fn view_with_no_root_loaded_has_zero_rows() {
663 let (root, loaded, expanded) = empty_state();
664 let view = Manifest::view_for(None, &root, &loaded, &expanded);
665 assert_eq!(view.rows.len(), 0);
666 assert_eq!(view.root_ref_hex, None);
667 }
668}