feat: browse GitHub GitLab and Gitea pull requests

This commit is contained in:
Valdemar Lorenzen
2026-10-14 13:22:09 +02:00
parent 15968a328c
commit af6496bd5a
11 changed files with 1349 additions and 47 deletions
+93 -18
View File
@@ -2594,6 +2594,7 @@ fn draw_diff(
return;
}
if diff.phase != Phase::View {
let pull_requests = diff.pull_request_remote.is_some();
let panel = floating_panel(area);
frame.render_widget(Clear, panel);
let stage = if diff.phase == Phase::Before {
@@ -2601,12 +2602,18 @@ fn draw_diff(
} else {
"After"
};
let title = format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label());
let title = if pull_requests {
" Pull requests ".into()
} else {
format!(" Diff · Choose {stage} · {} ", diff.comparison_mode.label())
};
let content = diff_panel(frame, panel, &title, "", "", true);
let notice = diff.error.as_deref().or_else(|| {
if diff.loading {
Some(if diff.after.is_some() {
"Loading comparison…"
} else if pull_requests {
"Loading pull requests…"
} else {
"Loading repository…"
})
@@ -2629,7 +2636,7 @@ fn draw_diff(
Constraint::Length(u16::from(content.height >= 4)),
Constraint::Length(1),
Constraint::Length(u16::from(notice.is_some() && content.height >= 3)),
Constraint::Length(u16::from(content.height >= 2)),
Constraint::Length(u16::from(!pull_requests && content.height >= 2)),
Constraint::Fill(1),
Constraint::Length(u16::from(content.height >= 7)),
])
@@ -2638,17 +2645,18 @@ fn draw_diff(
.before
.as_ref()
.map_or("not selected", |revision| revision.label.as_str());
frame.render_widget(
Paragraph::new(format!(
let stage_text = if pull_requests {
"Select a pull request to compare its branch changes".into()
} else {
format!(
"{} · Ctrl+b toggle · Before: {}",
diff.comparison_mode.label(),
display_text(before)
)),
stage_area,
);
if let Some(catalog) = &diff.catalog {
let tabs: Vec<Span> = catalog
.kind
)
};
frame.render_widget(Paragraph::new(stage_text), stage_area);
if pull_requests || diff.catalog.is_some() {
let tabs: Vec<Span> = diff
.categories()
.iter()
.enumerate()
@@ -2696,7 +2704,7 @@ fn draw_diff(
);
}
let mut entries = diff.entries();
if let Some(working) = entries.next() {
if !pull_requests && let Some(working) = entries.next() {
let label = format!(
"{} {} · pinned",
if diff.selected == 0 { ">" } else { " " },
@@ -2721,6 +2729,7 @@ fn draw_diff(
1
};
let count = usize::from(results.height) / row_height;
let pinned = usize::from(!pull_requests);
let mut key = std::collections::hash_map::DefaultHasher::new();
(
diff.phase as u8,
@@ -2735,17 +2744,16 @@ fn draw_diff(
let first = PickerViewport::follow(
&mut panels.picker_scroll.revisions,
key.finish(),
diff.selected.saturating_sub(1),
diff.selected.saturating_sub(pinned),
usize::from(results.height),
row_height,
panels.picker_scroll.distance,
)
.saturating_add(1);
);
let mut lines = Vec::with_capacity(count * row_height);
for (index, revision) in entries
.enumerate()
.map(|(index, revision)| (index + 1, revision))
.skip(first - 1)
.skip(first)
.map(|(index, revision)| (index + pinned, revision))
.take(count)
{
let style = if index == diff.selected {
@@ -2783,13 +2791,23 @@ fn draw_diff(
}
}
if lines.is_empty() && !diff.loading {
frame.render_widget(Paragraph::new("No matching revisions"), results);
let empty = if pull_requests {
"No matching pull requests"
} else {
"No matching revisions"
};
frame.render_widget(Paragraph::new(empty), results);
} else {
frame.render_widget(Paragraph::new(lines), results);
}
let accept = if diff.error.is_some() {
"retry"
} else {
"select"
};
frame.render_widget(
Paragraph::new(format!(
"{navigation_hint} · Tab categories · type search · Enter select · Esc close · Ctrl+p actions"
"{navigation_hint} · Tab categories · type search · Enter {accept} · Esc close · Ctrl+p actions"
))
.style(Style::default().fg(Color::DarkGray)),
help,
@@ -6719,6 +6737,63 @@ mod tests {
);
}
#[test]
fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
use crate::diff::{Catalog, Category, RepoKind, Revision, RevisionTarget};
for (width, height) in [(100, 16), (44, 12)] {
let mut diff = DiffState::pull_requests("origin".into());
diff.take_request();
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let mut panels = WheelPanels::default();
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("Loading pull requests"));
diff.apply_result(1, Err("offline".into()));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("offline"));
diff.handle(Input::Enter);
diff.take_request();
diff.apply_result(
2,
Ok(crate::diff::Outcome::Catalog(Catalog {
kind: RepoKind::Git,
entries: (0..20)
.map(|index| Revision {
target: RevisionTarget::PullRequest {
remote: "origin".into(),
base_ref: "refs/heads/main".into(),
base_sha: "base".into(),
head_ref: format!("refs/pull/{index}/head"),
head_sha: format!("head-{index}"),
},
label: format!("#{index:02} Pull request"),
description: "open · main ← topic".into(),
category: Category::Open,
})
.collect(),
notice: None,
})),
);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("Pull requests"));
assert!(text.contains("Open") && text.contains("Closed") && text.contains("All"));
assert!(text.contains("> #00 Pull request"));
assert!(!text.contains("pinned"));
diff.handle(Input::End);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("> #19 Pull request"));
assert!(!text.contains("#00 Pull request"));
diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("> #00 Pull request"));
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("No matching pull requests"));
}
}
#[test]
fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {
let before: Vec<_> = (0..80)