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

nordisk/nornir

src/viz/timetravel.rs

//! Time-travel mode — three parallel "reels" (versions, deps, benches)
//! all pinned to the same release index. ⏮ ◀ ▶ ⏭ buttons step through
//! `release_order`; a play/pause button auto-advances so the user can
//! watch the workspace's reality scroll by.

use eframe::egui::{
    self, Align, Align2, CornerRadius, FontId, Layout, Pos2, Rect, Sense, Stroke, Vec2,
};
use uuid::Uuid;

use super::facett_theme::{AMBER, Theme, ThemeExt};
use super::graph::{DepGraphView, draw_dep_graph};
use super::model::{BenchHistory, Timeline};

pub struct TimeTravelState {
    pub idx: usize,
    pub playing: bool,
    pub speed_secs: f32,
    pub last_tick: std::time::Instant,
    /// Which reel is zoomed to fullscreen (0=versions, 1=deps, 2=benches);
    /// `None` shows all three as mini panels.
    pub zoom: Option<u8>,
    /// C4: render the **dependencies** reel as the laid-out dependency *graph*
    /// (reusing `depgraph_layout` + the 🔗 Dep Graph renderer) instead of the
    /// flat text edge-list. The graph is pinned to the time-travel cursor's
    /// release, so it shows the dep graph **as it was at that point in time**,
    /// colouring direct deps distinctly from transitive ones.
    pub graph: bool,
    /// The reused dep-graph view (deep/pan/zoom/collapse) for the graph reel.
    /// Shared with the 🔗 Dep Graph tab's core so we reuse, not duplicate, the
    /// transitive-closure + direct/transitive colouring.
    pub dep_view: DepGraphView,
    /// Repo selected inside the time-travel graph reel (drill-down panel).
    pub dep_selected_repo: Option<String>,
    /// Edge-trigger guard for the trace stream: the (idx, graph) we last emitted
    /// an IN/OUT pair for, so we emit the time-pinned graph data once per cursor
    /// move / view flip rather than every frame.
    last_traced: Option<(usize, bool)>,
    /// Active palette — every colour the reels paint derives from it so a
    /// palette switch re-skins the time-travel pane (and its graph reel).
    theme: Theme,
}

impl Default for TimeTravelState {
    fn default() -> Self {
        Self {
            idx: 0,
            playing: false,
            speed_secs: 1.5,
            last_tick: std::time::Instant::now(),
            zoom: None,
            graph: false,
            dep_view: DepGraphView::new(),
            dep_selected_repo: None,
            last_traced: None,
            theme: Theme::default(),
        }
    }
}

impl TimeTravelState {
    /// Swap the active palette so the next draw re-skins every colour. Also
    /// forwards to the shared dep-graph view so the time-travel graph reel
    /// re-skins in lockstep.
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
        self.dep_view.set_palette(t);
    }

    pub fn clamp(&mut self, n: usize) {
        if n == 0 {
            self.idx = 0;
        } else if self.idx >= n {
            self.idx = n - 1;
        }
    }

    /// The release the cursor is pinned to (the "point in time"), if any.
    pub fn pinned_release(&self, tl: &Timeline) -> Option<Uuid> {
        tl.release_order.get(self.idx).copied()
    }

    /// LAW #6 introspection for the C4 time-travel **dependency graph** reel: the
    /// cursor (`idx` + pinned `release`), the view mode (text vs graph), and —
    /// when in graph mode — the laid-out graph **as it was at that release**,
    /// reusing the same `depgraph_layout` structure (nodes, positions, edges
    /// classified direct vs transitive) that the 🔗 Dep Graph tab exposes. So a
    /// headless test can flip to graph view and assert the time-pinned graph's
    /// nodes/edges + direct/transitive classification without a screenshot.
    pub fn state_json(&self, tl: &Timeline) -> serde_json::Value {
        let release = self.pinned_release(tl);
        let graph = if self.graph {
            self.dep_view.state_json_for(tl, release)
        } else {
            serde_json::Value::Null
        };
        serde_json::json!({
            "idx": self.idx,
            "release_count": tl.release_order.len(),
            "pinned_release": release.map(|r| r.to_string()),
            "playing": self.playing,
            "view": if self.graph { "graph" } else { "text" },
            "graph": graph,
            "palette": self.theme.name,
        })
    }

    /// Edge-triggered IN/OUT/END trace for the C4 graph reel (LAW #6 + the
    /// `$NORNIR_VIZ_TRACE` contract, matching the other tabs): when the cursor
    /// moves or the view flips, emit an **IN** (the requested cursor + view) and
    /// an **OUT** + **END** (the time-pinned graph data the reel renders —
    /// node/edge counts + the direct/transitive split). Best-effort, once per
    /// change, never per frame.
    pub fn trace_if_changed(&mut self, tl: &Timeline) {
        let key = (self.idx, self.graph);
        if self.last_traced == Some(key) {
            return;
        }
        self.last_traced = Some(key);
        let release = self.pinned_release(tl);
        super::trace::emit_in(
            "timetravel.render",
            &serde_json::json!({
                "idx": self.idx,
                "view": if self.graph { "graph" } else { "text" },
                "pinned_release": release.map(|r| r.to_string()),
            }),
        );
        let graph = self.state_json(tl);
        super::trace::emit_out("timetravel.render", &graph);
        super::trace::emit_end(
            "timetravel.render",
            &serde_json::json!({
                "view": graph["view"],
                "node_count": graph["graph"]["node_count"],
                "direct_edges": graph["graph"]["direct_edges"],
                "transitive_edges": graph["graph"]["transitive_edges"],
            }),
        );
    }
}

/// Returns the currently-selected release id, if any (so the rest of
/// the app can stay in sync with the time-travel cursor).
pub fn draw_timetravel(
    ui: &mut egui::Ui,
    tl: &Timeline,
    state: &mut TimeTravelState,
) -> Option<Uuid> {
    let theme = state.theme;
    let n = tl.release_order.len();
    // EMIT-DOCTRINE (§selftest): the time-travel pane is a per-frame render whose
    // health can't be read from a return value — ok ⇔ there is SOMETHING to scrub
    // (a release lineage OR bench history); a wholly-empty warehouse is the RED
    // "No releases or bench runs yet." state.
    #[cfg(feature = "testmatrix")]
    {
        let max_bench = tl
            .bench_history
            .values()
            .map(|h| h.points.len())
            .max()
            .unwrap_or(0);
        crate::selftest::emit(
            "viz/timetravel (egui draw)",
            "timetravel_has_reels",
            n > 0 || max_bench > 0,
            &format!("{n} release(s), {max_bench} bench run(s)"),
        );
    }
    if n == 0 {
        // No releases — but we may still have bench history to scrub
        // through. Render just the benchmarks reel and a stepper that
        // walks the longest per-repo bench list.
        let max_bench = tl
            .bench_history
            .values()
            .map(|h| h.points.len())
            .max()
            .unwrap_or(0);
        if max_bench == 0 {
            ui.label("No releases or bench runs yet.");
            return None;
        }
        if state.idx >= max_bench {
            state.idx = max_bench - 1;
        }
        if state.playing && state.last_tick.elapsed().as_secs_f32() >= state.speed_secs {
            state.idx = (state.idx + 1) % max_bench;
            state.last_tick = std::time::Instant::now();
        }
        if state.playing {
            ui.ctx()
                .request_repaint_after(std::time::Duration::from_millis(120));
        }
        let max_idx = max_bench.saturating_sub(1);
        ui.horizontal(|ui| {
            if ui.button("⏮").clicked() {
                state.idx = 0;
            }
            if ui.button("◀").clicked() {
                state.idx = state.idx.saturating_sub(1);
            }
            if ui
                .button(if state.playing {
                    "⏸ pause"
                } else {
                    "▶ play"
                })
                .clicked()
            {
                state.playing = !state.playing;
                state.last_tick = std::time::Instant::now();
            }
            if ui.button("▶").clicked() {
                if state.idx + 1 < max_bench {
                    state.idx += 1;
                }
            }
            if ui.button("⏭").clicked() {
                state.idx = max_idx;
            }
            ui.separator();
            ui.label(format!("bench {} of {}", state.idx + 1, max_bench));
            ui.add(egui::Slider::new(&mut state.idx, 0..=max_idx).text("scrub"));
            ui.add(
                egui::Slider::new(&mut state.speed_secs, 0.25..=5.0)
                    .text("s/step")
                    .logarithmic(true),
            );
        });
        ui.separator();
        ui.colored_label(
            theme.info(),
            "ℹ no release_lineage in this warehouse — showing bench history only",
        );
        reel_benches(ui, &theme, tl, state.idx);
        return None;
    }
    state.clamp(n);

    // Auto-advance.
    if state.playing && state.last_tick.elapsed().as_secs_f32() >= state.speed_secs {
        state.idx = (state.idx + 1) % n;
        state.last_tick = std::time::Instant::now();
    }
    if state.playing {
        ui.ctx()
            .request_repaint_after(std::time::Duration::from_millis(120));
    }

    let max_idx = n.saturating_sub(1);
    ui.horizontal(|ui| {
        if ui.button("⏮").on_hover_text("first").clicked() {
            state.idx = 0;
        }
        if ui.button("◀").on_hover_text("previous").clicked() {
            state.idx = state.idx.saturating_sub(1);
        }
        if ui
            .button(if state.playing {
                "⏸ pause"
            } else {
                "▶ play"
            })
            .clicked()
        {
            state.playing = !state.playing;
            state.last_tick = std::time::Instant::now();
        }
        if ui.button("▶").on_hover_text("next").clicked() {
            if state.idx + 1 < n {
                state.idx += 1;
            }
        }
        if ui.button("⏭").on_hover_text("latest").clicked() {
            state.idx = max_idx;
        }
        ui.separator();
        ui.label(format!("release {} of {}", state.idx + 1, n));
        ui.separator();
        ui.add(egui::Slider::new(&mut state.idx, 0..=max_idx).text("scrub"));
        ui.separator();
        ui.add(
            egui::Slider::new(&mut state.speed_secs, 0.25..=5.0)
                .text("s/step")
                .logarithmic(true),
        );
    });

    let rid = tl.release_order[state.idx];
    // Edge-triggered IN/OUT/END trace of the time-pinned graph reel (C4).
    state.trace_if_changed(tl);
    ui.separator();
    ui.horizontal(|ui| {
        ui.label(
            egui::RichText::new(format!("⏱ pinned to release {rid}"))
                .monospace()
                .color(theme.text),
        );
        ui.separator();
        // C4: text ⇄ graph toggle for the dependencies reel. In graph mode the
        // deps reel renders the dependency *graph as it was at this release*,
        // reusing depgraph_layout's direct/transitive colouring.
        let toggle = if state.graph { "🔗 deps: graph" } else { "📄 deps: text" };
        if ui
            .selectable_label(state.graph, toggle)
            .on_hover_text("toggle the dependencies reel between the text edge-list and the laid-out dependency graph (C4)")
            .clicked()
        {
            state.graph = !state.graph;
            super::trace::emit_event(
                "timetravel.view",
                &serde_json::json!({
                    "view": if state.graph { "graph" } else { "text" },
                    "release": rid.to_string(),
                }),
            );
        }
    });
    ui.add_space(8.0);

    // Reels as mini panels you can zoom to fullscreen (⛶) and step back from.
    let idx = state.idx;
    let graph_mode = state.graph;
    let dep_view = &mut state.dep_view;
    let dep_sel = &mut state.dep_selected_repo;
    let mut render = |ui: &mut egui::Ui, k: u8| match k {
        0 => reel_versions(ui, &theme, tl, rid),
        1 => reel_dependencies(ui, &theme, tl, rid, graph_mode, dep_view, dep_sel),
        _ => reel_benches(ui, &theme, tl, idx),
    };
    if let Some(k) = state.zoom {
        if ui
            .button(egui::RichText::new("⬅  GO BACK").size(15.0).strong())
            .on_hover_text("back to all reels")
            .clicked()
        {
            state.zoom = None;
        }
        ui.separator();
        let full = ui.available_size();
        ui.allocate_ui_with_layout(
            Vec2::new(full.x, full.y - 8.0),
            Layout::top_down(Align::Min),
            |ui| render(ui, k),
        );
    } else {
        let avail = ui.available_size();
        let col_w = (avail.x - 24.0) / 3.0;
        ui.horizontal_top(|ui| {
            for (i, (k, title)) in [
                (0u8, "📦 versions"),
                (1, "🔗 dependencies"),
                (2, "📈 benches"),
            ]
            .iter()
            .enumerate()
            {
                ui.allocate_ui_with_layout(
                    Vec2::new(col_w, avail.y - 40.0),
                    Layout::top_down(Align::Min),
                    |ui| {
                        if ui
                            .button(format!("⛶ {title}"))
                            .on_hover_text("click to zoom this diagram fullscreen")
                            .clicked()
                        {
                            state.zoom = Some(*k);
                        }
                        render(ui, *k);
                    },
                );
                if i < 2 {
                    ui.separator();
                }
            }
        });
    }

    Some(rid)
}

fn reel_versions(ui: &mut egui::Ui, theme: &Theme, tl: &Timeline, rid: Uuid) {
    ui.heading("📦 Component versions");
    ui.separator();
    egui::ScrollArea::vertical()
        .id_salt("reel-versions")
        .show(ui, |ui| {
            for lane in &tl.lanes {
                let Some(node) = lane.nodes.iter().find(|n| n.release_id == rid) else {
                    ui.colored_label(
                        theme.text_dim,
                        format!("{}: (not part of this release)", lane.repo),
                    );
                    continue;
                };
                ui.group(|ui| {
                    ui.horizontal(|ui| {
                        ui.strong(&lane.repo);
                        ui.label(
                            egui::RichText::new(format!(
                                "@{}",
                                &node.sha[..node.sha.len().min(10)]
                            ))
                            .monospace()
                            .color(theme.accent),
                        );
                        if node.dirty {
                            ui.colored_label(AMBER, "✱ dirty");
                        }
                    });
                    ui.label(format!("branch: {} • {}", node.branch, node.gate_status));
                    if node.published_versions.is_empty() {
                        ui.colored_label(theme.text_dim, "(no published crates)");
                    } else {
                        ui.label("published:");
                        for (c, v) in &node.published_versions {
                            ui.monospace(format!("  {c} @ {v}"));
                        }
                    }
                });
                ui.add_space(4.0);
            }
        });
}

fn reel_dependencies(
    ui: &mut egui::Ui,
    theme: &Theme,
    tl: &Timeline,
    rid: Uuid,
    graph: bool,
    dep_view: &mut DepGraphView,
    dep_selected_repo: &mut Option<String>,
) {
    ui.heading("🔗 Dependencies");
    ui.separator();
    if graph {
        // C4: render the dependency GRAPH as it was at this release, reusing the
        // 🔗 Dep Graph tab's renderer (depgraph_layout + direct/transitive
        // colouring). Pinned to `rid` so it is the graph at the cursor's point
        // in time, not the latest.
        draw_dep_graph(ui, tl, Some(rid), dep_selected_repo, dep_view);
        return;
    }
    let Some(snap) = tl.snapshot_for(&rid) else {
        ui.colored_label(theme.text_dim, "(no snapshot for this release)");
        return;
    };
    ui.label(format!("snapshot: {}", snap.snapshot_id));
    ui.label(format!("{} cross-repo edge(s)", snap.edges.len()));
    ui.separator();
    egui::ScrollArea::vertical()
        .id_salt("reel-deps")
        .show(ui, |ui| {
            if snap.edges.is_empty() {
                ui.colored_label(theme.text_dim, "(no edges)");
            }
            for edge in &snap.edges {
                ui.group(|ui| {
                    ui.horizontal(|ui| {
                        ui.strong(&edge.from);
                        ui.label("→");
                        ui.strong(&edge.to);
                    });
                    let names = edge
                        .via
                        .iter()
                        .map(|c| c.as_str())
                        .collect::<Vec<_>>()
                        .join(", ");
                    ui.monospace(format!("via: {names}"));
                });
                ui.add_space(2.0);
            }
        });
}

fn reel_benches(ui: &mut egui::Ui, theme: &Theme, tl: &Timeline, release_idx: usize) {
    ui.heading("⚡ Benchmarks");
    ui.separator();
    egui::ScrollArea::vertical()
        .id_salt("reel-benches")
        .show(ui, |ui| {
            for lane in &tl.lanes {
                let Some(hist) = tl.bench_history.get(&lane.repo) else {
                    continue;
                };
                ui.group(|ui| {
                    ui.horizontal(|ui| {
                        ui.strong(&lane.repo);
                        ui.label(format!("({} runs)", hist.points.len()));
                    });
                    draw_sparkline(ui, theme, hist, release_idx);
                });
                ui.add_space(4.0);
            }
        });
}

fn draw_sparkline(ui: &mut egui::Ui, theme: &Theme, hist: &BenchHistory, release_idx: usize) {
    let n = hist.points.len();
    if n == 0 {
        ui.colored_label(theme.text_dim, "(no runs)");
        return;
    }
    let (mn, mx) = hist.min_max(None).unwrap_or((0.0, 1.0));
    let span = (mx - mn).max(1e-9);

    let (rect, _resp) =
        ui.allocate_exact_size(Vec2::new(ui.available_width(), 70.0), Sense::hover());
    let painter = ui.painter_at(rect);
    painter.rect_filled(rect, CornerRadius::same(3), theme.bg);

    let pad = 6.0;
    let inner = rect.shrink(pad);
    let xs = if n == 1 {
        vec![inner.center().x]
    } else {
        (0..n)
            .map(|i| inner.left() + inner.width() * (i as f32) / ((n - 1) as f32))
            .collect()
    };

    // Line.
    let mut prev: Option<Pos2> = None;
    for (i, p) in hist.points.iter().enumerate() {
        let t = ((p.primary_metric_value - mn) / span) as f32;
        let y = inner.bottom() - t * inner.height();
        let pt = Pos2::new(xs[i], y);
        if let Some(pp) = prev {
            painter.line_segment([pp, pt], Stroke::new(1.6_f32, theme.point));
        }
        painter.circle_filled(pt, 2.5, theme.point);
        prev = Some(pt);
    }

    // Cursor: map release index → bench index. We don't have a strict
    // release↔bench join, so use proportional position across the
    // available bench runs (this is "good enough" for the
    // time-travel reel — actually pinning would need a separate
    // bench→release lineage table).
    let cursor_i = if n == 1 || release_idx == 0 {
        0
    } else {
        ((release_idx as f32) * ((n - 1) as f32) / ((release_idx.max(n - 1)) as f32)).round()
            as usize
    }
    .min(n - 1);
    let cx = xs[cursor_i];
    painter.line_segment(
        [Pos2::new(cx, inner.top()), Pos2::new(cx, inner.bottom())],
        Stroke::new(1.5_f32, AMBER),
    );

    let pt = &hist.points[cursor_i];
    painter.text(
        Pos2::new(inner.left() + 2.0, inner.top() - 2.0),
        Align2::LEFT_BOTTOM,
        format!(
            "{} = {:.2}  ({}/{}/{})",
            pt.primary_metric_name,
            pt.primary_metric_value,
            pt.version,
            pt.machine,
            pt.timestamp.format("%Y-%m-%d")
        ),
        FontId::proportional(11.0),
        theme.text,
    );
    let _ = Rect::NOTHING; // silence unused-import in some configs
}