limb 0.1.0

A focused CLI for git worktree management
Documentation
//! Picker state and navigation logic.

use std::path::PathBuf;

use crate::fuzzy::Fuzzy;
use crate::worktree::{Upstream, Worktree};

use super::preview::Cache;
use super::theme::Theme;

/// Result of a single event pump in the picker's main loop.
pub enum Outcome {
    /// Keep drawing.
    Continue,
    /// User cancelled; picker should exit with `Canceled`.
    Cancel,
    /// User selected a worktree; picker should print this path.
    Select(PathBuf),
}

/// Whether the picker is listing one repo or many.
///
/// Controls layout (cross-repo gets the extra REPO column) and the
/// filter haystack (cross-repo includes the repo name).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    /// Single repo: no REPO column.
    SingleRepo,
    /// Multiple repos merged from `projects.roots`.
    CrossRepo,
}

/// A single row in the picker.
#[derive(Clone)]
pub struct Entry {
    /// Enclosing repo's name, or `None` in single-repo mode.
    pub repo: Option<String>,
    /// The worktree being listed.
    pub worktree: Worktree,
    /// Pre-computed dirty-file count.
    pub dirty: usize,
    /// Pre-computed upstream tracking, if any.
    pub upstream: Option<Upstream>,
}

/// Full picker state.
///
/// Mutated by the event-dispatch functions in [`super::events`] and
/// rendered by [`super::render::draw`].
pub struct App {
    /// All entries the user can pick from (unfiltered).
    pub entries: Vec<Entry>,
    /// Indices into [`Self::entries`] currently visible after filtering.
    pub visible: Vec<usize>,
    /// Index into [`Self::visible`] (not [`Self::entries`]) of the row
    /// the user has highlighted.
    pub selected: usize,
    /// Layout mode (see [`Mode`]).
    pub mode: Mode,
    /// Whether the `/` filter input is currently capturing keystrokes.
    pub filter_active: bool,
    /// Whether the `?` help overlay is shown.
    pub help_visible: bool,
    /// Fuzzy-scorer state (`nucleo_matcher`-backed).
    pub fuzzy: Fuzzy,
    /// Lazy preview cache (commits + diffstat per worktree path).
    pub preview: Cache,
    /// Resolved colour palette.
    pub theme: Theme,
}

impl App {
    #[must_use]
    pub fn new(entries: Vec<Entry>, mode: Mode, theme: Theme) -> Self {
        let visible = (0..entries.len()).collect();
        Self {
            entries,
            visible,
            selected: 0,
            mode,
            filter_active: false,
            help_visible: false,
            fuzzy: Fuzzy::new(),
            preview: Cache::default(),
            theme,
        }
    }

    pub fn toggle_help(&mut self) {
        self.help_visible = !self.help_visible;
    }

    #[must_use]
    pub fn selected_entry(&self) -> Option<&Entry> {
        self.visible
            .get(self.selected)
            .and_then(|ix| self.entries.get(*ix))
    }

    #[must_use]
    pub fn selected_path(&self) -> Option<PathBuf> {
        self.selected_entry().map(|e| e.worktree.path.clone())
    }

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

    pub fn move_down(&mut self) {
        if !self.visible.is_empty() && self.selected + 1 < self.visible.len() {
            self.selected += 1;
        }
    }

    pub fn move_first(&mut self) {
        self.selected = 0;
    }

    pub fn move_last(&mut self) {
        if !self.visible.is_empty() {
            self.selected = self.visible.len() - 1;
        }
    }

    pub fn page_up(&mut self, size: usize) {
        self.selected = self.selected.saturating_sub(size.max(1));
    }

    pub fn page_down(&mut self, size: usize) {
        if self.visible.is_empty() {
            return;
        }
        let max = self.visible.len() - 1;
        self.selected = (self.selected + size.max(1)).min(max);
    }

    pub fn open_filter(&mut self) {
        self.filter_active = true;
    }

    pub fn close_filter(&mut self, clear: bool) {
        self.filter_active = false;
        if clear {
            self.fuzzy.set_query("");
            self.rebuild_visible();
        }
    }

    pub fn filter_push(&mut self, c: char) {
        let mut q = self.fuzzy.query().to_string();
        q.push(c);
        self.fuzzy.set_query(&q);
        self.rebuild_visible();
    }

    pub fn filter_pop(&mut self) {
        let mut q = self.fuzzy.query().to_string();
        if q.pop().is_some() {
            self.fuzzy.set_query(&q);
            self.rebuild_visible();
        }
    }

    fn rebuild_visible(&mut self) {
        let query_empty = self.fuzzy.query().is_empty();
        if query_empty {
            self.visible = (0..self.entries.len()).collect();
        } else {
            let mut scored: Vec<(usize, u32)> = self
                .entries
                .iter()
                .enumerate()
                .filter_map(|(ix, e)| self.fuzzy.score(&haystack(e)).map(|s| (ix, s)))
                .collect();
            scored.sort_by_key(|b| std::cmp::Reverse(b.1));
            self.visible = scored.into_iter().map(|(ix, _)| ix).collect();
        }
        self.selected = 0;
    }
}

fn haystack(e: &Entry) -> String {
    let repo = e.repo.as_deref().unwrap_or("");
    let branch = e.worktree.branch.as_deref().unwrap_or("");
    format!("{}/{} {}", repo, e.worktree.name, branch)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn entry(repo: &str, name: &str) -> Entry {
        Entry {
            repo: Some(repo.into()),
            worktree: Worktree {
                path: Path::new("/tmp").join(name),
                name: name.into(),
                branch: Some(name.into()),
                head: None,
                bare: false,
                locked: false,
                locked_reason: None,
                prunable: false,
                prunable_reason: None,
            },
            dirty: 0,
            upstream: None,
        }
    }

    fn app(n: usize) -> App {
        App::new(
            (0..n).map(|i| entry("r", &format!("w{i}"))).collect(),
            Mode::SingleRepo,
            Theme::resolve(super::super::theme::ThemeKind::Plain, true),
        )
    }

    #[test]
    fn empty_list_has_no_selection() {
        let a = App::new(
            Vec::new(),
            Mode::SingleRepo,
            Theme::resolve(super::super::theme::ThemeKind::Plain, true),
        );
        assert!(a.selected_path().is_none());
    }

    #[test]
    fn move_down_clamps_at_end() {
        let mut a = app(3);
        for _ in 0..5 {
            a.move_down();
        }
        assert_eq!(a.selected, 2);
    }

    #[test]
    fn move_up_clamps_at_zero() {
        let mut a = app(3);
        a.move_down();
        for _ in 0..5 {
            a.move_up();
        }
        assert_eq!(a.selected, 0);
    }

    #[test]
    fn page_down_respects_bounds() {
        let mut a = app(5);
        a.page_down(10);
        assert_eq!(a.selected, 4);
    }

    #[test]
    fn first_last_seek() {
        let mut a = app(5);
        a.move_last();
        assert_eq!(a.selected, 4);
        a.move_first();
        assert_eq!(a.selected, 0);
    }

    #[test]
    fn filter_narrows_visible() {
        let mut a = App::new(
            vec![
                entry("r", "feat-auth"),
                entry("r", "feat-billing"),
                entry("r", "bugfix"),
            ],
            Mode::SingleRepo,
            Theme::resolve(super::super::theme::ThemeKind::Plain, true),
        );
        a.filter_push('f');
        a.filter_push('e');
        a.filter_push('a');
        a.filter_push('t');
        assert_eq!(a.visible.len(), 2);
    }

    #[test]
    fn filter_empty_shows_all() {
        let mut a = app(3);
        a.filter_push('x');
        assert_eq!(a.visible.len(), 0);
        a.filter_pop();
        assert_eq!(a.visible.len(), 3);
    }

    #[test]
    fn close_filter_with_clear() {
        let mut a = app(3);
        a.open_filter();
        a.filter_push('x');
        a.close_filter(true);
        assert!(!a.filter_active);
        assert_eq!(a.visible.len(), 3);
    }
}