// 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"
"regexp"
"slices"
"strconv"
"strings"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/highlight"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/legal"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/timing"
)
// 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
// The mark, embedded like everything else quarry serves: the privacy
// page states that these pages load nothing from another host, and an
// icon fetched from a CDN would make that untrue.
//
// favicon.ico carries 16, 32 and 48 in one file so the browser's
// unprompted /favicon.ico request is answered properly rather than
// 404ing and falling back to a letter.
//
//go:embed assets/favicon.ico
var faviconICO []byte
//go:embed assets/apple-touch-icon.png
var appleTouchIcon []byte
//go:embed assets/quarry.png
var markPNG []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.
// artifactTimeLayouts are the shapes a D card's timestamp arrives in.
// Fossil writes UTC with millisecond precision and no zone; imported
// corpora also carry the second-precision form, and quarry's own OLTP
// stamps are RFC3339. All are UTC, so none carries an offset to honour.
var artifactTimeLayouts = []string{
"2006-01-02T15:04:05.000",
"2006-01-02T15:04:05",
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
func parseArtifactTime(s string) (time.Time, bool) {
for _, l := range artifactTimeLayouts {
if t, err := time.Parse(l, s); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
// ago renders a timestamp the way a person reads one. Recent times go
// relative ("3 hours ago"), older ones go to a plain date; the exact
// value is kept in the title attribute either way, because a relative
// time is friendlier but an absolute one is what you quote in a bug
// report.
//
// An unparseable stamp is returned VERBATIM rather than blanked or
// guessed at: it came out of a real artifact, and showing it is how the
// layout gap gets noticed.
func ago(s string) template.HTML {
if s == "" {
return ""
}
t, ok := parseArtifactTime(s)
if !ok {
return template.HTML(template.HTMLEscapeString(s))
}
return template.HTML(fmt.Sprintf(`<time datetime="%s" title="%s">%s</time>`,
template.HTMLEscapeString(t.Format(time.RFC3339)),
template.HTMLEscapeString(t.Format("2006-01-02 15:04:05 UTC")),
template.HTMLEscapeString(relative(t, time.Now().UTC()))))
}
// relative words a duration at one significant unit. Fossil's forum uses
// the same shape ("2.0 hours ago", "13.4 days ago"); this rounds to
// whole units instead, because a tenth of a day is not a quantity anyone
// reasons in.
func relative(t, now time.Time) string {
d := now.Sub(t)
if d < 0 {
// Clock skew between a writer and this server. "in 3 minutes" is
// noise; the timestamp itself is the honest answer.
return t.Format("2006-01-02 15:04")
}
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return plural(int(d.Minutes()), "minute") + " ago"
case d < 24*time.Hour:
return plural(int(d.Hours()), "hour") + " ago"
case d < 30*24*time.Hour:
return plural(int(d.Hours()/24), "day") + " ago"
default:
// Past a month, a date is more useful than a count of days, and
// past a year the year is the part that matters.
if t.Year() == now.Year() {
return t.Format("2 Jan")
}
return t.Format("2 Jan 2006")
}
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", n, unit)
}
// agoShort words a duration for a badge: "34m", "12h", "3d", "2y".
//
// The long form belongs in prose. In a column of numbers it is the
// thing that breaks alignment, which is the whole reason the stats are
// badges rather than a sentence.
func agoShort(s string) template.HTML {
t, ok := parseArtifactTime(s)
if !ok {
return ""
}
d := time.Since(t)
var out string
switch {
case d < time.Minute:
out = "now"
case d < time.Hour:
out = fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
out = fmt.Sprintf("%dh", int(d.Hours()))
case d < 365*24*time.Hour:
out = fmt.Sprintf("%dd", int(d.Hours()/24))
default:
out = fmt.Sprintf("%dy", int(d.Hours()/24/365))
}
return template.HTML(fmt.Sprintf(`<time datetime="%s" title="%s">%s</time>`,
template.HTMLEscapeString(t.Format(time.RFC3339)),
template.HTMLEscapeString(t.Format("2006-01-02 15:04:05 UTC")),
out))
}
// statusClass sorts a ticket status into resolved or not. Fossil's
// status vocabulary is per-repository — a ticket schema can define any
// words it likes — so this recognizes the conventional resolved words
// and treats everything else as open. Getting it wrong tints a badge;
// it never hides a ticket.
func statusClass(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "closed", "fixed", "verified", "resolved", "deferred", "rejected", "duplicate":
return "st-done"
}
return "st-open"
}
// tagStrip removes markup from a title used as a LABEL.
//
// Fossil technote and forum titles carry HTML — "<b>Design Note:</b>
// About the Irreversible Schema Change" is a real one. Escaping it is
// correct and safe, but it then reads as literal markup in a list. This
// strips the tags and the caller still escapes the result, so nothing
// is rendered as HTML: safe and readable rather than safe and ugly.
var tagRE = regexp.MustCompile(`<[^>]{0,200}>`)
func plainTitle(s string) string {
return strings.TrimSpace(tagRE.ReplaceAllString(s, ""))
}
// initials is an avatar without an avatar store. quarry keeps no images
// for people and fetching a gravatar would send every reader's identity
// to a third party on every page, so the account button shows one or two
// letters from the name it already has.
func initials(name string) string {
fields := strings.FieldsFunc(name, func(r rune) bool {
return r == ' ' || r == '.' || r == '-' || r == '_' || r == '@'
})
switch len(fields) {
case 0:
return "?"
case 1:
r := []rune(fields[0])
if len(r) == 1 {
return strings.ToUpper(string(r))
}
return strings.ToUpper(string(r[:2]))
default:
return strings.ToUpper(string([]rune(fields[0])[:1]) + string([]rune(fields[1])[:1]))
}
}
// tipcol is one icon column header. Deliberately NOT a popover: the
// Popover API is click-driven, so using it as a tooltip meant a click
// target that also carried a native title, giving two competing
// tooltips and a question-mark cursor. There is no standard hover
// tooltip primitive — popover="hint" is Chrome-only — so this does the
// thing that needs no primitive: the label is VISIBLE wherever the
// table has room for it, and the icon carries the native title where it
// does not. Nothing is hidden behind a hover a touch reader cannot do.
func tipcol(icon, label string) template.HTML {
return template.HTML(fmt.Sprintf(
`<th scope="col" class="n"><span class="colhead" title="%s">`+
`<svg class="ic" aria-hidden="true"><use href="#i-%s"/></svg>`+
`<span class="collabel">%s</span></span></th>`,
label, icon, template.HTMLEscapeString(label)))
}
// countTag renders a count compactly with its exact value in a title,
// so a column's width comes from its shape rather than its largest
// repository.
func countTag(n int) template.HTML {
if n == 0 {
return ""
}
return template.HTML(fmt.Sprintf(`<span title="%d">%s</span>`, n, compactCount(n)))
}
// compactCount bounds a count to four characters: 0, 19, 503, 1.1K,
// 26K, 1.2M.
//
// The nav's width was a function of its numbers, so a repository with
// 25,557 check-ins and 5,809 tickets rendered a visibly different nav
// from one with none — on a phone it wrapped to three lines. Exact
// figures belong on the page the tab leads to; the tab needs an order of
// magnitude.
func compactCount(n int) string {
switch {
case n < 0:
return "0"
case n < 1000:
return strconv.Itoa(n)
case n < 10000:
// One decimal only where it distinguishes: 1.1K reads, 26.4K is
// a character longer for precision nobody is using here.
v := float64(n) / 1000
out := strconv.FormatFloat(v, 'f', 1, 64)
return strings.TrimSuffix(out, ".0") + "K"
case n < 1000000:
return strconv.Itoa(n/1000) + "K"
case n < 10000000:
v := float64(n) / 1000000
out := strconv.FormatFloat(v, 'f', 1, 64)
return strings.TrimSuffix(out, ".0") + "M"
default:
return strconv.Itoa(n/1000000) + "M"
}
}
// navlink renders one repository-nav entry. It is a function rather
// than inline template markup because an entry carries two independent
// classes — "here" and "empty" — and expressing that inline produced
// two class attributes on the same anchor, of which a browser honours
// only the first.
//
// count is `any` so a section with nothing to count (files, ci, chat)
// can pass false and get no badge, rather than a misleading zero.
func navlink(repo, section, label, current string, hasCounts bool, count any) template.HTML {
var class []string
if section == current {
class = append(class, "here")
}
n, counted := count.(int)
if hasCounts && counted && n == 0 {
// Dimmed, never hidden: a nav whose SHAPE changes per repository
// makes a reader relearn it on every store. An honest dead end
// costs one click; a moving nav costs orientation.
class = append(class, "empty")
}
var b strings.Builder
fmt.Fprintf(&b, `<a href="/r/%s/%s"`, url.PathEscape(repo), section)
if len(class) > 0 {
fmt.Fprintf(&b, ` class="%s"`, strings.Join(class, " "))
}
if section == current {
b.WriteString(` aria-current="page"`)
}
fmt.Fprintf(&b, `>%s`, template.HTMLEscapeString(label))
if hasCounts && counted {
// title carries the exact figure; the badge carries the shape.
fmt.Fprintf(&b, ` <span class="n" title="%d">%s</span>`, n, compactCount(n))
}
b.WriteString(`</a>`)
// Every part is escaped or generated above; section and label are
// compile-time constants from the template.
return template.HTML(b.String())
}
// paletteCSS is every colour the site uses, in one place so the
// contrast tests can find them all. A colour defined anywhere else
// escapes those tests, which is how the highlight and the danger red
// came to be wrong in one of the three theme states.
const paletteCSS = `/* Gruvbox, after craftdesign.group: a warm cream rather than white and a
soft near-black rather than pure black, because a forge is read for
long stretches and maximum contrast is tiring rather than legible.
--accent carries links and focus. Every pair here is contrast-checked
against both the page ground and --code-bg, the secondary surface
links also sit on; see TestPaletteContrast, which fails the build
rather than letting a nicer-looking colour quietly drop below AA.
Three states, not two. An explicit choice stamps data-theme on the
root; the default "system" stamps nothing, so only the media query
separates light from dark. Each set is therefore declared three times:
once bare, once under the media query GUARDED against an explicit
light choice, and once under the explicit dark choice — otherwise
picking light on a dark-mode phone would do nothing. */
:root {
--fg: #282828; --bg: #f3ecda; --muted: #665c54; --code-bg: #e6dcc0;
--rule: #d5c4a1; --accent: #a04412;
--mark: #f7dd9a; --add: #116329; --del: #a40e26;
/* --surface is the neutral wash under cards and the top bar. It is a
warm grey at 10% alpha rather than a colour per theme: one value,
and it tints whatever ground it lands on instead of fighting it.
--code-bg stays opaque, because code needs a solid block. */
--surface: #a899841a;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--fg: #f0ece4; --bg: #1d2021; --muted: #bdb6ab; --code-bg: #32302f;
--rule: #504945; --accent: #d99058;
--mark: #665418; --add: #56d364; --del: #ff7b72;
--surface: #a899841a;
--mark: #665418; --add: #56d364; --del: #ff7b72;
--surface: #a899841a;
}
}
:root[data-theme="dark"] {
--fg: #f0ece4; --bg: #1d2021; --muted: #bdb6ab; --code-bg: #32302f;
--rule: #504945; --accent: #d99058;
}
/* The page paints its own ground: a transparent body borrows whatever
the browser decided, which is the white flash this palette exists to
avoid. */
html { background: var(--bg); }
/* The rubber-band area past the top of the page painted --bg while the
bar above it is --surface, so overscrolling showed a stripe of the
wrong colour behind the header. Refusing the scroll chain is the
direct fix and does not require painting html a colour that would
then be wrong at the other end. */
html, body { overscroll-behavior: none; }`
var base = template.Must(template.New("base").Funcs(template.FuncMap{
"pathesc": url.PathEscape,
"navlink": navlink,
"ago": ago,
"agoShort": agoShort,
"plain": plainTitle,
"statusClass": statusClass,
"initials": initials,
"tipcol": tipcol,
"count": countTag,
"scopes": func() any { return searchScopes },
}).Parse(baseHTML))
var baseHTML = `<!doctype html>
<html lang="en"{{if .Theme}} data-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{- /* "quarry — quarry" was what the aggregate page called itself. */ -}}
<title>{{.Title}}{{if ne .Title "quarry"}} — quarry{{end}}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
` + paletteCSS + `
body { color: var(--fg); background: var(--bg); font: 16px/1.5 system-ui, sans-serif;
margin: 0; padding: 0; }
/* The bars span the viewport; their contents, and the page below them,
share one column. --page is the single place that column's width is
decided. */
:root { --page: 60rem; }
main { max-width: var(--page); margin: 0 auto; padding-left: 1rem; padding-right: 1rem; }
/* Room for the fixed footer, so the last line of a page is readable. */
/* One source for the footer's height, so the space reserved for it and
the bar itself cannot disagree. The gap is deliberate: clearing the
footer by a hair still reads as content running into it. */
:root { --footer-h: 2.25rem; }
body { padding-bottom: calc(var(--footer-h) + 2rem); }
/* Centred rather than baseline-aligned, so the two halves sit on one
optical line inside a bar of fixed height. */
footer { font-size: 0.78rem; }
/* One line, never two: the page reserves a fixed height for this bar, so
a bar that wraps overlaps the content it was supposed to sit below.
The left half truncates instead — version and render time are
diagnostics, and they are what a narrow screen can best afford to
lose. The links never shrink. */
footer .bar { display: flex; flex-wrap: nowrap; gap: 1rem; align-items: center;
min-height: var(--footer-h); padding-top: 0.2rem; padding-bottom: 0.2rem; }
.foot-l { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.foot-r { flex: none; }
.foot-r { margin-left: auto; display: flex; gap: 0.9rem; white-space: nowrap; }
details > summary { cursor: pointer; padding: 0.4rem 0; }
pre.licence { max-height: 24rem; overflow: auto; font-size: 0.8rem; white-space: pre-wrap; }
/* The bars use the viewport, not the content column: forgejo puts the
forge name at the far left and the account at the far right, which
reads as chrome. Content stays in the column below them. */
.bar { padding-left: 1rem; padding-right: 1rem; }
.repobar .bar { max-width: var(--page); margin: 0 auto; }
main { padding-top: 1rem; }
/* Present to the document and to a screen reader, absent to the eye:
a page still needs one h1 even when repeating it on screen would say
nothing a reader cannot already see. */
/* Explore tabs: the same pill vocabulary as the repo nav, so the two
navigations do not look like different systems. */
nav.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0 0 1rem; }
nav.tabs a { display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.3rem 0.7rem; border-radius: 999px; text-decoration: none;
border: 1px solid transparent; color: var(--fg); }
nav.tabs a:hover { border-color: var(--rule); }
nav.tabs a.here { background: var(--surface); border-color: var(--rule); font-weight: 600; }
nav.tabs .n { color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; }
ul.people { list-style: none; margin: 0; padding: 0; }
ul.people li { display: flex; align-items: center; gap: 0.6rem;
padding: 0.5rem 0; border-bottom: 1px solid var(--code-bg); }
.avatar.sm { width: 1.75rem; height: 1.75rem; font-size: 0.65rem; margin: 0; flex: none; }
.p-main { min-width: 0; }
.p-meta { color: var(--muted); font-size: 0.85rem; }
nav.pager { display: flex; align-items: center; gap: 1rem; margin: 1rem 0; }
nav.pager span { margin: 0 auto; }
.person-head { display: flex; align-items: center; gap: 0.9rem; margin-bottom: 1rem; }
.person-head h1 { margin: 0; }
.avatar.lg { width: 3rem; height: 3rem; font-size: 1rem; margin: 0; flex: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.topbar { background: var(--surface); border-bottom: 1px solid var(--rule); }
/* Three columns rather than a flex row: the search belongs in the
middle of the bar, and centring it with auto margins fights the
avatar's own push to the right. The side columns take equal width so
the box is centred on the VIEWPORT, not on whatever is left after the
forge name — which would move it every time the name changed. */
.topbar .bar { display: grid; grid-template-columns: 1fr minmax(7rem, 34rem) 1fr;
gap: 0.75rem; align-items: center; padding-top: 0.5rem; padding-bottom: 0.5rem; }
.topbar .site { justify-self: start; }
.topbar .find { justify-self: stretch; }
.repobar { border-bottom: 1px solid var(--rule); }
.repobar .bar { display: flex; gap: 0.75rem; align-items: baseline; flex-wrap: wrap;
padding-top: 0.6rem; padding-bottom: 0.4rem; }
/* Fixed and full width, like the bar at the top: the page scrolls under
it. body carries matching bottom padding so the last line of content
is never hidden behind it. */
footer { position: fixed; bottom: 0; left: 0; right: 0; z-index: 5;
background: var(--surface); backdrop-filter: blur(6px);
color: var(--muted); border-top: 1px solid var(--rule); margin: 0;
/* No padding here: .bar owns it, and a top-only 0.5rem left
the text sitting 20px from the top and 11px from the bottom
of its own bar. */
padding: 0; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.35rem 0.75rem 0.35rem 0; vertical-align: top; }
/* An action cell is a row of buttons, not a stack of forms. */
td.acts { display: flex; gap: 0.4rem; align-items: center; padding-top: 0.25rem; }
td.acts form { display: contents; }
/* A labelled control is a column: label over input. Side-by-side put
the trailing unit on its own line and made the row unreadable. */
form.addform { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end;
margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
form.addform label { display: flex; flex-direction: column; gap: 0.2rem;
font-size: 0.85rem; color: var(--muted); min-width: 0; }
form.addform label.grow { flex: 1 1 16rem; }
form.addform input { color: var(--fg); }
form.rowform { margin: 0.75rem 0 0; }
th { border-bottom: 1px solid var(--rule); }
/* Links carry the accent rather than inheriting the body colour: with
inherit the only cue was the underline, and a page of prose and a page
of links looked identical at a glance. */
a { color: var(--accent); }
/* A focus ring says "here you are", not "look at this". The accent made
every tab stop shout in the brand colour; --muted is neutral and
still clears WCAG 1.4.11's 3:1 against both grounds (8.2:1 dark,
5.5:1 light) — where --rule, the obvious "lighter" choice, is 1.9:1
and would not legally be an indicator at all. */
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible { outline: 2px solid var(--muted); outline-offset: 1px; }
.topbar .site { font-weight: bold; text-decoration: none; }
/* The repository is the subject of every page under it, so it reads as
the heading and "quarry" recedes to the crumb it is. Without this the
two sit at equal weight and the forge name — identical on every page —
competes with the one word that changes. */
.repobar .repo { font-weight: bold; font-size: 1.15rem; text-decoration: none; }
/* Search sits with the chrome now rather than under it. It takes the
leftover width so it is the element that shrinks when the header is
tight, which is right: a repository's name must stay readable and a
search box is usable at any width. */
.topbar .find { min-width: 0; margin: 0; }
.topbar .find input { width: 100%; }
/* The avatar is initials, not an image: quarry has no avatar store and
inventing one to fetch a gravatar would send every reader's identity
to a third party. */
/* A column header names itself when the table is wide enough to say it,
and falls back to the icon alone when it is not. The label is real
text, so it is readable by touch, by a screen reader and by anyone
who does not know the glyph. */
.colhead { display: inline-flex; align-items: center; gap: 0.35rem; justify-content: flex-end; }
.collabel { display: none; font-weight: normal; color: var(--muted); font-size: 0.8rem; }
@media (min-width: 56rem) { .collabel { display: inline; } }
/* The legend is the inverse of the labels: it appears exactly where the
headers stop being able to name themselves. */
.collegend { display: flex; flex-wrap: wrap; gap: 0.3rem 1rem; margin: 0.5rem 0 0;
color: var(--muted); font-size: 0.8rem; }
.collegend span { display: inline-flex; align-items: center; gap: 0.3rem; }
@media (min-width: 56rem) { .collegend { display: none; } }
@media (max-width: 30rem) { .collegend .mirror-only { display: none; } }
.avatar { flex: none; width: 2rem; height: 2rem; padding: 0;
border-radius: 999px; background: var(--bg); border: 1px solid var(--rule);
font-size: 0.75rem; font-weight: 700; cursor: pointer; display: grid; place-items: center; }
.avatar:hover { border-color: var(--fg); }
.avatar { anchor-name: --avatar; justify-self: end; }
.menu {
/* One inset declaration, not a shorthand plus overrides: the
shorthand resets the sides the longhands set, which is how this
first landed pinned to the top of the viewport. */
position: fixed; inset: 3.25rem 1rem auto auto; margin: 0;
min-width: 11rem; padding: 0.4rem; border: 1px solid var(--rule); border-radius: 8px;
background: var(--bg); color: var(--fg);
/* display is deliberately NOT set here. The UA stylesheet closes a
popover with [popover]:not(:popover-open) { display: none }, and an
author rule beats it — declaring display:flex on the base left the
menu laid out and hit-testable at opacity 0, an invisible 185x111
region swallowing clicks on every page whether or not it had ever
been opened. It is set only in the open state below. */
flex-direction: column; gap: 0.1rem;
}
/* Anchored to the avatar where the browser can do it, so the menu keeps
its relationship to the button at any viewport; the fixed corner above
is the fallback and lands in the same place at these sizes. */
@supports (anchor-name: --a) {
.menu { position-anchor: --avatar; inset: auto; top: anchor(bottom); right: anchor(right);
margin-top: 0.4rem; }
}
/* The menu grows out of the button rather than appearing. allow-discrete
is what lets display participate, so the transition survives the
popover being display:none between states. */
.menu {
opacity: 0; transform: translateY(-0.25rem) scale(0.97); transform-origin: top right;
transition: opacity 120ms ease, transform 120ms ease, overlay 120ms allow-discrete,
display 120ms allow-discrete;
}
.menu:popover-open { display: flex; opacity: 1; transform: none; }
@starting-style {
.menu:popover-open { opacity: 0; transform: translateY(-0.25rem) scale(0.97); }
}
@media (prefers-reduced-motion: reduce) { .menu { transition: none; } }
.menu a, .menu button {
/* border-box, or "width: 100%" means 100% PLUS the padding and every
row hangs over the menu's right edge — which reads as a clipped,
scrolling box even when the list is one item long. */
display: flex; align-items: center; gap: 0.4rem; width: 100%; box-sizing: border-box;
padding: 0.35rem 0.5rem; border: 0; border-radius: 5px; background: none;
color: var(--fg); text-decoration: none; text-align: left; font-weight: normal;
}
.menu a:hover, .menu button:hover { background: var(--surface); }
.menu form { margin: 0; }
.menu-who { padding: 0.35rem 0.5rem 0.5rem; color: var(--muted); font-size: 0.85rem;
border-bottom: 1px solid var(--rule); margin-bottom: 0.2rem; }
/* Without the Popover API the menu renders as a plain inline row rather
than vanishing: losing the popover is a cosmetic loss, losing the way
to sign out is not. */
@supports not selector(:popover-open) {
.avatar { display: none; }
.menu { position: static; display: flex; flex-direction: row; align-items: center;
margin-left: auto; border: 0; padding: 0; min-width: 0; gap: 0.5rem; }
.menu a, .menu button { width: auto; padding: 0.15rem 0.4rem; }
.menu-who { border: 0; margin: 0; padding: 0; }
}
form.theme button { padding: 0.3rem 0.45rem; line-height: 0; background: none;
border-color: transparent; color: var(--muted); }
form.theme button:hover { border-color: var(--rule); color: var(--fg); }
form.theme .ic { opacity: 1; width: 1.05em; height: 1.05em; }
/* Scope pills reuse the nav's shape: the reader is choosing among the
same sections, so they should not look like a different control. */
nav.scopes { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.6rem 0 1rem; }
nav.scopes a { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 999px;
border: 1px solid var(--rule); text-decoration: none; font-size: 0.9rem; }
nav.scopes a.here { background: var(--surface); font-weight: 600; }
ul.hits { list-style: none; margin: 0; padding: 0; }
ul.hits li { padding: 0.5rem 0; border-bottom: 1px solid var(--code-bg); }
.snip { color: var(--muted); font-size: 0.9rem; overflow-wrap: anywhere; }
/* Fleet-wide results lead with the repository: the same wiki title
exists in several stores, so a hit without its repo is ambiguous. */
.hit-repo { color: var(--muted); font-size: 0.85rem; }
.hit-repo::after { content: " /"; }
nav.scopes.facets a .n { color: var(--muted); font-size: 0.8rem; }
form.find.wide { max-width: none; display: flex; gap: 0.4rem; margin-bottom: 0.4rem; }
form.find.wide input { flex: 1 1 auto; min-width: 0; }
/* Chrome links are navigation, not references: colouring the whole nav
with the accent would make the accent mean nothing. */
.topbar .site, .repobar .repo, nav a, .crumbs a, table.tree a { color: var(--fg); }
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(--surface); 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) {
/* main carries the page's horizontal padding now, not body. This was
"body { padding: 0.5rem }" — a shorthand, which silently reset the
bottom reserve the fixed footer depends on and let content run
underneath it on exactly the screens where that hurts most. */
main { padding-left: 0.5rem; padding-right: 0.5rem; }
.bar { padding-left: 0.5rem; padding-right: 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; }
}
/* File listing. The old one spent ~52px of height and three columns on
one short filename; fossil fits roughly 60 entries in the space that
showed 25. Density here comes from tight rows and dropping the
repeated per-row "history" link, not from shrinking the type. */
table.tree { table-layout: auto; }
table.tree td { padding: 0.15rem 0; border: 0; line-height: 1.45; }
table.tree tr:hover { background: var(--surface); }
/* Thirty underlined names in a column is a wall of rules, and the
underline carries no information here — every name in the listing is
a link, so marking them all marks nothing. Hover restores it. */
table.tree a, .tree-bar .crumbs a { text-decoration: none; }
table.tree a:hover, .tree-bar .crumbs a:hover { text-decoration: underline; }
/* The icon and the name are one label, so they get a gap rather than
sitting flush: without it the glyph reads as the first character of
the filename. */
td.t-name { white-space: nowrap; }
td.t-name .ic { margin-right: 0.45rem; }
/* Sizes are only comparable when their digits line up, which means
right-aligned and tabular figures. The column takes only what it
needs so the name column is not pushed across the viewport. */
td.t-size { text-align: right; white-space: nowrap; width: 1%;
padding-left: 1.5rem; color: var(--muted); font-variant-numeric: tabular-nums; }
/* Icons are an SVG sprite (see iconSprite), referenced by <use>. They
were CSS masks with inline data: URIs, which Chrome accepts and iOS
Safari does not: the raw <, > and quotes make the URI invalid, the
mask silently fails, and every icon renders as a solid block. A
sprite has no encoding to get wrong. */
.sprite { display: none; }
.ic { width: 1em; height: 1em; vertical-align: -0.125em; fill: currentColor;
opacity: 0.55; flex: none; }
/* Flags replace ls(1)'s trailing punctuation. "compile*" reads as part
of the filename; a labelled chip does not. */
.flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--rule);
border-radius: 3px; padding: 0 0.25rem; vertical-align: 0.05em; }
/* A strip of repository facts, the way forgejo leads a repo page. Each
is a link to the surface that explains it. */
.repo-stats { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0 0 0.75rem;
padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--rule);
border-radius: 8px; font-size: 0.9rem; }
.repo-stats a { display: inline-flex; align-items: center; gap: 0.4rem;
color: var(--fg); text-decoration: none; }
.repo-stats a:hover { text-decoration: underline; }
.refbtn, .dlbtn { display: inline-flex; align-items: center; gap: 0.4rem; cursor: pointer; }
.refname { max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.caret { color: var(--muted); font-size: 0.7em; }
/* Hug the content; only long ref lists scroll, and their scrolling does
not chain to the page behind them. */
.menu.refs, .menu.dlmenu { position: fixed; inset: auto; max-height: 60vh; overflow-y: auto;
overscroll-behavior: contain; scrollbar-width: thin; min-width: 13rem; }
.menu.refs a.here, .menu.dlmenu a.here { background: var(--surface); font-weight: 600; }
@supports (anchor-name: --a) {
.refbtn { anchor-name: --refbtn; }
.menu.refs { position-anchor: --refbtn; top: anchor(bottom); left: anchor(left); margin-top: 0.3rem; }
.dlbtn { anchor-name: --dlbtn; }
.menu.dlmenu { position-anchor: --dlbtn; top: anchor(bottom); right: anchor(right); margin-top: 0.3rem; }
}
/* Two links styled as one segmented control: the choice is a URL, so it
survives a reload and can be linked to. */
.clonepick { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px;
overflow: hidden; font-size: 0.8rem; }
.clonepick a { padding: 0.2rem 0.55rem; text-decoration: none; color: var(--muted); }
.clonepick a.here { background: var(--surface); color: var(--fg); font-weight: 600; }
.clonebox { display: inline-flex; align-items: stretch; min-width: 0; }
.cloneurl { font-family: ui-monospace, monospace; font-size: 0.8rem; min-width: 0; width: 22rem;
border-top-right-radius: 0; border-bottom-right-radius: 0; }
.copybtn { border-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0;
padding: 0 0.5rem; cursor: pointer; }
/* Tree chrome: one row carrying branch, crumbs and clone. */
.tree-bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;
margin-bottom: 0.6rem; }
.tree-bar .crumbs { font-weight: 600; }
.tree-bar .tree-acts { margin-left: auto; display: flex; gap: 0.75rem;
align-items: center; font-size: 0.85rem; }
.tree-bar .dl { white-space: nowrap; }
/* A select is as wide as its widest option, so one long branch name set
the control's width for every repository — 288px, most of a phone
row, to show "trunk". The cap truncates the outlier instead; the full
name is still in the open list, which is where it is read. */
.tree-bar select { max-width: 11rem; }
@media (max-width: 40rem) {
/* The clone command is the first thing worth losing on a phone: it is
long, and it is not what a reader came to the page for. */
.tree-bar .clonebox { display: none; }
/* Without this the actions trail the crumbs mid-line and the row
collapses into a ragged mixture of control, path and links. Giving
them the full basis makes them break to their own line, so the row
is always "branch and where you are" over "what you can do". */
.tree-bar .tree-acts { margin-left: 0; flex-basis: 100%; }
.tree-bar { gap: 0.5rem; }
}
/* Form controls inherit the browser's palette, not the page's: a white
textarea on a dark page, and iOS Safari's blue pill for every button.
They also default to content-box, so the width:100% on our textareas
overflowed by exactly the padding and border. Both are the browser
being reasonable in isolation and wrong here, so they are set once for
every form rather than per-page. */
input, textarea, select, button {
font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--rule); border-radius: 6px;
padding: 0.35rem 0.55rem; box-sizing: border-box; max-width: 100%;
}
textarea { display: block; width: 100%; resize: vertical; line-height: 1.5; }
button, input[type=submit] {
-webkit-appearance: none; appearance: none;
background: var(--code-bg); cursor: pointer; font-weight: 600;
}
button:hover, input[type=submit]:hover { border-color: var(--fg); }
::placeholder { color: var(--muted); opacity: 1; }
/* select keeps its native disclosure arrow — replacing it costs a
hand-drawn caret that has to be redrawn for both themes. */
.readme-head { margin-top: 1.5rem; padding-bottom: 0.3rem;
border-bottom: 1px solid var(--rule); font-size: 1rem; }
/* Each entry is one unit — a pill with its count inside — rather than a
word and a loose number. Loose numbers made every repository's nav a
different shape and, with a wide count, a different number of lines.
"here" is a filled pill rather than a colour so it survives both
themes and does not depend on hue perception; "empty" is dimmed but
still a link. */
.repobar nav { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-left: auto; }
nav a { display: inline-flex; align-items: baseline; gap: 0.3rem;
padding: 0.15rem 0.55rem; border-radius: 999px; text-decoration: none;
border: 1px solid transparent; }
nav a:hover { border-color: var(--rule); }
nav a.here { font-weight: 600; background: var(--surface); border-color: var(--rule); }
nav a.empty { color: var(--muted); }
nav .n { color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums;
background: var(--code-bg); border-radius: 999px; padding: 0 0.35rem; }
nav a.here .n { background: var(--bg); }
.repo-sub { color: var(--muted); font-size: 0.9rem; margin: -0.6rem 0 1rem; }
.newrepo { margin: 0 0 0.8rem; }
.about { margin: -0.3rem 0 0.8rem; color: var(--fg); }
/* Red and green are the same two tokens the diff uses. They were
literals with the dark pair behind an unguarded media query, so an
explicit light choice on a dark-mode device drew the dark red on the
light ground — the same defect the highlight had. */
.danger-head { color: var(--del); }
.danger { border: 1px solid var(--del); border-radius: 4px; padding: 0.2rem 0.9rem; }
.danger form + form { border-top: 1px solid var(--rule); margin-top: 0.6rem; }
/* The listing's numeric and date columns take only what they need, so
the description and tip get the width instead of "Check-ins" wrapping
into two lines beside a mostly-empty row. */
table.fleet th, table.fleet td { vertical-align: top; }
/* The name column takes the slack; the numeric ones take only what
they need and never wrap. Right-aligned tabular figures line up down
the page by construction — which a per-row grid could not do. */
table.fleet td.r-name { width: 100%; }
table.fleet th.n, table.fleet td.n { text-align: right; white-space: nowrap;
width: 1%; padding-left: 0.9rem; font-variant-numeric: tabular-nums;
color: var(--muted); }
table.fleet td.n.last { color: var(--fg); }
table.fleet th.n { padding-bottom: 0.2rem; }
/* The pair reads as one entry: no rule between a name and its
description, the rule goes under the description instead. */
table.fleet tr.r-top td { padding-top: 0.5rem; border-top: 1px solid var(--code-bg); }
table.fleet tr.r-desc td { padding: 0 0 0.15rem; color: var(--muted);
font-size: 0.9rem; border: 0; }
table.fleet tr.r-desc + tr.r-top td { border-top: 1px solid var(--code-bg); }
@media (max-width: 30rem) {
/* A phone has room for the name and three numbers, not four. Mirror
freshness is the most specialised of them and the first to go — the
repository page still reports it. Tighter gutters, and a long name
may wrap rather than push the table into a sideways scroll. */
table.fleet th.n, table.fleet td.n { padding-left: 0.5rem; }
table.fleet td.r-name { white-space: normal; overflow-wrap: anywhere; }
table.fleet th.n:nth-child(4), table.fleet td.n:nth-child(4) { display: none; }
}
.ci-pass { border-color: var(--add); color: var(--add); }
.ci-fail { border-color: var(--del); color: var(--del); }
/* Ticket fields are label/value pairs, so a dl says what they are and
escapes the global table rules that banded every label with a border.
The label column sizes to its own widest label instead of a fixed
10rem: fossil ticket schemas are arbitrary, so the widest label is
whatever this repository's tracker defines. Values may be 40-char
hashes, which must wrap rather than push the page sideways. */
dl.tkt-head {
display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
gap: 0.3rem 1rem; margin: 0.8rem 0;
}
dl.tkt-head dt { color: var(--muted); font-size: 0.9rem; }
dl.tkt-head dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.tkt-id { overflow-wrap: anywhere; }
.tkt-body { margin: 0.5rem 0 1rem; }
.tkt-comment { border-top: 1px solid var(--rule); padding: 0.6rem 0; }
.tkt-comment:first-of-type { border-top: 0; }
.tl-badge { border: 1px solid var(--rule); border-radius: 3px; padding: 0 0.25rem; font-size: 0.72rem; }
.tl-pill { display: inline-block; border: 1px solid var(--rule); 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: var(--add); } pre.diff .del { color: var(--del); }
pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; }
/* Content can contain tokens no viewport fits — a hash, a URL, a wiki
page literally named "checkin/2cbbabdf5ef624d809fbb40d2d312a29".
Unbroken they push the whole page sideways, so a phone scrolls to
read anything. break-word rather than anywhere: it breaks only when a
word genuinely does not fit, leaving ordinary prose alone. <pre>
keeps its own horizontal scroll, which is correct for code. */
main { overflow-wrap: break-word; }
/* A date is one token to a reader: breaking "2020-12-19T13:20:19" across
two lines makes it unreadable and is what content-driven wrapping does
to it by default. */
time { white-space: nowrap; }
/* A ticket list is read title-first. A table gave the 10-character id
its own column and left the title the narrowest one on a phone; this
gives the title the full width and demotes id/status/date to a meta
line under it. Same shape at every width — no breakpoint to diverge. */
ul.tickets { list-style: none; margin: 0; padding: 0; }
ul.tickets li { padding: 0.6rem 0; border-bottom: 1px solid var(--code-bg); }
ul.tickets li:first-child { border-top: 1px solid var(--code-bg); }
.t-title { font-weight: 600; }
.t-meta { margin-top: 0.25rem; font-size: 0.85rem; color: var(--muted);
display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
/* Open vs resolved by weight, not hue: the palette is four variables and
a fifth for one badge would owe both themes a contrast check. */
.t-status { font-size: 0.7rem; padding: 0.05rem 0.5rem; border-radius: 999px;
border: 1px solid var(--rule); }
.st-open { color: var(--fg); border-color: var(--fg); font-weight: 600; }
.st-done { color: var(--muted); }
main pre { overflow-wrap: normal; }
/* Templates wrap their tables in .tw, which scrolls. Tables that arrive
from RENDERED CONTENT — the API doc, a wiki page, a ticket body —
have no wrapper to put them in, and a wide one pushed the whole page
sideways (the /docs rules column overflowed a phone by 208px). Making
such a table its own scroll container needs no wrapper and covers
every source of them, including markup quarry did not generate. */
main :not(.tw) > table { display: block; overflow-x: auto; max-width: 100%; }
.tw > table { display: table; }
/* The highlight is a palette token like everything else. It was a pair
of literals with the dark one behind an UNGUARDED media query, so a
reader who chose light on a dark-mode device got the dark highlight
under light text: a dark block with dark text on it. Tokens follow
the three-state rule for free, which is the point of having them. */
mark { background: var(--mark); color: var(--fg); }
</style>
<style>` + highlight.CSS() + `</style>
<script type="module" src="/assets/datastar.js" defer></script>
</head>
<body>
<svg class="sprite" aria-hidden="true"><symbol id="i-commit" viewBox="0 0 16 16"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></symbol><symbol id="i-people" viewBox="0 0 16 16"><path d="M2 5.5a3.5 3.5 0 1 1 5.898 2.549 5.508 5.508 0 0 1 3.034 4.084.75.75 0 1 1-1.482.235 4 4 0 0 0-7.9 0 .75.75 0 0 1-1.482-.236A5.507 5.507 0 0 1 3.102 8.05 3.493 3.493 0 0 1 2 5.5ZM11 4a3.001 3.001 0 0 1 2.22 5.018 5.01 5.01 0 0 1 2.56 3.012.749.749 0 0 1-.885.954.752.752 0 0 1-.549-.514 3.507 3.507 0 0 0-2.522-2.372.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5.75.75 0 0 1 11 4Zm-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5Z"/></symbol><symbol id="i-sync" viewBox="0 0 16 16"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></symbol><symbol id="i-clock" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></symbol><symbol id="i-dir" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></symbol><symbol id="i-file" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></symbol><symbol id="i-branch" viewBox="0 0 16 16"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.878A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></symbol><symbol id="i-tag" viewBox="0 0 16 16"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/></symbol><symbol id="i-copy" viewBox="0 0 16 16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></symbol><symbol id="i-person" viewBox="0 0 16 16"><path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></symbol><symbol id="i-sun" viewBox="0 0 16 16"><path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.06a.75.75 0 0 1 1.06 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8ZM8 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1.06-1.06a.75.75 0 0 1 0-1.06Zm9.193 9.193a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061Z"/></symbol><symbol id="i-moon" viewBox="0 0 16 16"><path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/></symbol><symbol id="i-auto" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/><path d="M8 2.5v11a5.5 5.5 0 0 0 0-11Z"/></symbol></svg>
{{- /* Two bars, as forgejo does it: a full-width strip carrying the
things that are the same everywhere — forge name, search, account
— and, under it, the repository's own name and nav at content
width. Before this the forge name, the repo name and the account
links shared one line, so the row's shape changed with the length
of whatever repository you were looking at. */ -}}
<header class="topbar"><div class="bar">
<a class="site" href="/">quarry</a>
{{if .Repo}}
<form class="find" action="/r/{{.Repo}}/search" method="get">
{{- /* The box searches what the reader is looking at. On the tickets
page "search tickets" is almost always what is meant. The scope is
a hidden field rather than a mode, so the URL says what was
searched and the results page can widen it. */ -}}
{{if .SearchKind}}<input type="hidden" name="kind" value="{{.SearchKind}}">{{end}}
<input type="search" name="q" value="{{.Query}}" placeholder="search {{.SearchScope}}" aria-label="search {{.SearchScope}}"></form>
{{else}}
<form class="find" action="/search" method="get">
<input type="search" name="q" value="{{.Query}}" placeholder="search all repositories" aria-label="search all repositories"></form>
{{end}}
{{- /* The account menu is a popover, which needs no script: the button
opens it, Escape and an outside click close it, and the browser
handles focus. Everything that was loose in the header lives in
it, so the bar keeps one shape whoever is signed in. */ -}}
<button class="avatar" popovertarget="acct" aria-label="account menu">{{if .SignedIn}}{{initials .SignedIn}}{{else}}<svg class="ic" aria-hidden="true"><use href="#i-person"/></svg>{{end}}</button>
<div id="acct" popover class="menu">
{{if .SignedIn}}<div class="menu-who">{{.SignedIn}}</div>{{end}}
{{if .IsAdmin}}<a href="/fleet">fleet</a>{{end}}
{{if .HasInbox}}<a href="/inbox">inbox</a>{{end}}
<form class="theme" action="/theme" method="post">
<input type="hidden" name="to" value="{{.NextTheme}}">
<button type="submit" aria-label="colour theme: {{.ThemeName}}, switch to {{.NextTheme}}">
<svg class="ic" aria-hidden="true"><use href="#i-{{.ThemeIcon}}"/></svg> theme: {{.ThemeName}}</button></form>
{{if .SignedIn}}<form action="/logout" method="post"><button type="submit">sign out</button></form>
{{else if .CanSignIn}}<a href="/login">sign in</a>{{end}}
</div>
</div></header>
{{if .Repo}}<div class="repobar"><div class="bar">
<a class="repo" href="/r/{{.Repo}}">{{.Repo}}</a>
<nav>
{{- /* Counts tell a reader where a repository's content actually IS.
Across the corpus that varies wildly — pikchr is 1,260 forum posts
and one ticket, tk is 5,809 tickets and one post — and without
them every store looks alike. An empty section stays LINKED but is
dimmed: hiding it would make the nav's shape change per repo,
which is worse than an honest dead end. */ -}}
{{$s := .Section}}{{$c := .HasCounts}}
{{navlink .Repo "tree" "files" $s false false}}
{{navlink .Repo "timeline" "timeline" $s $c .Counts.Checkins}}
{{navlink .Repo "wiki" "wiki" $s $c .Counts.WikiPages}}
{{navlink .Repo "ticket" "tickets" $s $c .Counts.Tickets}}
{{navlink .Repo "forum" "forum" $s $c .Counts.ForumThreads}}
{{navlink .Repo "technote" "technotes" $s $c .Counts.Technotes}}
{{navlink .Repo "ci" "ci" $s false false}}
{{navlink .Repo "chat" "chat" $s false false}}
</nav>
</div></div>{{end}}
<main>
{{if .HideH1}}<h1 class="sr-only">{{.Title}}</h1>{{else}}<h1>{{.Title}}</h1>{{end}}
{{block "content" .}}{{end}}
</main>
<footer><div class="bar">
<span class="foot-l">powered by <a href="https://github.com/danmestas/zeit">zeit</a> · quarry {{.Version}}{{if .Elapsed}} · <span data-volatile>rendered in {{.Elapsed}}{{if .Breakdown}} ({{.Breakdown}}){{end}}</span>{{end}}</span>
<span class="foot-r"><a href="/licenses">licenses</a> <a href="/privacy">privacy</a> <a href="/docs">API</a></span>
</div></footer>
</body>
</html>
{{define "explore-tabs"}}
{{- /* Two tabs, not four. Repositories and people are surfaces quarry
actually has; organisations are v2 (0001 §8 puts org/ACLs there)
and code search indexes nothing today, and a tab that opens onto
an empty page teaches a reader the forge is broken rather than
that the feature is unbuilt. */ -}}
<nav class="tabs">
<a href="/"{{if not .Explore}} class="here"{{end}}><svg class="ic" aria-hidden="true"><use href="#i-dir"/></svg> repositories {{count .FleetTotal}}</a>
<a href="/people"{{if eq .Explore "people"}} class="here"{{end}}><svg class="ic" aria-hidden="true"><use href="#i-people"/></svg> people {{count .PeopleTotal}}</a>
</nav>
{{end}}
`
var fleetPage = template.Must(template.Must(base.Clone()).Parse(fleetSource))
const fleetSource = `{{define "fleet-body"}}
<div class="tl-controls">
<form method="get" action="/"><input type="search" name="q" value="{{.Query}}" placeholder="filter" aria-label="filter repositories">
<select name="sort" onchange="this.form.submit()" aria-label="sort">
<option value="name"{{if eq .Sort "name"}} selected{{end}}>name</option>
<option value="active"{{if eq .Sort "active"}} selected{{end}}>recently active</option>
<option value="size"{{if eq .Sort "size"}} selected{{end}}>size</option>
</select> <noscript><button type="submit">go</button></noscript></form>
<span class="tl-meta-inline">{{if .FleetTotal}}{{len .Fleet}} of {{.FleetTotal}}{{end}}</span>
{{if .IsAdmin}}<span class="tree-acts"><a href="/new">New repository</a></span>{{end}}
</div>
{{if .Fleet}}{{- /* Below the width where a header can carry its own words, the icons
are unexplained — and a title attribute is unreachable on touch,
which is most of the readers at these widths. A legend says what
they mean in text, and drops the mirror entry at exactly the
width the mirror column itself drops.
It sits ABOVE the table because a key read after the data is not
a key: on a phone it landed under twenty-three repositories, so
the reader met the icons at the top and the explanation only
after scrolling past everything the icons described. */ -}}
<p class="collegend">
<span><svg class="ic" aria-hidden="true"><use href="#i-commit"/></svg> check-ins</span>
<span><svg class="ic" aria-hidden="true"><use href="#i-people"/></svg> contributors</span>
<span class="mirror-only"><svg class="ic" aria-hidden="true"><use href="#i-sync"/></svg> last mirrored</span>
<span><svg class="ic" aria-hidden="true"><use href="#i-clock"/></svg> last activity</span>
</p>
<div class="tw"><table class="fleet">
{{- /* Real columns, not a grid inside one cell. CSS grid aligns within
ONE grid; a per-row grid gives every row its own column widths, so
nothing lines up down the page — which was the whole point. */ -}}
{{- /* An icon with only a title tells a touch reader nothing: there is
no hover, so the label is unreachable. Each header keeps its
title for a pointer AND opens a popover on tap or keyboard, so
the column is nameable by every input. */ -}}
<thead><tr><th scope="col">Repository</th>
{{tipcol "commit" "check-ins"}}{{tipcol "people" "contributors"}}
{{tipcol "sync" "last mirrored"}}{{tipcol "clock" "last activity"}}</tr></thead>
<tbody>{{range .Fleet}}
{{- /* Two rows per repository: the name and its numbers, then the
description spanning the whole width beneath. Keeping the
description in the first column made that column as wide as the
longest sentence, which pushed the numeric columns off a phone
screen entirely. Spanning lets it wrap against the full page and
leaves the name column only as wide as a name. */ -}}
<tr class="r-top"><td class="r-name"><a href="/r/{{.Name}}/timeline">{{.Name}}</a>{{if .Private}} <span class="tl-badge">private</span>{{end}}</td>
{{- /* Compact, as the nav already does: tcl's 25,557 set this column's
width for every row, and nobody reads a check-in count to five
significant figures. The exact figure stays in the title. */ -}}
<td class="n">{{if .Indexing}}<small class="tl-meta-inline">…</small>{{else}}{{count .Checkins}}{{end}}</td>
<td class="n">{{if .Contributors}}{{count .Contributors}}{{end}}</td>
<td class="n">{{if .MirroredAt}}{{agoShort .MirroredAt}}{{end}}</td>
<td class="n last">{{if .LastActive}}{{agoShort .LastActive}}{{end}}</td></tr>
{{if .Description}}<tr class="r-desc"><td colspan="5">{{.Description}}</td></tr>{{end}}
{{end}}</tbody>
</table>
</div>{{else}}<p>{{if .Query}}No repository matches “{{.Query}}”.{{else}}No repositories are being served yet.{{if .IsAdmin}} <a href="/new">Create one</a>.{{end}}{{end}}</p>{{end}}
{{end}}
{{define "content"}}{{template "explore-tabs" .}}<div{{if .Live}} {{.LiveAttr}}{{end}}><div id="fleet">{{template "fleet-body" .}}</div></div>{{end}}`
var licensesPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h2>quarry</h2>
<p>quarry — the forge software serving this site — is BSD 2-Clause.</p>
<pre class="licence">{{.QuarryLicence}}</pre>
<h2>The repositories served here</h2>
<p>Each repository quarry serves carries its own licence, chosen by the
people who wrote it. quarry makes no claim over any of it: a store is
hosted content, not part of this software. Look in the repository itself
— conventionally a <code>LICENSE</code>, <code>LICENCE</code> or
<code>COPYING</code> file at the root of its tree — for the terms that
actually apply to that project.</p>
<h2>Third-party components</h2>
<p>quarry links the modules below. They are listed because their licences
require reproducing them, not because quarry claims anything about them.
The list is generated from the build graph, so it cannot quietly fall
behind what this binary contains.</p>
<details><summary>{{len .Notices}} modules</summary>
{{range .Notices}}
<h3 id="{{.Module}}">{{.Module}}</h3>
<p class="tl-meta-inline">{{.Version}} · {{.License}}</p>
<pre class="licence">{{.Text}}</pre>
{{end}}
</details>
{{end}}`))
var privacyPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p>This page describes what this quarry instance stores. It describes the
software's behaviour, not a legal commitment by whoever runs it.</p>
<h2>What is public by construction</h2>
<p>A repository quarry serves is a set of artifacts, and every artifact
records who wrote it and when. Names, e-mail addresses and dates inside
check-ins, wiki edits, tickets, forum posts and technotes are part of the
repository's content: quarry displays them, indexes them for search, and
serves them over the sync protocol. It cannot remove them without
rewriting history, which it will not do. If a repository is public, so is
everything in it.</p>
<h2>What quarry itself keeps</h2>
<ul>
<li><strong>Accounts</strong> — a login name, a password hash (argon2id;
the password itself is never stored), whether the account is a site
admin, and its creation date. Per-repository grants are stored alongside.</li>
<li><strong>Sessions</strong> — a random token in a cookie, with an
expiry. Signing out deletes it.</li>
<li><strong>Sync credentials</strong> — a shared secret per repository
user, as the sync protocol defines it. Never a plaintext password.</li>
<li><strong>Your colour theme</strong> — a cookie holding
"light" or "dark", set only if you choose one. Choosing "system" removes
it. It is not used to identify you.</li>
</ul>
<h2>What quarry does not do</h2>
<ul>
<li>No third-party analytics, advertising, or trackers. The pages load
nothing from another host — there is no external stylesheet, font, image
or script anywhere in this interface.</li>
<li>No avatar fetching. The circle in the corner is your initials drawn
locally, not an image requested from a third party using your address.</li>
<li>No access log of readers is kept by quarry itself. The server it runs
behind may keep its own; that is outside this software.</li>
</ul>
<h2>Deletion</h2>
<p>Deleting an account removes it, its sessions and its grants. It does
not remove artifacts that account authored, because those are repository
content and belong to the history rather than to the forge.</p>
{{end}}`))
var peoplePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{template "explore-tabs" .}}
<div class="tl-controls">
<form method="get" action="/people"><input type="search" name="q" value="{{.Query}}" placeholder="filter" aria-label="filter people">
<noscript><button type="submit">go</button></noscript></form>
</div>
{{if .People}}<ul class="people">{{range .People}}
<li><span class="avatar sm" aria-hidden="true">{{initials .User}}</span>
<div class="p-main"><a href="/people/{{pathesc .User}}">{{.User}}</a>
<div class="p-meta">{{count .Events}} contribution{{if ne .Events 1}}s{{end}} in {{len .Repos}} repositor{{if eq (len .Repos) 1}}y{{else}}ies{{end}} · last {{ago .Last}}</div></div></li>
{{end}}</ul>
<nav class="pager">
{{if .PrevPage}}<a href="/people?page={{.PrevPage}}{{if .Query}}&q={{.Query}}{{end}}">← newer</a>{{end}}
<span class="tl-meta-inline">{{len .People}} of {{count .PeopleTotal}}</span>
{{if .NextPage}}<a href="/people?page={{.NextPage}}{{if .Query}}&q={{.Query}}{{end}}">older →</a>{{end}}
</nav>
{{else}}<p>{{if .Query}}Nobody matches “{{.Query}}”.{{else}}No contributions have been indexed yet.{{end}}</p>{{end}}
{{end}}`))
var personPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<div class="person-head">
<span class="avatar lg" aria-hidden="true">{{initials .Title}}</span>
<div><h1>{{.Title}}</h1>
<p class="p-meta">{{count .Person.Events}} contribution{{if ne .Person.Events 1}}s{{end}} ·
first {{ago .Person.First}} · last {{ago .Person.Last}}</p></div>
</div>
<h2>Repositories</h2>
<ul class="hits">{{range .Person.Repos}}
<li><a href="/r/{{.}}/timeline">{{.}}</a></li>
{{end}}</ul>
{{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>{{ago .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 {{ago .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(--rule); padding-top:0.5rem; margin-top:0.5rem">
<p><small>{{ago .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" 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></label></p>
<p><label>Post<br><textarea name="body" rows="14" 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" 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" 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}}{{plain .Comment}}{{else}}{{ago .Date}}{{end}}</a> <small>{{ago .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 {{ago .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">{{ago .At}}</small><br>{{.Text}}</p>
{{end}}</div>{{end}}
{{define "content"}}
<style>.chatlog{max-height:60vh;overflow-y:auto;border:1px solid var(--rule);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" 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></label></p>
<p><label>Status<br><input name="status" value="Open"></label></p>
<p><label>Comment<br><textarea name="comment" rows="8"></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"}}"></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" 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>{{if .CIBadge}}{{.CIBadge}} {{end}}{{ago .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"}}
{{- /* Open by default, closed on request. Half of every tracker in this
corpus is finished work, so showing everything makes the live
tickets the minority of the page. */ -}}
<nav class="scopes">
<a href="/r/{{.Repo}}/ticket"{{if not .ShowClosed}} class="here"{{end}}>open</a>
<a href="/r/{{.Repo}}/ticket?closed=1"{{if .ShowClosed}} class="here"{{end}}>all</a>
</nav>
{{if .Tickets}}<ul class="tickets">{{range .Tickets}}
<li><a class="t-title" href="/r/{{$.Repo}}/ticket/{{.ID}}">{{.Title}}</a>
<div class="t-meta"><span class="t-status {{statusClass .Status}}">{{.Status}}</span>
<code>{{printf "%.10s" .ID}}</code> · updated {{ago .Updated}}</div></li>
{{end}}</ul>{{else}}<p>{{if .ShowClosed}}No tickets.{{else}}No open tickets. <a href="/r/{{.Repo}}/ticket?closed=1">See all</a>.{{end}}</p>{{end}}
{{if .Next}}<p><a href="/r/{{.Repo}}/ticket?before={{.Next}}{{if .ShowClosed}}&closed=1{{end}}">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">{{ago .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 historyPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/file/{{.TreeVersion}}/{{.HistoryPath}}"><code>{{.HistoryPath}}</code></a></p>
{{if .History}}<div class="tw"><table>
<thead><tr><th scope="col">Check-in</th><th scope="col">When</th><th scope="col">Who</th><th scope="col">Comment</th></tr></thead>
<tbody>{{range .History}}
<tr><td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Hash}}"><code>{{printf "%.12s" .Hash}}</code></a></td>
<td class="tl-meta-inline">{{ago .Date}}</td><td>{{.User}}</td><td>{{.Comment}}</td></tr>
{{end}}</tbody>
</table></div>
{{else}}<p>No recorded changes to this file — it has existed in one state for its whole life here.</p>{{end}}
{{end}}`))
var ciPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<div{{if .Live}} {{.LiveAttr}}{{end}}><div id="ci">{{template "ci-body" .}}</div></div>
{{end}}
{{define "ci-body"}}
{{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"}}
{{- /* One line of chrome, not four. The branch picker, the crumb trail
and the clone affordances all belong to "which tree am I looking
at", so they share a row and the listing starts near the top. */ -}}
{{- /* Forgejo's shape: a strip of repository facts, then a row with the
ref switcher on the left and clone/download on the right. The
facts were previously nowhere and the switcher was a bare select
whose width was set by its longest branch name. */ -}}
<div class="repo-stats">
<a href="/r/{{.Repo}}/timeline"><svg class="ic" aria-hidden="true"><use href="#i-commit"/></svg> {{count .Counts.Checkins}} check-ins</a>
<a href="/r/{{.Repo}}/branch"><svg class="ic" aria-hidden="true"><use href="#i-branch"/></svg> {{count .BranchTotal}} branches</a>
<a href="/r/{{.Repo}}/tag"><svg class="ic" aria-hidden="true"><use href="#i-tag"/></svg> {{count .Counts.Tags}} tags</a>
<a href="/people"><svg class="ic" aria-hidden="true"><use href="#i-people"/></svg> {{count .Counts.Contributors}} contributors</a>
</div>
<div class="tree-bar">
{{if .Branches}}
{{- /* A popover, not a select: a select is as wide as its widest option
and cannot show branches and tags as separate lists. This shows
both, and stays one button wide whatever the names are. */ -}}
<div class="refpick">
<button type="button" class="refbtn" popovertarget="refs" aria-label="switch branch or tag">
<svg class="ic" aria-hidden="true"><use href="#i-branch"/></svg> <span class="refname">{{.RefName}}</span> <span class="caret" aria-hidden="true">▾</span></button>
<div id="refs" popover class="menu refs">
<div class="menu-who">branches{{if .MoreBranches}} · {{.MoreBranches}} older not shown{{end}}</div>
{{range .Branches}}<a href="/r/{{$.Repo}}/tree/{{.Tip}}"{{if eq .Tip $.TreeVersion}} class="here"{{end}}>{{.Label}}</a>{{end}}
{{if .Tags}}<div class="menu-who">tags</div>
{{range .Tags}}<a href="/r/{{$.Repo}}/tree/{{.Tip}}"{{if eq .Tip $.TreeVersion}} class="here"{{end}}>{{.Name}}</a>{{end}}{{end}}
</div>
</div>{{end}}
<span class="crumbs"><a href="/r/{{.Repo}}/tree">{{.Repo}}</a>{{range .Crumbs}} / <a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>{{end}}</span>
<span class="tree-acts">
{{- /* The clone URL is selectable text with a copy button beside it, and
the archives sit behind one "download" rather than two loose
links, which is what stopped this row fitting a phone. */ -}}
{{- /* zit and fossil share the sync protocol, so a store that never
emitted a sub-manifest can be cloned by either. Offering the
choice saves a reader who already has fossil from installing
anything; it is withheld where it would not work, because a
command that fails is worse than one never offered. */ -}}
{{if .FossilClone}}<span class="clonepick">
<a href="?clone=zit" class="{{if ne .CloneTool "fossil"}}here{{end}}">zit</a>
<a href="?clone=fossil" class="{{if eq .CloneTool "fossil"}}here{{end}}">fossil</a>
</span>{{end}}
<span class="clonebox"><input class="cloneurl" type="text" readonly value="{{.CloneTool}} clone {{.CloneURL}} {{.Repo}}" aria-label="clone command" onfocus="this.select()">
<button type="button" class="copybtn" title="select the clone command" aria-label="select the clone command" onclick="this.previousElementSibling.select()"><svg class="ic" aria-hidden="true"><use href="#i-copy"/></svg></button></span>
<button type="button" class="dlbtn" popovertarget="dl">download <span class="caret" aria-hidden="true">▾</span></button>
<div id="dl" popover class="menu dlmenu">
<a href="/r/{{.Repo}}/archive/{{.TreeVersion}}">tar.gz</a>
<a href="/r/{{.Repo}}/archive/{{.TreeVersion}}?format=zip">zip</a>
</div>
{{if .IsAdmin}}<a href="/r/{{.Repo}}/settings">settings</a>{{end}}</span>
</div>
{{- /* The description belongs where the repository is, not only in the
listing: a reader arriving from a link otherwise sees nav and a
file tree and no statement of what the project is. */ -}}
{{if or .RepoDescription .RepoPrivate}}<p class="about">{{if .RepoPrivate}}<span class="tl-badge">private</span> {{end}}{{.RepoDescription}}</p>{{end}}
{{- /* No History column: it was one identical link per row, thirty of
them, and the file page already links its own history. GitHub and
Forgejo both spend that space on the last change instead, which is
what goes here once the index carries per-file data. */ -}}
<div class="tw"><table class="tree">
<tbody>{{range .Tree}}
<tr>
<td class="t-name">{{if .Dir}}<svg class="ic" aria-hidden="true"><use href="#i-dir"/></svg><a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>
{{- else}}<svg class="ic" aria-hidden="true"><use href="#i-file"/></svg><a href="/r/{{$.Repo}}/file/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>
{{- if .Exec}} <span class="flag" title="executable">exe</span>{{end}}
{{- if .Link}} <span class="flag" title="symbolic link">link</span>{{end}}
{{- if .Binary}} <span class="flag" title="binary">bin</span>{{end}}{{end}}</td>
<td class="t-size">{{if .Dir}}{{.Files}} files{{else}}{{.Human}}{{end}}</td>
</tr>
{{end}}</tbody>
</table></div>
{{if .Readme}}<h3 class="readme-head">{{.ReadmeIn}}</h3>
<div class="tkt-body readme">{{.Readme}}</div>{{end}}
{{end}}`))
var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<dl class="tkt-head">{{range .Ticket.Header}}
<dt>{{.Name}}</dt><dd>{{.Value}}</dd>{{end}}{{range .Ticket.Extra}}
<dt>{{.Name}}</dt><dd>{{.Value}}</dd>{{end}}
<dt>created</dt><dd>{{ago .Ticket.Created}}</dd>
<dt>last change</dt><dd>{{ago .Ticket.Updated}}</dd>
<dt>ticket</dt><dd><code class="tkt-id">{{printf "%.20s" .Ticket.ID}}</code></dd>
</dl>
<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">{{ago .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}} · {{ago .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}}`))
// fleetAdminPage is the operator's view of the fleet. The ordering is
// deliberate: failures first and loud, because the whole reason this
// page exists is that with eleven stores a wedged one is invisible —
// the fleet listing shows counts, and a store that has failed its round
// every two seconds for an hour has exactly the counts it had when it
// broke.
var fleetAdminPage = template.Must(template.Must(base.Clone()).Parse(fleetAdminSource))
const fleetAdminSource = `{{define "content"}}
{{- /* The layout already renders the page title as its h1; a second
heading saying the same word twice is what "fleet Fleet" was. */ -}}
{{if .SyncResults}}<div class="status"><p><strong>Sync run.</strong></p>
<ul>{{range .SyncResults}}<li><code>{{.Repo}}</code> — {{if .Err}}<strong>failed:</strong> {{.Err}}{{else}}{{.Artifacts}} artifacts{{end}}</li>{{end}}</ul></div>{{end}}
<p>{{len .FleetHealth}} stores served{{if .Unhealthy}} — <strong>{{.Unhealthy}} failing</strong>{{else}} — all healthy{{end}}.</p>
<form method="post" action="/fleet/sync"><button type="submit">Sync all now</button></form>
<div class="tw"><table>
<thead><tr><th scope="col">Store</th><th scope="col">Check-ins</th><th scope="col">Last round</th>
<th scope="col">Artifacts</th><th scope="col">Parse fallbacks</th><th scope="col">State</th><th scope="col"></th></tr></thead>
<tbody>{{range .FleetHealth}}
<tr>
<td><a href="/r/{{.Name}}/timeline">{{.Name}}</a><br><small class="tl-meta-inline"><code>{{printf "%.12s" .Code}}</code></small></td>
<td>{{.Checkins}}</td>
<td>{{if .Health.LastOK}}{{.Health.LastOK}}<br><small class="tl-meta-inline">{{printf "%.3f" .Health.RoundSeconds}}s</small>{{else}}<em>never</em>{{end}}</td>
<td>{{.Health.Total}}{{if .Health.LastCount}} <small class="tl-meta-inline">(+{{.Health.LastCount}})</small>{{end}}</td>
<td>{{if .Health.Fallbacks}}<span class="tl-badge">{{.Health.Fallbacks}}</span>{{else}}0{{end}}</td>
<td>{{if .Health.Failures}}<strong>failing ({{.Health.Failures}} in a row)</strong><br><small class="tl-meta-inline">{{.Health.LastErrAt}} — {{.Health.LastErr}}</small>
{{else if .Indexing}}<span class="tl-badge">indexing…</span>{{else}}ok{{end}}</td>
<td><form method="post" action="/fleet/sync"><input type="hidden" name="repo" value="{{.Name}}"><button type="submit">sync</button></form></td>
</tr>
{{end}}</tbody>
</table></div>
<h3>Mirrors</h3>
{{if .MirrorResults}}<div class="status"><p><strong>Pull run.</strong></p>
<ul>{{range .MirrorResults}}<li><code>{{.Repo}}</code> — {{if .Err}}<strong>failed:</strong> {{.Err}}{{else if .Changed}}new artifacts{{else}}already up to date{{end}}</li>{{end}}</ul></div>{{end}}
{{if .Mirrors}}<div class="tw"><table>
<thead><tr><th scope="col">Store</th><th scope="col">Upstream</th><th scope="col">Every</th>
<th scope="col">Last pull</th><th scope="col">State</th><th scope="col"></th></tr></thead>
<tbody>{{range .Mirrors}}
<tr>
<td><a href="/r/{{.Repo}}/timeline">{{.Repo}}</a></td>
<td><small>{{.URL}}</small></td>
<td>{{.Every}}s</td>
<td>{{if .LastAt}}{{ago .LastAt}}{{else}}<em>never</em>{{end}}</td>
<td>{{if .Failures}}<strong>failing ({{.Failures}} in a row)</strong><br><small class="tl-meta-inline">{{.LastErr}}</small>
{{else if not .Enabled}}disabled{{else if .LastOK}}ok{{else}}<em>pending</em>{{end}}</td>
{{- /* Both actions on one line. A form is block-level, so two of them
in a cell stacked and made every row two and a half lines tall;
display:contents lets their buttons join the cell's own row. */ -}}
<td class="acts"><form method="post" action="/fleet/mirror/run"><input type="hidden" name="repo" value="{{.Repo}}"><button type="submit">pull</button></form>
<form method="post" action="/fleet/mirror/delete"><input type="hidden" name="repo" value="{{.Repo}}"><button type="submit">forget</button></form></td>
</tr>
{{end}}</tbody>
</table></div>
<form method="post" action="/fleet/mirror/run" class="rowform"><button type="submit">Pull all now</button></form>
{{else}}<p>No upstreams configured. A mirrored store tracks its origin — quarry pulls, it never pushes.</p>{{end}}
{{- /* Each control keeps its label above it rather than beside it: at
"every 3600 seconds" the trailing word wrapped onto its own line
and the row read as three broken sentences. */ -}}
<form method="post" action="/fleet/mirror" class="addform">
<label>store<input name="repo" required></label>
<label class="grow">upstream<input name="url" type="url" placeholder="https://fossil-scm.org/home" required></label>
<label>every (seconds)<input name="every" type="number" value="3600" min="60"></label>
<button type="submit">Add mirror</button></form>
{{end}}`
// newRepoPage is the forge's "create a repository" form. Deliberately
// the same three questions every forge asks — name, description, where
// it comes from — and NOT a filesystem path: the server places it.
var newRepoPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h2>New repository</h2>
{{if .Error}}<p class="status"><strong>{{.Error}}</strong></p>{{end}}
<form method="post" action="/new" class="newrepo-form">
<p><label>Name<br><input name="name" required autofocus pattern="[A-Za-z0-9][A-Za-z0-9._-]*"
title="letters, digits, dot, dash, underscore"></label></p>
<p><label>Description <small class="tl-meta-inline">optional</small><br>
<input name="description" size="52" maxlength="300"></label></p>
<p><label><input type="checkbox" name="private" value="true"> Private
<small class="tl-meta-inline">— only accounts with a role on it can see that it exists</small></label></p>
<fieldset><legend>Contents</legend>
<p><label><input type="radio" name="kind" value="create" checked> Start empty</label></p>
<p class="seed"><label><input type="checkbox" name="readme" value="true" checked> Add a README</label>
<label><input type="checkbox" name="ignore" value="true"> Add a .zitignore</label>
<label>License
<select name="license"><option value="">none</option><option value="mit">MIT</option><option value="bsd-2">BSD 2-Clause</option></select></label></p>
<p><label><input type="radio" name="kind" value="clone"> Clone an existing repository</label><br>
<input name="from" size="52" placeholder="https://fossil-scm.org/home" aria-label="repository URL"></p>
</fieldset>
<p><button type="submit">Create repository</button></p>
</form>
{{end}}`))
// quickStartPage is what an empty repository shows instead of an empty
// timeline: a repository with no check-ins is not broken, it is waiting,
// and the reader's next move is to push to it.
var quickStartPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if or .RepoDescription .RepoPrivate}}<p class="about">{{if .RepoPrivate}}<span class="tl-badge">private</span> {{end}}{{.RepoDescription}}</p>{{end}}
<p>This repository is empty.</p>
<h3>Push an existing repository</h3>
<pre>zit remote {{.CloneURL}}
zit push</pre>
<h3>Start from scratch</h3>
<pre>zit clone {{.CloneURL}} {{.Repo}}
cd {{.Repo}}
echo "# {{.Repo}}" > README.md
zit add README.md
zit commit -m "first check-in"
zit push</pre>
{{end}}`))
// settingsPage is the repository's own admin screen. Description and
// visibility for now; the destructive actions belong here too when they
// exist, kept visually apart from the settings that are reversible.
var settingsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h2>Settings</h2>
{{if .Error}}<p class="status"><strong>{{.Error}}</strong></p>{{end}}
{{if .Saved}}<p class="status">Saved.</p>{{end}}
<form method="post" action="/r/{{.Repo}}/settings">
<p><label>Description<br>
<input name="description" size="52" maxlength="300" value="{{.RepoDescription}}"></label></p>
<p><label><input type="checkbox" name="private" value="true"{{if .RepoPrivate}} checked{{end}}> Private
<small class="tl-meta-inline">— only accounts with a role on it can see that it exists</small></label></p>
<p><button type="submit">Save</button></p>
</form>
<h3>Clone</h3>
<pre>zit clone {{.CloneURL}} {{.Repo}}</pre>
{{- /* Kept apart, and last. Both actions here are one click from
something the settings above are not: irreversible. */ -}}
<h3 class="danger-head">Danger zone</h3>
<div class="danger">
<form method="post" action="/r/{{.Repo}}/settings/remove">
<p><strong>Stop serving this repository.</strong> The store on disk and its
index are left alone; registering it again brings it back.</p>
<p><button type="submit">Stop serving</button></p>
</form>
{{if .Deletable}}<form method="post" action="/r/{{.Repo}}/settings/delete">
<p><strong>Delete this repository.</strong> Its store, its index, its grants,
its sync users, its mirror and its webhooks are destroyed. There is no undo.</p>
<p><label>Type <code>{{.Repo}}</code> to confirm<br>
<input name="confirm" required autocomplete="off"></label></p>
<p><button type="submit">Delete forever</button></p>
</form>{{else}}<p class="tl-meta-inline">This repository's store was not created by quarry,
so it cannot be deleted here — stop serving it and remove the directory yourself.</p>{{end}}
</div>
{{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 busyPage = template.Must(template.Must(base.Clone()).Parse(
`{{define "content"}}<p>The server is at capacity for this kind of request. Nothing is wrong —
this is the limit doing its job — and the same request should succeed shortly.</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 globalSearchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* No second box: the header carries one on every page including
this one, and two inputs for the same query invite a reader to
wonder which is authoritative. */ -}}
{{if .Query}}<nav class="scopes">{{range scopes}}
<a href="/search?q={{$.Query}}{{if .Kind}}&kind={{.Kind}}{{end}}"{{if eq .Kind $.SearchKind}} class="here"{{end}}>{{.Label}}</a>
{{end}}</nav>
<p class="tl-meta-inline">{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} across {{.Searched}} repositor{{if eq .Searched 1}}y{{else}}ies{{end}}</p>
{{- /* One repository can fill a page of results honestly — fossil really
does have the most to say about memory leaks. The facet is how a
reader gets to the others without retyping the query. */ -}}
{{if .ByRepo}}<nav class="scopes facets">
<a href="/search?q={{.Query}}{{if .SearchKind}}&kind={{.SearchKind}}{{end}}"{{if not .RepoFilter}} class="here"{{end}}>all repositories</a>
{{range .ByRepo}}<a href="/search?q={{$.Query}}{{if $.SearchKind}}&kind={{$.SearchKind}}{{end}}&repo={{.Repo}}"{{if eq .Repo $.RepoFilter}} class="here"{{end}}>{{.Repo}} <span class="n">{{.Count}}</span></a>
{{end}}</nav>{{end}}{{end}}
{{if .Results}}<ul class="hits">{{range .Results}}
<li><a class="hit-repo" href="/r/{{.Repo}}">{{.Repo}}</a>
<a href="{{.URL}}">{{.Label}}</a> <small class="tl-meta-inline">{{.Kind}}</small>
<div class="snip">{{.Snippet}}</div></li>
{{end}}</ul>{{else if .Query}}<p>Nothing in {{.SearchScope}} matches “{{.Query}}”.</p>{{end}}
{{end}}`))
var searchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* The scope the header chose is shown and changeable here. A reader
who searched from the tickets page and found nothing needs to widen
without retyping, which is one click rather than a new query. */ -}}
{{if .Query}}<nav class="scopes">{{range scopes}}
<a href="/r/{{$.Repo}}/search?q={{$.Query}}{{if .Kind}}&kind={{.Kind}}{{end}}"{{if eq .Kind $.SearchKind}} class="here"{{end}}>{{.Label}}</a>
{{end}}</nav>{{end}}
{{if .Results}}<ul class="hits">{{range .Results}}
<li><a href="{{.URL}}">{{.Label}}</a> <small class="tl-meta-inline">{{.Kind}}</small>
<div class="snip">{{.Snippet}}</div></li>
{{end}}</ul>{{else if .Query}}<p>Nothing in {{.SearchScope}} matches “{{.Query}}”.</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 {{ago .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>{{ago .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">{{.CI}}{{if .CI}} {{end}}{{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
FleetTotal int
// Notices is the third-party licence inventory; QuarryLicence is
// this software's own.
Notices []legal.Notice
QuarryLicence string
// Explore names the aggregate tab in view; People and PeopleTotal
// back the people one.
Explore string
Person personRow
ShowClosed bool
Page int
NextPage int
PrevPage int
People []personRow
PeopleTotal int
// Searched is how many repositories a fleet-wide search consulted;
// ByRepo is the per-repository facet and RepoFilter the one the
// reader has narrowed to.
Searched int
ByRepo []repoFacet
RepoFilter string
Sort string
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
History []fileChange
HistoryPath string
CIRuns []ciRunRow
CIBadge template.HTML
Tree []treeRow
TreeVersion string
Crumbs []crumb
Branches []branchRow
MoreBranches int
// BranchTotal and TagTotal back the repository stats strip; Tags
// and RefName back the ref switcher.
BranchTotal int
TagTotal int
Tags []tagRow
RefName string
// FossilClone offers the fossil clone command alongside zit;
// CloneTool is the one currently shown.
FossilClone bool
CloneTool string
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
IsAdmin bool // site admin, so show the fleet 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
// FleetHealth is the admin surface's per-store ingest state, and
// Unhealthy the count an operator scans for. SyncResults is non-nil
// only just after a manual round.
FleetHealth []fleetHealthRow
Unhealthy int
SyncResults []fleetSyncRow
// Mirrors is the configured upstream tracking; MirrorResults is
// non-nil only just after a manual pull.
Mirrors []mirrorRow
MirrorResults []mirrorRunRow
// Section is the nav entry the current page belongs to, so the nav
// can say where the reader is. Counts is the per-section census;
// HasCounts distinguishes "no content" from "counts unavailable",
// which otherwise both render as zero.
Section string
// SearchKind and SearchScope are derived from Section by
// searchContext, so no handler has to remember to set them.
SearchKind string
SearchScope string
// Theme is the reader's explicit colour choice, empty when they
// have made none and the system decides. NextTheme is what the
// switcher will move to; ThemeIcon and ThemeName describe the
// current state.
Theme string
NextTheme string
ThemeIcon string
ThemeName string
Counts navCounts
HasCounts bool
// RepoDescription and RepoPrivate are this repository's settings,
// for the settings form and the About panel.
RepoDescription string
RepoPrivate bool
Saved bool
// Deletable is whether quarry created this repository's store and
// may therefore destroy it.
Deletable bool
// Elapsed is how long this request took to render, reported the way
// fossil reports it ("This page was generated in about 0.003s").
// Publishing it is what made the timeline regression measurable
// against fossil at all, so it stays on the page rather than in a
// header only a developer would look at.
Elapsed string
// Breakdown says which layer the time went to ("zit 147ms, sql 3ms").
// One number tells a reader the page is slow; it does not say whether
// to look at the store, the index, or the template.
Breakdown string
// HideH1 lets a page that already names itself suppress the layout's
// heading. The file listing does: its crumb trail starts with the
// repository, so an h1 above it repeats the same word and costs a
// heading's worth of vertical space to say nothing.
HideH1 bool
}
// navCounts mirrors the repo.counts op.
type navCounts struct {
Checkins int `json:"checkins"`
WikiPages int `json:"wikiPages"`
Tickets int `json:"tickets"`
ForumThreads int `json:"forumThreads"`
Technotes int `json:"technotes"`
Contributors int `json:"contributors"`
Tags int `json:"tags"`
}
// sectionOf names the nav entry a path belongs to. It reads the segment
// after /r/{repo}/, so every page under a section — a ticket, a wiki
// revision, a file's history — marks its section, not just the section's
// index page.
func sectionOf(path string) string {
rest, ok := strings.CutPrefix(path, "/r/")
if !ok {
return ""
}
_, rest, ok = strings.Cut(rest, "/")
if !ok || rest == "" {
return "tree" // the repository home is the file tree
}
seg, _, _ := strings.Cut(rest, "/")
switch seg {
// Pages that live under a section without sharing its path segment.
case "file", "history", "archive", "tree":
return "tree"
case "checkin", "diff", "timeline":
return "timeline"
default:
return seg
}
}
// mirrorRow mirrors the mirror.list op's row.
type mirrorRow struct {
Repo string `json:"repo"`
URL string `json:"url"`
Every int `json:"every"`
Enabled bool `json:"enabled"`
LastAt string `json:"lastAt"`
LastOK string `json:"lastOk"`
LastErr string `json:"lastErr"`
Failures int `json:"failures"`
}
type mirrorRunRow struct {
Repo string `json:"repo"`
Changed bool `json:"changed"`
Err string `json:"err"`
}
// fleetHealthRow mirrors the fleet.status op's row. Durations arrive as
// seconds and are formatted for reading here rather than in the op: the
// JSON surface stays machine-shaped, the page stays human-shaped.
type fleetHealthRow struct {
Name string `json:"name"`
Code string `json:"code"`
Checkins int `json:"checkins"`
Indexing bool `json:"indexing"`
Health struct {
LastOK string `json:"lastOk"`
RoundSeconds float64 `json:"roundSeconds"`
LastCount int `json:"lastCount"`
Total int `json:"total"`
Fallbacks int `json:"fallbacks"`
LastErr string `json:"lastErr"`
LastErrAt string `json:"lastErrAt"`
Failures int `json:"failures"`
} `json:"health"`
}
type fleetSyncRow struct {
Repo string `json:"repo"`
Artifacts int `json:"artifacts"`
Err string `json:"err"`
}
type repoInfo struct {
Name string `json:"name"`
Description string `json:"description"`
Private bool `json:"private"`
LastActive string `json:"lastActive"`
Deletable bool `json:"deletable"`
Contributors int `json:"contributors"`
MirroredAt string `json:"mirroredAt"`
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"`
CI string `json:"ci"`
}
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.
// CIBadge renders a check-in's verdict for a listing. Empty for a
// check-in with no verdict: a repository without CI, or a check-in made
// before its pipeline landed, should show nothing at all rather than a
// neutral badge that reads as a result.
func ciBadge(verdict string) template.HTML {
switch verdict {
case "ci-pass":
return template.HTML(`<span class="tl-badge ci-pass" title="CI passed">pass</span>`)
case "ci-fail":
return template.HTML(`<span class="tl-badge ci-fail" title="CI failed">fail</span>`)
}
return ""
}
// fileChange is one check-in that touched a file.
type fileChange struct {
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
}
// 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"`
Size int64 `json:"size"`
Exec bool `json:"exec"`
Link bool `json:"link"`
Binary bool `json:"binary"`
}
// Human renders a size the way a file listing should: bytes are noise
// past a few thousand, and the reader wants magnitude.
func (t treeRow) Human() string {
const unit = 1024
if t.Size < unit {
return fmt.Sprintf("%d B", t.Size)
}
div, exp := int64(unit), 0
for n := t.Size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(t.Size)/float64(div), "KMGT"[exp])
}
// labelBranches fills each row's selector label. zit allows two
// branches to share a name, so a name is not always enough to tell the
// options apart — but it almost always is, and appending a hash to every
// one made the control as wide as its widest hash for no gain. The hash
// appears only on the names that actually repeat.
func labelBranches(rows []branchRow) {
seen := map[string]int{}
for _, b := range rows {
seen[b.Name]++
}
for i, b := range rows {
rows[i].Label = b.Name
if seen[b.Name] > 1 {
rows[i].Label = b.Name + " · " + b.Tip[:min(10, len(b.Tip))]
}
}
}
// localRef is the Referer reduced to a path on this origin, or "/". A
// Referer is attacker-controllable, so only its path and query survive;
// anything naming another host, or a protocol-relative "//evil" that a
// browser would read as one, becomes the home page.
func localRef(req *http.Request) string {
ref := req.Referer()
if ref == "" {
return "/"
}
u, err := url.Parse(ref)
if err != nil || u.Path == "" || !strings.HasPrefix(u.Path, "/") ||
strings.HasPrefix(u.Path, "//") {
return "/"
}
out := u.Path
if u.RawQuery != "" {
out += "?" + u.RawQuery
}
return out
}
// themeCookie holds an explicit colour choice. Absent means "follow the
// system", which is the default and stores nothing — a reader who never
// touches the control is never given a cookie.
const themeCookie = "theme"
// themeCycle is the order the switcher moves through. Three states, not
// two: with only light and dark, a reader who wanted "follow my phone"
// back would have no way to ask for it.
var themeCycle = []string{"", "light", "dark"}
// themeState describes one point in the cycle for the template.
func themeState(theme string) (next, icon, name string) {
i := slices.Index(themeCycle, theme)
if i < 0 {
i = 0
}
next = themeCycle[(i+1)%len(themeCycle)]
switch theme {
case "light":
return next, "sun", "light"
case "dark":
return next, "moon", "dark"
}
return next, "auto", "system"
}
// searchScopeName is searchContext's inverse: the word for a kind, used
// where the kind is already known and the section is not.
func searchScopeName(kind string) (string, string) {
for _, section := range []string{"timeline", "wiki", "ticket", "forum", "technote"} {
if k, scope := searchContext(section); k == kind && kind != "" {
return k, scope
}
}
return "", "everything"
}
// searchScopes are the choices offered on the results page, in the
// order the nav uses so the two surfaces agree.
var searchScopes = []struct{ Kind, Label string }{
{"", "everything"}, {"checkin", "check-ins"}, {"wiki", "wiki"},
{"ticket", "tickets"}, {"forum", "forum"}, {"technote", "technotes"},
}
// searchContext maps a nav section to the search kind it means and the
// word for it. A section with nothing indexed — files, ci, chat — falls
// through to searching everything rather than to an empty scope that
// would silently find nothing.
func searchContext(section string) (kind, scope string) {
switch section {
case "timeline":
return "checkin", "check-ins"
case "wiki":
return "wiki", "wiki"
case "ticket":
return "ticket", "tickets"
case "forum":
return "forum", "forum"
case "technote":
return "technote", "technotes"
}
return "", "everything"
}
// tagRow is one release tag for the ref switcher.
type tagRow struct {
Name string `json:"name"`
Tip string `json:"tip"`
}
// 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"`
// Label is what the selector shows. Usually just the name: the hash
// was in every option, which made the control as wide as its widest
// hash on every repository and told a reader nothing they chose by.
// It comes back only for a name two branches share, which is the
// one case where the name alone does not identify the choice.
Label string `json:"-"`
}
// 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
}
// privateFields are the ticket fields fossil does NOT show publicly.
// Its own new-ticket template labels private_contact "Not publicly
// visible" and gates it behind capability "e" (view private email
// addresses) with enable_output [hascap e]. quarry rendered it to
// everyone: across this corpus that is 2,650 values, of which some in
// fossil's own tracker are real e-mail addresses. Reproducing a
// tracker's content faithfully must not mean publishing what the
// tracker deliberately withheld.
//
// quarry has no equivalent capability yet, so these are dropped
// outright rather than gated. Dropping is the safe direction: an admin
// who needs the address can read the artifact, and nobody else can.
var privateFields = map[string]bool{"private_contact": true, "contact": true}
// 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] && !privateFields[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"`
CI string `json:"ci"`
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
}
// personRow is one contributor on the people tab.
type personRow struct {
User string `json:"user"`
Events int `json:"events"`
First string `json:"first"`
Last string `json:"last"`
Repos []string `json:"repos"`
}
// repoFacet is one repository's share of a fleet-wide result set.
type repoFacet struct {
Repo string
Count int
}
type searchHit struct {
// Repo is set only for fleet-wide results, where a hit is
// meaningless without saying which repository it came from.
Repo string
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) api.Decision
// 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 at Token: HTTP Basic against
// verify, the authenticated user carried to the op via context. Almost
// every gated HTML page is a write page and wants exactly this.
func gate(verify Verify, sess Sessions, repo string, w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
return gateAt(verify, sess, repo, ops.Token, w, req)
}
// gateAt is gate at an explicit capability. The fleet admin pages are
// the reason it exists: they are not scoped to one repository and they
// expose operational detail, so Token is not the right bar for them.
func gateAt(verify Verify, sess Sessions, repo string, level ops.Auth, w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
if verify == nil {
return req, true
}
if sess != nil {
if user, ok := sess.Resolve(cookieToken(req)); ok {
if !sess.Allow(user, repo, level) {
// Signed in, but without the capability on THIS
// repository. A challenge would be a lie — better
// credentials are not what is missing.
forbid(w, user, repo, level)
return nil, false
}
return req.WithContext(ops.WithUser(req.Context(), user)), true
}
}
user, pass, _ := req.BasicAuth()
switch verify(repo, user, pass, level) {
case api.Allowed:
case api.Insufficient:
// Authenticated and short of the capability: a challenge would
// invite the reader to type the same password again.
forbid(w, user, repo, level)
return nil, false
default:
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
}
// forbid states what the signed-in user actually lacks. The fleet pages
// are not scoped to a repository, so a message naming one would be
// nonsense ("no write access to ”"); the level and the empty repo pick
// the honest wording.
// isEmptyRepo reports whether a registered repository simply has no
// check-ins yet, as opposed to not existing.
func isEmptyRepo(r *ops.Registry, req *http.Request, repo string) bool {
out, err := invoke(req.Context(), r, "repo.counts", url.Values{"repo": {repo}})
if err != nil {
return false // unknown repository: a real not-found
}
var c struct {
Checkins int `json:"checkins"`
}
remarshal(out, &c)
return c.Checkins == 0
}
// deletable reports whether quarry created this repository's store, so
// the settings page can offer deletion only where it would work rather
// than offering it and failing.
func deletable(r *ops.Registry, req *http.Request, repo string) bool {
out, err := invoke(req.Context(), r, "repo.list", nil)
if err != nil {
return false
}
var rl struct {
Repos []repoInfo `json:"repos"`
}
remarshal(out, &rl)
for _, x := range rl.Repos {
if x.Name == repo {
return x.Deletable
}
}
return false
}
// fleetHealth renders the admin page, optionally above the results of a
// sync or a pull the operator just ran.
func fleetHealth(r *ops.Registry, w http.ResponseWriter, req *http.Request,
sync []fleetSyncRow, pulls []mirrorRunRow) {
out, err := invoke(req.Context(), r, "fleet.status", nil)
if err != nil {
fail(r, w, req, err)
return
}
var fs struct {
Repos []fleetHealthRow `json:"repos"`
Unhealthy int `json:"unhealthy"`
}
remarshal(out, &fs)
data := pageData{
Title: "fleet", FleetHealth: fs.Repos, Unhealthy: fs.Unhealthy,
SyncResults: sync, MirrorResults: pulls,
}
// mirror.list is absent when no mirror store is configured, which is
// a forge that mirrors nothing rather than an error — the section
// simply renders its empty state.
if mo, err := invoke(req.Context(), r, "mirror.list", nil); err == nil {
var ml struct {
Mirrors []mirrorRow `json:"mirrors"`
}
remarshal(mo, &ml)
data.Mirrors = ml.Mirrors
}
render(r, w, req, fleetAdminPage, data)
}
func forbid(w http.ResponseWriter, user, repo string, level ops.Auth) {
w.WriteHeader(http.StatusForbidden)
switch {
case level >= ops.Admin && repo == "":
fmt.Fprintf(w, "%s is not a site admin", user)
case level >= ops.Admin:
fmt.Fprintf(w, "%s has no admin access to %s", user, repo)
default:
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)
})
}
mux.HandleFunc("GET /people", func(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query().Get("q")
// A page, not the fleet: at any real size this list is longer
// than anyone scrolls, and rendering all of it helps nobody.
const perPage = 50
page, _ := strconv.Atoi(req.URL.Query().Get("page"))
if page < 1 {
page = 1
}
vals := url.Values{
"limit": {strconv.Itoa(perPage)},
"offset": {strconv.Itoa((page - 1) * perPage)},
}
if q != "" {
vals.Set("q", q)
}
out, err := invoke(req.Context(), r, "people.list", vals)
if err != nil {
fail(r, w, req, err)
return
}
var pl struct {
People []personRow `json:"people"`
Total int `json:"total"`
}
remarshal(out, &pl)
data := pageData{Title: "people", HideH1: true, Explore: "people",
Query: q, People: pl.People, PeopleTotal: pl.Total, Page: page}
if page*perPage < pl.Total {
data.NextPage = page + 1
}
if page > 1 {
data.PrevPage = page - 1
}
// The other tab's count comes from its own op, so the pair of
// numbers on the tabs always describes the same moment.
if fo, err := invoke(req.Context(), r, "repo.list", nil); err == nil {
var fl struct {
Total int `json:"total"`
}
remarshal(fo, &fl)
data.FleetTotal = fl.Total
}
render(r, w, req, peoplePage, data)
})
mux.HandleFunc("GET /people/{user}", func(w http.ResponseWriter, req *http.Request) {
want := req.PathValue("user")
out, err := invoke(req.Context(), r, "people.list",
url.Values{"q": {want}, "limit": {"500"}})
if err != nil {
fail(r, w, req, err)
return
}
var pl struct {
People []personRow `json:"people"`
}
remarshal(out, &pl)
// q is a substring filter, so an exact match must be picked out
// of it: "dan" must not open "dan.mestas".
for _, p := range pl.People {
if p.User == want {
render(r, w, req, personPage, pageData{Title: p.User, HideH1: true, Person: p})
return
}
}
fail(r, w, req, fmt.Errorf("no contributor %q: %w", want, ops.ErrNotFound))
})
mux.HandleFunc("GET /licenses", func(w http.ResponseWriter, req *http.Request) {
notices, err := legal.Notices()
if err != nil {
fail(r, w, req, err)
return
}
render(r, w, req, licensesPage, pageData{Title: "licenses",
Notices: notices, QuarryLicence: legal.Quarry})
})
mux.HandleFunc("GET /privacy", func(w http.ResponseWriter, req *http.Request) {
render(r, w, req, privacyPage, pageData{Title: "privacy"})
})
mux.HandleFunc("POST /theme", func(w http.ResponseWriter, req *http.Request) {
to := req.FormValue("to")
if !slices.Contains(themeCycle, to) {
http.Error(w, "unknown theme", http.StatusBadRequest)
return
}
c := &http.Cookie{Name: themeCookie, Value: to, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 400 * 24 * 60 * 60}
if to == "" {
// "Follow the system" is the absence of a choice, so it
// removes the cookie rather than storing a third value.
c.MaxAge, c.Expires = -1, time.Unix(0, 0)
}
http.SetCookie(w, c)
// Back to the page the reader was on. Only a same-site path is
// honoured: an open redirect here would let any page bounce a
// reader off this origin through a control they expect to be
// inert.
http.Redirect(w, req, localRef(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)
})
}
// The mark. Immutable per build and small, so it is cached hard —
// a favicon refetched on every page load is the most pointless
// request a site can make.
icon := func(ctype string, body []byte) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", "public, max-age=604800")
w.Write(body)
}
}
mux.HandleFunc("GET /favicon.ico", icon("image/x-icon", faviconICO))
mux.HandleFunc("GET /apple-touch-icon.png", icon("image/png", appleTouchIcon))
mux.HandleFunc("GET /assets/quarry.png", icon("image/png", markPNG))
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) {
q, sortBy := req.URL.Query().Get("q"), req.URL.Query().Get("sort")
if sortBy == "" {
sortBy = "name"
}
out, err := invoke(req.Context(), r, "repo.list",
url.Values{"q": {q}, "sort": {sortBy}, "limit": {"100"}})
if err != nil {
fail(r, w, req, err)
return
}
var fl struct {
Repos []repoInfo `json:"repos"`
Total int `json:"total"`
}
remarshal(out, &fl)
// The forge names itself in the top bar on every page, so the
// aggregate page repeating it as a heading says nothing. The
// heading stays in the document for structure and screen
// readers; it is the visible duplicate that goes.
peopleTotal := 0
if po, err := invoke(req.Context(), r, "people.list", url.Values{"limit": {"1"}}); err == nil {
var pl struct {
Total int `json:"total"`
}
remarshal(po, &pl)
peopleTotal = pl.Total
}
data := pageData{Title: "repositories", HideH1: true, PeopleTotal: peopleTotal,
Fleet: fl.Repos, FleetTotal: fl.Total,
Query: q, Sort: sortBy}
if n != nil {
data.Live = true
// The stream has to carry the reader's view, not just the
// URL. A repaint re-renders the whole listing, so a stream
// that does not know the filter replaces what the reader
// asked for with the default a second after it appears —
// which reads as the filter not working at all.
live := url.Values{}
if q != "" {
live.Set("q", q)
}
// The default sort stays out of the URL: including it would
// make every plain home page hook a parameterised stream
// for no difference in what it shows.
if sortBy != "" && sortBy != "name" {
live.Set("sort", sortBy)
}
sse := "/fleet.sse"
if len(live) > 0 {
sse += "?" + live.Encode()
}
data.LiveAttr = template.HTMLAttr(`data-init="@get('` + template.JSEscapeString(sse) + `')"`)
}
render(r, w, req, fleetPage, data)
})
settingsFor := func(req *http.Request, repo string) (desc string, private bool) {
out, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}})
if err != nil {
return "", false
}
var info repoInfo
remarshal(out, &info)
return info.Description, info.Private
}
mux.HandleFunc("GET /r/{repo}/settings", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
if !ok {
return
}
desc, private := settingsFor(req, repo)
render(r, w, req, settingsPage, pageData{
Title: repo, Repo: repo, RepoDescription: desc, RepoPrivate: private,
CloneURL: cloneURL(req, repo), Deletable: deletable(r, req, repo),
})
})
mux.HandleFunc("POST /r/{repo}/settings/remove", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
if !ok {
return
}
if _, err := invoke(req.Context(), r, "repo.remove", url.Values{"name": {repo}}); err != nil {
fail(r, w, req, err)
return
}
http.Redirect(w, req, "/", http.StatusSeeOther)
})
mux.HandleFunc("POST /r/{repo}/settings/delete", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
if !ok {
return
}
_, err := invoke(req.Context(), r, "repo.delete",
url.Values{"name": {repo}, "confirm": {req.FormValue("confirm")}})
if err != nil {
desc, private := settingsFor(req, repo)
renderStatus(r, w, req, settingsPage, pageData{
Title: repo, Repo: repo, RepoDescription: desc, RepoPrivate: private,
CloneURL: cloneURL(req, repo), Deletable: deletable(r, req, repo),
Error: err.Error(),
}, http.StatusBadRequest)
return
}
http.Redirect(w, req, "/", http.StatusSeeOther)
})
mux.HandleFunc("POST /r/{repo}/settings", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
if !ok {
return
}
form := url.Values{"name": {repo}, "description": {req.FormValue("description")}}
if req.FormValue("private") == "true" {
form.Set("private", "true")
}
data := pageData{Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
RepoDescription: req.FormValue("description"),
RepoPrivate: req.FormValue("private") == "true"}
if _, err := invoke(req.Context(), r, "repo.update", form); err != nil {
// The form comes back holding what the reader typed, with
// the reason — not an error page that discards it.
data.Error = err.Error()
renderStatus(r, w, req, settingsPage, data, http.StatusBadRequest)
return
}
data.Saved = true
render(r, w, req, settingsPage, data)
})
mux.HandleFunc("GET /new", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
render(r, w, req, newRepoPage, pageData{Title: "new repository"})
})
mux.HandleFunc("POST /new", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
form := url.Values{
"name": {req.FormValue("name")},
"description": {req.FormValue("description")},
}
if req.FormValue("private") == "true" {
form.Set("private", "true")
}
if req.FormValue("kind") == "clone" {
form.Set("clone", req.FormValue("from"))
} else {
form.Set("create", "true")
// Seeding applies to a new repository only; a clone arrives
// with its own history and the op refuses the combination.
if req.FormValue("readme") == "true" {
form.Set("readme", "true")
}
if req.FormValue("ignore") == "true" {
form.Set("ignore", "true")
}
form.Set("license", req.FormValue("license"))
}
out, err := invoke(req.Context(), r, "repo.add", form)
if err != nil {
// A rejected form comes BACK as a form with the reason on it,
// not as an error page: the reader has a correction to make,
// and an error page throws their input away.
renderStatus(r, w, req, newRepoPage,
pageData{Title: "new repository", Error: err.Error()}, http.StatusBadRequest)
return
}
var added struct {
Name string `json:"name"`
}
remarshal(out, &added)
http.Redirect(w, req, "/r/"+added.Name, http.StatusSeeOther)
})
// The fleet admin pages are gated at Admin with an EMPTY repo: they
// are not scoped to a store, and a site admin is the only role for
// which "every store's health" is a meaningful question.
mux.HandleFunc("GET /fleet", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
fleetHealth(r, w, req, nil, nil)
})
mux.HandleFunc("POST /fleet/sync", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
out, err := invoke(req.Context(), r, "fleet.sync",
url.Values{"repo": {req.FormValue("repo")}})
if err != nil {
fail(r, w, req, err)
return
}
var fs struct {
Results []fleetSyncRow `json:"results"`
}
remarshal(out, &fs)
// Rendered in place rather than redirected: the results ARE the
// answer to pressing the button, and a redirect would drop them.
fleetHealth(r, w, req, fs.Results, nil)
})
mux.HandleFunc("POST /fleet/mirror", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
if _, err := invoke(req.Context(), r, "mirror.set", url.Values{
"repo": {req.FormValue("repo")}, "url": {req.FormValue("url")},
"every": {req.FormValue("every")},
}); err != nil {
fail(r, w, req, err)
return
}
http.Redirect(w, req, "/fleet", http.StatusSeeOther)
})
mux.HandleFunc("POST /fleet/mirror/delete", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
if _, err := invoke(req.Context(), r, "mirror.delete",
url.Values{"repo": {req.FormValue("repo")}}); err != nil {
fail(r, w, req, err)
return
}
http.Redirect(w, req, "/fleet", http.StatusSeeOther)
})
mux.HandleFunc("POST /fleet/mirror/run", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
out, err := invoke(req.Context(), r, "mirror.run",
url.Values{"repo": {req.FormValue("repo")}})
if err != nil {
fail(r, w, req, err)
return
}
var mr struct {
Results []mirrorRunRow `json:"results"`
}
remarshal(out, &mr)
fleetHealth(r, w, req, nil, mr.Results)
})
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 {
// A repository with no check-ins has an unborn trunk, so the
// listing legitimately finds nothing — and answering 404
// tells its owner the repository does not exist, moments
// after they created it. Distinguish the two by asking the
// index whether this repository is merely empty.
if isEmptyRepo(r, req, repo) {
desc, private := settingsFor(req, repo)
render(r, w, req, quickStartPage, pageData{
Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
RepoDescription: desc, RepoPrivate: private,
})
return
}
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)
// tcl has 1,855 branches and sqlite 1,747. Rendering every
// one is both an unusable control and 90% of the page's
// weight — 239KB of <option>. Show the recently active ones,
// which branch.list already returns first.
const shown = 25
data.Branches = bl.Branches
if len(bl.Branches) > shown {
data.Branches = bl.Branches[:shown]
data.MoreBranches = len(bl.Branches) - shown
// The branch being VIEWED must appear even when it is
// not recently active, or the selector silently shows
// the wrong thing for anyone browsing older work.
found := false
for _, b := range data.Branches {
if b.Tip == version {
found = true
break
}
}
if !found {
for _, b := range bl.Branches {
if b.Tip == version {
data.Branches = append([]branchRow{b}, data.Branches...)
break
}
}
}
}
labelBranches(data.Branches)
data.BranchTotal = len(bl.Branches)
// The button says which ref is in view. Falling back to the
// short hash rather than to "trunk" keeps it honest when a
// reader is on a detached check-in.
data.RefName = version[:min(12, len(version))]
for _, b := range data.Branches {
if b.Tip == version {
data.RefName = b.Name
break
}
}
}
if to, err := invoke(req.Context(), r, "tag.list",
url.Values{"repo": {repo}, "limit": {"25"}}); err == nil {
var tl struct {
Tags []tagRow `json:"tags"`
Count int `json:"count"`
}
remarshal(to, &tl)
data.Tags = tl.Tags
data.TagTotal = tl.Count
for _, t := range tl.Tags {
if t.Tip == version {
data.RefName = t.Name
break
}
}
}
// An empty repository is not a broken one — it is waiting for a
// first push. Showing a bare file table says nothing about what
// to do next, which is the only thing its reader wants to know.
data.RepoDescription, data.RepoPrivate = settingsFor(req, repo)
if len(data.Tree) == 0 && len(data.Crumbs) == 0 {
render(r, w, req, quickStartPage, pageData{
Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
RepoDescription: data.RepoDescription, RepoPrivate: data.RepoPrivate,
})
return
}
// The clone tool is a URL parameter so the choice survives a
// reload and can be linked to. fossil is offered only where the
// store can actually be cloned by it.
data.CloneTool = "zit"
if info, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}}); err == nil {
var ri struct {
FossilClone bool `json:"fossilClone"`
}
remarshal(info, &ri)
data.FossilClone = ri.FossilClone
if ri.FossilClone && req.URL.Query().Get("clone") == "fossil" {
data.CloneTool = "fossil"
}
}
// The crumb trail is this page's heading.
data.HideH1 = true
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}/history/{version}/{path...}", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
version, path := req.PathValue("version"), req.PathValue("path")
out, err := invoke(req.Context(), r, "file.history", url.Values{
"repo": {repo}, "version": {version}, "path": {path},
})
if err != nil {
fail(r, w, req, err)
return
}
var fh struct {
Changes []fileChange `json:"changes"`
}
remarshal(out, &fh)
render(r, w, req, historyPage, pageData{
Title: path, Repo: repo, TreeVersion: version,
HistoryPath: path, History: fh.Changes,
})
})
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)
data := pageData{Title: "ci", Repo: repo, CIRuns: cl.Runs}
if n != nil {
// A run's status changes while nobody is clicking, so the
// page follows it. data-init sits on a WRAPPER, outside the
// element the stream patches — a patch morphs its target to
// match the fragment, and the fragment has no data-init.
data.Live = true
data.LiveAttr = template.HTMLAttr(`data-init="@get('/r/` + repo + `/ci.sse')"`)
}
render(r, w, req, ciPage, data)
})
mux.HandleFunc("GET /r/{repo}/ci.sse", func(w http.ResponseWriter, req *http.Request) {
if n == nil {
http.NotFound(w, req)
return
}
repo := req.PathValue("repo")
liveCI(r, repo, w, req)
})
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")
closed := req.URL.Query().Get("closed") != ""
vals := url.Values{"repo": {repo}, "before": {req.URL.Query().Get("before")}}
if closed {
vals.Set("closed", "1")
}
out, err := invoke(req.Context(), r, "ticket.list", vals)
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,
ShowClosed: closed,
})
})
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 /search", func(w http.ResponseWriter, req *http.Request) {
q := req.URL.Query().Get("q")
kind := req.URL.Query().Get("kind")
if kind != "" && !slices.Contains(index.SearchKinds, kind) {
fail(r, w, req, fmt.Errorf("no such search scope %q: %w", kind, ops.ErrNotFound))
return
}
_, scope := searchScopeName(kind)
only := req.URL.Query().Get("repo")
data := pageData{Title: "search", Query: q, SearchKind: kind, SearchScope: scope,
RepoFilter: only}
if q != "" {
vals := url.Values{"q": {q}}
if kind != "" {
vals.Set("kind", kind)
}
if only != "" {
vals.Set("repo", only)
}
out, err := invoke(req.Context(), r, "search.all", vals)
if err != nil {
fail(r, w, req, err)
return
}
var sr struct {
Results []struct {
Repo string `json:"repo"`
Kind string `json:"kind"`
Ref string `json:"ref"`
Label string `json:"label"`
Snippet string `json:"snippet"`
} `json:"results"`
Searched int `json:"searched"`
ByRepo []struct {
Repo string `json:"repo"`
Count int `json:"count"`
} `json:"by_repo"`
}
remarshal(out, &sr)
data.Searched = sr.Searched
for _, b := range sr.ByRepo {
data.ByRepo = append(data.ByRepo, repoFacet{Repo: b.Repo, Count: b.Count})
}
for _, res := range sr.Results {
h := hitOf(res.Repo, res.Kind, res.Ref, res.Label, res.Snippet)
h.Repo = res.Repo
data.Results = append(data.Results, h)
}
}
render(r, w, req, globalSearchPage, data)
})
mux.HandleFunc("GET /r/{repo}/search", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
q := req.URL.Query().Get("q")
kind := req.URL.Query().Get("kind")
if kind != "" && !slices.Contains(index.SearchKinds, kind) {
// An unknown scope is a bad URL, not an empty result: saying
// "no results" would blame the query for a typo in the link.
fail(r, w, req, fmt.Errorf("no such search scope %q: %w", kind, ops.ErrNotFound))
return
}
_, scope := searchScopeName(kind)
data := pageData{Title: "search", Repo: repo, Query: q,
SearchKind: kind, SearchScope: scope}
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),
})
})
// Archive download. Same posture as an attachment: a download, never
// an inline render, with the stored content type discarded — a
// tarball is bytes a reader saves, not a document a browser should
// try to interpret.
mux.HandleFunc("GET /r/{repo}/archive/{version}", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
format := req.URL.Query().Get("format")
out, err := invoke(req.Context(), r, "archive.get", url.Values{
"repo": {repo}, "version": {req.PathValue("version")}, "format": {format},
})
if err != nil {
fail(r, w, req, err)
return
}
var ad struct {
Name string `json:"name"`
Version string `json:"version"`
Content string `json:"content"`
}
remarshal(out, &ad)
raw, err := base64.StdEncoding.DecodeString(ad.Content)
if err != nil {
fail(r, w, req, err)
return
}
ctype := "application/gzip"
if strings.HasSuffix(ad.Name, ".zip") {
ctype = "application/zip"
}
// The archive is a pure function of a resolved check-in and zit
// guarantees the bytes are reproducible, so this entity can
// never change and the ETag is the check-in itself. A reader who
// already has it gets a 304 and downloads nothing — which is
// worth more than any server-side cache, because the bytes never
// leave the machine at all.
etag := `"` + ad.Version + "-" + format + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if match := req.Header.Get("If-None-Match"); match == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("X-Content-Type-Options", "nosniff")
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)
})
// 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,
CIBadge: ciBadge(cd.CI),
})
})
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 withTimer(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) {
// Only the listing's own parameters are forwarded; anything else in
// the query string is a caller's invention and must not reach an op.
view := url.Values{}
for _, k := range []string{"q", "sort"} {
if v := req.URL.Query().Get(k); v != "" {
view.Set(k, v)
}
}
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, view)
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, view url.Values) (string, error) {
out, err := invoke(context.Background(), r, "repo.list", view)
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,
Query: view.Get("q"), Sort: view.Get("sort")}); 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
data.IsAdmin = id.isAdmin
}
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
if c, err := req.Cookie(themeCookie); err == nil && (c.Value == "light" || c.Value == "dark") {
data.Theme = c.Value
}
data.NextTheme, data.ThemeIcon, data.ThemeName = themeState(data.Theme)
if start, ok := req.Context().Value(ctxStart{}).(time.Time); ok {
data.Elapsed = timing.Format(time.Since(start))
}
// Read the breakdown LAST: the ops this page invoked have all run by
// now, so the accumulator is complete.
data.Breakdown = timing.From(req.Context()).Breakdown()
// The nav belongs to the layout, not to any one handler, so its
// counts and its current-section marker are filled in here rather
// than in each of the thirty-odd places that render a repo page.
// A counts failure is NOT fatal: the nav degrades to bare labels,
// which is what it showed before counts existed.
if data.Repo != "" {
data.Section = sectionOf(req.URL.Path)
// Derived in the one place every page passes through, so a new page
// gets a correctly scoped search box without knowing it exists. A
// handler that already set a scope keeps it: the results page holds
// the scope the reader is actually searching in, which is not the
// one its own section would imply.
if data.SearchScope == "" {
data.SearchKind, data.SearchScope = searchContext(data.Section)
}
if co, err := invoke(req.Context(), r, "repo.counts",
url.Values{"repo": {data.Repo}}); err == nil {
remarshal(co, &data.Counts)
data.HasCounts = true
} else {
log.Printf("web: counts for %s: %v", data.Repo, err)
}
}
// 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).
// fail renders an op error, mapping the op layer's error kinds the way
// the API router already does. Only ErrNotFound was mapped, so every
// other kind rendered as 500 — and a 500 tells a client the request
// failed, not that it should try again. Two paths depended on the
// distinction and lost it: a single-writer lease refusal (0001 §3),
// which is explicitly retryable, and a shed from a saturated work lane,
// which is retryable by construction.
func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) {
switch {
case errors.Is(err, ops.ErrNotFound):
renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound)
case errors.Is(err, ops.ErrInvalid):
renderStatus(r, w, req, errorPage, pageData{Title: "bad request"}, http.StatusBadRequest)
case errors.Is(err, ops.ErrUnavailable):
// Retry-After makes the refusal actionable rather than merely
// polite: a client that backs off is what keeps a shed from
// becoming a retry storm.
w.Header().Set("Retry-After", "5")
renderStatus(r, w, req, busyPage, pageData{Title: "busy"}, http.StatusServiceUnavailable)
default:
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 ctxStart struct{}
// withTimer stamps request start so the layout can report render time.
// Outermost of the middlewares, so the number covers everything the
// server does for the request, not just the part after auth.
func withTimer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
ctx, _ := timing.With(req.Context())
next.ServeHTTP(w, req.WithContext(
context.WithValue(ctx, ctxStart{}, time.Now())))
})
}
type ctxIdentity struct{}
type identity struct {
user string
canSignIn bool
hasInbox bool
// isAdmin drives the fleet link in the header. It is a DISPLAY
// decision only — /fleet gates itself, so a wrong answer here hides
// or shows a link, never grants access.
isAdmin 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
// Empty repo at Admin is the site-admin question — the same
// one /fleet asks.
id.isAdmin = sessions.Allow(user, "", ops.Admin)
}
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
}
// ciFragment renders the CI table for a repo — shared by the page and
// the stream, so a patch cannot drift from what a reload would draw.
func ciFragment(r *ops.Registry, repo string) (string, error) {
out, err := invoke(context.Background(), r, "ci.list", url.Values{"repo": {repo}})
if err != nil {
return "", err
}
var cl struct {
Runs []ciRunRow `json:"runs"`
}
remarshal(out, &cl)
var buf bytes.Buffer
if err := ciPage.ExecuteTemplate(&buf, "ci-body",
pageData{Repo: repo, CIRuns: cl.Runs}); err != nil {
return "", err
}
return `<div id="ci">` + buf.String() + `</div>`, nil
}
// liveCI streams the CI table. Unlike the timeline, a CI page changes
// without any artifact arriving — a run finishes inside dagnats — so
// this polls rather than waiting on the event plane, and sends only
// when the rendered fragment actually differs.
func liveCI(r *ops.Registry, 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()
poll := time.NewTicker(3 * time.Second)
defer poll.Stop()
heartbeat := time.NewTicker(20 * time.Second)
defer heartbeat.Stop()
last := ""
for {
select {
case <-req.Context().Done():
return
case <-heartbeat.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
case <-poll.C:
frag, err := ciFragment(r, repo)
if err != nil || frag == last {
continue
}
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()
}
}
}