Skip to main content

bee_tui/components/
manifest.rs

1//! S12 — Manifests screen (Mantaray tree browser).
2//!
3//! The first screen in bee-tui that gives operators X-ray vision into
4//! their *data* (not just their node). `:manifest <ref>` loads a
5//! Swarm reference's chunk; if it parses as a Mantaray manifest, the
6//! tree is opened here. `:inspect <ref>` is the universal "what is
7//! this thing?" verb that routes manifests to S12 and prints the
8//! shape of raw chunks.
9//!
10//! ## Render path
11//!
12//! Pure [`Manifest::view_for`] turns `(root, loaded, expanded)` into
13//! a flat [`ManifestView`] of [`TreeRow`]s. Snapshot tests in
14//! `tests/s12_manifest_view.rs` lock the rendering against future
15//! refactors.
16//!
17//! ## Tree state machine
18//!
19//! - `root: NodeState` — the root chunk's load status.
20//! - `forks_loaded: HashMap<[u8;32], NodeState>` — child fork nodes,
21//!   keyed by `self_address`.
22//! - `expanded: HashSet<[u8;32]>` — which fork-self-addresses are
23//!   currently shown expanded in the visible tree.
24//!
25//! Pressing Enter on a row that has children either toggles its
26//! expanded flag (cheap) or starts an async fetch (when the child
27//! isn't loaded yet).
28//!
29//! ## What's intentionally out of scope (v1)
30//!
31//! - **Encrypted refs (64 bytes).** v1.2 returns an error; once the
32//!   recursive walk handles obfuscation-key derivation we can lift it.
33//! - **Manifest editing / re-uploading.** Read-only browser only.
34//! - **Path-based addressing.** Operator types a chunk reference, not
35//!   `<ref>/path/in/manifest`. Path-resolve lives in bee-rs's
36//!   `resolve_path`; a v1.4 follow-up can pre-walk to a specified path.
37
38use 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/// Per-node load status. Distinguishes "operator hasn't asked"
61/// (`Idle`) from "we're fetching" (`Loading`) from "we have it"
62/// (`Loaded`) from "fetch or parse failed" (`Error`).
63#[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/// One row in the flat-rendered tree. Pure data; the renderer just
81/// walks `ManifestView::rows`.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct TreeRow {
84    /// Indentation level (0 = root's direct children).
85    pub depth: u8,
86    /// Path segment for this fork (UTF-8-decoded prefix bytes if
87    /// valid, else hex-escaped). `(root)` for depth-0 root summary.
88    pub label: String,
89    /// Glyph that signals state at a glance:
90    /// `▼` expanded, `▶` collapsed-with-children, `·` leaf-only-target,
91    /// `⌛` loading, `✗` error.
92    pub glyph: char,
93    /// True when this fork has child forks (TYPE_EDGE bit set).
94    pub has_children: bool,
95    /// Self-address of this fork's node, hex-encoded. Used to key
96    /// expand-state and load-state.
97    pub self_addr_hex: Option<String>,
98    /// File reference if this fork carries a target (TYPE_VALUE).
99    pub target_ref_hex: Option<String>,
100    /// Content-type from metadata, when present.
101    pub content_type: Option<String>,
102    /// Auxiliary status hint rendered in muted color
103    /// (`loading…`, `error: …`).
104    pub state_hint: Option<String>,
105}
106
107/// View fed to the renderer + snapshot tests. Pure transform of the
108/// component's state.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ManifestView {
111    /// Hex-encoded root reference, if `:manifest` has fired.
112    pub root_ref_hex: Option<String>,
113    /// One-line header summary: "32-byte chunk · 12 forks · 4 leaf nodes"
114    /// or "no manifest loaded — type :manifest <ref>".
115    pub header: String,
116    /// Flat list of visible rows.
117    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    /// Set when `:manifest <ref>` fires; cleared on `:manifest` with
131    /// no arg (or by typing a different ref over the existing one).
132    root_ref: Option<Reference>,
133    root: NodeState,
134    /// Per-self-address load states for child fork nodes.
135    forks_loaded: HashMap<[u8; 32], NodeState>,
136    /// Self-addresses of forks that are currently expanded in the UI.
137    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    /// Kick off a root-chunk fetch for `reference`. Replaces any
161    /// in-flight or completed manifest. Async; the result lands
162    /// on the fetch channel and is drained on the next Tick.
163    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    /// Build the visible-tree row list from current state.
199    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    /// Toggle expand on the highlighted row. If the fork's child is
238    /// not yet loaded, kick off an async fetch instead.
239    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        // Need to expand. Either we already have the child node or we
259        // start a fetch.
260        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        // Mark expanded immediately so the operator gets feedback;
282        // the row will render `⌛ loading…` until the fetch lands.
283        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        // 4-row split: header / body / detail / footer.
313        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        // Header: chunk size + fork/leaf summary or load-state hint.
322        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        // Body: tree rows.
337        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            // Clamp scroll/selected.
345            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        // Detail: full ID under cursor for click-drag copy.
398        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        // Footer keymap.
415        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
435// ---- pure helpers ------------------------------------------------------
436
437fn 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        // A node with no forks is itself a leaf if it has a target.
465        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        // Recurse if expanded + we have the child loaded.
543        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        // Control byte forces hex fallback.
639        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}