Skip to content
quarry Artifact [1d48df9623]
Login

Artifact [1d48df9623]

Artifact 1d48df962325b6004479b7f8d0ec8e01f6e9a6fa50220f65b4500f13ceb7a0f0:


// 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/base64"
	"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())
}

// ChatMessage is one rendered chat line (mirrors chat.Message without
// importing the data plane into a view type).
type ChatMessage struct {
	User string `json:"user"`
	Text string `json:"text"`
	At   string `json:"at"`
}

// ChatLive delivers a repo channel's live messages to the SSE handler.
// nil disables the live chat stream (history + form still work). The
// composition root's adapter maps the repo name to its project code.
type ChatLive interface {
	SubscribeChat(repo string) (<-chan ChatMessage, 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%; }
  /* Phone widths: the card's widest line is the metadata run, and its
     max-content is what pushed the table past the viewport (auto table
     layout prefers it, and .tw then scrolls sideways, hiding the
     right-aligned meta). Stacking the items drops that preference to
     the longest single item, so the table fits with the cards still
     wide. */
  .tl-metaitem { display: block; }
  .tl-card { overflow-wrap: anywhere; }
}
.tkt-title { margin-bottom: 0.3rem; }
.tkt-head th { text-align: left; color: var(--muted); font-weight: normal; width: 10rem; }
.tkt-body { margin: 0.5rem 0 1rem; }
.tkt-comment { border-top: 1px solid var(--muted); padding: 0.6rem 0; }
.tkt-comment:first-of-type { border-top: 0; }
.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}}/tree">files</a> <a href="/r/{{.Repo}}/timeline">timeline</a> <a href="/r/{{.Repo}}/wiki">wiki</a> <a href="/r/{{.Repo}}/ticket">tickets</a> <a href="/r/{{.Repo}}/forum">forum</a> <a href="/r/{{.Repo}}/technote">technotes</a> <a href="/r/{{.Repo}}/ci">ci</a> <a href="/r/{{.Repo}}/chat">chat</a></nav>
<form action="/r/{{.Repo}}/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form>
{{end}}
{{if .SignedIn}}<form class="who" action="/logout" method="post">{{if $.HasInbox}}<small><a href="/inbox">inbox</a></small> {{end}}<small class="tl-meta-inline">{{.SignedIn}}</small>
<button type="submit">sign out</button></form>{{else if .CanSignIn}}<small class="who"><a href="/login">sign in</a></small>{{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}}{{if .Indexing}} <span class="tl-badge">indexing…</span>{{end}}</td>
<td>{{if .Indexing}}<small class="tl-meta-inline">first index in progress; counts climb as it folds</small>{{else}}{{.TipDate}} — {{.TipComment}}{{end}}</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{{if .Live}} {{.LiveAttr}}{{end}}><div id="fleet">{{template "fleet-body" .}}</div></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}}
<form method="get" action="/r/{{.Repo}}/wiki-new"><input name="name" placeholder="new page name" required> <button type="submit">Create</button></form>
{{end}}`))

var forumListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Forum}}<ul>{{range .Forum}}
<li><a href="/r/{{$.Repo}}/forum/{{printf "%.16s" .Root}}">{{if .Title}}{{.Title}}{{else}}(untitled thread){{end}}</a>
<small>{{.Posts}} post{{if ne .Posts 1}}s{{end}} · last {{.Updated}}{{if .User}} · started by {{.User}}{{end}}</small></li>
{{end}}</ul>{{else}}<p>No forum threads.</p>{{end}}
<p><a href="/r/{{.Repo}}/forum-new">new thread</a></p>
{{end}}`))

var forumThreadPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/forum">← forum</a></p>
{{range .ForumPosts}}<article style="border-top:1px solid var(--muted); padding-top:0.5rem; margin-top:0.5rem">
<p><small>{{.Date}} by {{.User}}{{if .Parent}} · in reply to <code>{{printf "%.12s" .Parent}}</code>{{end}} · <code>{{printf "%.12s" .Hash}}</code></small></p>
{{.HTML}}
</article>{{end}}
<form method="post" action="/r/{{.Repo}}/forum/{{.ForumRoot}}/reply" style="margin-top:1rem">
<p><textarea name="body" rows="6" style="width:100%" placeholder="Reply (Markdown)" required></textarea></p>
<p><button type="submit">Post reply</button></p>
</form>
{{end}}`))

var forumNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/forum-new">
<p><label>Title<br><input name="title" required style="width:100%"></label></p>
<p><label>Post<br><textarea name="body" rows="14" style="width:100%" placeholder="Markdown" required></textarea></label></p>
<p><button type="submit">Open thread</button> <a href="/r/{{.Repo}}/forum">cancel</a></p>
</form>
{{end}}`))

var technoteEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit{{else}}/r/{{.Repo}}/technote-new{{end}}">
<p><label>Title<br><input name="comment" style="width:100%" maxlength="200" value="{{.Technote.Comment}}"></label>
<small class="tl-meta-inline">shown in the timeline; a note without one shows its event id</small></p>
<p><label>Note<br><textarea name="body" rows="16" style="width:100%" placeholder="Markdown" required>{{.EditSource}}</textarea></label></p>
<p><button type="submit">Save</button>
<a href="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}{{else}}/r/{{.Repo}}/technote{{end}}">cancel</a></p>
</form>
{{end}}`))

var technoteListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Technotes}}<ul>{{range .Technotes}}
<li><a href="/r/{{$.Repo}}/technote/{{printf "%.16s" .Event}}">{{if .Comment}}{{.Comment}}{{else}}{{.Date}}{{end}}</a> <small>{{.Date}} by {{.User}}</small></li>
{{end}}</ul>{{else}}<p>No technotes.</p>{{end}}
<p><a href="/r/{{.Repo}}/technote-new">new technote</a></p>
{{end}}`))

var technotePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<article>{{.WikiHTML}}</article>
<footer><small>technote {{.Technote.Date}} by {{.Technote.User}} ·
<a href="/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit">edit</a></small></footer>
{{end}}`))

var chatPage = template.Must(template.Must(base.Clone()).Parse(`{{define "chat-log"}}<div id="chatlog" class="chatlog">
{{range .ChatMsgs}}<p class="chatmsg"><b>{{.User}}</b> <small class="tl-meta-inline">{{.At}}</small><br>{{.Text}}</p>
{{end}}</div>{{end}}
{{define "content"}}
<style>.chatlog{max-height:60vh;overflow-y:auto;border:1px solid var(--muted);border-radius:6px;padding:0.5rem;margin-bottom:0.5rem}
.chatmsg{margin:0.3rem 0}.chatform{display:flex;gap:0.5rem}.chatform input[name=text]{flex:1}</style>
<div{{if .Live}} {{.LiveAttr}}{{end}}>{{template "chat-log" .}}</div>
<form class="chatform" method="post" action="/r/{{.Repo}}/chat">
<input name="text" placeholder="message" maxlength="4096" required autocomplete="off">
<button type="submit">Send</button>
</form>
<p><small class="tl-meta-inline">Messages persist for 7 days; durable forum-thread archival lands with zit forum verbs.</small></p>
{{end}}`))

var docsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<article>{{.WikiHTML}}</article>
{{end}}`))

var wikiEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/edit">
<p><textarea name="body" rows="20" style="width:100%" placeholder="Markdown">{{.EditSource}}</textarea></p>
<p><button type="submit">Save</button> <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">cancel</a></p>
</form>
{{end}}`))

var ticketNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/ticket/new">
<p><label>Title<br><input name="title" required style="width:100%"></label></p>
<p><label>Status<br><input name="status" value="Open"></label></p>
<p><label>Comment<br><textarea name="comment" rows="8" style="width:100%"></textarea></label></p>
<p><button type="submit">Open ticket</button></p>
</form>
{{end}}`))

var ticketEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">
<p><label>Title<br><input name="title" value="{{.Ticket.Field "title"}}" style="width:100%"></label></p>
<p><label>Status<br><input name="status" value="{{.Ticket.Field "status"}}"></label></p>
<p><label>Resolution<br><input name="resolution" value="{{.Ticket.Field "resolution"}}"></label></p>
<p><label>Comment<br><textarea name="comment" rows="6" style="width:100%" placeholder="add a comment"></textarea></label></p>
<p><button type="submit">Save change</button> <a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}">cancel</a></p>
</form>
{{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}}
<p><a href="/r/{{.Repo}}/ticket/new">new ticket</a></p>
{{end}}`))

var inboxPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}}
{{if .Inbox}}<ul>{{range .Inbox}}
<li>{{if not .Read}}<strong>{{.Subject}}</strong>{{else}}{{.Subject}}{{end}}
<small class="tl-meta-inline">{{.CreatedAt}}</small>
{{if .Content}}<br><small>{{.Content}}</small>{{end}}</li>
{{end}}</ul>{{else if not .Error}}<p>No notifications.</p>{{end}}
{{end}}`))

var loginPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{.LoginNext}}">
<p><label>Account<br><input name="user" autocomplete="username" required autofocus></label></p>
<p><label>Password<br><input name="password" type="password" autocomplete="current-password" required></label></p>
<p><button type="submit">Sign in</button></p>
</form>
{{end}}`))

var ciPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .CIRuns}}<div class="tw"><table>
<thead><tr><th scope="col">Status</th><th scope="col">Check-in</th><th scope="col">Pipeline</th><th scope="col">Started</th></tr></thead>
<tbody>{{range .CIRuns}}
<tr><td><span class="tl-badge">{{.Status}}</span></td>
<td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Checkin}}"><code>{{printf "%.12s" .Checkin}}</code></a></td>
<td>{{.Pipeline}} <small class="tl-meta-inline">{{.Workflow}}</small></td>
<td class="tl-meta-inline">{{.Started}}</td></tr>
{{end}}</tbody>
</table></div>
{{else}}<p>No CI runs. A pipeline is <code>ci/*.json</code> in the repository, in dagnats' workflow format; it runs on the check-ins made after it lands.</p>{{end}}
{{end}}`))

var treePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<div class="tl-controls">
{{if .Branches}}<form method="get" action="/r/{{.Repo}}/tree"><label>Branch
<select name="v" onchange="this.form.submit()">{{range .Branches}}
<option value="{{.Tip}}"{{if eq .Tip $.TreeVersion}} selected{{end}}>{{.Name}} · {{printf "%.10s" .Tip}}</option>
{{end}}</select></label> <noscript><button type="submit">go</button></noscript></form>{{end}}
<span class="tl-meta-inline">{{.CloneURL}}</span>
</div>
<p class="tl-meta-inline">clone: <code>zit clone {{.CloneURL}} {{.Repo}}</code></p>
<p><a href="/r/{{.Repo}}/tree">{{.Repo}}</a>{{range .Crumbs}} / <a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>{{end}}</p>
<div class="tw"><table>
<thead><tr><th scope="col">Name</th><th scope="col">Contents</th></tr></thead>
<tbody>{{range .Tree}}
<tr><td>{{if .Dir}}<a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}/</a>{{else}}<a href="/r/{{$.Repo}}/file/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>{{end}}</td>
<td class="tl-meta-inline">{{if .Dir}}{{.Files}} files{{else}}<code>{{printf "%.12s" .Blob}}</code>{{end}}</td></tr>
{{end}}</tbody>
</table></div>
{{if .Readme}}<h3>{{.ReadmeIn}}</h3>
<div class="tkt-body">{{.Readme}}</div>{{end}}
{{end}}`))

var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Ticket.Title}}<h2 class="tkt-title">{{.Ticket.Title}}</h2>{{end}}
<div class="tw"><table class="tkt-head">
<tbody>{{range .Ticket.Header}}
<tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr>
{{end}}{{range .Ticket.Extra}}
<tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr>
{{end}}
<tr><th scope="row">created</th><td>{{.Ticket.Created}}</td></tr>
<tr><th scope="row">last change</th><td>{{.Ticket.Updated}}</td></tr>
<tr><th scope="row">ticket</th><td><code>{{printf "%.20s" .Ticket.ID}}</code></td></tr>
</tbody>
</table></div>
<p><a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">edit ticket</a></p>
{{if .DescriptionHTML}}<h3>Description</h3>
<div class="tkt-body">{{.DescriptionHTML}}</div>{{end}}
{{if .Attachments}}<h3>Attachments</h3>
<ul>{{range .Attachments}}
<li><a href="/r/{{$.Repo}}/attachment/{{.Src}}">{{.Name}}</a> <small class="tl-meta-inline">{{.Date}} by {{.User}}</small></li>
{{end}}</ul>{{end}}
{{if .TicketComments}}<h3>Comments ({{len .TicketComments}})</h3>
{{range .TicketComments}}<div class="tkt-comment">
<div class="tl-meta-inline">{{.Login}}{{if .DifferentUsername}} (claiming to be {{.Username}}){{end}} · {{.Date}}</div>
{{.HTML}}
</div>
{{end}}{{end}}
{{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}}/edit">edit</a> ·
<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>
<option value="e"{{if eq .Y "e"}} selected{{end}}>technotes</option>
<option value="f"{{if eq .Y "f"}} selected{{end}}>forum</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{{if .Live}} {{.LiveAttr}}{{end}}><div id="tl">{{template "tl-body" .}}</div></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}}<span class="tl-metaitem">check-in: <a href="{{.URL}}"><b>{{.Short}}</b></a></span>
<span class="tl-metaitem">user: <b>{{.User}}</b></span>
<span class="tl-metaitem">branch: <a href="/r/{{$.Repo}}/timeline?branch={{.Branch}}"><b>{{.Branch}}</b></a></span></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 carries the data-init that opens the SSE stream. It MUST
	// sit on a wrapper OUTSIDE the element the stream patches: a patch
	// morphs the target to match the fragment, and the fragment has no
	// data-init, so an attribute on the target is deleted by the first
	// patch — taking Datastar's own connection with it. The failure is
	// quiet and easy to miss, because the first patch still applies.
	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
	EditSource  string    // wiki edit form's prefilled markdown
	Checkin     checkinDetail
	Tickets     []ticketSummary
	Ticket      ticketDetail
	Attachments []attachmentRow
	// DescriptionHTML and TicketComments are pre-rendered by the op; the
	// sanitizer already ran, which is why they are template.HTML here.
	DescriptionHTML template.HTML
	TicketComments  []ticketComment
	CIRuns          []ciRunRow
	Tree            []treeRow
	TreeVersion     string
	Crumbs          []crumb
	Branches        []branchRow
	CloneURL        string
	Readme          template.HTML
	ReadmeIn        string
	Forum           []forumThreadRow
	ForumRoot       string // reply-form target on the thread page
	ForumPosts      []forumPostRow
	LoginNext       string // post-login redirect target (Next is the page cursor)
	SignedIn        string // the signed-in account, for the header
	Inbox           []InboxMessage
	HasInbox        bool   // an inbox is configured, so link to it
	CanSignIn       bool   // sessions are configured, so offer the link
	Error           string // form-level failure, shown above the fields
	Technotes       []technoteRow
	Technote        technoteDetail
	ChatMsgs        []ChatMessage
	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"`
	Indexing   bool   `json:"indexing"`
}

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 forumThreadRow struct {
	Root    string `json:"root"`
	Title   string `json:"title"`
	Posts   int    `json:"posts"`
	Updated string `json:"updated"`
	User    string `json:"user"`
}

type forumPostRow struct {
	Hash   string `json:"hash"`
	Parent string `json:"parent"`
	Date   string `json:"date"`
	User   string `json:"user"`
	HTML   template.HTML
}

// attachmentRow is one live attachment on a ticket. Fossil keeps the
// content as its own artifact, which the download route serves by hash.
// ciRunRow is one CI run for the display.
type ciRunRow struct {
	RunID    string `json:"runId"`
	Checkin  string `json:"checkin"`
	Workflow string `json:"workflow"`
	Pipeline string `json:"pipeline"`
	Status   string `json:"status"`
	Started  string `json:"started"`
}

// treeRow is one entry of a directory listing.
type treeRow struct {
	Name  string `json:"name"`
	Path  string `json:"path"`
	Dir   bool   `json:"dir"`
	Blob  string `json:"blob"`
	Files int    `json:"files"`
}

// branchRow is one branch for the selector. Tip is carried because zit
// allows two branches to share a name, so the name alone cannot select.
type branchRow struct {
	Name string `json:"name"`
	Tip  string `json:"tip"`
}

// crumb is one path segment of the breadcrumb trail.
type crumb struct {
	Name string
	Path string
}

type attachmentRow struct {
	Name string `json:"name"`
	Src  string `json:"src"` // content artifact — the download target
	Date string `json:"date"`
	User string `json:"user"`
}

type technoteRow struct {
	Event   string `json:"event"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

type technoteDetail struct {
	Event   string `json:"event"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

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"`
	Created string        `json:"created"`
	Updated string        `json:"updated"`
	Fields  []ticketField `json:"fields"`
	Changes []struct {
		Date   string        `json:"date"`
		User   string        `json:"user"`
		Fields []ticketField `json:"fields"`
	} `json:"changes"`
}

// ticketComment is one rendered discussion entry.
type ticketComment struct {
	Date     string        `json:"date"`
	Login    string        `json:"login"`
	Username string        `json:"username"`
	HTML     template.HTML `json:"html"`
}

// DifferentUsername reports whether the typed name disagrees with the
// authenticated one. Fossil surfaces that ("claiming to be …") because
// on an open tracker the two need not match, and hiding the difference
// would attribute words to the wrong person.
func (c ticketComment) DifferentUsername() bool {
	return c.Username != "" && c.Username != c.Login
}

// Title is the ticket's headline, promoted out of the field table the
// way fossil's view does — a ticket is known by its title, not its hash.
func (t ticketDetail) Title() string { return t.Field("title") }

// Header is the ordered subset fossil shows above the description. The
// order is fossil's own (status/type, severity/priority,
// subsystem/resolution), and unset fields are dropped rather than
// rendered as blank rows.
func (t ticketDetail) Header() []ticketField {
	order := []string{"status", "type", "severity", "priority",
		"subsystem", "resolution", "assignee", "foundin", "submitter", "closer"}
	var out []ticketField
	for _, name := range order {
		if v := t.Field(name); v != "" {
			out = append(out, ticketField{Name: name, Value: v})
		}
	}
	return out
}

// Extra is everything else a repository's ticket schema carries — trackers
// define their own columns, so a fixed list would hide them.
func (t ticketDetail) Extra() []ticketField {
	known := map[string]bool{"title": true, "status": true, "type": true,
		"severity": true, "priority": true, "subsystem": true, "resolution": true,
		"assignee": true, "foundin": true, "submitter": true, "closer": true}
	var out []ticketField
	for _, f := range t.Fields {
		if !known[f.Name] && f.Value != "" {
			out = append(out, f)
		}
	}
	return out
}

// Field folds the current value of one named field (for form prefill).
func (t ticketDetail) Field(name string) string {
	for _, f := range t.Fields {
		if f.Name == name {
			return f.Value
		}
	}
	return ""
}

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).
// Verify checks one repo credential pair against a required capability;
// nil leaves writes open (single-user local serving, author "anonymous").
type Verify func(repo, user, password string, need ops.Auth) bool

// Sessions is the browser half of the gate: a cookie a person can drop.
// HTTP Basic keeps working alongside it — that is what the API and every
// script uses — but a browser needs a way to log OUT, which Basic does
// not give it. A nil Sessions disables the login page and leaves Basic
// as the only path.
type Sessions interface {
	// Login issues a token for correct credentials.
	Login(user, password string) (token string, expires time.Time, ok bool)
	// Resolve names the account a token belongs to.
	Resolve(token string) (user string, ok bool)
	// Logout drops one token.
	Logout(token string)
	// Allow authorizes an ALREADY-authenticated user, so a cookie is
	// checked against the same roles a password would be.
	Allow(user, repo string, need ops.Auth) bool
}

// InboxMessage is one notification as the inbox page shows it. quarry
// does not model notifications — notify owns them (0001 §5) — so this is
// only what the page renders.
type InboxMessage struct {
	Subject   string
	Content   string
	CreatedAt string
	Read      bool
}

// Inbox reads a subscriber's notifications from the notify companion.
// nil disables the inbox page, which is the default: notifications are
// off unless --notify is configured.
type Inbox interface {
	Messages(ctx context.Context, subscriber string) ([]InboxMessage, error)
}

// WithInbox attaches the notify-backed inbox.
func WithInbox(in Inbox) Option {
	return func(o *handlerOpts) { o.inbox = in }
}

// sessionCookie is the browser's session key. Host-only, path-wide,
// unreadable to scripts, and not sent on cross-site requests — the last
// of which is what keeps a POST form from being submitted by another
// origin on the reader's behalf.
const sessionCookie = "quarry_session"

func setSession(w http.ResponseWriter, req *http.Request, token string, expires time.Time) {
	http.SetCookie(w, &http.Cookie{
		Name: sessionCookie, Value: token, Path: "/",
		Expires:  expires,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   req.TLS != nil,
	})
}

func clearSession(w http.ResponseWriter, req *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
		HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: req.TLS != nil,
	})
}

func cookieToken(req *http.Request) string {
	if c, err := req.Cookie(sessionCookie); err == nil {
		return c.Value
	}
	return ""
}

// gate runs the write-page auth check: HTTP Basic against verify, the
// authenticated user carried to the op via context.
func gate(verify Verify, sess Sessions, repo string, w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
	if verify == nil {
		return req, true
	}
	// Every gated HTML page is a write page, so they all ask for the
	// same capability; the API router is where per-op levels differ.
	if sess != nil {
		if user, ok := sess.Resolve(cookieToken(req)); ok {
			if !sess.Allow(user, repo, ops.Token) {
				// Signed in, but without write on THIS repository. A
				// challenge would be a lie — better credentials are not
				// what is missing.
				forbid(w, user, repo)
				return nil, false
			}
			return req.WithContext(ops.WithUser(req.Context(), user)), true
		}
	}
	user, pass, _ := req.BasicAuth()
	if !verify(repo, user, pass, ops.Token) {
		if sess != nil && user == "" {
			// A browser with no credential gets the login page, not a
			// Basic prompt it cannot log out of.
			http.Redirect(w, req, "/login?next="+url.QueryEscape(req.URL.Path), http.StatusSeeOther)
			return nil, false
		}
		w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`)
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte("authentication required"))
		return nil, false
	}
	return req.WithContext(ops.WithUser(req.Context(), user)), true
}

func forbid(w http.ResponseWriter, user, repo string) {
	w.WriteHeader(http.StatusForbidden)
	fmt.Fprintf(w, "%s has no write access to %s", user, repo)
}

// WithSessions attaches the cookie plane to a handler built by Handler.
// Kept separate from Handler's signature so a caller that only needs the
// Basic gate — every test, and single-user local serving — is unchanged.
func WithSessions(s Sessions) func(*handlerOpts) {
	return func(o *handlerOpts) { o.sessions = s }
}

type handlerOpts struct {
	sessions Sessions
	inbox    Inbox
}

// Option configures the handler beyond its required planes.
type Option func(*handlerOpts)

func Handler(r *ops.Registry, n Notifier, live ChatLive, v ...Verify) http.Handler {
	return HandlerWith(r, n, live, nil, v...)
}

// HandlerWith is Handler plus options; Handler is the common case.
func HandlerWith(r *ops.Registry, n Notifier, live ChatLive, opts []Option, v ...Verify) http.Handler {
	var verify Verify
	if len(v) > 0 {
		verify = v[0]
	}
	o := &handlerOpts{}
	for _, fn := range opts {
		fn(o)
	}
	sessions := o.sessions
	inbox := o.inbox
	mux := http.NewServeMux()
	if sessions != nil {
		mux.HandleFunc("GET /login", func(w http.ResponseWriter, req *http.Request) {
			render(r, w, req, loginPage, pageData{
				Title: "sign in", LoginNext: safeNext(req.URL.Query().Get("next")),
			})
		})
		mux.HandleFunc("POST /login", func(w http.ResponseWriter, req *http.Request) {
			user, password := req.FormValue("user"), req.FormValue("password")
			next := safeNext(req.FormValue("next"))
			token, expires, ok := sessions.Login(user, password)
			if !ok {
				// One message for a bad name and a bad password: naming
				// which was wrong tells an attacker which accounts exist.
				w.WriteHeader(http.StatusUnauthorized)
				render(r, w, req, loginPage, pageData{
					Title: "sign in", LoginNext: next,
					Error: "That account and password do not match.",
				})
				return
			}
			setSession(w, req, token, expires)
			http.Redirect(w, req, next, http.StatusSeeOther)
		})
		mux.HandleFunc("POST /logout", func(w http.ResponseWriter, req *http.Request) {
			sessions.Logout(cookieToken(req))
			clearSession(w, req)
			http.Redirect(w, req, "/", http.StatusSeeOther)
		})
	}
	if inbox != nil && sessions != nil {
		mux.HandleFunc("GET /inbox", func(w http.ResponseWriter, req *http.Request) {
			user, ok := sessions.Resolve(cookieToken(req))
			if !ok {
				http.Redirect(w, req, "/login?next=%2Finbox", http.StatusSeeOther)
				return
			}
			data := pageData{Title: "inbox"}
			msgs, err := inbox.Messages(req.Context(), user)
			if err != nil {
				// A notify outage must not look like "you have nothing";
				// an empty list and a failure are different answers.
				log.Printf("web: inbox for %s: %v", user, err)
				data.Error = "Notifications are unavailable right now."
			}
			data.Inbox = msgs
			render(r, w, req, inboxPage, data)
		})
	}
	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)
	})
	// The repository's front door. A forge lands on code — fossil's own
	// default is the timeline, and departing from it is deliberate: a
	// repository with no file view has no front door at all, and every
	// GitHub habit misses.
	treeHandler := func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		version, path := req.PathValue("version"), req.PathValue("path")
		if v := req.URL.Query().Get("v"); v != "" {
			// The branch selector posts here; redirect so the chosen
			// version lives in the path and the page is linkable.
			http.Redirect(w, req, "/r/"+repo+"/tree/"+url.PathEscape(v)+"/", http.StatusSeeOther)
			return
		}
		if version == "" {
			version = "trunk"
		}
		out, err := invoke(req.Context(), r, "tree.list", url.Values{
			"repo": {repo}, "version": {version}, "path": {path},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Version  string    `json:"version"`
			Path     string    `json:"path"`
			Entries  []treeRow `json:"entries"`
			Readme   string    `json:"readme"`
			ReadmeIn string    `json:"readmeIn"`
		}
		remarshal(out, &tl)

		data := pageData{
			Title: repo, Repo: repo, Tree: tl.Entries, TreeVersion: version,
			Readme: template.HTML(tl.Readme), ReadmeIn: tl.ReadmeIn,
			CloneURL: cloneURL(req, repo),
		}
		if tl.Path != "" {
			var acc string
			for _, seg := range strings.Split(tl.Path, "/") {
				acc = strings.TrimPrefix(acc+"/"+seg, "/")
				data.Crumbs = append(data.Crumbs, crumb{Name: seg, Path: acc})
			}
		}
		if bo, err := invoke(req.Context(), r, "branch.list", url.Values{"repo": {repo}}); err == nil {
			var bl struct {
				Branches []branchRow `json:"branches"`
			}
			remarshal(bo, &bl)
			data.Branches = bl.Branches
		}
		render(r, w, req, treePage, data)
	}
	mux.HandleFunc("GET /r/{repo}", treeHandler)
	mux.HandleFunc("GET /r/{repo}/tree", treeHandler)
	mux.HandleFunc("GET /r/{repo}/tree/{version}/{path...}", treeHandler)
	mux.HandleFunc("GET /r/{repo}/ci", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "ci.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var cl struct {
			Runs []ciRunRow `json:"runs"`
		}
		remarshal(out, &cl)
		render(r, w, req, ciPage, pageData{Title: "ci", Repo: repo, CIRuns: cl.Runs})
	})
	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}/chat", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "chat.history", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ch struct {
			Messages []ChatMessage `json:"messages"`
		}
		remarshal(out, &ch)
		data := pageData{Title: "chat", Repo: repo, ChatMsgs: ch.Messages}
		if live != nil {
			data.Live = true
			data.LiveAttr = template.HTMLAttr(`data-init="@get('/r/` + repo + `/chat.sse')"`)
		}
		render(r, w, req, chatPage, data)
	})
	mux.HandleFunc("POST /r/{repo}/chat", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "chat.post",
			url.Values{"repo": {repo}, "text": {req.FormValue("text")}}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/chat", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/chat.sse", func(w http.ResponseWriter, req *http.Request) {
		if live == nil {
			http.NotFound(w, req)
			return
		}
		liveChat(live, req.PathValue("repo"), w, req)
	})
	mux.HandleFunc("GET /r/{repo}/forum", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "forum.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fl struct {
			Threads []forumThreadRow `json:"threads"`
		}
		remarshal(out, &fl)
		render(r, w, req, forumListPage, pageData{Title: "forum", Repo: repo, Forum: fl.Threads})
	})
	mux.HandleFunc("GET /r/{repo}/forum-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		render(r, w, req, forumNewPage, pageData{Title: "new thread", Repo: repo})
	})
	mux.HandleFunc("POST /r/{repo}/forum-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "forum.post", url.Values{
			"repo": {repo}, "title": {req.FormValue("title")}, "body": {req.FormValue("body")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fp struct {
			Hash string `json:"hash"`
		}
		remarshal(out, &fp)
		http.Redirect(w, req, "/r/"+repo+"/forum/"+fp.Hash[:min(16, len(fp.Hash))], http.StatusSeeOther)
	})
	mux.HandleFunc("POST /r/{repo}/forum/{root}/reply", func(w http.ResponseWriter, req *http.Request) {
		repo, root := req.PathValue("repo"), req.PathValue("root")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "forum.reply", url.Values{
			"repo": {repo}, "root": {root}, "body": {req.FormValue("body")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/forum/"+root, http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/technote-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		render(r, w, req, technoteEditPage, pageData{Title: "new technote", Repo: repo})
	})
	mux.HandleFunc("POST /r/{repo}/technote-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "technote.add", url.Values{
			"repo": {repo}, "body": {req.FormValue("body")},
			"comment": {req.FormValue("comment")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ta struct {
			Event string `json:"event"`
		}
		remarshal(out, &ta)
		http.Redirect(w, req, "/r/"+repo+"/technote/"+ta.Event[:min(16, len(ta.Event))], http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/technote/{event}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, event := req.PathValue("repo"), req.PathValue("event")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		out, err := invoke(req.Context(), r, "technote.get",
			url.Values{"repo": {repo}, "event": {event}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tg struct {
			Event   string `json:"event"`
			Body    string `json:"body"`
			Comment string `json:"comment"`
		}
		remarshal(out, &tg)
		render(r, w, req, technoteEditPage, pageData{
			Title: "edit technote", Repo: repo,
			Technote:   technoteDetail{Event: tg.Event, Comment: tg.Comment},
			EditSource: tg.Body,
		})
	})
	mux.HandleFunc("POST /r/{repo}/technote/{event}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, event := req.PathValue("repo"), req.PathValue("event")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "technote.add", url.Values{
			"repo": {repo}, "body": {req.FormValue("body")}, "event": {event},
			"comment": {req.FormValue("comment")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/technote/"+event, http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/forum/{root}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "forum.get",
			url.Values{"repo": {repo}, "root": {req.PathValue("root")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fg struct {
			Title string         `json:"title"`
			Posts []forumPostRow `json:"posts"`
		}
		remarshal(out, &fg)
		title := fg.Title
		if title == "" {
			title = "thread"
		}
		render(r, w, req, forumThreadPage, pageData{
			Title: title, Repo: repo, ForumPosts: fg.Posts,
			ForumRoot: req.PathValue("root"),
		})
	})
	mux.HandleFunc("GET /r/{repo}/technote", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "technote.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Notes []technoteRow `json:"notes"`
		}
		remarshal(out, &tl)
		render(r, w, req, technoteListPage, pageData{Title: "technotes", Repo: repo, Technotes: tl.Notes})
	})
	mux.HandleFunc("GET /r/{repo}/technote/{event}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "technote.get",
			url.Values{"repo": {repo}, "event": {req.PathValue("event")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tg struct {
			Event string `json:"event"`
			Date  string `json:"date"`
			User  string `json:"user"`
			HTML  string `json:"html"`
		}
		remarshal(out, &tg)
		render(r, w, req, technotePage, pageData{
			Title: "technote " + tg.Date, Repo: repo,
			Technote: technoteDetail{Event: tg.Event, Date: tg.Date, User: tg.User},
			WikiHTML: template.HTML(tg.HTML),
		})
	})
	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"`
			Attachments     []attachmentRow `json:"attachments"`
			DescriptionHTML string          `json:"descriptionHtml"`
			Comments        []ticketComment `json:"comments"`
		}
		remarshal(out, &td)
		// A ticket is known by its title, not its hash; fall back to the
		// id only when the tracker recorded no title.
		title := td.Ticket.Title()
		if title == "" {
			title = "ticket " + td.Ticket.ID[:10]
		}
		render(r, w, req, ticketPage, pageData{
			Title: title, Repo: repo, Ticket: td.Ticket,
			Attachments:     td.Attachments,
			DescriptionHTML: template.HTML(td.DescriptionHTML),
			TicketComments:  td.Comments,
		})
	})
	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),
		})
	})
	// Attachment download. This is the only route that hands a reader
	// bytes somebody else uploaded, so it is deliberately hostile to its
	// own payload: always a download, never an inline render; the stored
	// content type is discarded in favour of octet-stream; nosniff stops
	// the browser guessing its way back to HTML; and the CSP denies
	// everything in case a viewer renders it anyway.
	mux.HandleFunc("GET /r/{repo}/attachment/{hash}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "attachment.get", url.Values{
			"repo": {repo}, "hash": {req.PathValue("hash")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ad struct {
			Name    string `json:"name"`
			Content string `json:"content"`
		}
		remarshal(out, &ad)
		raw, err := base64.StdEncoding.DecodeString(ad.Content)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		w.Header().Set("Content-Type", "application/octet-stream")
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
		w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(ad.Name))
		w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
		w.Write(raw)
	})
	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}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, name := req.PathValue("repo"), req.PathValue("name")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		source := ""
		if out, err := invoke(req.Context(), r, "wiki.get",
			url.Values{"repo": {repo}, "name": {name}}); err == nil {
			var wd struct {
				Source string `json:"source"`
			}
			remarshal(out, &wd)
			source = wd.Source
		}
		render(r, w, req, wikiEditPage, pageData{
			Title: "edit " + name, Repo: repo,
			Wiki: wikiDetail{Title: name}, EditSource: source,
		})
	})
	mux.HandleFunc("POST /r/{repo}/wiki/{name}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, name := req.PathValue("repo"), req.PathValue("name")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "wiki.save", url.Values{
			"repo": {repo}, "name": {name},
			"body": {req.FormValue("body")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/wiki/"+url.PathEscape(name), http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/wiki-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		name := req.URL.Query().Get("name")
		if name == "" {
			http.Redirect(w, req, "/r/"+repo+"/wiki", http.StatusSeeOther)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/wiki/"+url.PathEscape(name)+"/edit", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/ticket/new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		render(r, w, req, ticketNewPage, pageData{Title: "new ticket", Repo: repo})
	})
	mux.HandleFunc("POST /r/{repo}/ticket/new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "ticket.new", url.Values{
			"repo": {repo}, "title": {req.FormValue("title")},
			"status": {req.FormValue("status")}, "comment": {req.FormValue("comment")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tn struct {
			ID string `json:"id"`
		}
		remarshal(out, &tn)
		http.Redirect(w, req, "/r/"+repo+"/ticket/"+tn.ID, http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/ticket/{id}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, id := req.PathValue("repo"), req.PathValue("id")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		out, err := invoke(req.Context(), r, "ticket.get",
			url.Values{"repo": {repo}, "id": {id}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tg struct {
			Ticket ticketDetail `json:"ticket"`
		}
		remarshal(out, &tg)
		render(r, w, req, ticketEditPage, pageData{
			Title: "edit ticket", Repo: repo, Ticket: tg.Ticket,
		})
	})
	mux.HandleFunc("POST /r/{repo}/ticket/{id}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, id := req.PathValue("repo"), req.PathValue("id")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "ticket.set", url.Values{
			"repo": {repo}, "id": {id},
			"title": {req.FormValue("title")}, "status": {req.FormValue("status")},
			"resolution": {req.FormValue("resolution")}, "comment": {req.FormValue("comment")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/ticket/"+id, http.StatusSeeOther)
	})
	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,
		})
	})
	// Identity is resolved ONCE per request and carried in the context,
	// so every page header can show who is signed in without each route
	// knowing about the session plane.
	return withIdentity(sessions, inbox != nil, 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) {
	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("*")
	defer cancel()
	// A first index emits events continuously, so the event tick alone
	// would show progress — but the "indexing" flag clears AFTER the
	// last event, so the badge would stick until something else
	// happened. A slow poll alongside the events closes that gap, and
	// sending only on change keeps an idle fleet silent.
	poll := time.NewTicker(2 * time.Second)
	defer poll.Stop()
	heartbeat := time.NewTicker(20 * time.Second)
	defer heartbeat.Stop()

	last := ""
	repaint := func() bool {
		frag, err := fleetFragment(r)
		if err != nil {
			log.Printf("web: live fleet: %v", err)
			return true
		}
		if frag == last {
			return true // nothing changed; stay quiet
		}
		last = frag
		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()
		return true
	}
	repaint()
	for {
		select {
		case <-req.Context().Done():
			return
		case <-heartbeat.C:
			fmt.Fprint(w, ": ping\n\n")
			fl.Flush()
		case <-poll.C:
			repaint()
		case _, open := <-ticks:
			if !open {
				return
			}
			time.Sleep(200 * time.Millisecond) // debounce a burst
			select {
			case <-ticks:
			default:
			}
			repaint()
		}
	}
}

// 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.
// liveChat streams new chat messages as Datastar append patches into
// #chatlog — each message an appended paragraph, so the log grows
// without re-rendering history. Without JS the page still shows the
// on-load window and posts via the plain form (Principle 3).
func liveChat(live ChatLive, repo string, w http.ResponseWriter, req *http.Request) {
	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()

	msgs, cancel := live.SubscribeChat(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 m, open := <-msgs:
			if !open {
				return
			}
			el := `<p class="chatmsg"><b>` + template.HTMLEscapeString(m.User) +
				`</b> <small class="tl-meta-inline">` + template.HTMLEscapeString(m.At) +
				`</small><br>` + template.HTMLEscapeString(m.Text) + `</p>`
			fmt.Fprint(w, "event: datastar-patch-elements\n")
			fmt.Fprint(w, "data: mode append\n")
			fmt.Fprint(w, "data: selector #chatlog\n")
			fmt.Fprintf(w, "data: elements %s\n\n", el)
			fl.Flush()
		}
	}
}

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) {
	if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok {
		data.SignedIn, data.CanSignIn, data.HasInbox = id.user, id.canSignIn, id.hasInbox
	}
	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)
	}
}

type ctxIdentity struct{}

type identity struct {
	user      string
	canSignIn bool
	hasInbox  bool
}

func withIdentity(sessions Sessions, hasInbox bool, next http.Handler) http.Handler {
	if sessions == nil {
		return next
	}
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		id := identity{canSignIn: true, hasInbox: hasInbox}
		if user, ok := sessions.Resolve(cookieToken(req)); ok {
			id.user = user
		}
		next.ServeHTTP(w, req.WithContext(context.WithValue(req.Context(), ctxIdentity{}, id)))
	})
}

// safeNext keeps the post-login redirect on this site. An attacker who
// can choose ?next= can otherwise land a freshly-signed-in reader on
// their own page, which is how a login form becomes an open redirect.
// Only a rooted, single-slash path is allowed; anything else goes home.
func safeNext(next string) string {
	if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
		return "/"
	}
	if strings.Contains(next, "\n") || strings.Contains(next, "\r") {
		return "/"
	}
	return next
}

// cloneURL is the address a reader clones from: quarry's own sync
// fronting for this repository (0001 §7), on whatever host they reached
// us by — a hardcoded base would be wrong for every deployment but one.
func cloneURL(req *http.Request, repo string) string {
	scheme := "http"
	if req.TLS != nil {
		scheme = "https"
	}
	if p := req.Header.Get("X-Forwarded-Proto"); p != "" {
		scheme = p
	}
	return scheme + "://" + req.Host + "/r/" + repo
}