nornir 0.4.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Generic warehouse browser — lists every Iceberg table in the namespace and
//! renders any one as an egui grid of stringified rows. The "see all the data"
//! tab.
//!
//! Works in both modes:
//! - **Local**: opens the warehouse directly (open → scan → drop per action, so
//!   it never holds the redb lock between clicks). Works when no `nornir-server`
//!   owns the warehouse.
//! - **Remote**: goes through the server's `Warehouse.Tables` / `Warehouse.Scan`
//!   gRPC — so a viz pointed at a server (e.g. a friend over Tailscale) browses
//!   the same data the server owns, lock and all.

use std::path::PathBuf;

use eframe::egui::{self, Color32, ScrollArea};

use crate::warehouse::iceberg::{IcebergWarehouse, TablePreview};

const PREVIEW_LIMIT: usize = 500;
const CELL_MAX_CHARS: usize = 80;

enum WhSource {
    Local(PathBuf),
    Remote { endpoint: String, token: String },
}

pub struct WarehouseBrowser {
    source: WhSource,
    tables: Vec<String>,
    selected: Option<String>,
    preview: Option<Result<TablePreview, String>>,
    err: Option<String>,
    listed: bool,
}

impl WarehouseBrowser {
    /// Browse a local warehouse dir directly (the `Source::Local` path).
    pub fn local(root: PathBuf) -> Self {
        Self::with(WhSource::Local(root))
    }

    /// Browse a remote server's warehouse over gRPC.
    pub fn remote(endpoint: String, token: String) -> Self {
        Self::with(WhSource::Remote { endpoint, token })
    }

    fn with(source: WhSource) -> Self {
        Self { source, tables: Vec::new(), selected: None, preview: None, err: None, listed: false }
    }

    pub(crate) fn refresh_tables(&mut self) {
        self.listed = true;
        let res = match &self.source {
            WhSource::Local(root) => {
                IcebergWarehouse::open(root).and_then(|wh| wh.table_names())
            }
            WhSource::Remote { endpoint, token } => super::remote::fetch_tables(endpoint, token),
        };
        match res {
            Ok(names) => {
                self.tables = names;
                self.err = None;
            }
            Err(e) => {
                self.err = Some(match &self.source {
                    WhSource::Local(_) => format!(
                        "open warehouse failed: {e:#}\n(a running nornir-server holds the redb \
                         lock — stop it to browse locally, or point the viz at the server)"
                    ),
                    WhSource::Remote { .. } => format!("Warehouse.Tables failed: {e:#}"),
                });
            }
        }
    }

    fn load_preview(&mut self, table: &str) {
        let res = match &self.source {
            WhSource::Local(root) => {
                IcebergWarehouse::open(root).and_then(|wh| wh.scan_preview(table, PREVIEW_LIMIT))
            }
            WhSource::Remote { endpoint, token } => {
                super::remote::scan_table(endpoint, token, table, PREVIEW_LIMIT as u32)
            }
        };
        self.preview = Some(res.map_err(|e| format!("{e:#}")));
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        if !self.listed {
            self.refresh_tables();
        }

        egui::SidePanel::left("wh_tables").default_width(240.0).show_inside(ui, |ui| {
            ui.horizontal(|ui| {
                ui.heading(format!("tables ({})", self.tables.len()));
                if ui.button("").clicked() {
                    self.refresh_tables();
                }
            });
            ui.separator();
            ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                let tables = self.tables.clone();
                for name in &tables {
                    let selected = self.selected.as_deref() == Some(name.as_str());
                    if ui.selectable_label(selected, name).clicked() {
                        self.selected = Some(name.clone());
                        self.load_preview(name);
                    }
                }
            });
        });

        egui::CentralPanel::default().show_inside(ui, |ui| {
            if let Some(err) = &self.err {
                ui.colored_label(Color32::RED, err);
                return;
            }
            let Some(table) = self.selected.clone() else {
                ui.label("select a table on the left to view its rows");
                return;
            };
            match &self.preview {
                Some(Ok(p)) => {
                    ui.horizontal(|ui| {
                        ui.heading(&table);
                        ui.label(format!(
                            "· {} rows shown (cap {PREVIEW_LIMIT}) · {} columns",
                            p.rows.len(),
                            p.columns.len()
                        ));
                    });
                    ui.separator();
                    ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
                        egui::Grid::new("wh_grid")
                            .striped(true)
                            .num_columns(p.columns.len())
                            .show(ui, |ui| {
                                for c in &p.columns {
                                    ui.strong(c);
                                }
                                ui.end_row();
                                for row in &p.rows {
                                    for cell in row {
                                        ui.label(truncate(cell));
                                    }
                                    ui.end_row();
                                }
                            });
                    });
                }
                Some(Err(e)) => {
                    ui.colored_label(Color32::RED, e);
                }
                None => {
                    ui.label("loading…");
                }
            }
        });
    }
}

/// Char-safe truncation (never splits a multibyte char) for grid cells.
fn truncate(s: &str) -> String {
    if s.chars().count() > CELL_MAX_CHARS {
        let head: String = s.chars().take(CELL_MAX_CHARS).collect();
        format!("{head}")
    } else {
        s.to_string()
    }
}