stax 0.50.2

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

use anyhow::{Context, Result};
use app::{DashboardMode, PendingCommand, WorktreeApp};
use crossterm::{
    event::{self, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::path::Path;
use std::process::Command;
use std::time::Duration;

use crate::git::GitRepo;

pub fn run() -> Result<()> {
    let mut status_message = None;
    let mut preferred_selection = None;

    loop {
        let outcome = run_once(status_message.take(), preferred_selection.take())?;
        match outcome {
            DashboardOutcome::Quit => return Ok(()),
            DashboardOutcome::Command(command) => {
                preferred_selection = selection_after_command(&command);
                status_message = execute_dashboard_command(&command)?;
            }
        }
    }
}

enum DashboardOutcome {
    Quit,
    Command(PendingCommand),
}

fn run_once(
    initial_status: Option<String>,
    preferred_selection: Option<String>,
) -> Result<DashboardOutcome> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let result = WorktreeApp::new(initial_status, preferred_selection)
        .and_then(|mut app| run_app(&mut terminal, &mut app));

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

    result
}

fn run_app(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut WorktreeApp,
) -> Result<DashboardOutcome> {
    loop {
        app.refresh_background();
        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, key.modifiers)?;
            }
        }
        app.refresh_background();

        if app.should_quit {
            if let Some(command) = app.pending_command.take() {
                return Ok(DashboardOutcome::Command(command));
            }
            return Ok(DashboardOutcome::Quit);
        }
    }
}

fn handle_key(app: &mut WorktreeApp, code: KeyCode, modifiers: KeyModifiers) -> Result<()> {
    match app.mode {
        DashboardMode::Normal => handle_normal_key(app, code, modifiers),
        DashboardMode::Help => {
            app.mode = DashboardMode::Normal;
            Ok(())
        }
        DashboardMode::CreateInput => handle_create_key(app, code),
        DashboardMode::ConfirmDelete => handle_delete_key(app, code),
        DashboardMode::ConfirmForceDelete => handle_force_delete_key(app, code),
    }
}

fn handle_normal_key(app: &mut WorktreeApp, code: KeyCode, modifiers: KeyModifiers) -> Result<()> {
    match code {
        KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true,
        KeyCode::Char('?') => app.mode = DashboardMode::Help,
        KeyCode::Up | KeyCode::Char('k') => app.select_previous(),
        KeyCode::Down | KeyCode::Char('j') => app.select_next(),
        KeyCode::Enter => app.request_go(),
        KeyCode::Char('c') => app.request_create(),
        KeyCode::Char('d') => app.request_delete(),
        KeyCode::Char('R') => app.request_restack(),
        KeyCode::Char('r') if modifiers.contains(KeyModifiers::SHIFT) => app.request_restack(),
        _ => {}
    }
    Ok(())
}

fn handle_create_key(app: &mut WorktreeApp, code: KeyCode) -> Result<()> {
    match code {
        KeyCode::Esc => {
            app.mode = DashboardMode::Normal;
            app.input_buffer.clear();
            app.input_cursor = 0;
        }
        KeyCode::Enter => app.confirm_create(),
        KeyCode::Left => {
            if app.input_cursor > 0 {
                app.input_cursor -= 1;
            }
        }
        KeyCode::Right => {
            if app.input_cursor < app.input_buffer.len() {
                app.input_cursor += 1;
            }
        }
        KeyCode::Home => app.input_cursor = 0,
        KeyCode::End => app.input_cursor = app.input_buffer.len(),
        KeyCode::Backspace => {
            if app.input_cursor > 0 {
                app.input_cursor -= 1;
                app.input_buffer.remove(app.input_cursor);
            }
        }
        KeyCode::Char(ch) => {
            app.input_buffer.insert(app.input_cursor, ch);
            app.input_cursor += 1;
        }
        _ => {}
    }
    Ok(())
}

fn handle_delete_key(app: &mut WorktreeApp, code: KeyCode) -> Result<()> {
    match code {
        KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
            app.mode = DashboardMode::Normal;
        }
        KeyCode::Char('y') | KeyCode::Char('Y') => app.confirm_delete(),
        _ => {}
    }
    Ok(())
}

fn handle_force_delete_key(app: &mut WorktreeApp, code: KeyCode) -> Result<()> {
    match code {
        KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {
            app.mode = DashboardMode::Normal;
        }
        KeyCode::Char('y') | KeyCode::Char('Y') => app.confirm_force_delete(),
        _ => {}
    }
    Ok(())
}

fn selection_after_command(command: &PendingCommand) -> Option<String> {
    match command {
        PendingCommand::Go { name } | PendingCommand::Create { name: Some(name) } => {
            Some(name.clone())
        }
        PendingCommand::Create { name: None } | PendingCommand::Restack => None,
    }
}

fn execute_dashboard_command(command: &PendingCommand) -> Result<Option<String>> {
    let repo = GitRepo::open()?;
    let exe = std::env::current_exe().context("Failed to locate current executable")?;
    let args = command.args();
    let workdir = repo.workdir()?;

    match command {
        PendingCommand::Go { .. } | PendingCommand::Create { .. } => {
            let status = Command::new(&exe)
                .args(&args)
                .current_dir(workdir)
                .status()
                .with_context(|| format!("Failed to run '{}'", args.join(" ")))?;

            if status.success() {
                Ok(None)
            } else {
                Ok(Some(format!("Command failed: {}", args.join(" "))))
            }
        }
        PendingCommand::Restack => run_captured_command(&exe, workdir, &args)
            .map(|status| status.or_else(|| Some("Restacked managed worktrees".to_string()))),
    }
}

fn run_captured_command(exe: &Path, cwd: &Path, args: &[String]) -> Result<Option<String>> {
    let output = Command::new(exe)
        .args(args)
        .current_dir(cwd)
        .output()
        .with_context(|| format!("Failed to run '{}'", args.join(" ")))?;

    if output.status.success() {
        return Ok(None);
    }

    let stderr = String::from_utf8_lossy(&output.stderr);
    let stdout = String::from_utf8_lossy(&output.stdout);
    let message = stderr
        .lines()
        .chain(stdout.lines())
        .find(|line| !line.trim().is_empty())
        .unwrap_or("Command failed")
        .to_string();

    Ok(Some(message))
}