feat: add persistent pull request sort and filter menus

This commit is contained in:
Valdemar Lorenzen
2026-10-21 13:30:38 +02:00
parent 37b47ebc8a
commit fbcb519e13
22 changed files with 1557 additions and 181 deletions
+303 -46
View File
@@ -432,8 +432,11 @@ fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcom
Category::Open
},
),
date.into(),
PullRequestReviewState::None,
PullRequestMetadata {
created_at: date.into(),
updated_at: date.into(),
..Default::default()
},
)
})
.collect(),
@@ -464,7 +467,8 @@ fn pull_request_review_refresh_preserves_identity_sorting_and_raw_search_labels(
result: Ok(page), ..
} = &mut page
{
page.entries[0].2 = review_state;
page.entries[0].1.requested = review_state == PullRequestReviewState::Requested;
page.entries[0].1.approved = review_state == PullRequestReviewState::Approved;
}
state.apply_result(1, Ok(page));
let selected = state.entries().nth(state.selected).unwrap();
@@ -487,8 +491,8 @@ fn pull_request_review_refresh_preserves_identity_sorting_and_raw_search_labels(
}
#[test]
fn pull_request_review_controls_compose_with_search_categories_and_streamed_updates() {
use PullRequestReviewState::{Approved, None, Requested};
fn pull_request_menus_apply_cancel_and_compose_with_query_categories_and_updates() {
use crate::pull_requests::{Direction, Preferences, ReviewFilter, SortField};
let mut editor = editor();
editor.execute("pull_requests origin");
let (id, _) = editor.take_diff_request().unwrap();
@@ -509,10 +513,9 @@ fn pull_request_review_controls_compose_with_search_categories_and_streamed_upda
result: Ok(page), ..
} = &mut page
{
for (entry, state) in page.entries.iter_mut().zip([
Requested, Approved, None, Requested, Approved, None, Requested,
]) {
entry.2 = state;
for (index, entry) in page.entries.iter_mut().enumerate() {
entry.1.requested = matches!(index, 0 | 3 | 6);
entry.1.approved = matches!(index, 0 | 1 | 4);
}
page.entries[6].0.category = Category::Drafts;
}
@@ -524,47 +527,110 @@ fn pull_request_review_controls_compose_with_search_categories_and_streamed_upda
.entries()
.map(|entry| {
let RevisionTarget::PullRequest { number, .. } = entry.target else {
panic!("expected pull request");
panic!("expected PR")
};
number
})
.collect::<Vec<_>>()
};
let selected = |editor: &Editor| {
let diff = editor.diff().unwrap();
diff.entries().nth(diff.selected).unwrap().target.clone()
};
assert_eq!(numbers(&editor), [6, 5, 4, 3, 2, 1]);
editor.handle(Input::End);
let target = selected(&editor);
editor.handle(Input::Ctrl('s'));
assert_eq!(numbers(&editor), [4, 1, 6, 5, 3, 2]);
assert_eq!(selected(&editor), target);
editor.handle(Input::Ctrl('s'));
assert_eq!(numbers(&editor), [5, 2, 6, 4, 3, 1]);
assert_eq!(selected(&editor), target);
editor.handle(Input::Ctrl('s'));
assert_eq!(numbers(&editor), [6, 5, 4, 3, 2, 1]);
for ch in "Fix".chars() {
editor.handle(Input::Char(ch));
}
assert_eq!(numbers(&editor), [5, 4, 3, 2, 1, 6]);
assert_eq!(
numbers(&editor),
[4, 1, 6, 5, 3, 2],
"query matches without outranking configured sort"
);
editor.handle(Input::Ctrl('u'));
editor.handle(Input::End);
let target = editor
.diff()
.unwrap()
.entries()
.last()
.unwrap()
.target
.clone();
editor.handle(Input::Ctrl('s'));
assert_eq!(numbers(&editor), [4, 1, 5, 3, 2, 6]);
editor.handle(Input::Down);
editor.handle(Input::Char('1'));
editor.handle(Input::Right);
assert_eq!(editor.diff().unwrap().query, "");
assert_eq!(editor.pull_request_preferences, Preferences::default());
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
assert!(editor.diff().unwrap().pull_request_menu.is_none());
assert!(!editor.pull_request_preferences_changed);
editor.handle(Input::Ctrl('s'));
editor.handle(Input::Ctrl('j'));
editor.handle(Input::Char('1'));
editor.handle(Input::Right);
editor.handle(Input::Enter);
assert_eq!(numbers(&editor), [1, 2, 3, 4, 5, 6]);
let diff = editor.diff().unwrap();
assert_eq!(diff.entries().nth(diff.selected).unwrap().target, target);
assert!(editor.pull_request_preferences_changed);
assert_eq!(
editor.pull_request_preferences.sort[&SortField::UpdatedAt].direction,
Direction::Asc
);
for ch in "Fix".chars() {
editor.handle(Input::Char(ch));
}
assert_eq!(
numbers(&editor),
[1, 2, 3, 4, 5, 6],
"settings sort wins over fuzzy scores"
);
editor.pull_request_preferences_changed = false;
editor.handle(Input::Ctrl('s'));
editor.handle(Input::Char('4'));
editor.handle(Input::Ctrl('f'));
assert_eq!(numbers(&editor), [4, 1]);
editor.handle(Input::Ctrl('f'));
assert_eq!(numbers(&editor), [5, 2]);
editor.handle(Input::Ctrl('f'));
assert_eq!(numbers(&editor), [4, 1, 5, 3, 2, 6]);
editor.handle(Input::Ctrl('f'));
editor.handle(Input::Home);
let mut update = pull_page(false, &[(4, "Fix", "4")], true);
assert!(
!editor
.diff()
.unwrap()
.pull_request_menu
.as_ref()
.unwrap()
.sort
);
assert_eq!(
editor
.diff()
.unwrap()
.pull_request_menu
.as_ref()
.unwrap()
.draft,
editor.pull_request_preferences
);
editor.handle(Input::Down);
editor.handle(Input::Char(' '));
editor.handle(Input::Down);
editor.handle(Input::Char(' '));
assert_eq!(
numbers(&editor),
[1, 2, 3, 4, 5, 6],
"draft filtering is not applied"
);
editor.handle(Input::Enter);
assert_eq!(
editor.pull_request_preferences.filters,
[ReviewFilter::Requested]
);
assert_eq!(
numbers(&editor),
[1, 4],
"outstanding request wins even with prior approval"
);
let mut update = pull_page(false, &[(4, "Fix updated", "0")], true);
if let Outcome::PullRequestPage {
result: Ok(page), ..
} = &mut update
{
page.entries[0].2 = Approved;
page.entries[0].1.approved = true;
}
editor.apply_diff_result(id, Ok(update));
assert_eq!(numbers(&editor), [1]);
@@ -576,19 +642,210 @@ fn pull_request_review_controls_compose_with_search_categories_and_streamed_upda
assert_eq!(numbers(&editor), [1, 7]);
assert_eq!(editor.diff().unwrap().query, "Fix");
assert!(editor.diff().unwrap().pull_request_partial());
editor.handle(Input::Ctrl('u'));
assert_eq!(numbers(&editor), [7, 1]);
editor.handle(Input::Ctrl('f'));
editor.handle(Input::Char(' '));
editor.handle(Input::Enter);
assert!(matches!(
editor.take_diff_request(),
Some((
_,
Operation::ResolvePullRequest(Revision {
target: RevisionTarget::PullRequest { number: 7, .. },
..
assert!(numbers(&editor).is_empty(), "no checkboxes means no rows");
editor.execute("diff.close");
editor.execute("pull_requests elsewhere");
assert_eq!(
editor.diff().unwrap().pull_request_preferences,
editor.pull_request_preferences
);
}
#[test]
fn pull_request_sort_ranks_pin_slots_shift_collisions_and_clear_without_changing_direction() {
use crate::pull_requests::{Direction, Preferences, SortField::*};
let mut state = DiffState::pull_requests("origin".into());
state.handle(Input::Ctrl('s'));
for input in [
Input::Char('2'),
Input::Home,
Input::Char('2'),
Input::Right,
] {
state.handle(input);
}
let draft = &state.pull_request_menu.as_ref().unwrap().draft;
assert_eq!(draft.sort[&Requested].precedence, Some(3));
assert_eq!(draft.sort[&UpdatedAt].precedence, Some(2));
assert_eq!(
draft.ordered_fields(),
[Approved, UpdatedAt, Requested, CreatedAt]
);
assert_eq!(state.pull_request_menu.as_ref().unwrap().selected, 1);
state.handle(Input::Backspace);
assert_eq!(state.pull_request_menu.as_ref().unwrap().selected, 0);
state.handle(Input::Enter);
assert_eq!(
state.pull_request_preferences.sort[&UpdatedAt].precedence,
None
);
assert_eq!(
state.pull_request_preferences.sort[&UpdatedAt].direction,
Direction::Asc
);
assert_eq!(
state.pull_request_preferences.ordered_fields(),
[UpdatedAt, Approved, Requested, CreatedAt]
);
let mut preferences = Preferences::default();
preferences.set_rank(Requested, Some(4));
preferences.set_rank(UpdatedAt, Some(4));
assert_eq!(preferences.sort[&Requested].precedence, Some(1));
for (field, rank) in [(Approved, 1), (CreatedAt, 2), (UpdatedAt, 1)] {
preferences.set_rank(field, Some(rank));
preferences.validate().unwrap();
}
assert_eq!(
preferences.ordered_fields(),
[UpdatedAt, Approved, CreatedAt, Requested]
);
}
#[test]
fn pull_request_sort_uses_independent_approval_dates_and_number_ties() {
use crate::pull_requests::SortField;
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
let mut page = pull_page(
false,
&[(1, "A", "1"), (2, "B", "1"), (3, "C", "1"), (4, "D", "2")],
false,
);
if let Outcome::PullRequestPage {
result: Ok(page), ..
} = &mut page
{
for entry in &mut page.entries {
entry.1.updated_at = "same".into();
}
page.entries[0].1.requested = true;
page.entries[0].1.approved = true;
page.entries[1].1.requested = true;
}
state.apply_result(1, Ok(page));
let numbers = |state: &DiffState| {
state
.entries()
.map(|entry| {
let RevisionTarget::PullRequest { number, .. } = entry.target else {
unreachable!()
};
number
})
))
));
.collect::<Vec<_>>()
};
assert_eq!(numbers(&state), [1, 2, 4, 3]);
assert_eq!(
state.pull_request_review_state(state.entries().next().unwrap()),
PullRequestReviewState::Requested
);
let mut preferences = state.pull_request_preferences.clone();
preferences.set_rank(SortField::CreatedAt, Some(1));
state.set_pull_request_preferences(preferences);
assert_eq!(numbers(&state), [4, 1, 2, 3]);
state.handle(Input::Ctrl('s'));
state.handle(Input::Home);
state.handle(Input::Left);
state.handle(Input::Enter);
assert_eq!(
numbers(&state),
[1, 2, 3, 4],
"creation direction is independent of update direction"
);
let mut tied = pull_page(false, &[(5, "E", "1")], false);
if let Outcome::PullRequestPage {
result: Ok(page), ..
} = &mut tied
{
page.entries[0].1.updated_at = "same".into();
}
state.apply_result(1, Ok(tied));
assert_eq!(
numbers(&state),
[1, 2, 5, 3, 4],
"number descending breaks complete ties"
);
let mut preferences = state.pull_request_preferences.clone();
preferences
.sort
.entry(SortField::Requested)
.or_default()
.direction = crate::pull_requests::Direction::Asc;
state.set_pull_request_preferences(preferences);
assert_eq!(
numbers(&state),
[5, 3, 1, 2, 4],
"explicit config directions also apply to booleans"
);
}
#[test]
fn pull_request_menu_drafts_survive_streaming_and_checkbox_unions_exclude_requests_from_approved() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
state.handle(Input::Ctrl('f'));
state.handle(Input::Char(' '));
let mut page = pull_page(
false,
&[(1, "Both", "1"), (2, "Approved", "2"), (3, "Other", "3")],
true,
);
if let Outcome::PullRequestPage {
result: Ok(page), ..
} = &mut page
{
page.entries[0].1.requested = true;
page.entries[0].1.approved = true;
page.entries[1].1.approved = true;
}
state.apply_result(1, Ok(page));
assert_eq!(state.entries().count(), 3);
assert_eq!(
state.pull_request_menu.as_ref().unwrap().draft.filters,
[ReviewFilter::Approved, ReviewFilter::Other]
);
state.handle(Input::Enter);
assert_eq!(
state
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["#3 Other", "#2 Approved"]
);
}
#[test]
fn pull_request_preferences_validate_ranks_filters_and_unknown_fields() {
for value in [
r#"{"sort":{"requested":{"precedence":0}}}"#,
r#"{"sort":{"requested":{"precedence":5}}}"#,
r#"{"sort":{"requested":{"precedence":1},"approved":{"precedence":1}}}"#,
r#"{"filters":["requested","requested"]}"#,
] {
let preferences: crate::pull_requests::Preferences = serde_json::from_str(value).unwrap();
assert!(preferences.validate().is_err(), "{value}");
}
for value in [
r#"{"extra":true}"#,
r#"{"sort":{"requested":{"extra":true}}}"#,
r#"{"filters":["unknown"]}"#,
] {
assert!(
serde_json::from_str::<crate::pull_requests::Preferences>(value).is_err(),
"{value}"
);
}
let preferences: crate::pull_requests::Preferences =
serde_json::from_str(r#"{"sort":{"updated_at":{}}}"#).unwrap();
preferences.validate().unwrap();
assert_eq!(preferences.filters, ReviewFilter::ALL);
assert_eq!(
preferences.sort[&SortField::UpdatedAt],
crate::pull_requests::SortRule::default()
);
}
#[test]