stax 0.84.0

Fast stacked Git branches and PRs
Documentation
pub mod app;
pub mod ui;

use anyhow::Result;
use app::{ReadyTuiApp, ReadyTuiUpdate};
use crossterm::{
    event::{self, Event, KeyCode},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures_util::{stream, StreamExt};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::thread;
use std::time::Duration;

use crate::commands::open::open_url_in_browser;
use crate::commands::ready::{
    fetch_row_for_branch, load_ready_scope, ReadyScope, ReadyScopeMode, READY_FETCH_CONCURRENCY,
};
use crate::engine::Stack;
use crate::forge::ForgeClient;
use crate::git::GitRepo;
use crate::remote::RemoteInfo;

pub fn run(scope_mode: ReadyScopeMode) -> Result<()> {
    let scope = load_ready_scope(scope_mode)?;
    let mut app = ReadyTuiApp::from_parts(
        scope.repo_label.clone(),
        scope.scope_label.clone(),
        scope.branches.clone(),
    );
    let mut loader = Some(spawn_loader(scope.clone()));

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let result = run_app(&mut terminal, &mut app, &scope, &mut loader);

    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;

    result
}

fn run_app(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut ReadyTuiApp,
    scope: &ReadyScope,
    loader: &mut Option<Receiver<ReadyTuiUpdate>>,
) -> Result<()> {
    loop {
        poll_loader(app, loader);
        terminal.draw(|f| ui::render(f, app))?;

        if event::poll(Duration::from_millis(100))? {
            if let Event::Key(key) = event::read()? {
                handle_key(app, key.code, scope, loader);
            }
        }

        if app.should_quit {
            return Ok(());
        }
    }
}

fn handle_key(
    app: &mut ReadyTuiApp,
    code: KeyCode,
    scope: &ReadyScope,
    loader: &mut Option<Receiver<ReadyTuiUpdate>>,
) {
    if app.show_help {
        app.show_help = false;
        return;
    }

    match code {
        KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
        KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
        KeyCode::Down | KeyCode::Char('j') => app.select_next(),
        KeyCode::Char('?') => app.show_help = true,
        KeyCode::Char('r') => {
            app.reset_for_refresh();
            *loader = Some(spawn_loader(scope.clone()));
        }
        KeyCode::Enter | KeyCode::Char('o') => {
            if let Some(url) = app.selected_pr_url() {
                open_url_in_browser(&url);
                app.status_message = Some(format!("Opened {url}"));
            } else {
                app.status_message = Some("Selected row has no loaded PR URL yet".to_string());
            }
        }
        _ => {}
    }
}

fn poll_loader(app: &mut ReadyTuiApp, loader: &mut Option<Receiver<ReadyTuiUpdate>>) {
    loop {
        let update = match loader.as_ref() {
            Some(receiver) => match receiver.try_recv() {
                Ok(update) => Some(update),
                Err(TryRecvError::Empty) => None,
                Err(TryRecvError::Disconnected) => {
                    *loader = None;
                    app.loading = false;
                    app.status_message = Some("Readiness loader disconnected".to_string());
                    None
                }
            },
            None => None,
        };

        let Some(update) = update else {
            break;
        };

        let done = matches!(update, ReadyTuiUpdate::Done);
        app.apply_update(update);
        if done {
            *loader = None;
        }
    }
}

fn spawn_loader(scope: ReadyScope) -> Receiver<ReadyTuiUpdate> {
    let (sender, receiver) = mpsc::channel();

    thread::spawn(move || {
        let repo = match GitRepo::open_from_path(&scope.git_dir) {
            Ok(repo) => repo,
            Err(error) => {
                send_all_unavailable(&sender, &scope, format!("Failed to open repo: {error}"));
                let _ = sender.send(ReadyTuiUpdate::Done);
                return;
            }
        };
        let stack = match Stack::load(&repo) {
            Ok(stack) => stack,
            Err(error) => {
                send_all_unavailable(&sender, &scope, format!("Failed to load stack: {error}"));
                let _ = sender.send(ReadyTuiUpdate::Done);
                return;
            }
        };
        let runtime = match tokio::runtime::Runtime::new() {
            Ok(runtime) => runtime,
            Err(error) => {
                send_all_unavailable(
                    &sender,
                    &scope,
                    format!("Failed to create runtime: {error}"),
                );
                let _ = sender.send(ReadyTuiUpdate::Done);
                return;
            }
        };
        let client = match create_loader_forge_client(&runtime, &scope.remote) {
            Ok(client) => client,
            Err(error) => {
                send_all_unavailable(
                    &sender,
                    &scope,
                    format!("Failed to create forge client: {error}"),
                );
                let _ = sender.send(ReadyTuiUpdate::Done);
                return;
            }
        };

        runtime.block_on(async {
            let mut pending =
                stream::iter(scope.branches.iter().enumerate().map(|(index, branch)| {
                    let repo = &repo;
                    let client = &client;
                    let remote = &scope.remote;
                    let stack = &stack;
                    let branch = branch.clone();
                    async move {
                        (
                            index,
                            branch.clone(),
                            fetch_row_for_branch(repo, client, remote, stack, &branch).await,
                        )
                    }
                }))
                .buffer_unordered(READY_FETCH_CONCURRENCY);

            while let Some((index, branch, result)) = pending.next().await {
                match result {
                    Ok(Some(row)) => {
                        let _ = sender.send(ReadyTuiUpdate::Loaded { index, row });
                    }
                    Ok(None) => {
                        let _ = sender.send(ReadyTuiUpdate::Unavailable {
                            index,
                            branch,
                            message: "No PR found for branch".to_string(),
                        });
                    }
                    Err(error) => {
                        let _ = sender.send(ReadyTuiUpdate::Unavailable {
                            index,
                            branch,
                            message: error.to_string(),
                        });
                    }
                }
            }
        });

        let _ = sender.send(ReadyTuiUpdate::Done);
    });

    receiver
}

fn create_loader_forge_client(
    runtime: &tokio::runtime::Runtime,
    remote: &RemoteInfo,
) -> Result<ForgeClient> {
    let _enter = runtime.enter();
    ForgeClient::new(remote)
}

fn send_all_unavailable(
    sender: &mpsc::Sender<ReadyTuiUpdate>,
    scope: &ReadyScope,
    message: String,
) {
    for (index, branch) in scope.branches.iter().enumerate() {
        let _ = sender.send(ReadyTuiUpdate::Unavailable {
            index,
            branch: branch.clone(),
            message: message.clone(),
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::remote::{ForgeType, RemoteInfo};
    use std::env;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn ready_tui_constructs_forge_client_with_entered_runtime() {
        let _guard = ENV_LOCK.lock().unwrap();
        let original_token = env::var("STAX_GITHUB_TOKEN").ok();
        env::set_var("STAX_GITHUB_TOKEN", "test-token");

        let runtime = tokio::runtime::Runtime::new().expect("runtime");
        let remote = RemoteInfo {
            name: "origin".to_string(),
            forge: ForgeType::GitHub,
            host: "github.com".to_string(),
            namespace: "owner".to_string(),
            repo: "repo".to_string(),
            base_url: "https://github.com".to_string(),
            api_base_url: Some("https://api.github.com".to_string()),
        };

        let result = create_loader_forge_client(&runtime, &remote);

        match original_token {
            Some(token) => env::set_var("STAX_GITHUB_TOKEN", token),
            None => env::remove_var("STAX_GITHUB_TOKEN"),
        }
        assert!(result.is_ok());
    }
}