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/repos.rs

//! The repository catalogue.

use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_query_map};
use serde::{Deserialize, Serialize};

use crate::api::Page;
use crate::app::session;
use crate::pages::resolved;
use crate::ui::{Badge, Empty, FailBox, PageHeader, Panel, Tone, Truncation};

/// A repository as the console shows it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Repo {
    pub path: String,
    pub namespace: String,
    pub name: String,
    pub hash_kind: String,
    pub default_branch: String,
    pub visibility: String,
    pub created_at_unix_ms: Option<i64>,
    pub description: String,
    /// What it stores (the list's one size column, owner 2026-10-02). Absent
    /// when the server could not read the sizes.
    #[serde(default)]
    pub stored_bytes: Option<u64>,
    /// gunnar's "encrypted" flag (the account admin's): the badge, and the Settings control.
    #[serde(default)]
    pub encrypted: bool,
}

#[derive(Debug, Clone, Serialize)]
struct NewRepo {
    namespace: String,
    name: String,
    visibility: String,
    description: String,
    /// The server holds ciphertext only, from the start (`Repos.SetEncrypted`).
    encrypted: bool,
}

/// **Where this session's New repository may create**, in gunnar's words
/// (`GET /api/repos/creatable`, owner 2026-10-06): an operator's session types
/// the namespace as before (`any_namespace`); an account admin picks one of the
/// accounts gunnar says they administer. Nothing here decides — the create is
/// gunnar's too.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct Creatable {
    #[serde(default)]
    pub any_namespace: bool,
    #[serde(default)]
    pub namespaces: Vec<CreatableNamespace>,
}

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct CreatableNamespace {
    pub name: String,
    #[serde(default)]
    pub public: bool,
}

impl Creatable {
    /// The button is offered: somewhere to create.
    pub fn offers(&self) -> bool {
        self.any_namespace || !self.namespaces.is_empty()
    }
    /// May a repository in `namespace` be made public from here?
    pub fn may_publish(&self, namespace: &str) -> bool {
        self.any_namespace || self.namespaces.iter().any(|n| n.name == namespace && n.public)
    }
}

#[component]
pub fn Repos() -> impl IntoView {
    let query = use_query_map();
    let navigate = use_navigate();
    let reload = RwSignal::new(0u32);

    // The filter lives in the URL, so a filtered view is an address someone can
    // send to a colleague.
    let filter = Memo::new(move |_| query.read().get("q").unwrap_or_default());
    // **Imported only**: repositories that are mirrors, as gunnar lists them.
    let imported = RwSignal::new(false);
    // **Just deleted** (owner spec 2026-10-28): the Danger zone comes here with
    // `?deleted=<ns>/<name>`; the notice says so and offers its Undo.
    let deleted = Memo::new(move |_| query.read().get("deleted").unwrap_or_default());

    let repos = LocalResource::new(move || {
        // Reading the signal here is what makes a creation or deletion show up
        // without a page reload.
        reload.get();
        // **The search is the server's** (thin layer, owner 2026-10-04): the
        // name query and "Imported only" go to gunnar, which applies them
        // before anything is paged, so the 251st repository is found too.
        // Until then this page filtered the first 250 it was handed.
        let q = filter.get();
        let only = imported.get();
        async move {
            let mut url = "/api/repos".to_owned();
            let mut sep = '?';
            if !q.trim().is_empty() {
                url.push_str(&format!("{sep}q={}", crate::auth::percent_encode(q.trim())));
                sep = '&';
            }
            if only {
                url.push_str(&format!("{sep}imported=true"));
            }
            crate::api::get::<Page<Repo>>(&url).await
        }
    });
    let typed = RwSignal::new(String::new());
    Effect::new(move |_| typed.set(filter.get()));

    let apply = move || {
        let text = typed.get_untracked();
        let text = text.trim();
        let target = if text.is_empty() {
            "/repos".to_owned()
        } else {
            format!("/repos?q={}", crate::auth::percent_encode(text))
        };
        navigate(&target, Default::default());
    };
    let apply_button = apply.clone();

    let creating = RwSignal::new(false);
    // Where New repository may create, asked of the server for THIS session
    // (gunnar answers it); a visitor has nowhere.
    let creatable = LocalResource::new(move || {
        // Read here, outside the future, so a sign-in asks again.
        let signed_in = session().get().is_some_and(|s| s.authenticated || s.open);
        async move {
            if signed_in {
                crate::api::get::<Creatable>("/api/repos/creatable").await.unwrap_or_default()
            } else {
                Creatable::default()
            }
        }
    });
    let can_create = move || creatable.get().is_some_and(|c| c.offers());
    let signed_in = move || session().get().is_some_and(|s| s.authenticated || s.open);
    // The forge's SSH host key is for someone who will push: a visitor is not
    // shown it (owner order 2026-10-18).
    let server_key = LocalResource::new(move || {
        let signed_in = signed_in();
        async move {
            if !signed_in {
                return None;
            }
            crate::api::get::<crate::pages::overview::ServerKey>("/api/server-key").await.ok()
        }
    });
    // **Why "New repository" is not offered**, when it is not (owner order
    // 2026-10-18): the server's answers in words — a member's account admin,
    // a Free account (the queue's `free-cannot-push`), or no room at all.
    let free = LocalResource::new(move || {
        let signed_in = signed_in();
        async move {
            if !signed_in {
                return None;
            }
            crate::api::get::<crate::actions::Actions>("/api/account/actions")
                .await
                .ok()
                .and_then(|a| a.items.into_iter().find(|i| i.id == crate::first_push::FREE_ITEM))
        }
    });
    let why_not = move || {
        let s = session().get()?;
        if !s.authenticated || s.open || creatable.get().is_none_or(|c| c.offers()) {
            return None;
        }
        let member = s.is_member().then(|| (s.account.clone().unwrap_or_default(), s.account_admin.clone().unwrap_or_default()));
        let free = free.get().flatten();
        Some((crate::first_push::why_no_create(member.as_ref().map(|(a, b)| (a.as_str(), b.as_str())), free.is_some()), free))
    };
    view! {
        // Line 2 is the forge's SSH host key (owner, 2026-10-02): what a user
        // checks their ssh client's first-connect prompt against, read live
        // (`GET /api/server-key`), never written here.
        <PageHeader
            title="Repositories"
            line=view! {
                {move || server_key
                    .get()
                    .flatten()
                    .and_then(|k| crate::headers::server_key_line(&k.fingerprint))
                    .map(|said| view! { <span class="mono">{said}</span> })}
            }.into_any()
        >
            // **The page's own actions are BUTTONS here, not menu entries**
            // (owner's final navigation, 2026-10-02): "New repository" for
            // whoever may create one — an operator, and since 2026-10-06 an
            // account admin, where gunnar says (`/api/repos/creatable`) — and
            // "Import repository" beside it on the same answer (round 5.3): an
            // import MAKES a repository, so it is offered where gunnar names a
            // namespace this person may make one in — an account's member it
            // names none for (the members walkthrough: ben's Import landed in
            // `ben/`, which does not exist) is offered no dead end. A visitor
            // has no namespace and no button.
            <Show when=can_create>
                <button class="btn primary" id="new-repository" on:click=move |_| creating.update(|on| *on = !*on)>
                    "New repository"
                </button>
            </Show>
            <Show when=move || can_create() && session().get().is_some_and(|s| s.authenticated || s.open)>
                <a class="btn" href="/mirrors/import">"Import repository"</a>
            </Show>
        </PageHeader>

        <div class="stack">
            <Show when=move || crate::repo_delete::split(&deleted.get()).is_some()>
                <DeletedNotice path=deleted on_restored=Callback::new(move |()| reload.update(|n| *n += 1))/>
            </Show>
            {move || why_not().map(|(why, free)| view! {
                <p class="hint" id="why-no-new-repository">
                    {why}
                    {free.map(|item| view! { " " <a href=item.href.clone()>{item.cta.clone()}</a> })}
                </p>
            })}
            <Show when=move || creating.get() && can_create()>
                <CreateRepo
                    creatable=Signal::derive(move || creatable.get().unwrap_or_default())
                    on_created=Callback::new(move |()| reload.update(|n| *n += 1))
                />
            </Show>

            <Panel title="Catalog">
                <div class="panel-body row">
                    <label class="vh" for="filter">"Filter"</label>
                    <input
                        id="filter"
                        type="search"
                        placeholder="namespace/name"
                        prop:value=move || typed.get()
                        on:input=move |ev| typed.set(event_target_value(&ev))
                        on:keydown={
                            let apply = apply.clone();
                            move |ev: leptos::ev::KeyboardEvent| {
                                if ev.key() == "Enter" {
                                    apply();
                                }
                            }
                        }
                    />
                    <button class="btn" on:click=move |_| apply_button()>"Filter"</button>
                    <Show when=move || session().get().is_some_and(|s| s.authenticated || s.open)>
                        <label class="toggle">
                            <input type="checkbox" id="imported-only" prop:checked=move || imported.get() on:change=move |ev| imported.set(event_target_checked(&ev))/>
                            " Imported only"
                        </label>
                        <a class="btn quiet" href="/mirrors">"Your mirrors"</a>
                    </Show>
                </div>

                {move || {
                    let searching = !filter.get().trim().is_empty() || imported.get();
                    resolved(repos.get(), None, move |page: Page<Repo>| {
                        let total = page.total;
                        // Already the server's answer to the search.
                        let matching: Vec<Repo> = page.items;

                        if total == 0 && !searching {
                            let (title, detail) = crate::first_push::empty_catalogue(
                                session().get_untracked().is_some_and(|s| s.authenticated || s.open),
                            );
                            return view! { <Empty title=title detail=detail/> }.into_any();
                        }
                        if matching.is_empty() {
                            // A filter with no matches is not an empty server.
                            return view! {
                                <Empty
                                    title="No name matches the filter"
                                    detail="Clear the search field to see the whole catalogue again."
                                />
                            }
                            .into_any();
                        }
                        let shown = matching.len();
                        view! {
                            <div class="table-wrap">
                                <table>
                                    <caption class="vh">"Repositories"</caption>
                                    <thead>
                                        <tr>
                                            <th scope="col">"Repository"</th>
                                            <th scope="col">"Visibility"</th>
                                            <th scope="col">"Hash"</th>
                                            <th scope="col">"Default branch"</th>
                                            <th scope="col">"Size"</th>
                                            // No visible label: the column is one
                                            // link per row and a heading over it
                                            // would say less than the link does.
                                            <th scope="col"><span class="vh">"Actions"</span></th>
                                        </tr>
                                    </thead>
                                    <tbody>
                                        {matching
                                            .into_iter()
                                            .map(|repo| {
                                                let href = format!(
                                                    "/{}/{}",
                                                    crate::api::segment(&repo.namespace),
                                                    crate::api::segment(&repo.name),
                                                );
                                                let files = format!("{href}/tree");
                                                let tone = match repo.visibility.as_str() {
                                                    "public" => Tone::Warn,
                                                    _ => Tone::Plain,
                                                };
                                                view! {
                                                    <tr>
                                                        <td>
                                                            <a href=href>{repo.path.clone()}</a>
                                                            {(!repo.description.is_empty())
                                                                .then(|| view! {
                                                                    <div class="unset" style="font-style:normal">
                                                                        {repo.description.clone()}
                                                                    </div>
                                                                })}
                                                        </td>
                                                        <td>
                                                            <Badge tone=tone label=repo.visibility.clone()/>
                                                            {crate::encryption::badge(repo.encrypted).map(|b| view! {
                                                                " "<Badge tone=Tone::Plain label=b/>
                                                            })}
                                                        </td>
                                                        <td class="mono">{repo.hash_kind.clone()}</td>
                                                        <td class="mono">
                                                            {crate::fmt::short_ref(&repo.default_branch).to_owned()}
                                                        </td>
                                                        <td class="num">{repo.stored_bytes.map(crate::fmt::bytes).unwrap_or_default()}</td>
                                                        <td class="row-actions">
                                                            <a class="btn quiet" href=files>
                                                                "Browse"
                                                                // Every row's link says the same
                                                                // word, so the accessible name
                                                                // carries which repository it opens.
                                                                <span class="vh">
                                                                    {format!(" the code of {}", repo.path)}
                                                                </span>
                                                            </a>
                                                        </td>
                                                    </tr>
                                                }
                                            })
                                            .collect_view()}
                                    </tbody>
                                </table>
                            </div>
                            {(shown < total)
                                .then(|| view! { <Truncation shown=shown total=total/> })}
                        }
                        .into_any()
                    })
                }}
            </Panel>
        </div>
    }
}

/// ★ **"Deleted <ns>/<name>" and its Undo** (owner spec 2026-10-28), on the list the Danger
/// zone came back to. The Undo is `Repos.Undelete` signed as the person
/// (`POST /api/repos/{ns}/{name}/undelete`); gunnar decides, and a refusal is said inline in
/// plain words with gunnar's own sentence under it. The administrator also gets the way to the
/// box's whole list of deleted repositories (Admin → Storage → Deleted, with its Restore).
#[component]
fn DeletedNotice(path: Memo<String>, on_restored: Callback<()>) -> impl IntoView {
    let busy = RwSignal::new(false);
    let restored = RwSignal::new(None::<String>);
    let failed = RwSignal::new(None::<(String, crate::api::Fail)>);
    Effect::new(move |_| {
        let _ = path.get();
        restored.set(None);
        failed.set(None);
    });
    let undo = move |_| {
        let at = path.get_untracked();
        let Some((ns, name)) = crate::repo_delete::split(&at) else { return };
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        leptos::task::spawn_local(async move {
            match crate::api::post_empty::<serde_json::Value>(&crate::repo_delete::undelete_url(&ns, &name)).await {
                Ok(_) => {
                    restored.set(Some(at));
                    on_restored.run(());
                }
                Err(fail) => failed.set(Some((crate::repo_delete::undo_refused(fail.status, &at), fail))),
            }
            busy.set(false);
        });
    };
    let admin = move || session().get().is_some_and(|s| s.is_admin());
    view! {
        <div class="notice repo-deleted" id="repo-deleted-notice" role="status">
            {move || match restored.get() {
                Some(at) => {
                    let href = crate::repo_delete::split(&at)
                        .map(|(ns, name)| format!("/{}/{}", crate::api::segment(&ns), crate::api::segment(&name)))
                        .unwrap_or_default();
                    view! {
                        <span id="repo-restored">{crate::repo_delete::restored(&at)}</span>
                        <a class="btn quiet" href=href>"Open it"</a>
                    }
                    .into_any()
                }
                None => view! {
                    <span id="repo-deleted">{move || crate::repo_delete::notice(&path.get())}</span>
                    <button
                        id="repo-undo"
                        class=move || if busy.get() { "btn busy" } else { "btn" }
                        disabled=move || busy.get()
                        on:click=undo
                    >
                        "Undo"
                    </button>
                    {move || admin().then(|| view! {
                        <a id="repo-recently-deleted" class="btn quiet" href=crate::repo_delete::RECENTLY_DELETED>"Recently deleted"</a>
                    })}
                }
                .into_any(),
            }}
            {move || failed.get().map(|(line, fail)| view! {
                <p class="refused" id="repo-undo-refused">{line}</p>
                <FailBox fail=fail/>
            })}
        </div>
    }
}

/// The create form, shown only to sessions that could succeed with it: the
/// namespace typed (an operator's) or picked from the accounts gunnar says the
/// person administers.
#[component]
fn CreateRepo(creatable: Signal<Creatable>, on_created: Callback<()>) -> impl IntoView {
    let navigate = use_navigate();
    let namespace = RwSignal::new(
        creatable.get_untracked().namespaces.first().map(|n| n.name.clone()).unwrap_or_default(),
    );
    let name = RwSignal::new(String::new());
    let visibility = RwSignal::new("private".to_owned());
    let description = RwSignal::new(String::new());
    let encrypted = RwSignal::new(false);
    // The level `gunnar crypt init` seals it at: gunnar's own default first (CRYPT-PQ-DEFAULT).
    let level = RwSignal::new(crate::encryption::level_choices()[0].0);
    let busy = RwSignal::new(false);
    let failed = RwSignal::new(None::<crate::api::Fail>);

    let submit = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        failed.set(None);
        let body = NewRepo {
            namespace: namespace.get_untracked().trim().to_owned(),
            name: name.get_untracked().trim().to_owned(),
            visibility: visibility.get_untracked(),
            description: description.get_untracked(),
            encrypted: encrypted.get_untracked() && creatable.get_untracked().may_publish(&namespace.get_untracked()),
        };
        let navigate = navigate.clone();
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Repo>("/api/repos", &body).await {
                // ★ **To the new repository** (owner order 2026-10-18), not a
                // reload of the list: its page shows how to push to it.
                Ok(made) => {
                    name.set(String::new());
                    description.set(String::new());
                    encrypted.set(false);
                    on_created.run(());
                    navigate(
                        &format!("/{}/{}", crate::api::segment(&made.namespace), crate::api::segment(&made.name)),
                        Default::default(),
                    );
                }
                Err(fail) => failed.set(Some(fail)),
            }
            busy.set(false);
        });
    };

    view! {
        <Panel title="New repository">
            <div class="panel-body">
                <div class="row" style="align-items:flex-end">
                    <div class="field">
                        <label for="ns">"Namespace"</label>
                        {move || if creatable.get().any_namespace {
                            view! {
                                <input
                                    id="ns"
                                    type="text"
                                    prop:value=move || namespace.get()
                                    on:input=move |ev| namespace.set(event_target_value(&ev))
                                />
                            }.into_any()
                        } else {
                            view! {
                                <select id="ns" on:change=move |ev| {
                                    namespace.set(event_target_value(&ev));
                                    visibility.set("private".to_owned());
                                }>
                                    {creatable.get().namespaces.into_iter().map(|n| {
                                        let selected = n.name == namespace.get_untracked();
                                        view! { <option value=n.name.clone() selected=selected>{n.name.clone()}</option> }
                                    }).collect_view()}
                                </select>
                            }.into_any()
                        }}
                    </div>
                    <div class="field">
                        <label for="name">"Name"</label>
                        <input
                            id="name"
                            type="text"
                            prop:value=move || name.get()
                            on:input=move |ev| name.set(event_target_value(&ev))
                        />
                    </div>
                    <div class="field">
                        <label for="vis">"Visibility"</label>
                        <select
                            id="vis"
                            on:change=move |ev| visibility.set(event_target_value(&ev))
                        >
                            <option value="private">"private"</option>
                            // No "internal" here, deliberately: gunnar has two
                            // visibilities and refuses INTERNAL by name
                            // ("public" means anonymous-readable, so internal
                            // no longer aliases it). Offering a value the server
                            // refuses — or worse, silently widens, which is what
                            // it did until 2026-08-19 — is how a picker lies.
                            <Show when=move || creatable.get().may_publish(&namespace.get())>
                                <option value="public">"public — readable by anyone, no key"</option>
                            </Show>
                        </select>
                    </div>
                    // **Encrypted is the account admin's decision**, as public is
                    // (`Repos.SetEncrypted`: Admin over the namespace — the same word
                    // `may_publish` reads; wiring tracer B 2026-10-08). Not offered where
                    // gunnar would refuse it.
                    <Show when=move || creatable.get().may_publish(&namespace.get())>
                    <div class="field">
                        <label for="enc">
                            <input
                                id="enc"
                                type="checkbox"
                                prop:checked=move || encrypted.get()
                                on:change=move |ev| encrypted.set(event_target_checked(&ev))
                            />
                            " encrypted"
                        </label>
                        " "
                        <crate::ui::InfoTip markup=crate::encryption::INFO/>
                        <Show when=move || encrypted.get()>
                            <div class="field enc-level-choice">
                                <label for="enc-level">"Level"</label>
                                <select id="enc-level" on:change=move |ev| {
                                    let picked = event_target_value(&ev);
                                    if let Some((l, _)) = crate::encryption::level_choices().into_iter().find(|(l, _)| l.word() == picked) {
                                        level.set(l);
                                    }
                                }>
                                    {crate::encryption::level_choices().into_iter().map(|(l, words)| view! {
                                        <option value=l.word() selected=move || level.get() == l>{words}</option>
                                    }).collect_view()}
                                </select>
                            </div>
                            <p class="hint-line cli-hint enc-seal">
                                "Seal it with " <code class="mono">{move || crate::encryption::seal_command(level.get())}</code>
                                " on your own computer. "
                                <a href=crate::cli::PAGE>{crate::cli::LINK}</a>
                            </p>
                        </Show>
                    </div>
                    </Show>
                    <div class="field" style="flex:1;min-width:12rem">
                        <label for="desc">"Description"</label>
                        <input
                            id="desc"
                            type="text"
                            prop:value=move || description.get()
                            on:input=move |ev| description.set(event_target_value(&ev))
                        />
                    </div>
                    <button
                        class=move || if busy.get() { "btn primary busy" } else { "btn primary" }
                        disabled=move || busy.get()
                        on:click=submit
                    >
                        "Create"
                    </button>
                </div>
                <p class="hint" style="margin-top:.5rem;color:var(--ink-mute);font-size:.8rem">
                    "New repositories get sha256 unless told otherwise. Encrypted: the server \
                     holds ciphertext only."
                </p>
                {move || failed.get().map(|fail| view! {
                    <div style="margin-top:.75rem"><FailBox fail=fail/></div>
                })}
            </div>
        </Panel>
    }
}