src/viz/helix_tab.rs
//! 🧬 **Time-Helix tab** — the dedicated viz pane that renders the workspace's
//! release history through the reusable facett component
//! [`facett_helix::HelixView`] (a [`facett_core::Facet`], imported here as
//! [`facett_jobview::Facet`] — the SAME `facett-core` 0.1 trait both crates
//! re-export).
//!
//! Each monitored repo's release timeline becomes a [`facett_helix::Coil`]: the
//! releases march along the helix time axis `t` (index in the lane), the repo is
//! the `system` lane, and each release depends on its predecessor (the intra-coil
//! DAG that winds the coil forward through time). The workspace's latest
//! dependency-graph snapshot supplies the **cross-repo** edges, which bridge the
//! coils as inter-helix links ([`facett_helix::Scene::add_link`]).
//!
//! When the selected workspace has no releases yet, the pane falls back to
//! [`HelixView::demo`] so the tab is never blank.
//!
//! [`state_json`](Facet::state_json) delegates to the facett `HelixView`, so the
//! headless matrix asserts the drawn helix as DATA (LAW 6).
use facett_helix::{Coil, HelixRef, HelixView, Kind, NodeId, PlanDag, Scene, Status};
use facett_jobview::Facet; // == facett_core::Facet (both crates re-export the 0.1 trait)
use super::facett_theme::Theme;
use super::model::Timeline;
/// 🧬 The Time-Helix tab pane state — a thin host around the facett [`HelixView`].
pub struct HelixTabState {
view: HelixView,
/// The workspace + release-count signature the current scene was built from, so
/// we rebuild the helix only when the underlying timeline actually changes.
built_sig: Option<(String, usize, usize)>,
/// `true` once a REAL (non-demo) scene has been built from workspace data.
from_data: bool,
/// Nodes in the current scene (0 for the demo fallback path only if it were
/// empty — the demo always has nodes). Surfaced for the empty-state note.
coil_count: usize,
}
impl Default for HelixTabState {
fn default() -> Self {
Self {
view: HelixView::demo(),
built_sig: None,
from_data: false,
coil_count: 0,
}
}
}
impl HelixTabState {
/// Discovery-contract ctor (the no-arg surface the test matrix enumerates).
pub fn local() -> Self {
Self::default()
}
/// The active facett palette (C8). The Facet reads the ambient egui theme, so
/// this is a no-op seam kept for parity with the other tabs' `set_palette`.
pub fn set_palette(&mut self, _t: Theme) {}
/// Signature of a timeline for cheap change-detection: (workspace, total
/// releases across all lanes, cross-repo edge count in the latest snapshot).
fn signature(tl: &Timeline) -> (String, usize, usize) {
let releases: usize = tl.lanes.iter().map(|l| l.nodes.len()).sum();
let edges = tl
.latest_snapshot
.as_ref()
.map(|s| s.edges.len())
.unwrap_or(0);
(tl.workspace_name.clone(), releases, edges)
}
/// Rebuild the helix scene from the current timeline when it changed. Falls back
/// to [`HelixView::demo`] when the workspace has no release data yet.
fn rebuild_if_needed(&mut self, tl: Option<&Timeline>) {
let sig = tl.map(Self::signature);
if sig == self.built_sig {
return;
}
self.built_sig = sig.clone();
match tl.and_then(scene_from_timeline) {
Some((scene, coils)) => {
let title = format!(
"🧬 Time Helix — {}",
tl.map(|t| t.workspace_name.as_str()).unwrap_or("")
);
self.view = HelixView::new(title, scene);
self.from_data = true;
self.coil_count = coils;
}
None => {
// No releases (or no timeline) — never blank, show the demo helix.
self.view = HelixView::demo();
self.from_data = false;
self.coil_count = 0;
}
}
}
/// Draw the pane: header + the facett [`HelixView`], scoped to the stable
/// `Helix` pane id so the AccessKit `id_salt` matches the surfaces registry.
pub fn draw(&mut self, ui: &mut egui::Ui, tl: Option<&Timeline>) {
self.rebuild_if_needed(tl);
ui.push_id("Helix", |ui| {
ui.horizontal(|ui| {
ui.heading("🧬 Time Helix");
ui.label(
egui::RichText::new(
"each repo's releases wind forward along the time axis; cross-repo deps bridge the coils",
)
.weak(),
);
});
if !self.from_data {
ui.label(
egui::RichText::new(
"no releases recorded in this workspace yet — showing the demo helix. Populate the workspace to render its real release timelines.",
)
.weak(),
);
} else {
ui.label(
egui::RichText::new(format!("{} repo coil(s) advancing through release time", self.coil_count)).weak(),
);
}
ui.separator();
Facet::ui(&mut self.view, ui);
});
// EMIT-DOCTRINE (§selftest): a helix draw is a per-frame render — ok ⇔ the
// scene was built from REAL workspace release data (coils advancing through
// time); the demo fallback (no releases yet) is the honest RED empty-state.
#[cfg(feature = "testmatrix")]
crate::selftest::emit(
"viz/helix (egui draw)",
"helix_from_data",
self.from_data,
&format!("{} coil(s), from_data={}", self.coil_count, self.from_data),
);
}
/// The drawn helix as DATA (LAW 6) — delegates to the facett `HelixView`, plus a
/// `from_data` flag so a headless drive can tell the real scene from the demo.
pub fn state_json(&self) -> serde_json::Value {
let mut v = Facet::state_json(&self.view);
if let Some(obj) = v.as_object_mut() {
obj.insert("from_data".into(), serde_json::Value::Bool(self.from_data));
obj.insert("coils".into(), serde_json::Value::from(self.coil_count));
}
v
}
}
/// Build a [`Scene`] from a workspace [`Timeline`]: one coil per repo lane (its
/// releases along the time axis, each depending on its predecessor), and the
/// latest dep-graph snapshot's cross-repo edges as inter-helix links. Returns the
/// scene + coil count, or `None` when there is no release data to render.
fn scene_from_timeline(tl: &Timeline) -> Option<(Scene, usize)> {
// No releases anywhere ⇒ nothing to build (caller falls back to the demo).
if tl.lanes.iter().all(|l| l.nodes.is_empty()) {
return None;
}
let mut scene = Scene::new();
// repo name → (helix id, node id of each release in lane order).
let mut placed: std::collections::HashMap<String, (u32, Vec<NodeId>)> =
std::collections::HashMap::new();
let n = tl
.lanes
.iter()
.filter(|l| !l.nodes.is_empty())
.count()
.max(1) as f32;
let mut coil_idx = 0usize;
for lane in &tl.lanes {
if lane.nodes.is_empty() {
continue;
}
let mut dag = PlanDag::new();
let mut ids: Vec<NodeId> = Vec::with_capacity(lane.nodes.len());
for (ri, node) in lane.nodes.iter().enumerate() {
// Label: prefer a published crate version, else the short git sha.
let label = if let Some((c, v)) = node.published_versions.first() {
format!("{c} {v}")
} else if !node.sha.is_empty() {
node.sha.chars().take(7).collect::<String>()
} else {
format!("r{ri}")
};
// A published release is a `Crate` node; an un-published gate run a `Task`.
let kind = if node.published_versions.is_empty() {
Kind::Task
} else {
Kind::Crate
};
let id = dag.add_idea(label, lane.repo.clone(), ri as f64, kind);
// Status from the gate verdict / test tallies.
let status = match node.gate_status.to_ascii_lowercase().as_str() {
"pass" | "passed" | "ok" | "green" | "clean" => Status::Done,
"fail" | "failed" | "red" | "blocked" => Status::Blocked,
_ if node.tests_failed > 0 => Status::Blocked,
_ => Status::Active,
};
dag.set_status(id, status);
// Intra-coil dep: each release winds forward from its predecessor.
if let Some(&prev) = ids.last() {
dag.add_dep(id, prev);
}
ids.push(id);
}
// Stack the coils in parallel along Z, centred on the origin.
let z = (coil_idx as f32 - (n - 1.0) / 2.0) * 6.0;
let coil = Coil::new(dag).at([0.0, 0.0, z]);
let hid = scene.add_coil(coil);
placed.insert(lane.repo.clone(), (hid, ids));
coil_idx += 1;
}
// Cross-repo (inter-helix) links from the latest dep-graph snapshot: link the
// most-recent release of the consumer repo to that of the producer repo.
if let Some(snap) = tl.latest_snapshot.as_ref() {
for e in &snap.edges {
if let (Some((fh, fids)), Some((th, tids))) = (placed.get(&e.from), placed.get(&e.to)) {
if let (Some(&fnode), Some(&tnode)) = (fids.last(), tids.last()) {
scene.add_link(
HelixRef {
helix: *fh,
node: fnode,
},
HelixRef {
helix: *th,
node: tnode,
},
1,
);
}
}
}
}
Some((scene, coil_idx))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::viz::model::{Lane, LaneNode};
use crate::warehouse::dep_graph::{CrossRepoEdge, DepGraphSnapshot};
use std::collections::BTreeMap;
fn lane(repo: &str, releases: usize) -> Lane {
let nodes = (0..releases)
.map(|i| LaneNode {
release_id: uuid::Uuid::new_v4(),
timestamp: chrono::Utc::now(),
sha: format!("{repo}{i:07}"),
branch: "main".into(),
dirty: false,
gate_status: "pass".into(),
tests_passed: 1,
tests_failed: 0,
published_versions: vec![(repo.to_string(), format!("0.1.{i}"))],
})
.collect();
Lane {
repo: repo.to_string(),
nodes,
}
}
fn tl_with(lanes: Vec<Lane>, edges: Vec<CrossRepoEdge>) -> Timeline {
let snap = (!edges.is_empty()).then(|| DepGraphSnapshot {
snapshot_id: uuid::Uuid::new_v4(),
workspace_name: "ws".into(),
timestamp: chrono::Utc::now(),
edges,
});
Timeline {
workspace_name: "ws".into(),
lanes,
release_order: Vec::new(),
release_snapshot: BTreeMap::new(),
snapshots: BTreeMap::new(),
latest_snapshot: snap,
bench_history: BTreeMap::new(),
}
}
#[test]
fn empty_timeline_builds_no_scene() {
let tl = tl_with(
vec![Lane {
repo: "a".into(),
nodes: Vec::new(),
}],
vec![],
);
assert!(scene_from_timeline(&tl).is_none());
}
#[test]
fn two_repos_build_two_coils_and_a_link() {
let edges = vec![CrossRepoEdge::normal("a", "b", Default::default())];
let tl = tl_with(vec![lane("a", 3), lane("b", 2)], edges);
let (scene, coils) = scene_from_timeline(&tl).expect("scene");
assert_eq!(coils, 2, "one coil per non-empty lane");
assert_eq!(scene.coils.len(), 2);
assert_eq!(
scene.links.len(),
1,
"the cross-repo edge became an inter-helix link"
);
}
#[test]
fn tab_falls_back_to_demo_without_data() {
let mut tab = HelixTabState::local();
tab.rebuild_if_needed(None);
assert!(!tab.from_data);
assert_eq!(tab.state_json()["from_data"], serde_json::json!(false));
}
}