yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Top-level app state and the render entry points.
//!
//! [`Args`] is the CLI surface; [`AppModel`] is the live **multi-workspace**
//! model (§15 Y11, M2 shell). It owns the snapshot map (`HashMap<PathBuf,
//! GitTree>`) over every enumerated workspace ([`binding::workspaces`]), the
//! held [`ProbeStack`] (§10 — one platform probe stack across ticks), the
//! `ui.json` document ([`UiState`] — durable pins/collapsed/seen), and the
//! per-instance [`Focus`] (RAM, §13.1). [`tick`](AppModel::tick) folds
//! bridge-delivered disk events and the periodic sweeps into re-derivations,
//! `ui.json` adoption, and watch reconciliation each frame.
//!
//! The impl is split for the 300-line budget: [`derive`] holds the tick /
//! sweep / re-derivation machinery, [`focus`] the tab-bar / conversation /
//! attention / seen-acknowledgement surface. This root stays declaration-light
//! so the `pub mod` list carries no coverable `impl` header (llvm-cov phantom).

mod balls;
mod derive;
mod dirty;
mod focus;

use crate::binding::{self, Workspace, WorkspaceKind};
use crate::fs_watcher::RootKind;
use crate::git_tree::{AgentState, GitTree, ProbeStack};
use crate::keymap::InspectorTab;
use crate::nav::ws_key;
use crate::opslog::OpRow;
use crate::projects::balls::Ball;
use crate::projects::join::JoinRow;
use crate::projects::runner::BlRunner;
use crate::state::{DirtySet, WatchSetHandle, lock_watchset, new_watchset};
use crate::ui_state::{Clock, UiState, derive_startup_focus};
use crate::world::toolgate::{self, ToolProbe, ToolchainState};
use clap::Parser;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

pub use self::dirty::CHEAP_SWEEP;

#[derive(Parser, Debug, Clone, PartialEq, Eq)]
#[command(version, about = "yog: egui frontend for lernie loops")]
pub struct Args {
    /// Workspace to focus at startup (overrides the derived next-attention
    /// focus). Absent ⇒ the first attention-bearing workspace, else the
    /// first (§4.1 startup-focus derivation).
    #[arg(long)]
    pub workspace: Option<PathBuf>,
}

/// The enumeration roots the model watches and walks (§7.1): yog's own flat
/// names root, the lernie data root (foreign workspaces + replays), and the yog
/// state root (`ui.json`). Built once from [`crate::xdg::Env`] by the shell.
#[derive(Debug, Clone)]
pub struct Roots {
    pub yog_data: PathBuf,
    pub lernie_data: PathBuf,
    pub yog_state: PathBuf,
    /// The balls per-project clones dir (`$XDG_STATE_HOME/balls/clones/`, §5.1
    /// #1) — project enumeration and the `BallsClones` watch (§7.1).
    pub balls_clones: PathBuf,
    /// The operator's home dir (`~`, §3.4) — the bare rung's driver cwd.
    pub home: PathBuf,
}

impl Roots {
    /// yog's flat names root (`$XDG_DATA_HOME/yog/workspaces/`, §3.1).
    fn names(&self) -> PathBuf {
        binding::names_root(&self.yog_data)
    }
    fn workspaces(&self) -> PathBuf {
        self.lernie_data.join("workspaces")
    }
    fn replays(&self) -> PathBuf {
        self.lernie_data.join("replays")
    }
    fn ui_json(&self) -> PathBuf {
        self.yog_state.join("ui.json")
    }
}

/// The focused viewport position — **per-instance RAM** (§13.1: focus is
/// *which data you look at*, not data; it re-derives at startup and is never
/// mirrored). A focused workspace drives the center panel; a focused agent is
/// the inspector target and the seen-acknowledgement subject (§6); `tab` is the
/// selected §11 Altitude-2 inspector tab (sticky across focus changes).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Focus {
    pub ws: Option<PathBuf>,
    pub agent: Option<String>,
    pub tab: InspectorTab,
}

/// The live multi-workspace model (§15 Y11). The render snapshot is the public
/// `workspaces` classification + the per-workspace `trees`; the watch / dirty /
/// sweep / ui-state machinery that keeps them fresh is private.
pub struct AppModel {
    roots: Roots,
    workspaces: Vec<Workspace>,
    trees: HashMap<PathBuf, GitTree>,
    ui: UiState,
    probes: ProbeStack,
    focus: Focus,
    watchset: WatchSetHandle,
    dirty: DirtySet,
    schedule: dirty::Schedule,
    /// The injected `bl` read runner (§15 Y16, the LockProbe template): drives
    /// `bl list --json` per project on the fetch cadence (§7.2).
    balls: Box<dyn BlRunner>,
    /// `$USER` fallback for the claim identity (§4.1) — read from the env
    /// snapshot at the shell boundary, never live here (the xdg discipline).
    identity_user: Option<String>,
    /// Cached live balls per project (§5.1 #2); only successfully-listed
    /// projects are keyed — a cloned project absent here is orphaned (§3.5).
    balls_by_project: HashMap<PathBuf, Vec<Ball>>,
    /// On-demand closed listing per project (§5.1 #4): populated only after a
    /// `bl close`, never on the cadence — the delivered-row source.
    closed_by_project: HashMap<PathBuf, Vec<Ball>>,
    /// The derived §3.5 join (roster badges + Close/Unclaim enablement).
    join_rows: Vec<JoinRow>,
    /// Cached `ops.jsonl` tail (§4.2), the ops-pane render source.
    ops: Vec<OpRow>,
    /// The injected phase-1 toolchain probe (§16.4 W5) — the on-demand seam.
    probe: Box<dyn ToolProbe>,
    /// Classified host-tool state (§16.6 W5): the pane's source + verbs' gate.
    toolchain: ToolchainState,
}

impl AppModel {
    /// Enumerate every workspace, take an initial snapshot of each through the
    /// held probe stack, arm the watch set, load `ui.json`, derive the startup
    /// focus (`initial_focus` override, else next-attention, §4.1), and take the
    /// first ball/ops fetch. `balls` is the injected `bl` read runner and `user`
    /// the `$USER` identity fallback (both from the shell). The clock is shared
    /// (cloned) between the sweep schedule and the `ui.json` debounce (§7.2).
    pub fn new(
        roots: Roots,
        initial_focus: Option<PathBuf>,
        clock: Arc<dyn Clock>,
        balls: Box<dyn BlRunner>,
        probe: Box<dyn ToolProbe>,
        user: Option<String>,
    ) -> Self {
        let ui = UiState::with_clock(roots.ui_json(), Arc::clone(&clock));
        let probes = ProbeStack::platform();
        let workspaces = binding::workspaces(&roots.yog_data, &roots.lernie_data);
        let trees = workspaces
            .iter()
            .filter_map(|w| Some((w.path.clone(), probes.derive(&w.path).ok()?)))
            .collect();
        let watchset = new_watchset();
        let desired = desired_watches(&roots, &workspaces);
        lock_watchset(&watchset).reconcile(&desired);
        // Probe the host toolchain once at startup (§16.6 W5); refreshed on
        // demand via `refresh_toolchain`.
        let toolchain = toolgate::probe(probe.as_ref());
        let mut model = Self {
            roots,
            workspaces,
            trees,
            ui,
            probes,
            focus: Focus::default(),
            watchset,
            dirty: DirtySet::default(),
            schedule: dirty::Schedule::new(clock),
            balls,
            identity_user: user,
            balls_by_project: HashMap::new(),
            closed_by_project: HashMap::new(),
            join_rows: Vec::new(),
            ops: Vec::new(),
            probe,
            toolchain,
        };
        model.focus = model.startup_focus(initial_focus);
        model.refresh_balls();
        model.refresh_ops();
        model
    }

    /// Startup focus (§4.1): the `--workspace` override if given, else the first
    /// attention-bearing workspace in derived (path) order, else the first.
    fn startup_focus(&self, initial: Option<PathBuf>) -> Focus {
        if let Some(ws) = initial {
            return Focus {
                ws: Some(ws),
                ..Focus::default()
            };
        }
        let mut roster: Vec<String> = self.workspaces.iter().map(|w| ws_key(&w.path)).collect();
        roster.sort();
        let mut attention: Vec<String> = Vec::new();
        for w in &self.workspaces {
            if self.workspace_stats(&w.path).0 > 0 {
                attention.push(ws_key(&w.path));
            }
        }
        let roster_refs: Vec<&str> = roster.iter().map(String::as_str).collect();
        let attention_refs: Vec<&str> = attention.iter().map(String::as_str).collect();
        Focus {
            ws: derive_startup_focus(&roster_refs, &attention_refs).map(PathBuf::from),
            ..Focus::default()
        }
    }
}

impl AppModel {
    /// The shared watch set (the bridge polls it; the frame reconciles it).
    pub fn watchset_handle(&self) -> WatchSetHandle {
        Arc::clone(&self.watchset)
    }

    /// The shared dirty hand-off (the bridge fills it; the frame drains it).
    pub fn dirty_handle(&self) -> DirtySet {
        self.dirty.clone()
    }

    /// The classified workspace set — a test-only reader of the roster input
    /// the shell derives internally (§11).
    #[cfg(test)]
    pub(crate) fn workspaces(&self) -> &[Workspace] {
        &self.workspaces
    }

    /// The current snapshot for `ws`, if derived.
    pub fn tree(&self, ws: &Path) -> Option<&GitTree> {
        self.trees.get(ws)
    }

    /// The focused workspace path (center-panel target), if any.
    pub fn focused_workspace(&self) -> Option<&Path> {
        self.focus.ws.as_deref()
    }

    /// The focused workspace's snapshot (the center panel renders this tree).
    pub fn focused_tree(&self) -> Option<&GitTree> {
        self.focus.ws.as_deref().and_then(|w| self.trees.get(w))
    }

    /// Whether the focused workspace is a read-only replay (§3.1
    /// `<lernie-data>/replays/*`). "Replay is not a mode": the ordinary center
    /// view renders it through the same tree renderer — this query only gates
    /// the mutating composer off, so a replay offers no write surface.
    pub fn focused_is_replay(&self) -> bool {
        let Some(ws) = self.focus.ws.as_deref() else {
            return false;
        };
        self.workspaces
            .iter()
            .any(|w| w.path == ws && w.kind == WorkspaceKind::Replay)
    }

    /// The per-instance focus (RAM, §13.1) — a test-only reader.
    #[cfg(test)]
    pub(crate) fn focus(&self) -> &Focus {
        &self.focus
    }

    /// The focused agent's snapshot row — the inspector's per-agent target
    /// (§11 Altitude-2): the [`Agent`](crate::git_tree::Agent) in the focused
    /// workspace's tree whose id is the focused agent id. `None` when no agent
    /// is selected or its row is absent (an unfetched or moved tree).
    pub fn focused_agent(&self) -> Option<&crate::git_tree::Agent> {
        let agent_id = self.focus.agent.as_deref()?;
        let tree = self.focused_tree()?;
        tree.agents.iter().find(|a| a.agent_id == agent_id)
    }
}

/// The roots the model watches (§7.1): every enumerated workspace, the three
/// enumeration roots (the flat names root, lernie workspaces + replays), and the
/// yog state root. Missing roots are tolerated —
/// [`WatchSet::reconcile`](crate::watch::WatchSet::reconcile) skips one that fails to arm.
fn desired_watches(roots: &Roots, workspaces: &[Workspace]) -> Vec<(PathBuf, RootKind)> {
    let mut desired = vec![
        (roots.names(), RootKind::NamesRoot),
        (roots.workspaces(), RootKind::WorkspacesRoot),
        (roots.replays(), RootKind::WorkspacesRoot),
        (roots.yog_state.clone(), RootKind::YogState),
        (roots.balls_clones.clone(), RootKind::BallsClones),
    ];
    desired.extend(
        workspaces
            .iter()
            .map(|w| (w.path.clone(), RootKind::Workspace)),
    );
    desired
}

/// Whether `tree` holds an agent that could have died *silently* — a Live/
/// InFlight agent; a released flock emits no fs event (§7.2 targeted re-probe).
fn needs_liveness_reprobe(tree: &GitTree) -> bool {
    tree.agents
        .iter()
        .any(|a| matches!(a.state, AgentState::Live | AgentState::InFlight))
}

#[cfg(test)]
mod tests;