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