Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/src/explorer.rs

//! **🧭 Explorer — the first tab of ⚒ Build**: the Dwarves project as a tree on the
//! left (project → Mímir → Workspaces → Build Things → Pipelines), the selected node
//! on the right.
//!
//! | selected | right side |
//! |---|---|
//! | workspace | its repos and their state, the live unit graph (the 🌳 Tree pane, embedded), recent builds, `▶ Build workspace` |
//! | Build Thing | its variants, the artifacts Builder 2 landed in Mímir, its builds, `▶ Build` |
//! | pipeline | its stages, its recent runs, `▶ Run` |
//!
//! A thin layer: every fact comes from the forge (`dwarf.v1.Explorer`,
//! [`dwarves_embed::explorer`]); the tab folds nothing, keeps no store and decides no
//! permission. A question is answered at once — the fold the forge keeps, or the jera
//! job folding it — and asked again off the UI thread while it waits. A button queues
//! a jera job on the forge; the tab follows that job (`queued → running → done`) by
//! asking `Explorer.Job`, and asks the tree again when it ends, so the run shows in
//! the history. Links open the full tabs (🌳 Tree, ⚒ Build Thing, Pipeline).

use std::sync::Arc;
use std::time::{Duration, Instant};

use dwarves_embed::explorer::Reply;
use facett_core::Facet;
use serde_json::{json, Value};

use crate::front_tree::FrontTree;
use crate::room::Pending;

/// How often an open tab asks the tree again.
pub const REFRESH: Duration = Duration::from_secs(2);

/// How soon a waiting answer is asked again.
pub const REASK: Duration = Duration::from_millis(300);

/// How often a followed job is asked about.
pub const JOB_POLL: Duration = Duration::from_millis(250);

/// The tab's label (the 🧭 lives in the strip text).
pub const TITLE: &str = "Explorer";

/// **The cargo feature choice the ▶ Build button sends** — the picked variant's OPEN
/// features ticked on screen, and the two flags.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CargoChoice {
    pub features: Vec<String>,
    pub no_default_features: bool,
    pub all_features: bool,
}

/// Where the tab's answers come from. Injected, so a test can stand the tab on canned
/// answers; the shipped source is [`ForgeExplorer`].
pub trait ExplorerSource: Send + Sync {
    fn ask(&self, project: &str, select: &str, fresh: bool) -> Reply;
    fn build_workspace(&self, project: &str, workspace: &str, only: &str) -> Reply;
    fn build_thing(&self, project: &str, name: &str, variant: &str, cargo: &CargoChoice) -> Reply;
    fn run_pipeline(&self, project: &str, name: &str) -> Reply;
    fn job(&self, job_id: &str) -> Reply;
    fn describe(&self) -> String;
}

/// The shipped source: the forge's `Explorer` service.
pub struct ForgeExplorer {
    pub endpoint: String,
    pub token: String,
}

impl ExplorerSource for ForgeExplorer {
    fn ask(&self, project: &str, select: &str, fresh: bool) -> Reply {
        dwarves_embed::explorer::ask(&self.endpoint, &self.token, project, select, fresh)
    }
    fn build_workspace(&self, project: &str, workspace: &str, only: &str) -> Reply {
        dwarves_embed::explorer::build_workspace(
            &self.endpoint,
            &self.token,
            project,
            workspace,
            only,
        )
    }
    fn build_thing(&self, project: &str, name: &str, variant: &str, cargo: &CargoChoice) -> Reply {
        dwarves_embed::explorer::build_thing_with(
            &self.endpoint,
            &self.token,
            project,
            name,
            variant,
            &cargo.features,
            cargo.no_default_features,
            cargo.all_features,
        )
    }
    fn run_pipeline(&self, project: &str, name: &str) -> Reply {
        dwarves_embed::explorer::run_pipeline(&self.endpoint, &self.token, project, name)
    }
    fn job(&self, job_id: &str) -> Reply {
        dwarves_embed::explorer::job(&self.endpoint, &self.token, job_id)
    }
    fn describe(&self) -> String {
        format!("Explorer @ {}", self.endpoint)
    }
}

/// The icon a node kind wears in the tree.
pub fn icon(kind: &str) -> &'static str {
    match kind {
        "project" => "📦",
        "mimir" => "👁",
        "group" => "📁",
        "workspace" => "📚",
        "build_thing" => "🔨",
        "pipeline" => "🔀",
        _ => "•",
    }
}

/// The text a tree row is drawn (and found by a robot) with: `<icon> <label>`.
pub fn row_text(node: &Value) -> String {
    format!(
        "{} {}",
        icon(node["kind"].as_str().unwrap_or("")),
        node["label"].as_str().unwrap_or("?")
    )
}

/// A job being followed: its id, the last answer, every state it was seen in.
struct Followed {
    id: String,
    reply: Reply,
    seen: Vec<String>,
    pending: Option<Pending<Reply>>,
    polled: Instant,
}

impl Followed {
    fn terminal(&self) -> bool {
        matches!(
            self.reply.state.as_str(),
            "done" | "failed" | "unknown" | "unreachable"
        )
    }
    fn see(&mut self, r: Reply) {
        if self.seen.last() != Some(&r.state) {
            self.seen.push(r.state.clone());
        }
        self.reply = r;
    }
}

/// The tab.
pub struct ExplorerTab {
    source: Arc<dyn ExplorerSource>,
    /// The forge endpoint + token, for the embedded 🌳 graph (`None` for an injected source).
    forge: Option<(String, String)>,
    project: String,
    select: String,
    /// The last reply to an ask, and the last fold it carried.
    reply: Reply,
    body: Value,
    pending: Option<Pending<Reply>>,
    landed: Option<Instant>,
    asked_fresh: bool,
    /// An action call in flight, and the last action's answer.
    act: Option<Pending<Reply>>,
    action: Option<Reply>,
    job: Option<Followed>,
    /// The workspace graph (🌳 Tree pane) of the selected workspace.
    graph: Option<(String, FrontTree)>,
    variant: String,
    /// The cargo feature choice for the picked variant (reset with it).
    cargo: CargoChoice,
    /// The last link followed: `{ tab, select }`.
    last_link: Option<Value>,
}

impl ExplorerTab {
    /// The shipped tab: asks the forge at `endpoint`.
    pub fn new(endpoint: String, token: String) -> Self {
        let mut me = Self::with_source(Arc::new(ForgeExplorer {
            endpoint: endpoint.clone(),
            token: token.clone(),
        }));
        me.forge = Some((endpoint, token));
        me
    }

    /// A tab over an injected source (tests).
    pub fn with_source(source: Arc<dyn ExplorerSource>) -> Self {
        Self {
            source,
            forge: None,
            project: String::new(),
            select: String::new(),
            reply: Reply::default(),
            body: Value::Null,
            pending: None,
            landed: None,
            asked_fresh: false,
            act: None,
            action: None,
            job: None,
            graph: None,
            variant: String::new(),
            cargo: CargoChoice::default(),
            last_link: None,
        }
    }

    /// Ask the forge (on a worker; the frame polls).
    fn ask(&mut self, fresh: bool) {
        let (src, project, select) = (
            Arc::clone(&self.source),
            self.project.clone(),
            self.select.clone(),
        );
        self.asked_fresh = fresh;
        self.pending = Some(Pending::spawn("explorer-ask", move || {
            src.ask(&project, &select, fresh)
        }));
    }

    fn land(&mut self, r: Reply) {
        let body = r.body();
        if !body.is_null() {
            if self.project.is_empty() {
                self.project = body["project"].as_str().unwrap_or("").to_string();
            }
            if self.select.is_empty() || body["select"] == self.select.as_str() {
                self.body = body;
            }
        }
        self.landed = Some(Instant::now());
        self.reply = r;
    }

    fn poll(&mut self) {
        if let Some(p) = &self.pending {
            match p.try_take() {
                None => {}
                Some(Ok(r)) => {
                    self.pending = None;
                    self.land(r);
                }
                Some(Err(())) => {
                    self.pending = None;
                    self.land(Reply {
                        state: "read_failed".into(),
                        note: "the ask worker died".into(),
                        ..Default::default()
                    });
                }
            }
        }
        if let Some(p) = &self.act {
            if let Some(r) = p.try_take() {
                self.act = None;
                self.acted(r.unwrap_or_else(|_| Reply {
                    state: "read_failed".into(),
                    note: "the action worker died".into(),
                    ..Default::default()
                }));
            }
        }
        let mut ended = false;
        if let Some(j) = &mut self.job {
            if let Some(p) = &j.pending {
                if let Some(r) = p.try_take() {
                    j.pending = None;
                    j.see(r.unwrap_or_else(|_| Reply {
                        state: "unreachable".into(),
                        note: "the job worker died".into(),
                        ..Default::default()
                    }));
                    ended = j.terminal();
                }
            }
            if j.pending.is_none() && !j.terminal() && j.polled.elapsed() >= JOB_POLL {
                let (src, id) = (Arc::clone(&self.source), j.id.clone());
                j.polled = Instant::now();
                j.pending = Some(Pending::spawn("explorer-job", move || src.job(&id)));
            }
        }
        if ended {
            // the run is in the history now: fold again
            self.ask(true);
        }
    }

    /// An action's answer: follow its job, or show its refusal.
    fn acted(&mut self, r: Reply) {
        if !r.job_id.is_empty() && r.is_waiting() {
            let mut f = Followed {
                id: r.job_id.clone(),
                reply: Reply::default(),
                seen: Vec::new(),
                pending: None,
                polled: Instant::now(),
            };
            f.see(r.clone());
            self.job = Some(f);
        }
        self.action = Some(r);
    }

    fn start_act(
        &mut self,
        what: impl FnOnce(&dyn ExplorerSource, &str) -> Reply + Send + 'static,
    ) {
        let (src, project) = (Arc::clone(&self.source), self.project.clone());
        self.act = Some(Pending::spawn("explorer-act", move || {
            what(&*src, &project)
        }));
    }

    fn select(&mut self, id: &str) {
        if self.select == id {
            return;
        }
        self.select = id.to_string();
        self.body = Value::Null;
        self.variant.clear();
        self.cargo = CargoChoice::default();
        self.ask(false);
    }

    /// The selected node's kind and name.
    fn selected(&self) -> (String, String) {
        let d = &self.body["detail"];
        (
            d["kind"].as_str().unwrap_or("").to_string(),
            d["name"].as_str().unwrap_or("").to_string(),
        )
    }

    fn build_workspace(&mut self, only: &str) {
        let (kind, ws) = self.selected();
        if kind != "workspace" {
            return;
        }
        let only = only.to_string();
        self.start_act(move |s, p| s.build_workspace(p, &ws, &only));
    }

    fn build_thing(&mut self, variant: &str) {
        let (kind, name) = self.selected();
        if kind != "build_thing" {
            return;
        }
        let variant = variant.to_string();
        let cargo = self.cargo.clone();
        self.start_act(move |s, p| s.build_thing(p, &name, &variant, &cargo));
    }

    fn run_pipeline(&mut self) {
        let (kind, name) = self.selected();
        if kind != "pipeline" {
            return;
        }
        self.start_act(move |s, p| s.run_pipeline(p, &name));
    }

    /// Keep the embedded graph on the selected workspace (none for another kind).
    fn sync_graph(&mut self) {
        let (kind, ws) = self.selected();
        match (&self.forge, kind.as_str()) {
            (Some((endpoint, token)), "workspace") => {
                if self.graph.as_ref().map(|(w, _)| w.as_str()) != Some(ws.as_str()) {
                    self.graph = Some((
                        ws.clone(),
                        FrontTree::new(endpoint.clone(), token.clone(), ws),
                    ));
                }
            }
            _ => self.graph = None,
        }
    }

    /// Block until the ask in flight lands, asking again while it waits (robot seam).
    pub fn wait(&mut self) {
        let t0 = Instant::now();
        loop {
            if self.pending.is_none() {
                self.ask(false);
            }
            if let Some(p) = self.pending.take() {
                let r = p.wait().unwrap_or_default();
                self.land(r);
            }
            if !self.reply.is_waiting() || t0.elapsed() > Duration::from_secs(60) {
                break;
            }
            std::thread::sleep(REASK);
        }
        self.sync_graph();
    }

    /// Block until the followed job ends (robot seam).
    pub fn wait_job(&mut self) {
        if let Some(p) = self.act.take() {
            let r = p.wait().unwrap_or_default();
            self.acted(r);
        }
        let t0 = Instant::now();
        while self.job.as_ref().is_some_and(|j| !j.terminal())
            && t0.elapsed() < Duration::from_secs(120)
        {
            self.poll();
            std::thread::sleep(Duration::from_millis(20));
        }
    }

    /// Open the selected node's full view, on that node ([`link_for`]).
    fn open_full_view(&mut self, ui: &egui::Ui) {
        if let Some((tab, select)) = link_for(&self.body["detail"], &self.variant) {
            self.goto(ui, tab, select);
        }
    }

    /// Open `tab` on `select` (handed to that tab's `update_json`), and remember it.
    fn goto(&mut self, ui: &egui::Ui, tab: &str, select: Value) {
        self.last_link = Some(json!({ "tab": tab, "select": select }));
        crate::tabbed::request_tab_with(ui.ctx(), tab, select);
    }
}

/// **Where a link from the selected node goes, and the selection it carries** — what
/// the target tab is told through its own `update_json`, the door a user selection
/// takes there: a workspace opens 🌲 Tree on it, a Build Thing opens ⚒ Build Thing
/// resolved on its repo (and on the picked variant), a pipeline opens Pipeline on its
/// file. `None` for a node with no full view.
pub fn link_for(detail: &Value, variant: &str) -> Option<(&'static str, Value)> {
    let s = |k: &str| detail[k].as_str().unwrap_or("").to_string();
    match detail["kind"].as_str()? {
        "workspace" => Some(("tree", json!({ "set_workspace": s("name") }))),
        "build_thing" => {
            let mut select = json!({ "artifact": s("repo"), "resolve": true });
            if !variant.is_empty() {
                select["pick_row"] = json!(variant);
            }
            Some(("build-thing", select))
        }
        "pipeline" => Some((
            "pipeline",
            json!({ "open_pipeline": { "repo": s("repo"), "file": s("file") } }),
        )),
        _ => None,
    }
}

/// Where a run of the history opens: the Forge tab, that run pinned.
pub fn run_link(run_id: &str) -> (&'static str, Value) {
    ("forge", json!({ "run_id": run_id }))
}

fn arr(v: &Value) -> &[Value] {
    v.as_array().map(Vec::as_slice).unwrap_or(&[])
}

fn ms_text(ms: Option<u64>) -> String {
    match ms {
        Some(ms) if ms >= 60_000 => format!("{}m{:02}s", ms / 60_000, (ms % 60_000) / 1000),
        Some(ms) => format!("{:.1}s", ms as f64 / 1000.0),
        None => "—".into(),
    }
}

fn state_color(state: &str) -> egui::Color32 {
    match state {
        "done" | "passed" | "landed" => egui::Color32::from_rgb(46, 150, 72),
        "failed" | "refused" | "unreachable" => egui::Color32::from_rgb(198, 40, 40),
        "queued" => egui::Color32::from_rgb(214, 160, 20),
        "running" | "compiling" => egui::Color32::from_rgb(40, 104, 214),
        _ => egui::Color32::from_rgb(110, 110, 110),
    }
}

fn chip(ui: &mut egui::Ui, text: &str) {
    ui.label(
        egui::RichText::new(format!(" {text} "))
            .small()
            .strong()
            .color(egui::Color32::WHITE)
            .background_color(state_color(text)),
    );
}

/// A grid of runs (`run_id · status · finished · took`); a run id links to that run
/// in the Forge tab — the clicked one is returned.
fn runs_grid(ui: &mut egui::Ui, id: &str, runs: &[Value]) -> Option<String> {
    if runs.is_empty() {
        ui.weak("no run in the forge's history yet");
        return None;
    }
    let mut clicked = None;
    egui::Grid::new(id).striped(true).show(ui, |ui| {
        for h in ["run", "status", "steps", "finished", "took", "units"] {
            ui.strong(h);
        }
        ui.end_row();
        for r in runs {
            let run_id = r["run_id"].as_str().unwrap_or("?");
            if ui
                .link(egui::RichText::new(run_id).monospace())
                .on_hover_text("open this run in the Forge tab")
                .clicked()
            {
                clicked = Some(run_id.to_string());
            }
            chip(ui, r["status"].as_str().unwrap_or("?"));
            ui.label(format!("{}/{}", r["passed"], r["steps"]));
            ui.label(r["finished_at"].as_str().unwrap_or(""));
            ui.label(ms_text(r["duration_ms"].as_u64()));
            units_cell(ui, &r["units"]);
            ui.end_row();
        }
    });
    clicked
}

/// A build's units (the forge's unit history): counts, and per repo + the failed
/// units on hover.
fn units_cell(ui: &mut egui::Ui, units: &Value) {
    let steps = arr(units);
    if steps.is_empty() {
        ui.weak("—");
        return;
    }
    for s in steps {
        let c = &s["counts"];
        let text = format!(
            "{} compiled · {} fresh · {} failed · {} skipped",
            c["compiled"], c["fresh"], c["failed"], c["skipped"]
        );
        let mut hover: Vec<String> = arr(&s["repos"])
            .iter()
            .map(|r| {
                format!(
                    "{}: {}",
                    r["repo"].as_str().unwrap_or("?"),
                    r["result"].as_str().unwrap_or("?")
                )
            })
            .collect();
        for f in arr(&s["failed"]) {
            hover.push(format!(
                "✗ {} {} — {}",
                f["crate"].as_str().unwrap_or("?"),
                f["target"].as_str().unwrap_or(""),
                f["error"]
                    .as_str()
                    .unwrap_or("")
                    .lines()
                    .next()
                    .unwrap_or("")
            ));
        }
        if let Some(why) = s["why"].as_str() {
            hover.push(why.to_string());
        }
        ui.label(text).on_hover_text(hover.join("\n"));
    }
}

/// The action jobs the forge holds for a node.
fn jobs_list(ui: &mut egui::Ui, jobs: &[Value]) {
    for j in jobs {
        ui.horizontal(|ui| {
            chip(ui, j["state"].as_str().unwrap_or("?"));
            ui.label(j["label"].as_str().unwrap_or(""));
            ui.weak(format!(
                "job {} · queued {} · ran {}",
                j["job_id"].as_str().unwrap_or("?"),
                j["queued_at"].as_str().unwrap_or(""),
                ms_text(j["ran_ms"].as_u64())
            ));
        });
    }
}

impl ExplorerTab {
    fn tree_ui(&mut self, ui: &mut egui::Ui) {
        let nodes: Vec<Value> = arr(&self.body["tree"]["nodes"]).to_vec();
        if nodes.is_empty() {
            ui.weak("no project tree yet");
            return;
        }
        let mut pick: Option<String> = None;
        let selected = self.body["select"].as_str().unwrap_or("").to_string();
        egui::ScrollArea::vertical()
            .id_salt("explorer-tree")
            .show(ui, |ui| {
                for n in &nodes {
                    let id = n["id"].as_str().unwrap_or("");
                    let depth = n["depth"].as_u64().unwrap_or(0) as f32;
                    ui.horizontal(|ui| {
                        ui.add_space(depth * 14.0);
                        let text = row_text(n);
                        let text = if n["kind"] == "group" || n["kind"] == "project" {
                            egui::RichText::new(text).strong()
                        } else {
                            egui::RichText::new(text)
                        };
                        if ui.selectable_label(id == selected, text).clicked() {
                            pick = Some(id.to_string());
                        }
                        let badge = n["badge"].as_str().unwrap_or("");
                        if !badge.is_empty() && n["kind"] != "project" {
                            ui.weak(badge);
                        }
                    });
                }
            });
        if let Some(id) = pick {
            self.select(&id);
        }
    }

    fn job_ui(&mut self, ui: &mut egui::Ui) {
        if let Some(a) = &self.action {
            if a.state == "refused" || a.state == "unreachable" {
                ui.horizontal_wrapped(|ui| {
                    chip(ui, &a.state);
                    ui.label(&a.note);
                });
            }
        }
        let Some(j) = &self.job else { return };
        let body = j.reply.body();
        ui.group(|ui| {
            ui.horizontal_wrapped(|ui| {
                chip(ui, &j.reply.state);
                ui.strong(body["label"].as_str().unwrap_or("job"));
                ui.weak(format!("job {} · {}", j.id, j.seen.join(" → ")));
                if !j.terminal() {
                    ui.spinner();
                }
            });
            ui.weak(&j.reply.note);
            let lines: Vec<&str> = arr(&body["lines"])
                .iter()
                .filter_map(Value::as_str)
                .collect();
            if !lines.is_empty() {
                if j.reply.state == "failed" {
                    // the whole log: a failed step's reason sits right under it
                    egui::ScrollArea::vertical()
                        .id_salt("explorer-job-log")
                        .max_height(320.0)
                        .show(ui, |ui| {
                            ui.label(egui::RichText::new(lines.join("\n")).monospace().small());
                        });
                } else {
                    let tail = &lines[lines.len().saturating_sub(12)..];
                    ui.label(egui::RichText::new(tail.join("\n")).monospace().small());
                }
            }
        });
    }

    fn workspace_ui(&mut self, ui: &mut egui::Ui) {
        // The details (registry, actions, repos, recent builds, jobs) in a top region
        // with a drag bar; the live unit graph below with ALL the remaining height
        // (facett_core::graph_split, remembered per viewer). It used to sit at a fixed
        // 420 px under everything else — "only the lower part of the window".
        facett_core::graph_split::header_above(ui, "explorer-workspace", |ui| {
            let d = self.body["detail"].clone();
            let ws = d["name"].as_str().unwrap_or("?").to_string();
            ui.horizontal_wrapped(|ui| {
                ui.heading(format!("📚 {ws}"));
                let reg = &d["registry"];
                if let Some(e) = reg["error"].as_str() {
                    chip(ui, "failed");
                    ui.label(format!("registry: {e}"));
                } else {
                    ui.weak(format!(
                        "{} repos · {} · {}",
                        reg["members"],
                        reg["mode"].as_str().unwrap_or("?"),
                        if reg["populated"] == true {
                            "populated"
                        } else {
                            "not populated"
                        }
                    ));
                }
                match d["board"]["error"].as_str() {
                    Some(e) => {
                        ui.weak(format!("· board: {e}"));
                    }
                    None if d["board"]["total"].as_u64().unwrap_or(0) == 0 => {
                        ui.weak("· board: no plan yet");
                    }
                    None => {
                        ui.weak(format!(
                            "· board {}/{} units done",
                            d["board"]["done"].as_u64().unwrap_or(0),
                            d["board"]["total"]
                        ));
                    }
                }
            });
            let busy = self.act.is_some() || self.job.as_ref().is_some_and(|j| !j.terminal());
            ui.horizontal(|ui| {
                if ui
                    .add_enabled(!busy, egui::Button::new("▶ Build workspace"))
                    .on_hover_text("queue the super build (all members) as a jera job on the forge")
                    .clicked()
                {
                    self.build_workspace("all");
                }
                let affected = arr(&d["only"])
                    .iter()
                    .find(|o| o["name"] == "affected")
                    .cloned()
                    .unwrap_or(Value::Null);
                let available = affected["available"] == true;
                if ui
                    .add_enabled(
                        available && !busy,
                        egui::Button::new("▶ Build only what changed"),
                    )
                    .on_hover_text(
                        "queue the super build of what moved and what depends on it (--affected)",
                    )
                    .on_disabled_hover_text(affected["why"].as_str().unwrap_or("not available"))
                    .clicked()
                {
                    self.build_workspace("affected");
                }
                if ui.link("open 🌲 Tree").clicked() {
                    self.open_full_view(ui);
                }
            });
            self.job_ui(ui);
            ui.separator();
            ui.strong("repos");
            egui::Grid::new("explorer-repos")
                .striped(true)
                .show(ui, |ui| {
                    for h in ["repo", "state", "units", "sha", ""] {
                        ui.strong(h);
                    }
                    ui.end_row();
                    for r in arr(&d["repos"]) {
                        ui.label(r["repo"].as_str().unwrap_or("?"));
                        chip(ui, r["phase"].as_str().unwrap_or("?"));
                        ui.label(match (r["done"].as_u64(), r["units"].as_u64()) {
                            (Some(a), Some(b)) => format!("{a}/{b}"),
                            _ => "—".into(),
                        });
                        ui.monospace(
                            r["sha"]
                                .as_str()
                                .unwrap_or("")
                                .chars()
                                .take(10)
                                .collect::<String>(),
                        );
                        let mut marks = Vec::new();
                        if r["build_thing"] == true {
                            marks.push("🔨".to_string());
                        }
                        if r["pipelines"].as_u64().unwrap_or(0) > 0 {
                            marks.push(format!("🔀×{}", r["pipelines"]));
                        }
                        ui.label(marks.join(" "));
                        ui.end_row();
                    }
                });
            ui.separator();
            ui.strong(format!(
                "recent builds ({})",
                d["pipeline"].as_str().unwrap_or("")
            ));
            if let Some(run) = runs_grid(ui, "explorer-ws-runs", arr(&d["builds"])) {
                let (tab, select) = run_link(&run);
                self.goto(ui, tab, select);
            }
            jobs_list(ui, arr(&d["jobs"]));
        });
        ui.strong("🌲 live unit graph");
        match &mut self.graph {
            Some((_, g)) => g.ui(ui),
            None => {
                ui.weak("the graph is drawn from the forge's 🌲 Tree surface");
            }
        }
    }

    fn build_thing_ui(&mut self, ui: &mut egui::Ui) {
        let d = self.body["detail"].clone();
        ui.horizontal_wrapped(|ui| {
            ui.heading(format!("🔨 {}", d["name"].as_str().unwrap_or("?")));
            ui.weak(format!(
                "repo {} · workspace {}",
                d["repo"].as_str().unwrap_or("?"),
                d["workspace"].as_str().unwrap_or("?")
            ));
            match d["checkout"]["path"].as_str() {
                Some(p) => ui.weak(format!("· {p}")),
                None => ui.weak(format!(
                    "· checkout: {}",
                    d["checkout"]["error"].as_str().unwrap_or("?")
                )),
            };
        });
        ui.horizontal_wrapped(|ui| {
            ui.strong("variants");
            match d["variants"].as_array() {
                Some(vs) => {
                    for v in vs {
                        let name = v["name"].as_str().unwrap_or("?");
                        let text = if v["default"] == true {
                            format!("{name} (default)")
                        } else {
                            name.to_string()
                        };
                        let chosen = v["chosen"] == true;
                        if ui
                            .add_enabled(
                                chosen,
                                egui::Button::selectable(self.variant == name, text),
                            )
                            .clicked()
                        {
                            self.variant = if self.variant == name {
                                String::new()
                            } else {
                                name.to_string()
                            };
                            self.cargo = CargoChoice::default();
                        }
                    }
                }
                None => {
                    ui.label(format!(
                        "not read: {}",
                        d["variants"]["error"].as_str().unwrap_or("?")
                    ));
                }
            }
        });
        // The picked variant's OPEN cargo features, as toggles — what ▶ Build sends
        // (`--features`), plus the two flags. Only for a picked variant: the
        // default build is the declaration as written.
        let open: Vec<String> = arr(&d["variants"])
            .iter()
            .find(|v| v["name"].as_str() == Some(self.variant.as_str()))
            .map(|v| arr(&v["open"]).iter().filter_map(|f| f.as_str().map(str::to_string)).collect())
            .unwrap_or_default();
        if !self.variant.is_empty() {
            ui.horizontal_wrapped(|ui| {
                ui.strong("features");
                for f in &open {
                    let mut on = self.cargo.features.contains(f);
                    if ui.checkbox(&mut on, f.as_str()).on_hover_text(format!("--features {f}")).changed() {
                        if on {
                            self.cargo.features.push(f.clone());
                        } else {
                            self.cargo.features.retain(|x| x != f);
                        }
                    }
                }
                ui.checkbox(&mut self.cargo.no_default_features, "--no-default-features");
                ui.checkbox(&mut self.cargo.all_features, "--all-features");
            });
        }
        let busy = self.act.is_some() || self.job.as_ref().is_some_and(|j| !j.terminal());
        ui.horizontal(|ui| {
            let label = if self.variant.is_empty() {
                "▶ Build".to_string()
            } else {
                format!("▶ Build {}", self.variant)
            };
            if ui
                .add_enabled(!busy, egui::Button::new(label))
                .on_hover_text("queue the build as a jera job on the forge")
                .clicked()
            {
                let v = self.variant.clone();
                self.build_thing(&v);
            }
            if ui.link("open ⚒ Build Thing").clicked() {
                self.open_full_view(ui);
            }
        });
        self.job_ui(ui);
        ui.separator();
        match d["artifacts"]["error"].as_str() {
            Some(e) => {
                ui.strong("artifacts");
                ui.label(format!("Coderoom.Artifacts: {e}"));
            }
            None => {
                ui.strong(format!("artifacts in Mímir ({})", d["artifacts"]["count"]));
                let rows = arr(&d["artifacts"]["rows"]);
                if rows.is_empty() {
                    ui.weak("no Build Thing run of this repo has landed in Mímir yet");
                } else {
                    egui::Grid::new("explorer-artifacts")
                        .striped(true)
                        .show(ui, |ui| {
                            for h in ["file", "variant", "target", "built from"] {
                                ui.strong(h);
                            }
                            ui.end_row();
                            for a in rows {
                                ui.monospace(a["file"].as_str().unwrap_or("?"));
                                ui.label(a["variant"].as_str().unwrap_or(""));
                                ui.label(a["target"].as_str().unwrap_or(""));
                                ui.weak(format!(
                                    "{} crates · {}",
                                    arr(&a["crates"]).len(),
                                    arr(&a["commits"])
                                        .iter()
                                        .filter_map(Value::as_str)
                                        .collect::<Vec<_>>()
                                        .join(", ")
                                ));
                                ui.end_row();
                            }
                        });
                }
            }
        }
        ui.separator();
        nightly_row_ui(ui, &d["nightly"]);
        ui.separator();
        ui.strong("builds queued here");
        jobs_list(ui, arr(&d["jobs"]));
    }

    fn pipeline_ui(&mut self, ui: &mut egui::Ui) {
        let d = self.body["detail"].clone();
        ui.horizontal_wrapped(|ui| {
            ui.heading(format!("🔀 {}", d["name"].as_str().unwrap_or("?")));
            ui.weak(format!(
                "{}/{}",
                d["repo"].as_str().unwrap_or("?"),
                d["file"].as_str().unwrap_or("?")
            ));
        });
        let busy = self.act.is_some() || self.job.as_ref().is_some_and(|j| !j.terminal());
        ui.horizontal(|ui| {
            if ui
                .add_enabled(!busy, egui::Button::new("▶ Run"))
                .on_hover_text("queue the run as a jera job on the forge")
                .clicked()
            {
                self.run_pipeline();
            }
            if ui.link("open Pipeline").clicked() {
                self.open_full_view(ui);
            }
        });
        self.job_ui(ui);
        ui.separator();
        ui.strong("stages");
        match d["stages"].as_array() {
            Some(stages) => {
                egui::Grid::new("explorer-stages")
                    .striped(true)
                    .show(ui, |ui| {
                        for h in ["stage", "needs", "runs on", "steps"] {
                            ui.strong(h);
                        }
                        ui.end_row();
                        for s in stages {
                            ui.label(s["name"].as_str().unwrap_or("?"));
                            ui.label(
                                arr(&s["needs"])
                                    .iter()
                                    .filter_map(Value::as_str)
                                    .collect::<Vec<_>>()
                                    .join(", "),
                            );
                            ui.label(s["runs_on"].as_str().unwrap_or(""));
                            ui.label(
                                arr(&s["steps"])
                                    .iter()
                                    .filter_map(Value::as_str)
                                    .collect::<Vec<_>>()
                                    .join(" · "),
                            );
                            ui.end_row();
                        }
                    });
            }
            None => {
                ui.label(format!(
                    "not read: {}",
                    d["stages"]["error"].as_str().unwrap_or("?")
                ));
            }
        }
        ui.separator();
        ui.strong("recent runs");
        if let Some(run) = runs_grid(ui, "explorer-pl-runs", arr(&d["runs"])) {
            let (tab, select) = run_link(&run);
            self.goto(ui, tab, select);
        }
        jobs_list(ui, arr(&d["jobs"]));
    }

    fn summary_ui(&mut self, ui: &mut egui::Ui) {
        let d = &self.body["detail"];
        ui.heading(format!("📦 {}", d["project"].as_str().unwrap_or("?")));
        if let Some(a) = d["about"].as_str().filter(|a| !a.is_empty()) {
            ui.label(a);
        }
        ui.label(format!(
            "👁 Mímir {} — {}",
            d["mimir"].as_str().unwrap_or("?"),
            d["mimir_server"].as_str().unwrap_or("this forge's nornir")
        ));
        for (k, title) in [
            ("workspaces", "📚 Workspaces"),
            ("build_things", "🔨 Build Things"),
            ("pipelines", "🔀 Pipelines"),
        ] {
            let names: Vec<&str> = arr(&d[k]).iter().filter_map(Value::as_str).collect();
            ui.label(format!("{title} ({}): {}", names.len(), names.join(", ")));
        }
        nightly_summary_ui(ui, &d["nightly"]);
    }

    fn detail_ui(&mut self, ui: &mut egui::Ui) {
        match self.body["detail"]["kind"].as_str() {
            Some("workspace") => self.workspace_ui(ui),
            Some("build_thing") => self.build_thing_ui(ui),
            Some("pipeline") => self.pipeline_ui(ui),
            Some("project" | "mimir" | "group") => self.summary_ui(ui),
            Some(_) => {
                ui.label(self.body["detail"]["note"].as_str().unwrap_or("?"));
            }
            None => {
                ui.weak("select a node of the project");
            }
        }
    }
}

/// The project's nightly builds: the schedule (UTC, the forge's clock) and last night.
fn nightly_summary_ui(ui: &mut egui::Ui, n: &Value) {
    if n.is_null() {
        return;
    }
    let at = n["at"].as_str().unwrap_or("?");
    if n["enabled"].as_bool() == Some(false) {
        ui.label(format!("🌙 Nightly builds: off ({at})"));
        return;
    }
    let last = &n["last"];
    match last["night"].as_str() {
        Some(night) => {
            let c = &last["counts"];
            ui.label(format!(
                "🌙 Nightly builds at {at} — last night {night}: {} built · {} skipped · {} failed",
                c["built"], c["skipped"], c["failed"]
            ));
            if let Some(rows) = last["rows"].as_object() {
                for (name, r) in rows {
                    ui.weak(format!(
                        "  {name}: {} — {}",
                        r["outcome"].as_str().or(r["state"].as_str()).unwrap_or("?"),
                        r["why"].as_str().or(r["note"].as_str()).unwrap_or("")
                    ));
                }
            }
        }
        None => {
            ui.label(format!("🌙 Nightly builds at {at} — none has run yet"));
        }
    }
}

/// A Build Thing's last night, and the drift `Mimir.BinaryDrift` measured against
/// the artifact filed before it.
fn nightly_row_ui(ui: &mut egui::Ui, r: &Value) {
    ui.strong("🌙 last night");
    if r.is_null() {
        ui.weak("no nightly build of this Build Thing has run yet");
        return;
    }
    ui.label(format!(
        "{} {} — {}",
        r["night"].as_str().unwrap_or("?"),
        r["outcome"].as_str().or(r["state"].as_str()).unwrap_or("?"),
        r["why"].as_str().or(r["note"].as_str()).unwrap_or("")
    ));
    let d = &r["drift"];
    if let Some(e) = d["error"].as_str() {
        ui.weak(format!("drift: {e}"));
    } else if let Some(n) = d["note"].as_str() {
        ui.weak(format!("drift: {n}"));
    } else if d.is_object() {
        ui.label(format!(
            "drift {} → {}: {:+} B, explained by {}",
            d["from_commit"].as_str().unwrap_or("?"),
            d["to_commit"].as_str().unwrap_or("?"),
            d["total_delta_bytes"].as_i64().unwrap_or(0),
            d["explained_by"].as_str().unwrap_or("?")
        ));
        for c in arr(&d["changes"]) {
            ui.monospace(format!(
                "  {} {} {:+} B",
                c["kind"].as_str().unwrap_or(""),
                c["symbol"].as_str().unwrap_or(""),
                c["delta_bytes"].as_i64().unwrap_or(0)
            ));
        }
    }
}

impl Facet for ExplorerTab {
    fn title(&self) -> &str {
        TITLE
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        self.poll();
        let due = match self.landed {
            None => true,
            Some(l) if self.reply.is_waiting() => l.elapsed() >= REASK,
            Some(l) => l.elapsed() >= REFRESH,
        };
        if self.pending.is_none() && due {
            self.ask(false);
        }
        self.sync_graph();
        ui.horizontal_wrapped(|ui| {
            ui.strong("🗺 Explorer");
            ui.weak(if self.project.is_empty() {
                "(asking the forge for its project)".to_string()
            } else {
                format!("project {}", self.project)
            });
            if self.reply.state != "done" && !self.reply.state.is_empty() {
                chip(ui, &self.reply.state);
                ui.weak(&self.reply.note);
            }
            if self.pending.is_some() && self.body.is_null() {
                ui.spinner();
            }
            ui.weak(format!("· via {}", self.source.describe()));
        });
        egui::Panel::left("explorer-tree-panel")
            .resizable(true)
            .default_size(300.0)
            .show(ui, |ui| self.tree_ui(ui));
        egui::CentralPanel::default().show(ui, |ui| {
            if self.body["detail"]["kind"] == "workspace" {
                // the workspace detail splits itself: details above a drag bar, the
                // live unit graph below with the rest of the height
                self.detail_ui(ui);
            } else {
                egui::ScrollArea::vertical()
                    .id_salt("explorer-detail")
                    .show(ui, |ui| self.detail_ui(ui));
            }
        });
        ui.ctx().request_repaint_after(Duration::from_millis(100));
    }

    fn state_json(&self) -> Value {
        let d = &self.body["detail"];
        let names = |v: &Value, k: &str| -> Vec<String> {
            arr(v)
                .iter()
                .filter_map(|x| x[k].as_str().map(str::to_string))
                .collect()
        };
        json!({
            "kind": "explorer",
            "via": self.source.describe(),
            "project": self.project,
            "select": self.select,
            "answer": { "state": self.reply.state, "job_id": self.reply.job_id, "note": self.reply.note, "via": self.reply.via },
            "tree": names(&self.body["tree"]["nodes"], "id"),
            "tree_rows": arr(&self.body["tree"]["nodes"]).iter().map(row_text).collect::<Vec<_>>(),
            "detail_kind": d["kind"],
            "repos": names(&d["repos"], "repo"),
            "builds": names(&d["builds"], "run_id"),
            "build_units": d["builds"][0]["units"][0]["counts"],
            "variants": names(&d["variants"], "name"),
            "cargo": { "features": self.cargo.features, "no_default_features": self.cargo.no_default_features, "all_features": self.cargo.all_features },
            "artifacts": names(&d["artifacts"]["rows"], "file"),
            "stages": names(&d["stages"], "name"),
            "runs": names(&d["runs"], "run_id"),
            "nightly": d["nightly"],
            "action": self.action.as_ref().map(|a| json!({ "state": a.state, "job_id": a.job_id, "note": a.note })),
            "job": self.job.as_ref().map(|j| json!({ "id": j.id, "state": j.reply.state, "seen": j.seen, "note": j.reply.note, "run_id": j.reply.body()["run_id"], "lines": j.reply.body()["lines"] })),
            "graph": self.graph.as_ref().map(|(ws, g)| { let s = g.state_json(); json!({ "workspace": ws, "state": s["answer"]["state"], "nodes": s["nodes"], "screen": s["screen"] }) }),
            "pending": self.pending.is_some(),
            "reads_nornir": false,
            "last_link": self.last_link,
        })
    }

    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<Value>(msg_json) else {
            return;
        };
        if let Some(id) = v.get("select").and_then(Value::as_str) {
            self.select(id);
            return;
        }
        if let Some(only) = v.get("build_workspace").and_then(Value::as_str) {
            self.build_workspace(only);
            return;
        }
        if let Some(variant) = v.get("build_thing").and_then(Value::as_str) {
            self.build_thing(variant);
            return;
        }
        if v.get("run_pipeline").and_then(Value::as_bool) == Some(true) {
            self.run_pipeline();
            return;
        }
        if v.get("wait").and_then(Value::as_bool) == Some(true) {
            self.wait();
            return;
        }
        if v.get("wait_job").and_then(Value::as_bool) == Some(true) {
            self.wait_job();
            self.wait();
            return;
        }
        if v.get("reread").and_then(Value::as_bool) == Some(true) {
            self.ask(true);
            self.wait();
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::Mutex;

    /// Canned forge answers: a tree, a workspace detail, a job that walks queued →
    /// running → done; and the log of what was asked.
    #[derive(Default)]
    struct Canned {
        asked: Mutex<Vec<String>>,
        job_script: Mutex<Vec<&'static str>>,
    }

    fn fold(select: &str) -> Value {
        let detail = match select {
            "workspace:w" => {
                json!({ "kind": "workspace", "name": "w", "registry": { "members": 2, "mode": "monitored", "populated": true },
                "board": { "done": 1, "total": 2 }, "repos": [{ "repo": "a", "phase": "landed" }, { "repo": "b", "phase": "compiling" }],
                "builds": [], "jobs": [], "only": [{ "name": "all", "available": true }, { "name": "affected", "available": true }] })
            }
            _ => json!({ "kind": "project", "project": "p" }),
        };
        json!({ "project": "p", "select": if select.is_empty() { "project:p" } else { select },
                "tree": { "nodes": [{ "id": "project:p", "kind": "project", "label": "p", "depth": 0 }, { "id": "workspace:w", "kind": "workspace", "label": "w", "depth": 3, "badge": "2 repos" }] },
                "detail": detail })
    }

    impl ExplorerSource for Canned {
        fn ask(&self, project: &str, select: &str, _: bool) -> Reply {
            self.asked
                .lock()
                .unwrap()
                .push(format!("ask {project} {select}"));
            Reply {
                state: "done".into(),
                json: fold(select).to_string(),
                ..Default::default()
            }
        }
        fn build_workspace(&self, project: &str, ws: &str, only: &str) -> Reply {
            self.asked
                .lock()
                .unwrap()
                .push(format!("build_workspace {project} {ws} {only}"));
            if only != "all" && only != "affected" {
                return Reply {
                    state: "refused".into(),
                    note: "refused by name".into(),
                    ..Default::default()
                };
            }
            Reply {
                state: "queued".into(),
                job_id: "j-1".into(),
                ..Default::default()
            }
        }
        fn build_thing(&self, _: &str, _: &str, _: &str, _: &CargoChoice) -> Reply {
            Reply::default()
        }
        fn run_pipeline(&self, _: &str, _: &str) -> Reply {
            Reply::default()
        }
        fn job(&self, id: &str) -> Reply {
            let mut s = self.job_script.lock().unwrap();
            let state = if s.len() > 1 { s.remove(0) } else { s[0] };
            Reply {
                state: state.into(),
                job_id: id.into(),
                json: json!({ "run_id": "r-1", "label": "build workspace w" }).to_string(),
                ..Default::default()
            }
        }
        fn describe(&self) -> String {
            "canned".into()
        }
    }

    /// **Every link carries its selection** — the message the target tab applies
    /// through its own `update_json`.
    #[test]
    fn every_link_carries_its_selection() {
        let ws = json!({ "kind": "workspace", "name": "w" });
        assert_eq!(
            link_for(&ws, ""),
            Some(("tree", json!({ "set_workspace": "w" })))
        );
        let bt = json!({ "kind": "build_thing", "name": "tunnr", "repo": "tunnr" });
        assert_eq!(
            link_for(&bt, ""),
            Some((
                "build-thing",
                json!({ "artifact": "tunnr", "resolve": true })
            ))
        );
        assert_eq!(
            link_for(&bt, "tunnr-cli"),
            Some((
                "build-thing",
                json!({ "artifact": "tunnr", "resolve": true, "pick_row": "tunnr-cli" })
            ))
        );
        let pl = json!({ "kind": "pipeline", "name": "ci", "repo": "dwarves", "file": "pipelines/release.runa.ron" });
        assert_eq!(
            link_for(&pl, ""),
            Some((
                "pipeline",
                json!({ "open_pipeline": { "repo": "dwarves", "file": "pipelines/release.runa.ron" } })
            ))
        );
        assert_eq!(run_link("r-1"), ("forge", json!({ "run_id": "r-1" })));
        assert_eq!(link_for(&json!({ "kind": "project" }), ""), None);
    }

    /// The tab asks the forge, draws its tree, and a selection asks the node.
    #[test]
    fn the_tab_draws_the_forges_tree_and_asks_a_selection() {
        let src = Arc::new(Canned::default());
        let mut t = ExplorerTab::with_source(src.clone());
        t.wait();
        let s = t.state_json();
        assert_eq!(
            (s["project"].as_str(), s["tree"].clone()),
            (Some("p"), json!(["project:p", "workspace:w"])),
            "{s}"
        );
        assert_eq!(s["tree_rows"], json!(["📦 p", "📚 w"]));
        t.update_json(r#"{"select":"workspace:w"}"#);
        t.wait();
        let s = t.state_json();
        assert_eq!(
            (s["detail_kind"].as_str(), s["repos"].clone()),
            (Some("workspace"), json!(["a", "b"])),
            "{s}"
        );
        assert_eq!(
            src.asked.lock().unwrap().last().unwrap(),
            "ask p workspace:w"
        );
    }

    /// ▶ Build workspace asks the forge to queue it and follows the job it got back
    /// through every state; a refusal is shown, never followed.
    #[test]
    fn a_build_is_queued_on_the_forge_and_its_job_followed() {
        let src = Arc::new(Canned::default());
        *src.job_script.lock().unwrap() = vec!["queued", "running", "running", "done"];
        let mut t = ExplorerTab::with_source(src.clone());
        t.update_json(r#"{"select":"workspace:w"}"#);
        t.wait();
        t.update_json(r#"{"build_workspace":"most"}"#);
        t.wait_job();
        let s = t.state_json();
        assert_eq!(s["action"]["state"], "refused");
        assert!(s["job"].is_null(), "a refusal is not followed: {s}");
        t.update_json(r#"{"build_workspace":"all"}"#);
        t.update_json(r#"{"wait_job":true}"#);
        let s = t.state_json();
        assert_eq!(
            s["job"]["seen"],
            json!(["queued", "running", "done"]),
            "{s}"
        );
        assert_eq!(s["job"]["run_id"], "r-1");
        let asked = src.asked.lock().unwrap().clone();
        assert!(
            asked.contains(&"build_workspace p w most".to_string())
                && asked.contains(&"build_workspace p w all".to_string()),
            "{asked:?}"
        );
        assert_eq!(s["reads_nornir"], false);
    }
}