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);
}
}