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());
}
}