crates/gunnar-ui-web/src/branches.rs
//! **Every branch, for a branch picker** (Henrik, 2026-10-31: the pull request
//! form offered only the first page of `/refs`, so every branch after the
//! `fix/...` ones, `main` among them, could not be picked), on the host side of
//! the wasm boundary. Same argument as [`crate::room`].
//!
//! A picker reads `GET /api/repos/{ns}/{name}/refs?prefix=refs/heads/` page after
//! page: each next page starts after the last name of the one before
//! (`&after=`), until the server says nothing more comes. The server pages; the
//! console only follows its cursor.
/// The branches a picker lists: the references under this prefix.
pub const BRANCHES: &str = "refs/heads/";
/// The address of one page of the repository's branches: from the start, or
/// after the reference named `after`. `namespace` and `name` come as path
/// segments, already encoded.
pub fn refs_url(namespace: &str, name: &str, after: Option<&str>) -> String {
let first = format!("/api/repos/{namespace}/{name}/refs?prefix={BRANCHES}");
match after {
Some(after) => format!("{first}&after={}", crate::auth::percent_encode(after)),
None => first,
}
}
/// Where the next page starts, or `None` when this was the last: a page the
/// server cut short (`truncated`) continues after its last name. A cursor that
/// did not move (a server that ignores `after`) ends the reading instead of
/// asking for the same page forever.
pub fn next_after(truncated: bool, last: Option<&str>, before: Option<&str>) -> Option<String> {
match last {
Some(last) if truncated && Some(last) != before => Some(last.to_owned()),
_ => None,
}
}
/// The address of one page of EVERY reference of the repository (the
/// repository page's References panel, its default-branch picker and the
/// graph's ref chips): from the start, or after the reference named `after`.
pub fn every_ref_url(namespace: &str, name: &str, after: Option<&str>) -> String {
let first = format!("/api/repos/{namespace}/{name}/refs");
match after {
Some(after) => format!("{first}?after={}", crate::auth::percent_encode(after)),
None => first,
}
}
/// **A listing read to its end**: `get` answers one page (its items and whether
/// the server cut it short) for a cursor, `name` is an item's cursor. The answer
/// is every item, and whether the listing is whole: a cut page whose cursor did
/// not move ends the reading, and the listing is then NOT whole (a caller that
/// must never claim "nothing more" reads that).
pub async fn every_page<T, E, F, Fut>(mut get: F, name: fn(&T) -> &str) -> Result<(Vec<T>, bool), E>
where
F: FnMut(Option<String>) -> Fut,
Fut: core::future::Future<Output = Result<(Vec<T>, bool), E>>,
{
let mut all: Vec<T> = Vec::new();
let mut after: Option<String> = None;
loop {
let (items, truncated) = get(after.clone()).await?;
let next = next_after(truncated, items.last().map(name), after.as_deref());
all.extend(items);
match next {
Some(n) => after = Some(n),
// A cut page with no cursor to follow is a listing that is not whole.
None => return Ok((all, !truncated)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_page_and_the_ones_after_it_are_asked_by_name() {
assert_eq!(
refs_url("ns", "repo", None),
"/api/repos/ns/repo/refs?prefix=refs/heads/"
);
assert_eq!(
refs_url("ns", "repo", Some("refs/heads/fix/a b")),
"/api/repos/ns/repo/refs?prefix=refs/heads/&after=refs/heads/fix/a%20b",
"the cursor is encoded"
);
}
/// ★ A cut page continues after its last name; a whole page, an empty one or a
/// cursor that did not move ends the reading.
#[test]
fn a_cut_page_is_followed_to_the_end_and_never_forever() {
assert_eq!(
next_after(true, Some("refs/heads/fix/249"), None).as_deref(),
Some("refs/heads/fix/249")
);
assert_eq!(
next_after(false, Some("refs/heads/main"), Some("refs/heads/fix/249")),
None,
"the last page"
);
assert_eq!(
next_after(true, None, Some("refs/heads/fix/249")),
None,
"an empty page"
);
assert_eq!(
next_after(true, Some("refs/heads/fix/249"), Some("refs/heads/fix/249")),
None,
"a cursor that did not move"
);
}
/// ★ **The pull request form picks from every branch** (Henrik, 2026-10-31): it
/// reads the branches page after page, not the first page of every reference.
#[test]
fn the_pull_request_form_reads_every_page_of_branches() {
let page = crate::code::of(include_str!("pages/pulls.rs"));
assert!(
page.contains("crate::branches::refs_url(")
&& page.contains("crate::branches::next_after("),
"the form follows the server's cursor"
);
assert!(
!page.contains("\"/api/repos/{}/{}/refs\""),
"the form still reads only the first page of /refs"
);
}
/// Poll a future that never waits (the fakes below answer at once).
fn now<F: core::future::Future>(f: F) -> F::Output {
let mut f = core::pin::pin!(f);
let mut cx = core::task::Context::from_waker(core::task::Waker::noop());
match f.as_mut().poll(&mut cx) {
core::task::Poll::Ready(v) => v,
core::task::Poll::Pending => panic!("the fake never waits"),
}
}
fn own(s: &String) -> &str {
s.as_str()
}
/// A server holding `all` (sorted), answering pages of `limit` after the cursor.
fn server(all: &[String], limit: usize, after: Option<String>) -> (Vec<String>, bool) {
let rest: Vec<String> = all
.iter()
.filter(|n| after.as_deref().is_none_or(|a| n.as_str() > a))
.cloned()
.collect();
let cut = rest.len() > limit;
(rest.into_iter().take(limit).collect(), cut)
}
/// ★ **Every page, to the end** (GUN-FIELD-TEST, 2026-10-31): the Settings
/// default-branch picker and the graph's ref chips read the first page of
/// `/refs` only, so a repository with more references than one page lost the
/// rest. 507 references in pages of 250 come back whole, in order.
#[test]
fn a_listing_longer_than_a_page_is_read_to_its_end() {
let all: Vec<String> = (0..507).map(|i| format!("refs/heads/b{i:04}")).collect();
let mut asked = Vec::new();
let (items, whole) = now(every_page(
|after: Option<String>| {
asked.push(after.clone());
let page = server(&all, 250, after);
async move { Ok::<_, ()>(page) }
},
own,
))
.unwrap();
assert_eq!(items, all, "every reference, in order");
assert!(whole, "the listing is whole");
assert_eq!(asked.len(), 3, "three pages: {asked:?}");
assert_eq!(asked[1].as_deref(), Some("refs/heads/b0249"));
}
/// ★ A server that ignores the cursor ends the reading after the page that
/// did not move, and the listing is then NOT whole.
#[test]
fn a_cursor_that_does_not_move_ends_the_reading_and_says_not_whole() {
let all: Vec<String> = (0..300).map(|i| format!("refs/heads/b{i:04}")).collect();
let mut calls = 0;
let (_, whole) = now(every_page(
|_after: Option<String>| {
calls += 1;
let page = server(&all, 250, None);
async move { Ok::<_, ()>(page) }
},
own,
))
.unwrap();
assert!(!whole, "a stuck cursor is not a whole listing");
assert!(calls <= 2, "asked {calls} times");
}
/// ★ A refusal on any page is the answer, not a shorter list.
#[test]
fn a_refusal_on_a_later_page_is_the_answer() {
let all: Vec<String> = (0..300).map(|i| format!("refs/heads/b{i:04}")).collect();
let got = now(every_page(
|after: Option<String>| {
let page = if after.is_some() { Err("refused") } else { Ok(server(&all, 250, None)) };
async move { page }
},
own,
));
assert_eq!(got, Err("refused"));
}
#[test]
fn every_reference_is_asked_by_name_too() {
assert_eq!(every_ref_url("ns", "repo", None), "/api/repos/ns/repo/refs");
assert_eq!(
every_ref_url("ns", "repo", Some("refs/tags/v 1")),
"/api/repos/ns/repo/refs?after=refs/tags/v%201"
);
}
/// ★ **The Settings default-branch picker and the graph's ref chips read every
/// page of `/refs`** (GUN-FIELD-TEST, 2026-10-31), as the pull request form does.
#[test]
fn the_repository_page_and_the_graph_read_every_page_of_refs() {
let repo = crate::code::of(include_str!("pages/repo.rs"));
assert!(
repo.contains("pub async fn every_ref(")
&& repo.contains("crate::branches::every_page(")
&& repo.contains("crate::branches::every_ref_url("),
"pages/repo.rs reads every reference by the server's cursor"
);
for (file, page) in [
("pages/repo.rs", repo.clone()),
("pages/graph.rs", crate::code::of(include_str!("pages/graph.rs"))),
] {
assert!(page.contains("every_ref(&ns, &repo)"), "{file} reads every reference");
assert!(
!page.contains("\"/api/repos/{}/{}/refs\""),
"{file} still reads only the first page of /refs"
);
}
}
}