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>
}
}