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/issues.rs

//! Issues: the list, the open form, and one issue's page.
//!
//! The other half of the forge from a pull request's review, and built to the
//! same pattern: every row on screen is gunnar's fold of signed events,
//! never a store this page keeps. Opening, commenting and closing are each a
//! signed `Forge.Append`, and what the page shows afterwards is the fold read
//! back — so the state on screen is always exactly the log, and a reader who
//! folds the same events sees the same thing.
//!
//! # Whose issue it is
//!
//! The console signs nothing on anybody's behalf. An issue filed from the
//! console is sent AS THE PERSON, from their own session (thin layer,
//! 2026-10-04), and Gunnar signs it in their name after a fresh touch of their
//! passkey (GHUNT-19) — the console has no access to a human's ssh key and must
//! not invent one. To put your own ssh key on an issue, use `gunnar forge
//! issue`. The page says so.

use leptos::prelude::*;
use leptos_router::hooks::use_params_map;
use serde::Deserialize;

use crate::api::{segment, Fail};
use crate::fmt;
use crate::pages::{now_ms, resolved};
use crate::ui::{Badge, Empty, FailBox, Hint, PageHeader, Panel, Tone};

/// An issue, as the BFF sends it — nothing but gunnar's fold.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Issue {
    pub number: u64,
    pub title: String,
    pub body: String,
    /// The identity-table principal that opened it — the opening event's signer.
    /// Empty where the server names nobody.
    #[serde(default)]
    pub author: String,
    pub open: bool,
    pub comments: u64,
    pub opened_at_unix_ms: i64,
}

fn state_tone(open: bool) -> Tone {
    if open {
        Tone::Live
    } else {
        Tone::Plain
    }
}

fn state_label(open: bool) -> &'static str {
    if open {
        "open"
    } else {
        "closed"
    }
}

/// `/:namespace/:name/issues` — the list and the open form.
#[component]
pub fn IssuesList() -> impl IntoView {
    let params = use_params_map();
    let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
    let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
    let path = Memo::new(move |_| format!("{}/{}", namespace.get(), name.get()));
    let reload = RwSignal::new(0u32);

    let issues = LocalResource::new(move || {
        reload.track();
        let url = format!(
            "/api/repos/{}/{}/issues",
            segment(&namespace.get()),
            segment(&name.get())
        );
        async move { crate::api::get::<Vec<Issue>>(&url).await }
    });

    let title = RwSignal::new(String::new());
    let body = RwSignal::new(String::new());
    let open_fail = RwSignal::new(None::<Fail>);
    let busy = RwSignal::new(false);

    // gunnar's per-repository answer (Write on it), never the console role.
    let rights = crate::pages::repo_rights();
    let may_write = Memo::new(move |_| rights.get().may_work());

    let open = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        open_fail.set(None);
        let url = format!(
            "/api/repos/{}/{}/issues",
            segment(&namespace.get_untracked()),
            segment(&name.get_untracked())
        );
        let payload = serde_json::json!({
            "title": title.get_untracked(),
            "body": body.get_untracked(),
        });
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Issue>(&url, &payload).await {
                Ok(_) => {
                    title.set(String::new());
                    body.set(String::new());
                    reload.update(|n| *n += 1);
                }
                Err(refusal) => open_fail.set(Some(refusal)),
            }
            busy.set(false);
        });
    };

    let repo_href = move || {
        format!(
            "/{}/{}",
            segment(&namespace.get()),
            segment(&name.get())
        )
    };

    view! {
        <PageHeader
            title=move || format!("{} — issues", path.get())
        >
            <a class="btn" href=repo_href>"Repository"</a>
        </PageHeader>
        <crate::pages::repo::RepoTabs/>

        // A visitor is asked to sign in here (GUN-GUEST, `crate::guest`).
        <crate::pages::Writes what="open an issue" may=may_write>
            <Panel title="Open an issue">
                <div class="panel-body">
                    <div class="field">
                        <label for="issue-title">"Title"</label>
                        <input
                            id="issue-title"
                            type="text"
                            prop:value=move || title.get()
                            on:input=move |ev| title.set(event_target_value(&ev))
                        />
                    </div>
                    <div class="field">
                        <label for="issue-body">"Body"</label>
                        <textarea
                            id="issue-body"
                            rows="4"
                            prop:value=move || body.get()
                            on:input=move |ev| body.set(event_target_value(&ev))
                        ></textarea>
                    </div>
                    <div class="form-row">
                        <button
                            class="btn"
                            disabled=move || busy.get()
                            on:click=open
                        >
                            {move || if busy.get() { "Opening …" } else { "Open issue" }}
                        </button>
                    </div>
                    {move || open_fail.get().map(|fail| view! { <FailBox fail=fail/> })}
                </div>
            </Panel>
        </crate::pages::Writes>

        <Panel title="Issues">
            {move || resolved(issues.get(), None, |items: Vec<Issue>| {
                if items.is_empty() {
                    return view! {
                        <Empty
                            title="No issues"
                            detail="Open one, and its whole thread is a log of signed events."
                        />
                    }
                    .into_any();
                }
                let now = now_ms();
                let ns = segment(&namespace.get_untracked());
                let nm = segment(&name.get_untracked());
                view! {
                    <div class="table-wrap">
                        <table>
                            <caption class="vh">"Issues"</caption>
                            <thead>
                                <tr>
                                    <th scope="col">"#"</th>
                                    <th scope="col">"Title"</th>
                                    <th scope="col">"State"</th>
                                    <th scope="col">"Comments"</th>
                                    <th scope="col">"Author"</th>
                                    <th scope="col">"Opened"</th>
                                </tr>
                            </thead>
                            <tbody>
                                {items
                                    .into_iter()
                                    .map(|issue| {
                                        let href = format!(
                                            "/{ns}/{nm}/issues/{}",
                                            issue.number
                                        );
                                        view! {
                                            <tr>
                                                <td class="num">{issue.number}</td>
                                                <td><a href=href>{issue.title.clone()}</a></td>
                                                <td>
                                                    <Badge
                                                        tone=state_tone(issue.open)
                                                        label=state_label(issue.open)
                                                    />
                                                </td>
                                                <td class="num">{issue.comments}</td>
                                                <td>{issue.author.clone()}</td>
                                                <td class="num">
                                                    {fmt::since(issue.opened_at_unix_ms, now)}
                                                </td>
                                            </tr>
                                        }
                                    })
                                    .collect_view()}
                            </tbody>
                        </table>
                    </div>
                }
                .into_any()
            })}
        </Panel>
    }
}

/// `/:namespace/:name/issues/:number` — one issue.
#[component]
pub fn IssueDetail() -> impl IntoView {
    let params = use_params_map();
    let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
    let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
    let number = Memo::new(move |_| params.read().get("number").unwrap_or_default());
    let reload = RwSignal::new(0u32);

    let issue = LocalResource::new(move || {
        reload.track();
        let url = format!(
            "/api/repos/{}/{}/issues/{}",
            segment(&namespace.get()),
            segment(&name.get()),
            number.get()
        );
        async move { crate::api::get::<Issue>(&url).await }
    });

    let comment_body = RwSignal::new(String::new());
    let action_fail = RwSignal::new(None::<Fail>);
    let busy = RwSignal::new(false);
    // gunnar's per-repository answer (Write on it), never the console role.
    let rights = crate::pages::repo_rights();
    let may_write = Memo::new(move |_| rights.get().may_work());

    let comment = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        action_fail.set(None);
        let url = format!(
            "/api/repos/{}/{}/issues/{}/comment",
            segment(&namespace.get_untracked()),
            segment(&name.get_untracked()),
            number.get_untracked()
        );
        let payload = serde_json::json!({ "body": comment_body.get_untracked() });
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Issue>(&url, &payload).await {
                Ok(_) => {
                    comment_body.set(String::new());
                    reload.update(|n| *n += 1);
                }
                Err(refusal) => action_fail.set(Some(refusal)),
            }
            busy.set(false);
        });
    };

    let close = move |_| {
        if busy.get_untracked() {
            return;
        }
        busy.set(true);
        action_fail.set(None);
        let url = format!(
            "/api/repos/{}/{}/issues/{}/close",
            segment(&namespace.get_untracked()),
            segment(&name.get_untracked()),
            number.get_untracked()
        );
        let payload = serde_json::json!({ "reason": "" });
        leptos::task::spawn_local(async move {
            match crate::api::post::<_, Issue>(&url, &payload).await {
                Ok(_) => reload.update(|n| *n += 1),
                Err(refusal) => action_fail.set(Some(refusal)),
            }
            busy.set(false);
        });
    };

    let list_href = move || {
        format!(
            "/{}/{}/issues",
            segment(&namespace.get()),
            segment(&name.get())
        )
    };

    view! {
        <PageHeader
            title=move || format!("{}/{} — issue #{}", namespace.get(), name.get(), number.get())
        >
            <a class="btn" href=list_href>"All issues"</a>
        </PageHeader>
        <crate::pages::repo::RepoTabs/>

        {move || resolved(issue.get(), None, {
            move |issue: Issue| {
                let now = now_ms();
                let is_open = issue.open;
                // The server may name no author (a visitor's read): then none is said.
                let opened = if issue.author.is_empty() {
                    format!("Opened {} · {} comment(s)", fmt::since(issue.opened_at_unix_ms, now), issue.comments)
                } else {
                    format!("{} opened this {} · {} comment(s)", issue.author, fmt::since(issue.opened_at_unix_ms, now), issue.comments)
                };
                let title = issue.title.clone();
                let body = issue.body.clone();
                view! {
                    <Panel title=title>
                        <div class="panel-body">
                            <p>
                                <Badge
                                    tone=state_tone(is_open)
                                    label=state_label(is_open)
                                />
                            </p>
                            <p class="truncation">{opened}</p>
                            {(!body.is_empty()).then(|| view! { <p>{body.clone()}</p> })}
                            <Hint
                                line="What you write here is shown under your name, after a fresh touch of your own key (your passkey)."
                                more="Each issue is a list of signed entries. When you open, comment on or \
                                      close an issue here, Gunnar records it under your name and signs it \
                                      for you after the touch, saying so in the entry."
                            />

                            {is_open.then(|| view! {
                                <crate::pages::Writes what="comment" may=may_write>
                                <div class="field">
                                    <label for="issue-comment">"Add a comment"</label>
                                    <textarea
                                        id="issue-comment"
                                        rows="3"
                                        prop:value=move || comment_body.get()
                                        on:input=move |ev| comment_body.set(event_target_value(&ev))
                                    ></textarea>
                                </div>
                                <div class="form-row">
                                    <button
                                        class="btn"
                                        disabled=move || busy.get()
                                        on:click=comment
                                    >
                                        {move || if busy.get() { "Commenting …" } else { "Comment" }}
                                    </button>
                                    <button
                                        class="btn quiet"
                                        disabled=move || busy.get()
                                        on:click=close
                                    >
                                        "Close issue"
                                    </button>
                                </div>
                                </crate::pages::Writes>
                            })}
                            {move || action_fail.get().map(|fail| view! { <FailBox fail=fail/> })}
                        </div>
                    </Panel>
                }
                .into_any()
            }
        })}
    }
}