feat: stream newest pull requests by category
This commit is contained in:
+461
-169
@@ -74,12 +74,8 @@ pub enum RevisionTarget {
|
||||
},
|
||||
PullRequest {
|
||||
remote: String,
|
||||
base_ref: String,
|
||||
base_sha: String,
|
||||
head_ref: String,
|
||||
head_sha: String,
|
||||
number: u64,
|
||||
},
|
||||
UnavailablePullRequest(String),
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
}
|
||||
@@ -96,6 +92,19 @@ pub struct Catalog {
|
||||
pub entries: Vec<Revision>,
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct PullRequestPage {
|
||||
pub kind: RepoKind,
|
||||
pub remote: String,
|
||||
pub entries: Vec<(Revision, String)>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PullRequestCategory {
|
||||
complete: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FileStatus {
|
||||
Added,
|
||||
@@ -137,6 +146,7 @@ pub struct Comparison {
|
||||
pub enum Operation {
|
||||
Discover,
|
||||
PullRequests(String),
|
||||
ResolvePullRequest(Revision),
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
Compare {
|
||||
@@ -158,6 +168,14 @@ pub enum Operation {
|
||||
#[derive(Debug)]
|
||||
pub enum Outcome {
|
||||
Catalog(Catalog),
|
||||
PullRequestPage {
|
||||
closed: bool,
|
||||
result: Result<PullRequestPage, String>,
|
||||
},
|
||||
ResolvedPullRequest {
|
||||
before: Revision,
|
||||
after: Revision,
|
||||
},
|
||||
Comparison(Comparison),
|
||||
CheckoutRefresh(Option<Comparison>),
|
||||
File {
|
||||
@@ -232,6 +250,9 @@ enum RequestKind {
|
||||
pub struct DiffState {
|
||||
pub catalog: Option<Catalog>,
|
||||
pub pull_request_remote: Option<String>,
|
||||
pull_request_categories: [PullRequestCategory; 2],
|
||||
pull_request_order: BTreeMap<u64, (String, usize)>,
|
||||
pub(crate) pull_request_retries: [u64; 2],
|
||||
pub phase: Phase,
|
||||
pub word_wrap: bool,
|
||||
pub view_mode: ViewMode,
|
||||
@@ -281,6 +302,9 @@ impl DiffState {
|
||||
Self {
|
||||
catalog: None,
|
||||
pull_request_remote: None,
|
||||
pull_request_categories: Default::default(),
|
||||
pull_request_order: BTreeMap::new(),
|
||||
pull_request_retries: [0; 2],
|
||||
phase: Phase::Before,
|
||||
word_wrap: false,
|
||||
view_mode: ViewMode::Split,
|
||||
@@ -406,7 +430,7 @@ impl DiffState {
|
||||
return;
|
||||
}
|
||||
self.request_kind = Some(match operation {
|
||||
Operation::Compare { .. } => RequestKind::Comparison,
|
||||
Operation::Compare { .. } | Operation::ResolvePullRequest(_) => RequestKind::Comparison,
|
||||
Operation::LoadFile {
|
||||
comparison_id,
|
||||
file_index,
|
||||
@@ -422,6 +446,71 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) -> bool {
|
||||
if let Ok(Outcome::PullRequestPage { closed, result }) = result {
|
||||
if self.pull_request_remote.is_none()
|
||||
|| self.request_kind != Some(RequestKind::Catalog)
|
||||
|| self.pending.is_some()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.error = None;
|
||||
let status = &mut self.pull_request_categories[usize::from(closed)];
|
||||
match result {
|
||||
Ok(page) => {
|
||||
status.complete = !page.has_more;
|
||||
status.error = None;
|
||||
let selected = self
|
||||
.entries()
|
||||
.nth(self.selected)
|
||||
.map(|entry| entry.target.clone());
|
||||
let catalog = self.catalog.get_or_insert_with(|| Catalog {
|
||||
kind: page.kind,
|
||||
entries: Vec::new(),
|
||||
notice: Some(format!("Pull requests from {}", page.remote)),
|
||||
});
|
||||
for (revision, created_at) in page.entries {
|
||||
let RevisionTarget::PullRequest { number, .. } = &revision.target else {
|
||||
continue;
|
||||
};
|
||||
if let Some((date, index)) = self.pull_request_order.get_mut(number) {
|
||||
*date = created_at;
|
||||
catalog.entries[*index] = revision;
|
||||
} else {
|
||||
self.pull_request_order
|
||||
.insert(*number, (created_at, catalog.entries.len()));
|
||||
catalog.entries.push(revision);
|
||||
}
|
||||
}
|
||||
let number = |revision: &Revision| match revision.target {
|
||||
RevisionTarget::PullRequest { number, .. } => number,
|
||||
_ => unreachable!("pull request catalog"),
|
||||
};
|
||||
catalog.entries.sort_unstable_by(|a, b| {
|
||||
self.pull_request_order[&number(b)]
|
||||
.0
|
||||
.cmp(&self.pull_request_order[&number(a)].0)
|
||||
.then_with(|| number(b).cmp(&number(a)))
|
||||
});
|
||||
for (index, revision) in catalog.entries.iter().enumerate() {
|
||||
self.pull_request_order
|
||||
.get_mut(&number(revision))
|
||||
.expect("known pull request")
|
||||
.1 = index;
|
||||
}
|
||||
self.prepare_search();
|
||||
self.refilter();
|
||||
if let Some(target) = selected {
|
||||
self.select_target(&target);
|
||||
}
|
||||
}
|
||||
Err(error) => status.error = Some(error),
|
||||
}
|
||||
self.loading = self
|
||||
.pull_request_categories
|
||||
.iter()
|
||||
.any(|status| !status.complete && status.error.is_none());
|
||||
return true;
|
||||
}
|
||||
if self.request_kind.is_none() {
|
||||
return false;
|
||||
}
|
||||
@@ -530,27 +619,20 @@ impl DiffState {
|
||||
self.loading = false;
|
||||
self.pending = None;
|
||||
match result {
|
||||
Ok(Outcome::ResolvedPullRequest { before, after })
|
||||
if expected == Some(RequestKind::Comparison) =>
|
||||
{
|
||||
self.before = Some(before);
|
||||
self.after = Some(after);
|
||||
self.queue_comparison();
|
||||
}
|
||||
Ok(Outcome::Catalog(catalog)) if expected == Some(RequestKind::Catalog) => {
|
||||
let selected = self
|
||||
.entries()
|
||||
.nth(self.selected)
|
||||
.map(|entry| entry.target.clone());
|
||||
self.search_fields = catalog
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let id = match &entry.target {
|
||||
RevisionTarget::Commit(id) => id.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
[
|
||||
entry.label.to_lowercase(),
|
||||
id.to_lowercase(),
|
||||
entry.description.to_lowercase(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
self.catalog = Some(catalog);
|
||||
self.prepare_search();
|
||||
self.category = self.category.min(self.categories().len() - 1);
|
||||
self.refilter();
|
||||
if let Some(target) = selected {
|
||||
@@ -626,6 +708,80 @@ impl DiffState {
|
||||
}
|
||||
true
|
||||
}
|
||||
fn prepare_search(&mut self) {
|
||||
self.search_fields = self
|
||||
.catalog
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|catalog| &catalog.entries)
|
||||
.map(|entry| {
|
||||
let id = match &entry.target {
|
||||
RevisionTarget::Commit(id) => id.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
[
|
||||
entry.label.to_lowercase(),
|
||||
id.to_lowercase(),
|
||||
entry.description.to_lowercase(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
pub(crate) fn pull_request_partial(&self) -> bool {
|
||||
self.pull_request_remote.is_some()
|
||||
&& self
|
||||
.pull_request_categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, status)| {
|
||||
(self.category == 2 || self.category == index) && !status.complete
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn pull_request_notice(&self) -> Option<String> {
|
||||
self.pull_request_remote.as_ref()?;
|
||||
let statuses: Vec<_> = self
|
||||
.pull_request_categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(index, _)| self.category == 2 || self.category == *index)
|
||||
.map(|(index, status)| {
|
||||
let category = [Category::Open, Category::Closed][index];
|
||||
let count = self.catalog.as_ref().map_or(0, |catalog| {
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.category == category)
|
||||
.count()
|
||||
});
|
||||
let state = status.error.as_deref().unwrap_or(if status.complete {
|
||||
"complete"
|
||||
} else {
|
||||
"loading…"
|
||||
});
|
||||
format!("{}: {count} loaded · {state}", category.label())
|
||||
})
|
||||
.collect();
|
||||
Some(format!(
|
||||
"{}{}",
|
||||
statuses.join(" · "),
|
||||
if self.pull_request_partial() {
|
||||
" · partial search"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
fn retry_pull_requests(&mut self) {
|
||||
for (index, status) in self.pull_request_categories.iter_mut().enumerate() {
|
||||
if (self.category == 2 || self.category == index) && status.error.take().is_some() {
|
||||
self.pull_request_retries[index] += 1;
|
||||
self.loading = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refilter(&mut self) {
|
||||
self.visible.clear();
|
||||
@@ -767,7 +923,14 @@ impl DiffState {
|
||||
Input::Down | Input::Ctrl('j') => self.navigate(true),
|
||||
Input::Home => self.selected = 0,
|
||||
Input::End => self.selected = self.visible.len().saturating_sub(1),
|
||||
Input::Enter if !self.loading => self.choose(),
|
||||
Input::Enter
|
||||
if !self.loading
|
||||
|| (self.pull_request_remote.is_some()
|
||||
&& self.request_kind == Some(RequestKind::Catalog)) =>
|
||||
{
|
||||
self.choose()
|
||||
}
|
||||
Input::Ctrl('r') if self.pull_request_remote.is_some() => self.retry_pull_requests(),
|
||||
input if BackspaceKind::from_input(&input).is_some() => {
|
||||
if BackspaceKind::from_input(&input)
|
||||
.expect("guard checked backspace input")
|
||||
@@ -791,8 +954,11 @@ impl DiffState {
|
||||
fn choose(&mut self) {
|
||||
let revision = self.entries().nth(self.selected).cloned();
|
||||
let Some(revision) = revision else {
|
||||
self.retry_pull_requests();
|
||||
// Listing can be retried without closing the modal.
|
||||
if self.catalog.is_none() {
|
||||
if self.catalog.is_none()
|
||||
&& (self.pull_request_remote.is_none() || self.error.is_some())
|
||||
{
|
||||
self.queue(match &self.pull_request_remote {
|
||||
Some(remote) => Operation::PullRequests(remote.clone()),
|
||||
None => Operation::Discover,
|
||||
@@ -800,44 +966,18 @@ impl DiffState {
|
||||
}
|
||||
return;
|
||||
};
|
||||
if let RevisionTarget::UnavailablePullRequest(error) = &revision.target {
|
||||
if matches!(revision.target, RevisionTarget::PullRequest { .. }) {
|
||||
self.before = None;
|
||||
self.after = None;
|
||||
self.error = Some(error.clone());
|
||||
self.comparison_mode = ComparisonMode::BranchChanges;
|
||||
self.phase = Phase::After;
|
||||
self.queue(Operation::ResolvePullRequest(revision));
|
||||
return;
|
||||
}
|
||||
if revision.target == RevisionTarget::Working {
|
||||
self.comparison_mode = ComparisonMode::Snapshots;
|
||||
}
|
||||
if let RevisionTarget::PullRequest {
|
||||
remote,
|
||||
base_ref,
|
||||
base_sha,
|
||||
head_ref,
|
||||
head_sha,
|
||||
} = &revision.target
|
||||
{
|
||||
self.before = Some(Revision {
|
||||
target: RevisionTarget::RemoteCommit {
|
||||
remote: remote.clone(),
|
||||
reference: base_ref.clone(),
|
||||
id: base_sha.clone(),
|
||||
},
|
||||
label: base_ref.clone(),
|
||||
description: String::new(),
|
||||
category: revision.category,
|
||||
});
|
||||
self.after = Some(Revision {
|
||||
target: RevisionTarget::RemoteCommit {
|
||||
remote: remote.clone(),
|
||||
reference: head_ref.clone(),
|
||||
id: head_sha.clone(),
|
||||
},
|
||||
..revision
|
||||
});
|
||||
self.comparison_mode = ComparisonMode::BranchChanges;
|
||||
self.queue_comparison();
|
||||
} else if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
self.queue(Operation::FetchRemote(remote.clone()));
|
||||
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
||||
self.queue(Operation::ListRemote(remote.clone()));
|
||||
@@ -2421,36 +2561,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn pull_request_catalog() -> Catalog {
|
||||
Catalog {
|
||||
kind: RepoKind::Git,
|
||||
entries: [
|
||||
("#1 Fix Ünicode", Category::Open),
|
||||
("#2 Fix Ünicode", Category::Closed),
|
||||
("#3 Other work", Category::Open),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(label, category)| {
|
||||
revision(
|
||||
RevisionTarget::PullRequest {
|
||||
remote: "origin".into(),
|
||||
base_ref: "refs/heads/main".into(),
|
||||
base_sha: "base-sha".into(),
|
||||
head_ref: "refs/pull/1/head".into(),
|
||||
head_sha: "head-sha".into(),
|
||||
},
|
||||
label,
|
||||
"Alice · main ← topic",
|
||||
category,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
notice: None,
|
||||
fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcome {
|
||||
Outcome::PullRequestPage {
|
||||
closed,
|
||||
result: Ok(PullRequestPage {
|
||||
kind: RepoKind::Git,
|
||||
remote: "origin".into(),
|
||||
has_more,
|
||||
entries: rows
|
||||
.iter()
|
||||
.map(|&(number, title, date)| {
|
||||
(
|
||||
revision(
|
||||
RevisionTarget::PullRequest {
|
||||
remote: "origin".into(),
|
||||
number,
|
||||
},
|
||||
&format!("#{number} {title}"),
|
||||
"Alice · main ← topic",
|
||||
if closed {
|
||||
Category::Closed
|
||||
} else {
|
||||
Category::Open
|
||||
},
|
||||
),
|
||||
date.into(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_categories_keep_query_and_select_both_pinned_branch_tips() {
|
||||
fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() {
|
||||
let mut state = DiffState::pull_requests("origin".into());
|
||||
assert_eq!(
|
||||
state.categories(),
|
||||
@@ -2461,136 +2605,273 @@ mod tests {
|
||||
for ch in "FIX üni Alice".chars() {
|
||||
state.handle(Input::Char(ch));
|
||||
}
|
||||
state.apply_result(1, Ok(Outcome::Catalog(pull_request_catalog())));
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(pull_page(
|
||||
true,
|
||||
&[(2, "Fix Ünicode", "2026-01-02T00:00:00Z")],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
assert!(state.loading);
|
||||
assert!(state.pull_request_partial());
|
||||
assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode");
|
||||
state.handle(Input::Char('\t'));
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(pull_page(
|
||||
false,
|
||||
&[
|
||||
(1, "Fix Ünicode", "2026-01-03T00:00:00Z"),
|
||||
(3, "Other work", "2026-01-01T00:00:00Z"),
|
||||
],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.map(|entry| entry.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["#2 Fix Ünicode"]
|
||||
["#1 Fix Ünicode", "#2 Fix Ünicode"]
|
||||
);
|
||||
state.handle(Input::Char('\t'));
|
||||
assert_eq!(state.entries().len(), 2);
|
||||
state.handle(Input::Char('\t'));
|
||||
assert_eq!(state.query, "FIX üni Alice");
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.map(|entry| entry.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["#1 Fix Ünicode"]
|
||||
);
|
||||
state.handle(Input::Ctrl('u'));
|
||||
assert_eq!(state.entries().len(), 2);
|
||||
state.comparison_mode = ComparisonMode::Snapshots;
|
||||
state.handle(Input::Enter);
|
||||
assert_eq!(state.phase, Phase::After);
|
||||
let Operation::Compare {
|
||||
before,
|
||||
after,
|
||||
mode,
|
||||
buffers,
|
||||
} = state.take_request().unwrap()
|
||||
else {
|
||||
panic!("expected direct paired comparison");
|
||||
};
|
||||
assert_eq!(mode, ComparisonMode::BranchChanges);
|
||||
assert!(buffers.is_empty());
|
||||
assert_eq!(before.label, "refs/heads/main");
|
||||
assert_eq!(after.label, "#1 Fix Ünicode");
|
||||
assert!(state.before.is_none() && state.after.is_none());
|
||||
assert_eq!(state.comparison_mode, ComparisonMode::BranchChanges);
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::ResolvePullRequest(Revision {
|
||||
target: RevisionTarget::PullRequest { number: 1, .. },
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() {
|
||||
let mut state = DiffState::pull_requests("origin".into());
|
||||
state.take_request();
|
||||
state.handle(Input::BackTab);
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(pull_page(
|
||||
false,
|
||||
&[
|
||||
(1, "First", "2026-01-01T00:00:00Z"),
|
||||
(2, "Second", "2026-01-02T00:00:00Z"),
|
||||
],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
state.handle(Input::End);
|
||||
assert_eq!(
|
||||
before.target,
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/heads/main".into(),
|
||||
id: "base-sha".into(),
|
||||
}
|
||||
state.entries().nth(state.selected).unwrap().label,
|
||||
"#1 First"
|
||||
);
|
||||
state.apply_result(1, Ok(pull_page(true, &[], true)));
|
||||
assert!(state.pull_request_partial());
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(pull_page(
|
||||
true,
|
||||
&[(3, "Newest", "2026-01-03T00:00:00Z")],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
assert_eq!(
|
||||
after.target,
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/pull/1/head".into(),
|
||||
id: "head-sha".into(),
|
||||
}
|
||||
state.entries().nth(state.selected).unwrap().label,
|
||||
"#1 First"
|
||||
);
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(pull_page(
|
||||
false,
|
||||
&[(2, "Second updated", "2026-01-02T00:00:00Z")],
|
||||
false,
|
||||
)),
|
||||
);
|
||||
assert_eq!(state.entries().len(), 3);
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.map(|entry| entry.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["#3 Newest", "#2 Second updated", "#1 First"]
|
||||
);
|
||||
assert!(state.pull_request_partial());
|
||||
state.apply_result(1, Ok(pull_page(true, &[], false)));
|
||||
assert!(!state.pull_request_partial());
|
||||
assert!(!state.loading);
|
||||
assert_eq!(
|
||||
state.entries().nth(state.selected).unwrap().label,
|
||||
"#1 First"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_loading_retry_and_escape_reject_stale_results() {
|
||||
fn pull_request_late_category_errors_retry_without_losing_rows_or_other_results() {
|
||||
let mut editor = editor();
|
||||
editor.execute("pull_requests origin");
|
||||
let (id, _) = editor.take_diff_request().unwrap();
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(pull_page(
|
||||
false,
|
||||
&[(1, "Open", "2026-01-01T00:00:00Z")],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(pull_page(
|
||||
true,
|
||||
&[(2, "Closed", "2026-01-02T00:00:00Z")],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
editor.handle(Input::Char('\t'));
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(Outcome::PullRequestPage {
|
||||
closed: true,
|
||||
result: Err("offline".into()),
|
||||
}),
|
||||
);
|
||||
let diff = editor.diff().unwrap();
|
||||
assert!(diff.pull_request_notice().unwrap().contains("offline"));
|
||||
assert_eq!(diff.entries().next().unwrap().label, "#2 Closed");
|
||||
assert!(diff.pull_request_partial());
|
||||
editor.handle(Input::Ctrl('r'));
|
||||
assert_eq!(editor.diff().unwrap().pull_request_retries, [0, 1]);
|
||||
assert!(editor.take_diff_request().is_none());
|
||||
editor.apply_diff_result(
|
||||
id,
|
||||
Ok(pull_page(
|
||||
false,
|
||||
&[(3, "More open", "2026-01-03T00:00:00Z")],
|
||||
false,
|
||||
)),
|
||||
);
|
||||
assert_eq!(
|
||||
editor.diff().unwrap().entries().next().unwrap().label,
|
||||
"#2 Closed"
|
||||
);
|
||||
editor.apply_diff_result(id, Ok(pull_page(true, &[], false)));
|
||||
assert!(!editor.diff().unwrap().pull_request_partial());
|
||||
editor.handle(Input::Enter);
|
||||
let (_, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::ResolvePullRequest(_)));
|
||||
assert!(!editor.apply_diff_result(id, Ok(pull_page(false, &[], false))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_loading_retry_escape_and_reopen_reject_stale_results() {
|
||||
let mut editor = editor();
|
||||
editor
|
||||
.apply(vec![Effect::Replace("unsaved source".into())])
|
||||
.unwrap();
|
||||
editor.execute("pull_requests origin");
|
||||
let (first, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
|
||||
editor.handle(Input::Ctrl('b'));
|
||||
let (first, _) = editor.take_diff_request().unwrap();
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.diff().unwrap().loading);
|
||||
assert!(editor.take_diff_request().is_none());
|
||||
text(&mut editor, "alice");
|
||||
assert!(editor.apply_diff_result(first, Err("API unavailable".into())));
|
||||
editor.apply_diff_result(
|
||||
first,
|
||||
Ok(Outcome::PullRequestPage {
|
||||
closed: false,
|
||||
result: Err("API unavailable".into()),
|
||||
}),
|
||||
);
|
||||
editor.handle(Input::Enter);
|
||||
assert_eq!(editor.diff().unwrap().pull_request_retries, [1, 0]);
|
||||
assert!(editor.take_diff_request().is_none());
|
||||
assert!(editor.apply_diff_result(
|
||||
first,
|
||||
Ok(pull_page(
|
||||
false,
|
||||
&[
|
||||
(1, "Open", "2026-01-02T00:00:00Z"),
|
||||
(2, "Other open", "2026-01-01T00:00:00Z"),
|
||||
],
|
||||
true
|
||||
))
|
||||
));
|
||||
editor.handle(Input::Enter);
|
||||
let (resolve, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::ResolvePullRequest(_)));
|
||||
assert!(!editor.apply_diff_result(first, Ok(pull_page(true, &[], false))));
|
||||
editor.apply_diff_result(resolve, Err("Historical head no longer retained".into()));
|
||||
assert!(editor.diff().unwrap().before.is_none());
|
||||
assert!(editor.diff().unwrap().after.is_none());
|
||||
editor.handle(Input::Down);
|
||||
editor.handle(Input::Enter);
|
||||
let (retry, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
|
||||
assert_eq!(editor.diff().unwrap().query, "alice");
|
||||
assert!(!editor.apply_diff_result(first, Ok(Outcome::Catalog(pull_request_catalog()))));
|
||||
assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
|
||||
editor.handle(Input::Enter);
|
||||
let (compare, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(
|
||||
operation,
|
||||
Operation::Compare {
|
||||
mode: ComparisonMode::BranchChanges,
|
||||
Operation::ResolvePullRequest(Revision {
|
||||
target: RevisionTarget::PullRequest { number: 2, .. },
|
||||
..
|
||||
}
|
||||
})
|
||||
));
|
||||
assert!(editor.apply_diff_result(compare, Err("fetch unavailable".into())));
|
||||
editor.handle(Input::Enter);
|
||||
let (compare_retry, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::Compare { .. }));
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_none());
|
||||
assert!(!editor.apply_diff_result(compare_retry, Ok(Outcome::Comparison(comparison()))));
|
||||
assert!(!editor.apply_diff_result(retry, Ok(Outcome::Comparison(comparison()))));
|
||||
assert_eq!(editor.lines(), ["unsaved source"]);
|
||||
assert!(editor.dirty());
|
||||
editor.execute("pull_requests");
|
||||
let (reopened, operation) = editor.take_diff_request().unwrap();
|
||||
assert!(matches!(operation, Operation::PullRequests(remote) if remote.is_empty()));
|
||||
assert!(!editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
|
||||
let mut empty = pull_request_catalog();
|
||||
empty.entries.clear();
|
||||
assert!(editor.apply_diff_result(reopened, Ok(Outcome::Catalog(empty))));
|
||||
let (reopened, _) = editor.take_diff_request().unwrap();
|
||||
assert!(!editor.apply_diff_result(first, Ok(pull_page(false, &[], false))));
|
||||
assert!(editor.apply_diff_result(reopened, Ok(pull_page(false, &[], false))));
|
||||
assert!(editor.apply_diff_result(reopened, Ok(pull_page(true, &[], false))));
|
||||
editor.handle(Input::Enter);
|
||||
assert!(editor.take_diff_request().is_none());
|
||||
editor.handle(Input::Escape);
|
||||
assert!(editor.diff().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_pull_request_does_not_block_other_catalog_entries() {
|
||||
fn resolved_pull_request_modes_reuse_pinned_endpoints_without_resolving_again() {
|
||||
let mut state = DiffState::pull_requests("origin".into());
|
||||
state.take_request();
|
||||
let mut catalog = pull_request_catalog();
|
||||
catalog.entries.insert(
|
||||
0,
|
||||
revision(
|
||||
RevisionTarget::UnavailablePullRequest("Historical head no longer retained".into()),
|
||||
"#194 Deleted head",
|
||||
"unavailable",
|
||||
Category::Open,
|
||||
),
|
||||
state.apply_result(
|
||||
1,
|
||||
Ok(pull_page(
|
||||
false,
|
||||
&[(1, "Open", "2026-01-01T00:00:00Z")],
|
||||
true,
|
||||
)),
|
||||
);
|
||||
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
|
||||
state.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
state.error.as_deref(),
|
||||
Some("Historical head no longer retained")
|
||||
state.take_request();
|
||||
let before = revision(
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/heads/main".into(),
|
||||
id: "base-sha".into(),
|
||||
},
|
||||
"main",
|
||||
"",
|
||||
Category::Open,
|
||||
);
|
||||
let after = revision(
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/pull/1/head".into(),
|
||||
id: "head-sha".into(),
|
||||
},
|
||||
"#1 Open",
|
||||
"",
|
||||
Category::Open,
|
||||
);
|
||||
state.apply_result(
|
||||
2,
|
||||
Ok(Outcome::ResolvedPullRequest {
|
||||
before: before.clone(),
|
||||
after: after.clone(),
|
||||
}),
|
||||
);
|
||||
assert!(state.take_request().is_none());
|
||||
state.handle(Input::Down);
|
||||
state.handle(Input::Enter);
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::Compare {
|
||||
@@ -2598,12 +2879,23 @@ mod tests {
|
||||
..
|
||||
})
|
||||
));
|
||||
state.apply_result(2, Err("fetch failed".into()));
|
||||
state.handle(Input::Home);
|
||||
state.handle(Input::Enter);
|
||||
assert!(state.before.is_none() && state.after.is_none());
|
||||
assert!(state.toggle_comparison_mode().is_err());
|
||||
assert!(state.take_request().is_none());
|
||||
state.apply_result(3, Err("No common ancestor".into()));
|
||||
assert_eq!(state.before.as_ref(), Some(&before));
|
||||
assert_eq!(state.after.as_ref(), Some(&after));
|
||||
state.toggle_comparison_mode().unwrap();
|
||||
let Some(Operation::Compare {
|
||||
before: pinned_before,
|
||||
after: pinned_after,
|
||||
mode,
|
||||
..
|
||||
}) = state.take_request()
|
||||
else {
|
||||
panic!("expected ordinary pinned comparison");
|
||||
};
|
||||
assert_eq!(
|
||||
(pinned_before, pinned_after, mode),
|
||||
(before, after, ComparisonMode::Snapshots)
|
||||
);
|
||||
}
|
||||
|
||||
fn editor() -> Editor {
|
||||
|
||||
Reference in New Issue
Block a user