bohay 0.9.6

Mission control for your AI coding agents
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
//! Git & GitHub integration — the **git tab** (docs/17). A workspace's branch is
//! clickable; clicking it opens a built-in dashboard of the repo's branches,
//! commit flow, working tree, and (later) GitHub PRs/issues. Data is shelled out
//! to `git`/`gh` and fetched on a background thread — no HTTP dependency.
//!
//! GIT-1 (this layer): the tab, local-git sections (Branches / Commits / Status),
//! async fetch. PRs/issues (GIT-2), actions (GIT-3), and the flow renderer +
//! integrations (GIT-4) build on these pieces.

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,
};

/// Which section of the git tab is shown.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Section {
    Commits,
    Flow,
    Branches,
    Prs,
    Issues,
    Status,
}

impl Section {
    /// The view selector order (Commits is the default first tab).
    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)
    }
}

/// PR/issue scope: the current repo, or everything you're involved in.
#[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,
        }
    }
}

/// Which PRs/issues to list by state — cycled with `s` in the git tab (docs/17).
/// The default is `Open`, so a git tab opens on open PRs/issues like before.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub enum StateFilter {
    #[default]
    Open,
    Closed,
    /// PRs only — issues can't be merged (they skip this in the cycle).
    Merged,
    All,
}

impl StateFilter {
    /// Advance the filter with `s`. PRs cycle Open → Closed → Merged → All; issues
    /// skip Merged (Open → Closed → All), since GitHub issues are never "merged".
    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,
                // Merged is meaningless for issues; treat it as All and move on.
                StateFilter::Merged | StateFilter::All => StateFilter::Open,
            }
        }
    }
    /// The `gh … --state <v>` value.
    pub fn gh_arg(self) -> &'static str {
        match self {
            StateFilter::Open => "open",
            StateFilter::Closed => "closed",
            StateFilter::Merged => "merged",
            StateFilter::All => "all",
        }
    }
    /// The value valid for **issues** (which have no "merged" state) — Merged maps
    /// to All so a filter carried over from the PRs list still fetches cleanly.
    pub fn issue_arg(self) -> &'static str {
        match self {
            StateFilter::Merged => "all",
            other => other.gh_arg(),
        }
    }
}

/// Load state of a fetched section.
#[derive(Clone, Default)]
pub enum Load<T> {
    #[default]
    Idle,
    Loading,
    Loaded(T),
    Error(String),
}

/// Results delivered back to the loop from a fetch thread.
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>),
    // Boxed: `PrDetail` is large and would bloat every `AppEvent`.
    PrDetail(Box<Result<PrDetail, String>>),
    // The `git show` output for a clicked commit (docs/17), shown in-tab.
    CommitDetail(Box<Result<CommitShow, String>>),
    // Full detail for a clicked issue (docs/17), shown in-tab.
    IssueDetail(Box<Result<IssueDetail, String>>),
}

fn next_id() -> u64 {
    static NEXT: AtomicU64 = AtomicU64::new(1);
    NEXT.fetch_add(1, Ordering::Relaxed)
}

/// State of an open git tab.
pub struct GitView {
    /// Token used to match async results back to this tab.
    pub id: u64,
    pub repo_root: PathBuf,
    pub repo_name: String,
    pub section: Section,
    pub cursor: usize,
    /// Vertical scroll offset for non-cursor views (Flow / Status).
    pub scroll: usize,
    pub filter: String,
    pub filtering: bool,
    pub scope: Scope,
    /// Open/Closed/All filter for the PRs and Issues lists (docs/17).
    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>>,
    /// Last-seen CI state per PR, to notify only on a *transition* to failing.
    pub prev_pr_checks: HashMap<u64, Checks>,
    /// The open PR detail panel (`Some(number)` ⇒ the panel is showing that PR).
    pub open_pr: Option<u64>,
    pub detail: Load<PrDetail>,
    /// The open commit-detail view (`Some(sha)` ⇒ showing that commit's `git
    /// show`, in-tab, back with esc). Mirrors `open_pr` (docs/17).
    pub open_commit: Option<String>,
    pub commit_detail: Load<CommitShow>,
    /// The open issue-detail view (`Some(number)` ⇒ showing that issue in-tab,
    /// back with esc). Mirrors `open_pr` (docs/17).
    pub open_issue: Option<u64>,
    pub issue_detail: Load<IssueDetail>,
    /// The list body rect from the last render, so a click maps to the row under
    /// it. Transient (GitView is rebuilt on restore, never serialized).
    pub list_area: Rect,
    /// Status view: show every contributor instead of the meaningful-only default.
    /// Collapsed hides authors below [`CONTRIB_MIN_COMMITS`] (drive-by commits
    /// bury the real contributors on a big repo); expanding reveals **everyone**,
    /// so nobody is permanently hidden.
    pub contributors_expanded: bool,
    /// Status view: reveal contributor email addresses (hidden by default — the
    /// list is about who contributes, not how to mail them).
    pub show_emails: bool,
    /// Screen rect of the contributors "show more / show less" row from the last
    /// render, so a click can toggle it. `None` when it isn't on screen.
    pub contributors_more_rect: Option<Rect>,
}

/// Contributors below this many commits are hidden in the **collapsed** Status
/// view, so a big repo's list shows the people who actually shape the project
/// instead of a wall of one-off authors. Expanding shows everyone regardless.
pub const CONTRIB_MIN_COMMITS: u32 = 10;

/// Rows the collapsed contributor list shows before it offers "show more".
pub const CONTRIB_COLLAPSED_ROWS: usize = 20;

/// The contributors the Status view should draw, plus how many stay hidden.
///
/// *Collapsed* keeps authors with at least [`CONTRIB_MIN_COMMITS`] commits, up to
/// [`CONTRIB_COLLAPSED_ROWS`] rows — on a large repo that replaces a wall of
/// one-commit authors with the people who actually shape the project, and the
/// cap no longer cuts off real contributors. *Expanded* returns everyone,
/// uncapped (the view scrolls), so nobody is permanently hidden.
///
/// If **nobody** clears the commit floor (a young repo where everyone has a
/// handful) the floor is dropped instead of drawing an empty section.
///
/// Returns a sub-slice, never a new `Vec`: this runs on the render hot path.
/// Relies on `git shortlog -s -n` ordering (descending by commits), so the
/// qualifying authors are always a prefix.
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();
    // Nobody meets the bar → show the top of the list rather than nothing.
    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,
            // Commits (the flow of work) is the default first view.
            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,
        }
    }

    /// Apply an async fetch result.
    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),
            // Only apply detail if the panel is still open (it may have closed
            // while the fetch was in flight).
            GitPayload::PrDetail(r) => {
                if self.open_pr.is_some() {
                    self.detail = into_load(*r);
                }
            }
            // Only apply if the commit view is still open (it may have closed
            // while `git show` was running).
            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),
    }
}

/// Branches matching the filter (name/subject substring, case-insensitive).
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)
    })
}

/// Commits matching the filter (subject/author substring, case-insensitive).
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)
    })
}

/// PRs matching the filter (title/author/branch substring).
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)
    })
}

/// Issues matching the filter (title/author/label substring).
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,
        }
    }

    /// Collapsed hides drive-by authors so a big repo's list shows the people who
    /// actually shape it; expanding reveals everyone, so nobody is permanently
    /// hidden (the two halves of the contributor-privacy/visibility feature).
    #[test]
    fn collapsed_hides_small_contributors_and_expanding_reveals_all() {
        // Descending by commits, as `git shortlog -s -n` emits.
        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);
    }

    /// A young repo where nobody has 10 commits must not render an empty
    /// Contributors section — the floor is dropped rather than hiding all.
    #[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);
    }

    /// Collapsed caps its rows even when many authors clear the bar; the rest
    /// stay reachable through "show more".
    #[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);
    }
}