limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Interactive TUI picker used by `limb pick`.
//!
//! Split across focused submodules:
//!
//! - [`app`]. The `App` state machine, plus keyboard navigation logic.
//! - [`events`]. Reads crossterm key / mouse events and dispatches them
//!   into `App`.
//! - [`preview`]. Lazy per-worktree preview cache (commits + diffstat).
//! - [`render`]. Ratatui `draw` pass.
//! - [`theme`]. Palettes (`vesper`, `default`, `nord`, `gruvbox`,
//!   `solarized`, `plain`). `vesper` is the built-in default.
//!
//! The entrypoint is [`run_picker`]; everything else is an implementation
//! detail.

pub mod app;
pub mod events;
pub mod preview;
pub mod render;
pub mod theme;

use std::io::{self, IsTerminal, Stderr};

use anyhow::{Context as _, Result};
use crossterm::{execute, terminal};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;

use crate::error::Canceled;

use self::app::{App, Entry, Mode, Outcome};
use self::theme::Theme;

/// Runs the picker to completion and returns the selected path.
///
/// Takes over stderr (the TUI renders there to keep stdout clean for
/// path output), enters the alternate screen, and enables raw mode and
/// mouse capture. A `RawTerminalGuard` restores the terminal on drop.
/// even on panic. So the shell is always left usable.
///
/// # Errors
///
/// Returns an error if stdin or stderr is not a TTY, if ratatui fails to
/// initialise, or if the draw / event pump fails. Cancellation (Esc or
/// `q`) surfaces as [`crate::error::Canceled`].
pub fn run_picker(entries: Vec<Entry>, mode: Mode, theme: Theme) -> Result<std::path::PathBuf> {
    ensure_tty()?;

    let mut stderr = io::stderr();
    let _guard = RawTerminalGuard::enter(&mut stderr)?;
    let backend = CrosstermBackend::new(io::stderr());
    let mut term = Terminal::new(backend).context("initialize ratatui terminal on stderr")?;
    term.clear().ok();

    let mut app = App::new(entries, mode, theme);
    loop {
        if let Some(e) = app.selected_entry() {
            let _ = app.preview.get_or_compute(&e.worktree.path.clone());
        }
        term.draw(|f| render::draw(f, &mut app))
            .context("tui draw failed")?;
        match events::next(&mut app)? {
            Outcome::Continue => {}
            Outcome::Cancel => return Err(Canceled.into()),
            Outcome::Select(path) => return Ok(path),
        }
    }
}

fn ensure_tty() -> Result<()> {
    if !io::stdin().is_terminal() {
        anyhow::bail!("limb pick requires a terminal; stdin is not a tty");
    }
    if !io::stderr().is_terminal() {
        anyhow::bail!("limb pick requires a terminal; stderr is not a tty");
    }
    Ok(())
}

struct RawTerminalGuard;

impl RawTerminalGuard {
    fn enter(stderr: &mut Stderr) -> Result<Self> {
        terminal::enable_raw_mode().context("enable raw mode")?;
        execute!(
            stderr,
            terminal::EnterAlternateScreen,
            crossterm::event::EnableMouseCapture
        )
        .context("enter alternate screen")?;
        Ok(Self)
    }
}

impl Drop for RawTerminalGuard {
    fn drop(&mut self) {
        let _ = execute!(
            io::stderr(),
            crossterm::event::DisableMouseCapture,
            terminal::LeaveAlternateScreen
        );
        let _ = terminal::disable_raw_mode();
    }
}