crates/gunnar-ui-web/src/pages/repos.rs
//! The repository catalogue.
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_query_map};
use serde::{Deserialize, Serialize};
use crate::api::Page;
use crate::app::session;
use crate::pages::resolved;
use crate::ui::{Badge, Empty, FailBox, PageHeader, Panel, Tone, Truncation};
/// A repository as the console shows it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Repo {
pub path: String,
pub namespace: String,
pub name: String,
pub hash_kind: String,
pub default_branch: String,
pub visibility: String,
pub created_at_unix_ms: Option<i64>,
pub description: String,
/// What it stores (the list's one size column, owner 2026-10-02). Absent
/// when the server could not read the sizes.
#[serde(default)]
pub stored_bytes: Option<u64>,
/// gunnar's "encrypted" flag (the account admin's): the badge, and the Settings control.
#[serde(default)]
pub encrypted: bool,
}
#[derive(Debug, Clone, Serialize)]
struct NewRepo {
namespace: String,
name: String,
visibility: String,
description: String,
/// The server holds ciphertext only, from the start (`Repos.SetEncrypted`).
encrypted: bool,
}
/// **Where this session's New repository may create**, in gunnar's words
/// (`GET /api/repos/creatable`, owner 2026-10-06): an operator's session types
/// the namespace as before (`any_namespace`); an account admin picks one of the
/// accounts gunnar says they administer. Nothing here decides — the create is
/// gunnar's too.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct Creatable {
#[serde(default)]
pub any_namespace: bool,
#[serde(default)]
pub namespaces: Vec<CreatableNamespace>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct CreatableNamespace {
pub name: String,
#[serde(default)]
pub public: bool,
}
impl Creatable {
/// The button is offered: somewhere to create.
pub fn offers(&self) -> bool {
self.any_namespace || !self.namespaces.is_empty()
}
/// May a repository in `namespace` be made public from here?
pub fn may_publish(&self, namespace: &str) -> bool {
self.any_namespace || self.namespaces.iter().any(|n| n.name == namespace && n.public)
}
}
#[component]
pub fn Repos() -> impl IntoView {
let query = use_query_map();
let navigate = use_navigate();
let reload = RwSignal::new(0u32);
// The filter lives in the URL, so a filtered view is an address someone can
// send to a colleague.
let filter = Memo::new(move |_| query.read().get("q").unwrap_or_default());
// **Imported only**: repositories that are mirrors, as gunnar lists them.
let imported = RwSignal::new(false);
// **Just deleted** (owner spec 2026-10-28): the Danger zone comes here with
// `?deleted=<ns>/<name>`; the notice says so and offers its Undo.
let deleted = Memo::new(move |_| query.read().get("deleted").unwrap_or_default());
let repos = LocalResource::new(move || {
// Reading the signal here is what makes a creation or deletion show up
// without a page reload.
reload.get();
// **The search is the server's** (thin layer, owner 2026-10-04): the
// name query and "Imported only" go to gunnar, which applies them
// before anything is paged, so the 251st repository is found too.
// Until then this page filtered the first 250 it was handed.
let q = filter.get();
let only = imported.get();
async move {
let mut url = "/api/repos".to_owned();
let mut sep = '?';
if !q.trim().is_empty() {
url.push_str(&format!("{sep}q={}", crate::auth::percent_encode(q.trim())));
sep = '&';
}
if only {
url.push_str(&format!("{sep}imported=true"));
}
crate::api::get::<Page<Repo>>(&url).await
}
});
let typed = RwSignal::new(String::new());
Effect::new(move |_| typed.set(filter.get()));
let apply = move || {
let text = typed.get_untracked();
let text = text.trim();
let target = if text.is_empty() {
"/repos".to_owned()
} else {
format!("/repos?q={}", crate::auth::percent_encode(text))
};
navigate(&target, Default::default());
};
let apply_button = apply.clone();
let creating = RwSignal::new(false);
// Where New repository may create, asked of the server for THIS session
// (gunnar answers it); a visitor has nowhere.
let creatable = LocalResource::new(move || {
// Read here, outside the future, so a sign-in asks again.
let signed_in = session().get().is_some_and(|s| s.authenticated || s.open);
async move {
if signed_in {
crate::api::get::<Creatable>("/api/repos/creatable").await.unwrap_or_default()
} else {
Creatable::default()
}
}
});
let can_create = move || creatable.get().is_some_and(|c| c.offers());
let signed_in = move || session().get().is_some_and(|s| s.authenticated || s.open);
// The forge's SSH host key is for someone who will push: a visitor is not
// shown it (owner order 2026-10-18).
let server_key = LocalResource::new(move || {
let signed_in = signed_in();
async move {
if !signed_in {
return None;
}
crate::api::get::<crate::pages::overview::ServerKey>("/api/server-key").await.ok()
}
});
// **Why "New repository" is not offered**, when it is not (owner order
// 2026-10-18): the server's answers in words — a member's account admin,
// a Free account (the queue's `free-cannot-push`), or no room at all.
let free = LocalResource::new(move || {
let signed_in = signed_in();
async move {
if !signed_in {
return None;
}
crate::api::get::<crate::actions::Actions>("/api/account/actions")
.await
.ok()
.and_then(|a| a.items.into_iter().find(|i| i.id == crate::first_push::FREE_ITEM))
}
});
let why_not = move || {
let s = session().get()?;
if !s.authenticated || s.open || creatable.get().is_none_or(|c| c.offers()) {
return None;
}
let member = s.is_member().then(|| (s.account.clone().unwrap_or_default(), s.account_admin.clone().unwrap_or_default()));
let free = free.get().flatten();
Some((crate::first_push::why_no_create(member.as_ref().map(|(a, b)| (a.as_str(), b.as_str())), free.is_some()), free))
};
view! {
// Line 2 is the forge's SSH host key (owner, 2026-10-02): what a user
// checks their ssh client's first-connect prompt against, read live
// (`GET /api/server-key`), never written here.
<PageHeader
title="Repositories"
line=view! {
{move || server_key
.get()
.flatten()
.and_then(|k| crate::headers::server_key_line(&k.fingerprint))
.map(|said| view! { <span class="mono">{said}</span> })}
}.into_any()
>
// **The page's own actions are BUTTONS here, not menu entries**
// (owner's final navigation, 2026-10-02): "New repository" for
// whoever may create one — an operator, and since 2026-10-06 an
// account admin, where gunnar says (`/api/repos/creatable`) — and
// "Import repository" beside it on the same answer (round 5.3): an
// import MAKES a repository, so it is offered where gunnar names a
// namespace this person may make one in — an account's member it
// names none for (the members walkthrough: ben's Import landed in
// `ben/`, which does not exist) is offered no dead end. A visitor
// has no namespace and no button.
<Show when=can_create>
<button class="btn primary" id="new-repository" on:click=move |_| creating.update(|on| *on = !*on)>
"New repository"
</button>
</Show>
<Show when=move || can_create() && session().get().is_some_and(|s| s.authenticated || s.open)>
<a class="btn" href="/mirrors/import">"Import repository"</a>
</Show>
</PageHeader>
<div class="stack">
<Show when=move || crate::repo_delete::split(&deleted.get()).is_some()>
<DeletedNotice path=deleted on_restored=Callback::new(move |()| reload.update(|n| *n += 1))/>
</Show>
{move || why_not().map(|(why, free)| view! {
<p class="hint" id="why-no-new-repository">
{why}
{free.map(|item| view! { " " <a href=item.href.clone()>{item.cta.clone()}</a> })}
</p>
})}
<Show when=move || creating.get() && can_create()>
<CreateRepo
creatable=Signal::derive(move || creatable.get().unwrap_or_default())
on_created=Callback::new(move |()| reload.update(|n| *n += 1))
/>
</Show>
<Panel title="Catalog">
<div class="panel-body row">
<label class="vh" for="filter">"Filter"</label>
<input
id="filter"
type="search"
placeholder="namespace/name"
prop:value=move || typed.get()
on:input=move |ev| typed.set(event_target_value(&ev))
on:keydown={
let apply = apply.clone();
move |ev: leptos::ev::KeyboardEvent| {
if ev.key() == "Enter" {
apply();
}
}
}
/>
<button class="btn" on:click=move |_| apply_button()>"Filter"</button>
<Show when=move || session().get().is_some_and(|s| s.authenticated || s.open)>
<label class="toggle">
<input type="checkbox" id="imported-only" prop:checked=move || imported.get() on:change=move |ev| imported.set(event_target_checked(&ev))/>
" Imported only"
</label>
<a class="btn quiet" href="/mirrors">"Your mirrors"</a>
</Show>
</div>
{move || {
let searching = !filter.get().trim().is_empty() || imported.get();
resolved(repos.get(), None, move |page: Page<Repo>| {
let total = page.total;
// Already the server's answer to the search.
let matching: Vec<Repo> = page.items;
if total == 0 && !searching {
let (title, detail) = crate::first_push::empty_catalogue(
session().get_untracked().is_some_and(|s| s.authenticated || s.open),
);
return view! { <Empty title=title detail=detail/> }.into_any();
}
if matching.is_empty() {
// A filter with no matches is not an empty server.
return view! {
<Empty
title="No name matches the filter"
detail="Clear the search field to see the whole catalogue again."
/>
}
.into_any();
}
let shown = matching.len();
view! {
<div class="table-wrap">
<table>
<caption class="vh">"Repositories"</caption>
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col">"Visibility"</th>
<th scope="col">"Hash"</th>
<th scope="col">"Default branch"</th>
<th scope="col">"Size"</th>
// No visible label: the column is one
// link per row and a heading over it
// would say less than the link does.
<th scope="col"><span class="vh">"Actions"</span></th>
</tr>
</thead>
<tbody>
{matching
.into_iter()
.map(|repo| {
let href = format!(
"/{}/{}",
crate::api::segment(&repo.namespace),
crate::api::segment(&repo.name),
);
let files = format!("{href}/tree");
let tone = match repo.visibility.as_str() {
"public" => Tone::Warn,
_ => Tone::Plain,
};
view! {
<tr>
<td>
<a href=href>{repo.path.clone()}</a>
{(!repo.description.is_empty())
.then(|| view! {
<div class="unset" style="font-style:normal">
{repo.description.clone()}
</div>
})}
</td>
<td>
<Badge tone=tone label=repo.visibility.clone()/>
{crate::encryption::badge(repo.encrypted).map(|b| view! {
" "<Badge tone=Tone::Plain label=b/>
})}
</td>
<td class="mono">{repo.hash_kind.clone()}</td>
<td class="mono">
{crate::fmt::short_ref(&repo.default_branch).to_owned()}
</td>
<td class="num">{repo.stored_bytes.map(crate::fmt::bytes).unwrap_or_default()}</td>
<td class="row-actions">
<a class="btn quiet" href=files>
"Browse"
// Every row's link says the same
// word, so the accessible name
// carries which repository it opens.
<span class="vh">
{format!(" the code of {}", repo.path)}
</span>
</a>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
{(shown < total)
.then(|| view! { <Truncation shown=shown total=total/> })}
}
.into_any()
})
}}
</Panel>
</div>
}
}
/// ★ **"Deleted <ns>/<name>" and its Undo** (owner spec 2026-10-28), on the list the Danger
/// zone came back to. The Undo is `Repos.Undelete` signed as the person
/// (`POST /api/repos/{ns}/{name}/undelete`); gunnar decides, and a refusal is said inline in
/// plain words with gunnar's own sentence under it. The administrator also gets the way to the
/// box's whole list of deleted repositories (Admin → Storage → Deleted, with its Restore).
#[component]
fn DeletedNotice(path: Memo<String>, on_restored: Callback<()>) -> impl IntoView {
let busy = RwSignal::new(false);
let restored = RwSignal::new(None::<String>);
let failed = RwSignal::new(None::<(String, crate::api::Fail)>);
Effect::new(move |_| {
let _ = path.get();
restored.set(None);
failed.set(None);
});
let undo = move |_| {
let at = path.get_untracked();
let Some((ns, name)) = crate::repo_delete::split(&at) else { return };
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
leptos::task::spawn_local(async move {
match crate::api::post_empty::<serde_json::Value>(&crate::repo_delete::undelete_url(&ns, &name)).await {
Ok(_) => {
restored.set(Some(at));
on_restored.run(());
}
Err(fail) => failed.set(Some((crate::repo_delete::undo_refused(fail.status, &at), fail))),
}
busy.set(false);
});
};
let admin = move || session().get().is_some_and(|s| s.is_admin());
view! {
<div class="notice repo-deleted" id="repo-deleted-notice" role="status">
{move || match restored.get() {
Some(at) => {
let href = crate::repo_delete::split(&at)
.map(|(ns, name)| format!("/{}/{}", crate::api::segment(&ns), crate::api::segment(&name)))
.unwrap_or_default();
view! {
<span id="repo-restored">{crate::repo_delete::restored(&at)}</span>
<a class="btn quiet" href=href>"Open it"</a>
}
.into_any()
}
None => view! {
<span id="repo-deleted">{move || crate::repo_delete::notice(&path.get())}</span>
<button
id="repo-undo"
class=move || if busy.get() { "btn busy" } else { "btn" }
disabled=move || busy.get()
on:click=undo
>
"Undo"
</button>
{move || admin().then(|| view! {
<a id="repo-recently-deleted" class="btn quiet" href=crate::repo_delete::RECENTLY_DELETED>"Recently deleted"</a>
})}
}
.into_any(),
}}
{move || failed.get().map(|(line, fail)| view! {
<p class="refused" id="repo-undo-refused">{line}</p>
<FailBox fail=fail/>
})}
</div>
}
}
/// The create form, shown only to sessions that could succeed with it: the
/// namespace typed (an operator's) or picked from the accounts gunnar says the
/// person administers.
#[component]
fn CreateRepo(creatable: Signal<Creatable>, on_created: Callback<()>) -> impl IntoView {
let navigate = use_navigate();
let namespace = RwSignal::new(
creatable.get_untracked().namespaces.first().map(|n| n.name.clone()).unwrap_or_default(),
);
let name = RwSignal::new(String::new());
let visibility = RwSignal::new("private".to_owned());
let description = RwSignal::new(String::new());
let encrypted = RwSignal::new(false);
// The level `gunnar crypt init` seals it at: gunnar's own default first (CRYPT-PQ-DEFAULT).
let level = RwSignal::new(crate::encryption::level_choices()[0].0);
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
let submit = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
let body = NewRepo {
namespace: namespace.get_untracked().trim().to_owned(),
name: name.get_untracked().trim().to_owned(),
visibility: visibility.get_untracked(),
description: description.get_untracked(),
encrypted: encrypted.get_untracked() && creatable.get_untracked().may_publish(&namespace.get_untracked()),
};
let navigate = navigate.clone();
leptos::task::spawn_local(async move {
match crate::api::post::<_, Repo>("/api/repos", &body).await {
// ★ **To the new repository** (owner order 2026-10-18), not a
// reload of the list: its page shows how to push to it.
Ok(made) => {
name.set(String::new());
description.set(String::new());
encrypted.set(false);
on_created.run(());
navigate(
&format!("/{}/{}", crate::api::segment(&made.namespace), crate::api::segment(&made.name)),
Default::default(),
);
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
view! {
<Panel title="New repository">
<div class="panel-body">
<div class="row" style="align-items:flex-end">
<div class="field">
<label for="ns">"Namespace"</label>
{move || if creatable.get().any_namespace {
view! {
<input
id="ns"
type="text"
prop:value=move || namespace.get()
on:input=move |ev| namespace.set(event_target_value(&ev))
/>
}.into_any()
} else {
view! {
<select id="ns" on:change=move |ev| {
namespace.set(event_target_value(&ev));
visibility.set("private".to_owned());
}>
{creatable.get().namespaces.into_iter().map(|n| {
let selected = n.name == namespace.get_untracked();
view! { <option value=n.name.clone() selected=selected>{n.name.clone()}</option> }
}).collect_view()}
</select>
}.into_any()
}}
</div>
<div class="field">
<label for="name">"Name"</label>
<input
id="name"
type="text"
prop:value=move || name.get()
on:input=move |ev| name.set(event_target_value(&ev))
/>
</div>
<div class="field">
<label for="vis">"Visibility"</label>
<select
id="vis"
on:change=move |ev| visibility.set(event_target_value(&ev))
>
<option value="private">"private"</option>
// No "internal" here, deliberately: gunnar has two
// visibilities and refuses INTERNAL by name
// ("public" means anonymous-readable, so internal
// no longer aliases it). Offering a value the server
// refuses — or worse, silently widens, which is what
// it did until 2026-08-19 — is how a picker lies.
<Show when=move || creatable.get().may_publish(&namespace.get())>
<option value="public">"public — readable by anyone, no key"</option>
</Show>
</select>
</div>
// **Encrypted is the account admin's decision**, as public is
// (`Repos.SetEncrypted`: Admin over the namespace — the same word
// `may_publish` reads; wiring tracer B 2026-10-08). Not offered where
// gunnar would refuse it.
<Show when=move || creatable.get().may_publish(&namespace.get())>
<div class="field">
<label for="enc">
<input
id="enc"
type="checkbox"
prop:checked=move || encrypted.get()
on:change=move |ev| encrypted.set(event_target_checked(&ev))
/>
" encrypted"
</label>
" "
<crate::ui::InfoTip markup=crate::encryption::INFO/>
<Show when=move || encrypted.get()>
<div class="field enc-level-choice">
<label for="enc-level">"Level"</label>
<select id="enc-level" on:change=move |ev| {
let picked = event_target_value(&ev);
if let Some((l, _)) = crate::encryption::level_choices().into_iter().find(|(l, _)| l.word() == picked) {
level.set(l);
}
}>
{crate::encryption::level_choices().into_iter().map(|(l, words)| view! {
<option value=l.word() selected=move || level.get() == l>{words}</option>
}).collect_view()}
</select>
</div>
<p class="hint-line cli-hint enc-seal">
"Seal it with " <code class="mono">{move || crate::encryption::seal_command(level.get())}</code>
" on your own computer. "
<a href=crate::cli::PAGE>{crate::cli::LINK}</a>
</p>
</Show>
</div>
</Show>
<div class="field" style="flex:1;min-width:12rem">
<label for="desc">"Description"</label>
<input
id="desc"
type="text"
prop:value=move || description.get()
on:input=move |ev| description.set(event_target_value(&ev))
/>
</div>
<button
class=move || if busy.get() { "btn primary busy" } else { "btn primary" }
disabled=move || busy.get()
on:click=submit
>
"Create"
</button>
</div>
<p class="hint" style="margin-top:.5rem;color:var(--ink-mute);font-size:.8rem">
"New repositories get sha256 unless told otherwise. Encrypted: the server \
holds ciphertext only."
</p>
{move || failed.get().map(|fail| view! {
<div style="margin-top:.75rem"><FailBox fail=fail/></div>
})}
</div>
</Panel>
}
}