Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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"
            );
        }
    }
}