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

nordisk/nornir

src/viz/repo_pane.rs

//! 🔁 **Shared per-repo pane empty-state + auto-refresh helper** (LAW 2 + the
//! sticky-`loaded` fix), applied identically across every per-repo viz pane
//! (🏛 arch / 🕸 callgraph / 🔗 depgraph / 🧠 knowledge / 🚇 metro / 🧪 test).
//!
//! ## The systemic bug this closes
//! Every per-repo pane carried its OWN `loaded: bool` latch — "true once a load
//! has been attempted (so we don't reload every frame)". It latched after the
//! FIRST fetch, *even a zero-row one*, and never re-queried until a workspace
//! re-scope dropped the cache. Two failures fell out of that:
//!
//! 1. **No LAW-2 empty-state.** A zero-row warehouse query rendered an empty
//!    board — nothing drawn — conflating "no data yet" with "nothing to draw".
//! 2. **No auto-refresh.** A background `workspace_populate` could write the
//!    repo's rows a second after the first (empty) fetch, but the pane stayed
//!    blank until the user clicked ↻ or switched workspaces.
//!
//! ## The one shared mechanism
//! * [`should_refetch`] — replaces the boolean latch. While the pane is EMPTY it
//!   re-fetches when a populate job for this repo is active OR a throttled
//!   interval ([`REFETCH_EVERY`]) has elapsed; once it has data it latches like
//!   before (no per-frame churn).
//! * [`RefetchGate`] — bundles `loaded` + `last_fetch_at` so a pane adopts the
//!   throttle by storing one field and calling [`RefetchGate::should_fetch`].
//! * [`classify_empty`] / [`EmptyState`] — picks the LAW-2 message: if an active
//!   job targets this repo → **"⏳ in route for populate…"**, else → **"not
//!   scanned yet — run populate / deep-scan"**. The pane renders that instead of
//!   a silent blank board.
//! * [`populate_active_for`] — answers "is a populate/clone/scan/arch/etc. job
//!   active for this repo right now?" from the process-global jobs snapshot the
//!   app's jobs poller publishes via [`publish_active_jobs`] (so panes need NO
//!   per-pane `Viz.Jobs` plumbing — one poll feeds all of them).

use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use crate::jobs::{JobRecord, kind};

/// How often an EMPTY per-repo pane re-queries the warehouse on its own (the
/// throttle floor). Short enough that a background populate's rows appear within
/// a couple of seconds without a click; long enough that an empty pane never
/// re-scans every frame. Mirrors `LIVE_RELOAD_EVERY` in `arch_tab`.
pub const REFETCH_EVERY: Duration = Duration::from_millis(2000);

// ───────────────────────────────────────────────────────────────────────────
// Process-global active-jobs snapshot.
//
// The app already polls `Viz.Jobs` (fat: `jobs.redb`) once every ~1.5s in the
// 🧬 nornir pane. Rather than give every per-repo pane its own RPC, that one
// poller PUBLISHES the records here and the panes READ them — one source of
// truth, zero extra round-trips. Cheap clone-free reads behind a Mutex.
// ───────────────────────────────────────────────────────────────────────────

fn jobs_cell() -> &'static Mutex<Vec<JobRecord>> {
    static JOBS: OnceLock<Mutex<Vec<JobRecord>>> = OnceLock::new();
    JOBS.get_or_init(|| Mutex::new(Vec::new()))
}

/// Publish the latest jobs snapshot (called by the app's jobs poller every cycle).
/// Replaces the previous snapshot wholesale — the poller always ships the full
/// newest-first list, so the panes see exactly what the Jobs panel sees.
pub fn publish_active_jobs(records: &[JobRecord]) {
    if let Ok(mut g) = jobs_cell().lock() {
        *g = records.to_vec();
    }
}

/// Job kinds whose ACTIVE (non-terminal) presence means a repo's rows are
/// "in route" — a populate/clone or any scan that will write the warehouse the
/// per-repo panes read. A pane that is empty *because one of these is running*
/// must say "in route", not "not scanned".
fn is_populating_kind(k: &str) -> bool {
    matches!(
        k,
        kind::WORKSPACE_POPULATE
            | kind::WORKSPACE_CLONE
            | kind::WORKSPACE_FETCH
            | kind::WORKSPACE_REPUBLISH
            | kind::KNOWLEDGE_SCAN
            | kind::DEEPSCAN
            | kind::SYMBOL_SCAN
            | kind::SNAPSHOT
            | kind::ARCH_GENERATE
            | kind::INDEX_BUILD
    )
}

/// Does a job in `records` "target this repo" in `workspace`? A job's `target`
/// is the repo/member name (or the workspace name for whole-workspace ops like
/// populate). We match when the job is for this workspace AND (a) `repo` is
/// unknown/empty (any populating job for the workspace counts), or (b) the job's
/// `target` names this repo, or (c) the job is a whole-workspace populate/fetch
/// (its `target` is the workspace, so it covers every member).
fn job_targets_repo(r: &JobRecord, workspace: &str, repo: &str) -> bool {
    if !workspace.is_empty() && r.workspace != workspace {
        return false;
    }
    if repo.is_empty() {
        return true;
    }
    // A per-member job names the repo directly …
    if r.target == repo {
        return true;
    }
    // … and a whole-workspace umbrella op (its target is the workspace itself,
    // or it is the populate/fetch parent) covers every member of the workspace.
    matches!(
        r.kind.as_str(),
        kind::WORKSPACE_POPULATE | kind::WORKSPACE_FETCH | kind::WORKSPACE_REPUBLISH
    ) || r.target == workspace
}

/// Is a populate/clone/scan/arch job ACTIVE (queued or running, i.e. non-terminal)
/// for `repo` in `workspace` right now? `repo` may be empty when a pane has not
/// yet picked a member (then any populating job for the workspace counts). Reads
/// the snapshot [`publish_active_jobs`] keeps fresh — no RPC.
pub fn populate_active_for(workspace: &str, repo: &str) -> bool {
    let Ok(g) = jobs_cell().lock() else {
        return false;
    };
    g.iter().any(|r| {
        !r.is_terminal() && is_populating_kind(&r.kind) && job_targets_repo(r, workspace, repo)
    })
}

/// Test seam: clear the global jobs snapshot (so a unit test starts from a known
/// empty state and isn't polluted by an earlier test in the same process).
#[doc(hidden)]
pub fn clear_active_jobs_for_test() {
    if let Ok(mut g) = jobs_cell().lock() {
        g.clear();
    }
}

// ───────────────────────────────────────────────────────────────────────────
// The auto-refresh throttle (replaces the sticky `loaded` latch).
// ───────────────────────────────────────────────────────────────────────────

/// Decide whether a per-repo pane should (re)fetch its warehouse data THIS frame.
///
/// * `loaded` — has a fetch been attempted at all yet?
/// * `last_fetch_at` — when the last fetch ran (`None` ⇒ never).
/// * `is_empty` — did the most recent fetch yield ZERO rows (the empty board)?
/// * `populate_active` — is a populate/scan job for this repo active right now
///   (from [`populate_active_for`])?
///
/// Contract:
/// * **Never fetched** → fetch (`!loaded`).
/// * **Has data** (`!is_empty`) → do NOT auto-refetch (latch, exactly the old
///   behaviour — no per-frame churn once the board is populated).
/// * **Empty + a populate job is active** → refetch eagerly (the rows are
///   landing; don't wait the throttle out).
/// * **Empty + no job** → refetch once [`REFETCH_EVERY`] has elapsed since the
///   last fetch (a slow throttle so an empty pane self-heals when the warehouse
///   gains the repo's rows by any means, without a click).
pub fn should_refetch(
    loaded: bool,
    last_fetch_at: Option<Instant>,
    is_empty: bool,
    populate_active: bool,
) -> bool {
    if !loaded {
        return true;
    }
    if !is_empty {
        // Populated → latch. (The pane's own ↻ reload / workspace switch still
        // drops the cache explicitly; this helper just stops blank-state sticking.)
        return false;
    }
    if populate_active {
        return true;
    }
    match last_fetch_at {
        Some(t) => t.elapsed() >= REFETCH_EVERY,
        None => true,
    }
}

/// A tiny adopt-me bundle of the throttle's two pieces of state, so a pane can
/// replace its `loaded: bool` with one field and call [`RefetchGate::should_fetch`].
/// Keeps the per-pane diff minimal and the behaviour identical everywhere.
#[derive(Debug, Clone, Copy)]
pub struct RefetchGate {
    /// `true` once a fetch has been attempted (the old `loaded` flag's role).
    pub loaded: bool,
    /// When the last fetch ran (`None` ⇒ never), for the throttle.
    pub last_fetch_at: Option<Instant>,
}

impl Default for RefetchGate {
    fn default() -> Self {
        Self {
            loaded: false,
            last_fetch_at: None,
        }
    }
}

impl RefetchGate {
    /// Should the pane fetch this frame? See [`should_refetch`].
    pub fn should_fetch(&self, is_empty: bool, populate_active: bool) -> bool {
        should_refetch(self.loaded, self.last_fetch_at, is_empty, populate_active)
    }

    /// Record that a fetch just ran (mark loaded + stamp the throttle clock).
    /// Call this immediately after a fetch attempt, success or empty.
    pub fn mark_fetched(&mut self) {
        self.loaded = true;
        self.last_fetch_at = Some(Instant::now());
    }

    /// Drop the latch (a ↻ reload / workspace switch) so the next frame refetches.
    pub fn reset(&mut self) {
        self.loaded = false;
        self.last_fetch_at = None;
    }
}

// ───────────────────────────────────────────────────────────────────────────
// The LAW-2 empty-state classifier.
// ───────────────────────────────────────────────────────────────────────────

/// Why a per-repo pane is empty — drives the message it renders INSTEAD of a
/// silent blank board (LAW 2 + its POPULATE-DIAGNOSTIC corollary).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmptyState {
    /// The pane HAS data — not empty (render the board).
    Populated,
    /// Empty AND a populate/clone/scan job for this repo is active right now: the
    /// rows are on their way. "⏳ in route for populate…".
    InRoute,
    /// Empty and no job is acting on this repo: nothing has produced the data yet.
    /// "not scanned yet — run populate / deep-scan".
    NotScanned,
}

impl EmptyState {
    /// The stable `state_json` tag (so a headless drive asserts WHICH empty-state
    /// the pane is in, not just "empty").
    pub fn id(self) -> &'static str {
        match self {
            EmptyState::Populated => "populated",
            EmptyState::InRoute => "in_route",
            EmptyState::NotScanned => "not_scanned",
        }
    }

    /// The user-facing message for the empty board (`None` when populated).
    /// The exact strings the panes render + the matrix asserts on.
    pub fn message(self, repo: &str) -> Option<String> {
        match self {
            EmptyState::Populated => None,
            EmptyState::InRoute => Some(if repo.is_empty() {
                "⏳ in route for populate…".to_string()
            } else {
                format!("⏳ in route for populate… ({repo})")
            }),
            EmptyState::NotScanned => Some(if repo.is_empty() {
                "not scanned yet — run populate / deep-scan".to_string()
            } else {
                format!("not scanned yet — run populate / deep-scan for {repo}")
            }),
        }
    }

    /// `true` for the two genuinely-empty variants (not [`EmptyState::Populated`]).
    pub fn is_empty(self) -> bool {
        !matches!(self, EmptyState::Populated)
    }
}

/// Classify a per-repo pane's empty-state. `is_empty` is the pane's own
/// zero-rows verdict; `workspace`/`repo` name what it is showing (look up the
/// active jobs internally). This is the single call every pane makes to turn a
/// blank board into a reasoned message.
pub fn classify_empty(is_empty: bool, workspace: &str, repo: &str) -> EmptyState {
    if !is_empty {
        return EmptyState::Populated;
    }
    if populate_active_for(workspace, repo) {
        EmptyState::InRoute
    } else {
        EmptyState::NotScanned
    }
}

/// Render the LAW-2 empty-state message into an egui pane (centred, never a hard
/// error). Returns the [`EmptyState`] it drew so the caller can fold it into
/// `state_json`. No-op render when populated (returns [`EmptyState::Populated`]).
#[cfg(feature = "viz")]
pub fn render_empty(
    ui: &mut eframe::egui::Ui,
    is_empty: bool,
    workspace: &str,
    repo: &str,
    text_color: eframe::egui::Color32,
) -> EmptyState {
    let st = classify_empty(is_empty, workspace, repo);
    if let Some(msg) = st.message(repo) {
        ui.add_space(16.0);
        ui.vertical_centered(|ui| {
            ui.colored_label(text_color, msg);
        });
    }
    st
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rec(kind: &str, target: &str, workspace: &str, status: &str) -> JobRecord {
        JobRecord {
            job_id: format!("{kind}-{target}-{status}"),
            kind: kind.to_string(),
            target: target.to_string(),
            workspace: workspace.to_string(),
            status: status.to_string(),
            ts_start_micros: 0,
            ts_end_micros: None,
            elapsed_ms: None,
            detail_json: String::new(),
            result_ref: String::new(),
            parent_id: None,
            node: None,
            phase: None,
            actor: None,
            actor_method: None,
        }
    }

    #[test]
    fn never_fetched_always_fetches() {
        assert!(should_refetch(false, None, false, false));
        assert!(should_refetch(false, Some(Instant::now()), false, true));
    }

    #[test]
    fn populated_latches_no_refetch() {
        // Has data → never auto-refetch, even with a job active.
        assert!(!should_refetch(true, Some(Instant::now()), false, true));
        assert!(!should_refetch(true, Some(Instant::now()), false, false));
    }

    #[test]
    fn empty_with_active_job_refetches_eagerly() {
        // Just fetched (clock barely elapsed) but a populate is active → refetch.
        assert!(should_refetch(true, Some(Instant::now()), true, true));
    }

    #[test]
    fn empty_without_job_throttles() {
        // Just fetched, empty, no job → wait the throttle out (no refetch yet).
        assert!(!should_refetch(true, Some(Instant::now()), true, false));
        // Throttle elapsed → refetch.
        let old = Instant::now() - (REFETCH_EVERY + Duration::from_millis(50));
        assert!(should_refetch(true, Some(old), true, false));
    }

    #[test]
    fn gate_lifecycle() {
        let mut g = RefetchGate::default();
        assert!(g.should_fetch(false, false), "fresh gate fetches");
        g.mark_fetched();
        assert!(!g.should_fetch(false, false), "populated gate latches");
        // Empty + active job → refetch even right after.
        assert!(g.should_fetch(true, true));
        g.reset();
        assert!(g.should_fetch(true, false), "reset gate fetches");
    }

    #[test]
    fn classify_empty_picks_in_route_when_job_active() {
        clear_active_jobs_for_test();
        publish_active_jobs(&[rec(kind::WORKSPACE_POPULATE, "ws", "ws", "running")]);
        // A whole-workspace populate covers every member → in route for repo `a`.
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::InRoute);
        // Populated short-circuits regardless of jobs.
        assert_eq!(classify_empty(false, "ws", "a"), EmptyState::Populated);
        clear_active_jobs_for_test();
    }

    #[test]
    fn classify_empty_not_scanned_when_no_job() {
        clear_active_jobs_for_test();
        // No jobs at all → not scanned.
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
        // A job for a DIFFERENT workspace must not count.
        publish_active_jobs(&[rec(kind::KNOWLEDGE_SCAN, "a", "other-ws", "running")]);
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
        clear_active_jobs_for_test();
    }

    #[test]
    fn classify_empty_terminal_job_is_not_in_route() {
        clear_active_jobs_for_test();
        // A DONE populate is not active → not scanned, not in route.
        publish_active_jobs(&[rec(kind::WORKSPACE_POPULATE, "ws", "ws", "done")]);
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
        clear_active_jobs_for_test();
    }

    #[test]
    fn per_member_scan_targets_that_repo_only() {
        clear_active_jobs_for_test();
        // A per-member knowledge scan of repo `a` → repo `a` is in route, `b` not.
        publish_active_jobs(&[rec(kind::KNOWLEDGE_SCAN, "a", "ws", "running")]);
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::InRoute);
        assert_eq!(classify_empty(true, "ws", "b"), EmptyState::NotScanned);
        clear_active_jobs_for_test();
    }

    #[test]
    fn empty_state_messages_are_the_law2_strings() {
        assert!(
            EmptyState::InRoute
                .message("nornir")
                .unwrap()
                .contains("in route for populate")
        );
        assert!(
            EmptyState::NotScanned
                .message("nornir")
                .unwrap()
                .contains("not scanned yet")
        );
        assert!(EmptyState::Populated.message("nornir").is_none());
    }
}