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 {
#[arg(long)]
pub workspace: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Roots {
pub yog_data: PathBuf,
pub lernie_data: PathBuf,
pub yog_state: PathBuf,
pub balls_clones: PathBuf,
pub home: PathBuf,
}
impl Roots {
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")
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Focus {
pub ws: Option<PathBuf>,
pub agent: Option<String>,
pub tab: InspectorTab,
}
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,
balls: Box<dyn BlRunner>,
identity_user: Option<String>,
balls_by_project: HashMap<PathBuf, Vec<Ball>>,
closed_by_project: HashMap<PathBuf, Vec<Ball>>,
join_rows: Vec<JoinRow>,
ops: Vec<OpRow>,
probe: Box<dyn ToolProbe>,
toolchain: ToolchainState,
}
impl AppModel {
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);
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
}
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 {
pub fn watchset_handle(&self) -> WatchSetHandle {
Arc::clone(&self.watchset)
}
pub fn dirty_handle(&self) -> DirtySet {
self.dirty.clone()
}
#[cfg(test)]
pub(crate) fn workspaces(&self) -> &[Workspace] {
&self.workspaces
}
pub fn tree(&self, ws: &Path) -> Option<&GitTree> {
self.trees.get(ws)
}
pub fn focused_workspace(&self) -> Option<&Path> {
self.focus.ws.as_deref()
}
pub fn focused_tree(&self) -> Option<&GitTree> {
self.focus.ws.as_deref().and_then(|w| self.trees.get(w))
}
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)
}
#[cfg(test)]
pub(crate) fn focus(&self) -> &Focus {
&self.focus
}
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)
}
}
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
}
fn needs_liveness_reprobe(tree: &GitTree) -> bool {
tree.agents
.iter()
.any(|a| matches!(a.state, AgentState::Live | AgentState::InFlight))
}
#[cfg(test)]
mod tests;