crates/gunnar-ui-web/src/pages/events.rs
//! The event feed, in full.
use leptos::prelude::*;
use crate::feed::Connection;
use crate::fmt;
use crate::pages::now_ms;
use crate::ui::{Badge, Empty, Panel, Tone};
/// The kinds gunnar publishes, for the filter.
const KINDS: &[(&str, &str)] = &[
("", "all"),
("ref_updated", "ref moved"),
("ref_deleted", "ref deleted"),
("push_rejected", "push rejected"),
("repo_created", "repo created"),
("repo_deleted", "repo deleted"),
("key_used", "key used"),
("push_attestation", "attestation"),
("replication_failed", "replication failed"),
("pull_request_opened", "PR opened"),
("pull_request_merged", "PR merged"),
("pull_request_closed", "PR closed"),
("visibility_changed", "visibility changed"),
("encrypted_changed", "encryption changed"),
];
/// **A feed, drawn**: the live badge, the kind and repository filters, and the
/// lines — for whichever feed it is handed (the account's, one repository's).
/// What the feed carries is the SERVER's decision (`sse::Filter`); these
/// filters only narrow what was already sent.
#[component]
pub fn Stream(feed: crate::feed::Feed) -> impl IntoView {
let kind = RwSignal::new(String::new());
let repo = RwSignal::new(String::new());
view! {
<div class="stack">
<Panel title="Stream">
<div class="panel-body row">
{move || match feed.connection.get() {
Connection::Live => {
view! { <Badge tone=Tone::Live label="live"/> }.into_any()
}
Connection::Connecting => {
view! { <Badge tone=Tone::Plain label="connecting …"/> }.into_any()
}
Connection::Ended => {
view! { <Badge tone=Tone::Bad label="stream lost"/> }.into_any()
}
}}
<div class="field">
<label class="vh" for="kind">"Kind"</label>
<select id="kind" on:change=move |ev| kind.set(event_target_value(&ev))>
{KINDS
.iter()
.map(|(value, label)| view! {
<option value=*value>{*label}</option>
})
.collect_view()}
</select>
</div>
<div class="field">
<label class="vh" for="repo">"Repository"</label>
<input
id="repo"
type="search"
placeholder="namespace/name"
prop:value=move || repo.get()
on:input=move |ev| repo.set(event_target_value(&ev))
/>
</div>
<span class="spacer"></span>
<button class="btn quiet" on:click=move |_| feed.items.set(Vec::new())>
"Clear"
</button>
</div>
{move || {
let wanted_kind = kind.get();
let wanted_repo = repo.get().to_lowercase();
let items = feed.items.get();
let matching: Vec<_> = items
.into_iter()
.filter(|item| {
// A gap — and a restart — always passes the filter.
// These are the lines that say the feed is
// incomplete, and hiding one behind a filter would
// make the feed look whole.
item.breaks_continuity()
|| ((wanted_kind.is_empty() || item.kind == wanted_kind)
&& (wanted_repo.is_empty()
|| item
.repo
.as_deref()
.is_some_and(|r| r.to_lowercase().contains(&wanted_repo))))
})
.collect();
if matching.is_empty() {
return view! {
<Empty
title="Nothing to show"
detail="The stream starts when the page opens."
more="Gunnar has no history to fetch backwards, so whatever \
happened before is gone."
/>
}
.into_any();
}
let now = now_ms();
view! {
<ul class="feed">
{matching
.into_iter()
.map(|item| {
if item.breaks_continuity() {
return view! {
<li class="gap">{item.summary.clone()}</li>
}
.into_any();
}
view! {
<li>
<time title=fmt::since(item.at_unix_ms, now)>
{fmt::clock(item.at_unix_ms)}
</time>
<span class="what">{item.kind.replace('_', " ")}</span>
<span class="where">
{item.repo.clone().unwrap_or_default()}
" "
{item.summary.clone()}
</span>
</li>
}
.into_any()
})
.collect_view()}
</ul>
}
.into_any()
}}
</Panel>
</div>
}
}