feat: browse GitHub GitLab and Gitea pull requests
This commit is contained in:
+292
-18
@@ -19,6 +19,9 @@ pub enum Category {
|
||||
Commits,
|
||||
Branches,
|
||||
Tags,
|
||||
Open,
|
||||
Closed,
|
||||
All,
|
||||
}
|
||||
impl Category {
|
||||
pub fn label(self) -> &'static str {
|
||||
@@ -28,6 +31,9 @@ impl Category {
|
||||
Self::Commits => "Commits",
|
||||
Self::Branches => "Branches",
|
||||
Self::Tags => "Tags",
|
||||
Self::Open => "Open",
|
||||
Self::Closed => "Closed",
|
||||
Self::All => "All",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +63,23 @@ impl ComparisonMode {
|
||||
pub enum RevisionTarget {
|
||||
Working,
|
||||
Commit(String),
|
||||
Remote { remote: String, reference: String },
|
||||
Remote {
|
||||
remote: String,
|
||||
reference: String,
|
||||
},
|
||||
RemoteCommit {
|
||||
remote: String,
|
||||
reference: String,
|
||||
id: String,
|
||||
},
|
||||
PullRequest {
|
||||
remote: String,
|
||||
base_ref: String,
|
||||
base_sha: String,
|
||||
head_ref: String,
|
||||
head_sha: String,
|
||||
},
|
||||
UnavailablePullRequest(String),
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
}
|
||||
@@ -114,6 +136,7 @@ pub struct Comparison {
|
||||
#[derive(Debug)]
|
||||
pub enum Operation {
|
||||
Discover,
|
||||
PullRequests(String),
|
||||
ListRemote(String),
|
||||
FetchRemote(String),
|
||||
Compare {
|
||||
@@ -208,6 +231,7 @@ enum RequestKind {
|
||||
#[derive(Debug)]
|
||||
pub struct DiffState {
|
||||
pub catalog: Option<Catalog>,
|
||||
pub pull_request_remote: Option<String>,
|
||||
pub phase: Phase,
|
||||
pub word_wrap: bool,
|
||||
pub view_mode: ViewMode,
|
||||
@@ -256,6 +280,7 @@ impl DiffState {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
catalog: None,
|
||||
pull_request_remote: None,
|
||||
phase: Phase::Before,
|
||||
word_wrap: false,
|
||||
view_mode: ViewMode::Split,
|
||||
@@ -301,6 +326,24 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pull_requests(remote: String) -> Self {
|
||||
Self {
|
||||
pull_request_remote: Some(remote.clone()),
|
||||
pending: Some(Operation::PullRequests(remote)),
|
||||
..Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn categories(&self) -> &'static [Category] {
|
||||
if self.pull_request_remote.is_some() {
|
||||
&[Category::Open, Category::Closed, Category::All]
|
||||
} else {
|
||||
self.catalog
|
||||
.as_ref()
|
||||
.map_or(&[], |catalog| catalog.kind.categories())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn entries(&self) -> impl ExactSizeIterator<Item = &Revision> {
|
||||
self.visible
|
||||
.iter()
|
||||
@@ -507,8 +550,8 @@ impl DiffState {
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
self.category = self.category.min(catalog.kind.categories().len() - 1);
|
||||
self.catalog = Some(catalog);
|
||||
self.category = self.category.min(self.categories().len() - 1);
|
||||
self.refilter();
|
||||
if let Some(target) = selected {
|
||||
self.select_target(&target);
|
||||
@@ -588,20 +631,26 @@ impl DiffState {
|
||||
self.visible.clear();
|
||||
self.search_matches.clear();
|
||||
if let Some(catalog) = &self.catalog {
|
||||
let category = catalog.kind.categories()[self.category];
|
||||
let category = self.categories()[self.category];
|
||||
let query = self.query.to_lowercase();
|
||||
let tokens: Vec<_> = query.split_whitespace().collect();
|
||||
// Working is always pinned, even outside its category or query.
|
||||
self.visible.extend(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
// Repository revisions pin Working outside its category and query.
|
||||
if self.pull_request_remote.is_none() {
|
||||
self.visible.extend(
|
||||
catalog
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
|
||||
.map(|(index, _)| index),
|
||||
);
|
||||
}
|
||||
for (index, entry) in catalog.entries.iter().enumerate() {
|
||||
if entry.target == RevisionTarget::Working || entry.category != category {
|
||||
if entry.target == RevisionTarget::Working
|
||||
|| (entry.category != category
|
||||
&& !(category == Category::All
|
||||
&& matches!(entry.category, Category::Open | Category::Closed)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if tokens.is_empty() {
|
||||
@@ -704,8 +753,8 @@ impl DiffState {
|
||||
}
|
||||
match input {
|
||||
Input::Char('\t') | Input::BackTab => {
|
||||
if let Some(catalog) = &self.catalog {
|
||||
let count = catalog.kind.categories().len();
|
||||
let count = self.categories().len();
|
||||
if count > 0 {
|
||||
self.category = if input == Input::BackTab {
|
||||
(self.category + count - 1) % count
|
||||
} else {
|
||||
@@ -742,16 +791,53 @@ impl DiffState {
|
||||
fn choose(&mut self) {
|
||||
let revision = self.entries().nth(self.selected).cloned();
|
||||
let Some(revision) = revision else {
|
||||
// Discovery can be retried without closing the modal.
|
||||
// Listing can be retried without closing the modal.
|
||||
if self.catalog.is_none() {
|
||||
self.queue(Operation::Discover);
|
||||
self.queue(match &self.pull_request_remote {
|
||||
Some(remote) => Operation::PullRequests(remote.clone()),
|
||||
None => Operation::Discover,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
if let RevisionTarget::UnavailablePullRequest(error) = &revision.target {
|
||||
self.before = None;
|
||||
self.after = None;
|
||||
self.error = Some(error.clone());
|
||||
return;
|
||||
}
|
||||
if revision.target == RevisionTarget::Working {
|
||||
self.comparison_mode = ComparisonMode::Snapshots;
|
||||
}
|
||||
if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
||||
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 {
|
||||
self.queue(Operation::FetchRemote(remote.clone()));
|
||||
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
||||
self.queue(Operation::ListRemote(remote.clone()));
|
||||
@@ -776,6 +862,9 @@ impl DiffState {
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_comparison_mode(&mut self) -> Result<(), &'static str> {
|
||||
if self.pull_request_remote.is_some() && self.after.is_none() {
|
||||
return Err("Select a pull request before changing comparison mode");
|
||||
}
|
||||
if self.working_selected() {
|
||||
return Err("Working checkout requires Snapshots");
|
||||
}
|
||||
@@ -2332,6 +2421,191 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_categories_keep_query_and_select_both_pinned_branch_tips() {
|
||||
let mut state = DiffState::pull_requests("origin".into());
|
||||
assert_eq!(
|
||||
state.categories(),
|
||||
[Category::Open, Category::Closed, Category::All]
|
||||
);
|
||||
state.take_request();
|
||||
state.handle(Input::Char('\t'));
|
||||
for ch in "FIX üni Alice".chars() {
|
||||
state.handle(Input::Char(ch));
|
||||
}
|
||||
state.apply_result(1, Ok(Outcome::Catalog(pull_request_catalog())));
|
||||
assert_eq!(
|
||||
state
|
||||
.entries()
|
||||
.map(|entry| entry.label.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["#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_eq!(
|
||||
before.target,
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/heads/main".into(),
|
||||
id: "base-sha".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
after.target,
|
||||
RevisionTarget::RemoteCommit {
|
||||
remote: "origin".into(),
|
||||
reference: "refs/pull/1/head".into(),
|
||||
id: "head-sha".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_loading_retry_and_escape_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'));
|
||||
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.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,
|
||||
..
|
||||
}
|
||||
));
|
||||
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_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))));
|
||||
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() {
|
||||
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(Outcome::Catalog(catalog)));
|
||||
state.handle(Input::Enter);
|
||||
assert_eq!(
|
||||
state.error.as_deref(),
|
||||
Some("Historical head no longer retained")
|
||||
);
|
||||
assert!(state.take_request().is_none());
|
||||
state.handle(Input::Down);
|
||||
state.handle(Input::Enter);
|
||||
assert!(matches!(
|
||||
state.take_request(),
|
||||
Some(Operation::Compare {
|
||||
mode: ComparisonMode::BranchChanges,
|
||||
..
|
||||
})
|
||||
));
|
||||
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());
|
||||
}
|
||||
|
||||
fn editor() -> Editor {
|
||||
Editor::open(None, vec![]).unwrap()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user