yog 0.0.5

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The §9.3 per-workspace config-branch pane: pick the lineage, pick the file,
//! load what it actually holds, edit it — typed where yog has a grammar for the
//! file (§9.5), raw where it does not — and drive `lernie config`, the only
//! lawful `config/*` writer.
//!
//! Two things this pane used to be: a free-text branch name and a free-text
//! path over an empty body, so a config commit was authored blind against a
//! file nobody had read. Both are now chosen from what the workspace actually
//! has. The git reads happen on the **Browse** and **Load** gestures, never per
//! frame (§7.2, bl-ee0a — the listing used to spawn `for-each-ref` every frame).
//!
//! Coverage-excluded glue: the staging, the plan, the drive and the typed form
//! are tested in `config_edit`; this file only wires widgets. Since bl-3f46 the
//! Send **is** the boundary's
//! [`ApplyConfig`](crate::boundary::Action::ApplyConfig) variant on a
//! [`Branch`](crate::boundary::config::ConfigFile::Branch) destination — the
//! click-glue constructs it and calls the chokepoint, so the lineage write has
//! one implementation and a headless spelling.

use super::{ConfigState, form_ui, status};
use crate::AppModel;
use crate::config_edit::branch::edit::EditOrigin;
use crate::config_edit::branch::{config_branches, config_file, config_tree};
use crate::config_edit::form::{self, Schema};
use status::status_line;
use std::path::Path;

/// The dropdown's escape: a lineage the workspace does not have yet, named in
/// the text field the choice reveals. Every list ends in its own escape (§9.4).
const NEW_LINEAGE: &str = "new lineage…";

/// The lineage choice (§9.3) — one phrase for the dropdown and the name field
/// it reveals (§11 rule 4: a phrase worn twice is a named const).
const LINEAGE_HINT: &str = "Which config branch to write. `default` is the one new conversations in \
     this workspace read; any other name is a lineage of its own. Typed, the lineage is \
     `/config branch|fork|orphan <name> <text…>`.";

/// The file choice (§9.3) — worn by the dropdown and the free-path field.
const FILE_HINT: &str = "Which file inside the config branch to edit — `providers.yaml`, for \
     instance. It is created if the branch has no such file. Typed, the file rides the \
     same `/config` line.";

/// Per-workspace config branches (§9.3): the lineage, the file, its settings,
/// and the staged edit that drives `lernie config`.
pub(super) fn render(
    ui: &mut egui::Ui,
    model: &mut AppModel,
    config: &mut ConfigState,
    provider_rows: &[String],
) {
    ui.heading("workspace config branches");
    let Some(ws) = model.focused_workspace() else {
        ui.weak("focus a workspace to edit its config branches");
        return;
    };
    // The browse half (§9.3, §5.1 #18): what lineages exist and where each tip
    // sits. The selector below chooses among them; this says what they are.
    for b in &config.branches {
        ui.monospace(format!("config/{} @ {}", b.name, b.tip_short_oid));
    }
    // The send below is a post (REMOTE §9.8, bl-4841), so its receipt is folded
    // here, on the frame it lands — and the re-read it triggers happens then
    // too: until the engine answers, the pre-write branch is still the branch.
    super::send::settle(model, config, &ws);
    lineage(ui, config, &ws);
    file_row(ui, config, &ws);
    match schema_of(&config.cb_path) {
        Some(schema) => settings(ui, config, &schema, provider_rows),
        None => {
            ui.weak(form_ui::NO_READER);
        }
    }
    egui::CollapsingHeader::new("raw file body")
        .show(ui, |ui| {
            form_ui::raw_editor(ui, &mut config.cb_body);
        })
        .header_response
        .on_hover_text(
            "Open the file's whole contents, exactly as they will be committed — \
             the raw text behind the settings above. No key of its own: Tab reaches \
             it, Space presses it.",
        );
    if ui
        .button("Send (stage + lernie config)")
        .on_hover_text(
            "Stage this file and run `lernie config` — the only thing allowed to \
             write a config branch. New conversations in this workspace read the \
             result; running ones stay on the commit they started from. Typed, it is \
             `/config branch <name> <text…>`.",
        )
        .clicked()
    {
        super::send::edit(model, config, &ws);
    }
    status_line(ui, &config.cb_act.line());
}

/// Read the workspace's lineages and the selected one's file listing — the
/// §9 read-on-demand rule extended to git: the pane's own open gesture fills
/// this, and the pane refills it after a `lernie config` it caused (§7.2 — the
/// frame marks what it changed; it never polls). Both answers come from one
/// pass, so the listing and the tree can never be of different commits.
pub(super) fn reread(config: &mut ConfigState, workspace: Option<&Path>) {
    let Some(ws) = workspace else {
        config.branches.clear();
        config.cb_files.clear();
        return;
    };
    config.branches = config_branches(ws).unwrap_or_default();
    config.cb_files = tree(config, ws);
}

/// The selected lineage's tree, or nothing when the selection is a lineage the
/// workspace does not have yet (there is no ref to read).
fn tree(config: &ConfigState, ws: &Path) -> Vec<String> {
    if !config.branches.iter().any(|b| b.name == config.cb_name) {
        return Vec::new();
    }
    config_tree(ws, &format!("config/{}", config.cb_name)).unwrap_or_default()
}

/// The lineage row: the branches the open gesture found, the escape that names
/// a new one, and the advance/orphan origin. Choosing a lineage re-reads its
/// tree — one git call on the gesture that changed the answer, never per frame.
fn lineage(ui: &mut egui::Ui, config: &mut ConfigState, ws: &Path) {
    let before = config.cb_name.clone();
    let known = config.branches.iter().any(|b| b.name == config.cb_name);
    // §11 rule 8, not rule 1b (bl-7414): every member here is a control of its
    // own — a dropdown, a revealed field, and the two origin peers — and none
    // may be dropped, so the row grows a LINE when it cannot fit rather than
    // running off the pane. Laid `horizontal` it could not fit at 480x1400 at
    // all, and an over-full row ratchets the seat's `max_rect`, which is how the
    // §9.5 no-reader sentence below it came to elide at 285 pt inside a 224 pt
    // pane and be hard-cut with its ellipsis outside the clip.
    crate::shell::row::peers(ui, |ui| {
        ui.label("lineage:");
        let shown = if known {
            config.cb_name.clone()
        } else {
            NEW_LINEAGE.to_string()
        };
        egui::ComboBox::from_id_salt("config-branch")
            .selected_text(shown)
            .show_ui(ui, |ui| {
                for b in &config.branches {
                    ui.selectable_value(
                        &mut config.cb_name,
                        b.name.clone(),
                        format!("config/{} @ {}", b.name, b.tip_short_oid),
                    )
                    .on_hover_text(LINEAGE_HINT);
                }
                ui.selectable_value(&mut config.cb_name, String::new(), NEW_LINEAGE)
                    .on_hover_text(
                        "Name a config branch this workspace does not have yet, in the \
                         field this choice reveals — the name `/config orphan <name>` \
                         would carry.",
                    );
            })
            .response
            .on_hover_text(LINEAGE_HINT);
        if !known {
            // The remainder of the line it is on, never egui's fixed 280 pt
            // `text_edit_width` — which is what forced this row wider than the
            // pane whatever the pane had. The peers wrap below it (bl-7414).
            ui.add(egui::TextEdit::singleline(&mut config.cb_name).desired_width(f32::INFINITY))
                .on_hover_text(LINEAGE_HINT);
        }
        ui.selectable_value(&mut config.cb_origin, EditOrigin::Advance, "advance")
            .on_hover_text(
                "Commit on top of the branch as it stands, keeping everything already \
                 on it. Typed, it is `/config branch <name> <text…>`.",
            );
        ui.selectable_value(&mut config.cb_origin, EditOrigin::Orphan, "orphan")
            .on_hover_text(
                "Start the branch over from nothing — only the file below survives, \
                 and its previous history is left behind. Typed, it is \
                 `/config orphan <name> <text…>`.",
            );
    });
    if config.cb_name != before {
        config.cb_files = tree(config, ws);
    }
}

/// The file row: the paths the lineage's commit actually holds, and the Load
/// that fills the body from it — an edit is over what is there, never a blank.
fn file_row(ui: &mut egui::Ui, config: &mut ConfigState, ws: &Path) {
    // §11 rule 8 (bl-7414): a dropdown, the free-path field it reveals and Load
    // are each a control of their own, and a control that does not fit is not
    // elided — egui simply never lays it out (`Load` measured 26 pt laid, 2 pt
    // shown). So the row wraps to a second line rather than pushing the verb off
    // the pane, and the field takes the remainder of its own line rather than
    // egui's fixed 280 pt `text_edit_width`, which is what forced the row wider
    // than the pane and ratcheted the seat's `max_rect` for every row below it.
    let files = config.cb_files.clone();
    let load_asked = crate::shell::row::peers(ui, |ui| {
        ui.label("file:").on_hover_text(FILE_HINT);
        egui::ComboBox::from_id_salt("config-branch-file")
            .selected_text(config.cb_path.as_str())
            .show_ui(ui, |ui| {
                for path in &files {
                    ui.selectable_value(&mut config.cb_path, path.clone(), path)
                        .on_hover_text(FILE_HINT);
                }
            })
            .response
            .on_hover_text(FILE_HINT);
        if files.is_empty() {
            ui.add(egui::TextEdit::singleline(&mut config.cb_path).desired_width(f32::INFINITY))
                .on_hover_text(FILE_HINT);
        }
        ui.button("Load")
            .on_hover_text(
                "Read this file out of the selected lineage's tip into the editor \
                 below, so the edit starts from what is actually there. \
                 `/config branch <name> <path>` with no text reads the same \
                 bytes, and `/lineages` lists what there is to read.",
            )
            .clicked()
    });
    if load_asked {
        let said = load(config, ws);
        config.cb_act.say(said);
    }
}

/// Read the selected file out of the selected lineage's tip into the body.
fn load(config: &mut ConfigState, ws: &Path) -> String {
    let refspec = format!("config/{}", config.cb_name);
    match config_file(ws, &refspec, &config.cb_path) {
        Ok(bytes) => {
            config.cb_body = String::from_utf8_lossy(&bytes).into_owned();
            format!("loaded {refspec}:{}", config.cb_path)
        }
        Err(e) => format!("load: {e}"),
    }
}

/// The schema for the selected path's basename, or `None` — the raw fallback.
fn schema_of(path: &str) -> Option<Schema> {
    form::schema_for(path.rsplit('/').next()?)
}

/// The loaded file's settings as controls; an edit rewrites the body in RAM and
/// `lernie config` remains the writer.
fn settings(
    ui: &mut egui::Ui,
    config: &mut ConfigState,
    schema: &Schema,
    provider_rows: &[String],
) {
    let groups = form::read(schema, &config.cb_body, provider_rows);
    if let Some((row, value)) = form_ui::render(ui, "config-branch", &groups, provider_rows) {
        match form::write(schema, &config.cb_body, &row, &value) {
            Ok(text) => {
                config.cb_body = text;
                config.cb_act.say(format!(
                    "{}.{} drafted — Send to commit",
                    row.entry, row.field
                ));
            }
            Err(e) => config.cb_act.say(e.to_string()),
        }
    }
}