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/pages/repo.rs

//! One repository: its README, its references, and what it costs to store (its commits are the History tab's).

use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use serde::Deserialize;

use crate::api::{segment, Page};
use crate::fmt;
use crate::pages::policy::PolicyPanels;
use crate::pages::{now_ms, resolved, when_text};
use crate::ui::{Badge, Empty, FailBox, Hint, Metric, PageHeader, Panel, Tone, Truncation};

/// ★ **A repository's references** — every branch and tag and the object it
/// names. Drawn on the History tab since 2026-10-04 (owner: the README tab is
/// the README and the clone line); `refs` is the History page's own read.
#[component]
pub fn References(refs: LocalResource<Result<Page<RefTarget>, crate::api::Fail>>) -> impl IntoView {
    view! {
        <Panel title="References">
            {move || resolved(refs.get(), None, |page: Page<RefTarget>| {
                if page.items.is_empty() {
                    return view! {
                        <Empty
                            title="No references"
                            detail="No one has pushed a branch or tag here yet."
                        />
                    }
                    .into_any();
                }
                let total = page.total;
                let shown = page.items.len();
                view! {
                    <div class="table-wrap">
                        <table>
                            <caption class="vh">"References"</caption>
                            <thead>
                                <tr>
                                    <th scope="col">"Reference"</th>
                                    <th scope="col">"Commit"</th>
                                </tr>
                            </thead>
                            <tbody>
                                {page.items
                                    .into_iter()
                                    .map(|target| {
                                        let symbolic = target.symbolic_target.clone();
                                        view! {
                                            <tr>
                                                <td>
                                                    {fmt::short_ref(&target.ref_name).to_owned()}
                                                    {symbolic.map(|s| view! {
                                                        <span class="unset" style="font-style:normal">
                                                            {format!(" → {}", fmt::short_ref(&s))}
                                                        </span>
                                                    })}
                                                </td>
                                                <td class="oid">{fmt::short_oid(&target.oid)}</td>
                                            </tr>
                                        }
                                    })
                                    .collect_view()}
                            </tbody>
                        </table>
                    </div>
                    {(shown < total).then(|| view! { <Truncation shown=shown total=total/> })}
                }
                .into_any()
            })}
        </Panel>
    }
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct RefTarget {
    pub ref_name: String,
    pub oid: String,
    pub symbolic_target: Option<String>,
    pub peeled_oid: Option<String>,
}

/// **Every reference of a repository**, read page after page by the server's
/// cursor ([`crate::branches::every_page`]): one `Page` holding the whole
/// listing, `truncated` only when the server's cursor did not move.
pub async fn every_ref(ns: &str, repo: &str) -> Result<Page<RefTarget>, crate::api::Fail> {
    let (items, whole) = crate::branches::every_page(
        |after: Option<String>| {
            let url = crate::branches::every_ref_url(ns, repo, after.as_deref());
            async move {
                let page = crate::api::get::<Page<RefTarget>>(&url).await?;
                Ok((page.items, page.truncated))
            }
        },
        ref_name,
    )
    .await?;
    Ok(Page { total: items.len(), items, truncated: !whole })
}

/// A reference's cursor: its name.
fn ref_name(t: &RefTarget) -> &str {
    &t.ref_name
}

/// **The panel.** Nothing at all for a session gunnar does not answer the
/// list to (a member, an outsider): that is not this person's panel.
#[component]
pub fn MembersPanel(namespace: Memo<String>, name: Memo<String>) -> impl IntoView {
    let beat = RwSignal::new(0u32);
    let failed = RwSignal::new(None::<crate::api::Fail>);
    let busy = RwSignal::new(false);
    let url = move || format!("/api/repos/{}/{}/members", segment(&namespace.get()), segment(&name.get()));
    let list = LocalResource::new(move || {
        beat.track();
        let url = url();
        async move { crate::api::get::<crate::members::Members>(&url).await }
    });
    let act = move |id: String, level: Option<&'static str>| {
        let at = format!("{}/{}", url(), segment(&id));
        busy.set(true);
        failed.set(None);
        leptos::task::spawn_local(async move {
            let done = match level {
                Some(level) => crate::api::put::<_, serde_json::Value>(&at, &crate::members::Give { level }).await.map(|_| ()),
                None => crate::api::delete::<serde_json::Value>(&at).await.map(|_| ()),
            };
            if let Err(fail) = done {
                failed.set(Some(fail));
            }
            busy.set(false);
            beat.update(|n| *n += 1);
        });
    };
    move || {
        let Some(Ok(table)) = list.get() else {
            return ().into_any();
        };
        let account = table.account.clone();
        view! {
            <Panel title="Members">
                <div class="panel-body" id="members-panel">
                    <p class="hint">{format!("Everyone {account} added reads all of it. Give Write to whoever pushes here.")}</p>
                    {move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
                    {if table.members.is_empty() {
                        view! { <p>{format!("{account} has added nobody yet: Add users is on /{account}.")}</p> }.into_any()
                    } else {
                        view! {
                            <div class="table-wrap">
                                <table>
                                    <thead>
                                        <tr>
                                            <th scope="col">"Member"</th>
                                            <th scope="col">"Holds"</th>
                                            <th scope="col">""</th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        {table.members.iter().map(|m| {
                                            let holds = crate::members::holds(m, &account);
                                            let id = m.id.clone();
                                            let (write_id, take_id, read_id) = (id.clone(), id.clone(), id.clone());
                                            let writes = m.repo_level.as_deref() == Some("write") || m.repo_level.as_deref() == Some("admin");
                                            let on_repo = m.repo_level.is_some();
                                            let reads = on_repo || m.account_level.is_some();
                                            view! {
                                                <tr id=format!("member-{id}")>
                                                    <td class="mono">{m.name.clone()}</td>
                                                    <td class="member-holds">{holds}</td>
                                                    <td class="row">
                                                        {(!writes).then(|| view! {
                                                            <button type="button" class="btn" id=format!("member-{id}-write") disabled=move || busy.get()
                                                                on:click=move |_| act(write_id.clone(), Some("write"))>"Give Write"</button>
                                                        })}
                                                        {(!reads).then(|| view! {
                                                            <button type="button" class="btn" id=format!("member-{id}-read") disabled=move || busy.get()
                                                                on:click=move |_| act(read_id.clone(), Some("read"))>"Give Read"</button>
                                                        })}
                                                        {on_repo.then(|| view! {
                                                            <button type="button" class="btn" id=format!("member-{id}-take") disabled=move || busy.get()
                                                                on:click=move |_| act(take_id.clone(), None)>"Take it away"</button>
                                                        })}
                                                    </td>
                                                </tr>
                                            }
                                        }).collect_view()}
                                    </tbody>
                                </table>
                            </div>
                        }.into_any()
                    }}
                </div>
            </Panel>
        }
        .into_any()
    }
}

/// **The Settings tab** (`/:ns/:name/settings`): visibility,
/// maintenance and where the repository is mirrored from — the panels the
/// Code tab carried until the final navigation (owner, 2026-10-02).
#[component]
pub fn RepoSettings() -> impl IntoView {
    // A guest (the server sends one to sign in; a link inside the console still lands
    // here) sees only the sign-in line: no settings and no Danger zone (GUN-FIELD-TEST).
    let session = crate::app::session();
    let params = use_params_map();
    move || match session.get() {
        Some(s) if !s.authenticated && !s.open => {
            let path = params.with(|p| format!("{}/{}", p.get("namespace").unwrap_or_default(), p.get("name").unwrap_or_default()));
            let next = format!("/login?next=/{path}/settings");
            view! {
                <PageHeader title=path.clone()/>
                <RepoTabs/>
                <div class="panel-body">
                    <p class="hint">{crate::nav::SETTINGS_SIGN_IN}" "<a href=next rel="external">"Log in"</a></p>
                </div>
            }
            .into_any()
        }
        _ => view! { <RepoDetail settings=true/> }.into_any(),
    }
}

/// **A repository's tabs**, under its header on every repository page:
/// Readme · Code · History · Compare · Search · Blame · Provenance ·
/// Pulls · Issues · Activity · Settings
/// (`crate::nav::repo_tabs` decides which, and a test states it). The
/// current one is marked by the address it is on.
#[component]
pub fn RepoTabs() -> impl IntoView {
    let params = use_params_map();
    let location = leptos_router::hooks::use_location();
    let session = crate::app::session();
    // **Settings is for whoever may administer THIS repository** — gunnar's
    // per-repository answer (`crate::rights`), asked as this person: the
    // account's admin, an admin of the repository, or an operator. A
    // customer's session is a viewer's, so the role alone hid the tab from the
    // one person whose settings they are.
    let rights = crate::pages::repo_rights();
    move || {
        let (ns, name) = params.with(|p| (p.get("namespace").unwrap_or_default(), p.get("name").unwrap_or_default()));
        let signed_in = session.get().is_some_and(|s| s.authenticated || s.open);
        let may_write = session.get().is_some_and(|s| s.may_write()) || rights.get().may_administer();
        let here = location.pathname.get();
        let tabs = crate::nav::repo_tabs(&ns, &name, signed_in, may_write);
        // The tab whose address is the longest prefix of where we are — Code
        // is a prefix of every one of them.
        let current = crate::nav::current_tab(&tabs, &here);
        view! {
            <nav class="repo-tabs" aria-label="Repository">
                {tabs.into_iter().map(|(label, href)| {
                    let on = Some(label) == current;
                    view! {
                        <a class=if on { "repo-tab on" } else { "repo-tab" } href=href aria-current=if on { "page" } else { "false" }>{label}</a>
                    }
                }).collect_view()}
            </nav>
        }
    }
}

#[component]
pub fn RepoDetail(
    /// `true` draws the Settings tab's panels; `false` the Readme tab: the
    /// README and the clone line (references and commits are the History
    /// tab's).
    #[prop(optional)]
    settings: bool,
) -> impl IntoView {
    let params = use_params_map();
    let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
    let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
    let path = Memo::new(move |_| format!("{}/{}", namespace.get(), name.get()));

    // This page is reachable WITHOUT a session: the three reads below
    // (`/api/repos/{ns}/{name}`, `…/refs`, `…/log`) are among the routes the
    // server answers for an anonymous visitor through its grantless guest
    // identity, so a public repository can be browsed with nobody logged in.
    // Everything else the page used to offer is still refused, and the three
    // `<Show>` gates below — two panels that would fetch, and the two header
    // links that lead somewhere a guest may not go — are what keep a visitor
    // from asking for any of it. `None` — the session has not answered yet —
    // counts as not signed in, which is why every gate uses `is_some_and`.
    let session = crate::app::session();

    let repo = LocalResource::new(move || {
        let url = format!(
            "/api/repos/{}/{}",
            segment(&namespace.get()),
            segment(&name.get())
        );
        async move { crate::api::get::<super::repos::Repo>(&url).await }
    });
    // Every reference, page after page (GUN-FIELD-TEST, 2026-10-31: the first
    // page alone left the default-branch picker without the branches past it).
    let refs = LocalResource::new(move || {
        let (ns, repo) = (segment(&namespace.get()), segment(&name.get()));
        async move { every_ref(&ns, &repo).await }
    });

    // ★ **What the visibility control needs, gathered at page level.**
    //
    // `changed` is the word the PATCH came back with, and `None` until one has.
    // It is a signal rather than a refetch of `repo` because the server's answer
    // to the PATCH *is* the repository as it stands afterwards — asking again
    // would be a second round trip to learn something already in hand, and it
    // would leave a gap in which the metric still said the old word.
    let changed = RwSignal::new(None::<String>);
    // The "encrypted" flag the PATCH came back with, as `changed` is for visibility.
    let enc_changed = RwSignal::new(None::<bool>);
    // The default branch the PATCH came back with (owner, 2026-10-14), likewise.
    let branch_changed = RwSignal::new(None::<String>);
    // Switching repository must not carry the last one's answer across.
    Effect::new(move |_| {
        let _ = (namespace.get(), name.get());
        changed.set(None);
        enc_changed.set(None);
        branch_changed.set(None);
    });
    let default_branch = Memo::new(move |_| {
        branch_changed
            .get()
            .or_else(|| repo.get().and_then(Result::ok).map(|r| r.default_branch))
            .unwrap_or_default()
    });
    // The repository's branches, from the references this page already reads.
    let branches = Memo::new(move |_| match refs.get() {
        Some(Ok(page)) => crate::default_branch::choices(page.items.iter().map(|t| t.ref_name.as_str())),
        _ => Vec::new(),
    });
    let encrypted = Memo::new(move |_| {
        enc_changed
            .get()
            .or_else(|| repo.get().and_then(Result::ok).map(|r| r.encrypted))
            .unwrap_or(false)
    });
    let visibility = Memo::new(move |_| {
        changed
            .get()
            .or_else(|| repo.get().and_then(Result::ok).map(|r| r.visibility))
            .unwrap_or_default()
    });

    // ★ **What publishing would hand over, from the list this page ALREADY
    // has.** The References panel below is drawn from the same `refs` read, so
    // naming the branches in the confirmation costs no extra request — which is
    // what makes naming them affordable enough to be unconditional. A read that
    // has not landed, or was refused, becomes `Unknown` rather than an empty
    // branch list: "you are publishing nothing" is the one sentence this panel
    // must never print by accident.
    let exposure = Memo::new(move |_| match refs.get() {
        Some(Ok(page)) => crate::visibility::Exposure::from_refs(
            page.items.iter().map(|t| t.ref_name.as_str()),
            page.truncated,
        ),
        _ => crate::visibility::Exposure::Unknown,
    });


    // What this person may do here: gunnar's per-repository answer.
    let rights = crate::pages::repo_rights();
    // The clone line: from the server's git base and the session's user, or
    // nothing at all while /auth/status has not answered / no base is set.
    let status = crate::app::session();
    let clone = move || {
        let s = status.get()?;
        fmt::clone_line(s.git_url.as_deref(), &s.user, &namespace.get(), &name.get())
    };
    view! {
        // The clone line is DATA (the URL this repository clones from), rendered
        // reactively beside the header instead: it appears once /auth/status has
        // answered and stays blank when no git base is configured.
        // Code, Compare, Search, Blame and Provenance are tabs now (owner,
        // 2026-10-02), not buttons in this header.
        <PageHeader title=move || path.get()/>
        <RepoTabs/>
        {move || crate::encryption::badge(encrypted.get()).map(|b| view! {
            <p class="repo-badges" id="repo-encrypted"><Badge tone=Tone::Plain label=b/></p>
            <crate::pages::cli::NeedsCli needs=crate::cli::Needs::Read/>
        })}
        {move || clone().map(|c| view! { <p class="clone-line"><code>{c}</code></p> })}

        <div class="stack">
            // ★ **The README, first.** It is what a person came to read, and on
            // this page everything else is metadata about the thing it
            // describes. It draws NOTHING when there is no README and nothing
            // when the call is refused (see `pages::readme`), so a repository
            // without one loses no vertical space and a visitor who cannot read
            // one gets no failure box above a history they can read fine.
            //
            // No `<Show>` gate: `…/readme` is on the server's anonymous
            // allow-list, so unlike `GcPanel` and `MirrorPanel` below there is
            // no request here a visitor is refused for being a visitor.
            // ★ **How to push, on an empty repository** (owner order
            // 2026-10-18): the real address, the commands, and the way to the
            // SSH key — or, for a Free account, the plain truth that it cannot
            // push and where room comes from. Drawn only for whoever may push
            // here (gunnar's per-repository answer).
            <Show when=move || !settings>
                {move || {
                    let empty = matches!(refs.get(), Some(Ok(ref page)) if page.items.is_empty());
                    empty.then(|| view! {
                        <crate::pages::Writes what="push to this repository" may=Signal::derive(move || rights.get().may_work())>
                            <HowToPush clone=Signal::derive(move || clone().unwrap_or_default()) branch=default_branch/>
                        </crate::pages::Writes>
                    })
                }}
            </Show>
            <Show when=move || !settings>
                <super::readme::ReadmePanel namespace=namespace name=name/>
            </Show>

            // ★ **The reading and the changing, in one panel.**
            //
            // `<Panel>` is OUTSIDE `resolved` here, which it was not before.
            // Two reasons, and neither is layout. The first: `VisibilityControl`
            // holds the armed/busy state of a request in flight, and rendering
            // it inside the closure would destroy and rebuild it every time the
            // repository read settles — including the settle its own PATCH
            // causes. The second: the control has to be able to say "the
            // console could not read this repository's references" (see
            // [`crate::visibility::Exposure::Unknown`]), and a control that only
            // exists once every read succeeded can never say it.
            <Show when=move || settings>
            <Panel title="Settings">
                {move || resolved(repo.get(), None, |repo: super::repos::Repo| {
                    // `visibility` and not `repo.visibility`: after a successful
                    // PATCH the freshest word on this is the one the server
                    // answered with, and the metric beside the button must not
                    // go on showing what the page loaded with.
                    let shown = visibility.get();
                    let tone = if shown == "public" { Tone::Warn } else { Tone::Plain };
                    view! {
                        <dl class="metrics">
                            <Metric label="Visibility" value=shown.clone()/>
                            <Metric label="Encrypted" value=if encrypted.get() { "yes".to_owned() } else { "no".to_owned() }/>
                            <Metric label="Hash" value=repo.hash_kind.clone()/>
                            <Metric
                                label="Default branch"
                                value=fmt::short_ref(&default_branch.get()).to_owned()
                            />
                            <Metric
                                label="Created"
                                value=when_text(repo.created_at_unix_ms, now_ms())
                            />
                        </dl>
                        {(!repo.description.is_empty()).then(|| view! {
                            <p class="truncation">{repo.description.clone()}</p>
                        })}
                        <p class="truncation">
                            <Badge tone=tone label=shown/>
                        </p>
                    }
                })}
                <DefaultBranchControl
                    namespace=namespace
                    name=name
                    current=default_branch
                    branches=branches
                    changed=branch_changed
                />
                <VisibilityControl
                    namespace=namespace
                    name=name
                    visibility=visibility
                    exposure=exposure
                    changed=changed
                />
                <EncryptionControl
                    namespace=namespace
                    name=name
                    encrypted=encrypted
                    changed=enc_changed
                />
            </Panel>
            </Show>

            // ★ **Maintenance belongs to whoever may change things**, and the
            // gate has to sit HERE rather than inside the panel.
            //
            // `GcPanel` mounts an `Effect` that fetches `GET …/gc` the instant
            // the component is created, and renders a live "Run gc" button with
            // no condition on it. Both were wrong for two different readers: a
            // logged-in Viewer got a button whose POST the server refuses with
            // `role_floor`, and an anonymous visitor — who can now reach this
            // page for a public repository — got a 401 rendered as a failure
            // box next to a button that could never work. `may_write()` covers
            // both, and covers an open console too, where it is true.
            //
            // `<Show>` and not `hidden`, a CSS class, or an early return inside
            // `GcPanel`: all three would suppress only the RENDER, and the
            // mount effect would still send the request. `Show` does not create
            // the component until the condition holds — and does create it when
            // the session arrives, which is always after first paint because
            // `/auth/status` is a round trip.
            // **Members** (2026-10-07): the account's admin gives a member
            // Read/Write here. Drawn only when gunnar answers this person the
            // list (the account's admin); nothing for anybody else.
            <Show when=move || settings>
                <MembersPanel namespace=namespace name=name/>
            </Show>

            <Show when=move || settings && session.get().is_some_and(|s| s.may_write())>
                <GcPanel namespace=namespace name=name/>
            </Show>

            // The README tab is the README and the clone line, nothing else
            // (owner, 2026-10-04): the references and where the repository
            // came from are the History tab's (`pages::graph`), which reads
            // the same refs for its chips anyway.

            // Branch protection, the ref-name policy and the gate list. Both
            // panels that used to sit here were `DisabledPanel`s making claims
            // that had stopped being true:
            //
            // * "The whole Policy service answers Unimplemented" — it does not.
            //   gunnar stores the policy document at `refs/gunnar/policy` and
            //   serves all six RPCs. Branch protection worked and the console
            //   was the thing telling every operator it did not exist.
            // * "Refs.Diff … the console can compute diffs itself once phase
            //   two lands" — phase two landed. `api::browse::diff` decodes the
            //   trees here and `/:ns/:name/diff` renders them, which is
            //   what the Compare button in the header above opens.
            //
            // Gated on being SIGNED IN, not on `may_write()`. The distinction
            // is deliberate: a Viewer is meant to read branch protection, the
            // ref-name policy and the gate list — knowing what a push will be
            // refused for is a reader's business — and `PolicyPanels` already
            // has its own `may_write` memo hiding the write half. An anonymous
            // visitor is a different case: `…/protection`, `…/gates` and
            // `…/ref-names` are not among the routes the guest identity may
            // read, so leaving the panels mounted would fire three requests
            // that 401 and paint three failure boxes under a repository the
            // visitor is otherwise browsing happily. The panels build their
            // `LocalResource`s on creation, so as with `GcPanel` above the gate
            // must keep the component from being created at all.
            // On the Settings tab since the final navigation (2026-10-02).
            <Show when=move || settings && session.get().is_some_and(|s| s.authenticated || s.open)>
                <PolicyPanels namespace=namespace name=name/>
            </Show>

            // ★ **The Danger zone, last** (owner spec 2026-10-28): drawn on the Settings tab for
            // everybody, and what it offers is the server's answer (`…/deletable`): the delete for
            // whoever gunnar lets delete, the refusal sentence for everybody else — never a
            // hidden section. gunnar decides again when the delete is signed as the person.
            <Show when=move || settings>
                <DeleteRepository namespace=namespace name=name/>
            </Show>
        </div>
    }
}

/// ★ **The Danger zone: delete the repository** (gunnar.rs 2026-10-28; the owner's spec the
/// same day, GitHub's shape). A SOFT `Repos.Delete`, signed as the person, through `DELETE
/// /api/repos/{ns}/{name}`. Whether it is offered, and for how many days the repository can be
/// restored, is the server's answer (`GET …/deletable`, [`crate::repo_delete::Standing`], the
/// VisibilityControl pattern). The first press opens the confirm step; the path is typed back
/// before its button arms ([`crate::repo_delete::may_send`]). On success the page goes to the
/// repository list, which says what was deleted and offers its Undo.
#[component]
fn DeleteRepository(namespace: Memo<String>, name: Memo<String>) -> impl IntoView {
    use crate::repo_delete::Standing;

    let session = crate::app::session();
    let navigate = leptos_router::hooks::use_navigate();
    let open = RwSignal::new(false);
    let typed = RwSignal::new(String::new());
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<(String, crate::api::Fail)>);
    let gone = RwSignal::new(None::<String>);
    let path = Memo::new(move |_| format!("{}/{}", namespace.get(), name.get()));
    // May THIS session delete it, and for how many days: the server's answer. Not asked for a
    // visitor: nobody to ask about, and the route is not on the anonymous allow-list.
    let authority = LocalResource::new(move || {
        let signed_in = session.get().is_some_and(|s| s.authenticated || s.open);
        let url = crate::repo_delete::authority_url(&namespace.get(), &name.get());
        async move {
            if !signed_in {
                return None;
            }
            Some(crate::api::get::<crate::repo_delete::Deletable>(&url).await)
        }
    });
    // An open confirm step belongs to ONE repository.
    Effect::new(move |_| {
        let _ = path.get();
        open.set(false);
        typed.set(String::new());
        failed.set(None);
    });
    // Gone: to the repository list, told what was deleted.
    Effect::new(move |_| {
        if let Some(to) = gone.get() {
            navigate(&to, Default::default());
        }
    });
    let send = move |_| {
        let at = path.get_untracked();
        if busy.get_untracked() || !crate::repo_delete::may_send(&typed.get_untracked(), &at) {
            return;
        }
        busy.set(true);
        failed.set(None);
        let (ns, repo) = (namespace.get_untracked(), name.get_untracked());
        leptos::task::spawn_local(async move {
            match crate::api::delete::<serde_json::Value>(&crate::repo_delete::url(&ns, &repo)).await {
                Ok(_) => gone.set(Some(crate::repo_delete::after(&ns, &repo))),
                Err(fail) => failed.set(Some((crate::repo_delete::refused(fail.status, &at), fail))),
            }
            busy.set(false);
        });
    };
    view! {
        <section class="panel danger-zone" id="danger-zone">
            <header>
                <h2>{crate::repo_delete::ZONE}</h2>
            </header>
            <div class="panel-body repo-delete">
                {move || {
                    let status = session.get();
                    let held = authority.get().flatten();
                    let standing = crate::repo_delete::Standing::of(
                        status.is_some(),
                        status.as_ref().is_some_and(|s| s.authenticated || s.open),
                        held.as_ref().map(|answer| answer.as_ref().map_err(|fail| fail.message.as_str())),
                    );
                    match standing {
                        // `/auth/status` or the server's answer has not come: draw nothing yet.
                        Standing::Waiting => ().into_any(),
                        Standing::Refused(why) => view! {
                            <div class="zone-row">
                                <div>
                                    <strong>{crate::repo_delete::BUTTON}</strong>
                                    <p class="hint" id="repo-delete-refused">{why}</p>
                                </div>
                            </div>
                        }
                        .into_any(),
                        Standing::Allowed(days) => view! {
                            <div class="zone-row">
                                <div>
                                    <strong>{crate::repo_delete::BUTTON}</strong>
                                    <p class="hint">{crate::repo_delete::summary(days)}</p>
                                </div>
                                <button
                                    id="repo-delete-open"
                                    class="btn danger"
                                    aria-expanded=move || if open.get() { "true" } else { "false" }
                                    on:click=move |_| open.update(|o| *o = !*o)
                                >
                                    {crate::repo_delete::BUTTON}
                                </button>
                            </div>
                            <Show when=move || open.get()>
                                <div class="confirm" role="alert">
                                    <p id="repo-delete-consequence">{move || crate::repo_delete::consequence(&path.get(), days)}</p>
                                    <p>
                                        <label for="repo-delete-confirm">"To confirm, type " <code>{move || path.get()}</code></label>
                                    </p>
                                    <p>
                                        <input
                                            id="repo-delete-confirm"
                                            type="text"
                                            autocomplete="off"
                                            spellcheck="false"
                                            prop:value=move || typed.get()
                                            on:input=move |ev| typed.set(event_target_value(&ev))
                                        />
                                    </p>
                                    <p>
                                        <button
                                            id="repo-delete"
                                            class=move || if busy.get() { "btn danger busy" } else { "btn danger" }
                                            disabled=move || busy.get() || !crate::repo_delete::may_send(&typed.get(), &path.get())
                                            on:click=send
                                        >
                                            {crate::repo_delete::BUTTON}
                                        </button>
                                        " "
                                        <button
                                            id="repo-delete-cancel"
                                            class="btn quiet"
                                            disabled=move || busy.get()
                                            on:click=move |_| { open.set(false); typed.set(String::new()); }
                                        >
                                            "Cancel"
                                        </button>
                                    </p>
                                </div>
                            </Show>
                        }
                        .into_any(),
                    }
                }}
                {move || failed.get().map(|(line, fail)| view! { <p class="refused" id="repo-delete-failed">{line}</p> <FailBox fail=fail/> })}
            </div>
        </section>
    }
}

/// ★ **How to push to an empty repository** (owner order 2026-10-18). The
/// words are `crate::first_push`'s; whether this account is Free or has no key
/// yet is the server's queue (`/api/account/actions`).
#[component]
fn HowToPush(clone: Signal<String>, branch: Memo<String>) -> impl IntoView {
    let queue = LocalResource::new(|| async {
        crate::api::get::<crate::actions::Actions>("/api/account/actions").await.unwrap_or_default()
    });
    let item = move |id: &'static str| queue.get().and_then(|q| q.items.into_iter().find(|i| i.id == id));
    let has = move |id: &'static str| item(id).is_some();
    view! {
        <Panel title="How to push">
            <div class="panel-body" id="how-to-push">
                {move || if let Some(free) = item(crate::first_push::FREE_ITEM) {
                    view! {
                        <p>{crate::first_push::FREE_CANNOT_PUSH}</p>
                        <a class="btn" href=free.href>{free.cta}</a>
                    }.into_any()
                } else {
                    let url = clone.get();
                    let commands = (!url.is_empty()).then(|| crate::first_push::push_commands(&url, &branch.get()));
                    view! {
                        <p>"This repository is empty. From a repository on your computer:"</p>
                        {commands.map(|[add, push]| view! {
                            <pre class="command-line">{add}"\n"{push}</pre>
                        })}
                        <p>
                            {if has(crate::first_push::NO_KEY_ITEM) {
                                "Git signs in with an SSH key. Add yours first: "
                            } else {
                                "Git signs in with the SSH key on your account: "
                            }}
                            <a href=crate::first_push::KEYS_HREF>"Your SSH keys"</a>
                        </p>
                    }.into_any()
                }}
            </div>
        </Panel>
    }
}

/// ★ **Changing the default branch** (owner, 2026-10-14: Settings showed
/// "Default branch ny_dok" with no control). A select of the repository's
/// branches and Save, which sends `PATCH {"default_branch":"refs/heads/<b>"}`
/// signed as the person; gunnar decides (`Repos.SetDefaultBranch`, Admin on
/// the repository) and the answer is said in plain words
/// ([`crate::default_branch`]). Drawn for a signed-in session only: the PATCH
/// is not on the anonymous allow-list.
#[component]
fn DefaultBranchControl(
    namespace: Memo<String>,
    name: Memo<String>,
    current: Memo<String>,
    branches: Memo<Vec<String>>,
    changed: RwSignal<Option<String>>,
) -> impl IntoView {
    use crate::default_branch as db;

    let session = crate::app::session();
    let chosen = RwSignal::new(String::new());
    let busy = RwSignal::new(false);
    // The last answer: `Ok(line)` or `Err((line, gunnar's own words))`.
    let said = RwSignal::new(None::<Result<String, (String, String)>>);
    // What is selected follows what is set, until the person picks another.
    Effect::new(move |_| {
        chosen.set(fmt::short_ref(&current.get()).to_owned());
    });
    Effect::new(move |_| {
        let _ = (namespace.get(), name.get());
        said.set(None);
    });

    let save = move |_| {
        let branch = chosen.get_untracked();
        if busy.get_untracked() || !db::changes(&current.get_untracked(), &branch) {
            return;
        }
        busy.set(true);
        said.set(None);
        let path = format!("{}/{}", namespace.get_untracked(), name.get_untracked());
        let url = format!("/api/repos/{}/{}", segment(&namespace.get_untracked()), segment(&name.get_untracked()));
        leptos::task::spawn_local(async move {
            let body = serde_json::json!({ "default_branch": db::full_ref(&branch) });
            match crate::api::patch::<_, super::repos::Repo>(&url, &body).await {
                Ok(fresh) => {
                    said.set(Some(Ok(db::done(&fresh.default_branch))));
                    changed.set(Some(fresh.default_branch));
                }
                Err(fail) => said.set(Some(Err((db::refused(fail.status, &fail.code, &path), fail.message)))),
            }
            busy.set(false);
        });
    };

    view! {
        <Show when=move || session.get().is_some_and(|s| s.authenticated || s.open)>
            <div class="panel-body default-branch">
                {move || {
                    let names = branches.get();
                    if names.is_empty() {
                        return view! {
                            <p class="hint" id="branch-none">"This repository has no branches yet, so there is none to choose."</p>
                        }
                        .into_any();
                    }
                    let set = fmt::short_ref(&current.get()).to_owned();
                    view! {
                        <p>
                            <label for="branch-select">"Default branch "</label>
                            <select
                                id="branch-select"
                                disabled=move || busy.get()
                                prop:value=move || chosen.get()
                                on:change=move |ev| chosen.set(event_target_value(&ev))
                            >
                                {names.into_iter().map(|b| {
                                    let selected = b == set;
                                    view! { <option value=b.clone() selected=selected>{b.clone()}</option> }
                                }).collect_view()}
                            </select>
                            " "
                            <button
                                id="branch-save"
                                class=move || if busy.get() { "btn busy" } else { "btn" }
                                disabled=move || busy.get() || !db::changes(&current.get(), &chosen.get())
                                on:click=save
                            >
                                "Save"
                            </button>
                        </p>
                    }
                    .into_any()
                }}
                {move || said.get().map(|answer| match answer {
                    Ok(line) => view! { <p class="hint" id="branch-said" role="status">{line}</p> }.into_any(),
                    Err((line, words)) => view! {
                        <div class="failure" id="branch-refused" role="alert">
                            <p>{line}</p>
                            {(!words.is_empty()).then(|| view! { <p class="detail">{words}</p> })}
                        </div>
                    }
                    .into_any(),
                })}
            </div>
        </Show>
    }
}

/// ★ **Changing what this repository is readable by.**
///
/// Until this existed the console could only ever READ a visibility: the word
/// was a metric and a badge, and `gunnar control repo set-visibility` in a
/// terminal was the only way to move it. The whole reason it took this long is
/// in [`crate::visibility`], and it is worth repeating where the button is:
///
/// > **`public` publishes every reference, not the default branch.**
///
/// So this is two controls wearing one heading. Making a repository private is
/// one press. Making it public is a press that only ARMS a confirmation, and
/// the confirmation names the branches — by count and, up to a dozen, by name —
/// that anyone with the address would be able to clone the moment it is
/// pressed again. `alice/gunnar` carries three `sec/*` branches; the
/// difference between a toggle and this panel is whether a mis-click publishes
/// a security review of this forge.
///
/// **No request on mount**, unlike [`GcPanel`], so this is NOT behind a
/// `<Show>`: it renders its own refusal for a viewer instead of vanishing.
/// Vanishing would have been the easy gate and the wrong one — an operator's
/// control that is simply absent for a viewer is indistinguishable from a
/// console that has not shipped it, which is the confusion that made Alice
/// go looking in the CLI in the first place.
///
/// **Whose it is** is the server's to say (`GET …/visibility`, see
/// [`crate::visibility::Authority`]): a repository's visibility is its ACCOUNT
/// admin's, not whoever may write — so the control is drawn for the account
/// admin only, and everybody else is told whose decision it is. The PATCH re-asks before it signs. There is no
/// step-up to reflect: `require_step_up` guards three routes in
/// `auth/routes.rs` and none of them is this one.
#[component]
fn VisibilityControl(
    namespace: Memo<String>,
    name: Memo<String>,
    visibility: Memo<String>,
    exposure: Memo<crate::visibility::Exposure>,
    changed: RwSignal<Option<String>>,
) -> impl IntoView {
    use crate::visibility::{press, Press, Standing, Turn};

    let session = crate::app::session();
    let armed = RwSignal::new(false);
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<crate::api::Fail>);

    let url = move || {
        format!(
            "/api/repos/{}/{}",
            segment(&namespace.get()),
            segment(&name.get())
        )
    };

    // ★ **May THIS session change it?** Asked of the server, which reads
    // gunnar's grant table: a repository's visibility is its ACCOUNT admin's
    // (gunnar's default for every account — the owner's "GitHub Enterprise
    // makes it impossible for a non-admin to make repos public"). Not asked for
    // a visitor: there is nobody to ask about, and the route is not on the
    // anonymous allow-list.
    let authority = LocalResource::new(move || {
        let signed_in = session.get().is_some_and(|s| s.authenticated || s.open);
        let url = format!(
            "/api/repos/{}/{}/visibility",
            segment(&namespace.get()),
            segment(&name.get())
        );
        async move {
            if !signed_in {
                return None;
            }
            Some(crate::api::get::<crate::visibility::Authority>(&url).await)
        }
    });

    // An armed confirmation belongs to ONE repository. Navigating from a
    // private repository with the confirmation open to another one must not
    // leave the next page's first press being the one that publishes it.
    Effect::new(move |_| {
        let _ = (namespace.get(), name.get());
        armed.set(false);
        failed.set(None);
    });

    let send = move |turn: Turn| {
        busy.set(true);
        failed.set(None);
        let url = url();
        leptos::task::spawn_local(async move {
            let body = serde_json::json!({ "visibility": turn.target() });
            match crate::api::patch::<_, super::repos::Repo>(&url, &body).await {
                Ok(fresh) => {
                    changed.set(Some(fresh.visibility));
                    armed.set(false);
                }
                // The server's own words, never reworded: `FailBox` prints
                // gunnar's status code and message verbatim under the console's
                // one-line summary. A generic "something went wrong" here would
                // hide the one refusal an operator needs to read.
                Err(fail) => failed.set(Some(fail)),
            }
            busy.set(false);
        });
    };

    let act = move |_| {
        let Some(turn) = Turn::from_current(&visibility.get_untracked()) else {
            return;
        };
        match press(turn, armed.get_untracked(), busy.get_untracked()) {
            Press::Arm => armed.set(true),
            Press::Send => send(turn),
            Press::Ignore => {}
        }
    };

    view! {
        <div class="panel-body">
            {move || {
                let status = session.get();
                let held = authority.get().flatten();
                let standing = Standing::of(
                    status.is_some(),
                    status.as_ref().is_some_and(|s| s.authenticated || s.open),
                    held.as_ref()
                        .map(|answer| answer.as_ref().map_err(|fail| fail.message.as_str())),
                );
                match standing {
                    // /auth/status or the authority read has not answered.
                    // Draw nothing rather than flash either answer — the rule
                    // `app::session` states.
                    Standing::Waiting => ().into_any(),
                    Standing::Refused(why) => view! {
                        <p class="hint" id="vis-refused">{why}</p>
                    }
                    .into_any(),
                    Standing::Allowed => {
                        let current = visibility.get();
                        let Some(turn) = Turn::from_current(&current) else {
                            // gunnar answers two visibilities and this is a
                            // third. Say so rather than offer a button whose
                            // effect this build cannot predict.
                            return view! {
                                <Hint
                                    line=format!("Gunnar reports this repository as \"{current}\", neither private nor public.")
                                    more="This console does not change a visibility it does not recognise."
                                />
                            }
                            .into_any();
                        };
                        let exposure = exposure.get();
                        view! {
                            <div class="visibility">
                                {(turn == Turn::Retract).then(|| view! {
                                    <p class="hint">
                                        {crate::visibility::retraction_note()}
                                    </p>
                                })}
                                // ★ The armed confirmation. It is the ONLY
                                // place the publishing button exists — the
                                // first press does not publish, it opens this.
                                {(turn == Turn::Publish && armed.get()).then(|| {
                                    let label = exposure.confirm_label();
                                    view! {
                                        <div class="consequence" role="alert">
                                            <p>{exposure.warning()}</p>
                                            <p class="hint">
                                                "It cannot be taken back for anyone who clones it \
                                                 in the meantime."
                                            </p>
                                        </div>
                                        <p>
                                            <button
                                                id="vis-confirm"
                                                class=move || if busy.get() {
                                                    "btn danger busy"
                                                } else {
                                                    "btn danger"
                                                }
                                                disabled=move || busy.get()
                                                on:click=act
                                            >
                                                {label}
                                            </button>
                                            " "
                                            <button
                                                id="vis-cancel"
                                                class="btn quiet"
                                                disabled=move || busy.get()
                                                on:click=move |_| armed.set(false)
                                            >
                                                "Keep it private"
                                            </button>
                                        </p>
                                    }
                                })}
                                // The control's own button. For the publishing
                                // direction its ellipsis is a promise: it opens
                                // the block above and sends nothing.
                                {(!(turn == Turn::Publish && armed.get())).then(|| {
                                    let label = match turn {
                                        Turn::Publish => "Make public…",
                                        Turn::Retract => "Make private",
                                    };
                                    view! {
                                        <p>
                                            <button
                                                id="vis-change"
                                                class=move || if busy.get() {
                                                    "btn busy"
                                                } else {
                                                    "btn"
                                                }
                                                disabled=move || busy.get()
                                                on:click=act
                                            >
                                                {label}
                                            </button>
                                        </p>
                                    }
                                })}
                            </div>
                        }
                        .into_any()
                    }
                }
            }}
            {move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
        </div>
    }
}

/// ★ **Turning encryption on or off for an existing repository** (round 5.3.1; README §5f:
/// the account admin turns it on at the console). Gated EXACTLY like visibility — the same
/// `GET …/visibility` answer (gunnar's `Access.Check{namespace, ADMIN}`, asked as this person:
/// the account's admin, appointed or not) — and the PATCH `{"encrypted": …}` is signed as the
/// person; gunnar decides. Both directions open a confirmation first
/// ([`crate::encryption::Flip`]). A session that may not change it is shown nothing here: the
/// visibility control above already says whose decision it is.
#[component]
fn EncryptionControl(
    namespace: Memo<String>,
    name: Memo<String>,
    encrypted: Memo<bool>,
    changed: RwSignal<Option<bool>>,
) -> impl IntoView {
    use crate::encryption::Flip;
    use crate::visibility::Standing;

    let session = crate::app::session();
    let armed = RwSignal::new(false);
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<crate::api::Fail>);
    let authority = LocalResource::new(move || {
        let signed_in = session.get().is_some_and(|s| s.authenticated || s.open);
        let url = format!("/api/repos/{}/{}/visibility", segment(&namespace.get()), segment(&name.get()));
        async move {
            if !signed_in {
                return None;
            }
            Some(crate::api::get::<crate::visibility::Authority>(&url).await)
        }
    });
    Effect::new(move |_| {
        let _ = (namespace.get(), name.get());
        armed.set(false);
        failed.set(None);
    });
    let send = move |flip: Flip| {
        busy.set(true);
        failed.set(None);
        let url = format!("/api/repos/{}/{}", segment(&namespace.get_untracked()), segment(&name.get_untracked()));
        leptos::task::spawn_local(async move {
            let body = serde_json::json!({ "encrypted": flip.target() });
            match crate::api::patch::<_, super::repos::Repo>(&url, &body).await {
                Ok(fresh) => {
                    changed.set(Some(fresh.encrypted));
                    armed.set(false);
                }
                Err(fail) => failed.set(Some(fail)),
            }
            busy.set(false);
        });
    };

    view! {
        <div class="panel-body encryption">
            {move || {
                let status = session.get();
                let held = authority.get().flatten();
                let standing = Standing::of(
                    status.is_some(),
                    status.as_ref().is_some_and(|s| s.authenticated || s.open),
                    held.as_ref().map(|answer| answer.as_ref().map_err(|fail| fail.message.as_str())),
                );
                if standing != Standing::Allowed {
                    return ().into_any();
                }
                let flip = Flip::from_current(encrypted.get());
                let path = format!("{}/{}", namespace.get(), name.get());
                if armed.get() {
                    view! {
                        <div class="consequence" role="alert">
                            <p id="enc-consequence">{flip.consequence(&path)}</p>
                        </div>
                        {(flip == Flip::On).then(|| view! { <crate::pages::cli::NeedsCli needs=crate::cli::Needs::Seal/> })}
                        <p>
                            <button
                                id="enc-confirm"
                                class=move || if busy.get() { "btn busy" } else { "btn" }
                                disabled=move || busy.get()
                                on:click=move |_| if !busy.get_untracked() { send(flip) }
                            >
                                {flip.confirm_label()}
                            </button>
                            " "
                            <button id="enc-cancel" class="btn quiet" disabled=move || busy.get() on:click=move |_| armed.set(false)>
                                "Cancel"
                            </button>
                        </p>
                    }
                    .into_any()
                } else {
                    view! {
                        <p>
                            <button id="enc-change" class="btn" disabled=move || busy.get() on:click=move |_| armed.set(true)>
                                {flip.label()}
                            </button>
                            " "
                            <crate::ui::InfoTip markup=crate::encryption::INFO/>
                        </p>
                    }
                    .into_any()
                }
            }}
            {move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
        </div>
    }
}

/// One maintenance run, as `/api/repos/{ns}/{name}/gc` reports it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct GcJob {
    pub job_id: String,
    pub path: String,
    /// `queued` | `running` | `done` | `failed` | `unknown`.
    pub state: String,
    pub queue_position: u32,
    /// Only ever `Some` when `state` is `done` — the BFF withholds it otherwise
    /// so a running job cannot render as "freed 0 bytes".
    pub bytes_reclaimed: Option<u64>,
    pub error: Option<String>,
    pub enqueued_at_unix_ms: Option<i64>,
    pub finished_at_unix_ms: Option<i64>,
}

impl GcJob {
    /// Whether this job is still moving, and therefore whether to keep polling.
    /// `unknown` counts as settled: a state this build does not recognise must
    /// not make the page poll for ever.
    fn in_flight(&self) -> bool {
        self.state == "queued" || self.state == "running"
    }
}

/// ★ **The gc button, and where the gc got to.**
///
/// Pressing it does not run a gc — it **enqueues** one. The server runs one gc
/// at a time across the whole box and queues the rest, because a gc unlinks the
/// exploded read cache (hundreds of thousands of files, pure I/O) and ten
/// owners pressing at once is a self-inflicted outage. So the button's job is
/// to say where in the line the repository is, and then what it freed.
///
/// **The label says "reclaim storage" and not "drop a cache" deliberately**
/// (gunnar-ui `bd0b244`): `admin::gc` calls `Engine::maintain`, which is THREE
/// steps — `seal` -> `store.gc()` -> `drop_explode_cache`. The middle one is
/// real compaction and can genuinely reduce `pack_bytes`, which is the billed
/// meter. A button promising it only touches a cache would be lying about the
/// one leg that moves a number the customer pays for.
///
/// **Pressing twice is one gc.** The server returns the job already queued or
/// running rather than enqueuing a second, so the impatient double-click is
/// harmless and needs no guard here beyond not firing two requests at once.
///
/// The panel loads the CURRENT job on mount rather than starting empty: a gc
/// enqueued from the CLI, or by another operator's console, is this
/// repository's business and should be visible without pressing anything.
#[component]
fn GcPanel(namespace: Memo<String>, name: Memo<String>) -> impl IntoView {
    let job = RwSignal::new(None::<GcJob>);
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<crate::api::Fail>);
    let polling = RwSignal::new(false);

    let url = move || {
        format!(
            "/api/repos/{}/{}/gc",
            segment(&namespace.get()),
            segment(&name.get())
        )
    };

    // One poller at a time. Without the guard, pressing the button while a poll
    // is already running starts a second loop and the two race to write `job`.
    let start_polling = move |url: String| {
        if polling.get_untracked() {
            return;
        }
        polling.set(true);
        leptos::task::spawn_local(async move {
            loop {
                gloo_timers::future::TimeoutFuture::new(2_000).await;
                match crate::api::get::<Option<GcJob>>(&url).await {
                    Ok(latest) => {
                        let keep = latest.as_ref().is_some_and(GcJob::in_flight);
                        job.set(latest);
                        if !keep {
                            break;
                        }
                    }
                    // Stop on the first refusal rather than hammering a server
                    // that is already saying no.
                    Err(fail) => {
                        failed.set(Some(fail));
                        break;
                    }
                }
            }
            polling.set(false);
        });
    };

    // Reads `namespace`/`name`, so switching repository re-runs this and the
    // panel shows the NEW repository's job instead of the old one's.
    Effect::new(move |_| {
        let url = url();
        job.set(None);
        failed.set(None);
        leptos::task::spawn_local(async move {
            if let Ok(current) = crate::api::get::<Option<GcJob>>(&url).await {
                let keep = current.as_ref().is_some_and(GcJob::in_flight);
                job.set(current);
                if keep {
                    start_polling(url);
                }
            }
        });
    });

    let click = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        let url = url();
        leptos::task::spawn_local(async move {
            match crate::api::post_empty::<GcJob>(&url).await {
                Ok(enqueued) => {
                    let keep = enqueued.in_flight();
                    job.set(Some(enqueued));
                    if keep {
                        start_polling(url);
                    }
                }
                Err(fail) => failed.set(Some(fail)),
            }
            busy.set(false);
        });
    };

    let label = move || {
        if busy.get() {
            return "Queueing…".to_owned();
        }
        match job.get() {
            Some(j) if j.state == "queued" => "Queued".to_owned(),
            Some(j) if j.state == "running" => "Running…".to_owned(),
            _ => "Run gc".to_owned(),
        }
    };
    // Disabled only while the request is in flight or the job is. A finished or
    // failed job must leave the button pressable — that is how you retry.
    let disabled = move || busy.get() || job.get().is_some_and(|j| j.in_flight());

    view! {
        <Panel title="Maintenance">
            <Hint
                line="Reclaims storage: seals the repository, compacts it, then drops the read cache."
                more="The compaction leg is real data reduction and can genuinely lower this \
                      repository's stored size — it is not merely a cache drop. Nothing it removes \
                      is unrecoverable. The server runs one gc at a time and queues the rest."
            />
            <p class="truncation">
                <button class="btn" disabled=disabled on:click=click>{label}</button>
            </p>
            {move || job.get().map(|j| {
                let tone = match j.state.as_str() {
                    "done" => Tone::Plain,
                    "failed" => Tone::Warn,
                    _ => Tone::Plain,
                };
                view! {
                    <dl class="metrics">
                        <Metric label="State" value=j.state.clone()/>
                        {(j.state == "queued").then(|| view! {
                            <Metric
                                label="Ahead in the queue"
                                value=j.queue_position.to_string()
                            />
                        })}
                        {j.bytes_reclaimed.map(|b| view! {
                            <Metric label="Reclaimed" value=fmt::bytes(b)/>
                        })}
                        <Metric
                            label="Asked"
                            value=when_text(j.enqueued_at_unix_ms, now_ms())
                        />
                        {j.finished_at_unix_ms.map(|_| view! {
                            <Metric
                                label="Finished"
                                value=when_text(j.finished_at_unix_ms, now_ms())
                            />
                        })}
                    </dl>
                    <p class="truncation"><Badge tone=tone label=j.state.clone()/></p>
                    {j.error.clone().map(|e| view! { <p class="truncation">{e}</p> })}
                }
            })}
            {move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
        </Panel>
    }
}