yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The ball action row of the composer (§8.2/§11): the focused ball's
//! Close / Release / Move, split from [`super::input_bar`] per §12's 300-line
//! budget. Coverage-excluded glue — the enablement predicates and the `bl`
//! dispatchers it wires are covered in `actions`.

use crate::AppModel;
use crate::actions::{ActionsState, close_enabled, move_enabled, unclaim_enabled, verbs};
use crate::cli_outbound::{Binary, Cli};
use crate::projects::join::{JoinRow, owner_name};
use std::io;
use std::path::Path;

/// The focused ball's Close / Release / Move, gated by its §3.5 join state (§8.2).
/// Every `bl` verb stamps `--as` the ball's **bound workspace name** (its claimant,
/// via [`owner_name`]) — the §3.2 ownership line, never the operator `$USER`.
pub(super) fn actions(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    actions: &mut ActionsState,
    bl: &Cli,
    join: Option<&JoinRow>,
) {
    let Some(row) = join else {
        return;
    };
    let state_root = model.state_root().to_path_buf();
    // The phase-1 bl gate (§16.6 W5): the ball verbs (Close/Release/Move) refuse
    // on a Mismatch/Missing `bl` while the ball still renders (§16.4). The owned
    // clone is the tested dispatchers' gate; `bl_ok` is the cosmetic greying mirror.
    let gate = model.toolchain().clone();
    let bl_ok = model.toolchain().permits(Binary::Bl);
    let owner = owner_name(row);
    // Move targets: the other named workspaces this bound ball can be re-homed to.
    let targets: Vec<String> = model
        .workspace_names()
        .into_iter()
        .filter(|n| *n != owner)
        .collect();
    ui.horizontal(|ui| {
        ui.label(format!("ball {}", row.ball_id));
        if ui
            .add_enabled(
                close_enabled(row.state) && bl_ok,
                egui::Button::new("Close"),
            )
            .clicked()
        {
            let closed = verbs::close(
                &gate,
                bl,
                &state_root,
                &super::now_ts(),
                &row.project,
                &row.ball_id,
                &owner,
            );
            after_bl(closed, model, actions, &row.project);
        }
        if ui
            .add_enabled(
                unclaim_enabled(row.state) && bl_ok,
                egui::Button::new("Release"),
            )
            .clicked()
        {
            let released = verbs::unclaim(
                &gate,
                bl,
                &state_root,
                &super::now_ts(),
                &row.project,
                &row.ball_id,
                &owner,
            );
            after_bl(released, model, actions, &row.project);
        }
    });
    // Move (§8.2): re-home a bound ball to another workspace — one button per
    // target, each an `unclaim --as owner` then `claim --as target`.
    if move_enabled(row.state) && bl_ok && !targets.is_empty() {
        ui.horizontal(|ui| {
            ui.label("move to:");
            for to in &targets {
                if ui.button(to).clicked() {
                    let moved = verbs::reassign(
                        &gate,
                        bl,
                        (&state_root, &super::now_ts()),
                        &row.project,
                        &row.ball_id,
                        &owner,
                        to,
                    );
                    after_bl(moved, model, actions, &row.project);
                }
            }
        });
    }
}

/// The `bl`-verb aftermath (the input bar's `after_lernie` sibling): refresh its balls +
/// the ops tail, then the banner. The result is discarded (the ops line is the
/// durable fact); consuming it by value is the stderr-free replacement (INV-2).
fn after_bl(
    _result: io::Result<verbs::Outcome>,
    model: &mut AppModel,
    actions: &mut ActionsState,
    project: &Path,
) {
    model.after_bl_verb(project);
    actions.last_failure = model.last_failure();
}