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

nordisk/holger-ui

src/pages/repositories.rs

//! Repository list — scannable, with the writability/archive facts as badges
//! (never color alone), every name a real link, and the filter/sort state in
//! the URL so a filtered view is a shareable address.

use leptos::prelude::*;
use leptos_router::components::A;
use leptos_router::hooks::{use_navigate, use_query_map};

use crate::components::feedback::{Badge, EmptyState, FailBox, Tone};
use crate::components::skeleton::TableSkeleton;
use crate::grpc;
use crate::util::{enc, group_label, matches_name_filter, repo_order, repositories_href, toggled_sort};

/// **The Repositories entry's tabs** (lane HOL-UI-FIX): the list, and what works across
/// repositories: search, replication and the migration in (once the menu's Search and
/// Operations entries; the menu has one entry per job now).
pub const REPO_TABS: [(&str, &str); 4] = [("/repositories", "All repositories"), ("/search", "Search"), ("/operations", "Replicate"), ("/operations/migrate", "Migrate in")];

/// The tab strip of [`REPO_TABS`], the open one marked.
#[component]
pub fn RepoTabs() -> impl IntoView {
    view! {
        <nav class="subnav" aria-label="Repositories">
            {REPO_TABS.iter().map(|(href, label)| view! { <A href=*href exact=true>{*label}</A> }).collect_view()}
        </nav>
    }
}

#[component]
pub fn Repositories() -> impl IntoView {
    crate::webio::set_title("Repositories");
    let query = use_query_map();
    let navigate = use_navigate();

    let repos = LocalResource::new(|| async {
        grpc::admin()
            .list_repositories(grpc::pb::ListRepositoriesRequest {})
            .await
            .map(|r| r.into_inner().repositories)
            .map_err(|s| grpc::fail(&s))
    });

    // Filter + sort live in the URL (?q=…&sort=name|type) — typed state is
    // synced on Apply/Enter, and a shared address reproduces the view.
    let q_filter = Memo::new(move |_| query.read().get("q").unwrap_or_default());
    let sort_key = Memo::new(move |_| query.read().get("sort").unwrap_or_default());
    let typed = RwSignal::new(String::new());
    Effect::new(move |_| typed.set(q_filter.get()));

    let apply = {
        let navigate = navigate.clone();
        move || {
            let typed = typed.get_untracked();
            navigate(
                &repositories_href(typed.trim(), &sort_key.get_untracked()),
                Default::default(),
            );
        }
    };
    let apply_click = apply.clone();

    // Clicking the active sort clears it (back to config order).
    let sort_by = move |key: &'static str| {
        navigate(
            &repositories_href(
                &q_filter.get_untracked(),
                toggled_sort(&sort_key.get_untracked(), key),
            ),
            Default::default(),
        );
    };

    view! {
        <section class="page">
            <RepoTabs/>
            <h2>"Repositories"</h2>
            <div class="toolbar">
                <label class="vh" for="repo-filter">"Filter repositories by name"</label>
                <input
                    id="repo-filter"
                    type="search"
                    placeholder="filter by name…"
                    prop:value=move || typed.get()
                    on:input=move |ev| typed.set(event_target_value(&ev))
                    on:keydown=move |ev| { if ev.key() == "Enter" { apply(); } }
                />
                <button on:click=move |_| apply_click()>"Apply"</button>
            </div>
            {move || match repos.get() {
                None => view! { <TableSkeleton rows=6 cols=5 what="repositories"/> }.into_any(),
                Some(Err(f)) => {
                    let f2 = f.clone();
                    view! {
                        <FailBox context="failed to list repositories"
                                 fail=Signal::derive(move || f2.clone())
                                 retry=Callback::new(move |()| repos.refetch())/>
                    }.into_any()
                }
                Some(Ok(list)) if list.is_empty() =>
                    view! { <EmptyState message="no repositories configured — the server's RON graph defines them at startup"/> }.into_any(),
                Some(Ok(list)) => {
                    let needle = q_filter.get();
                    let mut list: Vec<_> = list
                        .into_iter()
                        .filter(|r| matches_name_filter(&r.name, &needle))
                        .collect();
                    // Stable sort: an unknown/absent key compares equal, which
                    // leaves the server's configuration order untouched.
                    let sort = sort_key.get();
                    list.sort_by(|a, b| {
                        repo_order((&a.name, &a.repo_type), (&b.name, &b.repo_type), &sort)
                    });
                    if list.is_empty() {
                        // A filter with zero matches is NOT "no repositories".
                        return view! {
                            <EmptyState message="no repository name matches the filter"/>
                        }.into_any();
                    }
                    let shown = list.len();
                    let sort_name = sort_by.clone();
                    let sort_type = sort_by.clone();
                    view! {
                        <div class="tablewrap">
                            <table>
                                <caption class="vh">"Configured repositories"</caption>
                                <thead>
                                    <tr>
                                        <th scope="col">
                                            <button class="th-sort" on:click=move |_| sort_name("name")>
                                                {move || if sort_key.get() == "name" { "Name ▲" } else { "Name" }}
                                            </button>
                                        </th>
                                        <th scope="col">
                                            <button class="th-sort" on:click=move |_| sort_type("type")>
                                                {move || if sort_key.get() == "type" { "Type ▲" } else { "Type" }}
                                            </button>
                                        </th>
                                        <th scope="col">"Access"</th>
                                        <th scope="col">"Storage"</th>
                                        <th scope="col">"Actions"</th>
                                    </tr>
                                </thead>
                                <tbody>
                                    {list.into_iter().map(|r| {
                                        let href = format!("/repo/{}", enc(&r.name));
                                        let search_href = format!("/search?repo={}", enc(&r.name));
                                        let access = if r.writable {
                                            view! { <Badge tone=Tone::Good label="writable"/> }
                                        } else {
                                            view! { <Badge tone=Tone::Neutral label="read-only"/> }
                                        };
                                        // A group is a VIEW over its members and owns no
                                        // storage of its own — said on the row from holger's
                                        // `is_group`/`members`, never guessed from the type.
                                        let group = group_label(r.is_group, r.members.len());
                                        let storage = if r.is_group {
                                            view! { <span class="count">"view over members"</span> }.into_any()
                                        } else if r.has_archive {
                                            view! { <Badge tone=Tone::Neutral label="znippy archive"/> }.into_any()
                                        } else {
                                            view! { <span class="count">"file-backed"</span> }.into_any()
                                        };
                                        view! {
                                            <tr>
                                                <td><a href=href.clone()>{r.name.clone()}</a></td>
                                                <td>
                                                    {r.repo_type}
                                                    {group.map(|g| view! { " " <span class="count">{g}</span> })}
                                                </td>
                                                <td>{access}</td>
                                                <td>{storage}</td>
                                                <td class="rowactions">
                                                    <a href=href>"browse"</a>
                                                    " · "
                                                    <a href=search_href>"search"</a>
                                                </td>
                                            </tr>
                                        }
                                    }).collect_view()}
                                </tbody>
                            </table>
                        </div>
                        <p class="count">{format!("{shown} shown")}</p>
                    }.into_any()
                }
            }}
        </section>
    }
}