crates/gunnar-ui-web/src/app.rs
//! The console's shell: navigation, session, and the routes.
use leptos::prelude::*;
use leptos_router::components::{Route, Router, Routes, A};
use leptos_router::path;
use serde::Deserialize;
use crate::auth::AuthMode;
use crate::pages;
use crate::theme::Theme;
use crate::ui::{Badge, Tone};
/// Who is logged in, shared by every page through context.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Status {
pub authenticated: bool,
pub user: String,
pub role: Option<String>,
pub passkey_enabled: bool,
/// True when nothing guards the console at all.
pub open: bool,
/// `GUNNAR_UI_GIT_URL` on the server: the base git clones from
/// (`ssh://host:port`), `None` when the operator set none.
#[serde(default)]
pub git_url: Option<String>,
/// **The administrator's switches this console's build carries** (`public-site`,
/// `mcp`), as `/auth/status` tells an administrator (GUI-PUBSW); empty for anybody
/// else, and from a console that carries none or predates the field.
#[serde(default)]
pub switches: Vec<String>,
/// **The ONE sign-up page's two doors**, as `/auth/status` of a build that
/// sells names them (`/signup?plan=10gb`: a signed-in person's account
/// grows there; `/signup?plan=private`: a private Gunnar). Absent where
/// nothing is sold; the console holds no address of its own.
#[serde(default)]
pub buy_url: Option<String>,
#[serde(default)]
pub private_url: Option<String>,
/// **Where a visitor signs up**, where this console signs people up (`/signup`);
/// absent elsewhere. "Sign in to …" offers it beside the login (GUN-GUEST).
#[serde(default)]
pub signup_url: Option<String>,
/// **The account this person acts in, and as what** — gunnar's answer
/// (`Access.WhoAmI`, 2026-10-07: a customer is an ACCOUNT that pays; its
/// members use its space and never buy). `account_role` is `admin`,
/// `individual` or `employee`; absent with no account.
#[serde(default)]
pub account: Option<String>,
#[serde(default)]
pub account_role: Option<String>,
/// The account admin's name: whom a member asks for more room.
#[serde(default)]
pub account_admin: Option<String>,
/// The server's one sentence for a member, where the shop would be.
#[serde(default)]
pub account_line: Option<String>,
/// The server's word on whether this session reaches the shop at all (read where the
/// way to more room is drawn: `mirror::room_way`).
#[serde(default)]
pub may_buy: Option<bool>,
/// **Whether THIS console's shop is its own** (one shop, 2026-10-07):
/// `false` on a SOLD (provided) Gunnar, whose room and instances are its
/// provider's to give; absent from an older server, which sells.
#[serde(default)]
pub shop_here: Option<bool>,
/// On a provided Gunnar, the server's one line where the shop would be.
#[serde(default)]
pub shop_line: Option<String>,
/// **The site this Gunnar was bought at** (`https://<apex>`; the server's
/// `GUNNAR_UI_PROVIDER`), on a sold Gunnar; absent otherwise.
#[serde(default)]
pub provider: Option<String>,
/// **This Gunnar's name at that site** (`GUNNAR_UI_INSTANCE`).
#[serde(default)]
pub instance: Option<String>,
/// **gunnar is restarting** (GUN-SESSION-RESTART): the browser holds a session the
/// server could not ask gunnar about yet. Not signed out: the console waits and asks
/// again ([`crate::auth::ask_status_again_in_ms`]). Absent from an older server.
#[serde(default)]
pub restarting: bool,
}
impl Status {
/// True when this session may change things.
pub fn may_write(&self) -> bool {
self.open || matches!(self.role.as_deref(), Some("operator" | "admin"))
}
/// **A member of an account** (gunnar's word): uses the account's space,
/// is shown no shop, and reads the account's room.
pub fn is_member(&self) -> bool {
crate::nav::is_member_role(self.account_role.as_deref())
}
/// **The account's Audit log is offered** — its admin, appointed or not, and the
/// operator; never a plain member (gunnar admits the account's admins alone).
pub fn shows_audit_log(&self) -> bool {
crate::nav::shows_audit_log(self.account_role.as_deref())
}
/// **True when THIS console's shop is its own** (the server's
/// `shop_here`). A provided Gunnar draws no shop: the one line where it is.
pub fn sells_here(&self) -> bool {
crate::offers::shop_here(self.shop_here)
}
/// Where "Grow this instance" leads on a sold Gunnar: its shop's sign-up
/// page, growing it ([`crate::offers::grow_this_instance`]).
pub fn grow_this_instance(&self) -> Option<String> {
crate::offers::grow_this_instance(self.sells_here(), self.provider.as_deref(), self.instance.as_deref())
}
/// True when this session may manage identity and passkeys.
pub fn is_admin(&self) -> bool {
self.open || self.role.as_deref() == Some("admin")
}
pub fn mode(&self) -> AuthMode {
if self.passkey_enabled {
AuthMode::Passkey
} else {
AuthMode::Open
}
}
}
/// The session, once `/auth/status` has answered.
#[derive(Clone, Copy)]
pub struct SessionContext(pub RwSignal<Option<Status>>);
/// **A bump every time something that might change the queue has changed.**
///
/// The Åtgärder bar polls slowly on purpose — nothing in that queue moves in
/// less than days — but a person who has just FIXED one of its items should not
/// sit under a banner about it for another five minutes. So the bar re-reads on
/// this signal as well as on its timer, and anything that resolves an item
/// bumps it. Measured 2026-09-18: the owner vouched for the console's key, the
/// panel vanished, and the bar went on saying the opposite.
#[derive(Clone, Copy)]
pub struct ActionsBeat(pub RwSignal<u32>);
/// Tell the Åtgärder bar to look again. Silent when there is no bar — a page
/// rendered outside the shell in a test has nothing to nudge.
pub fn nudge_actions() {
if let Some(ActionsBeat(beat)) = use_context::<ActionsBeat>() {
beat.update(|n| *n += 1);
}
}
/// Read the session from context.
///
/// `None` until the first answer arrives. Pages must not guess in the meantime:
/// rendering write affordances for a viewer, or hiding them from an admin, are
/// both worse than waiting one round trip.
pub fn session() -> RwSignal<Option<Status>> {
use_context::<SessionContext>()
.expect("the shell provides the session")
.0
}
/// **The one line the shell carries when this account has something to do.**
///
/// Polled rather than pushed, and slowly: nothing in this queue is urgent to
/// the minute — a subscription period and a disk both move over days — and a
/// console that asked every few seconds would spend a tenant's appliance budget
/// on a question whose answer almost never changes. It refreshes on navigation
/// (the component is rebuilt) and otherwise every five minutes.
///
/// A failed read draws NOTHING. This is the shell: an error here would appear
/// on every page at once, about a queue, which is the least important thing on
/// any of them.
#[component]
fn ActionBar() -> impl IntoView {
use crate::actions::{bar, Actions, Bar};
// The beat is the ONE thing that makes this re-read: the timer below bumps
// it every five minutes, and [`nudge_actions`] bumps it the moment somebody
// resolves an item. One path, so a fix and a tick cannot behave differently.
let beat = use_context::<ActionsBeat>().map(|b| b.0).unwrap_or_else(|| RwSignal::new(0));
let answer = LocalResource::new(move || {
beat.track();
async { crate::api::get::<Actions>("/api/account/actions").await }
});
Effect::new(move |_| {
leptos::task::spawn_local(async move {
loop {
gloo_timers::future::TimeoutFuture::new(5 * 60 * 1000).await;
beat.update(|n| *n += 1);
}
});
});
move || match answer.get().and_then(|a| a.ok()).as_ref().map(bar) {
Some(Bar::Show { line, urgent, href }) => view! {
<a
class=if urgent { "actions-bar urgent" } else { "actions-bar" }
href=href
role="status"
>
<span class="actions-dot" aria-hidden="true"></span>
<span class="actions-line" title=line.clone()>{line.clone()}</span>
</a>
}
.into_any(),
_ => ().into_any(),
}
}
/// The console.
#[component]
pub fn App() -> impl IntoView {
let status = RwSignal::new(None::<Status>);
provide_context(SessionContext(status));
provide_context(ActionsBeat(RwSignal::new(0u32)));
Effect::new(move |_| {
leptos::task::spawn_local(async move {
// GUN-SESSION-RESTART: while gunnar restarts the server says `restarting`; the
// console keeps asking until it knows who this session is, instead of drawing
// a signed-out console.
loop {
let Ok(fresh) = crate::api::get::<Status>("/auth/status").await else {
break;
};
let again = crate::auth::ask_status_again_in_ms(fresh.restarting);
status.set(Some(fresh));
match again {
Some(ms) => gloo_timers::future::TimeoutFuture::new(ms).await,
None => break,
}
}
});
});
// ★ **Whether this browser has a session at all**, which is a coarser
// question than `may_write()` or `is_admin()` and the only one the shell
// asks. Anonymous browsing of public repositories is a real, persistent
// state now — not a half-loaded console — and everything outside `/repos`
// and `/{ns}/{name}/**` is refused in it, documents included.
//
// `open` counts as signed in: with nothing guarding the console every route
// answers, and hiding half the navigation there would be a lie about a
// console that has no lock on it.
//
// `is_some_and` — so an unanswered `/auth/status` counts as NOT signed in —
// follows the rule already written on [`session`]: the console waits one
// round trip rather than guessing. Guessing the other way would flash the
// operator's navigation at a visitor who may not have it, and every entry
// in that flash is a link that ends in a 401.
let signed_in = Memo::new(move |_| status.get().is_some_and(|s| s.authenticated || s.open));
// The one navigation entry that is an admin's. Same rule as `signed_in`:
// an unanswered `/auth/status` counts as NOT, so the console waits a round
// trip rather than flashing a link that would end in a 403.
let is_admin = Memo::new(move |_| status.get().is_some_and(|s| s.is_admin()));
// The Administration group's own floor. Wider than `is_admin` on purpose:
// `/admin` carries the twin's heartbeat, which is an operator's read, and
// an admin-only group would have hidden the one page that shows it from the
// role that is allowed to see it.
let may_write = Memo::new(move |_| status.get().is_some_and(|s| s.may_write()));
// The badger leads somewhere the reader may actually go. `/` is the
// Overview, which is refused without a session, so for a visitor the
// wordmark points at the catalogue instead — clicking the logo must never
// be the thing that ejects someone from anonymous browsing.
let reader = move || crate::nav::Reader {
signed_in: signed_in.get(),
may_write: may_write.get(),
is_admin: is_admin.get(),
// gunnar's word (`account_role: employee`): a member's You ▾ has no Clans.
member: status.get().is_some_and(|s| s.is_member()),
};
// "You ▾" carries the reader's own name, so who is signed in is still on
// the bar (it was the Identity badge's job).
let you_label = move || {
status.get().map(|s| if s.user.is_empty() { "You".to_owned() } else { s.user.clone() }).unwrap_or_else(|| "You".to_owned())
};
view! {
<Router>
<TitleKeeper/>
<div class="app">
<div class="workspace">
// ★ **The top bar IS the navigation** (owner's final
// navigation, 2026-10-02): `[gunnar] Repositories
// (search…) You ▾ Administration ▾`, from
// `crate::nav::menu`, where a test states it. The sidebar is
// gone. Nothing here is a security boundary: the server
// refuses every route whatever is drawn.
<header class="topbar">
<a class="skip" href="#main">"Skip to content"</a>
<a class="brand-top" href="/repos" aria-label="Gunnar — Repositories">
<span class="brand-mark brand-mark-small" aria-hidden="true"></span>
<span>"Gunnar"</span>
</a>
{move || crate::nav::menu(reader()).main.into_iter().map(|l| view! {
<A href=l.href attr:class="top-link">{l.label}</A>
}).collect_view()}
<TopSearch/>
// ★ **The health badge is an operator's instrument.**
// `GET /api/health` is not one of the routes the server
// answers for an anonymous visitor, so for someone
// browsing a public repository without a session it
// returns 401 — and `ServingBadge` renders any error as
// "gunnar is not responding". That is a false alarm
// about the SERVER caused by the READER not being
// logged in, and it is the first thing a visitor would
// see on the page.
//
// The gate is a `<Show>` around the component rather
// than a branch inside it, because the badge builds its
// `LocalResource` as it is created: a branch in the
// view would hide the badge and still send the request.
// The condition is `signed_in`, the same memo the
// sidebar uses: an open console still gets the badge,
// because there the call succeeds and an operator wants
// gunnar's version on screen.
//
// Since 2026-10-04 (thin layer) `/api/health` is asked
// AS THE READER, and gunnar answers `Admin.Health` to
// a server-wide Read grant only — a customer's own key
// is refused. So the badge is the instrument of the
// sessions gunnar answers it for, and a customer is not
// shown "gunnar is not responding" about a read that
// was never theirs.
<Show when=move || may_write.get()>
<ServingBadge/>
</Show>
// ★ **Åtgärder.** One line, between the health badge
// and the spacer, on every page — because the things
// it carries ("you cannot push yet", "your period ends
// this week") are true wherever the reader happens to
// be, and a message that only appears on the page
// somebody never opens is not a message.
//
// Gated on `signed_in` for the same reason the badge
// is: `/api/account/actions` takes its subject from the
// session, so for a visitor it is a 401 and the bar
// would render a refusal about an account they do not
// have. It draws NOTHING when there is nothing to say,
// which is the ordinary state and what keeps it worth
// reading on the day it appears.
<Show when=move || signed_in.get()>
<ActionBar/>
</Show>
<span class="spacer"></span>
<ThemeToggle/>
{move || {
let m = crate::nav::menu(reader());
if m.you.is_empty() {
view! { <Identity/> }.into_any()
} else {
view! { <MenuDrop label=you_label() links=m.you logout=true/> }.into_any()
}
}}
{move || {
let m = crate::nav::menu(reader());
(!m.admin.is_empty()).then(|| view! {
<MenuDrop label="Administration".to_owned() links=m.admin admin=true/>
})
}}
</header>
<main class="page" id="main">
<Routes fallback=unmatched>
// `/` leads a person to Repositories and an
// administrator to the Overview (`nav::moved`).
<Route path=path!("/") view=|| view! { <Placed/> }/>
<Route path=path!("/overview") view=|| view! { <Placed/> }/>
<Route path=path!("/admin/overview") view=|| view! { <Placed><pages::overview::Overview/></Placed> }/>
<Route path=path!("/repos") view=pages::repos::Repos/>
// The old `/repo/…` addresses (GUN-GUEST): the server answers
// them 301, and a link the console still holds takes the same
// road here (`crate::addr::from_old`).
<Route path=path!("/repo") view=RepoMoved/>
<Route path=path!("/repo/*rest") view=RepoMoved/>
<Route path=path!("/access") view=|| view! { <Placed><pages::access::Access/></Placed> }/>
// `/storage` is gone: a person's storage is under
// Account, the machine's under Administration, and
// the old address leads to the right one.
<Route path=path!("/storage") view=|| view! { <Placed/> }/>
<Route path=path!("/account/storage") view=pages::my_storage::StorageGoesOn/>
<Route path=path!("/admin/storage") view=|| view! { <Placed><pages::admin_storage::AdminStorage/></Placed> }/>
// `/machine` is gone (owner, 2026-10-04): the
// Overview's Machine card draws the estate and its
// few lines, Administration → Storage every disk
// detail. The old address leads there (`nav::moved`).
<Route path=path!("/machine") view=|| view! { <Placed/> }/>
// Per-namespace meters. `/usage` picks; `/usage/:namespace`
// is the page itself, and it is a separate address so a
// namespace's own numbers can be linked to.
// An administrator's; for anybody else they lead to
// their own storage (`nav::moved`), so a buy link or
// a bookmark never breaks.
<Route path=path!("/usage") view=|| view! { <Placed><pages::usage::UsageIndex/></Placed> }/>
<Route path=path!("/usage/:namespace") view=|| view! { <Placed><pages::usage::NamespaceUsage/></Placed> }/>
// Mirrors: the panel and the picker. Two
// top-level routes and not one with a query
// parameter — that was tried and MEASURED, on the
// theory that `leptos_router` monomorphises one
// `EitherOf<N>` per route count (the note in
// `.github/wasm-baseline`). It made the bundle
// 3 268 bytes LARGER, so the address that reads
// better is the one that ships.
<Route path=path!("/mirrors") view=pages::mirrors::Mirrors/>
<Route path=path!("/cli") view=pages::cli::Cli/>
<Route path=path!("/mirrors/import") view=pages::mirrors::ImportPicker/>
// The whole server's events are the Overview's
// (an admin's); a person's are their account's.
<Route path=path!("/events") view=|| view! { <Placed/> }/>
<Route path=path!("/account") view=pages::account::Account/>
<Route path=path!("/account/activity") view=pages::activity::AccountActivity/>
// You → Clans (2026-10-05): the person's own clans.
<Route path=path!("/account/clans") view=pages::clans::ClansPage/>
// `/paid/{reference}` (the vendor's return) is NOT a
// route here: it is `pages::subscription::vendor_return`,
// reached through `unmatched()` and gated `metered`,
// so an open build has no such address.
// Administration. The panels it draws came from
// `/account`, `/storage` and the Overview, each
// with its own gate travelling with it; the page
// adds one `may_write()` floor above them and the
// banner that says which half of the console this
// is. See `pages::admin`.
<Route path=path!("/admin") view=pages::admin::Admin/>
// Company SSO (2026-10-05): the ACCOUNT admin's — gunnar admits
// them alone; not behind `<Placed>`, which is the box admin's.
<Route path=path!("/admin/identity-provider") view=pages::identity_provider::IdentityProvider/>
// The owner's enterprise list (2026-10-05): the accounts'
// TOTALS are the box admin's; the Audit log is the
// ACCOUNT admin's — gunnar admits them alone, so it is
// not behind `<Placed>`, which is the box admin's.
<Route path=path!("/admin/accounts") view=|| view! { <Placed><pages::account_totals::AccountTotals/></Placed> }/>
<Route path=path!("/admin/audit") view=pages::audit_log::AuditLog/>
// ★ **A repository is `/{ns}/{name}`** (GUN-GUEST, owner
// 2026-10-30): the one address scheme, the same for a
// visitor, a person and a crawler (`crate::addr`). After
// every named route above, so `/admin/storage` stays the
// admin's page; `/{ns}` is last of all.
// Every repository page sits behind `RepoGate`: a
// namespace or repository the caller cannot see is
// drawn as "This page does not exist" and NO panes
// (`crate::gate`; the server's `api::absent`).
<Route path=path!("/:namespace/:name") view=RepoHome/>
<Route path=path!("/:namespace/:name/tree") view=|| view! { <pages::RepoGate><pages::browse::Browse/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/tree/*path") view=|| view! { <pages::RepoGate><pages::browse::Browse/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/blob/*path") view=|| view! { <pages::RepoGate><pages::browse::Browse/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/commits") view=|| view! { <pages::RepoGate><pages::graph::CommitGraph/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/pulls") view=|| view! { <pages::RepoGate><pages::pulls::PullsList/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/pulls/:number") view=|| view! { <pages::RepoGate><pages::pulls::PullDetail/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/issues") view=|| view! { <pages::RepoGate><pages::issues::IssuesList/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/issues/:number") view=|| view! { <pages::RepoGate><pages::issues::IssueDetail/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/diff") view=|| view! { <pages::RepoGate><pages::diff::DiffView/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/blame") view=|| view! { <pages::RepoGate><pages::blame::BlameView/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/search") view=|| view! { <pages::RepoGate><pages::search::SearchView/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/chain") view=|| view! { <pages::RepoGate><pages::chain::ChainView/></pages::RepoGate> }/>
// The repository's own events (its Activity tab) and
// its settings (visibility, maintenance, mirror).
<Route path=path!("/:namespace/:name/activity") view=|| view! { <pages::RepoGate><pages::activity::RepoActivity/></pages::RepoGate> }/>
<Route path=path!("/:namespace/:name/settings") view=|| view! { <pages::RepoGate><pages::repo::RepoSettings/></pages::RepoGate> }/>
// **`/<ns>`** (owner, 2026-10-03): a namespace as
// the reader may see it — a visitor sees a private
// namespace's public repositories only, and one with
// none is "This page does not exist". Last, so every
// named route above it wins.
<Route path=path!("/:namespace") view=pages::namespace::NamespacePage/>
</Routes>
</main>
</div>
</div>
</Router>
}
}
/// Whether gunnar is answering.
#[component]
fn ServingBadge() -> impl IntoView {
let health = LocalResource::new(|| async {
crate::api::get::<pages::overview::Health>("/api/health").await
});
view! {
{move || match health.get() {
None => view! { <Badge tone=Tone::Plain label="checking …"/> }.into_any(),
Some(Err(_)) => view! { <Badge tone=Tone::Bad label="Gunnar is not responding"/> }.into_any(),
Some(Ok(health)) => {
let version = health.version.clone();
if !health.serving {
view! { <Badge tone=Tone::Bad label="Gunnar is not accepting requests"/> }.into_any()
} else if !health.grpc_authentication_enabled {
// gunnar reports this on the wire precisely so an operator
// can see the hole. Showing it as "fine" would waste that.
view! {
<Badge tone=Tone::Warn label="control plane is unauthenticated"/>
}.into_any()
} else {
view! { <Badge tone=Tone::Pass label=format!("Gunnar {version}")/> }.into_any()
}
}
}}
}
}
/// This browser's stored choice of theme.
///
/// Read in two places and they must agree: here, and the four lines in
/// `index.html` that apply it before the first paint (`crate::theme` holds
/// the names and a test holds both to them). Changing one without the other is
/// a console that forgets the choice on every reload.
///
/// Every call goes through a `try`-shaped path: `local_storage()` ANSWERS AN
/// ERROR rather than `None` in a private window, which is exactly where a
/// console that unwrapped would stop at a blank page over a colour.
fn stored_theme() -> Theme {
let word = web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|store| store.get_item(crate::theme::KEY).ok().flatten());
Theme::from_stored(word.as_deref())
}
/// Paint the choice and remember it.
///
/// The attribute is what the stylesheet reads (`:root[data-theme="dark"]`,
/// `:root[data-theme="surprise"]`); bare `:root` is the light palette, so
/// light is the ABSENCE of the attribute and not a value — one less state that
/// can disagree with `index.html`'s pre-paint script.
fn apply_theme(theme: Theme) {
let Some(window) = web_sys::window() else {
return;
};
if let Some(root) = window.document().and_then(|d| d.document_element()) {
match theme.attr() {
Some(value) => {
let _ = root.set_attribute("data-theme", value);
}
None => {
let _ = root.remove_attribute("data-theme");
}
}
}
// Storage may be absent, refused, or full. None of those is a reason to
// fail the click: the theme is already on screen by this line.
if let Ok(Some(store)) = window.local_storage() {
let _ = store.set_item(crate::theme::KEY, theme.name());
}
}
/// ★ **The theme switch — a choice, never an inference.**
///
/// The console is white by default. The dark palette it shipped with is kept
/// whole and selectable, because an operator with a terminal beside the
/// browser has a real reason to want it, and since 2026-10-05 a third, the
/// surprise theme (dark Nordic, purple and cyan), comes after it: one press
/// walks ☀ light → ☾ dark → ✦ surprise → ☀ light (`crate::theme`).
/// `prefers-color-scheme` is deliberately not consulted — see the note at the
/// top of `styles/console.css`.
///
/// The label says what the click DOES, not what the console currently is: a
/// button that reads "Dark" in a light console is a button whose label is an
/// instruction, and that is the only reading that survives being seen for half
/// a second on the way past.
#[component]
fn ThemeToggle() -> impl IntoView {
let theme = RwSignal::new(stored_theme());
view! {
<button
class="btn quiet theme-toggle"
type="button"
title=move || theme.get().title()
on:click=move |_| {
let next = theme.get_untracked().next();
theme.set(next);
apply_theme(next);
}
>
{move || theme.get().label()}
</button>
}
}
/// Who is logged in, and the way out.
#[component]
fn Identity() -> impl IntoView {
let status = crate::app::session();
view! {
{move || match status.get() {
None => view! { <span class="badge plain">"…"</span> }.into_any(),
Some(status) if status.open => view! {
<Badge tone=Tone::Warn label="unlocked console"/>
}.into_any(),
// GUN-SESSION-RESTART: gunnar is restarting; the session is kept, no login offered.
Some(status) if status.restarting => view! {
<Badge tone=Tone::Warn label="Gunnar is starting…"/>
}.into_any(),
Some(status) if status.authenticated => {
let who = format!("{} · {}", status.user, status.role.clone().unwrap_or_default());
view! {
<span class="badge plain">{who}</span>
<button class="btn quiet" on:click=move |_| leptos::task::spawn_local(log_out())>"Log out"</button>
}.into_any()
}
// Signed out — which is now a normal way to be here, not an error
// state, because a public repository can be browsed with no
// session. This link is the ONLY way back in the console offers
// such a visitor: the sidebar leads nowhere they may go and no page
// they can reach paints a failure box with a login in it any more,
// now that the gated panels are not mounted for them at all.
//
// `primary` and not `quiet`: the quiet variant is transparent with
// dimmed ink, and at the far right of a 52px top bar it reads as a
// caption rather than the way in. The badge beside it says WHY the
// console looks emptier than it should — without it, a visitor has
// no way to tell "signed out" from "this deployment has nothing".
//
// `rel="external"`, and not decoration: `/login` is a page the
// SERVER renders, and the console has no route for it. The router
// claims every same-origin anchor click for itself unless the
// anchor says otherwise, and a claimed click on a route it does
// not have moves the URL bar and nothing else — measured on
// gunnar.rs 2026-09-10 from a public repository's graph: "Log
// in" did nothing. `rel="external"` is the router's own opt-out
// and gives the click back to the browser, which is what every
// other road to `/login` in this crate already takes by setting
// `location.href`. The `next` carries the page the visitor was
// on, so logging in returns them to it.
Some(_) => {
let here = web_sys::window()
.and_then(|w| w.location().pathname().ok())
.unwrap_or_else(|| "/".to_owned());
view! {
<Badge tone=Tone::Plain label="not signed in"/>
<a class="btn primary" rel="external" href=crate::auth::login_url(&here)>"Log in"</a>
}.into_any()
}
}}
}
}
/// **Log out**: to the login page once gunnar ended the session; when it did not
/// (RA5-11), its words, and the person stays signed in (the cookie is kept).
async fn log_out() {
let Some(window) = web_sys::window() else { return };
match crate::api::post_empty::<serde_json::Value>("/auth/logout").await {
Ok(_) => {
let _ = window.location().set_href("/login");
}
Err(fail) => {
let _ = window.alert_with_message(&fail.message);
}
}
}
/// **A top-bar menu**: a `<details>` that drops its links, closes when one is
/// followed, and — for You ▾ — ends with Log out. `<details>` and not a script
/// so it works on a phone, by keyboard and with no JavaScript of its own.
#[component]
fn MenuDrop(
label: String,
links: Vec<crate::nav::Link>,
#[prop(optional)] logout: bool,
#[prop(optional)] admin: bool,
) -> impl IntoView {
let close = |ev: leptos::ev::MouseEvent| {
use wasm_bindgen::JsCast as _;
if let Some(d) = ev
.target()
.and_then(|t| t.dyn_into::<web_sys::Element>().ok())
.and_then(|e| e.closest("details").ok().flatten())
{
let _ = d.remove_attribute("open");
}
};
view! {
<details class=if admin { "menu admin-menu" } else { "menu" }>
<summary>{label}" \u{25be}"</summary>
<div class="menu-items" on:click=close>
{links.into_iter().map(|l| view! { <A href=l.href attr:class="menu-link">{l.label}</A> }).collect_view()}
{logout.then(|| view! {
<button class="menu-link menu-logout" on:click=move |_| leptos::task::spawn_local(log_out())>"Log out"</button>
})}
</div>
</details>
}
}
/// **The search box on the top bar**: a repository filter, answered by the
/// Repositories page (`/repos?q=`).
#[component]
fn TopSearch() -> impl IntoView {
let navigate = leptos_router::hooks::use_navigate();
let typed = RwSignal::new(String::new());
let go = move |ev: leptos::ev::SubmitEvent| {
ev.prevent_default();
let q = typed.get_untracked();
let to = if q.trim().is_empty() { "/repos".to_owned() } else { format!("/repos?q={}", crate::auth::percent_encode(q.trim())) };
navigate(&to, Default::default());
};
view! {
<form class="top-search" role="search" on:submit=go>
<label class="vh" for="top-search">"Search repositories"</label>
<input
id="top-search"
type="search"
placeholder="Search…"
prop:value=move || typed.get()
on:input=move |ev| typed.set(event_target_value(&ev))
/>
</form>
}
}
/// **The document's title follows the address** (GUN-FIELD-TEST, 2026-11-01): the page,
/// the repository and the site (`crate::title`). The site's name is the one the server's
/// first document carried.
#[component]
fn TitleKeeper() -> impl IntoView {
let location = leptos_router::hooks::use_location();
let site = web_sys::window()
.and_then(|w| w.document())
.map(|d| crate::title::site_of(&d.title()))
.unwrap_or_else(|| "Gunnar".to_owned());
Effect::new(move |_| {
let title = crate::title::of(&location.pathname.get(), &site);
if let Some(d) = web_sys::window().and_then(|w| w.document()) {
d.set_title(&title);
}
});
}
/// **A page that may have moved** (`crate::nav::moved`): once the session is
/// known, an address that leads elsewhere for this reader is replaced by where
/// it leads; otherwise the page is drawn. Waits for `/auth/status` rather than
/// guessing, so nobody is bounced on a half-loaded console.
#[component]
fn Placed(#[prop(optional)] children: Option<ChildrenFn>) -> impl IntoView {
let status = session();
let location = leptos_router::hooks::use_location();
let navigate = leptos_router::hooks::use_navigate();
move || {
let Some(s) = status.get() else {
return view! { <crate::ui::Loading/> }.into_any();
};
match crate::nav::moved(&location.pathname.get(), s.is_admin()) {
Some(to) => {
navigate(to, leptos_router::NavigateOptions { replace: true, ..Default::default() });
().into_any()
}
None => match children.as_ref() {
Some(children) => children().into_any(),
None => ().into_any(),
},
}
}
}
/// **An old `/repo/…` address** (GUN-GUEST): replaced by its new form at once
/// (`crate::addr::from_old`), so a link the console still holds lands where the
/// server's 301 would have sent it.
#[component]
fn RepoMoved() -> impl IntoView {
let location = leptos_router::hooks::use_location();
let navigate = leptos_router::hooks::use_navigate();
let path = location.pathname.get_untracked();
let query = location.search.get_untracked();
let to = crate::addr::from_old(&path, &query).unwrap_or_else(|| "/repos".to_owned());
navigate(&to, leptos_router::NavigateOptions { replace: true, ..Default::default() });
}
/// **`/{ns}/{name}`**, a repository's home. In a build that sells, the one outside
/// address with the same shape, the pay link `/billing/{tenant}`, goes to its page first.
#[component]
fn RepoHome() -> impl IntoView {
#[cfg(feature = "metered")]
if let Some(page) = crate::pages::subscription::vendor_return() {
return page;
}
view! { <pages::RepoGate><pages::repo::RepoDetail/></pages::RepoGate> }.into_any()
}
/// **An address no route names.** One exception, and only in a build that
/// sells: the pay link every refusal ends in (`/billing/{tenant}`), which lives
/// with the rest of that flow in `pages::subscription` (the money scanner's
/// `METERED_ONLY`). The vendor's ways back are the server's ONE order page.
fn unmatched() -> AnyView {
#[cfg(feature = "metered")]
if let Some(page) = crate::pages::subscription::vendor_return() {
return page;
}
view! {
<div class="empty">
<h3>"Page not found"</h3>
<p>"This address leads nowhere in this console."</p>
</div>
}
.into_any()
}