// Package web is the HTML consumer of the registry (0001 §9). Pages are
// server-rendered html/template over op results — the same ops the API
// serves, so every page capability is API-visible by construction.
// Repo-scoped pages live under /r/{repo}/; the home page is the fleet.
package web
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"reflect"
"strconv"
"strings"
"time"
"craftdesign.group/quarry/internal/highlight"
"craftdesign.group/quarry/internal/ops"
)
// datastarJS is the vendored Datastar bundle (MIT; see the provenance
// ledger) — the one allowed frontend framework (0001 Principle 2),
// served self-contained. Everything works without it (Principle 3);
// it only adds live updates.
//
//go:embed assets/datastar.js
var datastarJS []byte
// Notifier is the change signal live pages subscribe to. The
// composition root supplies an events-backed implementation; web never
// touches the event plane directly. nil disables liveness. repo "*"
// watches every repo (the fleet page's signal).
type Notifier interface {
Watch(repo string) (<-chan struct{}, func())
}
// base is the layout every page renders through. Both themes come from
// prefers-color-scheme; no JavaScript anywhere (0001 Principle 3 — the
// no-JS baseline is the baseline). The header carries the fleet crumb
// and, inside a repo, that repo's nav and search.
// pathesc escapes one path segment — wiki titles carry slashes in real
// stores (tcl's "(Initi/Fin)alize"), which must ride as %2F to stay one
// segment for the mux.
var base = template.Must(template.New("base").Funcs(template.FuncMap{
"pathesc": url.PathEscape,
}).Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — quarry</title>
<style>
:root { --fg: #1a1a1a; --bg: #ffffff; --muted: #6a6a6a; --code-bg: #f4f5f7; }
@media (prefers-color-scheme: dark) {
:root { --fg: #e8e8e8; --bg: #14161a; --muted: #9a9a9a; --code-bg: #1c2026; }
}
body { color: var(--fg); background: var(--bg); font: 16px/1.5 system-ui, sans-serif;
max-width: 60rem; margin: 0 auto; padding: 1rem; }
footer { color: var(--muted); border-top: 1px solid var(--muted); margin-top: 2rem;
padding-top: 0.5rem; font-size: 0.85rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; }
th { border-bottom: 1px solid var(--muted); }
a { color: inherit; }
header { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap;
border-bottom: 1px solid var(--muted); padding-bottom: 0.5rem; }
header .site { font-weight: bold; }
header form { margin-left: auto; }
.tw { overflow-x: auto; }
/* Timeline card/detail metrics adapted from fossil src/default.css
(BSD-2-Clause; see third_party/PROVENANCE.md). The graph tiles
seamlessly: a fixed 36px head band (node + curves) and a stretching
tail fill each row edge-to-edge at identical pixel x positions, and
date rows carry the active rails through. */
table.tl td { border: 0; padding: 0; vertical-align: top; }
table.tl td.tl-graph { position: relative; }
svg.tl-head { position: absolute; top: 0; left: 0; display: block; }
svg.tl-tail { position: absolute; top: 36px; left: 0; height: calc(100% - 36px); display: block; }
tr.tl-date svg.tl-tail, tr.tl-flat svg.tl-tail { top: 0; height: 100%; }
.tl-card-flat { padding: 0.45em 0.75em; font-size: 0.95em; }
.tl-meta-inline { color: var(--muted); font-size: 85%; }
table.tl td.tl-time { white-space: nowrap; width: 3.4rem; padding: 13px 0.5rem 0 0; text-align: right; }
table.tl td.tl-time a { color: var(--muted); text-decoration: none; }
table.tl td.tl-time a:hover { text-decoration: underline; }
table.tl td.tl-slot { width: 100%; padding: 0 0 5px 0.6rem; }
table.tl td.tl-datecell { padding: 6px 0 6px 0.6rem; }
.tl-card { background: var(--code-bg); border-radius: 1em; padding: 0.75em; }
.tl-title { font-weight: 550; }
.tl-p { margin: 0.35em 0 0; color: color-mix(in srgb, var(--fg) 78%, var(--muted)); }
.tl-meta { color: var(--muted); font-size: 80%; text-align: right; opacity: 0.85; margin-top: 0.5em; }
.tl-meta a { text-decoration: none; }
.tl-meta a:hover { text-decoration: underline; }
@media (max-width: 640px) {
body { padding: 0.5rem; }
table.tl td.tl-time { width: 2.6rem; font-size: 0.8rem; padding-top: 15px; }
.tl-card { padding: 0.75em 0.55em; border-radius: 0.6em; }
.tl-meta { font-size: 72%; }
}
.tl-badge { border: 1px solid var(--muted); border-radius: 3px; padding: 0 0.25rem; font-size: 0.72rem; }
.tl-pill { display: inline-block; border: 1px solid var(--muted); border-radius: 6px;
padding: 0.05rem 0.5rem; font-weight: bold; margin: 0.55rem 0 0.25rem; }
.tl-controls { color: var(--muted); font-size: 0.9rem; display: flex; gap: 0.8rem;
align-items: baseline; margin-bottom: 0.4rem; }
pre { overflow-x: auto; background: var(--code-bg); padding: 0.5rem; border-radius: 4px; }
pre.diff .add { color: #116329; } pre.diff .del { color: #a40e26; }
pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; }
mark { background: #fff3b8; color: inherit; }
@media (prefers-color-scheme: dark) {
pre.diff .add { color: #56d364; } pre.diff .del { color: #ff7b72; }
mark { background: #5c4d00; }
}
</style>
<style>` + highlight.CSS() + `</style>
<script type="module" src="/assets/datastar.js" defer></script>
</head>
<body>
<header>
<a class="site" href="/">quarry</a>
{{if .Repo}}<a href="/r/{{.Repo}}/timeline">{{.Repo}}</a>
<nav><a href="/r/{{.Repo}}/timeline">timeline</a> <a href="/r/{{.Repo}}/wiki">wiki</a> <a href="/r/{{.Repo}}/ticket">tickets</a></nav>
<form action="/r/{{.Repo}}/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form>
{{end}}
</header>
<main>
<h1>{{.Title}}</h1>
{{block "content" .}}{{end}}
</main>
<footer>quarry {{.Version}} · <a href="/docs">API</a></footer>
</body>
</html>
`))
var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "fleet-body"}}
{{if .Fleet}}<div class="tw"><table>
<thead><tr><th scope="col">Repository</th><th scope="col">Check-ins</th><th scope="col">Tip</th></tr></thead>
<tbody>{{range .Fleet}}
<tr><td><a href="/r/{{.Name}}/timeline">{{.Name}}</a></td><td>{{.Checkins}}</td><td>{{.TipDate}} — {{.TipComment}}</td></tr>
{{end}}</tbody>
</table></div>{{else}}<p>No repositories are being served. Start with <code>quarry serve --repo name=path</code>.</p>{{end}}
{{end}}
{{define "content"}}<div id="fleet"{{if .Live}} {{.LiveAttr}}{{end}}>{{template "fleet-body" .}}</div>{{end}}`))
var wikiListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .WikiPages}}<ul>{{range .WikiPages}}
<li><a href="/r/{{$.Repo}}/wiki/{{pathesc .Title}}">{{.Title}}</a> <small>{{.Date}} by {{.User}}</small></li>
{{end}}</ul>{{else}}<p>No wiki pages.</p>{{end}}
{{end}}`))
var docsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<article>{{.WikiHTML}}</article>
{{end}}`))
var checkinPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p>{{.Checkin.Comment}}</p>
<p><small>{{.Checkin.Date}} by {{.Checkin.User}} on <a href="/r/{{.Repo}}/timeline?branch={{.Checkin.Branch}}">{{.Checkin.Branch}}</a> — <code>{{.Checkin.Hash}}</code></small></p>
{{if .Checkin.Parents}}<p>parent{{if gt (len .Checkin.Parents) 1}}s{{end}}:
{{range .Checkin.Parents}} <a href="/r/{{$.Repo}}/checkin/{{.}}"><code>{{printf "%.12s" .}}</code></a>{{end}}</p>{{end}}
<p><a href="/r/{{.Repo}}/checkin/{{.Checkin.Hash}}/diff">view diff</a></p>
{{if .Checkin.Files}}<div class="tw"><table>
<thead><tr><th scope="col">File</th><th scope="col">Blob</th></tr></thead>
<tbody>{{range .Checkin.Files}}
<tr><td><a href="/r/{{$.Repo}}/file/{{$.Checkin.Hash}}/{{.Name}}">{{.Name}}</a></td><td><code>{{printf "%.12s" .Blob}}</code></td></tr>
{{end}}</tbody>
</table></div>{{end}}
{{end}}`))
var ticketListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Tickets}}<div class="tw"><table>
<thead><tr><th scope="col">Ticket</th><th scope="col">Status</th><th scope="col">Title</th><th scope="col">Updated</th></tr></thead>
<tbody>{{range .Tickets}}
<tr><td><a href="/r/{{$.Repo}}/ticket/{{.ID}}"><code>{{printf "%.10s" .ID}}</code></a></td><td>{{.Status}}</td><td>{{.Title}}</td><td>{{.Updated}}</td></tr>
{{end}}</tbody>
</table></div>{{else}}<p>No tickets.</p>{{end}}
{{if .Next}}<p><a href="/r/{{.Repo}}/ticket?before={{.Next}}">older →</a></p>{{end}}
{{end}}`))
var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<table>
<tbody>{{range .Ticket.Fields}}
<tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr>
{{end}}</tbody>
</table>
<h2>History</h2>
<ul>{{range .Ticket.Changes}}
<li>{{.Date}} by {{.User}}: {{range .Fields}}{{.Name}}={{.Value}} {{end}}</li>
{{end}}</ul>
{{end}}`))
var filePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><small>at <a href="/r/{{.Repo}}/checkin/{{.File.Hash}}"><code>{{printf "%.12s" .File.Hash}}</code></a> — {{.File.Size}} bytes</small></p>
{{if .File.Binary}}<p>Binary file.</p>{{else if .FileHTML}}{{.FileHTML}}{{else}}<pre>{{.File.Content}}</pre>{{end}}
{{end}}`))
var notFoundPage = template.Must(template.Must(base.Clone()).Parse(
`{{define "content"}}<p>Nothing lives at this address. Start from the <a href="/">repository list</a>.</p>{{end}}`))
var errorPage = template.Must(template.Must(base.Clone()).Parse(
`{{define "content"}}<p>Something went wrong serving this page. The details are in the server log.</p>{{end}}`))
var searchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form action="/r/{{.Repo}}/search" method="get">
<input type="search" name="q" value="{{.Query}}" placeholder="search check-ins, wiki, tickets" aria-label="search">
<button type="submit">Search</button>
</form>
{{if .Results}}<ul>{{range .Results}}
<li><code>{{.Kind}}</code> <a href="{{.URL}}">{{.Label}}</a> — <small>{{.Snippet}}</small></li>
{{end}}</ul>{{else if .Query}}<p>No results.</p>{{end}}
{{end}}`))
var diffPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><small>{{if .Diff.From}}<a href="/r/{{.Repo}}/checkin/{{.Diff.From}}"><code>{{printf "%.12s" .Diff.From}}</code></a> →{{else}}root check-in →{{end}}
<a href="/r/{{.Repo}}/checkin/{{.Diff.To}}"><code>{{printf "%.12s" .Diff.To}}</code></a></small></p>
{{if .Diff.Note}}<p><em>{{.Diff.Note}}</em></p>{{end}}
{{if .Diff.Files}}<ul>{{range .Diff.Files}}<li><code>{{.Status}}</code> {{.Name}}</li>{{end}}</ul>{{end}}
{{if .DiffLines}}<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
{{end}}</pre>{{end}}
{{end}}`))
var wikiPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .WikiRev}}<p class="status"><em>Historical revision <code>{{printf "%.12s" .Wiki.Hash}}</code> ({{.Wiki.Date}}) —
<a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">view latest</a></em></p>{{end}}
<article>{{.WikiHTML}}</article>
<footer><small>edited {{.Wiki.Date}} by {{.Wiki.User}} ·
<a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/history">history</a></small></footer>
{{end}}`))
var wikiHistoryPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">← {{.Wiki.Title}}</a></p>
<ul>{{$top := true}}{{range .WikiRevs}}
<li>{{.Date}} <a href="/r/{{$.Repo}}/wiki/{{pathesc $.Wiki.Title}}{{if not $top}}?rev={{printf "%.12s" .Hash}}{{end}}"><code>{{printf "%.12s" .Hash}}</code></a>
by {{.User}}{{if $top}} <small>(current)</small>{{end}}{{$top = false}}</li>{{end}}
</ul>
{{end}}`))
var timelinePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form class="tl-controls" action="/r/{{.Repo}}/timeline" method="get">
{{if .Branch}}<input type="hidden" name="branch" value="{{.Branch}}">
<span>branch <a href="/r/{{.Repo}}/timeline?n={{.Limit}}"><b>{{.Branch}}</b> ✕</a></span>{{end}}
<label>Show <select name="y">
<option value="all"{{if or (eq .Y "") (eq .Y "all")}} selected{{end}}>everything</option>
<option value="ci"{{if eq .Y "ci"}} selected{{end}}>check-ins</option>
<option value="w"{{if eq .Y "w"}} selected{{end}}>wiki</option>
<option value="t"{{if eq .Y "t"}} selected{{end}}>tickets</option>
</select></label>
<label>Max <select name="n">{{range .LimitChoices}}<option{{if eq . $.Limit}} selected{{end}}>{{.}}</option>{{end}}</select></label>
<button type="submit">Apply</button>
</form>
<div id="tl"{{if .Live}} {{.LiveAttr}}{{end}}>{{template "tl-body" .}}</div>
{{end}}
{{define "tl-body"}}
{{if .Days}}<div class="tw"><table class="tl">
<tbody>{{range .Days}}
<tr class="tl-date"><td></td><td class="tl-graph" style="min-width:{{.Width}}px">{{.Carry}}</td><td class="tl-datecell"><span class="tl-pill">{{.Date}}</span></td></tr>
{{range .Rows}}<tr{{if ne .Kind "ci"}} class="tl-flat"{{end}}>
<td class="tl-time"><a href="{{.URL}}">{{.Time}}</a></td>
<td class="tl-graph" style="min-width:{{.Width}}px">{{.Head}}{{.Tail}}</td>
<td class="tl-slot"><div class="tl-card{{if ne .Kind "ci"}} tl-card-flat{{end}}"{{with .Tint}} style="{{.}}"{{end}}>
{{if eq .Kind "ci"}}<span class="tl-title">{{.Title}}</span>
{{range .Paras}}<p class="tl-p">{{.}}</p>{{end}}
<div class="tl-meta">{{if .IsMerge}}<span class="tl-badge">Merge</span> {{end}}{{if .IsLeaf}}<span class="tl-badge">Leaf</span> {{end}}check-in: <a href="{{.URL}}"><b>{{.Short}}</b></a>
user: <b>{{.User}}</b>
branch: <a href="/r/{{$.Repo}}/timeline?branch={{.Branch}}"><b>{{.Branch}}</b></a></div>
{{else}}<span class="tl-badge">{{if eq .Kind "w"}}wiki{{else}}ticket{{end}}</span> <a href="{{.URL}}">{{.Title}}</a>
<span class="tl-meta-inline">by <b>{{.User}}</b></span>{{end}}
</div></td>
</tr>
{{end}}{{end}}</tbody>
</table></div>
{{if .Next}}<p><a href="/r/{{.Repo}}/timeline?before={{.Next}}{{if .Branch}}&branch={{.Branch}}{{end}}{{with .Y}}&y={{.}}{{end}}&n={{.Limit}}">older →</a></p>{{end}}
{{else}}<p>No check-ins.</p>{{end}}
{{end}}`))
type pageData struct {
Title string
Version string
Repo string // repo context; empty on fleet-level pages
Branch string // active timeline branch filter
Limit int
LimitChoices []int
Y string
Live bool
LiveAttr template.HTMLAttr
Fleet []repoInfo
Entries []timelineEntry
Days []tlDay
Next string
WikiPages []wikiTitle
Wiki wikiDetail
WikiRev string // non-empty when viewing a historical revision
WikiRevs []wikiRev // the history page's edit list
Checkin checkinDetail
Tickets []ticketSummary
Ticket ticketDetail
File fileDetail
Diff diffDetail
DiffLines []diffLine
Query string
Results []searchHit
// WikiHTML and FileHTML are op-rendered, sanitizer-gated markup —
// the trusted HTML injection points (0002 §2: the sanitizer is the
// gate).
WikiHTML template.HTML
FileHTML template.HTML
}
type repoInfo struct {
Name string `json:"name"`
Checkins int `json:"checkins"`
TipDate string `json:"tipDate"`
TipComment string `json:"tipComment"`
}
type timelineEntry struct {
Kind string `json:"kind"`
Ref string `json:"ref"`
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
Branch string `json:"branch"`
IsLeaf bool `json:"isLeaf"`
IsMerge bool `json:"isMerge"`
Parents []string `json:"parents"`
}
type wikiTitle struct {
Title string `json:"title"`
Date string `json:"date"`
User string `json:"user"`
}
type wikiDetail struct {
Hash string `json:"hash"`
Title string `json:"title"`
Date string `json:"date"`
User string `json:"user"`
HTML string `json:"html"`
}
type wikiRev struct {
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
}
type ticketSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Updated string `json:"updated"`
}
type ticketField struct {
Name string `json:"name"`
Value string `json:"value"`
}
type ticketDetail struct {
ID string `json:"id"`
Fields []ticketField `json:"fields"`
Changes []struct {
Date string `json:"date"`
User string `json:"user"`
Fields []ticketField `json:"fields"`
} `json:"changes"`
}
type checkinDetail struct {
Hash string `json:"hash"`
Branch string `json:"branch"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
Parents []string `json:"parents"`
Files []struct {
Blob string `json:"blob"`
Name string `json:"name"`
} `json:"files"`
}
type fileDetail struct {
Hash string `json:"hash"`
Path string `json:"path"`
Size int `json:"size"`
Binary bool `json:"binary"`
Content string `json:"content"`
HTML string `json:"html"`
}
type diffDetail struct {
From string `json:"from"`
To string `json:"to"`
Note string `json:"note"`
Files []struct {
Status string `json:"status"`
Name string `json:"name"`
} `json:"files"`
Unified string `json:"unified"`
}
type diffLine struct {
Class string
Text string
}
// classifyDiff maps unified-diff lines to display classes; the template
// escapes Text, so content stays inert.
func classifyDiff(unified string) []diffLine {
if unified == "" {
return nil
}
var out []diffLine
for _, line := range strings.Split(strings.TrimSuffix(unified, "\n"), "\n") {
class := "ctx"
switch {
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
class = "head"
case strings.HasPrefix(line, "@@"):
class = "hunk"
case strings.HasPrefix(line, "+"):
class = "add"
case strings.HasPrefix(line, "-"):
class = "del"
}
out = append(out, diffLine{Class: class, Text: line})
}
return out
}
type searchHit struct {
Kind string
Label string
URL string
Snippet template.HTML
}
// hitOf turns one search result into its page link. The label is the
// human-readable name the op provides (ticket title, wiki name,
// check-in comment) with the ref as fallback; the snippet's ⟦…⟧ match
// markers become <mark> AFTER escaping, so content stays inert.
func hitOf(repo, kind, ref, label, snippet string) searchHit {
h := searchHit{Kind: kind, Label: label}
prefix := "/r/" + repo
switch kind {
case "checkin":
h.URL = prefix + "/checkin/" + ref
if h.Label == "" {
h.Label = ref[:min(12, len(ref))]
}
case "wiki":
h.URL = prefix + "/wiki/" + url.PathEscape(ref)
if h.Label == "" {
h.Label = ref
}
case "ticket":
h.URL = prefix + "/ticket/" + ref
if h.Label == "" {
h.Label = ref[:min(10, len(ref))]
}
}
esc := template.HTMLEscapeString(snippet)
esc = strings.ReplaceAll(esc, "⟦", "<mark>")
esc = strings.ReplaceAll(esc, "⟧", "</mark>")
h.Snippet = template.HTML(esc)
return h
}
// Handler mounts every HTML page over the registry. n may be nil (no
// live updates; pages are unchanged otherwise).
func Handler(r *ops.Registry, n Notifier) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /assets/datastar.js", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(datastarJS)
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, req *http.Request) {
out, err := invoke(req.Context(), r, "repo.list", nil)
if err != nil {
fail(r, w, req, err)
return
}
var fl struct {
Repos []repoInfo `json:"repos"`
}
remarshal(out, &fl)
data := pageData{Title: "quarry", Fleet: fl.Repos}
if n != nil {
data.Live = true
data.LiveAttr = template.HTMLAttr(`data-init="@get('/fleet.sse')"`)
}
render(r, w, req, fleetPage, data)
})
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, req *http.Request) {
out, err := invoke(req.Context(), r, "meta.docs", nil)
if err != nil {
fail(r, w, req, err)
return
}
var d struct {
HTML string `json:"html"`
}
remarshal(out, &d)
render(r, w, req, docsPage, pageData{
Title: "API", WikiHTML: template.HTML(d.HTML),
})
})
mux.HandleFunc("GET /fleet.sse", func(w http.ResponseWriter, req *http.Request) {
if n == nil {
http.NotFound(w, req)
return
}
liveFleet(r, n, w, req)
})
// Everything unmatched gets the themed 404, never Go's bare
// plain-text default.
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
fail(r, w, req, ops.ErrNotFound)
})
mux.HandleFunc("GET /r/{repo}/timeline", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
vals := req.URL.Query()
vals.Set("repo", repo)
// The page's Max control uses fossil's n= name; the op's field
// is limit.
limit := 50
if n := vals.Get("n"); n != "" {
vals.Set("limit", n)
if v, err := strconv.Atoi(n); err == nil && v > 0 && v <= 200 {
limit = v
}
}
out, err := invoke(req.Context(), r, "timeline.list", vals)
if err != nil {
fail(r, w, req, err)
return
}
var tl struct {
Entries []timelineEntry `json:"entries"`
Next string `json:"next"`
}
remarshal(out, &tl)
data := pageData{
Title: "timeline", Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next,
Branch: req.URL.Query().Get("branch"), Y: req.URL.Query().Get("y"),
Limit: limit, LimitChoices: []int{20, 50, 100, 200},
}
// Live updates enhance the first page only; history pages are
// static by nature. Without the notifier (or JS) the page is
// simply itself — Principle 3's refresh-fallback.
if n != nil && req.URL.Query().Get("before") == "" {
data.Live = true
// Built as a trusted attribute: html/template would JS-escape
// data-on-* values (slashes become \/).
data.LiveAttr = template.HTMLAttr(
`data-init="@get('/r/` + repo + `/timeline.sse?` +
template.HTMLEscapeString(req.URL.RawQuery) + `')"`)
}
render(r, w, req, timelinePage, data)
})
mux.HandleFunc("GET /r/{repo}/timeline.sse", func(w http.ResponseWriter, req *http.Request) {
if n == nil {
http.NotFound(w, req)
return
}
liveTimeline(r, n, w, req)
})
mux.HandleFunc("GET /r/{repo}/wiki", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
out, err := invoke(req.Context(), r, "wiki.list", url.Values{"repo": {repo}})
if err != nil {
fail(r, w, req, err)
return
}
var wl struct {
Pages []wikiTitle `json:"pages"`
}
remarshal(out, &wl)
render(r, w, req, wikiListPage, pageData{Title: "wiki", Repo: repo, WikiPages: wl.Pages})
})
mux.HandleFunc("GET /r/{repo}/ticket", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
out, err := invoke(req.Context(), r, "ticket.list",
url.Values{"repo": {repo}, "before": {req.URL.Query().Get("before")}})
if err != nil {
fail(r, w, req, err)
return
}
var tl struct {
Tickets []ticketSummary `json:"tickets"`
Next string `json:"next"`
}
remarshal(out, &tl)
render(r, w, req, ticketListPage, pageData{
Title: "tickets", Repo: repo, Tickets: tl.Tickets, Next: tl.Next,
})
})
mux.HandleFunc("GET /r/{repo}/ticket/{id}", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
out, err := invoke(req.Context(), r, "ticket.get",
url.Values{"repo": {repo}, "id": {req.PathValue("id")}})
if err != nil {
fail(r, w, req, err)
return
}
var td struct {
Ticket ticketDetail `json:"ticket"`
}
remarshal(out, &td)
render(r, w, req, ticketPage, pageData{
Title: "ticket " + td.Ticket.ID[:10], Repo: repo, Ticket: td.Ticket,
})
})
mux.HandleFunc("GET /r/{repo}/search", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
q := req.URL.Query().Get("q")
data := pageData{Title: "search", Repo: repo, Query: q}
if q != "" {
vals := req.URL.Query()
vals.Set("repo", repo)
out, err := invoke(req.Context(), r, "search.query", vals)
if err != nil {
fail(r, w, req, err)
return
}
var sr struct {
Results []struct {
Kind string `json:"kind"`
Ref string `json:"ref"`
Label string `json:"label"`
Snippet string `json:"snippet"`
} `json:"results"`
}
remarshal(out, &sr)
for _, res := range sr.Results {
data.Results = append(data.Results, hitOf(repo, res.Kind, res.Ref, res.Label, res.Snippet))
}
}
render(r, w, req, searchPage, data)
})
mux.HandleFunc("GET /r/{repo}/checkin/{hash}/diff", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
out, err := invoke(req.Context(), r, "checkin.diff",
url.Values{"repo": {repo}, "hash": {req.PathValue("hash")}})
if err != nil {
fail(r, w, req, err)
return
}
var dd diffDetail
remarshal(out, &dd)
render(r, w, req, diffPage, pageData{
Title: "diff " + dd.To[:12], Repo: repo, Diff: dd, DiffLines: classifyDiff(dd.Unified),
})
})
mux.HandleFunc("GET /r/{repo}/file/{hash}/{path...}", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
out, err := invoke(req.Context(), r, "file.get", url.Values{
"repo": {repo},
"hash": {req.PathValue("hash")},
"path": {req.PathValue("path")},
})
if err != nil {
fail(r, w, req, err)
return
}
var fd fileDetail
remarshal(out, &fd)
render(r, w, req, filePage, pageData{
Title: fd.Path, Repo: repo, File: fd, FileHTML: template.HTML(fd.HTML),
})
})
mux.HandleFunc("GET /r/{repo}/checkin/{hash}", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
out, err := invoke(req.Context(), r, "checkin.get",
url.Values{"repo": {repo}, "hash": {req.PathValue("hash")}})
if err != nil {
fail(r, w, req, err)
return
}
var cd checkinDetail
remarshal(out, &cd)
render(r, w, req, checkinPage, pageData{
Title: "check-in " + cd.Hash[:12], Repo: repo, Checkin: cd,
})
})
mux.HandleFunc("GET /r/{repo}/wiki/{name}", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
rev := req.URL.Query().Get("rev")
out, err := invoke(req.Context(), r, "wiki.get",
url.Values{"repo": {repo}, "name": {req.PathValue("name")}, "rev": {rev}})
if err != nil {
fail(r, w, req, err)
return
}
var wd wikiDetail
remarshal(out, &wd)
render(r, w, req, wikiPage, pageData{
Title: wd.Title, Repo: repo, Wiki: wd, WikiRev: rev,
WikiHTML: template.HTML(wd.HTML),
})
})
mux.HandleFunc("GET /r/{repo}/wiki/{name}/history", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
name := req.PathValue("name")
out, err := invoke(req.Context(), r, "wiki.history",
url.Values{"repo": {repo}, "name": {name}})
if err != nil {
fail(r, w, req, err)
return
}
var wh struct {
Title string `json:"title"`
Edits []wikiRev `json:"edits"`
}
remarshal(out, &wh)
render(r, w, req, wikiHistoryPage, pageData{
Title: wh.Title + " history", Repo: repo,
Wiki: wikiDetail{Title: wh.Title}, WikiRevs: wh.Edits,
})
})
return mux
}
// liveTimeline streams Datastar element patches: on every change tick
// (debounced), the timeline body re-renders through the same op path
// the page uses and morphs into #tl.
func liveTimeline(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
streamPatches(n, repo, w, req, func() (string, error) {
return timelineFragment(r, repo, req.URL.Query())
})
}
// liveFleet does the same for the home page: any repo's change re-renders
// the fleet table.
func liveFleet(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) {
streamPatches(n, "*", w, req, func() (string, error) {
return fleetFragment(r)
})
}
// streamPatches is the SSE loop both live pages share: subscribe to one
// repo's changes ("*" = the whole fleet), debounce bursts, and send each
// repaint as a Datastar element patch that morphs in place.
func streamPatches(n Notifier, repo string, w http.ResponseWriter, req *http.Request, fragment func() (string, error)) {
fl, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprint(w, ": live\n\n")
fl.Flush()
ticks, cancel := n.Watch(repo)
defer cancel()
heartbeat := time.NewTicker(20 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-req.Context().Done():
return
case <-heartbeat.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
case _, open := <-ticks:
if !open {
return
}
// Debounce: a burst of artifacts is one repaint.
time.Sleep(200 * time.Millisecond)
select {
case <-ticks:
default:
}
frag, err := fragment()
if err != nil {
log.Printf("web: live %s: %v", repo, err)
continue
}
// Datastar v1 element patch: morphs the fragment in place.
fmt.Fprint(w, "event: datastar-patch-elements\n")
for _, line := range strings.Split(frag, "\n") {
fmt.Fprintf(w, "data: elements %s\n", line)
}
fmt.Fprint(w, "\n")
fl.Flush()
}
}
}
// fleetFragment renders the #fleet body through repo.list — shared by
// the page render and the live patches.
func fleetFragment(r *ops.Registry) (string, error) {
out, err := invoke(context.Background(), r, "repo.list", nil)
if err != nil {
return "", err
}
var fl struct {
Repos []repoInfo `json:"repos"`
}
remarshal(out, &fl)
var buf bytes.Buffer
buf.WriteString(`<div id="fleet">`)
if err := fleetPage.ExecuteTemplate(&buf, "fleet-body", pageData{Fleet: fl.Repos}); err != nil {
return "", err
}
buf.WriteString(`</div>`)
return buf.String(), nil
}
// timelineFragment renders the #tl body for the given query — shared by
// the page render and the live patches, so both always agree.
func timelineFragment(r *ops.Registry, repo string, q url.Values) (string, error) {
vals := url.Values{}
for k, v := range q {
vals[k] = v
}
vals.Set("repo", repo)
if nn := vals.Get("n"); nn != "" {
vals.Set("limit", nn)
}
out, err := invoke(context.Background(), r, "timeline.list", vals)
if err != nil {
return "", err
}
var tl struct {
Entries []timelineEntry `json:"entries"`
Next string `json:"next"`
}
remarshal(out, &tl)
limit := 50
if v, err := strconv.Atoi(vals.Get("limit")); err == nil && v > 0 {
limit = v
}
data := pageData{
Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next,
Branch: q.Get("branch"), Y: q.Get("y"), Limit: limit,
}
var buf bytes.Buffer
buf.WriteString(`<div id="tl">`)
if err := timelinePage.ExecuteTemplate(&buf, "tl-body", data); err != nil {
return "", err
}
buf.WriteString(`</div>`)
return buf.String(), nil
}
// render executes a page template with the version footer served by the
// meta.version op — every page reports it (0001 Principle 1).
func render(r *ops.Registry, w http.ResponseWriter, req *http.Request,
page *template.Template, data pageData) {
renderStatus(r, w, req, page, data, http.StatusOK)
}
func renderStatus(r *ops.Registry, w http.ResponseWriter, req *http.Request,
page *template.Template, data pageData, status int) {
out, err := invoke(req.Context(), r, "meta.version", nil)
if err != nil {
http.Error(w, "version op failed", http.StatusInternalServerError)
return
}
var v struct {
Version string `json:"version"`
}
remarshal(out, &v)
data.Version = v.Version
// Render to a buffer first: a template error mid-stream would
// otherwise emit a silently truncated page (a bug class this exact
// code once shipped) — buffered, it becomes a loud, logged 500.
var buf bytes.Buffer
if err := page.Execute(&buf, data); err != nil {
log.Printf("web: template %s: %v", req.URL.Path, err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
w.Write(buf.Bytes())
}
// fail renders the themed error page for an op failure: not-found gets
// the 404 page, everything else logs and gets the 500 page — never
// Go's bare plain-text defaults (every page keeps theme, nav, version).
func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) {
if errors.Is(err, ops.ErrNotFound) {
renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound)
return
}
log.Printf("web: %s: %v", req.URL.Path, err)
renderStatus(r, w, req, errorPage, pageData{Title: "error"}, http.StatusInternalServerError)
}
// invoke runs an op through its descriptor with form-shaped input — the
// same decode path the API router uses, so HTML pages and API calls hit
// identical op semantics. Results come back as opaque values; pages pick
// the fields they render via remarshal.
func invoke(ctx context.Context, r *ops.Registry, name string, form url.Values) (any, error) {
d, ok := r.Lookup(name)
if !ok {
return nil, fmt.Errorf("web: unknown op %q", name)
}
inPtr := d.NewIn()
if err := ops.DecodeValues(form, inPtr); err != nil {
return nil, err
}
in := reflect.ValueOf(inPtr).Elem().Interface()
if err := ops.Validate(in); err != nil {
return nil, err
}
return r.Invoke(ctx, name, in)
}
// remarshal projects an op result onto the fields a page renders — the
// JSON round-trip keeps web coupled to op output *shapes* (the API
// contract) rather than to concrete Go types in other packages.
func remarshal(from any, to any) {
b, err := json.Marshal(from)
if err == nil {
json.Unmarshal(b, to)
}
}