use std::collections::BTreeMap;
use eframe::egui::{
self, Align2, Color32, FontId, Pos2, Rect, CornerRadius, Sense, Stroke, Ui, Vec2,
};
use uuid::Uuid;
use super::model::Timeline;
const NODE_W: f32 = 160.0;
const NODE_H: f32 = 56.0;
const COL_GAP: f32 = 220.0;
const ROW_GAP: f32 = 90.0;
pub fn draw_dep_graph(
ui: &mut Ui,
tl: &Timeline,
selected_release: Option<Uuid>,
selected_repo: &mut Option<String>,
) {
let release = selected_release.or_else(|| tl.release_order.last().copied());
let Some(release_id) = release else {
ui.label("no releases recorded yet");
return;
};
let snapshot = tl.snapshot_for(&release_id);
let mut order_by_repo: BTreeMap<&str, usize> = BTreeMap::new();
for lane in &tl.lanes {
if let Some(n) = lane.nodes.iter().find(|n| n.release_id == release_id) {
let _ = n;
}
}
let mut repos: Vec<&str> = tl.lanes.iter().map(|l| l.repo.as_str()).collect();
repos.sort();
if let Some(snap) = snapshot {
repos = toposort(&repos, &snap.edges);
}
for (i, r) in repos.iter().enumerate() {
order_by_repo.insert(r, i);
}
let available = ui.available_size();
let needed_w = LEFT_PAD + repos.len() as f32 * COL_GAP + NODE_W;
let canvas = Vec2::new(available.x.max(needed_w), available.y.max(360.0));
let (rect, resp) = ui.allocate_exact_size(canvas, Sense::click());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(18, 18, 24));
let row_for = |_repo: &str| 0usize;
let pos_for = |repo: &str| -> Pos2 {
let col = *order_by_repo.get(repo).unwrap_or(&0);
let row = row_for(repo);
Pos2::new(
rect.left() + LEFT_PAD + col as f32 * COL_GAP,
rect.top() + TOP_PAD + row as f32 * ROW_GAP,
)
};
if let Some(snap) = snapshot {
for e in &snap.edges {
let (Some(_), Some(_)) = (
order_by_repo.get(e.from.as_str()),
order_by_repo.get(e.to.as_str()),
) else {
continue;
};
let from = pos_for(&e.from) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
let to = pos_for(&e.to) + Vec2::new(NODE_W / 2.0, NODE_H / 2.0);
draw_arrow(&painter, from, to, Color32::from_rgb(180, 140, 220));
let mid = Pos2::new((from.x + to.x) / 2.0, (from.y + to.y) / 2.0 - 8.0);
let via = e.via.iter().take(2).cloned().collect::<Vec<_>>().join(", ");
let label = if e.via.len() > 2 {
format!("{via} +{}", e.via.len() - 2)
} else {
via
};
painter.text(
mid,
Align2::CENTER_BOTTOM,
&label,
FontId::monospace(10.0),
Color32::from_rgb(190, 190, 210),
);
}
}
for lane in &tl.lanes {
let node_pos = pos_for(&lane.repo);
let node_rect = Rect::from_min_size(node_pos, Vec2::new(NODE_W, NODE_H));
let status_color = lane
.nodes
.iter()
.find(|n| n.release_id == release_id)
.map(|n| status_color(&n.gate_status))
.unwrap_or(Color32::from_rgb(80, 80, 100));
let is_sel = selected_repo.as_deref() == Some(lane.repo.as_str());
let fill = if is_sel { Color32::from_rgb(28, 46, 72) } else { Color32::from_rgb(30, 30, 40) };
painter.rect_filled(node_rect, CornerRadius::same(6), fill);
let (bw, bc) = if is_sel {
(3.0, Color32::from_rgb(120, 210, 255))
} else {
(2.0, status_color)
};
painter.rect_stroke(node_rect, CornerRadius::same(6), Stroke::new(bw, bc), egui::StrokeKind::Inside);
painter.text(
node_pos + Vec2::new(NODE_W / 2.0, 14.0),
Align2::CENTER_TOP,
&lane.repo,
FontId::proportional(15.0),
Color32::WHITE,
);
if let Some(n) = lane.nodes.iter().find(|n| n.release_id == release_id) {
painter.text(
node_pos + Vec2::new(NODE_W / 2.0, 34.0),
Align2::CENTER_TOP,
&n.sha[..n.sha.len().min(12)],
FontId::monospace(11.0),
Color32::from_rgb(200, 200, 210),
);
}
}
if resp.clicked() {
if let Some(pos) = resp.interact_pointer_pos() {
if let Some(r) = tl
.lanes
.iter()
.map(|l| l.repo.clone())
.find(|r| Rect::from_min_size(pos_for(r), Vec2::new(NODE_W, NODE_H)).contains(pos))
{
*selected_repo = if selected_repo.as_deref() == Some(r.as_str()) { None } else { Some(r) };
}
}
}
if let (Some(repo), Some(snap)) = (selected_repo.clone(), snapshot) {
let deps: Vec<String> = snap
.edges
.iter()
.filter(|e| e.from == repo)
.map(|e| format!(" → {} [{}]", e.to, e.via.iter().cloned().collect::<Vec<_>>().join(", ")))
.collect();
let users: Vec<String> = snap
.edges
.iter()
.filter(|e| e.to == repo)
.map(|e| format!(" ← {} [{}]", e.from, e.via.iter().cloned().collect::<Vec<_>>().join(", ")))
.collect();
let rows = 3 + deps.len() + users.len();
let panel = Rect::from_min_size(
rect.left_top() + Vec2::new(8.0, 8.0),
Vec2::new(380.0, rows as f32 * 16.0 + 16.0),
);
painter.rect_filled(panel, CornerRadius::same(6), Color32::from_rgba_unmultiplied(16, 26, 42, 236));
painter.rect_stroke(panel, CornerRadius::same(6), Stroke::new(1.0, Color32::from_rgb(80, 130, 180)), egui::StrokeKind::Inside);
let mut y = panel.top() + 8.0;
let mut put = |s: &str, c: Color32| {
painter.text(Pos2::new(panel.left() + 10.0, y), Align2::LEFT_TOP, s, FontId::proportional(12.0), c);
y += 16.0;
};
put(&format!("◆ {repo} (click node again to deselect)"), Color32::from_rgb(120, 210, 255));
put(&format!("depends on ({}):", deps.len()), Color32::from_rgb(150, 185, 215));
for d in &deps {
put(d, Color32::from_rgb(205, 215, 235));
}
put(&format!("used by ({}):", users.len()), Color32::from_rgb(150, 185, 215));
for u in &users {
put(u, Color32::from_rgb(205, 215, 235));
}
}
}
const LEFT_PAD: f32 = 30.0;
const TOP_PAD: f32 = 40.0;
fn status_color(status: &str) -> Color32 {
match status {
s if s.starts_with("succeeded_dry_run") => Color32::from_rgb(200, 160, 60),
s if s.starts_with("succeeded") => Color32::from_rgb(80, 180, 120),
"failed_test" => Color32::from_rgb(220, 80, 80),
"failed_bench" => Color32::from_rgb(220, 130, 60),
_ => Color32::from_rgb(140, 140, 160),
}
}
fn draw_arrow(painter: &egui::Painter, from: Pos2, to: Pos2, color: Color32) {
let stroke = Stroke::new(2.0, color);
painter.line_segment([from, to], stroke);
let dir = (to - from).normalized();
let perp = Vec2::new(-dir.y, dir.x);
let tip = to - dir * (NODE_W / 2.0 + 4.0);
let base = tip - dir * 10.0;
let a = base + perp * 5.0;
let b = base - perp * 5.0;
painter.add(egui::Shape::convex_polygon(vec![tip, a, b], color, Stroke::NONE));
}
fn toposort<'a>(
repos: &[&'a str],
edges: &'a [crate::warehouse::dep_graph::CrossRepoEdge],
) -> Vec<&'a str> {
use std::collections::{BTreeMap, BTreeSet, VecDeque};
let set: BTreeSet<&str> = repos.iter().copied().collect();
let mut indeg: BTreeMap<&str, usize> = repos.iter().map(|r| (*r, 0)).collect();
let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for e in edges {
let from = e.from.as_str();
let to = e.to.as_str();
if !set.contains(from) || !set.contains(to) {
continue;
}
adj.entry(to).or_default().push(from);
*indeg.entry(from).or_insert(0) += 1;
}
let mut q: VecDeque<&str> = indeg.iter().filter(|(_, d)| **d == 0).map(|(r, _)| *r).collect();
let mut out = Vec::with_capacity(repos.len());
while let Some(r) = q.pop_front() {
out.push(r);
if let Some(children) = adj.get(r) {
for &c in children {
let Some(d) = indeg.get_mut(c) else { continue };
*d = d.saturating_sub(1);
if *d == 0 {
q.push_back(c);
}
}
}
}
if out.len() == repos.len() {
out
} else {
repos.to_vec()
}
}