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]
+20 -10
View File
@@ -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}"
);
}
}
}
+125
View File
@@ -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);
}
+77
View File
@@ -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}"
);
}
}
+158
View File
@@ -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
View File
@@ -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(),