crates/gunnar-ui-web/src/pages/mirrors.rs
//! **Connect a forge, pick repositories, watch them arrive.**
//!
//! Three screens, and this module draws all of them:
//!
//! * `/mirrors` — the forges this session has connected, and every mirror on
//! the box with its live job;
//! * `/mirrors/import` — the picker;
//! * [`MirrorPanel`] — the same row, on a repository's own page.
//!
//! # This module decides nothing
//!
//! Every judgement lives in [`crate::mirror`], on the host side of the wasm
//! boundary, because `pages::*` is `#[cfg(target_arch = "wasm32")]` and
//! `cargo test` compiles none of it. The one that matters is `has_progress`
//! before the object pair — gunnar's proto puts a warning label on it — and a
//! rule that only ever ran in a browser is a rule with no test. `room.rs` was
//! split out for the same reason.
//!
//! # The poller is the gc panel's, exactly
//!
//! One poller behind a guard, started only when something is in flight, stopped
//! the moment it settles, and stopped on the first refusal rather than
//! hammering a server that is already saying no. Without the guard, pressing a
//! button while a poll is running starts a second loop and the two race to
//! write the same signal. `pages::repo::GcPanel` is the model and this is the
//! same shape.
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use serde::Serialize;
use crate::api::segment;
use crate::fmt;
use crate::mirror::{
can_import, can_import_url, import_home, lands_at_url, nowhere_to_import, room_way, IMPORT_UNASKED, PRUNE_LABEL,
transport_note, visible, Connected, ForgeRepo, Forges, Mirror, RoomWay,
};
use crate::pages::{now_ms, resolved, when_text};
use crate::ui::{Badge, Empty, FailBox, Hint, Metric, PageHeader, Panel, Tone};
/// How often a live job is re-read. The gc panel's interval, and for the same
/// reason: fast enough to feel live, slow enough that ten open tabs are not a
/// load.
const POLL_MS: u32 = 2_000;
/// The badge tone for a job's state. Colour is never alone — the word is always
/// beside it, which is the rule the whole palette was built on.
fn tone(state: &str) -> Tone {
match state {
"running" => Tone::Live,
"failed" => Tone::Bad,
"done" => Tone::Pass,
_ => Tone::Plain,
}
}
// ───────────────────────────────────────────────────────────────────────────
// /mirrors
// ───────────────────────────────────────────────────────────────────────────
/// **The mirrors this session tends** — every one on the box for an
/// administrator, and a person's OWN for anybody else (the BFF narrows the
/// list; this page only draws what it is given).
#[component]
pub fn Mirrors() -> impl IntoView {
let session = crate::app::session();
let is_admin = Memo::new(move |_| session.get().is_some_and(|s| s.is_admin()));
let mirrors = RwSignal::new(None::<Result<Vec<Mirror>, crate::api::Fail>>);
let polling = RwSignal::new(false);
let reload = RwSignal::new(0u32);
// One poller, started only when a row is live. `reload` is bumped by every
// mutation so the list is re-read without a second loop being spawned.
let start_polling = move || {
if polling.get_untracked() {
return;
}
polling.set(true);
leptos::task::spawn_local(async move {
loop {
gloo_timers::future::TimeoutFuture::new(POLL_MS).await;
match crate::api::get::<Vec<Mirror>>("/api/mirrors").await {
Ok(rows) => {
let keep = rows.iter().any(Mirror::in_flight);
mirrors.set(Some(Ok(rows)));
if !keep {
break;
}
}
Err(fail) => {
mirrors.set(Some(Err(fail)));
break;
}
}
}
polling.set(false);
});
};
Effect::new(move |_| {
reload.track();
leptos::task::spawn_local(async move {
let answer = crate::api::get::<Vec<Mirror>>("/api/mirrors").await;
let keep = answer
.as_ref()
.is_ok_and(|rows| rows.iter().any(Mirror::in_flight));
mirrors.set(Some(answer));
if keep {
start_polling();
}
});
});
view! {
<PageHeader
title=move || if is_admin.get() { "Mirrors" } else { "Your mirrors" }
>
<a class="btn primary" href="/mirrors/import">"Import repository"</a>
</PageHeader>
// The Administration banner: for an administrator this page acts on
// the box, not on the reader. Anybody else is looking at their own.
<Show when=move || is_admin.get()>
<crate::pages::admin::Banner/>
</Show>
<div class="stack">
<Show when=move || is_admin.get()>
<ConnectPanel/>
</Show>
<Panel title="Mirrors">
{move || resolved(mirrors.get(), None, move |rows: Vec<Mirror>| {
if rows.is_empty() {
return view! {
<Empty
title="No mirrors yet"
detail="Nothing here comes from another git server yet. \
Import a repository to start one."
/>
}
.into_any();
}
view! {
<ul class="indexing">
{rows
.into_iter()
.map(|m| view! {
<MirrorRow
mirror=m
on_change=Callback::new(move |()| {
reload.update(|n| *n += 1);
})
/>
})
.collect_view()}
</ul>
}
.into_any()
})}
</Panel>
</div>
}
}
/// One mirror, with its job and its two buttons.
///
/// A `<li>` inside the `.indexing` list the Overview's index panel already
/// uses: the shape is the same — a name, a badge, a per-cent, a bar and a line
/// of meta — and a second stylesheet for the same shape is a second thing to
/// keep looking alike.
#[component]
fn MirrorRow(mirror: Mirror, on_change: Callback<()>) -> impl IntoView {
// The server's word on whether THIS session may sync, forget or stop it.
// It checks again on every press; this only keeps the page from offering
// a button it would refuse.
let may_write = mirror.may_tend;
let path = mirror.path.clone();
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
let confirming = RwSignal::new(false);
// `StoredValue` rather than a captured `String`: the three handlers below
// live inside a `<Show>`, whose children must be `Fn` and are therefore
// called more than once. A closure that captured the path by value would be
// `FnOnce` and would not compile — and the fix that first suggests itself,
// cloning per handler, only moves the problem one level out.
let held = StoredValue::new(path.clone());
let sync = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
let url = format!("/api/mirrors/{}/sync", encoded(&held.get_value()));
leptos::task::spawn_local(async move {
// The button fires ONE request. Everything past that is the
// server's: it dedups on the repository, so a second press is the
// same job and needs no client-side guard beyond this one.
match crate::api::post_empty::<Mirror>(&url).await {
Ok(_) => on_change.run(()),
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
let forget = move |_| {
let url = format!("/api/mirrors/{}/credential", encoded(&held.get_value()));
leptos::task::spawn_local(async move {
match crate::api::post::<_, Mirror>(&url, &serde_json::json!({ "kind": "none" })).await
{
Ok(_) => on_change.run(()),
Err(fail) => failed.set(Some(fail)),
}
});
};
let remove = move |_| {
// The typed-back path travels as `?confirm=`, and the BFF checks it
// again: a fetch from a console tab is one line, so a guard that lived
// only here would protect the button and not the verb.
let full = held.get_value();
let url = format!(
"/api/mirrors/{}?confirm={}",
encoded(&full),
crate::auth::percent_encode(&full)
);
leptos::task::spawn_local(async move {
match crate::api::delete::<serde_json::Value>(&url).await {
Ok(_) => on_change.run(()),
Err(fail) => failed.set(Some(fail)),
}
});
};
let job = mirror.job.clone();
let state_word = job.as_ref().map(|j| j.word()).unwrap_or("idle");
let percent = job.as_ref().and_then(crate::mirror::MirrorJob::percent);
let line = job
.as_ref()
.map(crate::mirror::MirrorJob::live_line)
.unwrap_or_else(|| {
if mirror.sync_count == 0 {
"Registered. Nothing has been fetched yet — press Sync now.".to_owned()
} else {
format!("Idle. {} sync(s) so far.", fmt::count(mirror.sync_count))
}
});
let needs_room = job.as_ref().is_some_and(|j| j.needs_room);
let room_href = job.as_ref().and_then(|j| j.room_href.clone());
let error = job
.as_ref()
.filter(|j| !j.needs_room)
.and_then(|j| j.error.clone());
let updates = job.as_ref().map(|j| j.updates.clone()).unwrap_or_default();
let live = mirror.in_flight();
let mismatch = mirror.credential_mismatch();
let repo_href = format!("/{}", encoded(&path));
let credential = mirror.credential_line();
let remote = mirror.remote.clone();
let last = when_text(mirror.last_sync_at_unix_ms, now_ms());
let lfs = mirror.lfs_line();
// An import whose first sync has not landed has no repository to open yet.
let pending = mirror.repo_pending;
view! {
<li>
<div class="row">
<strong>
{if pending {
view! { <span>{path.clone()}</span> }.into_any()
} else {
view! { <a href=repo_href>{path.clone()}</a> }.into_any()
}}
</strong>
<Badge tone=tone(state_word) label=state_word/>
{percent.map(|p| view! { <span class="pct">{format!("{p}%")}</span> })}
</div>
// The bar exists ONLY when there is a denominator. `has_progress`
// false with (0, 0) draws nothing at all, because a bar at zero per
// cent claims a measurement that has not been taken.
{percent.map(|p| view! {
<div
class="bar"
role="progressbar"
aria-label=format!("syncing {path}")
aria-valuenow=p.to_string()
aria-valuemin="0"
aria-valuemax="100"
>
<span style=format!("width:{p}%")></span>
</div>
})}
<div class="meta">{line}</div>
// The remote, with any userinfo already removed by the BFF. The
// console never re-derives it.
<div class="meta">
{remote} " · credential: " {credential} " · last sync: " {last}
</div>
{mismatch.map(|why| view! { <div class="meta">{why}</div> })}
{lfs.map(|why| view! { <div class="meta" role="note">{why}</div> })}
{error.map(|e| view! {
<div class="failure" role="alert">
<h3>"This sync did not finish"</h3>
<p class="detail">{e}</p>
</div>
})}
// Stopped for want of room: the sentence is already the line
// above; this is the way on for THIS session (round 5.3).
{room_way_view(needs_room, room_href.clone())}
{(!updates.is_empty()).then(|| view! {
<div class="meta">
{updates
.iter()
.map(|u| format!("{} {}", fmt::short_ref(&u.ref_name), u.movement()))
.collect::<Vec<_>>()
.join(" · ")}
</div>
})}
<Show when=move || may_write>
<p class="row-actions">
<button
class="btn quiet"
disabled=move || busy.get() || live
on:click=sync
>
{move || if busy.get() { "Queueing…" } else { "Sync now" }}
</button>
<button class="btn quiet" on:click=forget>"Forget credential"</button>
<Show
when=move || confirming.get()
fallback=move || view! {
<button
class="btn danger"
on:click=move |_| confirming.set(true)
>
"Remove mirror"
</button>
}
>
<span class="hint">
{if pending {
"Stop this import? Nothing was copied yet, so nothing stays."
} else {
"Stop mirroring? The repository and everything already fetched stay."
}}
</span>
<button class="btn danger" on:click=remove>"Yes, remove"</button>
<button class="btn quiet" on:click=move |_| confirming.set(false)>
"Cancel"
</button>
</Show>
</p>
</Show>
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</li>
}
}
/// `<namespace>/<name>` with both segments encoded, for a URL path.
fn encoded(path: &str) -> String {
match path.split_once('/') {
Some((namespace, name)) => format!("{}/{}", segment(namespace), segment(name)),
None => segment(path),
}
}
// ───────────────────────────────────────────────────────────────────────────
// Connecting
// ───────────────────────────────────────────────────────────────────────────
/// **The two doors.**
///
/// Pasting a token is first because it is the one that always works: every
/// forge, self-hosted included, no application to register. The OAuth buttons
/// come from `GET /api/forges` and a forge with no application configured has
/// **no button** — absent, not disabled.
#[component]
pub fn ConnectPanel() -> impl IntoView {
let forges = RwSignal::new(None::<Result<Forges, crate::api::Fail>>);
let reload = RwSignal::new(0u32);
let refresh = move || reload.update(|n| *n += 1);
Effect::new(move |_| {
reload.track();
leptos::task::spawn_local(async move {
forges.set(Some(crate::api::get::<Forges>("/api/forges").await));
});
});
let chosen = RwSignal::new("github".to_owned());
let base = RwSignal::new(String::new());
let token = RwSignal::new(String::new());
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
let connect = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
let body = NewConnection {
forge: chosen.get_untracked(),
base: base.get_untracked().trim().to_owned(),
token: token.get_untracked(),
};
leptos::task::spawn_local(async move {
match crate::api::post::<_, Connected>("/api/forges/connect", &body).await {
Ok(_) => {
// Cleared the instant it has been sent. The console has no
// use for it afterwards — the BFF holds it — and a token
// left in a form field is a token in the page's memory and
// in whatever the browser restores after a reload.
token.set(String::new());
refresh();
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
view! {
<Panel title="Forges">
{move || match forges.get() {
// Forge import that is off is a setting (GUN-FIELD-TEST, 2026-11-01).
Some(Err(fail)) if crate::mirror::forges_off(&fail.code, &fail.message).is_some() => view! {
<div class="panel-body">
<p class="hint forges-off">{crate::mirror::forges_off(&fail.code, &fail.message).unwrap_or_default()}</p>
</div>
}.into_any(),
state => resolved(state, None, move |f: Forges| {
let connected = f.connected.clone();
let oauth = f.oauth.clone();
view! {
<div class="panel-body">
{(!connected.is_empty()).then(|| view! {
<dl class="metrics">
{connected
.iter()
.map(|c| {
let held = if c.holds_token {
"token held"
} else {
// Said out loud: an https import
// needs one, and the picker would
// otherwise offer a button the BFF
// refuses.
"token handed over — reconnect to import over HTTPS"
};
view! {
<Metric
label=c.forge.clone()
value=format!(
"{} · {} repositories",
if c.account.is_empty() {
"connected".to_owned()
} else {
c.account.clone()
},
c.repos,
)
unit=held
/>
}
})
.collect_view()}
</dl>
})}
<Hint
line="Paste a personal access token. It stays on the server, never in this browser."
more="It is kept on the server, keyed to your session — never in this browser, \
never in a cookie — and is handed to the appliance once if you mirror \
over HTTPS."
/>
<div class="form-row">
<div class="field">
<label for="forge">"Forge"</label>
<select
id="forge"
on:change=move |e| chosen.set(event_target_value(&e))
>
<option value="github">"GitHub"</option>
<option value="codeberg">"Codeberg / Forgejo / Gitea"</option>
<option value="gitlab">"GitLab"</option>
</select>
</div>
<div class="field">
<label for="base">"Self-hosted address"</label>
<input
id="base"
type="text"
placeholder="https://git.example.com — leave empty for the public one"
prop:value=move || base.get()
on:input=move |e| base.set(event_target_value(&e))
/>
</div>
<div class="field">
<label for="token">"Token"</label>
<input
id="token"
type="password"
autocomplete="off"
prop:value=move || token.get()
on:input=move |e| token.set(event_target_value(&e))
/>
</div>
</div>
<p class="row-actions">
<button
class="btn"
disabled=move || busy.get() || token.get().trim().is_empty()
on:click=connect
>
{move || if busy.get() { "Checking…" } else { "Connect with a token" }}
</button>
// A button per forge that has an application, and
// nothing at all for the rest.
{oauth
.iter()
.map(|forge| {
let href = format!("/api/forge/{}/authorize", segment(forge));
let label = format!("Connect {forge}");
view! { <a class="btn" href=href>{label}</a> }
})
.collect_view()}
</p>
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
}
}),
}}
</Panel>
}
}
/// What the connect form sends.
#[derive(Debug, Clone, Serialize)]
struct NewConnection {
forge: String,
base: String,
token: String,
}
// ───────────────────────────────────────────────────────────────────────────
// /mirrors/import
// ───────────────────────────────────────────────────────────────────────────
/// What the picker sends.
#[derive(Debug, Clone, Serialize)]
struct Import {
forge: String,
namespace: String,
transport: String,
repos: Vec<String>,
prune: bool,
include_archived: bool,
}
/// One repository's outcome, as the summary reports it.
#[derive(Debug, Clone, serde::Deserialize)]
struct Outcome {
full_name: String,
path: String,
ok: bool,
step: String,
message: String,
already_mirrored: bool,
/// Where to buy more room, on a row refused for the lack of it.
#[serde(default)]
room_href: Option<String>,
}
/// What the address form sends. The token rides here once, to the BFF, which
/// hands it to the appliance and keeps nothing.
#[derive(Debug, Clone, Serialize)]
struct ImportUrl {
url: String,
namespace: String,
name: String,
token: String,
username: String,
prune: bool,
/// The private CA an https address is issued under (PEM), passed through as typed;
/// gunnar judges it (GUN-MIRROR-CA).
ca_pem: String,
}
/// The whole import, summarised.
#[derive(Debug, Clone, serde::Deserialize)]
struct Imported {
results: Vec<Outcome>,
registered: usize,
queued: usize,
skipped: usize,
failed: usize,
}
/// What `GET /api/forges/{forge}/repos` answers.
#[derive(Debug, Clone, serde::Deserialize)]
struct Listing {
connection: Connected,
repos: Vec<ForgeRepo>,
}
/// ★ **The picker.**
///
/// Which repositories are shown, whether the button can be pressed, and the
/// sentence under each transport are all [`crate::mirror`]'s, so they are
/// tested; this draws them.
#[component]
pub fn ImportPicker() -> impl IntoView {
let session = crate::app::session();
// **SSH import is the operator's**: its deploy key comes from the
// appliance, which nobody else can reach (owner order 2026-10-18).
let operator = Memo::new(move |_| session.get().is_some_and(|s| s.may_write()));
let query = use_query_map();
// The OAuth callback lands here with `?forge=` and, when something went
// wrong, `?problem=` — a word from a closed set, never a forge's own text.
let forge = Memo::new(move |_| {
query
.read()
.get("forge")
.unwrap_or_else(|| "github".to_owned())
});
let problem = Memo::new(move |_| query.read().get("problem"));
let listing = RwSignal::new(None::<Result<Listing, crate::api::Fail>>);
Effect::new(move |_| {
let url = format!("/api/forges/{}/repos", segment(&forge.get()));
listing.set(None);
leptos::task::spawn_local(async move {
listing.set(Some(crate::api::get::<Listing>(&url).await));
});
});
// **Where an import may land, as gunnar answers it for this session**
// (`/api/repos/creatable`, round 5.3): an import makes a repository, which
// is the namespace's administrator's to make. `None` until both answers are
// in; `Some(None)` when gunnar names nowhere — the page says so, and draws
// no form gunnar would refuse. An unanswered ask fills in nothing and says
// so (round 5.3.1): no name is guessed for the person.
let creatable = LocalResource::new(|| async {
crate::api::get::<crate::pages::repos::Creatable>("/api/repos/creatable").await.ok()
});
let home = Memo::new(move |_| {
let status = session.get()?;
let answered = creatable.get()?;
let names: Vec<String> = answered.as_ref().map(|c| c.namespaces.iter().map(|n| n.name.clone()).collect()).unwrap_or_default();
Some(import_home(answered.as_ref().map(|c| (names.as_slice(), c.any_namespace)), status.account.as_deref(), &status.user))
});
let nowhere = Memo::new(move |_| match home.get() {
Some(None) if matches!(creatable.get(), Some(None)) => Some(IMPORT_UNASKED.to_owned()),
Some(None) => session.get().map(|s| {
let member = s.is_member().then(|| (s.account.clone().unwrap_or_default(), s.account_admin.clone().unwrap_or_default()));
nowhere_to_import(member.as_ref().map(|(a, b)| (a.as_str(), b.as_str())))
}),
_ => None,
});
let namespace = RwSignal::new(String::new());
// Defaults to where gunnar says this person may make repositories ([`home`]):
// the account it names for them, then their own — never a name of theirs
// gunnar holds no namespace under.
Effect::new(move |_| {
if let Some(Some(ns)) = home.get() {
if namespace.get_untracked().is_empty() {
namespace.set(ns);
}
}
});
let over_https = RwSignal::new(true);
let show_archived = RwSignal::new(false);
let prune = RwSignal::new(false);
let picked = RwSignal::new(Vec::<String>::new());
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
let summary = RwSignal::new(None::<Imported>);
let toggle = move |full_name: String| {
picked.update(|chosen| {
if let Some(at) = chosen.iter().position(|n| n == &full_name) {
chosen.remove(at);
} else {
chosen.push(full_name);
}
});
};
let import = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
summary.set(None);
let body = Import {
forge: forge.get_untracked(),
namespace: namespace.get_untracked().trim().to_owned(),
transport: if over_https.get_untracked() || !operator.get_untracked() {
"https"
} else {
"ssh"
}
.to_owned(),
repos: picked.get_untracked(),
prune: prune.get_untracked(),
include_archived: show_archived.get_untracked(),
};
leptos::task::spawn_local(async move {
match crate::api::post::<_, Imported>("/api/mirrors/import", &body).await {
Ok(done) => {
picked.set(Vec::new());
summary.set(Some(done));
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
view! {
<PageHeader
title="Import repositories"
>
<a class="btn quiet" href="/mirrors">"Your mirrors"</a>
</PageHeader>
<div class="stack">
{move || problem.get().map(|word| view! {
<div class="failure" role="alert">
<h3>"That forge connection did not complete"</h3>
<p class="detail">{oauth_problem(&word)}</p>
</div>
})}
{move || nowhere.get().map(|line| view! {
<div class="failure" role="status" id="import-nowhere">
<h3>"Nowhere to import into"</h3>
<p class="detail">{line}</p>
</div>
})}
// Not drawn where gunnar names nowhere: a form it would refuse.
<div class="stack" id="import-forms" hidden=move || nowhere.get().is_some()>
<ImportByAddress summary=summary home=home operator=operator/>
<ConnectPanel/>
// Not drawn when forge import is off: the Forges panel above says so once.
<Show when=move || !matches!(listing.get(), Some(Err(ref f)) if crate::mirror::forges_off(&f.code, &f.message).is_some())>
<Panel title="From a connected forge">
{move || match listing.get() {
// No connection held for this forge yet: the ordinary state
// for somebody who came for the address form, said as a
// hint and not as a failure.
Some(Err(fail)) if fail.status == 404 => view! {
<div class="panel-body">
<p class="hint">
"Connect a forge above to pick from its repositories, or \
import one by its address."
</p>
</div>
}
.into_any(),
state => resolved(state, None, move |l: Listing| {
let connection = l.connection.clone();
let rows = l.repos.clone();
let account = connection.account.clone();
view! {
<div class="panel-body">
<p class="hint">
{format!(
"{} repositories on {} for {}.",
rows.len(),
connection.forge,
if account.is_empty() { "this token" } else { &account },
)}
</p>
<div class="form-row">
<div class="field">
<label for="namespace">"Namespace on this box"</label>
<input
id="namespace"
type="text"
prop:value=move || namespace.get()
on:input=move |e| namespace.set(event_target_value(&e))
/>
<span class="hint">
"Each repository lands at <namespace>/<name>."
</span>
</div>
<Show when=move || operator.get()>
<div class="field">
<label for="transport">"Dial the forge over"</label>
<select
id="transport"
on:change=move |e| {
over_https.set(event_target_value(&e) == "https")
}
>
<option value="https">"HTTPS"</option>
<option value="ssh">"SSH"</option>
</select>
<span class="hint">
{move || transport_note(over_https.get())}
</span>
</div>
</Show>
</div>
<div class="switches">
<label class="switch">
<input
type="checkbox"
prop:checked=move || show_archived.get()
on:change=move |e| show_archived.set(event_target_checked(&e))
/>
" Show archived repositories"
</label>
<label class="switch">
<input
type="checkbox"
prop:checked=move || prune.get()
on:change=move |e| prune.set(event_target_checked(&e))
/>
{PRUNE_LABEL}
</label>
</div>
</div>
<div class="table-wrap">
<table>
<caption class="vh">"Repositories to import"</caption>
<thead>
<tr>
<th scope="col">
<button
class="btn quiet"
on:click={
let rows = rows.clone();
move |_| {
let all: Vec<String> =
visible(&rows, show_archived.get_untracked())
.into_iter()
.map(|r| r.full_name.clone())
.collect();
picked.update(|chosen| {
if chosen.len() == all.len() {
chosen.clear();
} else {
*chosen = all.clone();
}
});
}
}
>
"Select all"
</button>
</th>
<th scope="col">"Repository"</th>
<th scope="col">"Lands at"</th>
<th scope="col">"Default branch"</th>
<th scope="col">"Size"</th>
</tr>
</thead>
<tbody>
{move || {
// Owned copies: `visible` borrows from
// `rows`, and a view built from those
// references outlives the borrow.
visible(&rows, show_archived.get())
.into_iter()
.cloned()
.collect::<Vec<_>>()
.into_iter()
.map(|r| {
let full_name = r.full_name.clone();
let checked = {
let full_name = full_name.clone();
move || picked.get().contains(&full_name)
};
let on_toggle = {
let full_name = full_name.clone();
move |_| toggle(full_name.clone())
};
view! {
<tr>
<td>
<input
type="checkbox"
prop:checked=checked
on:change=on_toggle
/>
</td>
<td>
{r.full_name.clone()}
{r.private.then(|| view! {
" " <Badge tone=Tone::Warn label="private"/>
})}
{r.archived.then(|| view! {
" " <Badge tone=Tone::Plain label="archived"/>
})}
</td>
<td class="mono">
{
let leaf = r.name.clone();
move || format!(
"{}/{leaf}",
namespace.get().trim(),
)
}
</td>
<td>{r.default_branch.clone()}</td>
<td class="num">{r.size_line()}</td>
</tr>
}
})
.collect_view()
}}
</tbody>
</table>
</div>
<div class="panel-body">
{move || {
let ready = can_import(
Some(&connection),
over_https.get(),
&namespace.get(),
picked.get().len(),
operator.get(),
);
match ready {
Ok(()) => view! {
<button class="btn primary" disabled=move || busy.get() on:click=import>
{move || if busy.get() {
"Importing…".to_owned()
} else {
format!("Import {} repositories", picked.get().len())
}}
</button>
}
.into_any(),
Err(why) => view! {
<p class="hint">{why}</p>
}
.into_any(),
}
}}
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
}
.into_any()
}),
}}
</Panel>
</Show>
</div>
{move || summary.get().map(|done| view! { <ImportSummary done=done/> })}
</div>
}
}
/// ★ **Import one repository by its address** — any git server, no forge
/// connection needed.
///
/// Whether the button can be pressed is [`can_import_url`]'s, on the host side
/// where it is tested. The token field is a password field, cleared the moment
/// the request is sent: the BFF hands it to the appliance once and keeps
/// nothing, and neither does this page.
#[component]
fn ImportByAddress(
summary: RwSignal<Option<Imported>>,
home: Memo<Option<Option<String>>>,
operator: Memo<bool>,
) -> impl IntoView {
let url = RwSignal::new(String::new());
let namespace = RwSignal::new(String::new());
let name = RwSignal::new(String::new());
let token = RwSignal::new(String::new());
let username = RwSignal::new(String::new());
let ca_pem = RwSignal::new(String::new());
let prune = RwSignal::new(false);
let busy = RwSignal::new(false);
let failed = RwSignal::new(None::<crate::api::Fail>);
// Defaults to where gunnar says this person may make repositories (the
// picker's `home`, round 5.3) — never a name gunnar holds no namespace under.
Effect::new(move |_| {
if let Some(Some(ns)) = home.get() {
if namespace.get_untracked().is_empty() {
namespace.set(ns);
}
}
});
let send = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
summary.set(None);
let body = ImportUrl {
url: url.get_untracked().trim().to_owned(),
namespace: namespace.get_untracked().trim().to_owned(),
name: name.get_untracked().trim().to_owned(),
token: token.get_untracked(),
username: username.get_untracked().trim().to_owned(),
prune: prune.get_untracked(),
ca_pem: ca_pem.get_untracked(),
};
// Out of the form the moment it is on its way.
token.set(String::new());
leptos::task::spawn_local(async move {
match crate::api::post::<_, Imported>("/api/mirrors/import-url", &body).await {
Ok(done) => {
url.set(String::new());
name.set(String::new());
summary.set(Some(done));
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
view! {
<Panel title="From an address">
<div class="panel-body">
<Hint
line="Paste the address you would clone from. A private one over https also needs a token."
more="A public repository needs nothing else. For a private one over https, add a \
token — it is handed to this box once, sealed there, and never shown again."
/>
<div class="form-row">
<div class="field">
<label for="import-url">"Address"</label>
<input
id="import-url"
type="text"
placeholder="https://codeberg.org/owner/repo.git"
prop:value=move || url.get()
on:input=move |e| url.set(event_target_value(&e))
/>
</div>
<div class="field">
<label for="import-namespace">"Namespace"</label>
<input
id="import-namespace"
type="text"
prop:value=move || namespace.get()
on:input=move |e| namespace.set(event_target_value(&e))
/>
<span class="hint">
{move || format!(
"Lands at {}.",
lands_at_url(&namespace.get(), &url.get(), &name.get())
)}
</span>
</div>
<div class="field">
<label for="import-name">"Name here (optional)"</label>
<input
id="import-name"
type="text"
prop:value=move || name.get()
on:input=move |e| name.set(event_target_value(&e))
/>
</div>
</div>
<div class="form-row">
<div class="field">
<label for="import-token">"Token (private https only)"</label>
<input
id="import-token"
type="password"
autocomplete="off"
prop:value=move || token.get()
on:input=move |e| token.set(event_target_value(&e))
/>
</div>
<div class="field">
<label for="import-username">"Token user name (optional)"</label>
<input
id="import-username"
type="text"
autocomplete="off"
placeholder="filled in for github.com and gitlab.com"
prop:value=move || username.get()
on:input=move |e| username.set(event_target_value(&e))
/>
</div>
</div>
<div class="form-row">
<div class="field">
<label for="import-ca">"CA certificate (PEM, private https only)"</label>
<textarea
id="import-ca"
rows="3"
placeholder="-----BEGIN CERTIFICATE-----"
prop:value=move || ca_pem.get()
on:input=move |e| ca_pem.set(event_target_value(&e))
></textarea>
<span class="hint">"Only for a server behind your own CA. Trusted for this mirror alone."</span>
</div>
</div>
<div class="switches">
<label class="switch">
<input
type="checkbox"
prop:checked=move || prune.get()
on:change=move |e| prune.set(event_target_checked(&e))
/>
{PRUNE_LABEL}
</label>
</div>
{move || match can_import_url(
&url.get(),
&namespace.get(),
!token.get().trim().is_empty(),
operator.get(),
) {
Ok(()) => view! {
<p class="row-actions">
<button
id="import-url-go"
class="btn primary"
disabled=move || busy.get()
on:click=send
>
{move || if busy.get() { "Importing…" } else { "Import" }}
</button>
</p>
}
.into_any(),
Err(why) => view! { <p class="hint">{why}</p> }.into_any(),
}}
{move || failed.get().map(|fail| {
let room = fail.code == "needsroom";
view! {
<FailBox fail=fail/>
{room_link(room)}
}
})}
</div>
</Panel>
}
}
/// The way to more room, under a "needs more room" refusal. The community
/// build has no Account "More room" panel to send anybody to: no door there.
fn room_link(needs_room: bool) -> impl IntoView {
#[cfg(feature = "metered")]
let href = Some("/account".to_owned());
#[cfg(not(feature = "metered"))]
let href: Option<String> = None;
room_way_view(needs_room, href)
}
/// **The way on under "needs more room", for THIS session** (round 5.3):
/// [`room_way`] over the server's answers — a member reads the account's
/// line, a sold Gunnar its shop's, whoever buys here the More room door
/// (`href`, when this build has one). Nothing when room is not the matter.
fn room_way_view(needs_room: bool, href: Option<String>) -> impl IntoView {
let session = crate::app::session();
move || {
if !needs_room {
return ().into_any();
}
let way = session
.get()
.map(|s| room_way(s.account_line.as_deref(), s.sells_here(), s.shop_line.as_deref(), s.may_buy))
.unwrap_or(RoomWay::Nothing);
match (way, href.clone()) {
(RoomWay::Buy, Some(h)) => view! { <p class="row-actions"><a class="btn primary" href=h>"More room"</a></p> }.into_any(),
(RoomWay::Line(line), _) => view! { <p class="hint room-way">{line}</p> }.into_any(),
_ => ().into_any(),
}
}
}
/// **What happened to each repository**, including the ones that did not work.
///
/// Shown as a table and not as a toast: an import of forty repositories with
/// three refusals in it is a result somebody has to read, and gunnar's words on
/// each row are the only thing that says why.
#[component]
fn ImportSummary(done: Imported) -> impl IntoView {
let counts = format!(
"{} registered · {} queued · {} skipped · {} failed",
done.registered, done.queued, done.skipped, done.failed
);
view! {
<Panel title="Import result">
<div class="panel-body">
<p class="hint">{counts}</p>
<p class="hint">
<a href="/mirrors">"Watch them arrive on Your mirrors →"</a>
</p>
</div>
<div class="table-wrap">
<table>
<caption class="vh">"What happened to each repository"</caption>
<thead>
<tr>
<th scope="col">"Repository"</th>
<th scope="col">"Landed at"</th>
<th scope="col">"Result"</th>
<th scope="col">"Detail"</th>
</tr>
</thead>
<tbody>
{done.results
.into_iter()
.map(|r| {
let (badge, word) = match (r.ok, r.step.as_str()) {
(true, "skipped") => (Tone::Plain, "skipped".to_owned()),
(true, _) if r.already_mirrored => (Tone::Pass, "re-registered".to_owned()),
(true, _) => (Tone::Pass, "queued".to_owned()),
(false, "room") => (Tone::Warn, "needs more room".to_owned()),
(false, "busy") => (Tone::Warn, "not yet".to_owned()),
(false, "exists") => (Tone::Warn, "name taken".to_owned()),
(false, step) => (Tone::Bad, step.to_owned()),
};
let room = r.room_href.clone();
view! {
<tr>
<td>{r.full_name}</td>
<td class="mono">{r.path}</td>
<td><Badge tone=badge label=word/></td>
<td>
{r.message}
{room.map(|href| view! {
" " <a href=href>"More room →"</a>
})}
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
</Panel>
}
}
/// The closed set of words an OAuth callback can come back with, in sentences.
///
/// The forge's own message is deliberately not carried in the URL: putting
/// somebody else's text in this console's address bar puts it in history and in
/// every referrer.
fn oauth_problem(word: &str) -> &'static str {
match word {
"bad-state" => {
"The callback matches no unused request from this console. Nothing was exchanged; start \
again from the button."
}
"no-code" => "The forge came back without an authorization code.",
"no-app" => {
"This console has no OAuth application for that forge. Paste a personal access token: it \
works on every forge."
}
"exchange-refused" => {
"The forge would not exchange the code for a token. It may have expired; start \
again from the button."
}
"exchange-failed" | "exchange-unreadable" => {
"This console could not reach the forge's token endpoint, or could not read what \
it answered."
}
"listing-refused" => {
"The token came back, but the forge would not list the repositories with it. Check the \
application's scopes."
}
_ => "The forge connection did not complete.",
}
}
// ───────────────────────────────────────────────────────────────────────────
// The panel on a repository page
// ───────────────────────────────────────────────────────────────────────────
/// ★ **Where this repository came from**, on its own page.
///
/// Absent when there is no mirror, deliberately: a panel reading "not a mirror"
/// on every one of a hundred repositories is noise an operator learns to skip,
/// and then misses on the one that is. Same rule the Overview's index panel
/// states.
///
/// The poller is the gc panel's shape: read once on mount so a sync started
/// from the CLI or another console is visible without pressing anything, poll
/// while the job is live, stop when it settles, one poller behind a guard.
#[component]
pub fn MirrorPanel(namespace: Memo<String>, name: Memo<String>) -> impl IntoView {
let mirror = RwSignal::new(None::<Mirror>);
let polling = RwSignal::new(false);
let reload = RwSignal::new(0u32);
let url = move || {
format!(
"/api/mirrors/{}/{}",
segment(&namespace.get()),
segment(&name.get())
)
};
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(POLL_MS).await;
match crate::api::get::<Option<Mirror>>(&url).await {
Ok(latest) => {
let keep = latest.as_ref().is_some_and(Mirror::in_flight);
mirror.set(latest);
if !keep {
break;
}
}
// Stop on the first refusal rather than hammering a server
// that is already saying no.
Err(_) => break,
}
}
polling.set(false);
});
};
// Reads `namespace`/`name`, so switching repository re-runs this and the
// panel shows the NEW repository's mirror instead of the old one's.
Effect::new(move |_| {
reload.track();
let url = url();
leptos::task::spawn_local(async move {
if let Ok(current) = crate::api::get::<Option<Mirror>>(&url).await {
let keep = current.as_ref().is_some_and(Mirror::in_flight);
mirror.set(current);
if keep {
start_polling(url);
}
}
});
});
view! {
{move || mirror.get().map(|m| view! {
<Panel title="Mirrored from">
<ul class="indexing">
<MirrorRow
mirror=m
on_change=Callback::new(move |()| reload.update(|n| *n += 1))
/>
</ul>
// "Your mirrors" lives here and on Repositories since the
// final navigation (2026-10-02) — not in a menu.
<p class="panel-body"><a class="btn quiet" href="/mirrors">"Your mirrors"</a></p>
</Panel>
})}
}
}