use std::path::PathBuf;
use crate::fuzzy::Fuzzy;
use crate::worktree::{Upstream, Worktree};
use super::preview::Cache;
use super::theme::Theme;
pub enum Outcome {
Continue,
Cancel,
Select(PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
SingleRepo,
CrossRepo,
}
#[derive(Clone)]
pub struct Entry {
pub repo: Option<String>,
pub worktree: Worktree,
pub dirty: usize,
pub upstream: Option<Upstream>,
}
pub struct App {
pub entries: Vec<Entry>,
pub visible: Vec<usize>,
pub selected: usize,
pub mode: Mode,
pub filter_active: bool,
pub help_visible: bool,
pub fuzzy: Fuzzy,
pub preview: Cache,
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);
}
}