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