Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions crates/nebula-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2887,10 +2887,11 @@ impl PointerShape {
}
}

/// What `gh pr list` last said about one project's open pull requests, and
/// the timer deciding when to ask again. Held per project rather than
/// refetched per repaint because every answer is a `gh` process and a
/// GitHub API call, and the list changes on the order of minutes.
/// What `pull_request::list` last said about one project's open pull
/// requests, and the timer deciding when to ask again. Held per project
/// rather than refetched per repaint because every answer is a `gh`
/// process and a GitHub API call, and the list changes on the order of
/// minutes.
#[derive(Debug, Clone)]
pub struct OpenPrs {
/// Open pull requests: newest first, with the drafts sunk below every
Expand Down Expand Up @@ -3547,14 +3548,20 @@ pub struct App {
/// entry, so arriving somewhere always asks again promptly; so does its
/// pull request leaving the project's open list.
pub pr_recheck: HashMap<WorktreeId, (std::time::Instant, std::time::Duration)>,
/// What `gh pr list` last said about each project's open pull requests
/// What `pull_request::list` last said about each project's open pull requests
/// — the group at the bottom of the Worktrees panel. A missing key
/// means "never asked"; only the selected project is ever asked, so a
/// machine with thirty projects still costs one call per refresh.
pub open_prs: HashMap<ProjectId, OpenPrs>,
/// Projects with a list lookup in flight, so a repaint can't stack a
/// second `gh` on the first.
pub open_prs_inflight: std::collections::HashSet<ProjectId>,
/// Projects whose last list lookup came back with no answer — `gh`
/// failed, timed out, or the checkout is gone. The list kept on screen
/// is then the last one that worked, so the PULL REQUESTS MODAL says
/// `couldn't refresh` rather than pass it off as current; the next
/// answer that lands clears it.
pub open_prs_failed: std::collections::HashSet<ProjectId>,
/// Bodies and conversations of the pull requests the cursor has rested
/// on, keyed by URL. A second API call on top of the list, so it is
/// fetched only for the row actually being read and kept for the whole
Expand Down Expand Up @@ -3839,6 +3846,7 @@ impl App {
pr_recheck: HashMap::new(),
open_prs: HashMap::new(),
open_prs_inflight: std::collections::HashSet::new(),
open_prs_failed: std::collections::HashSet::new(),
pr_detail: HashMap::new(),
pr_detail_inflight: std::collections::HashSet::new(),
pr_detail_failed: std::collections::HashSet::new(),
Expand Down Expand Up @@ -4872,7 +4880,7 @@ impl App {
.collect()
}

/// The selected project's open pull requests, every one `gh pr list`
/// The selected project's open pull requests, every one the list query
/// answered with — drafts included whatever `hide_draft_prs` says, and
/// whether or not the group under the checkouts is showing them. The
/// list the fetch cap (`pull_request::LIST_LIMIT`) is measured
Expand Down Expand Up @@ -5225,7 +5233,7 @@ impl App {
}
}

/// Whether `gh pr list` should be run for this project now: not while
/// Whether the open list should be asked for this project now: not while
/// an answer is in flight, and not before the timer the last answer
/// armed. A project nebula has never asked about is always due.
pub fn open_prs_lookup_due(&self, project: &ProjectId) -> bool {
Expand Down
2 changes: 1 addition & 1 deletion crates/nebula-tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1058,7 +1058,7 @@ pub struct Config {
/// Leave draft pull requests out of the PROJECT OPEN PRS GROUP and the
/// `/` PALETTE's pull-request rows, so browsing what's open shows only
/// the rows asking for a reviewer. A view filter, not a fetch filter:
/// `gh pr list` still returns the drafts and the cache still holds
/// the open list's query still returns the drafts and the cache holds
/// them, so switching this off shows them again at once, and a draft
/// marked ready on GitHub joins the rows on the next refresh. Never
/// touches a checkout, its sessions, or the checkout's own PR ROW in
Expand Down
38 changes: 31 additions & 7 deletions crates/nebula-tui/src/event_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ const PR_SWEEP_REFRESH: Duration = Duration::from_secs(5 * 60);

/// How often the selected *project's* open-pull-request list is re-asked
/// once a repo has proved it has any, and how a repo that answers empty (or
/// can't answer at all) backs off. One `gh pr list` is one GraphQL call —
/// can't answer at all) backs off. One list lookup is one GraphQL call —
/// one point, however many pull requests come back. Every other project's
/// list is on the slower `OPEN_PRS_SWEEP_REFRESH`.
///
Expand Down Expand Up @@ -197,7 +197,7 @@ pub(crate) const OPEN_PRS_RECHECK_MAX: Duration = Duration::from_secs(10 * 60);
/// How often the open list of a project the cursor is *not* on is re-asked
/// — the background pass that keeps every project's group warm, so
/// switching to one shows a list minutes old at worst (and the cache the
/// next launch hydrates from is as fresh as that). One `gh pr list` per
/// next launch hydrates from is as fresh as that). One list lookup per
/// project per beat, one project per tick (`sweep_open_prs`): twelve
/// calls an hour per project against the budget above, and a project that
/// answers empty keeps its own backoff on top. Same reasoning and cadence
Expand Down Expand Up @@ -1216,7 +1216,9 @@ fn open_prs_sweep_target(app: &App) -> Option<(ProjectId, std::path::PathBuf)> {
/// attempt a backoff step further out, so a repo with no PRs (or a machine
/// with no `gh`) settles at `OPEN_PRS_RECHECK_MAX` instead of asking all
/// day. A failed call keeps whatever list was already on screen: one flaky
/// network round trip is no reason to blank the group.
/// network round trip is no reason to blank the group. It is noted,
/// though (`App::open_prs_failed`), so a call that keeps failing shows as
/// a list that `couldn't refresh` rather than a current one.
fn note_open_prs_answer(
app: &mut App,
project: nebula_core::ProjectId,
Expand All @@ -1232,6 +1234,15 @@ fn note_open_prs_answer(
// the cursor goes with it — a checkout is never lost to a re-list.
let checkout = app.selected_worktree().map(|w| w.id.clone());
app.open_prs_inflight.remove(&project);
let failed = list.is_none();
if failed != app.open_prs_failed.contains(&project) {
app.dirty = true;
if failed {
app.open_prs_failed.insert(project.clone());
} else {
app.open_prs_failed.remove(&project);
}
}
let previous = app.open_prs.get(&project);
let found = list.as_ref().is_some_and(|l| !l.is_empty());
let step = if found {
Expand Down Expand Up @@ -1415,7 +1426,7 @@ fn adopt_pr_state(app: &mut App, detail: &crate::pull_request::PrDetail) {
}

/// Retire one pull request from every project's list ahead of the next
/// `gh pr list`, because GitHub has just told us — in the detail fetched
/// list lookup, because GitHub has just told us — in the detail fetched
/// for the row the cursor is resting on — that it is merged or closed.
/// The list refresh would catch it within the minute anyway; this is for
/// the case where the user is looking straight at it.
Expand Down Expand Up @@ -1450,6 +1461,7 @@ fn prune_pull_requests_to_tree(app: &mut App) {
app.pull_requests.retain(|w, _| worktrees.contains(w));
app.pr_recheck.retain(|w, _| worktrees.contains(w));
app.open_prs.retain(|p, _| projects.contains(p));
app.open_prs_failed.retain(|p| projects.contains(p));
app.pr_cache_dirty |= before != (app.pull_requests.len(), app.open_prs.len());
forget_retired_prs(app);
}
Expand Down Expand Up @@ -4584,7 +4596,7 @@ fn toggle_issues(app: &mut App, out: &mut Vec<ClientRequest>) {
}

/// Re-seat the Worktrees cursor on checkout `id` after the rows regrouped
/// under it — a fold, a draft toggle, a fresh `gh pr list` answer — each
/// under it — a fold, a draft toggle, a fresh open-list answer — each
/// of which can move a checkout under its pull request's row or back out
/// among the plain ones (`App::worktree_rows`). The pane needs nothing:
/// the worktree under the cursor is the one it was showing. A cursor
Expand Down Expand Up @@ -10550,6 +10562,7 @@ fn apply_removal(app: &mut App, id: &nebula_core::EntityId) {
app.pull_requests.retain(|w, _| !wt_ids.contains(w));
app.pr_recheck.retain(|w, _| !wt_ids.contains(w));
app.open_prs.remove(id);
app.open_prs_failed.remove(id);
app.pr_cache_dirty = true;
app.tree.worktrees.retain(|w| &w.project_id != id);
app.tree.projects.retain(|p| &p.id != id);
Expand Down Expand Up @@ -12691,12 +12704,23 @@ mod tests {
);
assert_eq!(app.visible_open_prs().len(), 1);

assert!(!app.open_prs_failed.contains(&pid));
note_open_prs_answer(&mut app, pid.clone(), None, &mut Vec::new());
assert_eq!(
app.open_prs[&pid].list, found,
"a failed call keeps the last good list"
);
assert!(app.open_prs[&pid].step > OPEN_PRS_REFRESH, "but backs off");
assert!(
app.open_prs_failed.contains(&pid),
"and marks it as one that couldn't be refreshed (#106)"
);

note_open_prs_answer(&mut app, pid.clone(), Some(vec![]), &mut Vec::new());
assert!(
!app.open_prs_failed.contains(&pid),
"the next real answer clears the mark"
);
}

/// Arriving at a project asks again promptly — but never more often than
Expand Down Expand Up @@ -25740,12 +25764,12 @@ diff --git a/src/c.rs b/src/c.rs
"the ROOT WORKTREE is on our main: {:?}",
app.tree.worktrees
);
let list = crate::pull_request::parse_list(
let list = crate::pull_request::parse_list(&crate::pull_request::list_answer(
r#"[{"number":129,"title":"Prefer PowerShell 7",
"url":"https://github.com/o/r/pull/129","isDraft":false,
"headRefName":"main","isCrossRepository":true,
"headRepositoryOwner":{"login":"givemeurhats"}}]"#,
)
))
.expect("parsed");
let project = app.selected_project().expect("a project").id.clone();
let now = std::time::Instant::now();
Expand Down
70 changes: 64 additions & 6 deletions crates/nebula-tui/src/pr_modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,9 @@ pub(crate) fn draw(
let rows: Vec<OpenPr> = rows(app, &view.project).to_vec();
let inflight = app.open_prs_inflight.contains(&view.project);
let asked = app.open_prs.contains_key(&view.project);
// The last ask came back with nothing — these rows are the last
// answer that worked, however old — and no second ask is running yet.
let stale = app.open_prs_failed.contains(&view.project) && !inflight;
// The rows the filter leaves, and where the cursor sits among them.
let visible = visible_rows(&view.query, &rows);
let cursor = cursor_index(view, &rows);
Expand Down Expand Up @@ -836,14 +839,28 @@ pub(crate) fn draw(
let line = search_line(&view.query, "type to filter…", query_area, th);
f.render_widget(Paragraph::new(line), query_area);
}
let rows_area = crate::ui::below_first_row(list_inner);
if rows.is_empty() {
let text = if inflight || !asked {
"asking GitHub…"
let mut rows_area = crate::ui::below_first_row(list_inner);
// A list GitHub could not be asked for says so on a row of its own
// under the filter, never only in a title a narrow list would cut:
// rows that stopped refreshing look exactly like current ones (#106).
if stale {
let note = if rows.is_empty() {
"couldn't ask GitHub (^r retries)"
} else {
"no open pull requests"
"couldn't refresh (^r retries)"
};
empty_list_row(f, rows_area, text, th);
if let Some(note_area) = row_rect(rows_area, 0) {
let note = Span::styled(note, Style::default().fg(th.warn));
f.render_widget(Paragraph::new(note), note_area);
}
rows_area = crate::ui::below_first_row(rows_area);
}
if rows.is_empty() {
if inflight || !asked {
empty_list_row(f, rows_area, "asking GitHub…", th);
} else if !stale {
empty_list_row(f, rows_area, "no open pull requests", th);
}
} else if visible.is_empty() {
empty_list_row(f, rows_area, "no pull requests match", th);
}
Expand Down Expand Up @@ -1438,6 +1455,47 @@ mod tests {
assert!(screen(&mut app, 100, 20).contains("asking GitHub…"));
}

/// A list GitHub could not be asked for says so, on a row of its own
/// under the filter where a narrow modal cannot cut it off — rows that
/// stopped refreshing must not pass for current ones (#106). The rows
/// stay, and stay clickable under the note; a retry in flight says
/// `refreshing…` instead, and an answer clears it.
#[test]
fn a_list_that_could_not_be_refreshed_says_so() {
let (mut app, project) = app_with(vec![pr(42, "Fix login", false)], true);
open(&mut app);
let fine = screen(&mut app, 100, 20);
assert!(!fine.contains("couldn't refresh"), "{fine}");
let first_row = view(&app).list_area.y;

app.open_prs_failed.insert(project.clone());
let stale = screen(&mut app, 100, 20);
assert!(stale.contains("couldn't refresh (^r retries)"), "{stale}");
assert!(stale.contains("#42 Fix login"), "{stale}");
assert_eq!(
view(&app).list_area.y,
first_row + 1,
"the rows' hit area starts under the note"
);

app.open_prs_inflight.insert(project.clone());
// Wide enough for the title to say it in full.
let retrying = screen(&mut app, 160, 20);
assert!(!retrying.contains("couldn't refresh"), "{retrying}");
assert!(retrying.contains("refreshing…"), "{retrying}");
app.open_prs_inflight.remove(&project);

app.open_prs.get_mut(&project).unwrap().list = vec![];
let never = screen(&mut app, 100, 20);
assert!(never.contains("couldn't ask GitHub"), "{never}");
assert!(!never.contains("no open pull requests"), "{never}");

app.open_prs_failed.remove(&project);
let answered = screen(&mut app, 100, 20);
assert!(!answered.contains("couldn't"), "{answered}");
assert!(answered.contains("no open pull requests"), "{answered}");
}

/// `Ctrl+o` and a click on the reading pane's `↗ open in browser` button run
/// one open: the footer names where the browser went either way (INPUT
/// PARITY), and the modal stays up. The button is drawn pinned right on
Expand Down
Loading
Loading