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

nordisk/gunnar-ui

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