Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-web/src/pages/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>
        })}
    }
}