feat: add persistent pull request sort and filter menus
This commit is contained in:
+303
-46
@@ -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]
|
||||
|
||||
+20
-10
@@ -498,6 +498,7 @@ fn summaries_keep_outstanding_requests_and_approvals_independent() {
|
||||
});
|
||||
}
|
||||
Ok(json!([{"number":7,"iid":7,"title":"Request again","created_at":"2024-01-01T00:00:00Z",
|
||||
"updated_at":"2024-01-02T01:00:00+01:00",
|
||||
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||
"merged":false,"merged_at":null,"user":{"login":"author"},"author":{"username":"author"},
|
||||
"requested_reviewers":[{"login":"viewer","id":42}],"reviewers":[{"username":"viewer"}],
|
||||
@@ -506,6 +507,7 @@ fn summaries_keep_outstanding_requests_and_approvals_independent() {
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
assert!(page.entries[0].approved && page.entries[0].review_requested);
|
||||
assert!(page.entries[0].description.contains("+1 -2"));
|
||||
assert_eq!(page.entries[0].updated_at, "2024-01-02T00:00:00.000000000Z");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,7 +526,8 @@ fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
|
||||
else if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||
"merged": merged,
|
||||
"merged_at": if merged { Some("2024-01-02T00:00:00Z") } else { None },
|
||||
"user": null, "author": null, "created_at": "2024-01-01T00:00:00Z"
|
||||
"user": null, "author": null, "created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-02T00:00:00Z"
|
||||
});
|
||||
let entry = summary(provider, &value, Some((1, 2))).unwrap();
|
||||
assert!(entry.draft);
|
||||
@@ -602,6 +605,7 @@ fn pull_request_sizes_include_zero_and_preserve_unknown_statistics() {
|
||||
"state":if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||
"user":{"login":"author"}, "author":{"username":"author"},
|
||||
"created_at":"2024-01-01T00:00:00Z",
|
||||
"updated_at":"2024-01-02T00:00:00Z",
|
||||
"base":{"ref":"main"}, "head":{"ref":"topic"},
|
||||
"target_branch":"main", "source_branch":"topic",
|
||||
"additions":match id { 7 => Some(123), 8 => Some(0), _ => None },
|
||||
@@ -654,10 +658,10 @@ fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
||||
assert_eq!(url, format!("{}?{query}", forge.endpoint));
|
||||
Ok(json!([
|
||||
{"number": 1, "iid": 1, "title": "Older", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00"},
|
||||
"user": null, "author": null, "merged_at": null, "merged": false, "created_at": "2024-01-01T01:00:00+01:00", "updated_at":"2024-02-01T01:00:00+01:00"},
|
||||
{"number": 2, "iid": 2, "title": "Newer", "state": if provider == Provider::Gitlab { "opened" } else { "open" },
|
||||
"user": {"login": "author"}, "author": {"username": "author"}, "merged_at": null, "merged": false,
|
||||
"created_at": "2024-01-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
||||
"created_at": "2024-01-01T00:00:00.001Z", "updated_at":"2024-02-01T00:00:00.001Z", "diff_refs": null, "head": {"sha": "not-a-commit"}}
|
||||
]))
|
||||
}).unwrap();
|
||||
assert_eq!(
|
||||
@@ -665,6 +669,8 @@ fn pages_are_bounded_newest_first_summaries_without_historical_metadata() {
|
||||
[2, 1]
|
||||
);
|
||||
assert!(page.entries[1].description.contains("deleted user"));
|
||||
assert_eq!(page.entries[1].updated_at, "2024-02-01T00:00:00.000000000Z");
|
||||
assert_eq!(page.entries[0].updated_at, "2024-02-01T00:00:00.001000000Z");
|
||||
assert!(
|
||||
page.has_more,
|
||||
"A short, server-capped page is not exhaustion"
|
||||
@@ -708,7 +714,7 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
||||
_ => return Ok(json!([])),
|
||||
};
|
||||
Ok(json!([{"iid": id, "title": state, "state": state,
|
||||
"author": null, "created_at": date, "diff_refs": null}]))
|
||||
"author": null, "created_at": date, "updated_at": date, "diff_refs": null}]))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(states, ["closed", "merged"]);
|
||||
@@ -733,7 +739,7 @@ fn gitlab_closed_combines_closed_and_merged_pages_without_open_requests() {
|
||||
return Err("statistics unavailable".into());
|
||||
}
|
||||
Ok(json!([{"iid": 7, "title": "Raced merge", "state": if url.contains("?state=merged&") { "merged" } else { "closed" },
|
||||
"author": null, "created_at": "2024-01-01T00:00:00Z"}]))
|
||||
"author": null, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-02T00:00:00Z"}]))
|
||||
}).unwrap();
|
||||
assert_eq!(raced.entries.len(), 1);
|
||||
assert!(raced.entries[0].description.contains("merged"));
|
||||
@@ -755,7 +761,9 @@ fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
|
||||
"2024-01-01T00:00:00.000000001Z",
|
||||
),
|
||||
] {
|
||||
assert_eq!(created_at(&json!({"created_at": input})).unwrap(), expected);
|
||||
for key in ["created_at", "updated_at"] {
|
||||
assert_eq!(timestamp(&json!({(key): input}), key).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
for input in [
|
||||
"",
|
||||
@@ -770,10 +778,12 @@ fn timestamps_normalize_offsets_precision_and_calendar_boundaries() {
|
||||
"0001-01-01T00:00:00+01:00",
|
||||
"9999-12-31T23:00:00-02:00",
|
||||
] {
|
||||
assert!(
|
||||
created_at(&json!({"created_at": input})).is_err(),
|
||||
"{input}"
|
||||
);
|
||||
for key in ["created_at", "updated_at"] {
|
||||
assert!(
|
||||
timestamp(&json!({(key): input}), key).is_err(),
|
||||
"{key}: {input}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
use super::*;
|
||||
use crate::pull_requests::{Direction, ReviewFilter, SortField, SortRule};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"vamp-pr-preferences-{}-{}",
|
||||
std::process::id(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> PathBuf {
|
||||
self.0.join("state/vamp/pull_requests.json")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_use_absolute_xdg_state_or_home_without_reading_environment() {
|
||||
assert_eq!(
|
||||
path_from(Some(OsStr::new("/state")), Some(OsStr::new("/home"))).unwrap(),
|
||||
PathBuf::from("/state/vamp/pull_requests.json")
|
||||
);
|
||||
for xdg in [None, Some(OsStr::new("")), Some(OsStr::new("relative"))] {
|
||||
assert_eq!(
|
||||
path_from(xdg, Some(OsStr::new("/home"))).unwrap(),
|
||||
PathBuf::from("/home/.local/state/vamp/pull_requests.json")
|
||||
);
|
||||
}
|
||||
assert!(path_from(None, None).is_err());
|
||||
assert!(path_from(None, Some(OsStr::new("relative"))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_is_lazy_and_applied_preferences_round_trip_atomically() {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.path();
|
||||
let (store, mut preferences, notice) = Store::load(Ok(path.clone()));
|
||||
assert_eq!(preferences, Preferences::default());
|
||||
assert!(notice.is_none());
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
preferences.filters = vec![ReviewFilter::Approved];
|
||||
preferences.sort.insert(
|
||||
SortField::UpdatedAt,
|
||||
SortRule {
|
||||
precedence: Some(1),
|
||||
direction: Direction::Asc,
|
||||
},
|
||||
);
|
||||
store.save(&preferences).unwrap();
|
||||
let (_, loaded, notice) = Store::load(Ok(path.clone()));
|
||||
assert_eq!(loaded, preferences);
|
||||
assert!(notice.is_none());
|
||||
preferences.filters.clear();
|
||||
preferences.sort.clear();
|
||||
store.save(&preferences).unwrap();
|
||||
assert_eq!(Store::load(Ok(path)).1, preferences);
|
||||
assert_eq!(
|
||||
fs::read_dir(fixture.0.join("state/vamp")).unwrap().count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_incompatible_and_invalid_data_is_preserved_even_after_apply() {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.path();
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
for bytes in [
|
||||
"not json",
|
||||
r#"{"version":2,"preferences":{}}"#,
|
||||
r#"{"version":1,"preferences":{"sort":{"unknown":{}}}}"#,
|
||||
r#"{"version":1,"preferences":{"sort":{"updated_at":{"direction":"up"}}}}"#,
|
||||
r#"{"version":1,"preferences":{"sort":{"updated_at":{"precedence":0}}}}"#,
|
||||
r#"{"version":1,"preferences":{"sort":{"updated_at":{"precedence":1},"created_at":{"precedence":1}}}}"#,
|
||||
r#"{"version":1,"preferences":{"filters":["unknown"]}}"#,
|
||||
r#"{"version":1,"preferences":{"filters":["requested","requested"]}}"#,
|
||||
] {
|
||||
fs::write(&path, bytes).unwrap();
|
||||
let (store, loaded, notice) = Store::load(Ok(path.clone()));
|
||||
assert_eq!(loaded, Preferences::default());
|
||||
assert!(notice.unwrap().contains("session-only"));
|
||||
assert!(store.save(&loaded).unwrap_err().contains("preserved"));
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_writes_and_newly_incompatible_data_are_reported_without_replacement() {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.path();
|
||||
let (store, preferences, _) = Store::load(Ok(path.clone()));
|
||||
fs::write(fixture.0.join("state"), "keep this file").unwrap();
|
||||
assert!(
|
||||
store
|
||||
.save(&preferences)
|
||||
.unwrap_err()
|
||||
.contains("session-only")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("state")).unwrap(),
|
||||
"keep this file"
|
||||
);
|
||||
fs::remove_file(fixture.0.join("state")).unwrap();
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let incompatible = r#"{"version":9,"preferences":{}}"#;
|
||||
fs::write(&path, incompatible).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.save(&preferences)
|
||||
.unwrap_err()
|
||||
.contains("unsupported")
|
||||
);
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), incompatible);
|
||||
}
|
||||
@@ -2356,3 +2356,80 @@ fn ruby_rejects_invalid_document_context_and_sync_async_dsl_usage() {
|
||||
plugin.host.child.is_none() && plugin.host.reader.is_none() && plugin.host.writer.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_preferences_distinguish_unset_nil_and_explicit_sections() {
|
||||
use crate::pull_requests::{Direction, ReviewFilter, SortField};
|
||||
let fixture = Fixture::new();
|
||||
for assignment in [
|
||||
"",
|
||||
"config.pull_request_sort = nil; config.pull_request_filters = nil",
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| {assignment} }}"),
|
||||
);
|
||||
let config = load_config(&path).unwrap();
|
||||
assert!(config.pull_request_sort.is_none());
|
||||
assert!(config.pull_request_filters.is_none());
|
||||
}
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
r#"
|
||||
Vamp.configure do |config|
|
||||
config.pull_request_sort = {
|
||||
updated_at: {precedence: 1, direction: :desc},
|
||||
created_at: {direction: :asc}
|
||||
}
|
||||
config.pull_request_filters = [:requested, :approved]
|
||||
end
|
||||
"#,
|
||||
);
|
||||
let config = load_config(&path).unwrap();
|
||||
let sort = config.pull_request_sort.unwrap();
|
||||
assert_eq!(sort[&SortField::UpdatedAt].precedence, Some(1));
|
||||
assert_eq!(sort[&SortField::UpdatedAt].direction, Direction::Desc);
|
||||
assert_eq!(sort[&SortField::CreatedAt].precedence, None);
|
||||
assert_eq!(sort[&SortField::CreatedAt].direction, Direction::Asc);
|
||||
assert_eq!(
|
||||
config.pull_request_filters.unwrap(),
|
||||
[ReviewFilter::Requested, ReviewFilter::Approved]
|
||||
);
|
||||
let path = fixture.file("config.rb", "Vamp.configure { |config| config.pull_request_sort = {}; config.pull_request_filters = [] }");
|
||||
let config = load_config(&path).unwrap();
|
||||
assert!(config.pull_request_sort.unwrap().is_empty());
|
||||
assert!(config.pull_request_filters.unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_preferences_reject_unknown_values_and_invalid_ranks() {
|
||||
let fixture = Fixture::new();
|
||||
for (field, value) in [
|
||||
("sort", "[]"),
|
||||
("sort", "{unknown: {}}"),
|
||||
("sort", "{updated_at: []}"),
|
||||
("sort", "{updated_at: {unknown: 1}}"),
|
||||
("sort", "{updated_at: {direction: :up}}"),
|
||||
("sort", "{updated_at: {direction: nil}}"),
|
||||
("sort", "{updated_at: {precedence: 0}}"),
|
||||
("sort", "{updated_at: {precedence: 5}}"),
|
||||
("sort", "{updated_at: {precedence: 1.0}}"),
|
||||
(
|
||||
"sort",
|
||||
"{updated_at: {precedence: 1}, created_at: {precedence: 1}}",
|
||||
),
|
||||
("filters", "{}"),
|
||||
("filters", "[:unknown]"),
|
||||
("filters", "[:requested, :requested]"),
|
||||
] {
|
||||
let path = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| config.pull_request_{field} = {value} }}"),
|
||||
);
|
||||
let error = load_config(&path).unwrap_err();
|
||||
assert!(
|
||||
error.contains(&format!("pull_request_{field}")),
|
||||
"{value}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3836,3 +3836,161 @@ fn async_responses_edit_only_current_bound_working_after_and_refresh_projection(
|
||||
session.editor.execute("diff.close");
|
||||
assert!(session.editor.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_pull_request_preferences_survive_startup_without_config() {
|
||||
use crate::pull_requests::ReviewFilter;
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.0.join("state/pull_requests.json");
|
||||
let mut session =
|
||||
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path.clone())))
|
||||
.unwrap();
|
||||
assert!(!path.exists());
|
||||
session.editor.execute("pull_requests");
|
||||
// Consume the queued listing so menu input cannot start a forge worker.
|
||||
assert!(session.editor.take_diff_request().is_some());
|
||||
session.handle(Input::Ctrl('f'));
|
||||
session.handle(Input::Char(' '));
|
||||
assert!(!path.exists());
|
||||
session.handle(Input::Escape);
|
||||
assert_eq!(
|
||||
session.editor.pull_request_preferences,
|
||||
crate::pull_requests::Preferences::default()
|
||||
);
|
||||
assert!(!path.exists());
|
||||
session.handle(Input::Ctrl('f'));
|
||||
session.handle(Input::Char(' '));
|
||||
session.handle(Input::Down);
|
||||
session.handle(Input::Char(' '));
|
||||
assert!(!path.exists());
|
||||
session.handle(Input::Enter);
|
||||
assert!(path.exists());
|
||||
assert!(!session.editor.pull_request_preferences_changed);
|
||||
let reopened =
|
||||
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path))).unwrap();
|
||||
assert_eq!(
|
||||
reopened.editor.pull_request_preferences.filters,
|
||||
[ReviewFilter::Other]
|
||||
);
|
||||
assert_eq!(reopened.editor.pull_request_configured, [false, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_pull_request_sections_replace_only_their_persisted_section() {
|
||||
use crate::pull_requests::{Direction, Preferences, ReviewFilter, SortField, SortRule};
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.0.join("state/pull_requests.json");
|
||||
let (store, mut preferences, _) =
|
||||
crate::pull_request_preferences::Store::load(Ok(path.clone()));
|
||||
preferences.filters = vec![ReviewFilter::Other];
|
||||
preferences.sort.insert(
|
||||
SortField::UpdatedAt,
|
||||
SortRule {
|
||||
precedence: Some(1),
|
||||
direction: Direction::Asc,
|
||||
},
|
||||
);
|
||||
store.save(&preferences).unwrap();
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
for (configuration, expected, configured) in [
|
||||
(
|
||||
"config.pull_request_sort = {}",
|
||||
Preferences {
|
||||
sort: Default::default(),
|
||||
filters: preferences.filters.clone(),
|
||||
},
|
||||
[true, false],
|
||||
),
|
||||
(
|
||||
"config.pull_request_filters = []",
|
||||
Preferences {
|
||||
sort: preferences.sort.clone(),
|
||||
filters: vec![],
|
||||
},
|
||||
[false, true],
|
||||
),
|
||||
(
|
||||
"config.pull_request_sort = {created_at: {direction: :asc}}; config.pull_request_filters = nil",
|
||||
Preferences {
|
||||
sort: [(
|
||||
SortField::CreatedAt,
|
||||
SortRule {
|
||||
precedence: None,
|
||||
direction: Direction::Asc,
|
||||
},
|
||||
)]
|
||||
.into(),
|
||||
filters: preferences.filters.clone(),
|
||||
},
|
||||
[true, false],
|
||||
),
|
||||
] {
|
||||
let config = fixture.file(
|
||||
"config.rb",
|
||||
&format!("Vamp.configure {{ |config| {configuration} }}"),
|
||||
);
|
||||
let session = EditorSession::open_with_preferences_path(
|
||||
None,
|
||||
vec![],
|
||||
Some(&config),
|
||||
Some(Ok(path.clone())),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(session.editor.pull_request_preferences, expected);
|
||||
assert_eq!(session.editor.pull_request_configured, configured);
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_pull_request_storage_warns_and_apply_remains_session_only() {
|
||||
let fixture = Fixture::new();
|
||||
let path = fixture.file("pull_requests.json", "preserve invalid bytes");
|
||||
let mut session =
|
||||
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path.clone())))
|
||||
.unwrap();
|
||||
assert!(session.editor.message().contains("session-only"));
|
||||
session.editor.execute("pull_requests");
|
||||
assert!(session.editor.take_diff_request().is_some());
|
||||
session.handle(Input::Ctrl('f'));
|
||||
for input in [
|
||||
Input::Char(' '),
|
||||
Input::Down,
|
||||
Input::Char(' '),
|
||||
Input::Down,
|
||||
Input::Char(' '),
|
||||
Input::Enter,
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
assert!(session.editor.message().contains("preserved"));
|
||||
assert!(session.editor.pull_request_preferences.filters.is_empty());
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "preserve invalid bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_pull_request_save_keeps_applied_session_preferences_and_reports_failure() {
|
||||
let fixture = Fixture::new();
|
||||
let parent = fixture.0.join("state");
|
||||
let path = parent.join("pull_requests.json");
|
||||
let mut session =
|
||||
EditorSession::open_with_preferences_path(None, vec![], None, Some(Ok(path))).unwrap();
|
||||
fs::write(&parent, "do not replace").unwrap();
|
||||
session.editor.execute("pull_requests");
|
||||
assert!(session.editor.take_diff_request().is_some());
|
||||
session.handle(Input::Ctrl('f'));
|
||||
for input in [
|
||||
Input::Char(' '),
|
||||
Input::Down,
|
||||
Input::Char(' '),
|
||||
Input::Down,
|
||||
Input::Char(' '),
|
||||
Input::Enter,
|
||||
] {
|
||||
session.handle(input);
|
||||
}
|
||||
assert!(session.editor.message().contains("session-only"));
|
||||
assert!(session.editor.pull_request_preferences.filters.is_empty());
|
||||
assert!(!session.editor.pull_request_preferences_changed);
|
||||
assert_eq!(fs::read_to_string(parent).unwrap(), "do not replace");
|
||||
}
|
||||
|
||||
+75
-4
@@ -3514,8 +3514,11 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
|
||||
Category::Open
|
||||
},
|
||||
},
|
||||
format!("2026-01-{:02}T00:00:00Z", 20 - index),
|
||||
crate::diff::PullRequestReviewState::None,
|
||||
crate::diff::PullRequestMetadata {
|
||||
created_at: format!("2026-01-{:02}T00:00:00Z", 20 - index),
|
||||
updated_at: format!("2026-01-{:02}T00:00:00Z", 20 - index),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
@@ -3560,6 +3563,70 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
|
||||
assert!(!text.contains("#00 Pull request") && !text.contains("#19 Pull request"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_request_menus_replace_picker_contents_and_show_explicit_choices() {
|
||||
for (width, height) in [(100, 24), (44, 12)] {
|
||||
let mut diff = DiffState::pull_requests("origin".into());
|
||||
let mut terminal =
|
||||
Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
|
||||
let mut panels = WheelPanels::default();
|
||||
diff.handle(Input::Ctrl('s'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Sort pull requests"));
|
||||
assert!(text.contains("[–] Requested from me"));
|
||||
assert!(text.contains("Updated at") && text.contains("Created at"));
|
||||
assert!(text.contains("newest first"));
|
||||
assert!(!text.contains("Loading pull requests") && !text.contains("[partial]"));
|
||||
assert!(text.contains("Enter Apply") && text.contains("Esc Cancel"));
|
||||
diff.handle(Input::Char('3'));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
let row = |label: &str| text.lines().position(|line| line.contains(label)).unwrap();
|
||||
assert!(row("Updated at") < row("Approved by me"));
|
||||
assert!(row("Approved by me") < row("Requested from me"));
|
||||
assert!(row("Requested from me") < row("Created at"));
|
||||
assert!(text.contains("> [3] Requested from me"));
|
||||
diff.handle(Input::Backspace);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("> [–] Requested from me"));
|
||||
diff.handle(Input::Down);
|
||||
diff.handle(Input::Char('4'));
|
||||
diff.handle(Input::Left);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> [4] Updated at · oldest first"));
|
||||
let row = |label: &str| text.lines().position(|line| line.contains(label)).unwrap();
|
||||
assert!(row("Requested from me") < row("Approved by me"));
|
||||
assert!(row("Approved by me") < row("Created at"));
|
||||
assert!(row("Created at") < row("Updated at"));
|
||||
diff.handle(Input::Backspace);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("> [–] Updated at · oldest first"));
|
||||
let row = |label: &str| text.lines().position(|line| line.contains(label)).unwrap();
|
||||
assert!(row("Requested from me") < row("Updated at"));
|
||||
assert!(row("Updated at") < row("Approved by me"));
|
||||
assert!(row("Approved by me") < row("Created at"));
|
||||
diff.handle(Input::Right);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("> [–] Updated at · newest first"));
|
||||
diff.handle(Input::Ctrl('f'));
|
||||
diff.handle(Input::Char(' '));
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
let text = screen_text(&terminal);
|
||||
assert!(text.contains("Filter pull requests"));
|
||||
assert!(text.contains("[ ] Requested from me"));
|
||||
assert!(text.contains("[x] Approved by me"));
|
||||
assert!(text.contains("[x] Other / unavailable"));
|
||||
assert!(!text.contains("Updated at"));
|
||||
diff.handle(Input::Escape);
|
||||
render_diff(&mut terminal, &diff, &mut panels);
|
||||
assert!(screen_text(&terminal).contains("Pull requests"));
|
||||
assert!(screen_text(&terminal).contains("[partial]"));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn pull_request_review_symbols_use_terminal_cells_and_keep_the_selection_arrow_independent() {
|
||||
use crate::diff::{
|
||||
@@ -3594,8 +3661,12 @@ fn pull_request_review_symbols_use_terminal_cells_and_keep_the_selection_arrow_i
|
||||
description: format!("metadata{index}"),
|
||||
category: Category::Open,
|
||||
},
|
||||
(3 - index).to_string(),
|
||||
state,
|
||||
crate::diff::PullRequestMetadata {
|
||||
created_at: (3 - index).to_string(),
|
||||
updated_at: (3 - index).to_string(),
|
||||
requested: state == PullRequestReviewState::Requested,
|
||||
approved: state != PullRequestReviewState::None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
|
||||
Reference in New Issue
Block a user