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(¤t.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(¤t.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(¤t.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(¤t.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(¤t) 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>
}
}