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(detail, Style::default().fg(t.dim)))),
407 chunks[2],
408 );
409 }
410
411 let footer = Line::from(vec![
413 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
414 Span::raw(" switch screen "),
415 Span::styled(
416 " ↑↓/jk ",
417 Style::default().fg(Color::Black).bg(Color::White),
418 ),
419 Span::raw(" select "),
420 Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
421 Span::raw(" expand/collapse "),
422 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
423 Span::raw(" help "),
424 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
425 Span::raw(" quit "),
426 ]);
427 frame.render_widget(Paragraph::new(footer), chunks[3]);
428 Ok(())
429 }
430}
431
432fn build_header(root_ref: Option<&Reference>, root: &NodeState) -> String {
435 match (root_ref, root) {
436 (None, _) => "no manifest loaded — type :manifest <ref> or :inspect <ref>".into(),
437 (Some(r), NodeState::Idle) => format!("ref {} — pending", short_hex(&r.to_hex(), 8)),
438 (Some(r), NodeState::Loading) => {
439 format!("ref {} — loading root chunk…", short_hex(&r.to_hex(), 8))
440 }
441 (Some(r), NodeState::Error(e)) => {
442 format!("ref {} — error: {}", short_hex(&r.to_hex(), 8), e)
443 }
444 (Some(r), NodeState::Loaded(node)) => {
445 let fork_count = node.forks.len();
446 let leaf_count = leaves_under(node);
447 format!(
448 "ref {} · {} fork{} · {} leaf node{}",
449 short_hex(&r.to_hex(), 8),
450 fork_count,
451 if fork_count == 1 { "" } else { "s" },
452 leaf_count,
453 if leaf_count == 1 { "" } else { "s" }
454 )
455 }
456 }
457}
458
459fn leaves_under(node: &MantarayNode) -> usize {
460 if node.forks.is_empty() {
461 return if node.is_null_target() { 0 } else { 1 };
463 }
464 node.forks
465 .values()
466 .map(|fork| {
467 let t = fork.node.determine_type();
468 (t & TYPE_VALUE != 0) as usize
469 })
470 .sum()
471}
472
473fn walk_into_rows(
474 node: &MantarayNode,
475 depth: u8,
476 forks_loaded: &HashMap<[u8; 32], NodeState>,
477 expanded: &HashSet<[u8; 32]>,
478 rows: &mut Vec<TreeRow>,
479) {
480 for fork in node.forks.values() {
481 let typ = fork.node.determine_type();
482 let has_children = (typ & TYPE_EDGE) != 0;
483 let has_target = (typ & TYPE_VALUE) != 0;
484 let self_addr = fork.node.self_address;
485 let target_ref_hex = if has_target && !fork.node.is_null_target() {
486 Some(hex_lower(&fork.node.target_address))
487 } else {
488 None
489 };
490 let content_type = fork
491 .node
492 .metadata
493 .as_ref()
494 .and_then(|m| m.get("Content-Type").or_else(|| m.get("content-type")))
495 .cloned();
496
497 let is_expanded = self_addr
498 .as_ref()
499 .map(|a| expanded.contains(a))
500 .unwrap_or(false);
501
502 let load_state = self_addr.as_ref().and_then(|a| forks_loaded.get(a));
503 let state_hint = match load_state {
504 Some(NodeState::Loading) => Some("loading…".to_string()),
505 Some(NodeState::Error(e)) => Some(format!("error: {e}")),
506 _ => None,
507 };
508 let glyph = if state_hint
509 .as_deref()
510 .map(|s| s.starts_with("loading"))
511 .unwrap_or(false)
512 {
513 '⌛'
514 } else if state_hint
515 .as_deref()
516 .map(|s| s.starts_with("error"))
517 .unwrap_or(false)
518 {
519 '✗'
520 } else if has_children && is_expanded {
521 '▼'
522 } else if has_children {
523 '▶'
524 } else {
525 '·'
526 };
527
528 rows.push(TreeRow {
529 depth,
530 label: prefix_to_label(&fork.prefix),
531 glyph,
532 has_children,
533 self_addr_hex: self_addr.map(|a| hex_lower(&a)),
534 target_ref_hex,
535 content_type,
536 state_hint,
537 });
538
539 if is_expanded {
541 if let Some(addr) = self_addr {
542 if let Some(NodeState::Loaded(child)) = forks_loaded.get(&addr) {
543 walk_into_rows(child, depth.saturating_add(1), forks_loaded, expanded, rows);
544 }
545 }
546 }
547 }
548}
549
550fn prefix_to_label(prefix: &[u8]) -> String {
551 if prefix.is_empty() {
552 return "(empty)".into();
553 }
554 if let Ok(s) = std::str::from_utf8(prefix) {
555 if s.chars().all(|c| !c.is_control()) {
556 return s.to_string();
557 }
558 }
559 hex_lower(prefix)
560}
561
562fn hex_lower(b: &[u8]) -> String {
563 let mut out = String::with_capacity(b.len() * 2);
564 for byte in b {
565 out.push_str(&format!("{byte:02x}"));
566 }
567 out
568}
569
570fn short_hex(s: &str, n: usize) -> String {
571 if s.len() <= n * 2 + 1 {
572 s.to_string()
573 } else {
574 format!("{}…{}", &s[..n], &s[s.len() - n..])
575 }
576}
577
578fn parse_hex_32(s: &str) -> std::result::Result<[u8; 32], String> {
579 let cleaned = s.trim().trim_start_matches("0x");
580 if cleaned.len() != 64 {
581 return Err(format!("expected 64 hex chars, got {}", cleaned.len()));
582 }
583 let mut out = [0u8; 32];
584 for i in 0..32 {
585 out[i] =
586 u8::from_str_radix(&cleaned[2 * i..2 * i + 2], 16).map_err(|e| format!("hex: {e}"))?;
587 }
588 Ok(out)
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 fn empty_state() -> (NodeState, HashMap<[u8; 32], NodeState>, HashSet<[u8; 32]>) {
596 (NodeState::Idle, HashMap::new(), HashSet::new())
597 }
598
599 #[test]
600 fn header_explains_no_load_yet() {
601 let (root, loaded, expanded) = empty_state();
602 let view = Manifest::view_for(None, &root, &loaded, &expanded);
603 assert!(
604 view.header.contains("no manifest loaded"),
605 "{}",
606 view.header
607 );
608 assert!(view.rows.is_empty());
609 }
610
611 #[test]
612 fn header_explains_loading_state() {
613 let (_, loaded, expanded) = empty_state();
614 let root = NodeState::Loading;
615 let r = Reference::from_hex(&"0".repeat(64)).unwrap();
616 let view = Manifest::view_for(Some(&r), &root, &loaded, &expanded);
617 assert!(view.header.contains("loading"), "{}", view.header);
618 assert!(view.rows.is_empty());
619 }
620
621 #[test]
622 fn header_propagates_load_error() {
623 let (_, loaded, expanded) = empty_state();
624 let root = NodeState::Error("download_chunk: 404".into());
625 let r = Reference::from_hex(&"0".repeat(64)).unwrap();
626 let view = Manifest::view_for(Some(&r), &root, &loaded, &expanded);
627 assert!(view.header.contains("error"), "{}", view.header);
628 assert!(view.header.contains("404"), "{}", view.header);
629 }
630
631 #[test]
632 fn prefix_to_label_renders_utf8_when_possible() {
633 assert_eq!(prefix_to_label(b"index.html"), "index.html");
634 assert_eq!(prefix_to_label(&[]), "(empty)");
635 assert_eq!(prefix_to_label(&[0x00, 0x01, 0xff]), "0001ff");
637 }
638
639 #[test]
640 fn short_hex_keeps_short_strings_intact() {
641 assert_eq!(short_hex("abcd", 4), "abcd");
642 let long = "a".repeat(64);
643 let s = short_hex(&long, 8);
644 assert!(s.contains('…'));
645 assert_eq!(s.chars().filter(|c| *c == 'a').count(), 16);
646 }
647
648 #[test]
649 fn parse_hex_32_round_trip() {
650 let s = "ab".repeat(32);
651 let arr = parse_hex_32(&s).unwrap();
652 assert_eq!(arr[0], 0xab);
653 assert_eq!(arr[31], 0xab);
654 assert!(parse_hex_32(&"a".repeat(63)).is_err());
655 assert!(parse_hex_32("0xABABA").is_err());
656 }
657
658 #[test]
659 fn view_with_no_root_loaded_has_zero_rows() {
660 let (root, loaded, expanded) = empty_state();
661 let view = Manifest::view_for(None, &root, &loaded, &expanded);
662 assert_eq!(view.rows.len(), 0);
663 assert_eq!(view.root_ref_hex, None);
664 }
665}