1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
//! 🧬 **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);
});
}
/// 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));
}
}