feat: configure personal pull request review indicators

This commit is contained in:
Valdemar Lorenzen
2026-10-21 11:14:50 +02:00
parent bd71d463a4
commit 0202644efa
18 changed files with 901 additions and 88 deletions
+46
View File
@@ -433,6 +433,7 @@ fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcom
},
),
date.into(),
PullRequestReviewState::None,
)
})
.collect(),
@@ -440,6 +441,51 @@ fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcom
}
}
#[test]
fn pull_request_review_refresh_preserves_identity_sorting_and_raw_search_labels() {
let mut state = DiffState::pull_requests("origin".into());
state.take_request();
state.apply_result(
1,
Ok(pull_page(
false,
&[(1, "First", "1"), (2, "Second", "2")],
true,
)),
);
state.handle(Input::End);
for review_state in [
PullRequestReviewState::Approved,
PullRequestReviewState::Requested,
PullRequestReviewState::None,
] {
let mut page = pull_page(false, &[(1, "First updated", "3")], true);
if let Outcome::PullRequestPage {
result: Ok(page), ..
} = &mut page
{
page.entries[0].2 = review_state;
}
state.apply_result(1, Ok(page));
let selected = state.entries().nth(state.selected).unwrap();
assert_eq!(selected.label, "#1 First updated");
assert_eq!(state.pull_request_review_state(selected), review_state);
assert_eq!(state.entries().count(), 2);
assert_eq!(state.entries().next().unwrap().target, selected.target);
}
state.handle(Input::Char('🙏'));
assert_eq!(state.entries().count(), 0);
state.handle(Input::Backspace);
for ch in "#1 First updated".chars() {
state.handle(Input::Char(ch));
}
assert_eq!(state.entries().count(), 1);
assert_eq!(
state.pull_request_review_state(&catalog().entries[0]),
PullRequestReviewState::None
);
}
#[test]
fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() {
let mut state = DiffState::pull_requests("origin".into());
+309 -1
View File
@@ -174,7 +174,9 @@ fn unavailable_viewer_or_metadata_never_fabricates_personal_approval() {
let forge = parse(url, None).unwrap();
for unavailable_viewer in [true, false] {
let metadata = forge.metadata_with(
&[json!({"number":7,"iid":7,"additions":0,"deletions":0})],
&[json!({"number":7,"iid":7,"additions":0,"deletions":0,
"requested_reviewers":[{"login":"viewer","id":42}],
"reviewers":[{"username":"viewer"}]})],
&mut |url, body| {
if url.ends_with("/user") {
return if unavailable_viewer {
@@ -194,6 +196,8 @@ fn unavailable_viewer_or_metadata_never_fabricates_personal_approval() {
},
);
assert!(metadata.values().all(|details| !details.approved));
assert!(metadata.values().all(|details| details.review_requested
== (provider == Provider::Github && !unavailable_viewer)));
if provider == Provider::Gitea {
assert_eq!(metadata[&7].size, Some((0, 0)));
}
@@ -201,6 +205,310 @@ fn unavailable_viewer_or_metadata_never_fabricates_personal_approval() {
}
}
#[test]
fn github_outstanding_requests_use_list_users_independently_of_review_metadata() {
let forge = parse("https://github.com/owner/repo", None).unwrap();
for (reviewers, expected) in [
(json!([{"login":"VIEWER"}]), true), // Direct or re-requested after approval.
(json!([]), false), // Completed or removed requests are absent.
(json!([{"login":"another-user"}]), false),
(Value::Null, false),
] {
for denied in [false, true] {
let metadata = forge.metadata_with(
&[json!({"number":7,"requested_reviewers":reviewers,
"requested_teams":[{"slug":"viewer"}],"additions":3,"deletions":4})],
&mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"login":"viewer"}));
}
assert!(body.is_some());
if denied {
return Err("GraphQL denied".into());
}
Ok(
json!({"data":{"repository":{"p7":{"additions":3,"deletions":4,
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}),
)
},
);
assert_eq!(metadata[&7].review_requested, expected);
assert_eq!(metadata[&7].approved, !denied);
assert_eq!(metadata[&7].size, Some((3, 4)));
}
}
}
#[test]
fn gitlab_outstanding_requests_require_personal_unfinished_review_and_follow_pages() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
let mut values: Vec<_> = (1..=10)
.map(|id| json!({"iid":id,"reviewers":[{"username":"viewer"}]}))
.collect();
values[8]["reviewers"] = json!([]); // Removed reviewer.
values[9]["reviewers"] = json!([{"username":"another-user"}]);
let mut request_reads = 0;
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
assert!(!query.contains("mutation"));
if !query.contains("reviewState") {
return Ok(json!({"data":{"project":{
"p1":{"diffStatsSummary":{"additions":5,"deletions":6}},
"approved":{"nodes":[{"iid":"1"},{"iid":"4"}],"pageInfo":{"hasNextPage":false}}
}}}));
}
request_reads += 1;
assert!(query.contains("reviewers(first:20"));
assert!(!query.contains("p9:") && !query.contains("p10:"));
if query.contains("after:\"next\"") {
return Ok(json!({"data":{"project":{"p2":{"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"REVIEW_STARTED"}}],
"pageInfo":{"hasNextPage":false}
}}}}}));
}
let mut entries = serde_json::Map::new();
for (id, state) in [(1, "UNREVIEWED"), (3, "REVIEWED"), (4, "APPROVED"),
(5, "REQUESTED_CHANGES"), (6, "UNAPPROVED"), (7, ""), (8, "UNKNOWN")] {
entries.insert(format!("p{id}"), json!({"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":state}}],
"pageInfo":{"hasNextPage":false}
}}));
}
entries.insert("p2".into(), json!({"reviewers":{
"nodes":[{"username":"another-user","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
"pageInfo":{"hasNextPage":true,"endCursor":"next"}
}}));
Ok(json!({"data":{"project":entries}}))
});
assert_eq!(request_reads, 2);
assert_eq!(
metadata
.iter()
.filter_map(|(id, details)| details.review_requested.then_some(*id))
.collect::<Vec<_>>(),
[1, 2]
);
assert!(metadata[&1].approved && metadata[&4].approved);
assert_eq!(metadata[&1].size, Some((5, 6)));
}
#[test]
fn gitlab_request_failures_preserve_approval_and_statistics() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
for denied in [
Err("review states denied".into()),
Ok(json!({"data":{"project":{
"p7":{"reviewers":null}
}}})),
Ok(json!({"data":{"project":{"p7":{"reviewers":{"nodes":[
{"username":"viewer","mergeRequestInteraction":{"reviewState":null}}
]}}}}})),
] {
let metadata = forge.metadata_with(
&[json!({"iid":7,"reviewers":[{"username":"viewer"}]})],
&mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
if body["query"].as_str().unwrap().contains("reviewState") {
return denied.clone();
}
Ok(json!({"data":{"project":{
"p7":{"diffStatsSummary":{"additions":0,"deletions":1}},
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
}}}))
},
);
assert!(!metadata[&7].review_requested);
assert!(metadata[&7].approved);
assert_eq!(metadata[&7].size, Some((0, 1)));
}
}
#[test]
fn gitlab_request_batches_cover_fifty_rows_without_exceeding_complexity_limit() {
let forge = parse("https://gitlab.com/group/repo", None).unwrap();
let values: Vec<_> = (1..=50)
.map(|id| {
json!({
"iid":id,"reviewers":[{"username":"viewer"}]
})
})
.collect();
let mut batches = 0;
let metadata = forge.metadata_with(&values, &mut |url, body| {
if url.ends_with("/user") {
return Ok(json!({"username":"viewer"}));
}
let body: Value = serde_json::from_str(body.unwrap()).unwrap();
let query = body["query"].as_str().unwrap();
if !query.contains("reviewState") {
return Err("independent metadata unavailable".into());
}
batches += 1;
assert_eq!(query.matches("reviewers(first:20)").count(), 10);
let entries: serde_json::Map<_, _> = (1..=50)
.filter(|id| query.contains(&format!("p{id}:mergeRequest")))
.map(|id| (format!("p{id}"), json!({"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
"pageInfo":{"hasNextPage":false}
}}))).collect();
Ok(json!({"data":{"project":entries}}))
});
assert_eq!(batches, 5);
assert_eq!(metadata.len(), 50);
assert!(metadata.values().all(|details| details.review_requested));
}
#[test]
fn gitea_requests_ignore_list_assignments_and_use_current_direct_review_rows() {
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
let own = |id, state: &str| {
json!({
"id":id,"user":{"id":42},"team":null,"state":state,"dismissed":false
})
};
for (reviews, requested, approved) in [
(json!([own(10, "REQUEST_REVIEW")]), true, false),
(json!([own(10, "APPROVED")]), false, true), // Completed or removed request row.
(json!([own(10, "COMMENT")]), false, false),
(json!([own(10, "REQUEST_CHANGES")]), false, false),
(
json!([own(10, "APPROVED"), own(20, "REQUEST_REVIEW")]),
true,
true,
),
(
json!([
own(20, "REQUEST_REVIEW"),
own(10, "APPROVED"),
own(30, "PENDING")
]),
true,
true,
),
(
json!([own(10, "REQUEST_REVIEW"), own(20, "COMMENT")]),
false,
false,
),
(
json!([own(10, "REQUEST_REVIEW"), own(20, "APPROVED")]),
false,
true,
),
(
json!([{"id":10,"user":{"id":7},"state":"REQUEST_REVIEW","dismissed":false}]),
false,
false,
),
(
json!([{"id":10,"user":{"id":42},"team":{"id":1},"state":"REQUEST_REVIEW","dismissed":false}]),
false,
false,
),
(
json!([{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW"},
own(10, "APPROVED")]),
false,
true,
),
(json!([]), false, false),
] {
let mut pages = vec![reviews, json!([])].into_iter();
let metadata = forge.metadata_with(
&[json!({"number":7,"additions":1,"deletions":2,"requested_reviewers":[{"id":42}]})],
&mut |url, body| {
assert!(body.is_none());
if url.ends_with("/user") {
return Ok(json!({"id":42}));
}
pages.next().ok_or_else(|| "unexpected page".into())
},
);
assert_eq!(metadata[&7].review_requested, requested);
assert_eq!(metadata[&7].approved, approved);
assert_eq!(metadata[&7].size, Some((1, 2)));
}
}
#[test]
fn gitea_incomplete_request_pages_do_not_fabricate_requests_or_lose_list_statistics() {
let forge = parse("https://codeberg.org/owner/repo", None).unwrap();
let metadata = forge.metadata_with(
&[json!({"number":7,"additions":0,"deletions":2})],
&mut |url, _| {
if url.ends_with("/user") {
return Ok(json!({"id":42}));
}
if url.ends_with("page=1") {
return Ok(json!([{"id":10,"user":{"id":42},"team":null,
"state":"REQUEST_REVIEW","dismissed":false}]));
}
Err("remaining reviews denied".into())
},
);
assert!(!metadata[&7].review_requested);
assert_eq!(metadata[&7].size, Some((0, 2)));
}
#[test]
fn summaries_keep_outstanding_requests_and_approvals_independent() {
for (url, provider) in [
("https://github.com/owner/repo", Provider::Github),
("https://gitlab.com/owner/repo", Provider::Gitlab),
("https://codeberg.org/owner/repo", Provider::Gitea),
] {
let forge = parse(url, None).unwrap();
let page = forge.page_with(false, 1, |url, body| {
if url.ends_with("/user") {
return Ok(json!({"id":42,"login":"viewer","username":"viewer"}));
}
if let Some(body) = body {
let body: Value = serde_json::from_str(body).unwrap();
if body["query"].as_str().unwrap().contains("reviewState") {
return Ok(json!({"data":{"project":{"p7":{"reviewers":{
"nodes":[{"username":"viewer","mergeRequestInteraction":{"reviewState":"UNREVIEWED"}}],
"pageInfo":{"hasNextPage":false}
}}}}}));
}
return if provider == Provider::Github {
Ok(json!({"data":{"repository":{"p7":{"additions":1,"deletions":2,
"reviews":{"nodes":[{"state":"APPROVED","author":{"login":"viewer"}}]}}}}}))
} else {
Ok(json!({"data":{"project":{
"p7":{"diffStatsSummary":{"additions":1,"deletions":2}},
"approved":{"nodes":[{"iid":"7"}],"pageInfo":{"hasNextPage":false}}
}}}))
};
}
if url.contains("/7/reviews?") {
return Ok(if url.ends_with("page=1") {
json!([
{"id":10,"user":{"id":42},"state":"APPROVED","dismissed":false},
{"id":20,"user":{"id":42},"state":"REQUEST_REVIEW","dismissed":false}
])
} else {
json!([])
});
}
Ok(json!([{"number":7,"iid":7,"title":"Request again","created_at":"2024-01-01T00:00:00Z",
"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"}],
"additions":1,"deletions":2}]))
}).unwrap();
assert_eq!(page.entries.len(), 1);
assert!(page.entries[0].approved && page.entries[0].review_requested);
assert!(page.entries[0].description.contains("+1 -2"));
}
}
#[test]
fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
for provider in [Provider::Github, Provider::Gitlab, Provider::Gitea] {
+83 -4
View File
@@ -106,7 +106,7 @@ fn review_directory_resolves_config_paths_and_keeps_session_startup_lazy() {
}
#[test]
fn review_directory_wire_field_is_required_nullable_and_validated() {
fn review_directory_and_review_symbol_wire_fields_are_required_and_validated() {
let mut frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
"review_directory": null, "leader": "space",
@@ -119,6 +119,7 @@ fn review_directory_wire_field_is_required_nullable_and_validated() {
"diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
for value in [serde_json::Value::Null, serde_json::json!("reviews")] {
@@ -136,10 +137,83 @@ fn review_directory_wire_field_is_required_nullable_and_validated() {
frame["review_directory"] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame["review_directory"] = serde_json::Value::Null;
for field in [
"pull_request_review_requested_symbol",
"pull_request_approved_symbol",
] {
for value in ["", "👩‍🚀 R", "ok"] {
frame[field] = serde_json::json!(value);
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
}
for value in [
serde_json::json!(null),
serde_json::json!(false),
serde_json::json!(7),
serde_json::json!([]),
serde_json::json!({}),
serde_json::json!("\0"),
serde_json::json!("\n"),
serde_json::json!("\t"),
serde_json::json!("\u{7f}"),
serde_json::json!("\u{85}"),
] {
frame[field] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
}
frame.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<Response>(frame.clone()).is_err());
frame[field] = serde_json::json!("");
}
frame.as_object_mut().unwrap().remove("review_directory");
assert!(serde_json::from_value::<Response>(frame).is_err());
}
#[test]
fn pull_request_symbols_load_defaults_custom_and_empty_and_reject_invalid_ruby_values() {
let fixture = Fixture::new();
let path = fixture.file("config.rb", "Vamp.configure { |_| }");
let config = load_config(&path).unwrap();
assert_eq!(config.pull_request_review_requested_symbol, "🙏");
assert_eq!(config.pull_request_approved_symbol, "👍");
for symbol in ["", "👩‍🚀 R", "ok"] {
let value = serde_json::to_string(symbol).unwrap();
let path = fixture.file("config.rb", &format!(
"Vamp.configure {{ |c| c.pull_request_review_requested_symbol = {value}; c.pull_request_approved_symbol = {value} }}"
));
let config = load_config(&path).unwrap();
assert_eq!(config.pull_request_review_requested_symbol, symbol);
assert_eq!(config.pull_request_approved_symbol, symbol);
}
for field in [
"pull_request_review_requested_symbol",
"pull_request_approved_symbol",
] {
for value in [
"nil",
"false",
"7",
"[]",
"{}",
":ok",
r#""\n""#,
r#""\t""#,
r#""\0""#,
r#""\u007f""#,
r#""\u0085""#,
] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |c| c.{field} = {value} }}"),
);
assert!(
load_config(&path).unwrap_err().contains(field),
"{field}: {value}"
);
}
}
}
#[test]
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
let fixture = Fixture::new();
@@ -544,9 +618,9 @@ fn config_leader_defaults_custom_keys_validation_and_required_wire_field() {
assert!(load_config(&path).unwrap_err().contains(error), "{value}");
}
for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"review_directory":null,"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","git_blame":true,"git_gutter":"bar","scroll_distance":0,"highlight_trailing_whitespace":false,"whitespace_characters":{},"whitespace_scope":"all","tab_spaces":null,"language_tab_spaces":{},"top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15,"diff_view_mode":"split","diff_split_orientation":"vertical","diff_follow_working_copy":true,"pull_request_review_requested_symbol":"🙏","pull_request_approved_symbol":"👍","resolve_history_layout":"columns"}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
@@ -632,6 +706,7 @@ end
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
@@ -773,6 +848,7 @@ end"#,
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
for value in ["all", "leading"] {
@@ -1036,6 +1112,7 @@ fn config_diff_excerpt_counts_accept_custom_values_and_reject_invalid_values() {
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
for setting in ["diff_context_lines", "diff_expand_lines"] {
@@ -1148,6 +1225,7 @@ fn config_scroll_distance_accepts_nil_and_counts_and_rejects_invalid_values() {
"diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true, "scroll_distance": 0
});
for distance in [None, Some(0), Some(7), Some(usize::MAX)] {
@@ -1582,6 +1660,7 @@ fn lsp_wire_configuration_is_resolved_after_decoding() {
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true
});
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap()
+17
View File
@@ -1481,6 +1481,23 @@ end
assert_eq!(session.editor().lines(), &["a!bc"]);
}
#[test]
fn configured_pull_request_symbols_reach_editor() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "abc");
let session = fixture.open(
r#"Vamp.configure do |config|
config.pull_request_review_requested_symbol = "👩‍🚀 review"
config.pull_request_approved_symbol = ""
end"#,
);
assert_eq!(
session.editor().pull_request_review_requested_symbol,
"👩‍🚀 review"
);
assert_eq!(session.editor().pull_request_approved_symbol, "");
}
#[test]
fn diff_display_preferences_survive_reopening() {
use crate::{diff::ViewMode, panes::SplitAxis};
+119 -4
View File
@@ -3515,6 +3515,7 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
},
},
format!("2026-01-{:02}T00:00:00Z", 20 - index),
crate::diff::PullRequestReviewState::None,
)
})
.collect(),
@@ -3526,17 +3527,17 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
let text = screen_text(&terminal);
assert!(text.contains("Pull requests"));
assert!(text.contains("Open") && text.contains("Closed") && text.contains("All"));
assert!(text.contains("> #00 Pull request"));
assert!(text.contains("> #00 Pull request"));
assert!(!text.contains("pinned"));
assert!(text.contains("[partial]"));
diff.handle(Input::End);
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("> #19 Pull request"));
assert!(text.contains("> #19 Pull request"));
assert!(!text.contains("#00 Pull request"));
diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels);
assert!(screen_text(&terminal).contains("> #00 Pull request"));
assert!(screen_text(&terminal).contains("> #00 Pull request"));
diff.handle(Input::Char('\t'));
diff.apply_result(
2,
@@ -3555,10 +3556,124 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal);
assert!(text.contains("> #01 Pull request"));
assert!(text.contains("> #01 Pull request"));
assert!(!text.contains("#00 Pull request") && !text.contains("#19 Pull request"));
}
}
#[test]
fn pull_request_review_symbols_use_terminal_cells_and_keep_the_selection_arrow_independent() {
use crate::diff::{
Category, Outcome, PullRequestPage, PullRequestReviewState, RepoKind, Revision,
RevisionTarget,
};
let mut diff = DiffState::pull_requests("origin".into());
diff.take_request();
diff.apply_result(
1,
Ok(Outcome::PullRequestPage {
closed: false,
result: Ok(PullRequestPage {
kind: RepoKind::Git,
remote: "origin".into(),
has_more: false,
entries: [
PullRequestReviewState::Requested,
PullRequestReviewState::Approved,
PullRequestReviewState::None,
]
.into_iter()
.enumerate()
.map(|(index, state)| {
(
Revision {
target: RevisionTarget::PullRequest {
remote: "origin".into(),
number: index as u64,
},
label: format!("#{index} title"),
description: format!("metadata{index}"),
category: Category::Open,
},
(3 - index).to_string(),
state,
)
})
.collect(),
}),
}),
);
for (requested, approved, with_editor) in [
("🙏", "👍", false),
("🙏", "👍", true),
("👩‍🚀 R", "界", true),
("ask", "ok", true),
("", "👍", true),
("🙏", "", true),
("", "", true),
] {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.pull_request_review_requested_symbol = requested.into();
editor.pull_request_approved_symbol = approved.into();
let slot = requested.width().max(approved.width());
let slot = slot + usize::from(slot > 0);
for width in [100, 44] {
let mut terminal =
Terminal::new(ratatui::backend::TestBackend::new(width, 24)).unwrap();
let mut panels = WheelPanels::default();
terminal
.draw(|frame| {
panels.begin_frame();
draw_diff(
frame,
&diff,
frame.area(),
"↑/↓",
"h/j/k/l",
&mut panels,
with_editor.then_some(&editor),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
let mut number_column = None;
for (index, symbol) in [requested, approved, ""].into_iter().enumerate() {
let (x, y) = (0..24)
.find_map(|y| {
(0..width).find_map(|x| {
(buffer[(x, y)].symbol() == "#"
&& buffer[(x + 1, y)].symbol() == index.to_string())
.then_some((x, y))
})
})
.expect("all review states render");
assert_eq!(*number_column.get_or_insert(x), x);
let arrow_x = x - (2 + slot) as u16;
assert_eq!(
buffer[(arrow_x, y)].symbol(),
if index == 0 { ">" } else { " " }
);
if !symbol.is_empty() {
assert_eq!(
buffer[(arrow_x + 2, y)].symbol(),
symbol.graphemes(true).next().unwrap()
);
} else {
for blank in arrow_x + 2..x {
assert_eq!(buffer[(blank, y)].symbol(), " ");
}
}
if width == 100 {
assert_eq!(
buffer[(x, y + 1)].symbol(),
"m",
"metadata aligns with #number"
);
}
}
assert!(!screen_text(&terminal).contains('✓'));
}
}
}
#[test]
fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {