src/pages/repository_detail.rs
//! One repository's home: metadata header, browse (filter + offset-cursor
//! pagination), the archive view when the backend carries a znippy archive,
//! and upload when the repository accepts writes.
use leptos::prelude::*;
use leptos::task::spawn_local;
use leptos_router::hooks::use_params_map;
use crate::components::feedback::{Badge, EmptyState, ErrorText, FailBox, Loading, Tone};
use crate::components::skeleton::TableSkeleton;
use crate::grpc;
use crate::util::{artifact_href, disk_view, display_name, empty_listing_note, enc, fmt_bytes, DiskView};
use crate::webio;
/// One page of artifacts per fetch; the view accumulates pages until the
/// server stops handing back a `next_page_token`.
const PAGE_SIZE: i32 = 100;
#[component]
pub fn RepositoryDetail() -> impl IntoView {
let params = use_params_map();
let repo = Memo::new(move |_| params.read().get("name").unwrap_or_default());
Effect::new(move |_| crate::webio::set_title(&repo.get()));
// The repository's own facts (writable / archive-backed / type) come from
// the same listing the repositories page uses — one source of truth.
let meta = LocalResource::new(move || {
let name = repo.get();
async move {
grpc::admin()
.list_repositories(grpc::pb::ListRepositoriesRequest {})
.await
.map(|r| {
r.into_inner()
.repositories
.into_iter()
.find(|r| r.name == name)
})
.map_err(|s| grpc::status_text(&s))
}
});
let entries: RwSignal<Vec<grpc::pb::ArtifactEntry>> = RwSignal::new(Vec::new());
let next_token: RwSignal<Option<String>> = RwSignal::new(None);
let error: RwSignal<Option<crate::grpc::Fail>> = RwSignal::new(None);
let loading = RwSignal::new(false);
let filter = RwSignal::new(String::new());
// The filter as last *applied* (on Enter / Apply), not as typed.
let applied_filter = RwSignal::new(String::new());
// Monotone generation: repo changes, filter applies and Load-more can all
// leave listings in flight at once, and gRPC-web cannot cancel — so a
// superseded response is DROPPED instead of overwriting a newer one.
let generation: StoredValue<u64> = StoredValue::new(0);
// `page_token: None` starts over (new filter / first load); `Some` appends.
let load = move |page_token: Option<String>| {
let repo = repo.get_untracked();
let name_filter = applied_filter.get_untracked();
let my_gen = generation.with_value(|g| g + 1);
generation.set_value(my_gen);
loading.set(true);
error.set(None);
spawn_local(async move {
let fresh = page_token.is_none();
let res = grpc::repositories()
.list_artifacts(grpc::pb::ListArtifactsRequest {
repository: repo,
name_filter,
limit: PAGE_SIZE,
page_token: page_token.unwrap_or_default(),
})
.await;
if generation.get_value() != my_gen {
return; // a newer listing superseded this one
}
loading.set(false);
match res {
Ok(resp) => {
let resp = resp.into_inner();
if fresh {
entries.set(resp.artifacts);
} else {
entries.update(|v| v.extend(resp.artifacts));
}
next_token
.set((!resp.next_page_token.is_empty()).then_some(resp.next_page_token));
}
Err(s) => {
if fresh {
// A refused listing has no rows. Keeping the previous
// query's rows would present them as THIS query's
// result — undimmed once loading ends, under this
// repo's heading, counted by the footer.
entries.set(Vec::new());
next_token.set(None);
}
error.set(Some(grpc::fail(&s)));
}
}
});
};
// First page — and again whenever the route's repo changes (the router
// reuses this component instance between /repo/a and /repo/b).
{
let load = load.clone();
Effect::new(move |_| {
let _ = repo.get();
applied_filter.set(String::new());
filter.set(String::new());
load(None);
});
}
let load_for_filter = load.clone();
let apply = move || {
applied_filter.set(filter.get_untracked());
load_for_filter(None);
};
let apply_click = apply.clone();
let load_more = load.clone();
let load_after_upload = load.clone();
view! {
<section>
<p class="crumbs">
<a href="/repositories">"repositories"</a>
" / " <strong>{move || repo.get()}</strong>
</p>
<h2>{move || repo.get()}</h2>
{move || match meta.get() {
None => view! { <TableSkeleton rows=8 cols=4 what="artifacts"/> }.into_any(),
Some(Err(e)) => view! { <ErrorText context="repository facts" message=e/> }.into_any(),
Some(Ok(None)) => view! {
<ErrorText context="unknown repository" message="not in the server's configuration"/>
}.into_any(),
Some(Ok(Some(m))) => view! {
<p class="metaline">
<Badge tone=Tone::Neutral label=m.repo_type.clone()/>
{if m.writable {
view! { <Badge tone=Tone::Good label="writable"/> }
} else {
view! { <Badge tone=Tone::Neutral label="read-only"/> }
}}
{m.has_archive.then(|| view! { <Badge tone=Tone::Neutral label="znippy archive"/> })}
<a class="count" href=move || format!("/search?repo={}", enc(&repo.get()))>
"search within this repository"
</a>
</p>
}.into_any(),
}}
<h3>"Browse"</h3>
<div class="toolbar">
<label class="vh" for="browse-filter">"Filter by name"</label>
<input
id="browse-filter"
type="search"
placeholder="filter by name…"
prop:value=move || filter.get()
on:input=move |ev| filter.set(event_target_value(&ev))
on:keydown=move |ev| { if ev.key() == "Enter" { apply(); } }
/>
<button on:click=move |_| apply_click()>"Apply"</button>
</div>
{
let retry = load.clone();
move || error.get().map(|f| {
let retry = retry.clone();
view! {
<FailBox context="failed to list"
fail=Signal::derive(move || f.clone())
retry=Callback::new(move |()| retry(None))/>
}
})
}
{move || {
let list = entries.get();
if list.is_empty() && !loading.get() && error.get().is_none() {
// An empty listing is proof of an empty repository ONLY when
// holger says `ListArtifacts` is a real enumeration here —
// `RepositoryInfo.supports_listing`, off the wire, never a
// guess from the repo type.
let supports_listing = match meta.get() {
Some(Ok(Some(ref m))) => Some(m.supports_listing),
_ => None,
};
return view! {
<EmptyState message=empty_listing_note(supports_listing)/>
}.into_any();
}
let repo = repo.get();
view! {
<div class="tablewrap" class:stale=move || loading.get()
aria-busy=move || loading.get().to_string()>
<table>
<caption class="vh">"Artifacts in this repository"</caption>
<thead>
<tr>
<th scope="col">"Artifact"</th>
<th scope="col">"Version"</th>
<th scope="col">"Size"</th>
<th scope="col">"Content type"</th>
</tr>
</thead>
<tbody>
{list.into_iter().map(|e| {
let id = e.id.unwrap_or_default();
let full_name = display_name(&id.namespace, &id.name);
let href = artifact_href(&repo, &id.namespace, &id.name, &id.version);
view! {
<tr>
<td><a href=href>{full_name}</a></td>
<td>{id.version.clone()}</td>
<td class="num">{fmt_bytes(e.size_bytes)}</td>
<td>{if e.content_type.is_empty() { "—".into() } else { e.content_type }}</td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</div>
}.into_any()
}}
<div class="pager">
{move || loading.get().then(|| view! { <span class="pending" role="status">"loading…"</span> })}
{move || {
let token = next_token.get();
let load_more = load_more.clone();
token.map(|t| view! {
<button on:click=move |_| load_more(Some(t.clone()))>"Load more"</button>
})
}}
{move || {
let n = entries.get().len();
(n > 0).then(|| view! { <span class="count">{format!("{n} shown")}</span> })
}}
</div>
// Disk — what this repository occupies on holger's disk and WHERE,
// holger's own reading (the number a paid limit is measured against).
<Disk repo=repo/>
// More room — holger's link to the ONE shop for THIS repository
// (`RoomService.Room`). "not in this build" on a holger without it.
<Room repo=repo/>
// Archive view — only when the backend actually carries one.
{move || matches!(meta.get(), Some(Ok(Some(ref m))) if m.has_archive).then(|| view! {
<Archive repo=repo/>
})}
// Upload — gated on the server's own writability fact. The server
// remains the judge either way; hiding the panel on a read-only
// repo just avoids promising what will be refused.
{move || match meta.get() {
Some(Ok(Some(m))) if m.writable => view! {
<Upload repo=Signal::derive(move || repo.get())
on_uploaded=Callback::new(move |()| load_after_upload(None))/>
}.into_any(),
Some(Ok(Some(_))) => view! {
<p class="count">"read-only repository — the server refuses writes here"</p>
}.into_any(),
_ => ().into_any(),
}}
</section>
}
}
/// The znippy-archive view: totals from `ArchiveInfo`, paths (with prefix
/// filter) from `ListArchiveFiles`. Read-gated like every other read.
#[component]
fn Archive(repo: Memo<String>) -> impl IntoView {
let prefix = RwSignal::new(String::new());
let applied_prefix = RwSignal::new(String::new());
let info = LocalResource::new(move || {
let repository = repo.get();
async move {
grpc::archives()
.archive_info(grpc::pb::ArchiveInfoRequest { repository })
.await
.map(|r| r.into_inner())
.map_err(|s| grpc::status_text(&s))
}
});
let files = LocalResource::new(move || {
let repository = repo.get();
let prefix = applied_prefix.get();
async move {
grpc::archives()
.list_archive_files(grpc::pb::ListArchiveFilesRequest { repository, prefix })
.await
.map(|r| r.into_inner())
.map_err(|s| grpc::status_text(&s))
}
});
let apply = move || applied_prefix.set(prefix.get_untracked());
let apply_click = apply.clone();
view! {
<h3>"Archive"</h3>
{move || match info.get() {
None => view! { <Loading what="archive info"/> }.into_any(),
Some(Err(e)) => view! { <ErrorText context="archive info" message=e/> }.into_any(),
Some(Ok(i)) => view! {
<p class="count">
{format!("{} files · {} uncompressed · {}",
i.file_count, fmt_bytes(i.total_uncompressed_bytes), i.archive_path)}
</p>
}.into_any(),
}}
<div class="toolbar">
<label class="vh" for="archive-prefix">"Filter by path prefix"</label>
<input
id="archive-prefix"
type="search"
placeholder="path prefix…"
prop:value=move || prefix.get()
on:input=move |ev| prefix.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 files.get() {
None => view! { <Loading what="archive files"/> }.into_any(),
Some(Err(e)) => view! { <ErrorText context="archive listing" message=e/> }.into_any(),
Some(Ok(f)) if f.paths.is_empty() =>
view! { <EmptyState message="no archive paths match"/> }.into_any(),
Some(Ok(f)) => {
let shown = f.paths.len();
view! {
<p class="count">{format!("{shown} shown of {} total", f.total_files)}</p>
<ul class="pathlist">
{f.paths.into_iter().map(|p| view! { <li class="mono">{p}</li> }).collect_view()}
</ul>
}.into_any()
}
}}
}
}
/// The write surface: one file becomes one `PutArtifact`. The name prefills
/// from the picked file (for a raw repo the name IS the path — type a full
/// `dir/sub/file.ext` to place it); the server's write gate decides whether
/// the caller may, and its refusal is shown verbatim.
#[component]
pub fn Upload(repo: Signal<String>, on_uploaded: Callback<()>) -> impl IntoView {
let namespace = RwSignal::new(String::new());
let name = RwSignal::new(String::new());
let version = RwSignal::new(String::new());
let picked: RwSignal<Option<web_sys::File>> = RwSignal::new(None);
let status: RwSignal<Option<Result<String, String>>> = RwSignal::new(None);
let busy = RwSignal::new(false);
let on_pick = move |ev: leptos::ev::Event| {
let input: web_sys::HtmlInputElement = event_target(&ev);
let file = input.files().and_then(|l| l.get(0));
if let Some(f) = &file {
// Prefill the name from the file; the operator refines it into a
// path/coordinate as the repo's addressing needs.
if name.get_untracked().is_empty() {
name.set(f.name());
}
}
picked.set(file);
status.set(None);
};
let send = move |_| {
if busy.get_untracked() {
return; // double-request guard beyond the disabled attribute
}
let Some(file) = picked.get_untracked() else {
status.set(Some(Err("pick a file first".into())));
return;
};
let artifact_name = name.get_untracked().trim().to_string();
if artifact_name.is_empty() {
status.set(Some(Err("name is required".into())));
return;
}
let repo = repo.get_untracked();
let id = grpc::pb::ArtifactId {
namespace: namespace.get_untracked().trim().to_string(),
name: artifact_name.clone(),
version: version.get_untracked().trim().to_string(),
};
busy.set(true);
status.set(None);
spawn_local(async move {
let bytes = match webio::read_file(&file).await {
Ok(b) => b,
Err(e) => {
busy.set(false);
status.set(Some(Err(format!("could not read file: {e}"))));
return;
}
};
let size = bytes.len();
let res = grpc::repositories()
.put_artifact(grpc::pb::PutArtifactRequest {
repository: repo,
id: Some(id),
data: bytes,
async_job: false,
})
.await;
busy.set(false);
match res {
Ok(r) => {
let r = r.into_inner();
if r.success {
status.set(Some(Ok(format!(
"uploaded {} ({})",
artifact_name,
fmt_bytes(size as i64)
))));
picked.set(None);
on_uploaded.run(());
} else {
// The server said no and told us why (policy, quarantine,
// immutable target) — that message IS the UI text.
status.set(Some(Err(r.message)));
}
}
Err(s) => status.set(Some(Err(grpc::status_text(&s)))),
}
});
};
view! {
<details class="upload">
<summary>"Upload artifact"</summary>
<div class="upload-form">
<label class="axis">
<span>"File"</span>
<input type="file" on:change=on_pick/>
</label>
<div class="axes">
<label class="axis">
<span>"Namespace (optional)"</span>
<input type="text" placeholder="groupId / scope — empty for none"
prop:value=move || namespace.get()
on:input=move |ev| namespace.set(event_target_value(&ev))/>
</label>
<label class="axis">
<span>"Name"</span>
<input type="text" placeholder="artifact name — for raw: the full path"
prop:value=move || name.get()
on:input=move |ev| name.set(event_target_value(&ev))/>
</label>
<label class="axis">
<span>"Version (optional for raw)"</span>
<input type="text" placeholder="e.g. 2.0.0"
prop:value=move || version.get()
on:input=move |ev| version.set(event_target_value(&ev))/>
</label>
</div>
<div class="toolbar">
<button on:click=send disabled=move || busy.get()>
{move || if busy.get() { "Uploading…" } else { "Upload" }}
</button>
{move || status.get().map(|s| match s {
Ok(m) => view! { <span class="ok" role="status">{m}</span> }.into_any(),
Err(m) => view! { <span class="err" role="alert">{format!("upload failed: {m}")}</span> }.into_any(),
})}
</div>
</div>
</details>
}
}
/// **Disk**: what the repository occupies on holger's disk and WHERE — holger's
/// own `disk_bytes` reading (`ReadUsage`), the number a paid limit is measured
/// against, each place with its share. Read-gated like every other read.
#[component]
fn Disk(repo: Memo<String>) -> impl IntoView {
let usage = LocalResource::new(move || {
let repository = repo.get();
async move {
grpc::usage()
.read_usage(grpc::pb::ReadUsageRequest { repository })
.await
.map(|r| r.into_inner())
.map_err(|s| grpc::status_text(&s))
}
});
view! {
<h3>"Disk"</h3>
{move || match usage.get() {
None => view! { <Loading what="disk usage"/> }.into_any(),
Some(Err(e)) => view! { <ErrorText context="disk usage" message=e/> }.into_any(),
Some(Ok(u)) => {
let repo_scope = grpc::pb::MeterScope::Repository as i32;
let v = disk_view(u.meters.iter().map(|m| {
(m.name.as_str(), m.scope == repo_scope, m.counted, m.value, m.source.as_str(), m.refusal_reason.as_str())
}));
match v {
DiskView::Counted { bytes, places } => view! {
<p class="count">{format!("{} on disk", fmt_bytes(bytes as i64))}</p>
<ul class="places">
{places.into_iter().map(|p| view! { <li><code>{p}</code></li> }).collect_view()}
</ul>
}.into_any(),
DiskView::Refused(why) => view! { <p class="count">{format!("not counted: {why}")}</p> }.into_any(),
DiskView::NotReported => view! { <p class="count">"this server does not report disk usage"</p> }.into_any(),
}
}
}}
}
}
/// **More room**: holger's word on where it is bought (`RoomService.Room`) — a
/// link to the ONE shop for this repository, or the sentence that this box
/// sells nothing. The console prices, orders and refuses nothing itself; what
/// the repository holds and its cap are the Disk panel above (holger's
/// `ReadUsage`). A metered console's only (A28 M13).
#[cfg(feature = "metered")]
#[component]
fn Room(repo: Memo<String>) -> impl IntoView {
let room = LocalResource::new(move || {
let repository = repo.get();
async move { grpc::answer_or_none(grpc::room().room(grpc::pb_room::RoomRequest { repository }).await) }
});
view! {
{move || match room.get() {
None => view! { <Loading what="where more room is bought"/> }.into_any(),
Some(Ok(None)) => view! { <h3>"More room"</h3><p class="count" role="status">{crate::util::not_in_build("More room")}</p> }.into_any(),
Some(Err(e)) => view! { <h3>"More room"</h3><ErrorText context="where more room is bought" message=e/> }.into_any(),
Some(Ok(Some(r))) => view! {
<h3>"More room"</h3>
<p class="count">{r.words}</p>
{(!r.shop_url.is_empty()).then(|| view! {
<p><a class="btn primary" href=r.shop_url>"Buy more room"</a></p>
})}
}.into_any(),
}}
}
}
/// An open console has no "More room" panel: room is not had anywhere (A28 M13). A `#[cfg]`
/// on a component node inside `view!` is not honoured, so the twin renders nothing.
#[cfg(not(feature = "metered"))]
#[component]
fn Room(repo: Memo<String>) -> impl IntoView {
let _ = repo;
}