stax 0.93.0

Fast stacked Git branches and PRs
Documentation
use crate::commands::ready::{ReadyBranch, ReadyRowState};
use std::collections::HashMap;

#[derive(Debug)]
pub enum ReadyTuiUpdate {
    Loaded {
        index: usize,
        row: crate::commands::ready::PrReadinessRow,
    },
    Unavailable {
        index: usize,
        branch: ReadyBranch,
        message: String,
    },
    Done,
}

pub struct ReadyTuiApp {
    pub repo_label: String,
    pub scope_label: String,
    pub rows: Vec<ReadyRowState>,
    branch_order: HashMap<String, usize>,
    pub selected_index: usize,
    pub status_message: Option<String>,
    pub show_help: bool,
    pub should_quit: bool,
    pub loading: bool,
}

impl ReadyTuiApp {
    #[cfg(test)]
    pub fn new_for_test(repo_label: &str, scope_label: &str, branches: Vec<ReadyBranch>) -> Self {
        Self::from_parts(repo_label.to_string(), scope_label.to_string(), branches)
    }

    pub fn from_parts(repo_label: String, scope_label: String, branches: Vec<ReadyBranch>) -> Self {
        let branch_order = branches
            .iter()
            .enumerate()
            .map(|(index, branch)| (branch.name.clone(), index))
            .collect::<HashMap<_, _>>();
        let rows = branches
            .into_iter()
            .map(|branch| ReadyRowState::Loading { branch })
            .collect::<Vec<_>>();

        Self {
            repo_label,
            scope_label,
            rows,
            branch_order,
            selected_index: 0,
            status_message: None,
            show_help: false,
            should_quit: false,
            loading: true,
        }
    }

    pub fn select_previous(&mut self) {
        if self.selected_index > 0 {
            self.selected_index -= 1;
        }
    }

    pub fn select_next(&mut self) {
        if self.selected_index + 1 < self.rows.len() {
            self.selected_index += 1;
        }
    }

    pub fn apply_update(&mut self, update: ReadyTuiUpdate) {
        match update {
            ReadyTuiUpdate::Loaded { index, row } => {
                if let Some(slot) = self.row_slot_mut(index, &row.branch) {
                    *slot = ReadyRowState::Loaded(row);
                }
                self.sort_rows_by_updated_at();
            }
            ReadyTuiUpdate::Unavailable {
                index,
                branch,
                message,
            } => {
                let branch_name = branch.name.clone();
                if let Some(slot) = self.row_slot_mut(index, &branch_name) {
                    *slot = ReadyRowState::Unavailable { branch, message };
                }
                self.sort_rows_by_updated_at();
            }
            ReadyTuiUpdate::Done => {
                self.loading = false;
            }
        }
    }

    fn row_slot_mut(&mut self, fallback_index: usize, branch: &str) -> Option<&mut ReadyRowState> {
        let index = self
            .rows
            .iter()
            .position(|row| row.branch() == branch)
            .unwrap_or(fallback_index);
        self.rows.get_mut(index)
    }

    fn sort_rows_by_updated_at(&mut self) {
        let selected_branch = self
            .rows
            .get(self.selected_index)
            .map(|row| row.branch().to_string());

        self.rows.sort_by(|a, b| match (a, b) {
            (ReadyRowState::Loaded(a), ReadyRowState::Loaded(b)) => {
                b.updated_at.cmp(&a.updated_at).then_with(|| {
                    self.branch_order
                        .get(a.branch.as_str())
                        .copied()
                        .unwrap_or(usize::MAX)
                        .cmp(
                            &self
                                .branch_order
                                .get(b.branch.as_str())
                                .copied()
                                .unwrap_or(usize::MAX),
                        )
                })
            }
            (ReadyRowState::Loaded(_), _) => std::cmp::Ordering::Less,
            (_, ReadyRowState::Loaded(_)) => std::cmp::Ordering::Greater,
            _ => self
                .branch_order
                .get(a.branch())
                .copied()
                .unwrap_or(usize::MAX)
                .cmp(
                    &self
                        .branch_order
                        .get(b.branch())
                        .copied()
                        .unwrap_or(usize::MAX),
                ),
        });

        if let Some(selected_branch) = selected_branch {
            if let Some(index) = self
                .rows
                .iter()
                .position(|row| row.branch() == selected_branch)
            {
                self.selected_index = index;
            }
        }
    }

    pub fn reset_for_refresh(&mut self) {
        self.rows = self
            .rows
            .iter()
            .map(|row| ReadyRowState::Loading {
                branch: ReadyBranch {
                    name: row.branch().to_string(),
                    pr_number: row.pr_number(),
                },
            })
            .collect();
        self.loading = true;
        self.status_message = Some("Refreshing PR readiness...".to_string());
    }

    pub fn loading_count(&self) -> usize {
        self.rows
            .iter()
            .filter(|row| matches!(row, ReadyRowState::Loading { .. }))
            .count()
    }

    pub fn selected_pr_url(&self) -> Option<String> {
        match self.rows.get(self.selected_index) {
            Some(ReadyRowState::Loaded(row)) => row.pr_url.clone(),
            _ => None,
        }
    }

    pub fn selected_row(&self) -> Option<&ReadyRowState> {
        self.rows.get(self.selected_index)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::ready::{
        PrReadinessRow, ReadyAction, ReadyBranch, ReadyReason, ReadyRowState,
    };

    fn branches() -> Vec<ReadyBranch> {
        vec![
            ReadyBranch {
                name: "feature/a".to_string(),
                pr_number: Some(10),
            },
            ReadyBranch {
                name: "feature/b".to_string(),
                pr_number: Some(11),
            },
        ]
    }

    fn loaded_row(branch: &str, pr_number: u64) -> PrReadinessRow {
        PrReadinessRow {
            branch: branch.to_string(),
            pr_number,
            title: "Ready PR".to_string(),
            updated_at: Some("2026-06-01T10:00:00Z".to_string()),
            action: ReadyAction::Merge,
            reason: ReadyReason::Ready,
            review_decision: Some("APPROVED".to_string()),
            approvals: 1,
            changes_requested: false,
            ci_status: "success".to_string(),
            ci_summary: "passed".to_string(),
            is_draft: false,
            mergeable: Some(true),
            mergeable_state: "clean".to_string(),
            review_summary: "1 approval".to_string(),
            pr_url: Some(format!("https://example.com/pull/{pr_number}")),
            pr_state: "open".to_string(),
        }
    }

    #[test]
    fn ready_tui_initializes_rows_as_loading_placeholders() {
        let app = ReadyTuiApp::new_for_test("owner/repo", "current stack", branches());

        assert_eq!(app.rows.len(), 2);
        assert_eq!(app.selected_index, 0);
        assert!(matches!(app.rows[0], ReadyRowState::Loading { .. }));
    }

    #[test]
    fn ready_tui_selection_stays_within_bounds() {
        let mut app = ReadyTuiApp::new_for_test("owner/repo", "current stack", branches());

        app.select_previous();
        assert_eq!(app.selected_index, 0);
        app.select_next();
        assert_eq!(app.selected_index, 1);
        app.select_next();
        assert_eq!(app.selected_index, 1);
    }

    #[test]
    fn ready_tui_applies_loaded_row_update() {
        let mut app = ReadyTuiApp::new_for_test("owner/repo", "current stack", branches());

        app.apply_update(ReadyTuiUpdate::Loaded {
            index: 1,
            row: loaded_row("feature/b", 11),
        });

        let loaded = app
            .rows
            .iter()
            .find(|row| row.branch() == "feature/b")
            .expect("feature/b row");
        match loaded {
            ReadyRowState::Loaded(row) => assert_eq!(row.branch, "feature/b"),
            other => panic!("expected loaded row, got {other:?}"),
        }
    }

    #[test]
    fn ready_tui_sorts_loaded_rows_by_updated_at_newest_first() {
        let mut app = ReadyTuiApp::new_for_test("owner/repo", "current stack", branches());
        let mut older = loaded_row("feature/a", 10);
        older.updated_at = Some("2026-06-01T10:00:00Z".to_string());
        let mut newer = loaded_row("feature/b", 11);
        newer.updated_at = Some("2026-06-02T10:00:00Z".to_string());

        app.apply_update(ReadyTuiUpdate::Loaded {
            index: 0,
            row: older,
        });
        app.apply_update(ReadyTuiUpdate::Loaded {
            index: 1,
            row: newer,
        });

        assert_eq!(app.rows[0].branch(), "feature/b");
        assert_eq!(app.rows[1].branch(), "feature/a");
    }

    #[test]
    fn ready_tui_selected_pr_url_comes_from_selected_loaded_row() {
        let mut app = ReadyTuiApp::new_for_test("owner/repo", "current stack", branches());
        app.apply_update(ReadyTuiUpdate::Loaded {
            index: 0,
            row: loaded_row("feature/a", 10),
        });

        assert_eq!(
            app.selected_pr_url(),
            Some("https://example.com/pull/10".to_string())
        );
    }

    #[test]
    fn ready_tui_refresh_resets_rows_to_loading() {
        let mut app = ReadyTuiApp::new_for_test("owner/repo", "current stack", branches());
        app.apply_update(ReadyTuiUpdate::Loaded {
            index: 0,
            row: loaded_row("feature/a", 10),
        });

        app.reset_for_refresh();

        assert!(
            app.rows
                .iter()
                .all(|row| matches!(row, ReadyRowState::Loading { .. }))
        );
    }
}