pub mod github;
pub mod local;
pub mod model;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use ratatui::layout::Rect;
pub use github::GhState;
pub use model::Checks;
pub use model::WorktreeMembership;
use model::{
BranchInfo, Commit, CommitShow, Contributor, Issue, IssueDetail, PrDetail, PullRequest,
RepoInfo, RepoStatus,
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Section {
Commits,
Flow,
Branches,
Prs,
Issues,
Status,
}
impl Section {
pub const ALL: [Section; 6] = [
Section::Commits,
Section::Flow,
Section::Branches,
Section::Prs,
Section::Issues,
Section::Status,
];
fn index(self) -> usize {
Self::ALL.iter().position(|s| *s == self).unwrap_or(0)
}
pub fn from_index(i: usize) -> Section {
Self::ALL[i % Self::ALL.len()]
}
pub fn next(self) -> Section {
Self::from_index(self.index() + 1)
}
pub fn prev(self) -> Section {
Self::from_index(self.index() + Self::ALL.len() - 1)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Scope {
ThisRepo,
MyWork,
}
impl Scope {
pub fn toggle(self) -> Scope {
match self {
Scope::ThisRepo => Scope::MyWork,
Scope::MyWork => Scope::ThisRepo,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum StateFilter {
#[default]
Open,
Closed,
Merged,
All,
}
impl StateFilter {
pub fn next(self, is_prs: bool) -> StateFilter {
if is_prs {
match self {
StateFilter::Open => StateFilter::Closed,
StateFilter::Closed => StateFilter::Merged,
StateFilter::Merged => StateFilter::All,
StateFilter::All => StateFilter::Open,
}
} else {
match self {
StateFilter::Open => StateFilter::Closed,
StateFilter::Closed => StateFilter::All,
StateFilter::Merged | StateFilter::All => StateFilter::Open,
}
}
}
pub fn gh_arg(self) -> &'static str {
match self {
StateFilter::Open => "open",
StateFilter::Closed => "closed",
StateFilter::Merged => "merged",
StateFilter::All => "all",
}
}
pub fn issue_arg(self) -> &'static str {
match self {
StateFilter::Merged => "all",
other => other.gh_arg(),
}
}
}
#[derive(Clone, Default)]
pub enum Load<T> {
#[default]
Idle,
Loading,
Loaded(T),
Error(String),
}
pub enum GitPayload {
Status(Result<RepoStatus, String>),
Info(Result<RepoInfo, String>),
Branches(Result<Vec<BranchInfo>, String>),
Commits(Result<Vec<Commit>, String>),
Gh(GhState),
Prs(Result<Vec<PullRequest>, String>),
Issues(Result<Vec<Issue>, String>),
PrDetail(Box<Result<PrDetail, String>>),
CommitDetail(Box<Result<CommitShow, String>>),
IssueDetail(Box<Result<IssueDetail, String>>),
}
fn next_id() -> u64 {
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
pub struct GitView {
pub id: u64,
pub repo_root: PathBuf,
pub repo_name: String,
pub section: Section,
pub cursor: usize,
pub scroll: usize,
pub filter: String,
pub filtering: bool,
pub scope: Scope,
pub state_filter: StateFilter,
pub gh: GhState,
pub status: Load<RepoStatus>,
pub info: Load<RepoInfo>,
pub branches: Load<Vec<BranchInfo>>,
pub commits: Load<Vec<Commit>>,
pub prs: Load<Vec<PullRequest>>,
pub issues: Load<Vec<Issue>>,
pub prev_pr_checks: HashMap<u64, Checks>,
pub open_pr: Option<u64>,
pub detail: Load<PrDetail>,
pub open_commit: Option<String>,
pub commit_detail: Load<CommitShow>,
pub open_issue: Option<u64>,
pub issue_detail: Load<IssueDetail>,
pub list_area: Rect,
pub contributors_expanded: bool,
pub show_emails: bool,
pub contributors_more_rect: Option<Rect>,
}
pub const CONTRIB_MIN_COMMITS: u32 = 10;
pub const CONTRIB_COLLAPSED_ROWS: usize = 20;
pub fn visible_contributors(all: &[Contributor], expanded: bool) -> (&[Contributor], usize) {
if expanded {
return (all, 0);
}
let qualifying = all
.iter()
.take_while(|c| c.commits >= CONTRIB_MIN_COMMITS)
.count();
let pool = if qualifying == 0 {
all.len()
} else {
qualifying
};
let shown = pool.min(CONTRIB_COLLAPSED_ROWS);
(&all[..shown], all.len() - shown)
}
impl GitView {
pub fn new(repo_root: PathBuf) -> GitView {
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("repo")
.to_string();
GitView {
id: next_id(),
repo_root,
repo_name,
section: Section::Commits,
cursor: 0,
scroll: 0,
filter: String::new(),
filtering: false,
scope: Scope::ThisRepo,
state_filter: StateFilter::Open,
gh: GhState::Missing,
status: Load::Loading,
info: Load::Loading,
branches: Load::Loading,
commits: Load::Loading,
prs: Load::Idle,
issues: Load::Idle,
prev_pr_checks: HashMap::new(),
open_pr: None,
detail: Load::Idle,
open_commit: None,
commit_detail: Load::Idle,
open_issue: None,
issue_detail: Load::Idle,
list_area: Rect::new(0, 0, 0, 0),
contributors_expanded: false,
show_emails: false,
contributors_more_rect: None,
}
}
pub fn apply(&mut self, payload: GitPayload) {
match payload {
GitPayload::Status(r) => self.status = into_load(r),
GitPayload::Info(r) => self.info = into_load(r),
GitPayload::Branches(r) => self.branches = into_load(r),
GitPayload::Commits(r) => self.commits = into_load(r),
GitPayload::Gh(s) => {
self.gh = s;
if s == GhState::Ready {
if matches!(self.prs, Load::Idle) {
self.prs = Load::Loading;
}
if matches!(self.issues, Load::Idle) {
self.issues = Load::Loading;
}
}
}
GitPayload::Prs(r) => self.prs = into_load(r),
GitPayload::Issues(r) => self.issues = into_load(r),
GitPayload::PrDetail(r) => {
if self.open_pr.is_some() {
self.detail = into_load(*r);
}
}
GitPayload::CommitDetail(r) => {
if self.open_commit.is_some() {
self.commit_detail = into_load(*r);
}
}
GitPayload::IssueDetail(r) => {
if self.open_issue.is_some() {
self.issue_detail = into_load(*r);
}
}
}
}
}
fn into_load<T>(r: Result<T, String>) -> Load<T> {
match r {
Ok(v) => Load::Loaded(v),
Err(e) => Load::Error(e),
}
}
pub fn filtered_branches<'a>(
v: &'a [BranchInfo],
filter: &'a str,
) -> impl Iterator<Item = &'a BranchInfo> {
let f = filter.to_lowercase();
v.iter().filter(move |b| {
f.is_empty() || b.name.to_lowercase().contains(&f) || b.subject.to_lowercase().contains(&f)
})
}
pub fn filtered_commits<'a>(v: &'a [Commit], filter: &'a str) -> impl Iterator<Item = &'a Commit> {
let f = filter.to_lowercase();
v.iter().filter(move |c| {
f.is_empty()
|| c.subject.to_lowercase().contains(&f)
|| c.author.to_lowercase().contains(&f)
})
}
pub fn filtered_prs<'a>(
v: &'a [PullRequest],
filter: &'a str,
) -> impl Iterator<Item = &'a PullRequest> {
let f = filter.to_lowercase();
v.iter().filter(move |p| {
f.is_empty()
|| p.title.to_lowercase().contains(&f)
|| p.author.to_lowercase().contains(&f)
|| p.head.to_lowercase().contains(&f)
})
}
pub fn filtered_issues<'a>(v: &'a [Issue], filter: &'a str) -> impl Iterator<Item = &'a Issue> {
let f = filter.to_lowercase();
v.iter().filter(move |i| {
f.is_empty()
|| i.title.to_lowercase().contains(&f)
|| i.author.to_lowercase().contains(&f)
|| i.labels.iter().any(|l| l.to_lowercase().contains(&f))
})
}
#[cfg(test)]
mod contributor_tests {
use super::*;
fn c(name: &str, commits: u32) -> Contributor {
Contributor {
name: name.into(),
email: format!("{name}@x.com"),
commits,
}
}
#[test]
fn collapsed_hides_small_contributors_and_expanding_reveals_all() {
let all: Vec<Contributor> = vec![
c("ada", 500),
c("bob", 40),
c("cy", 10),
c("dee", 9),
c("eve", 1),
];
let (shown, hidden) = visible_contributors(&all, false);
assert_eq!(
shown.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
["ada", "bob", "cy"],
"10+ commits kept; 9 and 1 hidden"
);
assert_eq!(hidden, 2, "the two under-10 authors are counted as hidden");
let (shown, hidden) = visible_contributors(&all, true);
assert_eq!(
shown.len(),
5,
"expanding shows everyone, including under 10"
);
assert_eq!(hidden, 0);
}
#[test]
fn collapsed_never_empties_a_young_repo() {
let all = vec![c("ada", 9), c("bob", 3), c("cy", 1)];
let (shown, hidden) = visible_contributors(&all, false);
assert_eq!(shown.len(), 3, "all shown when nobody clears the bar");
assert_eq!(hidden, 0);
}
#[test]
fn collapsed_caps_rows_and_reports_the_remainder() {
let all: Vec<Contributor> = (0..50).map(|i| c(&format!("a{i}"), 100 - i)).collect();
let (shown, hidden) = visible_contributors(&all, false);
assert_eq!(shown.len(), CONTRIB_COLLAPSED_ROWS);
assert_eq!(hidden, 50 - CONTRIB_COLLAPSED_ROWS);
}
}