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
+1 -1
View File
@@ -1,6 +1,6 @@
- [x] improve: pull requests - add a tab for drafts so PRs in this state don't populate the "open" tab. - [x] improve: pull requests - add a tab for drafts so PRs in this state don't populate the "open" tab.
- [x] improve: pull requests - add a checkmark left of the PR entry in the PR list if the user in question (me) has already approved it. - [x] improve: pull requests - add a checkmark left of the PR entry in the PR list if the user in question (me) has already approved it.
- [ ] improve: pull requests - add a `🙏` emoji left of the PR entry in the PR list if the user in question (me) has been requested as reviewer. - [x] improve: pull requests - add a `🙏` emoji left of the PR entry in the PR list if the user in question (me) has been requested as reviewer.
- also change the checkmark displayed for PRs approved by me to the `👍` emoji. - also change the checkmark displayed for PRs approved by me to the `👍` emoji.
- make the symbols displayed for these states configurable. - make the symbols displayed for these states configurable.
- [ ] chore: check to make sure we're mocking everything in tests, not pinging/fetching real data from real websites/APIs. - [ ] chore: check to make sure we're mocking everything in tests, not pinging/fetching real data from real websites/APIs.
+4
View File
@@ -30,6 +30,8 @@ Vamp.configure do |config|
config.diff_view_mode = "split" # or "unified" config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal" config.diff_split_orientation = "vertical" # or "horizontal"
config.diff_follow_working_copy = true config.diff_follow_working_copy = true
config.pull_request_review_requested_symbol = "🙏"
config.pull_request_approved_symbol = "👍"
config.resolve_history_layout = "columns" # or "base_below" config.resolve_history_layout = "columns" # or "base_below"
config.review_directory = nil # XDG data directory; override with an absolute or config-relative path config.review_directory = nil # XDG data directory; override with an absolute or config-relative path
config.bind "ctrl+h", config.core.left, mode: config.modes.tree config.bind "ctrl+h", config.core.left, mode: config.modes.tree
@@ -105,6 +107,8 @@ Indicators compare the current editor text (including unsaved edits) against Git
`config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior. `config.diff_follow_working_copy` defaults to `true`. A Working-checkout After follows descendant checkouts automatically, including in inactive tabs; `false` disables endpoint following without disabling ordinary live-buffer editing or reload. Only literal booleans are accepted. Pinned After endpoints never move, but their checkout-aware editability checks remain enabled regardless of this setting. See [repository diff](editing.md#repository-diff) for ancestry and refresh behavior.
`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol` control the markers before PR numbers in the pull-request picker. Defaults are `"🙏"` for an outstanding personal review request and `"👍"` for your active approval, using the API-authenticated account. Requests take precedence over approvals when both apply. Each option accepts a string without control characters; multi-character and wide Unicode symbols are supported. Set either to `""` to hide that state, or both to `""` to remove the marker gutter. PR numbers and metadata stay aligned by terminal-cell width, including rows without a marker. Non-strings and strings containing tabs, newlines, or other control characters reject the configuration.
`config.resolve_history_layout` controls the read-only conflict inputs opened by `:resolve.history`: `"columns"` (default) places left/base/right side by side; `"base_below"` places left/right above a full-width base. Both layouts share aligned rows and synchronized vertical scrolling. Tab/Shift+Tab follows layout order (left/base/right or left/right/base); directional pane actions follow the displayed geometry. Narrow terminals show the focused source only; the base-below layout does the same when there is insufficient height. This setting does not change the editable Resolution view or ordinary diffs. Invalid values reject the configuration. `config.resolve_history_layout` controls the read-only conflict inputs opened by `:resolve.history`: `"columns"` (default) places left/base/right side by side; `"base_below"` places left/right above a full-width base. Both layouts share aligned rows and synchronized vertical scrolling. Tab/Shift+Tab follows layout order (left/base/right or left/right/base); directional pane actions follow the displayed geometry. Narrow terminals show the focused source only; the base-below layout does the same when there is insufficient height. This setting does not change the editable Resolution view or ordinary diffs. Invalid values reject the configuration.
```ruby ```ruby
+6 -2
View File
@@ -408,9 +408,13 @@ The selector initially lists local revisions and known tracking refs **without c
Each pull request's metadata shows added/deleted line counts after its state, before the branch names: `author · open · +123 -45 · topic → main`. Zero counts are shown explicitly; missing, denied, or failed statistics show `size unavailable` without preventing selection. Counts come from one batched read-only statistics query per nonempty GitHub/GitLab page ([GitHub](https://docs.github.com/en/graphql/reference/pulls), [GitLab](https://docs.gitlab.com/api/graphql/reference/#diffstatssummary)); [Gitea](https://docs.gitea.com/api/operations/repo-list-pull-requests/) supplies them directly in its list response. GitHub statistics require API authentication even for public repositories. Listing does not fetch source diffs. Each pull request's metadata shows added/deleted line counts after its state, before the branch names: `author · open · +123 -45 · topic → main`. Zero counts are shown explicitly; missing, denied, or failed statistics show `size unavailable` without preventing selection. Counts come from one batched read-only statistics query per nonempty GitHub/GitLab page ([GitHub](https://docs.github.com/en/graphql/reference/pulls), [GitLab](https://docs.gitlab.com/api/graphql/reference/#diffstatssummary)); [Gitea](https://docs.gitea.com/api/operations/repo-list-pull-requests/) supplies them directly in its list response. GitHub statistics require API authentication even for public repositories. Listing does not fetch source diffs.
A **✓ immediately before `#number`** marks your active approval, separately from the `>` selection arrow. “You” means the account authenticated to that forge's API—not the repository's Git author or SSH identity. Other users' approvals do not count. A later request for changes or dismissal removes the mark; comment-only and pending reviews do not replace an existing approval. The mark is not the PR's overall approval decision or a guarantee that you reviewed its current head; the forge controls approval resets after new commits. Immediately before `#number`, **🙏 marks an outstanding personal review request** and **👍 marks your active approval**, separately from the `>` selection arrow. A review request takes precedence when both apply, including a re-review request after an earlier approval. “You” means the account authenticated to that forge's API—not the repository's Git author or SSH identity. Other users' and team-only requests do not count; completed or removed requests lose 🙏.
Approval reads reuse the same API credentials as listing. GitHub filters published [reviews by author and decision state](https://docs.github.com/en/graphql/reference/pulls#pullrequest); GitLab filters merge requests by the [authenticated approver](https://docs.gitlab.com/api/graphql/reference/). Both batch approval data with size metadata after resolving the authenticated account. Gitea needs [paginated review reads per PR](https://docs.gitea.com/api/operations/repo-list-pull-reviews/), so its pages can take longer to load. Anonymous, denied, missing, or failed approval lookups leave the mark blank without preventing browsing; a blank is not proof that you have not approved. Reopen the picker to fetch current approval state. No approval or other remote write is performed. A later request for changes or dismissal removes 👍; comment-only and pending reviews do not replace an existing approval. The mark is not the PR's overall approval decision or a guarantee that you reviewed its current head; the forge controls approval resets after new commits. Customize either symbol with [`config.pull_request_review_requested_symbol` and `config.pull_request_approved_symbol`](configuration.md); an empty string hides that symbol. Multi-cell symbols remain aligned, and decorations are not part of searchable PR labels.
Review-state reads reuse the same API credentials as listing. GitHub matches the current list's [requested reviewers](https://docs.github.com/en/rest/pulls/review-requests#list-requested-reviewers-for-a-pull-request) and filters published [reviews by author and decision state](https://docs.github.com/en/graphql/reference/pulls#pullrequest). GitLab filters approvals by the authenticated approver and reads individual reviewer [interaction states](https://docs.gitlab.com/api/graphql/reference/) in separate bounded, paginated batches; unsupported or denied reviewer-state fields do not discard known approvals or size metadata. Gitea reuses [paginated review reads per PR](https://docs.gitea.com/api/operations/repo-list-pull-reviews/) for both states, so its pages can take longer to load.
Anonymous, denied, missing, or failed state lookups leave the corresponding indicator unavailable without preventing browsing. A blank is not proof that no request or approval exists. Reopen the picker to fetch current review state. No approval, review request, or other remote write is performed.
GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately. GitHub, GitLab merge requests, and Gitea are supported. API reads require `curl`; public repositories work anonymously. Authentication uses `GH_TOKEN`/`GITHUB_TOKEN` for github.com, `GH_ENTERPRISE_TOKEN`/`GITHUB_ENTERPRISE_TOKEN` for GitHub Enterprise, `GITLAB_TOKEN` for GitLab, and `GITEA_TOKEN` for Gitea. With no matching environment token, GitHub/GitLab reuse `gh auth token --hostname HOST` / `glab auth token --hostname HOST` when available. Tokens are supplied to curl on stdin, not in subprocess arguments, and sent only to the HTTPS API; redirects are not followed. Git object fetching uses the configured remote's existing Git/SSH credentials separately.
+2
View File
@@ -18,6 +18,8 @@ Vamp.configure do |config|
config.diff_view_mode = "split" # or "unified" config.diff_view_mode = "split" # or "unified"
config.diff_split_orientation = "vertical" # or "horizontal" config.diff_split_orientation = "vertical" # or "horizontal"
config.diff_follow_working_copy = true # false keeps the selected Working After endpoint config.diff_follow_working_copy = true # false keeps the selected Working After endpoint
config.pull_request_review_requested_symbol = "🙏"
config.pull_request_approved_symbol = "👍"
config.resolve_history_layout = "columns" # or "base_below" for left/right above the base config.resolve_history_layout = "columns" # or "base_below" for left/right above the base
# config.popup.command = { anchor: :bottom_left } # config.popup.command = { anchor: :bottom_left }
# config.popup.search = { anchor: :center } # config.popup.search = { anchor: :center }
+25 -5
View File
@@ -101,11 +101,19 @@ pub struct Catalog {
pub entries: Vec<Revision>, pub entries: Vec<Revision>,
pub notice: Option<String>, pub notice: Option<String>,
} }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum PullRequestReviewState {
#[default]
None,
Approved,
Requested,
}
#[derive(Debug)] #[derive(Debug)]
pub struct PullRequestPage { pub struct PullRequestPage {
pub kind: RepoKind, pub kind: RepoKind,
pub remote: String, pub remote: String,
pub entries: Vec<(Revision, String)>, pub entries: Vec<(Revision, String, PullRequestReviewState)>,
pub has_more: bool, pub has_more: bool,
} }
@@ -572,7 +580,7 @@ pub struct DiffState {
pub catalog: Option<Catalog>, pub catalog: Option<Catalog>,
pub pull_request_remote: Option<String>, pub pull_request_remote: Option<String>,
pull_request_categories: [PullRequestCategory; 2], pull_request_categories: [PullRequestCategory; 2],
pull_request_order: BTreeMap<u64, (String, usize)>, pull_request_order: BTreeMap<u64, (String, usize, PullRequestReviewState)>,
pub(crate) pull_request_retries: [u64; 2], pub(crate) pull_request_retries: [u64; 2],
pub phase: Phase, pub phase: Phase,
pub word_wrap: bool, pub word_wrap: bool,
@@ -934,16 +942,18 @@ impl DiffState {
entries: Vec::new(), entries: Vec::new(),
notice: Some(format!("Pull requests from {}", page.remote)), notice: Some(format!("Pull requests from {}", page.remote)),
}); });
for (revision, created_at) in page.entries { for (revision, created_at, review_state) in page.entries {
let RevisionTarget::PullRequest { number, .. } = &revision.target else { let RevisionTarget::PullRequest { number, .. } = &revision.target else {
continue; continue;
}; };
if let Some((date, index)) = self.pull_request_order.get_mut(number) { if let Some((date, index, state)) = self.pull_request_order.get_mut(number)
{
*date = created_at; *date = created_at;
*state = review_state;
catalog.entries[*index] = revision; catalog.entries[*index] = revision;
} else { } else {
self.pull_request_order self.pull_request_order
.insert(*number, (created_at, catalog.entries.len())); .insert(*number, (created_at, catalog.entries.len(), review_state));
catalog.entries.push(revision); catalog.entries.push(revision);
} }
} }
@@ -1222,6 +1232,16 @@ impl DiffState {
} }
} }
pub(crate) fn pull_request_review_state(&self, revision: &Revision) -> PullRequestReviewState {
if let RevisionTarget::PullRequest { number, .. } = revision.target {
self.pull_request_order
.get(&number)
.map_or(PullRequestReviewState::None, |entry| entry.2)
} else {
PullRequestReviewState::None
}
}
pub(crate) fn pull_request_partial(&self) -> bool { pub(crate) fn pull_request_partial(&self) -> bool {
self.pull_request_remote.is_some() self.pull_request_remote.is_some()
&& self && self
+4
View File
@@ -2284,6 +2284,8 @@ pub struct Editor {
diff_return_buffer: Option<usize>, diff_return_buffer: Option<usize>,
diff_context_options: (usize, usize), diff_context_options: (usize, usize),
diff_display_options: (ViewMode, SplitAxis), diff_display_options: (ViewMode, SplitAxis),
pub(crate) pull_request_review_requested_symbol: String,
pub(crate) pull_request_approved_symbol: String,
next_diff_request: u64, next_diff_request: u64,
outstanding_diff_request: Option<u64>, outstanding_diff_request: Option<u64>,
pending_lsp_navigation: Option<PendingLspNavigation>, pending_lsp_navigation: Option<PendingLspNavigation>,
@@ -2424,6 +2426,8 @@ impl Editor {
diff_return_buffer: None, diff_return_buffer: None,
diff_context_options: (10, 15), diff_context_options: (10, 15),
diff_display_options: (ViewMode::Split, SplitAxis::Vertical), diff_display_options: (ViewMode::Split, SplitAxis::Vertical),
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
next_diff_request: 0, next_diff_request: 0,
outstanding_diff_request: None, outstanding_diff_request: None,
pending_lsp_navigation: None, pending_lsp_navigation: None,
+196 -58
View File
@@ -15,12 +15,14 @@ pub(crate) struct PullRequestSummary {
pub closed: bool, pub closed: bool,
pub draft: bool, pub draft: bool,
pub approved: bool, pub approved: bool,
pub review_requested: bool,
pub created_at: String, pub created_at: String,
} }
struct PullRequestMetadata { struct PullRequestMetadata {
size: Option<(u64, u64)>, size: Option<(u64, u64)>,
approved: bool, approved: bool,
review_requested: bool,
} }
pub(crate) struct PullRequestPage { pub(crate) struct PullRequestPage {
@@ -432,6 +434,7 @@ impl Forge {
let details = metadata.get(&id); let details = metadata.get(&id);
let mut entry = summary(self.provider, value, details.and_then(|d| d.size))?; let mut entry = summary(self.provider, value, details.and_then(|d| d.size))?;
entry.approved = details.is_some_and(|d| d.approved); entry.approved = details.is_some_and(|d| d.approved);
entry.review_requested = details.is_some_and(|d| d.review_requested);
if entry.closed != closed { if entry.closed != closed {
return Err("Forge API returned a pull request in the wrong category".into()); return Err("Forge API returned a pull request in the wrong category".into());
} }
@@ -478,28 +481,63 @@ impl Forge {
.and_then(Value::as_str) .and_then(Value::as_str)
.filter(|name| !name.is_empty()) .filter(|name| !name.is_empty())
}); });
if self.provider == Provider::Gitea { let mut metadata: BTreeMap<_, _> = values
let viewer = viewer.as_ref().and_then(|viewer| number(viewer, "id").ok()); .iter()
return values .filter_map(|value| {
.iter() let id = number(
.filter_map(|value| { value,
let id = number(value, "number").ok()?; if self.provider == Provider::Gitlab {
Some(( "iid"
id, } else {
PullRequestMetadata { "number"
size: value },
.get("additions") )
.and_then(Value::as_u64) .ok()?;
.zip(value.get("deletions").and_then(Value::as_u64)), Some((
// ponytail: Gitea has no batch reviews API; per-PR reads until it does. id,
approved: viewer.is_some_and(|viewer| { PullRequestMetadata {
self.gitea_approved_with(id, viewer, request) size: value
.unwrap_or(false) .get("additions")
.and_then(Value::as_u64)
.zip(value.get("deletions").and_then(Value::as_u64)),
approved: false,
// GitHub's list contains only outstanding direct requests, not teams.
review_requested: self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.get("requested_reviewers")
.and_then(Value::as_array)
.is_some_and(|reviewers| {
reviewers.iter().any(|reviewer| {
reviewer
.get("login")
.and_then(Value::as_str)
.is_some_and(|login| {
login.eq_ignore_ascii_case(username)
})
})
})
}), }),
}, },
)) ))
}) })
.collect(); .collect();
if self.provider == Provider::Gitea {
if let Some(viewer) = viewer.as_ref().and_then(|viewer| number(viewer, "id").ok()) {
for (&id, details) in &mut metadata {
// ponytail: Gitea has no batch reviews API; per-PR reads until it does.
// List requested_reviewers also includes completed reviews.
(details.approved, details.review_requested) = self
.gitea_reviews_with(id, viewer, request)
.unwrap_or_default();
}
}
return metadata;
}
if self.provider == Provider::Gitlab {
if let Some(username) = username {
self.gitlab_requests_with(values, username, &mut metadata, request);
}
} }
let reviews = if self.provider == Provider::Github { let reviews = if self.provider == Provider::Github {
username.map_or_else(String::new, |username| { username.map_or_else(String::new, |username| {
@@ -583,9 +621,9 @@ impl Forge {
) )
}; };
let body = serde_json::json!({ "query": query }).to_string(); let body = serde_json::json!({ "query": query }).to_string();
// Missing/denied metadata must not prevent browsing or imply approval. // Missing/denied metadata must not discard independently known list data.
let Ok(response) = request(&url, Some(&body)) else { let Ok(response) = request(&url, Some(&body)) else {
return BTreeMap::new(); return metadata;
}; };
let container = if self.provider == Provider::Github { let container = if self.provider == Provider::Github {
"/data/repository" "/data/repository"
@@ -593,42 +631,44 @@ impl Forge {
"/data/project" "/data/project"
}; };
let Some(entries) = response.pointer(container).and_then(Value::as_object) else { let Some(entries) = response.pointer(container).and_then(Value::as_object) else {
return BTreeMap::new(); return metadata;
}; };
let mut metadata: BTreeMap<_, _> = entries for (alias, value) in entries {
.iter() let Some(details) = alias
.filter_map(|(alias, value)| { .strip_prefix('p')
let stats = if self.provider == Provider::Gitlab { .and_then(|id| id.parse::<u64>().ok())
value.get("diffStatsSummary") .and_then(|id| metadata.get_mut(&id))
} else { else {
Some(value) continue;
}; };
let size = stats.and_then(|stats| { let stats = if self.provider == Provider::Gitlab {
value.get("diffStatsSummary")
} else {
Some(value)
};
details.size = stats
.and_then(|stats| {
stats stats
.get("additions")? .get("additions")?
.as_u64() .as_u64()
.zip(stats.get("deletions")?.as_u64()) .zip(stats.get("deletions")?.as_u64())
})
.or(details.size);
details.approved = self.provider == Provider::Github
&& username.is_some_and(|username| {
value
.pointer("/reviews/nodes")
.and_then(Value::as_array)
.is_some_and(|reviews| {
reviews.len() == 1
&& reviews[0]["state"] == "APPROVED"
&& reviews[0]
.pointer("/author/login")
.and_then(Value::as_str)
.is_some_and(|author| author.eq_ignore_ascii_case(username))
})
}); });
let approved = self.provider == Provider::Github }
&& username.is_some_and(|username| {
value
.pointer("/reviews/nodes")
.and_then(Value::as_array)
.is_some_and(|reviews| {
reviews.len() == 1
&& reviews[0]["state"] == "APPROVED"
&& reviews[0]
.pointer("/author/login")
.and_then(Value::as_str)
.is_some_and(|author| author.eq_ignore_ascii_case(username))
})
});
Some((
alias.strip_prefix('p')?.parse::<u64>().ok()?,
PullRequestMetadata { size, approved },
))
})
.collect();
if let Some(args) = approval_args { if let Some(args) = approval_args {
let mut response = response; let mut response = response;
let mut cursor = None::<String>; let mut cursor = None::<String>;
@@ -675,13 +715,90 @@ impl Forge {
metadata metadata
} }
fn gitea_approved_with( fn gitlab_requests_with(
&self,
values: &[Value],
username: &str,
metadata: &mut BTreeMap<u64, PullRequestMetadata>,
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) {
let mut pending: BTreeMap<_, _> = values
.iter()
.filter_map(|value| {
let reviewers = value.get("reviewers")?.as_array()?;
if !reviewers.iter().any(|reviewer| {
reviewer.get("username").and_then(Value::as_str) == Some(username)
}) {
return None;
}
Some((number(value, "iid").ok()?, None::<String>))
})
.collect();
// Keep optional reviewer-state reads separate: unsupported/denied fields
// must not invalidate the independent statistics and approval query.
while !pending.is_empty() {
use std::fmt::Write;
let mut fields = String::new();
// Ten aliases stay below GitLab's default query-complexity limit.
let batch: BTreeMap<_, _> = (0..10).filter_map(|_| pending.pop_first()).collect();
for (id, cursor) in &batch {
let after = cursor.as_ref().map_or_else(String::new, |cursor| {
format!(",after:{}", serde_json::to_string(cursor).unwrap())
});
write!(fields, "p{id}:mergeRequest(iid:\"{id}\"){{reviewers(first:20{after}){{nodes{{username mergeRequestInteraction{{reviewState}}}} pageInfo{{hasNextPage endCursor}}}}}}").unwrap();
}
let body = serde_json::json!({
"query": format!("query{{project(fullPath:{}){{{fields}}}}}",
serde_json::to_string(&self.repository).unwrap())
})
.to_string();
let Ok(response) = request(&format!("https://{}/api/graphql", self.host), Some(&body))
else {
continue;
};
for (id, cursor) in batch {
let Some(reviewers) = response.pointer(&format!("/data/project/p{id}/reviewers"))
else {
continue;
};
let own = reviewers
.get("nodes")
.and_then(Value::as_array)
.and_then(|nodes| {
nodes.iter().find(|node| {
node.get("username").and_then(Value::as_str) == Some(username)
})
});
if let Some(own) = own {
if let Some(details) = metadata.get_mut(&id) {
details.review_requested = matches!(
own.pointer("/mergeRequestInteraction/reviewState")
.and_then(Value::as_str),
Some("UNREVIEWED" | "REVIEW_STARTED")
);
}
} else if reviewers.pointer("/pageInfo/hasNextPage") == Some(&Value::Bool(true)) {
if let Some(after) = reviewers
.pointer("/pageInfo/endCursor")
.and_then(Value::as_str)
.filter(|after| !after.is_empty() && Some(*after) != cursor.as_deref())
{
pending.insert(id, Some(after.to_owned()));
}
}
}
}
}
fn gitea_reviews_with(
&self, &self,
id: u64, id: u64,
viewer: u64, viewer: u64,
request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>, request: &mut impl FnMut(&str, Option<&str>) -> Result<Value, String>,
) -> Result<bool, String> { ) -> Result<(bool, bool), String> {
let mut latest = None::<(u64, bool)>; let mut latest = None::<(u64, bool)>;
let mut requested = None::<u64>;
let mut submitted = 0;
for page in 1.. { for page in 1.. {
let response = request( let response = request(
&format!("{}/{id}/reviews?limit=50&page={page}", self.endpoint), &format!("{}/{id}/reviews?limit=50&page={page}", self.endpoint),
@@ -691,19 +808,39 @@ impl Forge {
.as_array() .as_array()
.ok_or("Forge API returned a non-array review list")?; .ok_or("Forge API returned a non-array review list")?;
if reviews.is_empty() { if reviews.is_empty() {
return Ok(latest.is_some_and(|(_, approved)| approved)); return Ok((
latest.is_some_and(|(_, approved)| approved),
requested.is_some_and(|id| id > submitted),
));
} }
for review in reviews { for review in reviews {
if review.pointer("/user/id").and_then(Value::as_u64) != Some(viewer) { if review.pointer("/user/id").and_then(Value::as_u64) != Some(viewer)
|| review.get("team").is_some_and(|team| !team.is_null())
{
continue; continue;
} }
let state = field(review, "state")?; let state = field(review, "state")?;
match state { match state {
"COMMENT" | "PENDING" | "REQUEST_REVIEW" => continue, "PENDING" => continue,
"REQUEST_REVIEW" => {
if review.get("dismissed").and_then(Value::as_bool) == Some(false) {
if let Some(id) = review.get("id").and_then(Value::as_u64) {
requested = Some(requested.map_or(id, |previous| previous.max(id)));
}
}
continue;
}
"COMMENT" => {
if let Some(id) = review.get("id").and_then(Value::as_u64) {
submitted = submitted.max(id);
}
continue;
}
"APPROVED" | "REQUEST_CHANGES" => {} "APPROVED" | "REQUEST_CHANGES" => {}
_ => return Err("Forge API returned invalid review state".into()), _ => return Err("Forge API returned invalid review state".into()),
} }
let id = number(review, "id")?; let id = number(review, "id")?;
submitted = submitted.max(id);
let dismissed = review let dismissed = review
.get("dismissed") .get("dismissed")
.and_then(Value::as_bool) .and_then(Value::as_bool)
@@ -917,6 +1054,7 @@ fn summary(
closed, closed,
draft, draft,
approved: false, approved: false,
review_requested: false,
created_at: created_at(value)?, created_at: created_at(value)?,
}) })
} }
+2 -1
View File
@@ -107,7 +107,8 @@ Local reviews: :diff.comment anchors exact Visual text, Visual Line rows, or the
Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing Snapshot anchors stay immutable; stale comments lose gutter markers; no publishing
Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search Pull requests: :pull_requests [REMOTE]; GitHub/GitLab/Gitea Open/Closed/Drafts/All tabs, type to search
Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab Enter opens the immutable PR head against its merge base; API auth uses provider tokens or gh/glab
✓ before the PR number marks your API account's active approval; blank may mean unavailable 🙏 marks a personal review request; 👍 marks your active approval (API account)
Symbols: config.pull_request_review_requested_symbol / config.pull_request_approved_symbol
Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace Resolve jj conflicts: :resolve opens the current working-copy conflicts in a dedicated workspace
Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base Editable marker-free Draft vs readonly Alternative; :resolve.history shows inputs/base
config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base) config.resolve_history_layout = \"columns\" (left/base/right) or \"base_below\" (left/right above base)
+9 -7
View File
@@ -1,7 +1,7 @@
//! Read-only active-repository discovery and isolated revision comparison. //! Read-only active-repository discovery and isolated revision comparison.
use crate::diff::{ use crate::diff::{
BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus, BlameRevision, Catalog, Category, ChangedFile, Comparison, ComparisonMode, FileStatus,
PullRequestPage, RepoKind, Revision, RevisionTarget, line_hunks, PullRequestPage, PullRequestReviewState, RepoKind, Revision, RevisionTarget, line_hunks,
}; };
use std::{ use std::{
collections::{BTreeMap, BTreeSet}, collections::{BTreeMap, BTreeSet},
@@ -1631,12 +1631,7 @@ impl Repository {
remote: remote.clone(), remote: remote.clone(),
number: pull.number, number: pull.number,
}, },
label: format!( label: format!("#{} {}", pull.number, pull.title),
"{}#{} {}",
if pull.approved { "✓ " } else { " " },
pull.number,
pull.title
),
description: pull.description, description: pull.description,
category: if pull.closed { category: if pull.closed {
Category::Closed Category::Closed
@@ -1647,6 +1642,13 @@ impl Repository {
}, },
}, },
pull.created_at, pull.created_at,
if pull.review_requested {
PullRequestReviewState::Requested
} else if pull.approved {
PullRequestReviewState::Approved
} else {
PullRequestReviewState::None
},
) )
}) })
.collect(), .collect(),
+24
View File
@@ -58,6 +58,8 @@ pub struct Config {
pub diff_view_mode: crate::diff::ViewMode, pub diff_view_mode: crate::diff::ViewMode,
pub diff_split_orientation: crate::panes::SplitAxis, pub diff_split_orientation: crate::panes::SplitAxis,
pub diff_follow_working_copy: bool, pub diff_follow_working_copy: bool,
pub pull_request_review_requested_symbol: String,
pub pull_request_approved_symbol: String,
pub resolve_history_layout: crate::editor::ResolverLayout, pub resolve_history_layout: crate::editor::ResolverLayout,
} }
@@ -112,6 +114,8 @@ impl Default for Config {
diff_view_mode: crate::diff::ViewMode::Split, diff_view_mode: crate::diff::ViewMode::Split,
diff_split_orientation: crate::panes::SplitAxis::Vertical, diff_split_orientation: crate::panes::SplitAxis::Vertical,
diff_follow_working_copy: true, diff_follow_working_copy: true,
pull_request_review_requested_symbol: "🙏".into(),
pull_request_approved_symbol: "👍".into(),
resolve_history_layout: crate::editor::ResolverLayout::Columns, resolve_history_layout: crate::editor::ResolverLayout::Columns,
} }
} }
@@ -153,6 +157,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
diff_view_mode, diff_view_mode,
diff_split_orientation, diff_split_orientation,
diff_follow_working_copy, diff_follow_working_copy,
pull_request_review_requested_symbol,
pull_request_approved_symbol,
resolve_history_layout, resolve_history_layout,
.. ..
} = *response; } = *response;
@@ -195,6 +201,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.diff_view_mode = diff_view_mode; config.diff_view_mode = diff_view_mode;
config.diff_split_orientation = diff_split_orientation; config.diff_split_orientation = diff_split_orientation;
config.diff_follow_working_copy = diff_follow_working_copy; config.diff_follow_working_copy = diff_follow_working_copy;
config.pull_request_review_requested_symbol = pull_request_review_requested_symbol;
config.pull_request_approved_symbol = pull_request_approved_symbol;
config.resolve_history_layout = resolve_history_layout; config.resolve_history_layout = resolve_history_layout;
Ok(config) Ok(config)
} }
@@ -556,9 +564,25 @@ struct ConfigFrame {
diff_view_mode: crate::diff::ViewMode, diff_view_mode: crate::diff::ViewMode,
diff_split_orientation: crate::panes::SplitAxis, diff_split_orientation: crate::panes::SplitAxis,
diff_follow_working_copy: bool, diff_follow_working_copy: bool,
#[serde(deserialize_with = "pull_request_symbol")]
pull_request_review_requested_symbol: String,
#[serde(deserialize_with = "pull_request_symbol")]
pull_request_approved_symbol: String,
resolve_history_layout: crate::editor::ResolverLayout, resolve_history_layout: crate::editor::ResolverLayout,
} }
fn pull_request_symbol<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<String, D::Error> {
let symbol = String::deserialize(deserializer)?;
if symbol.chars().any(char::is_control) {
return Err(serde::de::Error::custom(
"pull request symbols must not contain control characters",
));
}
Ok(symbol)
}
fn whitespace_characters<'de, D: serde::Deserializer<'de>>( fn whitespace_characters<'de, D: serde::Deserializer<'de>>(
deserializer: D, deserializer: D,
) -> Result<BTreeMap<char, char>, D::Error> { ) -> Result<BTreeMap<char, char>, D::Error> {
+23
View File
@@ -157,6 +157,8 @@ module Vamp
@diff_view_mode = "split" @diff_view_mode = "split"
@diff_split_orientation = "vertical" @diff_split_orientation = "vertical"
@diff_follow_working_copy = true @diff_follow_working_copy = true
@pull_request_review_requested_symbol = "🙏"
@pull_request_approved_symbol = "👍"
@resolve_history_layout = "columns" @resolve_history_layout = "columns"
end end
@@ -327,6 +329,18 @@ module Vamp
@diff_follow_working_copy = enabled @diff_follow_working_copy = enabled
end end
# @param symbol [String] empty to hide the review-request indicator
# @return [String]
def pull_request_review_requested_symbol=(symbol)
@pull_request_review_requested_symbol = pull_request_symbol!(symbol, "pull_request_review_requested_symbol")
end
# @param symbol [String] empty to hide the approval indicator
# @return [String]
def pull_request_approved_symbol=(symbol)
@pull_request_approved_symbol = pull_request_symbol!(symbol, "pull_request_approved_symbol")
end
# @param layout [String] "columns" or "base_below" # @param layout [String] "columns" or "base_below"
# @return [String] # @return [String]
def resolve_history_layout=(layout) def resolve_history_layout=(layout)
@@ -382,10 +396,19 @@ module Vamp
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines, "diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines,
"diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation, "diff_view_mode" => @diff_view_mode, "diff_split_orientation" => @diff_split_orientation,
"diff_follow_working_copy" => @diff_follow_working_copy, "diff_follow_working_copy" => @diff_follow_working_copy,
"pull_request_review_requested_symbol" => @pull_request_review_requested_symbol,
"pull_request_approved_symbol" => @pull_request_approved_symbol,
"resolve_history_layout" => @resolve_history_layout } "resolve_history_layout" => @resolve_history_layout }
end end
private private
def pull_request_symbol!(symbol, label)
symbol = Vamp.string!(symbol, label)
raise ArgumentError, "#{label} must not contain control characters" if symbol.match?(/[[:cntrl:]]/)
symbol
end
# @param spaces [Integer, nil] # @param spaces [Integer, nil]
# @return [Integer, nil] # @return [Integer, nil]
def tab_spaces!(spaces) def tab_spaces!(spaces)
+2
View File
@@ -542,6 +542,8 @@ impl EditorSession {
editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces); editor.set_tab_spaces(config.tab_spaces, config.language_tab_spaces);
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines); editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation); editor.set_diff_display_options(config.diff_view_mode, config.diff_split_orientation);
editor.pull_request_review_requested_symbol = config.pull_request_review_requested_symbol;
editor.pull_request_approved_symbol = config.pull_request_approved_symbol;
editor.resolve_history_layout = config.resolve_history_layout; editor.resolve_history_layout = config.resolve_history_layout;
if let Err(error) = if let Err(error) =
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels) editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
+29 -5
View File
@@ -3536,6 +3536,18 @@ fn draw_diff(
panels.picker_scroll.distance, panels.picker_scroll.distance,
); );
let mut lines = Vec::with_capacity(count * row_height); let mut lines = Vec::with_capacity(count * row_height);
let (requested_symbol, approved_symbol) = editor.map_or(("🙏", "👍"), |editor| {
(
editor.pull_request_review_requested_symbol.as_str(),
editor.pull_request_approved_symbol.as_str(),
)
});
let symbol_width = if pull_requests {
requested_symbol.width().max(approved_symbol.width())
} else {
0
};
let decoration_width = symbol_width + usize::from(symbol_width > 0);
for (index, revision) in entries for (index, revision) in entries
.enumerate() .enumerate()
.skip(first) .skip(first)
@@ -3549,10 +3561,21 @@ fn draw_diff(
} else { } else {
Style::default() Style::default()
}; };
let symbol = if pull_requests {
match diff.pull_request_review_state(revision) {
crate::diff::PullRequestReviewState::Requested => requested_symbol,
crate::diff::PullRequestReviewState::Approved => approved_symbol,
crate::diff::PullRequestReviewState::None => "",
}
} else {
""
};
let label = format!( let label = format!(
"{} {}", "{} {symbol}{:padding$}{}",
if index == diff.selected { ">" } else { " " }, if index == diff.selected { ">" } else { " " },
display_text(&revision.label) "",
display_text(&revision.label),
padding = decoration_width.saturating_sub(symbol.width()),
); );
let width = usize::from(results.width); let width = usize::from(results.width);
if row_height == 2 { if row_height == 2 {
@@ -3560,9 +3583,10 @@ fn draw_diff(
lines.push(Line::styled( lines.push(Line::styled(
visible_line( visible_line(
&format!( &format!(
"{}{}", "{:indent$}{}",
if pull_requests { " " } else { " " }, "",
display_text(&revision.description) display_text(&revision.description),
indent = 2 + decoration_width,
), ),
0, 0,
width, width,
+46
View File
@@ -433,6 +433,7 @@ fn pull_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcom
}, },
), ),
date.into(), date.into(),
PullRequestReviewState::None,
) )
}) })
.collect(), .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] #[test]
fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() { fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() {
let mut state = DiffState::pull_requests("origin".into()); 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(); let forge = parse(url, None).unwrap();
for unavailable_viewer in [true, false] { for unavailable_viewer in [true, false] {
let metadata = forge.metadata_with( 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| { &mut |url, body| {
if url.ends_with("/user") { if url.ends_with("/user") {
return if unavailable_viewer { 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.approved));
assert!(metadata.values().all(|details| details.review_requested
== (provider == Provider::Github && !unavailable_viewer)));
if provider == Provider::Gitea { if provider == Provider::Gitea {
assert_eq!(metadata[&7].size, Some((0, 0))); 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] #[test]
fn draft_summaries_keep_open_closed_and_merged_states_distinct() { fn draft_summaries_keep_open_closed_and_merged_states_distinct() {
for provider in [Provider::Github, Provider::Gitlab, Provider::Gitea] { 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] #[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!({ let mut frame = serde_json::json!({
"version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [], "version": 1, "type": "config", "plugins": [], "lsp": {}, "bindings": [],
"review_directory": null, "leader": "space", "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_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
for value in [serde_json::Value::Null, serde_json::json!("reviews")] { 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; frame["review_directory"] = value;
assert!(serde_json::from_value::<Response>(frame.clone()).is_err()); 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"); frame.as_object_mut().unwrap().remove("review_directory");
assert!(serde_json::from_value::<Response>(frame).is_err()); 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] #[test]
fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() { fn tab_spaces_follow_active_language_and_preserve_native_edit_semantics() {
let fixture = Fixture::new(); 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}"); assert!(load_config(&path).unwrap_err().contains(error), "{value}");
} }
for frame in [ 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":[],"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,"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,"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}"); assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
} }
@@ -632,6 +706,7 @@ end
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
assert!(serde_json::from_value::<Response>(frame.clone()).is_ok()); assert!(serde_json::from_value::<Response>(frame.clone()).is_ok());
@@ -773,6 +848,7 @@ end"#,
"labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15, "labels": {}, "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
for value in ["all", "leading"] { 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, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
for setting in ["diff_context_lines", "diff_expand_lines"] { 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_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true, "scroll_distance": 0 "diff_follow_working_copy": true, "scroll_distance": 0
}); });
for distance in [None, Some(0), Some(7), Some(usize::MAX)] { 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, "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15,
"diff_view_mode": "split", "diff_split_orientation": "vertical", "diff_view_mode": "split", "diff_split_orientation": "vertical",
"resolve_history_layout": "columns", "resolve_history_layout": "columns",
"pull_request_review_requested_symbol": "🙏", "pull_request_approved_symbol": "👍",
"diff_follow_working_copy": true "diff_follow_working_copy": true
}); });
let Response::Config(config) = serde_json::from_value::<Response>(frame.clone()).unwrap() 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"]); 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] #[test]
fn diff_display_preferences_survive_reopening() { fn diff_display_preferences_survive_reopening() {
use crate::{diff::ViewMode, panes::SplitAxis}; 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), format!("2026-01-{:02}T00:00:00Z", 20 - index),
crate::diff::PullRequestReviewState::None,
) )
}) })
.collect(), .collect(),
@@ -3526,17 +3527,17 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
let text = screen_text(&terminal); let text = screen_text(&terminal);
assert!(text.contains("Pull requests")); assert!(text.contains("Pull requests"));
assert!(text.contains("Open") && text.contains("Closed") && text.contains("All")); 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("pinned"));
assert!(text.contains("[partial]")); assert!(text.contains("[partial]"));
diff.handle(Input::End); diff.handle(Input::End);
render_diff(&mut terminal, &diff, &mut panels); render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal); let text = screen_text(&terminal);
assert!(text.contains("> #19 Pull request")); assert!(text.contains("> #19 Pull request"));
assert!(!text.contains("#00 Pull request")); assert!(!text.contains("#00 Pull request"));
diff.handle(Input::Home); diff.handle(Input::Home);
render_diff(&mut terminal, &diff, &mut panels); 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.handle(Input::Char('\t'));
diff.apply_result( diff.apply_result(
2, 2,
@@ -3555,10 +3556,124 @@ fn pull_request_picker_renders_all_rows_without_a_pinned_offset() {
diff.handle(Input::Char('\t')); diff.handle(Input::Char('\t'));
render_diff(&mut terminal, &diff, &mut panels); render_diff(&mut terminal, &diff, &mut panels);
let text = screen_text(&terminal); 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")); 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] #[test]
fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() { fn diff_excerpts_render_original_lines_and_expand_without_losing_changes() {