// 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 (
"runtime/debug"
"bytes"
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"html/template"
"io"
"log"
"net/http"
"net/url"
"reflect"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/diff"
"craftdesign.group/quarry/internal/errlog"
"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
//go:embed assets/composer.js
var composerJS []byte
//go:embed assets/wiki.js
var wikiJS []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.
// weekday is the UTC day-of-week of a stored date — the same
// convention zit's --weekday prints (zit#111), computed from the same
// UTC timestamps, so the CLI and the forge always name the same day.
func weekday(s string) string {
for _, layout := range []string{"2006-01-02T15:04:05.000", "2006-01-02T15:04:05",
"2006-01-02T15:04:05.999999999", "2006-01-02"} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC().Format("Mon")
}
}
return ""
}
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(``,
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:
// Once the day is no longer today, NAMING it adds what the
// count alone cannot — "Thu · 3 days ago" answers "which day
// was that" in place. The zit#111 convention, forge-wide: UTC
// weekday, so the CLI and these pages never disagree.
//
// The count is CALENDAR days, not elapsed-hours/24: naming the
// weekday exposed the rounding — a Tuesday event 55 hours before
// a Friday page read "Tue · 2 days ago", and Tuesday is not two
// days before Friday in any human's reckoning.
day := func(x time.Time) time.Time {
return time.Date(x.Year(), x.Month(), x.Day(), 0, 0, 0, 0, time.UTC)
}
days := int(day(now).Sub(day(t)).Hours() / 24)
if days < 1 {
days = 1
}
return t.Format("Mon") + " · " + plural(days, "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("Mon · 2 Jan")
}
return t.Format("Mon · 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(``,
template.HTMLEscapeString(t.Format(time.RFC3339)),
template.HTMLEscapeString(t.Format("2006-01-02 15:04:05 UTC")),
out))
}
// boardColumn is one board lane: the label as the tracker spells it,
// and the tickets currently in it.
// boardMove is one card move as the board reports it afterwards: what
// moved, where, and where it came from so undo is one post.
type boardMove struct {
ID, Title, Field, From, To string
}
type boardColumn struct {
Status string
Tickets []ticketSummary
// Expanded: drawn at full width; otherwise a strip. Folding is the
// reader's choice, carried in the URL (?fold=A,B) so it survives a
// reload and can be linked; with no choice yet, empty columns fold.
// ToggleURL is this view with this column's fold flipped — the
// chevron in the header, both ways.
Expanded bool
ToggleURL string
}
// boardFields are the ticket fields a board may be grouped by. The
// WORDS for each come from the repository's vocabulary (ticket.config:
// .quarry/tickets.json over fossil's defaults), passed in by the
// handler — the vocabulary is the project's, not this table's.
//
// The vocabulary draws lanes that hold NO tickets. A board whose
// columns are only the values already in use is a report, not a board:
// quarry's own tracker had only ever written "Open", so it drew one
// column and the move control could only move a ticket to where it
// already was. Observed values outside the vocabulary still get their
// own lanes rather than being dropped.
var boardFields = []struct {
Key, Label string
}{
{"status", "status"},
{"priority", "priority"},
{"severity", "severity"},
{"type", "type"},
{"subsystem", "subsystem"},
}
func boardAxis(key string) bool {
for _, f := range boardFields {
if f.Key == key {
return true
}
}
return false
}
// fieldOf reads one groupable field off a summary.
func fieldOf(t ticketSummary, key string) string {
switch key {
case "priority":
return t.Priority
case "severity":
return t.Severity
case "type":
return t.Type
case "subsystem":
return t.Subsystem
default:
return t.Status
}
}
// ticketState reads the tracker's state axis out of a URL. A value the
// op would reject folds back to the default instead of 400ing a page
// somebody reached by editing the address bar, and "open" normalises to
// empty so the one default state has exactly one URL.
// ticketAxes are the fields a report may narrow on. The URL IS the
// report: these six keys plus state, view and sort reproduce any
// listing exactly, which is why saved reports need no new storage and
// no new query language — just a link.
var ticketAxes = []string{"type", "severity", "priority", "subsystem", "status", "author"}
// ticketAxesAcross adds the axis that only the cross-repository listing
// has. Repository is addressing on a tracker page and a facet here, so
// it belongs to this list alone.
var ticketAxesAcross = append(append([]string{}, ticketAxes...), "repo")
// acrossLimit bounds the merged listing. K trackers merged in memory
// cannot be paged by a cursor that encodes one tracker's position, so
// the page takes a bounded slice and says when there is more.
const acrossLimit = 100
// ticketTokens are the query box's field prefixes. "is" spells state
// the way every forge's search box does; the rest name their axis
// outright. Both spellings reach the same URL parameters, which is the
// whole point: the box is INPUT SUGAR over the report, not a second
// query system with its own semantics to keep in step.
var ticketTokens = map[string]string{
"is": "state", "state": "state",
"type": "type", "severity": "severity", "priority": "priority",
"subsystem": "subsystem", "status": "status", "author": "author",
}
// splitQuery breaks a query box into words, honouring double quotes so
// a value with a space in it survives (subsystem:"web ui").
func splitQuery(q string) []string {
var out []string
var cur strings.Builder
quoted := false
flush := func() {
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
}
for _, r := range q {
switch {
case r == '"':
quoted = !quoted
case !quoted && (r == ' ' || r == '\t'):
flush()
default:
cur.WriteRune(r)
}
}
flush()
return out
}
// ticketParseQuery turns a query box into the URL parameters that ARE
// the report. Recognised prefixes become their axis; everything else is
// free text matched against titles. An unknown prefix is not an error
// and not silently dropped either — it stays part of the text, because
// a reader who types "fix:later" meant those characters.
func ticketParseQuery(q string) url.Values {
v := url.Values{}
var text []string
for _, w := range splitQuery(q) {
key, val, ok := strings.Cut(w, ":")
axis, known := ticketTokens[strings.ToLower(key)]
if ok && known && val != "" {
if axis == "state" {
val = strings.ToLower(val)
}
v.Set(axis, val)
continue
}
text = append(text, w)
}
if t := strings.Join(text, " "); t != "" {
v.Set("title", t)
}
return v
}
// ticketQueryString is ticketParseQuery run backwards: the report as a
// reader would have typed it, so the box shows the listing they are
// looking at however they got there — dropdown, link, or typing.
func ticketQueryString(state string, report url.Values, title string) string {
var out []string
if state != "" && state != "open" {
out = append(out, "is:"+state)
}
for _, k := range ticketAxes {
if v := report.Get(k); v != "" {
if strings.ContainsAny(v, " \t") {
v = `"` + v + `"`
}
out = append(out, k+":"+v)
}
}
if title != "" {
out = append(out, title)
}
return strings.Join(out, " ")
}
// ticketReport pulls the report axes out of a URL, ignoring everything
// else, so a link may carry fold state or a cursor without those ever
// reaching the query as a filter.
func ticketReport(q url.Values) url.Values { return ticketReportOf(q, ticketAxes) }
// ticketReportAcross reads the cross-repository listing's report, which
// is the tracker's axes plus the repository.
func ticketReportAcross(q url.Values) url.Values { return ticketReportOf(q, ticketAxesAcross) }
func ticketReportOf(q url.Values, axes []string) url.Values {
r := url.Values{}
for _, k := range axes {
if v := strings.TrimSpace(q.Get(k)); v != "" {
r.Set(k, v)
}
}
return r
}
func ticketState(q url.Values) string {
switch s := q.Get("state"); s {
case "closed", "all":
return s
default:
return ""
}
}
// boardData fetches the tickets and lays out the board. The page and the
// move endpoint share it so a drag and a reload cannot disagree about
// what the board looks like.
// boardData takes the nav the caller has already assembled and returns
// it completed — the axes in, the counts filled. One parameter object
// rather than six positional strings, because every one of them is an
// axis of the same view and they were drifting apart at the call sites.
func boardData(r *ops.Registry, req *http.Request, nav ticketNav) (pageData, error) {
repo, group, lane := nav.Repo, nav.Group, nav.Lane
vals := url.Values{"repo": {repo}, "limit": {strconv.Itoa(boardLimit)}}
if nav.State != "" {
vals.Set("state", nav.State)
}
if nav.Title != "" {
vals.Set("title", nav.Title)
}
for k, vs := range nav.Report {
vals[k] = vs
}
out, err := invoke(req.Context(), r, "ticket.list", vals)
if err != nil {
return pageData{}, err
}
var tl struct {
Tickets []ticketSummary `json:"tickets"`
Open int `json:"open"`
Closed int `json:"closed"`
Subsystems []ticketFacet `json:"subsystems"`
Authors []ticketFacet `json:"authors"`
}
remarshal(out, &tl)
if !boardAxis(group) {
group = "status"
}
// An explicit lane choice is honoured, including "none". Otherwise
// pick one that actually cuts this set: a board whose lane field
// every ticket shares spends a row of chrome saying nothing.
switch {
case lane == "none":
lane = ""
case lane == "":
// No lanes unless asked: a board that opens with four priority
// swimlanes nobody chose is logic the reader cannot see.
lane = ""
default:
if !boardAxis(lane) {
lane = ""
}
}
vocab := ticketVocabFor(r, req, repo)
board := buildBoard(tl.Tickets, group, lane, vocab)
// Which columns are folded is the reader's choice, carried in the
// URL so it survives a reload and can be linked. Before any choice
// is made the empty columns fold, which is what keeps seven status
// columns inside the window on the default (open) view. Each column
// gets the URL that flips its own state, for the chevron.
q := req.URL.Query()
folded := map[string]bool{}
explicit := q.Has("fold")
for _, c := range strings.Split(q.Get("fold"), ",") {
if c = strings.TrimSpace(c); c != "" {
folded[strings.ToLower(c)] = true
}
}
base := url.Values{"view": {"board"}, "group": {group}}
if nav.State != "" {
base.Set("state", nav.State)
}
if nav.Title != "" {
base.Set("title", nav.Title)
}
for k, vs := range nav.Report {
base[k] = vs
}
if lane != "" {
base.Set("lane", lane)
} else {
base.Set("lane", "none")
}
for li := range board {
for ci := range board[li].Columns {
col := &board[li].Columns[ci]
key := strings.ToLower(col.Status)
if explicit {
col.Expanded = !folded[key]
} else {
col.Expanded = len(col.Tickets) > 0
}
// The fold list after flipping this column; when nothing was
// explicit yet, the implicit folds become explicit so the
// other columns keep their state.
next := map[string]bool{}
for _, l := range board[li].Columns {
k := strings.ToLower(l.Status)
if explicit {
next[k] = folded[k]
} else {
next[k] = len(l.Tickets) == 0
}
}
next[key] = col.Expanded
var list []string
for _, l := range board[li].Columns {
if next[strings.ToLower(l.Status)] {
list = append(list, l.Status)
}
}
v := url.Values{}
for k, vs := range base {
v[k] = vs
}
v.Set("fold", strings.Join(list, ","))
col.ToggleURL = "?" + v.Encode()
}
}
data := pageData{Title: "tickets", Repo: repo,
BoardView: true, GroupBy: group, LaneBy: lane, Vocab: vocab,
Nav: ticketNav{Repo: repo, State: nav.State, View: "board",
Open: tl.Open, Closed: tl.Closed, Group: group, Lane: lane,
Report: nav.Report, Title: nav.Title},
Filters: ticketFilters(vocab, tl.Subsystems, tl.Authors, req.URL.Query()),
Shown: len(tl.Tickets), Truncated: len(tl.Tickets) >= boardLimit,
Board: board,
ColumnLabels: columnLabels(tl.Tickets, group, vocab),
}
for _, f := range boardFields {
data.AxisFields = append(data.AxisFields,
struct{ Key, Label string }{f.Key, f.Label})
}
return data, nil
}
// ticketVocabFor asks ticket.config for this repository's vocabulary.
// On ANY failure it answers with the defaults: the vocabulary shapes
// ordering and suggestions, so the wrong words are an inconvenience
// while an error page here would make a typo in .quarry/tickets.json
// take the whole tracker surface down.
func ticketVocabFor(r *ops.Registry, req *http.Request, repo string) map[string][]string {
out, err := invoke(req.Context(), r, "ticket.config", url.Values{"repo": {repo}})
if err != nil {
return api.DefaultTicketVocabulary
}
var cfg struct {
Vocab []struct {
Field string `json:"field"`
Words []string `json:"words"`
} `json:"vocab"`
Problem string `json:"problem"`
}
remarshal(out, &cfg)
if cfg.Problem != "" {
log.Printf("web: %s: %s ignored: %s", repo, ".quarry/tickets.json", cfg.Problem)
}
if len(cfg.Vocab) == 0 {
return api.DefaultTicketVocabulary
}
vocab := make(map[string][]string, len(cfg.Vocab))
for _, f := range cfg.Vocab {
vocab[f.Field] = f.Words
}
return vocab
}
// boardLimit bounds what one board draws. It is the op's own ceiling:
// a board is a working surface, not the tracker's archive, and 729 open
// tickets in seven columns is a scroll, not a plan.
const boardLimit = 200
// triageRank orders a vocabulary word by its position in the board
// field's word list; words the vocabulary does not know sort after it,
// and an unset value after those — triage you have not done yet is not
// more urgent than triage you have.
func triageRank(vocabAll map[string][]string, field, value string) int {
vocab := vocabAll[field]
v := strings.TrimSpace(value)
if v == "" {
return len(vocab) + 1
}
for i, w := range vocab {
if strings.EqualFold(w, v) {
return i
}
}
return len(vocab)
}
// byTriage sorts a page of summaries priority-first, severity as the
// tiebreak, then OLDEST first. It sorts the PAGE, not the tracker: the
// list op pages by updated, so this is only offered on the first page,
// where it covers everything up to the op's limit.
//
// Recency is deliberately not in the key. A ticket's position in the
// priority view should change only when a human changes its triage —
// with updated as the tiebreak, commenting on any ticket reshuffled its
// whole tier, and a bulk edit scrambled every tier at once. Equal triage
// is instead a queue: the ticket that has waited longest is first, so
// the tail of each tier is not a race to have been touched last.
func byTriage(ts []ticketSummary, vocab map[string][]string) {
sort.SliceStable(ts, func(i, j int) bool {
if a, b := triageRank(vocab, "priority", ts[i].Priority), triageRank(vocab, "priority", ts[j].Priority); a != b {
return a < b
}
if a, b := triageRank(vocab, "severity", ts[i].Severity), triageRank(vocab, "severity", ts[j].Severity); a != b {
return a < b
}
if ts[i].Created != ts[j].Created {
return ts[i].Created < ts[j].Created
}
return ts[i].ID < ts[j].ID
})
}
// byTriageRows is byTriage over decorated rows: the same vocabulary
// ordering, applied where the cross-repository listing has already
// merged K trackers into one slice.
func byTriageRows(rows []ticketRow, vocab map[string][]string) {
sort.SliceStable(rows, func(i, j int) bool {
if a, b := triageRank(vocab, "priority", rows[i].Priority), triageRank(vocab, "priority", rows[j].Priority); a != b {
return a < b
}
if a, b := triageRank(vocab, "severity", rows[i].Severity), triageRank(vocab, "severity", rows[j].Severity); a != b {
return a < b
}
if rows[i].Created != rows[j].Created {
return rows[i].Created < rows[j].Created
}
return rows[i].ID < rows[j].ID
})
}
// unsetLane labels the column or lane for tickets the tracker left
// blank. It is not a value any tracker writes, so it cannot collide.
const unsetLane = "unset"
// boardLane is one horizontal swimlane: a label and the same column set
// every other lane has, so the board reads as a grid.
type boardLane struct {
Label string
Count int
Columns []boardColumn
}
// buildBoard arranges tickets into columns (the workflow, read left to
// right) crossed by swimlanes (the cut, read top to bottom).
//
// A kanban with only columns answers "what stage is this in". The lane
// is what makes it answer "and which of these actually matters" without
// filtering anything out of view — the reason a board beats a list.
//
// laneKey == "" means one implicit lane holding everything.
func buildBoard(tickets []ticketSummary, groupKey, laneKey string, vocab map[string][]string) []boardLane {
if !boardAxis(groupKey) {
groupKey = "status"
}
if groupKey == laneKey {
laneKey = "" // crossing a field with itself yields a diagonal
}
// Every lane gets the SAME columns, including columns that are empty
// in that lane: a grid whose rows have different columns is not a
// grid, and a card cannot be dropped into a lane that isn't drawn.
labels := columnLabels(tickets, groupKey, vocab)
if laneKey == "" {
return []boardLane{{Count: len(tickets), Columns: fill(tickets, groupKey, labels)}}
}
byLane := map[string][]ticketSummary{}
var order []string
for _, want := range laneValues(tickets, laneKey, vocab) {
byLane[want] = nil
order = append(order, want)
}
for _, t := range tickets {
v := strings.TrimSpace(fieldOf(t, laneKey))
if v == "" {
v = unsetLane
}
byLane[v] = append(byLane[v], t)
}
out := make([]boardLane, 0, len(order))
for _, lane := range order {
ts := byLane[lane]
// A lane nothing is in is noise, not an affordance: cards move
// between COLUMNS, so an empty lane can never be a drop target.
if len(ts) == 0 {
continue
}
out = append(out, boardLane{Label: lane, Count: len(ts),
Columns: fill(ts, groupKey, labels)})
}
return out
}
// laneValues orders the lane labels: vocabulary order for words the
// tracker's vocabulary knows, then the rest by size, unset last.
func laneValues(tickets []ticketSummary, key string, vocabAll map[string][]string) []string {
vocab := vocabAll[key]
rank, count := map[string]int{}, map[string]int{}
label := map[string]string{}
for i, w := range vocab {
rank[strings.ToLower(w)] = i
}
for _, t := range tickets {
v := strings.TrimSpace(fieldOf(t, key))
if v == "" {
v = unsetLane
}
k := strings.ToLower(v)
count[k]++
if label[k] == "" || v < label[k] {
label[k] = v
}
}
keys := make([]string, 0, len(count))
for k := range count {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
ui, uj := keys[i] == unsetLane, keys[j] == unsetLane
if ui != uj {
return uj
}
ri, ok1 := rank[keys[i]]
rj, ok2 := rank[keys[j]]
if ok1 != ok2 {
return ok1
}
if ok1 && ri != rj {
return ri < rj
}
if count[keys[i]] != count[keys[j]] {
return count[keys[i]] > count[keys[j]]
}
return keys[i] < keys[j]
})
out := make([]string, len(keys))
for i, k := range keys {
out[i] = label[k]
}
return out
}
// columnLabels is the column set, in the order the board draws it.
//
// Grouping is case-folded because a tracker's vocabulary drifts: quarry's
// own store holds both "Open" and "open", which ungrouped would draw two
// columns where there is one. The label shown is the spelling that
// appears most often, so the board reads the way the tracker writes —
// except for a column that is empty, which can only be spelled the way
// the vocabulary spells it.
func columnLabels(tickets []ticketSummary, key string, vocabAll map[string][]string) []string {
vocab := vocabAll[key]
spellings := map[string]map[string]int{}
rank, count := map[string]int{}, map[string]int{}
add := func(v string) map[string]int {
k := strings.ToLower(strings.TrimSpace(v))
if spellings[k] == nil {
spellings[k] = map[string]int{}
rank[k] = len(vocab)
}
return spellings[k]
}
for i, w := range vocab {
add(w)[w]++
rank[strings.ToLower(w)] = i
}
for _, t := range tickets {
v := strings.TrimSpace(fieldOf(t, key))
if v == "" {
v = unsetLane
}
add(v)[v]++
count[strings.ToLower(v)]++
}
keys := make([]string, 0, len(spellings))
for k := range spellings {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
ui, uj := keys[i] == unsetLane, keys[j] == unsetLane
if ui != uj {
return uj
}
if rank[keys[i]] != rank[keys[j]] {
return rank[keys[i]] < rank[keys[j]]
}
if count[keys[i]] != count[keys[j]] {
return count[keys[i]] > count[keys[j]]
}
return keys[i] < keys[j]
})
out := make([]string, len(keys))
for i, k := range keys {
best := -1
for spelling, n := range spellings[k] {
if n > best || (n == best && spelling < out[i]) {
out[i], best = spelling, n
}
}
}
return out
}
// fill drops tickets into a fixed column set.
func fill(tickets []ticketSummary, key string, labels []string) []boardColumn {
at := map[string]int{}
cols := make([]boardColumn, len(labels))
for i, l := range labels {
cols[i] = boardColumn{Status: l}
at[strings.ToLower(strings.TrimSpace(l))] = i
}
for _, t := range tickets {
v := strings.TrimSpace(fieldOf(t, key))
if v == "" {
v = unsetLane
}
if i, ok := at[strings.ToLower(v)]; ok {
cols[i].Tickets = append(cols[i].Tickets, t)
}
}
return cols
}
// 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 — "Design Note:
// 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}>`)
// firstLine is a commit message's subject: everything before the first
// newline. A feed row is one line, and rendering the whole message
// there turns a list into a wall.
func firstLine(s string) string {
first, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return plainTitle(first)
}
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(
`
`+
``+
`%s
`,
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(`%s`, 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.
// thousands renders 54108 as 54,108 — a position in a diff wants the
// exact number, not the compact one the counts use.
func thousands(n int) string {
s := strconv.Itoa(n)
if len(s) <= 3 {
return s
}
var b strings.Builder
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(c)
}
return b.String()
}
// dur renders an interval in the unit it was meant in: 3600 is "1h",
// 86400 is "24h", 90 is "90s".
func dur(seconds int) string {
switch {
case seconds%86400 == 0 && seconds >= 86400*2:
return strconv.Itoa(seconds/86400) + "d"
case seconds%3600 == 0 && seconds >= 3600:
return strconv.Itoa(seconds/3600) + "h"
case seconds%60 == 0 && seconds >= 60:
return strconv.Itoa(seconds/60) + "m"
}
return strconv.Itoa(seconds) + "s"
}
// hostOf is the host of a URL, for a table that has no room for more.
func hostOf(u string) string {
if p, err := url.Parse(u); err == nil && p.Host != "" {
return p.Host
}
return u
}
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, aux ...int) template.HTML {
var class []string
if section == current {
class = append(class, "here")
}
// aux is a fold TIER: tier-1 tabs fold into the "…" menu first as
// the bar narrows, tier-3 last; untiered tabs never fold. Graduated
// rather than all-at-once, so a landscape phone keeps six tabs and
// hides two instead of losing five to a single breakpoint.
if len(aux) > 0 && aux[0] > 0 && aux[0] < 9 {
class = append(class, fmt.Sprintf("aux%d", aux[0]))
}
// Tier 9 never folds by width, but YIELDS its place when a drawer
// tab surfaces as the active section on the narrowest rows.
if len(aux) > 0 && aux[0] == 9 {
class = append(class, "yield")
}
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, ` 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 section == current {
// The underline is its own element so the view transition can
// morph IT alone: naming the whole tab morphed the text, and
// two different-sized labels scale-zoomed into each other. Two
// 2px bars morph as a clean sideways slide.
b.WriteString(``)
}
if hasCounts && counted {
// title carries the exact figure; the badge carries the shape.
fmt.Fprintf(&b, ` %s`, n, compactCount(n))
}
b.WriteString(``)
// 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; --uname-l: 32%;
--mark: #f7dd9a; --add: #116329; --del: #a40e26;
--add-row: #dafbe1; --del-row: #ffebe9; --add-mark: #aceebb; --del-mark: #ffcecb;
/* --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; --uname-l: 70%;
--mark: #665418; --add: #56d364; --del: #ff7b72;
--add-row: #203326; --del-row: #3e2727; --add-mark: #22492c; --del-mark: #5f2f2d;
--surface: #a899841a;
--mark: #665418; --add: #56d364; --del: #ff7b72;
--add-row: #203326; --del-row: #3e2727; --add-mark: #22492c; --del-mark: #5f2f2d;
--surface: #a899841a;
}
}
:root[data-theme="dark"] {
--fg: #f0ece4; --bg: #1d2021; --muted: #bdb6ab; --code-bg: #32302f;
--rule: #504945; --accent: #d99058; --uname-l: 70%;
}
/* 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; }
/* iOS Safari "text autosizing" inflates text inside wide scrollable
blocks — code in a phone-width pre rendered LARGER than the prose
around it. 100% keeps every font at its declared size; this is the
documented opt-out, not a workaround. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }`
var base = template.Must(template.New("base").Funcs(template.FuncMap{
"pathesc": url.PathEscape,
"queryesc": url.QueryEscape,
"splitrows": splitRows,
"plural": func(n int, one, many string) string {
if n == 1 {
return one
}
return many
},
"navlink": navlink,
"ago": ago,
"weekday": weekday,
"identicon": identicon,
"spark": sparkSVG,
"userhue": userHue,
"spectrum": spectrumSVG,
"votectx": func(repo, root, hash string, score, yourVote int) voteCtx {
return voteCtx{Repo: repo, Root: root, Hash: hash, Score: score, YourVote: yourVote}
},
"composerctx": func(repo string, rows int, placeholder string) composerCtx {
return composerCtx{Repo: repo, Rows: rows, Placeholder: placeholder}
},
"composerfocus": func(repo string, rows int, placeholder string, focus bool) composerCtx {
return composerCtx{Repo: repo, Rows: rows, Placeholder: placeholder, Autofocus: focus}
},
"descctx": func(repo string, rows int, value string) composerCtx {
return composerCtx{Repo: repo, Rows: rows, Placeholder: "Describe the ticket (Markdown)", Name: "description", Value: value}
},
"remarkctx": func(repo string, rows int, placeholder string) composerCtx {
return composerCtx{Repo: repo, Rows: rows, Placeholder: placeholder, Name: "comment", Optional: true}
},
"agoShort": agoShort,
"plain": plainTitle,
"firstline": firstLine,
"statusClass": statusClass,
"initials": initials,
"tipcol": tipcol,
"count": countTag,
"kindLabel": kindLabel,
"thousands": thousands,
"fieldOf": fieldOf,
"unsetLane": func() string { return unsetLane },
"dur": dur,
"hostOf": hostOf,
"add": func(a, b int) int { return a + b },
"commentFirst": func(s string) string {
first, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return commentText(first)
},
"sub": func(a, b int) int { return a - b },
"docIcon": func(label string) string {
switch strings.ToLower(label) {
case "readme":
return "i-book"
case "license", "copying":
return "i-law"
case "security":
return "i-shield"
case "contributing":
return "i-people"
}
return "i-file"
},
"docRowLabel": func(label, license string) string {
if strings.EqualFold(label, "license") && license != "" {
return license + " license"
}
return label
},
"scopes": func() any { return searchScopes },
}).Parse(baseHTML))
var baseHTML = `
{{- /* "quarry — quarry" was what the aggregate page called itself. */ -}}
{{.Title}}{{if ne .Title "quarry"}} — quarry{{end}}
{{- /* The mark at 512px, absolute because a scraper fetches it from its
own machine. summary, not summary_large_image: the Q is a square
logo, and the wide card would letterbox it into a thin band. */ -}}
{{- /* "timeline" alone names no repository; a shared link has to say
which one. The repo landing page titles itself with the repo,
which would otherwise read "sqlite — sqlite". */ -}}
{{if .OGDesc}}{{end}}
{{if .Composer}}{{end}}
{{if .WikiTOC}}{{end}}
{{- /* 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. */ -}}
{{- /* The mark replaces the wordmark, but the LINK keeps its name: alt
carries "quarry" so a screen reader, a text browser and a page
with images off all still read the same word the wordmark said.
An image with an empty alt here would leave the link unnamed. */ -}}
{{- /* The mark, drawn in the timeline's own language: a node (the bowl)
with a rail leaving it (the tail), and the check-in dot at its
centre. Inline SVG in currentColor, so one shape serves both
themes and every size with no image asset and no theme swap. */ -}}
{{if .Repo}}
{{else if .SearchAction}}
{{else}}
{{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. */ -}}
{{- /* A person's work is not in one repository. Gitea puts the
cross-repository queues in its top nav and is right to: without a
door, the only way to this page is a ticket already waiting. */ -}}
your tickets{{end}}
{{if .IsAdmin}}fleet{{end}}
{{if .HasInbox}}inbox{{end}}
{{if .SignedIn}}
{{else if .CanSignIn}}sign in{{end}}
{{if .Repo}}
{{.Repo}}
{{- /* Where the repository stands. A mirror says so and names its
upstream, because "can I contribute here?" is the question a
reader has before any other; a detached store keeps the link as
provenance the way a fork says where it came from. */ -}}
{{if .Posture.Mirror.Tracking}}mirror of {{.Posture.Host}}{{if .Posture.Mirror.AcceptWrites}} · accepts writes{{end}}
{{else if .Posture.DetachedFrom}}forked from {{.Posture.Host}}{{end}}
{{- /* GitHub's collapse: at narrow widths the aux sections fold into
this menu so the bar owns its one line instead of wrapping. The
links are duplicated, not moved — the popover lives OUTSIDE the
nav so the view-transition name on the visible tab stays unique,
and a hidden duplicate is inert. */ -}}
{{- /* Staleness as a state, not a timestamp. Lagging stays quiet — a
quiet upstream is normal. Failing is said once, in words a reader
who has never heard of a mirror can use. */ -}}
{{if eq .Posture.Mirror.State "failing"}}
upstream {{.Posture.Host}} unreachable ({{.Posture.Mirror.Failures}} {{plural .Posture.Mirror.Failures "attempt" "attempts"}}){{if .Posture.Mirror.LastOK}}; showing content as of {{ago .Posture.Mirror.LastOK}}{{else}}; nothing has been pulled yet{{end}}
{{end}}
{{end}}
{{if .Counts.IndexBehind}}
Index rebuilding.
History-derived data on this page — timeline, counts, last-change columns — may end before
the repository's real tip. File listings come straight from the store and are current.
{{end}}
{{if .HideH1}}
{{.Title}}
{{else}}
{{.Title}}
{{end}}
{{block "content" .}}{{end}}
{{define "ticket-head"}}
{{- /* The tracker's header, in the order every forge that works puts
it: which lives are listed (with their counts — "open" without a
number answers half the question), then how they are drawn, then
the one action the page exists to start.
State and view are independent axes: filtering to closed and
switching to the board must not undo each other, so every link
here is built by ticketNav from the current state. */ -}}
{{- /* The waiting-on-you view forces its own state, so offering the
three state links would be three controls that cannot work. */ -}}
{{if .Nav.Awaiting}}
{{count .Nav.Open}} waiting on you
{{else}}{{end}}
{{- /* Priority order is a LIST affordance: the table sorts by whichever
column its reader clicked, and the board is grouped, not ordered.
Showing this control on those two would offer a choice neither
view can honour. */ -}}
{{if .Nav.Is "view" ""}}{{end}}
{{- /* Filing needs one tracker to file into, so the cross-repository
listing offers no New: pick a repository first. */ -}}
{{if not .Across}}{{if .Posture.ReadOnly}}New ticket on {{.Posture.Host}} →{{else}}New ticket{{end}}{{end}}
{{end}}
{{define "ticket-filters"}}
{{- /* The report, as native selects in one GET form. Every dropdown is
a URL parameter and the URL is the whole report, so a narrowed
listing is shareable, bookmarkable and scriptable (append .json)
without a query language, saved-search storage, or a line of
state on the server.
onchange submits because a filter you must then click Apply to is
a filter most readers never apply; the form still submits on
Enter with no script at all, and the hidden fields restate the
axes a GET form would otherwise wipe. */ -}}
{{- /* The box and the dropdowns are the same report in two spellings.
They are separate FORMS on purpose: each submits only its own
spelling, the server turns whichever arrives into the canonical
parameters, and neither can contradict the other. */ -}}
{{if .Filters}}{{end}}
{{end}}
{{define "ticket-pager"}}
{{- /* Both ends are always drawn, dead ones as plain text, so the
control keeps its shape and nothing shifts under the pointer
between pages. The position line is what a one-way "older" link
never said: how much of the tracker is behind you. */ -}}
{{if .Pager}}{{end}}
{{end}}
{{define "ticket-rail"}}
{{- /* Ways in. Every entry is an ordinary report URL, so the rail
promises nothing the address bar cannot say and needs no storage
of its own. Clicking the browse entry you are already on removes
that axis, because a filter you cannot see how to leave is a
trap. Hidden on narrow screens: the dropdowns above the listing
reach every one of these values, so the rail is an affordance,
never the only way through. */ -}}
{{if .Rail}}{{end}}
{{end}}
{{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. */ -}}
{{end}}
`
var fleetPage = template.Must(template.Must(base.Clone()).Parse(fleetSource))
const fleetSource = `{{define "fleet-controls"}}
{{- /* Live where the one allowed script runs, a plain GET form where
it does not. The debounced @get uses Datastar's form content
type, so it sends exactly the q= and sort= this form would submit
on Enter — one request shape, one server path, and the listing
follows the keystrokes without a click.
The shell is basecoat's input-group: the border lives on the
wrapper and the controls inside are bare, so the clear button
reads as part of the field rather than as something beside it. */ -}}
{{if .IsAdmin}}New repository{{end}}
{{end}}
{{define "fleet-body"}}
{{- /* The count belongs with the listing, not with the controls: it
DESCRIBES the rows, so it has to be repainted with them or it
goes stale the moment the filter changes.
Filtered, the useful denominator is the fleet rather than the
match count — "1 of 1" is true and says nothing. Unfiltered there
is nothing to compare against, so it is just the total. */ -}}
{{if or .Query .Kind}}{{len .Fleet}} of {{.Served}} repositories{{else if .FleetTotal}}{{.FleetTotal}} repositories{{end}}
{{if .Fleet}}{{- /* Below the width where a header carries its own words the
icons stand alone: the sr-only label names each for a screen
reader and the tooltip for a pointer. A legend used to restate
all four in a row above the header — which read as a duplicate
of the header itself and said nothing the tooltips do not. Touch
readers at narrow widths are the honest gap; the numbers' shapes
(counts, durations) carry most of the meaning there. */ -}}
{{- /* 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 carries a
VISIBLE label wherever the table has room for one, and the legend
above names them where it does not. */ -}}
{{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. */ -}}
{{- /* A repository's name opens the repository itself, which is its
files. The timeline is one of its sections, reachable from the
nav; landing there made every arrival start with what changed
rather than with what the project IS. */ -}}
{{.Name}}{{if .Mirror.Tracking}} mirror{{else if .DetachedFrom}} fork{{end}}{{if .Private}} private{{end}}
{{- /* 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. */ -}}
{{if .Query}}No repository matches “{{.Query}}”.{{else}}No repositories are being served yet.{{if .IsAdmin}} Create one.{{end}}{{end}}
{{end}}
{{end}}
{{- /* The controls sit OUTSIDE #fleet on purpose. Everything inside is
replaced on every repaint, so a filter box in there would be torn
out from under the reader mid-keystroke and would re-run its own
data-init on each morph. Out here the input is a stable element:
it owns the stream, and typing re-opens it rather than racing a
second one. */ -}}
{{define "content"}}{{template "explore-tabs" .}}{{template "fleet-controls" .}}
{{template "fleet-body" .}}
{{end}}`
var licensesPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
quarry
quarry — the forge software serving this site — is BSD 2-Clause.
{{.QuarryLicence}}
The repositories served here
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 LICENSE, LICENCE or
COPYING file at the root of its tree — for the terms that
actually apply to that project.
Third-party components
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.
{{len .Notices}} modules
{{range .Notices}}
{{.Module}}
{{.Version}} · {{.License}}
{{.Text}}
{{end}}
{{end}}`))
var privacyPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
This page describes what this quarry instance stores. It describes the
software's behaviour, not a legal commitment by whoever runs it.
What is public by construction
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.
What quarry itself keeps
Accounts — 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.
Sessions — a random token in a cookie, with an
expiry. Signing out deletes it.
Sync credentials — a shared secret per repository
user, as the sync protocol defines it. Never a plaintext password.
Your colour theme — a cookie holding
"light" or "dark", set only if you choose one. Choosing "system" removes
it. It is not used to identify you.
What quarry does not do
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.
No avatar fetching. The circle in the corner is your initials drawn
locally, not an image requested from a third party using your address.
No access log of readers is kept by quarry itself. The server it runs
behind may keep its own; that is outside this software.
Deletion
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.
{{end}}`))
var peoplePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{template "explore-tabs" .}}
{{end}}`))
// wikiStructLimit bounds each structure list. Fossil's own wiki has
// over two hundred pages nothing links to; the panel exists to start a
// reader on the work, not to be the work.
const wikiStructLimit = 12
// wikiWant is a page name the wiki reaches for and has not got.
type wikiWant struct {
Title string
Links int
}
var wikiListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* A front door, not a directory listing. Eighty-three pages as one
alphabetical bullet list ran 2,265 pixels down a column using
two-fifths of the width, with fossil's own test fixtures filed
between real documentation and no way to narrow it. */ -}}
{{if .WikiHome}}{{.WikiHome.Title}}edited {{ago .WikiHome.Date}} by {{identicon .WikiHome.User}} {{.WikiHome.User}}{{end}}
{{with .DocEntry}}{{.Title}}{{if .Date}}changed {{ago .Date}}{{if .User}} by {{identicon .User}} {{.User}}{{end}}{{end}}{{end}}
{{- /* A filter box and a sort toggle over four documents are two
controls that cannot change what you see. They appear when the
list is long enough to need finding, or a filter is already on. */ -}}
{{if or (gt (len .DocRows) 8) .Query}}{{end}}
{{if .DocRows}}
{{if .ShowAssociated}}Showing {{count .Associated}} notes attached to branches and check-ins.
Hide them.{{else}}{{count .Associated}} wiki pages are notes on a branch or check-in, shown on those pages.
Show them anyway.{{end}}
{{end}}
{{if .Query}}{{count .Shown}} of {{count .Served}} match “{{.Query}}”{{else}}{{count .Shown}} documents{{end}}
{{template "ticket-pager" .}}
{{else}}
{{if .Query}}Nothing matches “{{.Query}}”. Show all.{{else}}No documents yet.{{end}}
{{else}}{{end}}
{{- /* What a page listing cannot tell you. A wiki goes bad in two
directions — pages nothing reaches, and links that promise a page
nobody wrote — and neither shows up in an alphabetical list.
MediaWiki keeps both as special pages; here they are derived from
the live text, so they cost nothing and cannot go stale. Shown
only when the wiki is big enough for either to be a question, and
never while a filter is narrowing the list they describe. */ -}}
{{if and (not .Query) (or .WikiOrphans .WikiWanted)}}
{{end}}`))
var forumAnnouncePage = template.Must(template.Must(base.Clone()).Parse(composerSource + `{{define "content"}}
Announcements pin above the forum and surface on the repository home. Anyone can reply; only maintainers start one.
{{end}}`))
// voteWidgetSource is the pair of arrows and the score. The FORMS are
// the mechanism (no-JS: POST + redirect, back where you were); datastar
// layers @post over submit, and the vote handler answers a datastar
// request with an SSE patch of exactly this span — no page flash. One
// form per arrow so the direction rides a hidden input: fetch-built
// form data never carries the clicked button.
var voteWidgetSource = `{{define "vote-widget"}}{{.Score}}{{end}}`
// voteCtx feeds vote-widget: one post's vote state on one thread page.
type voteCtx struct {
Repo, Root, Hash string
Score, YourVote int
}
// composerSource is the markdown composer: Write | Preview tabs over a
// raw textarea (raw text is never more than one click away — it IS the
// Write tab). Without JS the tab bar stays hidden and the composer is a
// plain textarea. Preview posts the draft to markup-preview, which runs
// the PRODUCTION render pipeline and patches the pane — the preview
// cannot drift from what a reader will see.
var composerSource = `{{define "composer"}}
{{end}}`
// composerCtx feeds the composer define.
type composerCtx struct {
Repo, Placeholder, Value string
Rows int
Name string // form field; "" means body
Optional bool // the text may be empty (a status change alone)
Autofocus bool // land the page IN the box (an aimed reply)
}
// FieldName is the textarea's name; the forum's reply is "body", a
// ticket's remark is "comment".
func (c composerCtx) FieldName() string {
if c.Name == "" {
return "body"
}
return c.Name
}
var forumThreadPage = template.Must(template.Must(base.Clone()).Parse(voteWidgetSource + composerSource + `{{define "content"}}
{{end}}`))
var technotePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{.WikiHTML}}
{{end}}`))
var chatPage = template.Must(template.Must(base.Clone()).Parse(`{{define "chat-log"}}
{{range .ChatMsgs}}
{{.User}} {{ago .At}} {{.Text}}
{{end}}
{{end}}
{{define "content"}}
{{template "chat-log" .}}
Messages persist for 7 days; durable forum-thread archival lands with zit forum verbs.
{{end}}`))
var docsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{.WikiHTML}}
{{end}}`))
var wikiEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{end}}`))
// ticketVocab renders the repository's vocabulary as datalists —
// shared by the new and edit forms, which are separate template trees;
// a define in one is invisible to the other.
const ticketVocab = `{{define "ticket-vocab"}}
{{- /* The repository's own words (ticket.config), offered as
SUGGESTIONS: a datalist completes, a select would refuse a word
the tracker already uses. */ -}}
{{range $field, $words := .Vocab}}{{if $words}}
{{end}}{{end}}{{end}}`
// The triage fields are `input list=` rather than `select`: the
// vocabulary below is fossil's DEFAULT, and a tracker may use any words
// it likes. A select would refuse a word the tracker already writes,
// which would leave quarry unable to edit trackers it can read.
var ticketNewPage = template.Must(template.Must(base.Clone()).Parse(ticketVocab + `{{define "content"}}
{{template "ticket-vocab" .}}
{{end}}
`))
var ticketEditPage = template.Must(template.Must(base.Clone()).Parse(ticketVocab + `{{define "content"}}
{{end}}`))
// commentText flattens fossil-wiki link syntax for places that want
// the comment as TEXT — the h1, the . "[target|label]" reads as
// its label, "[target]" as its target; fossil renders these as links
// where there is room for markup, but a heading full of pipe syntax
// reads as a rendering bug.
var fossilCommentLink = regexp.MustCompile(`\[([^\]|]*)\|([^\]]*)\]|\[([^\]]*)\]`)
func commentText(s string) string {
return fossilCommentLink.ReplaceAllStringFunc(s, func(m string) string {
sub := fossilCommentLink.FindStringSubmatch(m)
if sub[2] != "" {
return sub[2]
}
return sub[3]
})
}
var hexTarget = regexp.MustCompile(`^[0-9a-f]{6,64}$`)
// commentHTML renders a comment the way fossil renders it: escaped
// text with the bracket syntax turned into real links. A hex target
// goes to /info, which resolves it to a check-in, ticket, wiki edit,
// technote or forum post at click time — the renderer cannot know the
// kind, and fossil's own comments reference tickets as often as
// check-ins. An http(s) target leaves the site; anything else stays
// literal text, brackets and all. (A "[0]" is a footnote, not a page.)
func commentHTML(repo, s string) template.HTML {
var b strings.Builder
last := 0
for _, m := range fossilCommentLink.FindAllStringSubmatchIndex(s, -1) {
b.WriteString(template.HTMLEscapeString(s[last:m[0]]))
last = m[1]
target, label := "", ""
if m[2] >= 0 {
target, label = s[m[2]:m[3]], s[m[4]:m[5]]
} else {
target = s[m[6]:m[7]]
label = target
}
href := ""
switch {
case hexTarget.MatchString(strings.ToLower(target)):
href = "/r/" + url.PathEscape(repo) + "/info/" + url.PathEscape(target)
case strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://"):
href = target
}
if href == "" || label == "" {
b.WriteString(template.HTMLEscapeString(s[m[0]:m[1]]))
continue
}
b.WriteString(`` +
template.HTMLEscapeString(label) + ``)
}
b.WriteString(template.HTMLEscapeString(s[last:]))
return template.HTML(b.String())
}
var checkinPage = template.Must(template.Must(base.Clone()).Parse(diffBodySource + `{{define "content"}}
{{- /* GitHub's shape: the page is NAMED by its hash and the message
is CONTENT — a quiet box at body size, subject bolded, the rest
verbatim with its line breaks, bracket links live as fossil
renders them. No forge headlines the message, because none can
trust its length: fossil comments have no subject-line
convention and 2% of first lines in real repositories run past
200 characters. The full manifest lives in the tree browser —
this page answers "what changed". */ -}}
{{end}}
{{template "diff-body" .}}
{{end}}`))
var ticketBoardPage = template.Must(template.Must(base.Clone()).Parse(boardSource))
const boardSource = `{{define "content"}}
{{template "ticket-head" .}}
{{template "ticket-filters" .}}
{{- /* The board is a grid of server-grouped columns crossed by
swimlanes. Everything works with no script at all: a card is moved
with the select beneath it, which submits like any form.
That select is the MECHANISM, not a fallback. Dragging is layered
over it for pointer users and picking-up is layered over it for
everyone else; hiding the select once script loads would leave a
board that keyboard and assistive-technology readers cannot
operate. All three do the same POST. */ -}}
{{template "board-body" .}}
{{end}}
{{define "board-body"}}
{{if .Board}}
{{- /* Feedback for the last move, with its undo: the one thing a reader
needs after a drop is to know it landed, and a way back. A live
region so it is announced; a form so it works without script. */ -}}
{{with .Moved}}
Moved {{.Title}}{{if .From}} from {{.From}}{{end}} to {{.To}}
{{end}}
{{range .Board}}
{{if $.LaneBy}}
{{.Label}}{{count .Count}}
{{end}}
{{range .Columns}}
{{- /* The column is the drop target. While a card is over it the
column says so (data-class:over), which is the signifier a drag
needs: where this will land. */ -}}
{{- /* A folded column is GitHub's collapsed column: the name and its
count on a narrow strip, still a drop target. The name links to
the same view with this column opened. */ -}}
{{if not .Expanded}}
{{end}}
{{range .Tickets}}
{{- /* Two ways to move a card, one per kind of input: drag it (the
grip says so, the cursor says so), or open its Move menu — a
native popover of forms, so it works by keyboard, by touch, and
with scripting off. Nothing is a mode. */ -}}
{{if $.CanWrite}}⠇{{end}}
{{.Title}}
{{- /* A lane already names its field for every card in it; repeating
the badge on each card is noise. */ -}}
{{if and .Priority (ne $.LaneBy "priority")}}{{.Priority}}{{end}}
{{if and .Severity (ne $.LaneBy "severity")}}{{.Severity}}{{end}}
{{if and .Type (ne $.LaneBy "type")}}{{.Type}}{{end}}
{{if and .Subsystem (ne $.LaneBy "subsystem")}}{{.Subsystem}}{{end}}
priority order covers the {{count .Shown}} most recently updated; the rest are on the recent pages
{{end}}
{{- /* The row, in GitHub's anatomy over fossil's schema: a state glyph
in the gutter, the title as the whole click target, labels for
the fields that distinguish this ticket from its neighbours, and
a byline saying who opened it and when it last moved. What the
old row printed instead — Open, Medium, Important on every line —
was three chips of pure noise. */ -}}
{{if .Nav.Is "state" "closed"}}Nothing finished yet.{{else if .Nav.Is "state" "all"}}This tracker has no tickets.{{else}}No open tickets. See all {{count (add .Nav.Open .Nav.Closed)}}.{{end}}
{{end}}
{{template "ticket-pager" .}}
{{end}}`))
var ticketTablePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{template "ticket-head" .}}
{{template "ticket-filters" .}}
{{- /* Fossil parity, done properly: the dense tabular scan its report
pages are loved for, with the column headers as sort links and
the state glyph kept so a row's standing survives the change of
shape. .tw scrolls it rather than pushing the page sideways. */ -}}
{{if .Truncated}}
a sorted table covers the {{count .Shown}} most recently updated; narrow the report to see further
{{if .Nav.Is "state" "closed"}}Nothing finished yet.{{else if .Nav.Is "state" "all"}}This tracker has no tickets.{{else}}No open tickets. See all {{count (add .Nav.Open .Nav.Closed)}}.{{end}}
{{end}}
{{template "ticket-pager" .}}
{{end}}`))
var inboxPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Error}}
{{.Error}}
{{end}}
{{if .Inbox}}
{{range .Inbox}}
{{if not .Read}}{{.Subject}}{{else}}{{.Subject}}{{end}}
{{ago .CreatedAt}}
{{if .Content}} {{.Content}}{{end}}
{{end}}
{{else if not .Error}}
No notifications.
{{end}}
{{end}}`))
var loginPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Error}}
{{.Error}}
{{end}}
{{end}}`))
var historyPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
No CI runs. A pipeline is ci/*.json in the repository, in dagnats' workflow format; it runs on the check-ins made after it lands.
{{end}}
{{end}}`))
const docPanelSource = `{{define "doc-panel"}}
{{- /* Switching tabs is a MORPH, not a navigation: a reload threw away
the reader's scroll position mid-README. The href stays as the
no-script path — a plain load of the same view — and the click
handler fetches the one-shot fragment instead. */ -}}
{{with .About}}
{{$.DocHTML}}
{{end}}
{{end}}`
var dashboardPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{with .Dash}}
{{- /* The landing page answers three questions in the order a person
actually has them: what needs me, where do I go, what changed.
GitHub and Codeberg both lead with the feed — the least
actionable of the three — and leave "what needs me" to a
different page entirely. */ -}}
{{if .Awaiting}}
{{- /* Every artifact in quarry is one typed event stream, so this feed
can be filtered honestly — a chip's number counts exactly the
rows clicking it shows. Neither reference forge can do that,
because their feeds are not one type system. */ -}}
{{if .Kinds}}{{end}}
{{if .Feed}}
{{range .Feed}}
{{template "dash-event" .}}
{{end}}
{{else}}
Nothing yet — activity in your repositories lands here.
{{end}}
{{end}}
{{end}}
{{define "dash-event"}}
{{- /* ci and tickets and wiki link straight to their object; technote
and forum link to their section — their refs are not stable page
paths yet. */ -}}
{{if eq .Kind "ci"}}check-in{{else if eq .Kind "t"}}ticket{{else if eq .Kind "w"}}wiki{{else if eq .Kind "e"}}technote{{else if eq .Kind "f"}}forum{{else}}{{.Kind}}{{end}}
{{- if .Comment}}{{firstline .Comment}}{{else}}{{.Ref}}{{end}}
{{end}}`))
// splitRow is one side-by-side row: a deletion paired with the
// addition that replaced it, a context line on both sides, or one side
// alone. Pairing is the same run-alignment the word-diff uses, so the
// marks land opposite each other.
type splitRow struct {
Left, Right *diff.Line
}
func splitRows(h diff.Hunk) []splitRow {
var out []splitRow
i := 0
for i < len(h.Lines) {
l := h.Lines[i]
switch l.Kind {
case "context":
out = append(out, splitRow{&h.Lines[i], &h.Lines[i]})
i++
case "deletion":
ds := i
for i < len(h.Lines) && h.Lines[i].Kind == "deletion" {
i++
}
as := i
for i < len(h.Lines) && h.Lines[i].Kind == "addition" {
i++
}
dn, an := as-ds, i-as
for k := 0; k < max(dn, an); k++ {
var row splitRow
if k < dn {
row.Left = &h.Lines[ds+k]
}
if k < an {
row.Right = &h.Lines[as+k]
}
out = append(out, row)
}
default: // addition run with no deletions before it
out = append(out, splitRow{nil, &h.Lines[i]})
i++
}
}
return out
}
var branchesPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* fossil's /brlist split: the open branches are the living work
and lead; the closed ones are history and follow, marked. */ -}}
{{if .Branches}}
No tags yet. A release tag names a check-in: zit tag <name> <check-in>.
{{end}}
{{end}}`))
var treePage = template.Must(template.Must(base.Clone()).Parse(docPanelSource + `{{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. */ -}}
{{if .About}}{{with .About}}
{{- /* The document tabs: README | Contributing | License | Security by
root-level convention, or the .quarry/repo.json docs list when
the project curates. Tab choice is URL state (?doc=), so it is
linkable, back-button-correct and needs no script. The aside
precedes the file table in source order for the same reason it
does on GitHub and Forgejo: floated, it rides the right edge
from the top of the listing; stacked on a phone, "what is this"
comes before the files. */ -}}
{{end}}{{end}}
{{- /* 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. */ -}}
{{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. */ -}}
branches{{if .MoreBranches}} · {{.MoreBranches}} older not shown{{end}}
{{end}}
{{.Repo}}{{range .Crumbs}} / {{.Name}}{{end}}
{{- /* 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}}zitfossil{{end}}
{{- /* 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 and (or .RepoDescription .RepoPrivate) (not .About)}}
{{end}}
{{if .Tip}}
{{- /* GitHub's latest-commit bar: who last touched this tree, what they
said, and the door to the full history — the file table's header
row in spirit. Root listing only until the index carries
per-path change data. */ -}}
{{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. */ -}}
{{range .Tree}}
{{if .Dir}}{{.Name}}
{{- else}}{{.Name}}
{{- if .Exec}} exe{{end}}
{{- if .Link}} link{{end}}
{{- if .Binary}} bin{{end}}{{end}}
{{end}}`))
var ticketPage = template.Must(template.Must(base.Clone()).Parse(composerSource + ticketSource))
// The ticket page is the editor. Fossil keeps a separate tktedit page
// with every field as a select; GitHub has no edit page at all — the
// issue page carries its metadata in a sidebar, its composer at the
// foot of the timeline, and each field change in the timeline as an
// event. This is the GitHub shape over fossil's schema: a sidebar form
// of selects (one Update button without script, save-on-change with
// it), a remark composer whose second button closes or reopens, and
// the artifact's own J cards rendered as "status Open → Review" lines.
const ticketSource = `{{- /* One select per triage field. The form is the no-script
path: change several, press Update. With script each select
posts the form on change and the Update button hides — the
sidebar and timeline come back as a patch. */ -}}
{{define "tkt-side"}}{{if and .CanWrite (not .Posture.ReadOnly)}}{{else}}
{{end}}{{end}}
{{- /* Remarks and field moves, oldest first, one block per artifact. */ -}}
{{define "tkt-timeline"}}{{range .Timeline}}
{{.Login}}{{if .DifferentUsername}} (claiming to be {{.Username}}){{end}}{{with .Summary}} {{.}}{{end}} · {{ago .Date}}
{{.HTML}}
{{end}}{{end}}
{{define "content"}}
{{- /* Title and description edit where they are read: a details
fold holding the form, so it works without script and the page
never leaves. The layout already drew the title as its h1; the
fold's summary is the pencil beside it. */ -}}
{{if and .CanWrite (not .Posture.ReadOnly)}}✎ title{{end}}
{{if .DescriptionHTML}}
{{.DescriptionHTML}}
{{end}}
{{if and .CanWrite (not .Posture.ReadOnly)}}✎ {{if .DescriptionHTML}}edit description{{else}}add a description{{end}}{{end}}
{{if .Attachments}}
{{- /* The composer: Comment, or the same remark with a status move.
Closing asks for the resolution right there — the only moment
it is required, and the only place it is offered while open. */ -}}
{{if and .CanWrite (not .Posture.ReadOnly)}}{{end}}
{{end}}`
var filePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{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 = `{{- /* One row per store, ingest and mirror together, because they are
one store: the old page had two tables keyed by the same name and
the reader scrolled twice to learn one thing. Problems lead; when
there are any, the healthy rows fold behind a disclosure so a
phone shows the one row that matters and a count. Counts that
are zero do not render — a column of zeros says nothing — and
the ones that are not are chips the eye can find. The row is a
grid on a wide screen and a stack on a narrow one; same markup. */ -}}
{{define "fleet-row"}}
{{end}}
{{with .Mirror}}{{if and .Enabled .LastErr}}
pull: {{.LastErr}}
{{end}}{{end}}
{{with .Mirror}}{{if .Enabled}}
{{- /* A native popover, like the account menu: the browser owns
light-dismiss, Esc and the top layer, and the items share the
.menu rules so they size the same. Anchored to its own button;
the anchor name is the row's index, since store names can hold
characters a CSS ident cannot. */ -}}
{{end}}
{{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}}
{{end}}
{{if .ServerErrors}}
{{- /* The error journal: everything that answered "internal error" to
a reader since the server started, deduplicated by message. This
panel is what makes that answer a promise someone collects on —
before it, 500s were writes to a log nobody read, and the fleet
said "ok" while the web layer burned. */ -}}
{{$healthy := 0}}{{range .FleetRows}}{{if not .Problem}}{{$healthy = 1}}{{end}}{{end}}
{{if $healthy}}{{len .FleetRows}} stores in all — show the healthy ones
{{range .FleetRows}}{{if not .Problem}}{{template "fleet-row" .}}{{end}}{{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. */ -}}
{{- /* One form adds and edits: the store name is the key, so saving
a name that exists updates that row. Said on the button, because
"Add" alone hid that a repair was possible. */ -}}
{{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.
// mirrorDetachPage is the acknowledgement before a posture change that
// cannot be undone by a click: detaching makes the store a fork. The
// checkbox is required, which is what an acknowledgement is.
var mirrorDetachPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{with .MirrorEdit}}
{{.Repo}} tracks {{.URL}}. Detaching makes it a fork:
quarry becomes the source of truth; scheduled pulls stop.
pushes, tickets, wiki and forum writes are accepted here from now on.
the upstream is kept as provenance — the repository will say "forked from {{hostOf .URL}}".
re-attaching later merges whatever the upstream did in the meantime on top of local history.
{{end}}{{end}}`))
// mirrorReattachPage: one click to resume tracking, with the one thing
// that can go wrong said first. Drift is not measured yet (see the
// divergence ticket); the warning is the honest placeholder.
var mirrorReattachPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{with .MirrorEdit}}
{{.Repo}} is a fork of {{.DetachedFrom}}. Re-attaching resumes tracking it:
scheduled pulls resume every {{dur .Every}}; the next pull merges the upstream's history on top of whatever was committed here since the detach.
local writes return to refused; allow them again on the fleet page if this is meant to stay a fork that follows upstream.
if local history has diverged from the upstream, both lines stay in the store as separate branches — nothing is lost, but the timeline will show both.
{{end}}{{end}}`))
var newRepoPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Error}}
{{.Error}}
{{end}}
{{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}}
{{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"}}
{{if .Error}}
{{.Error}}
{{end}}
{{if .Saved}}
Saved.
{{end}}
{{if .Caps}}
Capabilities
Fossil capability letters (RFC 0004). Categories apply to
everyone: nobody to every visitor, anonymous to any
signed-in account, reader to holders of u,
developer to holders of v. Blanking nobody and anonymous
takes the repository private, exactly as fossil does.
{{end}}
Clone
zit clone {{.CloneURL}} {{.Repo}}
{{- /* Kept apart, and last. Both actions here are one click from
something the settings above are not: irreversible. */ -}}
Danger zone
{{if .Deletable}}{{else}}
This repository's store was not created by quarry,
so it cannot be deleted here — stop serving it and remove the directory yourself.
{{end}}
{{end}}`))
var notFoundPage = template.Must(template.Must(base.Clone()).Parse(
`{{define "content"}}
{{- /* A 404 keeps its footing: inside a known repository the repobar
stays (set by fail), so the reader is one click from every
surface that DOES exist rather than dumped back at the door.
The path is echoed so a typo is findable by eye. */ -}}
404 — nothing lives at {{.MissPath}}.
{{if .Repo}}
The repository exists; this page within it does not. The tabs above are all real.
{{end}}
{{end}}`))
var busyPage = template.Must(template.Must(base.Clone()).Parse(
`{{define "content"}}
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.
{{end}}`))
var errorPage = template.Must(template.Must(base.Clone()).Parse(
`{{define "content"}}
Something went wrong serving this page. The details are in the server log.
{{end}}`))
var forbiddenPage = template.Must(template.Must(base.Clone()).Parse(
`{{define "content"}}
{{.Error}}
{{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}}
{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} across {{.Searched}} repositor{{if eq .Searched 1}}y{{else}}ies{{end}}
{{- /* 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}}{{end}}{{end}}
{{if .Results}}
{{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}}{{end}}
{{if .Results}}
{{end}}
{{end}}`))
// diffBodySource is the structured diff shared by the check-in page
// (fossil's /info shape: metadata, then the change inline) and the
// standalone /diff deep link. One template, two doors.
const diffBodySource = `{{define "dtoks"}}{{range .Tokens}}{{if or .Class .Mark}}{{.Text}}{{else}}{{.Text}}{{end}}{{end}}{{end}}
{{define "diff-body"}}
{{if .Diff.Note}}
{{len .Diff.Structured}} {{plural (len .Diff.Structured) "file" "files"}} changed{{if .Diff.OmittedLines}} — a large change; the rest loads as you scroll{{end}}
{{end}}
{{end}}
{{- /* One window of file sections, then the sentinel that fetches the
next window when it scrolls into view. The sentinel is ALSO a
link: with scripting off the same URL renders the next window as
a page, so the whole diff stays reachable either way. The
fragment response replaces #diff-more with this very template,
which is how the chain continues. */ -}}
{{define "diff-more"}}{{range .Diff.Structured}}{{if .Hunks}}
{{else}}{{if .Diff.Offset}}{{end}}{{end}}
{{end}}
{{- /* The pager: where this window sits in the whole diff, and the
way to the one before, the one after, and the start. The paged
reading (no script) lives on it; with script it only appears on
a window reached by link, as a bearing. Disabled ends stay in
the row so the buttons never shift. */ -}}
{{define "diff-pager"}}{{end}}`
var diffPage = template.Must(template.Must(base.Clone()).Parse(diffBodySource + `{{define "content"}}
{{template "diff-body" .}}
{{end}}`))
var wikiPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* Three columns: the pages beside this one, the page, and where you
are inside it. A wiki page used to be an island — no siblings, no
contents, no way back but the browser button — and fossil's own
Cookbook answered that by hand-writing an index at the top, which
is the reader's need stated in the only place they could state
it. Both rails are hidden on narrow screens; every link in them
exists elsewhere on the page or one level up. */ -}}
{{end}}
{{- /* Fossil deletes a page by writing an empty edit. The tombstone
says so rather than presenting a blank page as content, and the
history stays one click away — the record survives deletion. */ -}}
{{if .Wiki.Deleted}}
This page was deleted {{ago .Wiki.Date}} by {{.Wiki.User}}.
Its history remains readable.
{{else}}
{{- /* The byline leads rather than trails: on a 26,000-pixel page a
footer saying how stale the text is arrives twenty-six screens
after the reader needed it. */ -}}
{{end}}`))
var docPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* A repository's own documentation lives in its TREE — fossil keeps
every page of www/ there, not as wiki artifacts — so without this
route the docs of every fossil repository quarry serves could only
be read as source. Same reading furniture as a wiki page, because
it is the same job: a long document someone has to get through. */ -}}
{{end}}`))
var timelinePage = template.Must(template.Must(base.Clone()).Parse(timelineSource))
const timelineSource = `{{define "content"}}
{{- /* Three selects, one row, whatever the content: a select shows
its choice, truncates its own text natively, and submits on
change (the button exists only where script does not). Pills
spent three phone rows saying the same thing. */ -}}
{{end}}
{{end}}`
type pageData struct {
Title string
Version string
Origin string // scheme://host we were reached by; for absolute URLs
Path string // the request path, for og:url
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
// Served is the fleet size before a filter, so a filtered listing
// can say how many of the whole it matched — "1 of 1" is true and
// tells a reader nothing.
Served 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
BoardView bool
// Nav builds every link in the tracker header; Rows are the list's
// decorated summaries.
Nav ticketNav
Rows []ticketRow
Filters []ticketFilterControl
// Across marks the cross-repository listing, where a row must say
// which tracker it came from and the page addresses /tickets.
Across bool
Columns []ticketColumn
Rail []railSection
Pager *pager
// CanWrite decides whether the board draws its move control. It is
// write CAPABILITY, not session state — see mayWrite.
CanWrite bool
Board []boardLane
// The board's two axes and the choices offered for them, so the
// controls can be rendered without the template knowing the
// vocabulary.
GroupBy string
LaneBy string
AxisFields []struct{ Key, Label string }
// The column set, so the per-card move select offers every column
// the board draws rather than only the ones already occupied — the
// bug that made the board useless: one column meant the only move
// available was to where the ticket already was.
ColumnLabels []string
// The board reads a bounded page of the tracker. Saying so is the
// difference between "these are the tickets" and "these are some".
Shown int
Truncated bool
ByPriority bool
// MissPath is the URL a 404 echoes back, so a typo is findable.
MissPath string
// TrackerRepo names the forge's own tracker for /fleet's file-ticket
// buttons; empty draws none.
TrackerRepo string
// DiffSplit selects the side-by-side diff view (?view=split).
DiffSplit bool
// CheckinLead is the comment's first line, linkified; CheckinBody
// is the rest of the comment, linkified, line breaks preserved.
// CheckinLongLead marks a first line too long to be a subject line —
// it wears near-body styling instead of the bold lead.
CheckinLead template.HTML
CheckinBody template.HTML
CheckinLongLead bool
// Prefill seeds the new-ticket form from query parameters, so a
// surface that knows about a problem (the fleet's error journal)
// can deep-link a half-written ticket. Values pass through
// template escaping like any other text.
Prefill ticketPrefill
// Dash is the signed-in landing page's aggregate (me.feed).
Dash *dashData
// ServerErrors is /fleet's error journal panel.
ServerErrors []errlogRow
ErrorTotal int
// About is the repo landing page's sidebar aggregate (repo.about);
// nil everywhere else. DocHTML is the selected tab, op-rendered and
// sanitizer-gated like WikiHTML.
About *aboutData
DocHTML template.HTML
// Vocab is the repository's ticket vocabulary (ticket.config), for
// form datalists and anything else that offers the words back.
Vocab map[string][]string
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
// The two things a page listing cannot say: what nothing links to,
// and what is linked to but was never written.
WikiOrphans []string
WikiWanted []wikiWant
// What the two lists above left out, so a bound is never silent.
MoreOrphans, MoreWanted int
DocRows []docRow
WikiHome *wikiTitle
DocEntry *docRow
WikiDocs []docEntry
// Associated counts the artifact notes left out of the listing;
// ShowAssociated says the reader asked for them anyway.
Associated int
ShowAssociated bool
DocVersion string
Doc docDetail
// DocPath is set when this file also has a rendered form.
DocPath bool
WikiTOC []wikiHeading
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
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
Timeline []ticketEvent // comments and field changes, oldest first
TicketFields []ticketControl
History []fileChange
HistoryPath string
CIRuns []ciRunRow
CIBadge template.HTML
Tree []treeRow
TreeVersion string
Crumbs []crumb
Branches []branchRow
ClosedCount int
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
ForumPinned []forumThreadRow
ForumRoot string // reply-form target on the thread page
Flash string // one-render notice ("post held for moderation")
OGDesc string // og:description for link unfurls
Composer bool // page carries a markdown composer: load its script
YourSub string // the viewer's thread-subscription standing
ReplyTo string // specific post the composer answers (hash prefix)
ReplyToUser string
CanMod bool // moderation controls follow Admin
ModOn bool
Queue []stagedRow
ModLog []modRow
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
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
// FleetRows is the joined, problems-first listing; FleetProblems
// how many lead it. MirrorCounts summarises the mirrors by state.
FleetRows []fleetRow
FleetProblems int
MirrorCounts map[string]int
SyncResults []fleetSyncRow
// Mirrors is the configured upstream tracking; MirrorResults is
// non-nil only just after a manual pull.
Mirrors []mirrorRow
MirrorResults []mirrorRunRow
// MirrorEdit prefills the add form with one row, for editing in
// place; set from ?edit= on the fleet page.
MirrorEdit *mirrorRow
// Posture is the repo's mirror standing, set for every repo page.
Posture posture
// Kind is the explore listing's posture filter: "", mirror, native.
Kind string
// Moved is the board's last move, for the status line and its undo.
Moved *boardMove
// SearchAction, when set on a fleet-level page, points the header
// box at that page as a filter instead of at global search.
SearchAction string
// 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
RepoHomepage string
RepoTopics string
Caps *capsPanel
Tip *timelineEntry
// TreeChanges: the listing has last-change data, so the table shows
// GitHub's message + age columns instead of sizes.
TreeChanges bool
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 {
IndexBehind bool `json:"indexBehind"`
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"`
AcceptWrites bool `json:"acceptWrites"`
DetachedFrom string `json:"detachedFrom"`
State string `json:"state"`
}
// posture is what every repo page knows about where the repository
// stands: tracking an upstream (and how freshly), detached from one,
// or native. It comes from repo.get, so the page and the API agree.
type posture struct {
Mirror struct {
Tracking bool `json:"tracking"`
URL string `json:"url"`
State string `json:"state"`
LastOK string `json:"lastOk"`
LastAt string `json:"lastAt"`
Failures int `json:"failures"`
AcceptWrites bool `json:"acceptWrites"`
} `json:"mirror"`
DetachedFrom string `json:"detachedFrom"`
}
// ReadOnly: the store refuses local writes, so the page offers the
// upstream's write surfaces instead of its own.
func (p posture) ReadOnly() bool { return p.Mirror.Tracking && !p.Mirror.AcceptWrites }
// Host is the upstream's host, which is how a badge names it.
func (p posture) Host() string {
u := p.Mirror.URL
if u == "" {
u = p.DetachedFrom
}
if parsed, err := url.Parse(u); err == nil && parsed.Host != "" {
return parsed.Host
}
return u
}
// Upstream joins a Fossil page path onto the upstream URL: the write
// surfaces over there have predictable names.
func (p posture) Upstream(path string) string {
return strings.TrimSuffix(p.Mirror.URL, "/") + "/" + strings.TrimPrefix(path, "/")
}
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"`
Gaps int `json:"gaps"`
Stalled bool `json:"stalled"`
RetryAt string `json:"retryAt"`
} `json:"health"`
}
// fleetRow is one store as the fleet page shows it: ingest health and
// mirror posture on one line, because they are one store. Problem is
// what sorts it to the top and keeps it out of the collapsed group.
type fleetRow struct {
fleetHealthRow
Mirror *mirrorRow
Problem bool
Idx int // position in the listing; names the row's popover and its anchor
}
// IngestState is the one-word verdict for the index side.
func (f fleetRow) IngestState() string {
switch {
case f.Health.Stalled:
return "stalled"
case f.Health.Failures > 0 || f.Health.LastErr != "":
return "failing"
case f.Indexing:
return "indexing"
case f.Health.Gaps > 0:
return "gaps"
}
return "ok"
}
// fleetRows joins the two lists by name, marks problems, and orders
// the problems first. The healthy majority reads as a single block.
func fleetRows(health []fleetHealthRow, mirrors []mirrorRow) (rows []fleetRow, problems int) {
byName := map[string]*mirrorRow{}
for i := range mirrors {
byName[mirrors[i].Repo] = &mirrors[i]
}
for _, h := range health {
row := fleetRow{fleetHealthRow: h, Mirror: byName[h.Name]}
st := row.IngestState()
row.Problem = st == "stalled" || st == "failing" || st == "gaps" ||
(row.Mirror != nil && row.Mirror.State == "failing")
if row.Problem {
problems++
}
rows = append(rows, row)
}
sort.SliceStable(rows, func(i, j int) bool { return rows[i].Problem && !rows[j].Problem })
for i := range rows {
rows[i].Idx = i
}
return rows, problems
}
// errlogRow mirrors errlog.Entry through the op boundary.
type errlogRow struct {
Message string `json:"message"`
Surface string `json:"surface"`
Path string `json:"path"`
Count int `json:"count"`
First string `json:"first"`
Last string `json:"last"`
}
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"`
Mirror struct {
Tracking bool `json:"tracking"`
URL string `json:"url"`
State string `json:"state"`
} `json:"mirror"`
DetachedFrom string `json:"detachedFrom"`
Homepage string `json:"homepage"`
Topics string `json:"topics"`
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"`
Revisions int `json:"revisions"`
}
// docEntry is one document in the tree, for the index.
type docEntry struct {
Path string `json:"path"`
Size int64 `json:"size"`
Date string `json:"date"`
User string `json:"user"`
}
// docRow is one entry of the documentation index, WHICHEVER store it
// came from. A reader looking for how something works does not care
// whether the answer is a wiki artifact or a file in the tree, and on
// fossil's own repository the distinction actively misleads: the wiki
// holds 41 real pages last touched in 2018, while the 113 documents in
// www/ are current. One list, one shape, the source named quietly for
// anyone who needs to know where to edit.
type docRow struct {
Title string
URL string
Date string
User string
// Kind is "wiki" or "tree" — how it is stored, and therefore how it
// is edited: in the browser, or by check-in.
Kind string
// Revisions is set for wiki pages.
Revisions int
}
// entryDoc matches a project's front door. Fossil has no per-document
// importance metadata — its one explicit signal, the index-page setting,
// lives in server config that zit does not carry — so the honest
// substitutes are CONVENTION and recency. This is the convention half:
// a documentation directory's own index is the page its authors wrote
// to be read first, and www/index.wiki is exactly that on fossil.
//
// Anchored to the top level of a documentation directory on purpose:
// www/server/debian/index.md is a leaf, not a front door.
var entryDoc = regexp.MustCompile(`^(?:www|doc|docs)/index\.(?:wiki|md|markdown|html?|txt)$`)
// repoReadme is the fallback front door for a project that keeps no
// documentation directory at all.
var repoReadme = regexp.MustCompile(`^README\.(?:wiki|md|markdown|html?|txt)$`)
// associatedWiki are the page-name prefixes fossil treats as ASSOCIATED
// wiki: notes attached to another artifact rather than documents in
// their own right (see fossil's wikitheory.wiki). A branch/ page belongs
// at the top of that branch's timeline and a checkin/ page in that
// check-in's About section — not in an index of a project's
// documentation, which is why fossil's own /wcontent hides them behind
// a "Show associated wikis" checkbox.
//
// This is not a tidying preference. On fossil's own repository 49 of 83
// wiki pages are these, so an index that lists them is 59% notes about
// something else.
var associatedWiki = []string{"branch/", "checkin/", "tag/", "ticket/"}
func isAssociatedWiki(title string) bool {
for _, p := range associatedWiki {
if strings.HasPrefix(title, p) {
return true
}
}
return false
}
// docDetail is a file from the tree rendered as documentation.
type docDetail struct {
Hash string `json:"hash"`
Path string `json:"path"`
Size int `json:"size"`
Mimetype string `json:"mimetype"`
HTML string `json:"html"`
Outline []wikiHeading `json:"outline"`
ContentType string `json:"contentType"`
Content string `json:"content"`
}
// wikiHeading is one entry of a page's contents.
type wikiHeading struct {
Level int `json:"level"`
ID string `json:"id"`
Text string `json:"text"`
// Sub is true for anything below the page's shallowest heading, so
// the contents can indent one level without pretending to model a
// tree the document may not actually have.
Sub bool `json:"-"`
}
// aboutData mirrors repo.about's aggregate for the landing page.
type aboutData struct {
Description string `json:"description"`
Homepage string `json:"homepage"`
Topics []string `json:"topics"`
Docs []struct {
Path string `json:"path"`
Label string `json:"label"`
} `json:"docs"`
DocPath string `json:"docPath"`
DocLabel string `json:"docLabel"`
DocHTML string `json:"docHtml"`
LatestTag string `json:"latestTag"`
LatestTagTip string `json:"latestTagTip"`
LatestTagDate string `json:"latestTagDate"`
TagCount int `json:"tagCount"`
Contributors int `json:"contributors"`
LicenseName string `json:"licenseName"`
AnnounceRoot string `json:"announceRoot"`
AnnounceTitle string `json:"announceTitle"`
AnnounceDate string `json:"announceDate"`
}
// dashData mirrors me.feed for the signed-in landing page.
type dashData struct {
User string `json:"user"`
Repos []struct {
Name string `json:"name"`
Description string `json:"description"`
LastActivity string `json:"lastActivity"`
Open int `json:"open"`
} `json:"repos"`
Kinds []struct {
Kind string `json:"kind"`
Count int `json:"count"`
} `json:"kinds"`
Feed []dashEvent `json:"feed"`
Mine []dashEvent `json:"mine"`
// Awaiting is the band at the top: the caller's open tickets that
// somebody else has answered and left with them. It is the one
// thing on this page that is about the reader rather than about
// what happened, so it leads — and when there is nothing waiting it
// does not render at all rather than sitting there empty.
Awaiting []ticketRow
// Kind is the feed's chosen chip, Find the repository filter box.
Kind, Find string
// CanCreate says the reader may start a repository, so the page can
// offer it. A control that cannot work does not render.
CanCreate bool
}
// KindLabel names a feed chip. The timeline's letters are storage, not
// something to show a reader.
func kindLabel(k string) string {
switch k {
case "ci":
return "check-ins"
case "t":
return "tickets"
case "w":
return "wiki"
case "f":
return "forum"
case "e":
return "technotes"
}
return k
}
// FeedURL is the dashboard at one kind chip, with the repository filter
// kept: changing what you look at must not silently drop what you
// narrowed to.
func (d dashData) FeedURL(kind string) string {
q := url.Values{}
if kind != "" {
q.Set("kind", kind)
}
if d.Find != "" {
q.Set("find", d.Find)
}
if len(q) == 0 {
return "/"
}
return "/?" + q.Encode()
}
type dashEvent struct {
Repo string `json:"repo"`
Kind string `json:"kind"`
Ref string `json:"ref"`
Hash string `json:"hash"`
Date string `json:"date"`
User string `json:"user"`
Comment string `json:"comment"`
}
type ticketPrefill struct {
Title, Comment, Type, Severity, Priority, Subsystem string
}
type wikiDetail struct {
Hash string `json:"hash"`
Title string `json:"title"`
Date string `json:"date"`
User string `json:"user"`
HTML string `json:"html"`
Outline []wikiHeading `json:"outline"`
Deleted bool `json:"deleted"`
}
// docsPageSize is the documentation index's page. Larger than the
// tracker's because these are one-line rows in two columns, so a page
// is fifty rows down each side rather than fifty down one.
//
// Wiki pages alone would not need paging — human authorship bounds them
// at a couple of hundred — but the tree half does not: a repository can
// carry any number of documents, and the previous bound of 400 was a
// dead end rather than a page, with everything past it reachable only
// by guessing a filter.
const docsPageSize = 100
// wikiTOCLimit bounds the contents rail. Fossil's Cookbook has 51
// headings and yields 17 entries once depth is capped; a rail longer
// than this has stopped being a glance at the page's shape.
const wikiTOCLimit = 40
// wikiRailLimit caps the sibling rail. Past a dozen it stops being a
// glance and starts being the index, which is one click away anyway.
const wikiRailLimit = 12
// wikiHome is the page a tracker opens on when it has one, the way
// every forge with a wiki does. Fossil has no such convention, so this
// is additive: with no Home page /wiki is the index it always was.
const wikiHome = "Home"
// wikiContents decides whether a page earns a contents rail and shapes
// it. Two headings do not need navigating and a page of h4s is still a
// page, so depth is relative to the SHALLOWEST heading present rather
// than to h1 — a document that opens at h2 reads the same as one that
// opens at h1, and neither should be indented for it.
func wikiContents(hs []wikiHeading) []wikiHeading {
if len(hs) < 3 {
return nil
}
top := hs[0].Level
for _, h := range hs {
if h.Level < top {
top = h.Level
}
}
pick := func(depth int) []wikiHeading {
out := make([]wikiHeading, 0, len(hs))
for _, h := range hs {
// Below the second level the rail becomes a wall; fossil's
// Cookbook alone would contribute 51 entries unbounded.
if h.Level > top+depth {
continue
}
h.Sub = h.Level > top
out = append(out, h)
}
return out
}
out := pick(1)
if len(out) > wikiTOCLimit {
// Shallower before shorter: dropping the subsections keeps the
// contents a true map of the page, where truncating it mid-way
// would claim the document ends where the rail does.
out = pick(0)
}
if len(out) > wikiTOCLimit {
out = out[:wikiTOCLimit]
}
if len(out) < 3 {
return nil
}
return out
}
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"`
Created string `json:"created"`
Updated string `json:"updated"`
User string `json:"user"`
Score int `json:"score"`
Excerpt string `json:"excerpt"`
HasPoll bool `json:"hasPoll"`
Participants []string `json:"participants"`
Spark []int `json:"spark"`
Dormant bool `json:"dormant"`
}
type forumPostRow struct {
Hash string `json:"hash"`
Parent string `json:"parent"`
Date string `json:"date"`
User string `json:"user"`
Score int `json:"score"`
YourVote int `json:"yourVote"`
Hidden bool `json:"hidden"`
HiddenReason string `json:"hiddenReason"`
ParentUser string // author of the post this one answers (web-side join)
IndentPx int // DAG depth as indentation (capped)
HTML template.HTML
Poll struct {
Present bool `json:"present"`
Question string `json:"question"`
Options []string `json:"options"`
Counts []int `json:"counts"`
Percents []int `json:"percents"`
Total int `json:"total"`
YourChoice int `json:"yourChoice"`
Closed bool `json:"closed"`
Closes string `json:"closes"`
} `json:"poll"`
}
// identicon draws a deterministic identity mark from a login name: a
// 5x5 horizontally-mirrored pixel grid, hued by the name's hash — the
// same idea as GitHub's identicons. Inline SVG, so it costs no request
// and no script; the same name renders the same mark everywhere.
// userHue is one login's hue, shared by the identicon, the hued name,
// the post edge and the spectrum ribbon — one color IS one person,
// everywhere.
func userHue(name string) int {
h := fnv.New64a()
h.Write([]byte(name))
return int(h.Sum64() % 360)
}
// spectrumSVG is a thread's fingerprint: one band per post, in the
// author's hue, in posting order. A two-person volley reads striped; a
// question with one long answer reads as a field with an interruption.
func spectrumSVG(posts []forumPostRow) template.HTML {
if len(posts) < 2 {
return ""
}
// One voice makes a solid bar that says nothing and reads as an
// error stripe; the ribbon draws only when there is a conversation
// to fingerprint.
voices := map[string]bool{}
for _, p := range posts {
voices[p.User] = true
}
if len(voices) < 2 {
return ""
}
var b strings.Builder
n := len(posts)
// One continuous bar, constant height: contiguous segments, no
// gaps — presence comes from height and the muted edge recipe, and
// only the rounded OUTER corners say "designed element".
fmt.Fprintf(&b, ``)
return template.HTML(b.String())
}
var identiconMemo sync.Map // name -> template.HTML; marks are immutable
func identicon(name string) template.HTML {
if v, ok := identiconMemo.Load(name); ok {
return v.(template.HTML)
}
h := fnv.New64a()
h.Write([]byte(name))
v := h.Sum64()
hue := v % 360
cells := v >> 9 // 15 bits: which of the 5x3 half-grid cells are on
shades := v >> 24 // 15 more: which on-cells take the deeper shade
if cells&0x7FFF == 0 {
cells = 0x1084 // a degenerate hash still gets a face: center column
}
var b strings.Builder
// A wash of the mark's own hue behind the grid gives it a plate to
// sit on; two lightness steps of one hue give it depth. One hue per
// person keeps the mark recognisable at a glance — the color IS the
// identity as much as the pattern.
fmt.Fprintf(&b, ``)
out := template.HTML(b.String())
identiconMemo.Store(name, out)
return out
}
// sparkSVG draws a thread's activity levels (0-4) as a tiny bar-run —
// conversation SHAPE at a glance: a flare-and-die support question and
// a slow-burn RFC stop looking alike.
func sparkSVG(levels []int) template.HTML {
if len(levels) == 0 {
return ""
}
var b strings.Builder
w := len(levels) * 3
fmt.Fprintf(&b, ``)
return template.HTML(b.String())
}
// capsPanelFor builds the settings editor's model. Returns nil when
// the forge has no caps administration plane — the section then simply
// does not render.
func capsPanelFor(caps Caps, repo string) *capsPanel {
admin, ok := caps.(CapsAdmin)
if !ok || admin == nil {
return nil
}
notes := map[string]string{
"nobody": "every visitor, no session",
"anonymous": "any signed-in account",
"reader": "holders of u",
"developer": "holders of v",
}
p := &capsPanel{}
for _, cat := range []string{"nobody", "anonymous", "reader", "developer"} {
p.Categories = append(p.Categories, capRow{
Name: cat, Caps: admin.CategoryCaps(repo, cat), Note: notes[cat],
})
}
holders, err := admin.CapHolders(repo)
if err != nil {
return p
}
for _, h := range holders {
row := capRow{Name: h.Account, Caps: h.Caps}
if c := caps.CapsOf(h.Account, repo); c != nil {
row.Effective = fmt.Sprint(c)
}
p.Accounts = append(p.Accounts, row)
}
return p
}
// capsPanel drives the settings page's capability editor: the four
// fossil categories and every account holding explicit caps here.
type capsPanel struct {
Categories []capRow
Accounts []capRow
}
type capRow struct {
Name string
Caps string
Note string
Effective string
}
// stagedRow is one held submission in the moderation queue.
type stagedRow struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Author string `json:"author"`
At string `json:"at"`
AIAction string `json:"aiAction"`
AIScore float64 `json:"aiScore"`
AIReason string `json:"aiReason"`
}
// modRow is one public moderation-log line.
type modRow struct {
At string `json:"at"`
Actor string `json:"actor"`
Action string `json:"action"`
Target string `json:"target"`
Reason string `json:"reason"`
}
// 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(`pass`)
case "ci-fail":
return template.HTML(`fail`)
}
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"`
LastHash string `json:"lastHash"`
LastDate string `json:"lastDate"`
LastComment string `json:"lastComment"`
}
// 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])
}
// openBranches drops closed branches from a selector — a hundred
// finished work branches bury the four live ones — but never the one
// the reader is currently on: a closed branch is still browsable, and
// a selector that cannot show its own state is lying.
func openBranches(rows []branchRow, current string) []branchRow {
out := rows[:0:0]
for _, b := range rows {
if !b.Closed || b.Name == current || b.Tip == current {
out = append(out, b)
}
}
return out
}
// 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"
}
// matchesRepo is the fleet page's filter: a case-insensitive substring
// of the store name or, for a mirror, its upstream.
func matchesRepo(q string, fields ...string) bool {
if q == "" {
return true
}
q = strings.ToLower(q)
for _, f := range fields {
if strings.Contains(strings.ToLower(f), q) {
return true
}
}
return false
}
// tagRow is one release tag for the ref switcher.
type tagRow struct {
Name string `json:"name"`
Tip string `json:"tip"`
Date string `json:"date"`
}
// 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"`
Date string `json:"date"`
Closed bool `json:"closed"`
// 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"`
Created string `json:"created,omitempty"`
Type string `json:"type,omitempty"`
Severity string `json:"severity,omitempty"`
Priority string `json:"priority,omitempty"`
Subsystem string `json:"subsystem,omitempty"`
Resolution string `json:"resolution,omitempty"`
Author string `json:"author,omitempty"`
LastUser string `json:"lastUser,omitempty"`
Comments int `json:"comments"`
}
// ticketNav is the tracker header's URL state: which lives are listed,
// which view draws them, how they are ordered, and the board's axes.
// One place builds every link on the page, so switching state keeps the
// view and switching view keeps the state — the axes stay independent,
// which is the whole reason they are separate controls.
type ticketNav struct {
Repo string
// Base is the listing this nav addresses, empty for one repository's
// tracker. The cross-repository listing is the same report machinery
// pointed at a different path, so every link, facet and page control
// asks the nav for the path rather than spelling one.
Base string
// Awaiting marks the "waiting on you" view. It is carried by every
// link this nav builds, so switching sort or view stays inside the
// view the reader chose rather than dumping them in the full list.
Awaiting bool
State string // "" or open | closed | all
View string // "" (list) | board
Sort string // "" (priority) | recent
Open int
Closed int
Group string
Lane string
// Order names the table view's column order; see index.TicketOrders.
Order string
// Title is the query box's free words, matched against titles.
Title string
// Offset is the page. Only PageURL sets it: changing ANY other axis
// returns to the first page, because "filtered to three results,
// showing rows 101-150" is an empty screen with no cause on it.
Offset int
// Report is the filter axes, rebuilt into every link this nav makes.
// Narrowing to a subsystem and then clicking Closed must show that
// subsystem's closed tickets, not the whole tracker's: an axis the
// header silently drops is an axis the reader cannot trust.
Report url.Values
}
// Filtered reports whether any report axis is set, for the clear link.
func (n ticketNav) Filtered() bool { return len(n.Report) > 0 || n.Title != "" }
// Clear is this view with every report axis dropped and the state, view
// and sort kept: "show me everything again", not "start over".
func (n ticketNav) Clear() string {
n.Report, n.Title = nil, ""
return n.URL("", "")
}
// Hidden is what the DROPDOWN form must restate: a GET form replaces
// the whole query string, so anything not carried here is discarded on
// the first dropdown change. The six report axes are deliberately
// absent — that form has a select for each of them already, and
// restating one would submit it twice.
func (n ticketNav) Hidden() []ticketField {
var out []ticketField
add := func(k, v string) {
if v != "" {
out = append(out, ticketField{Name: k, Value: v})
}
}
if n.State != "open" {
add("state", n.State)
}
add("view", n.View)
if n.View == "" {
add("sort", n.Sort)
}
if n.View == "table" {
add("order", n.Order)
}
add("title", n.Title)
return out
}
// ViewFields is the presentation axes alone, for the query box's form.
// That box's own text spells the state, the report and the free words,
// so restating any of them here would send each one twice.
func (n ticketNav) ViewFields() []ticketField {
var out []ticketField
for _, f := range n.Hidden() {
switch f.Name {
case "view", "sort", "order":
out = append(out, f)
}
}
return out
}
// Query is this listing spelled as a reader would type it.
func (n ticketNav) Query() string {
return ticketQueryString(n.State, n.Report, n.Title)
}
// with returns this nav with one axis replaced.
func (n ticketNav) with(axis, value string) ticketNav {
n.Offset = 0
switch axis {
case "state":
n.State = value
case "view":
n.View = value
case "sort":
n.Sort = value
case "order":
n.Order = value
}
return n
}
// params is the query this nav describes, and the ONE place that knows
// which axes a view carries: a board has group and lane, a table has a
// column order, a list has a sort. Building the page's links and its
// .json link from the same function is what keeps the two honest —
// the machine-readable URL is the human one with the view removed.
func (n ticketNav) params() url.Values {
q := url.Values{}
if n.Awaiting {
q.Set("awaiting", "1")
}
if n.State != "" && n.State != "open" {
q.Set("state", n.State)
}
if n.View != "" {
q.Set("view", n.View)
}
switch n.View {
case "board":
if n.Group != "" {
q.Set("group", n.Group)
}
if n.Lane != "" {
q.Set("lane", n.Lane)
}
case "table":
if n.Order != "" {
q.Set("order", n.Order)
}
default:
if n.Sort != "" {
q.Set("sort", n.Sort)
}
}
for k, vs := range n.Report {
q[k] = vs
}
if n.Title != "" {
q.Set("title", n.Title)
}
if n.Offset > 0 {
q.Set("offset", strconv.Itoa(n.Offset))
}
return q
}
// Facet is this listing narrowed to one value of a report axis — how a
// row's own chip becomes a way to see the rest of its kind.
func (n ticketNav) Facet(axis, value string) string {
n.Report = cloneValues(n.Report)
n.Report.Set(axis, value)
n.Offset = 0
return n.URL("", "")
}
// Path is where this listing lives — the one place that knows whether
// a control belongs to one tracker or to the cross-repository listing,
// so no form can post itself at a repository that is not there.
func (n ticketNav) Path() string { return n.path() }
func (n ticketNav) path() string {
if n.Base != "" {
return n.Base
}
return "/r/" + n.Repo + "/ticket"
}
// PageURL is this exact listing at another offset — the only way an
// offset is ever set, so no other control can strand a reader on a page
// that no longer exists.
func (n ticketNav) PageURL(offset int) string {
if offset < 0 {
offset = 0
}
n.Offset = offset
q := n.params()
if len(q) == 0 {
return n.path()
}
return n.path() + "?" + q.Encode()
}
// URL renders this nav with one axis replaced. An axis at its default
// contributes no parameter, so the plain tracker URL stays plain.
func (n ticketNav) URL(axis, value string) string {
q := n.with(axis, value).params()
if len(q) == 0 {
return n.path()
}
return n.path() + "?" + q.Encode()
}
// ticketReverse pairs the orders that have an opposite, so clicking the
// column you are already sorted by turns it around — the one thing a
// table header owes a reader. Columns with no pair simply stay put
// rather than pretending to toggle.
var ticketReverse = map[string]string{"updated": "oldest", "oldest": "updated"}
// OrderURL is one column header's link.
func (n ticketNav) OrderURL(key string) string {
if n.Order == key || (n.Order == "" && key == "updated") {
if rev, ok := ticketReverse[key]; ok {
return n.URL("order", rev)
}
}
return n.URL("order", key)
}
// OrderMark is the arrow on the column currently sorted by, if any.
func (n ticketNav) OrderMark(key string) string {
cur := n.Order
if cur == "" {
cur = "updated"
}
switch {
case cur == key:
return "\u25be" // sorted descending / natural
case ticketReverse[cur] == key:
return "\u25b4"
}
return ""
}
// Is reports whether an axis currently holds a value, for the "here"
// class. The empty state and "open" are the same state.
func (n ticketNav) Is(axis, value string) bool {
switch axis {
case "state":
cur := n.State
if cur == "" {
cur = "open"
}
return cur == value
case "view":
return n.View == value
case "sort":
return n.Sort == value
}
return false
}
// ticketChip is one value worth showing on a list row. A chip every row
// carries is not information: the old list printed status, priority and
// severity on all 71 rows, and 68 of them read "Open Medium Important".
// Only fields that DISTINGUISH a ticket earn one, which is why severity
// and priority appear solely in the urgent half of their vocabulary.
type ticketChip struct {
Text string
Class string
Title string
}
// ticketRow is a list row: the summary plus the two things the template
// should not compute — which glyph names its state, and which of its
// fields are worth a chip.
type ticketRow struct {
ticketSummary
// Repo is the tracker this row came from. Every row carries it, so
// one template serves a repository's tracker and the cross-repository
// listing without a link that guesses which page it is on.
Repo string
State string // open | review | done
Chips []ticketChip
}
// Href is the ticket this row stands for.
func (r ticketRow) Href() string { return "/r/" + r.Repo + "/ticket/" + r.ID }
// Glyph names the sprite symbol for this row's state. Fossil paints the
// whole row by status and needs a colour key above the table to explain
// itself; a glyph in the first column says the same thing in the place
// the eye already is, and reads without colour.
func (r ticketRow) Glyph() string {
switch r.State {
case "done":
return "i-tkt-done"
case "review":
return "i-auto" // the half-filled disc: started, not finished
}
return "i-tkt-open"
}
// urgent reports whether value sits in the leading half of its
// vocabulary — the half that means "look at this". A word the
// vocabulary does not list counts as urgent: an unknown value is
// exactly the kind of thing a reader should see.
func urgent(vocab []string, value string) bool {
if value == "" {
return false
}
for i, w := range vocab {
if strings.EqualFold(w, value) {
return i < len(vocab)/2
}
}
return true
}
// ticketFacet is one value of a browse field with its open count.
type ticketFacet struct {
Value string `json:"value"`
Open int `json:"open"`
}
// ticketPageSize is how many rows a paged view shows. Smaller than the
// board's bounded fetch: a page you scroll for a minute is not a page.
const ticketPageSize = 50
// pager is the position line under a listing: where the reader
// is, how much there is, and the two ways out. Both ends are rendered
// even when dead, so the control does not move under the cursor
// between pages.
type pager struct {
From, To, Total int
Prev, Next string
}
// pagerFor builds the pager for a listing that is ordered by SQL and so
// can be offset into. Nil when everything fits on one page — a pager
// under nine rows is furniture.
func pagerFor(nav ticketNav, shown, total int) *pager {
if shown == 0 || (nav.Offset == 0 && shown >= total) {
return nil
}
p := &pager{From: nav.Offset + 1, To: nav.Offset + shown, Total: total}
if nav.Offset > 0 {
p.Prev = nav.PageURL(nav.Offset - ticketPageSize)
}
if p.To < total {
p.Next = nav.PageURL(nav.Offset + ticketPageSize)
}
return p
}
// railLink is one entry in the left rail.
type railLink struct {
Label string
URL string
Count int
Here bool
// Bare suppresses the count for entries that are a way IN rather
// than a bucket — "recently updated" has no number of its own.
Bare bool
}
// railSection is a titled group of rail entries.
type railSection struct {
Title string
Links []railLink
}
// railLimit caps a browse group. A rail is a way in, not an inventory:
// past a dozen entries a reader scans past it, and the dropdown beside
// the listing already offers every value there is.
const railLimit = 8
// ticketRail builds the left rail: the standing ways into a tracker,
// then the two fields worth browsing by. Every entry is an ordinary
// report URL — the rail invents no query the address bar cannot say,
// which is why it needs no storage and stays honest about what a click
// will do.
func ticketRail(nav ticketNav, subs, authors, repos []ticketFacet, me string) []railSection {
var out []railSection
ways := railSection{Title: "views"}
add := func(sec *railSection, label, url string, count int, here, bare bool) {
sec.Links = append(sec.Links, railLink{Label: label, URL: url,
Count: count, Here: here, Bare: bare})
}
// The waiting-on-you view names its own author and state, so the
// state links would offer choices it cannot honour. The way out is
// the whole listing, not a state.
if nav.Awaiting {
add(&ways, "waiting on you", nav.URL("", ""), nav.Open, true, false)
out := nav
out.Awaiting = false
add(&ways, "all your tickets", out.URL("", ""), 0, false, true)
return []railSection{ways}
}
add(&ways, "open", nav.URL("state", "open"), nav.Open, nav.Is("state", "open"), false)
add(&ways, "everything", nav.URL("state", "all"), nav.Open+nav.Closed, nav.Is("state", "all"), false)
add(&ways, "recently updated", nav.URL("sort", "recent"), 0, nav.Is("sort", "recent"), true)
if me != "" {
n := nav
n.Report = cloneValues(nav.Report)
n.Report.Set("author", me)
add(&ways, "opened by me", n.URL("", ""), 0, nav.Report.Get("author") == me, true)
}
out = append(out, ways)
browse := func(title, axis string, fs []ticketFacet) {
if len(fs) == 0 {
return
}
sec := railSection{Title: title}
for i, f := range fs {
if i >= railLimit {
break
}
n := nav
n.Report = cloneValues(nav.Report)
here := nav.Report.Get(axis) == f.Value
if here {
n.Report.Del(axis) // a second click is how you get back out
} else {
n.Report.Set(axis, f.Value)
}
add(&sec, f.Value, n.URL("", ""), f.Open, here, false)
}
out = append(out, sec)
}
// Repository leads when there is one: on a cross-repository listing
// "where is this work" is answered by tracker before subsystem.
browse("repositories", "repo", repos)
browse("subsystems", "subsystem", subs)
browse("people", "author", authors)
return out
}
// cloneValues copies a report so a rail entry can vary one axis without
// every other entry inheriting the change.
func cloneValues(v url.Values) url.Values {
out := url.Values{}
for k, vs := range v {
out[k] = append([]string(nil), vs...)
}
return out
}
// ticketColumn is one table column: the order key it sorts by (a key of
// index.TicketOrders, never raw SQL) and what the header calls it.
type ticketColumn struct{ Key, Label string }
// ticketColumns is fossil's report table, which is the shape people who
// live in trackers actually scan: identity, then the triage fields as
// aligned columns you can run an eye down, then when it last moved.
// The list view is for reading titles; this is for comparing rows.
var ticketColumns = []ticketColumn{
{"id", "#"}, {"title", "title"}, {"status", "status"},
{"type", "type"}, {"subsystem", "subsystem"},
{"author", "opened by"}, {"updated", "updated"},
}
// ticketFilterControl is one report axis as a dropdown: the field, the
// words it offers, and which one is chosen.
type ticketFilterControl struct {
Key, Selected string
Options []string
}
// ticketFilters builds the report controls. The configured fields offer
// the project's own vocabulary in the project's own order (that is what
// .quarry/tickets.json is for); subsystem and author offer what the
// tracker actually holds, because no config can predict either. An axis
// with nothing to choose from is left out rather than rendered as an
// empty dropdown that answers nothing.
func ticketFilters(vocab map[string][]string, subs, authors []ticketFacet, q url.Values) []ticketFilterControl {
return ticketFiltersAcross(vocab, subs, authors, nil, q)
}
// ticketFiltersAcross is the same controls with the repository axis,
// which only the cross-repository listing offers.
func ticketFiltersAcross(vocab map[string][]string, subs, authors, repos []ticketFacet, q url.Values) []ticketFilterControl {
values := func(fs []ticketFacet) []string {
var out []string
for _, f := range fs {
out = append(out, f.Value)
}
return out
}
src := map[string][]string{
"type": vocab["type"], "status": vocab["status"],
"severity": vocab["severity"], "priority": vocab["priority"],
"subsystem": values(subs), "author": values(authors),
"repo": values(repos),
}
var out []ticketFilterControl
for _, k := range ticketAxesAcross {
if len(src[k]) == 0 {
continue
}
out = append(out, ticketFilterControl{
Key: k, Options: src[k], Selected: q.Get(k)})
}
return out
}
// ticketRows decorates summaries for the list. Fossil's own report
// pages colour a row by status and print every column; GitHub shows a
// state glyph, the title, and labels. This is the second shape over
// fossil's schema: type and subsystem read as labels, and the triage
// fields speak only when they are shouting.
// dominantShare is the fraction of a listing one value must cover before
// its chip is muted; dominantFloor is the smallest listing where a
// majority means anything rather than being an accident of three rows.
const (
dominantShare = 0.7
dominantFloor = 5
)
// dominant names the value of one field that says NOTHING on this page,
// because nearly every listed ticket shares it. A chip earns its box by
// distinguishing a ticket from its neighbours, so its neighbours are
// what decide: the old list printed status, priority and severity on all
// 71 rows and 68 of them read "Open Medium Important", and filtering to
// closed brought the identical noise back spelled "Fixed" on 34 of 34.
// Empty is never dominant — an unset field is already no chip.
func dominant(ts []ticketSummary, of func(ticketSummary) string) string {
if len(ts) < dominantFloor {
return ""
}
n := map[string]int{}
for _, t := range ts {
if v := of(t); v != "" {
n[v]++
}
}
for v, c := range n {
if float64(c) >= dominantShare*float64(len(ts)) {
return v
}
}
return ""
}
func ticketRows(repo string, ts []ticketSummary, vocab map[string][]string) []ticketRow {
// Keyed by the field name add() is called with, so a chip and its
// muted value cannot drift apart.
mute := map[string]string{
"type": dominant(ts, func(t ticketSummary) string { return t.Type }),
"subsystem": dominant(ts, func(t ticketSummary) string { return t.Subsystem }),
"severity": dominant(ts, func(t ticketSummary) string { return t.Severity }),
"priority": dominant(ts, func(t ticketSummary) string { return t.Priority }),
"resolution": dominant(ts, func(t ticketSummary) string { return t.Resolution }),
}
rows := make([]ticketRow, 0, len(ts))
for _, t := range ts {
r := ticketRow{ticketSummary: t, Repo: repo, State: "open"}
switch strings.ToLower(strings.TrimSpace(t.Status)) {
case "review", "testing", "tested", "pending":
r.State = "review"
default:
if statusClass(t.Status) == "st-done" {
r.State = "done"
}
}
add := func(text, class, title string) {
if text != "" && text != mute[title] {
r.Chips = append(r.Chips, ticketChip{Text: text, Class: class, Title: title})
}
}
add(t.Type, "c-type", "type")
add(t.Subsystem, "c-sub", "subsystem")
if urgent(vocab["severity"], t.Severity) {
add(t.Severity, "c-hot", "severity")
}
if urgent(vocab["priority"], t.Priority) {
add(t.Priority, "c-hot", "priority")
}
if r.State == "done" {
add(t.Resolution, "c-done", "resolution")
}
rows = append(rows, r)
}
return rows
}
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"`
Description string `json:"description"`
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 ""
}
// Closed reports whether the ticket is in a terminal status. Fossil's
// default vocabulary closes on "Closed" and "Fixed"; a tracker with
// other words still gets a Comment & close button that writes Closed.
func (t ticketDetail) Closed() bool {
switch strings.ToLower(t.Field("status")) {
case "closed", "fixed":
return true
}
return false
}
// ticketEvent is one entry of the ticket's timeline: a remark, a set of
// field changes, or both — fossil lets one artifact carry both, and a
// "closed with comment" is exactly that.
type ticketEvent struct {
Date string
Login string
Username string
HTML template.HTML
Deltas []fieldDelta
Redescribed bool // the change rewrote the description
}
// fieldDelta is one field's move, for the "status Open → Review" line.
type fieldDelta struct {
Name, From, To string
}
func (e ticketEvent) DifferentUsername() bool {
return e.Username != "" && e.Username != e.Login
}
// Summary is the event's verb phrase: "edited the description, retitled
// Short and changed status Open → Review". Empty for a
// plain remark.
func (e ticketEvent) Summary() template.HTML {
esc := template.HTMLEscapeString
var parts, moves []string
if e.Redescribed {
parts = append(parts, "edited the description")
}
for _, d := range e.Deltas {
if d.Name == "title" {
parts = append(parts, "retitled "+esc(d.To)+"")
continue
}
m := esc(d.Name) + " "
if d.From != "" {
m += esc(d.From) + " → "
}
if d.To != "" {
m += esc(d.To)
} else {
m += "—"
}
moves = append(moves, m+"")
}
if len(moves) > 0 {
parts = append(parts, "changed "+strings.Join(moves, ", "))
}
switch len(parts) {
case 0:
return ""
case 1:
return template.HTML(parts[0])
}
return template.HTML(strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1])
}
// timelineNoise is what a change artifact carries that is not a field
// the reader edits: the remark itself and the two mimetype columns.
var timelineNoise = map[string]bool{"icomment": true, "mimetype": true,
"comment": true, "cmimetype": true}
// ticketTimeline merges the rendered remarks with the field changes the
// artifacts record, oldest first. The first change is the ticket's
// birth — its fields are the description, not a move — so it only
// contributes its remark. Later changes walk forward from that state
// so each event knows where a field came from. A remark and the field
// changes written in the same artifact share a date, which is how they
// fold into one event.
func ticketTimeline(t ticketDetail, comments []ticketComment) []ticketEvent {
byDate := map[string]*ticketEvent{}
var out []*ticketEvent
at := func(date, login string) *ticketEvent {
if e := byDate[date]; e != nil {
return e
}
e := &ticketEvent{Date: date, Login: login}
byDate[date] = e
out = append(out, e)
return e
}
state := map[string]string{}
for i, c := range t.Changes {
if i == 0 {
for _, f := range c.Fields {
state[f.Name] = f.Value
}
continue
}
var deltas []fieldDelta
redescribed := false
for _, f := range c.Fields {
if f.Name == "comment" {
redescribed = true
}
if timelineNoise[f.Name] || privateFields[f.Name] {
continue
}
if state[f.Name] == f.Value {
continue
}
deltas = append(deltas, fieldDelta{Name: f.Name, From: state[f.Name], To: f.Value})
state[f.Name] = f.Value
}
if len(deltas) > 0 || redescribed {
e := at(c.Date, c.User)
e.Deltas = append(e.Deltas, deltas...)
e.Redescribed = e.Redescribed || redescribed
}
}
for _, c := range comments {
e := at(c.Date, c.Login)
e.Username, e.HTML = c.Username, c.HTML
if e.Login == "" {
e.Login = c.Login
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Date < out[j].Date })
res := make([]ticketEvent, len(out))
for i, e := range out {
res[i] = *e
}
return res
}
// ticketControl is one editable field in the sidebar: the words the
// tracker uses for it, with the current value kept even when the
// vocabulary does not list it — a select that refused a word the
// tracker already writes would make the ticket uneditable.
type ticketControl struct {
Name string
Value string
Options []string // empty: free text
}
// ticketControls builds the sidebar from fossil's triage fields in the
// order its view shows them, taking each field's words from the
// repository's vocabulary.
func ticketControls(t ticketDetail, vocab map[string][]string) []ticketControl {
var out []ticketControl
for _, name := range []string{"status", "type", "severity", "priority", "subsystem", "resolution"} {
c := ticketControl{Name: name, Value: t.Field(name)}
if words := vocab[name]; len(words) > 0 {
c.Options = append([]string(nil), words...)
if c.Value != "" && !slices.Contains(c.Options, c.Value) {
c.Options = append([]string{c.Value}, c.Options...)
}
}
out = append(out, c)
}
return out
}
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"`
DiscussedIn []forumThreadRow `json:"discussedIn"`
}
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"`
Renderable bool `json:"renderable"`
}
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"`
Structured []diff.FileDiff `json:"structured"`
OmittedLines int `json:"omittedLines"`
NextOffset int `json:"nextOffset"`
TotalLines int `json:"totalLines"`
LastOffset int `json:"lastOffset"`
// Page-side: the file the view is narrowed to, the offset this
// window began at, and the view mode — what the next-chunk URL
// has to repeat.
Repo string `json:"-"`
File string `json:"-"`
Offset int `json:"offset"` // snapped by the op to the hunk it starts on
Prev int `json:"-"` // offset of the window before this one; -1 when unknown
Split bool `json:"-"`
}
// Back is the URL of the window this one continued from, for the
// paged (no-script) reading: the continue link carries its own offset
// as prev, so one step back is always known. "" on the first window.
func (d diffDetail) Back() string {
if d.Offset == 0 || d.Prev < 0 {
return ""
}
return "?" + d.query(d.Prev, -1).Encode()
}
// Last is the URL of the final window, "" when this is it.
func (d diffDetail) Last() string {
if d.LastOffset <= d.Offset {
return ""
}
return "?" + d.query(d.LastOffset, -1).Encode()
}
// Start is the URL of the first window, same view.
func (d diffDetail) Start() string {
return "?" + d.query(0, -1).Encode()
}
func (d diffDetail) query(after, prev int) url.Values {
v := url.Values{}
if after > 0 {
v.Set("after", strconv.Itoa(after))
}
if prev >= 0 {
v.Set("prev", strconv.Itoa(prev))
}
if d.File != "" {
v.Set("file", d.File)
}
if d.Split {
v.Set("view", "split")
}
return v
}
// More is the URL of the window after this one, or "" at the end.
func (d diffDetail) More(frag bool) string {
if d.OmittedLines == 0 {
return ""
}
v := d.query(d.NextOffset, d.Offset)
if frag {
v.Del("prev")
// Absolute: the check-in page and its /diff deep link share
// this template, and a relative URL would resolve differently
// from each.
return "/r/" + d.Repo + "/checkin/" + d.To + "/diff.frag?" + v.Encode()
}
return "?" + v.Encode()
}
// Paged reports whether the diff spans more than this window, which
// is when the pager is worth drawing.
func (d diffDetail) Paged() bool { return d.Offset > 0 || d.OmittedLines > 0 }
// Continued reports whether this window opens mid-file.
func (d diffDetail) Continued() bool {
return len(d.Structured) > 0 && d.Structured[0].Before > 0 && len(d.Structured[0].Hunks) > 0
}
// diffWindow loads one window of a check-in's diff as the page shows
// it: ?file= narrows, ?after= continues, ?view=split widens.
func diffWindow(r *ops.Registry, req *http.Request, repo, hash string) (diffDetail, error) {
q := req.URL.Query()
after, _ := strconv.Atoi(q.Get("after"))
out, err := invoke(req.Context(), r, "checkin.diff", url.Values{
"repo": {repo}, "hash": {hash}, "file": {q.Get("file")},
"offset": {strconv.Itoa(max(after, 0))}, "maxLines": {diffBudget}})
if err != nil {
return diffDetail{}, err
}
var dd diffDetail
remarshal(out, &dd)
dd.Repo, dd.File, dd.Split = repo, q.Get("file"), q.Get("view") == "split"
dd.Prev = -1
if p, err := strconv.Atoi(q.Get("prev")); err == nil && p >= 0 && p < dd.Offset {
dd.Prev = p
}
return dd, nil
}
// diffBudget is the most diff lines a page renders. Past it, files
// show a count and a link to their own diff. ~3,000 keeps the whole
// page — diff, highlight, render — inside the response budget, and is
// already far more than anyone reads in one scroll.
const diffBudget = "3000"
// 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 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, "⟦", "")
esc = strings.ReplaceAll(esc, "⟧", "")
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
}
// CapSet is an expanded fossil capability set (RFC 0004). The web layer
// holds it structurally, never importing storage types.
type CapSet interface{ HasAny(letters string) bool }
// CapsAdmin is the settings editor's seam: read every account's caps
// on a repo, read and write the four category strings. Optional — a
// Caps plane that does not implement it simply has no editor.
type CapsAdmin interface {
CapHolders(repo string) ([]CapHolder, error)
CategoryCaps(repo, category string) string
SetCaps(account, repo, caps string) error
SetCategoryCaps(repo, category, caps string) error
}
// CapHolder is one account's stored caps on a repository.
type CapHolder struct {
Account string
Caps string
}
// Caps resolves effective capabilities for a viewer on a repository.
// A Sessions implementation that also satisfies this interface enables
// capability gating; without it the role floor is the whole story.
type Caps interface {
CapsOf(account, repo string) CapSet
}
// capsOf resolves the viewer's caps when the session plane provides
// them. The second return reports whether caps are available at all —
// callers fall back to role gating when they are not, so an
// unconfigured forge behaves exactly as before.
func capsOf(caps Caps, sess Sessions, verify Verify, repo string, req *http.Request) (CapSet, bool) {
c := caps
if c == nil {
sc, ok := sess.(Caps)
if !ok || sc == nil {
return nil, false
}
c = sc
}
return c.CapsOf(viewer(verify, sess, repo, req), repo), true
}
// mayCap answers "does this viewer hold any of these letters here?".
// Without a caps plane it falls back to the role floor the caller
// names, so every call site states both truths (RFC 0004 §5).
func mayCap(caps Caps, verify Verify, sess Sessions, repo string, letters string, floor ops.Auth, req *http.Request) bool {
if verify == nil {
return true
}
if c, ok := capsOf(caps, sess, verify, repo, req); ok {
return c.HasAny(letters)
}
return mayAt(verify, sess, repo, floor, req)
}
// 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.
// mayWrite answers whether this caller could write to the repository,
// WITHOUT challenging or refusing. gate answers the same question but
// as a decision that writes a response, which a page cannot use to
// decide whether to draw a control.
//
// It exists because the board's move control must follow write
// CAPABILITY, not session state: a caller authenticating with Basic
// auth can write and is not "signed in", and gating the control on
// SignedIn hid the only mechanism a no-script reader has for moving a
// card. Getting this wrong in the other direction is worse — a control
// shown to somebody who cannot use it — so it mirrors gateAt's decision
// rather than approximating it.
func mayWrite(verify Verify, sess Sessions, repo string, req *http.Request) bool {
return mayAt(verify, sess, repo, ops.Token, req)
}
// mayAt is mayWrite at an explicit capability — the announcement
// control follows Admin the way the board's move control follows Token.
func mayAt(verify Verify, sess Sessions, repo string, level ops.Auth, req *http.Request) bool {
if verify == nil {
return true
}
if sess != nil {
if user, ok := sess.Resolve(cookieToken(req)); ok {
return sess.Allow(user, repo, level)
}
}
user, pass, _ := req.BasicAuth()
return verify(repo, user, pass, level) == api.Allowed
}
// viewer resolves who is LOOKING at a page, without ever challenging:
// session identity first, then Basic credentials — mayWrite's order.
// "" is anonymous. It personalizes reads (the lit arrow on a vote the
// viewer already cast); it grants nothing.
func viewer(verify Verify, sess Sessions, repo string, req *http.Request) string {
if sess != nil {
if user, ok := sess.Resolve(cookieToken(req)); ok {
return user
}
}
if user, pass, ok := req.BasicAuth(); ok && verify != nil &&
verify(repo, user, pass, ops.Token) == api.Allowed {
return user
}
return ""
}
// gateCap is gate() refined by capability (RFC 0004): the Auth floor
// authenticates, then the letters decide. A caller holding a session
// but not the letters gets forbid() — "better credentials are not what
// is missing" — never a challenge. Without a caps plane it is exactly
// gateAt(floor), so a forge with no accounts behaves as before.
func gateCap(caps Caps, verify Verify, sess Sessions, repo, letters string, floor ops.Auth, w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
req, ok := gateAt(verify, sess, repo, floor, w, req)
if !ok {
return nil, false
}
// No verifier means no auth model at all — gateAt waves everyone
// through, and capabilities cannot be about a caller nobody
// identifies. An ungated forge stays ungated.
if verify == nil {
return req, true
}
c, have := capsOf(caps, sess, verify, repo, req)
if !have || c.HasAny(letters) {
return req, true
}
forbid(w, viewer(verify, sess, repo, req), repo, floor)
return nil, false
}
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.
// mirrorGate refuses a write PAGE on a read-only mirror the way the
// ops refuse the write itself: the page would only collect input the
// op is going to reject. The refusal names the upstream.
func mirrorGate(r *ops.Registry, w http.ResponseWriter, req *http.Request, repo string) bool {
info, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}})
if err != nil {
return true // an unknown repo fails later, with its own message
}
var p posture
remarshal(info, &p)
if !p.ReadOnly() {
return true
}
fail(r, w, req, fmt.Errorf("%w: %s mirrors %s and does not accept local writes", ops.ErrForbidden, repo, p.Mirror.URL))
return false
}
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, tracker string) {
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"`
Errors []errlogRow `json:"errors"`
ErrorTotal int `json:"errorTotal"`
}
remarshal(out, &fs)
// The header box filters THIS page: on the fleet page "search"
// means "find the store", and both tables narrow to the match.
q := req.URL.Query().Get("q")
if q != "" {
kept := fs.Repos[:0]
for _, row := range fs.Repos {
if matchesRepo(q, row.Name) {
kept = append(kept, row)
}
}
fs.Repos = kept
}
data := pageData{
Title: "fleet", FleetHealth: fs.Repos, Unhealthy: fs.Unhealthy,
SyncResults: sync, MirrorResults: pulls,
ServerErrors: fs.Errors, ErrorTotal: fs.ErrorTotal,
TrackerRepo: tracker, Query: q, SearchScope: "fleet", SearchAction: "/fleet",
}
// 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.MirrorCounts = map[string]int{}
for _, m := range ml.Mirrors {
if m.Enabled {
data.MirrorCounts[m.State]++
data.MirrorCounts["all"]++
}
if matchesRepo(q, m.Repo, m.URL, m.DetachedFrom) {
data.Mirrors = append(data.Mirrors, m)
}
}
if edit := req.URL.Query().Get("edit"); edit != "" {
for i := range ml.Mirrors {
if ml.Mirrors[i].Repo == edit {
data.MirrorEdit = &ml.Mirrors[i]
}
}
}
// The confirmation pages for the two posture changes.
if name := req.URL.Query().Get("detach"); name != "" {
for i := range ml.Mirrors {
if ml.Mirrors[i].Repo == name && ml.Mirrors[i].Enabled {
data.MirrorEdit = &ml.Mirrors[i]
data.Title = "detach " + name
render(r, w, req, mirrorDetachPage, data)
return
}
}
}
if name := req.URL.Query().Get("reattach"); name != "" {
for i := range ml.Mirrors {
if ml.Mirrors[i].Repo == name && !ml.Mirrors[i].Enabled {
data.MirrorEdit = &ml.Mirrors[i]
data.Title = "re-attach " + name
render(r, w, req, mirrorReattachPage, data)
return
}
}
}
}
data.FleetRows, data.FleetProblems = fleetRows(data.FleetHealth, data.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.
// WithCaps supplies the capability resolver (RFC 0004) independently of
// the sessions plane: capability enforcement and cookie login are
// separate concerns, and an API-only forge wants the first without the
// second.
func WithCaps(c Caps) func(*handlerOpts) {
return func(o *handlerOpts) { o.caps = c }
}
func WithSessions(s Sessions) func(*handlerOpts) {
return func(o *handlerOpts) { o.sessions = s }
}
type handlerOpts struct {
caps Caps
sessions Sessions
inbox Inbox
// tracker names the repository whose ticket tracker is the forge's
// own — where the fleet's "file ticket" buttons lead. Empty means
// no such repo is designated and the buttons are not drawn.
tracker string
}
// WithTracker designates the forge's own issue tracker.
func WithTracker(repo string) Option {
return func(o *handlerOpts) { o.tracker = repo }
}
// 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.
// recovered wraps the whole handler tree: a panic becomes a recorded
// journal entry and a clean 500 instead of a dropped connection and a
// stack trace nobody sees.
func recovered(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
defer func() {
if p := recover(); p != nil {
err := fmt.Errorf("panic: %v", p)
log.Printf("web: %s: %v\n%s", req.URL.Path, err, debug.Stack())
errlog.Default.Report("panic", req.URL.Path, err)
// Headers may already be gone; best effort.
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, req)
})
}
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
capsPlane := o.caps
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)
})
mux.HandleFunc("GET /inbox.unread", func(w http.ResponseWriter, req *http.Request) {
// The bar's dot: fired lazily by the page after render, so
// no page ever waits on notify to draw. Anonymous or outage
// answers "no dot" — a decoration must never 500 a page.
w.Header().Set("Content-Type", "text/event-stream")
user, ok := sessions.Resolve(cookieToken(req))
if !ok {
return
}
msgs, err := inbox.Messages(req.Context(), user)
if err != nil {
return
}
unread := 0
for _, m := range msgs {
if !m.Read {
unread++
}
}
if unread == 0 {
return
}
label := strconv.Itoa(unread)
if unread > 9 {
label = "9+"
}
fmt.Fprint(w, "event: datastar-patch-elements\n")
fmt.Fprintf(w, "data: elements %s\n\n", unread, label)
})
}
// 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/composer.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(composerJS)
})
mux.HandleFunc("GET /assets/wiki.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(wikiJS)
})
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)
})
// The cross-repository tracker: the same report machinery as one
// repository's, asked of every tracker the reader can reach. A
// person's work does not live in one repository, so this is the page
// that answers "what is on me" — the repository becomes one more
// browse axis rather than the address.
mux.HandleFunc("GET /tickets", func(w http.ResponseWriter, req *http.Request) {
id, ok := req.Context().Value(ctxIdentity{}).(identity)
if !ok || id.user == "" {
// Nothing here exists without an identity to scope it to.
http.Redirect(w, req, "/login?next=/tickets", http.StatusSeeOther)
return
}
q := req.URL.Query()
// The query box is input sugar here too, and canonicalises to
// this page's URL rather than a repository's.
if q.Has("q") {
v := ticketParseQuery(q.Get("q"))
for _, k := range []string{"view", "sort", "order", "repo"} {
if x := q.Get(k); x != "" {
v.Set(k, x)
}
}
dest := "/tickets"
if len(v) > 0 {
dest += "?" + v.Encode()
}
http.Redirect(w, req, dest, http.StatusSeeOther)
return
}
// Awaiting is a view, not an axis: it names its own author and
// state, so it cannot be combined with a report that would
// contradict it.
awaiting := q.Get("awaiting") == "1"
state := ticketState(q)
report := ticketReportAcross(q)
if awaiting {
state, report = "", url.Values{}
}
title := strings.TrimSpace(q.Get("title"))
view := q.Get("view")
if view != "table" {
// No board: its lanes are one tracker's vocabulary, and K
// trackers do not share one.
view = ""
}
order := q.Get("order")
if _, ok := index.TicketOrders[order]; !ok {
order = ""
}
sortBy := ""
if q.Get("sort") == "recent" {
sortBy = "recent"
}
byPriority := view != "table" && sortBy != "recent"
vals := url.Values{"limit": {strconv.Itoa(acrossLimit)}}
if awaiting {
vals.Set("awaiting", "1")
}
if state != "" {
vals.Set("state", state)
}
if view == "table" && order != "" {
vals.Set("order", order)
}
if title != "" {
vals.Set("title", title)
}
for k, vs := range report {
vals[k] = vs
}
out, err := invoke(ops.WithUser(req.Context(), id.user), r, "ticket.across", vals)
if err != nil {
fail(r, w, req, err)
return
}
var tl struct {
Tickets []struct {
ticketSummary
Repo string `json:"repo"`
} `json:"tickets"`
Open int `json:"open"`
Closed int `json:"closed"`
Repos []ticketFacet `json:"repos"`
Subsystems []ticketFacet `json:"subsystems"`
Authors []ticketFacet `json:"authors"`
Truncated bool `json:"truncated"`
}
remarshal(out, &tl)
// One vocabulary cannot describe K trackers, so the chips and the
// triage order here use the defaults rather than any one
// repository's words — the alternative is showing repository A's
// idea of "urgent" against repository B's rows.
vocab := ticketVocabFor(r, req, "")
var rows []ticketRow
for _, t := range tl.Tickets {
rows = append(rows, ticketRows(t.Repo, []ticketSummary{t.ticketSummary}, vocab)...)
}
if byPriority {
byTriageRows(rows, vocab)
}
nav := ticketNav{Base: "/tickets", State: state, Sort: sortBy, View: view,
Order: order, Title: title, Open: tl.Open, Closed: tl.Closed,
Report: report}
nav.Awaiting = awaiting
data := pageData{Title: "tickets", HideH1: true, Across: true,
Rows: rows, ByPriority: byPriority, Nav: nav,
Filters: ticketFiltersAcross(vocab, tl.Subsystems, tl.Authors, tl.Repos, q),
Columns: ticketColumns,
Shown: len(rows), Truncated: tl.Truncated}
data.Rail = ticketRail(data.Nav, tl.Subsystems, tl.Authors, tl.Repos, id.user)
if view == "table" {
render(r, w, req, ticketTablePage, data)
return
}
render(r, w, req, ticketListPage, data)
})
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, req *http.Request) {
// A signed-in reader's landing page is their dashboard —
// GitHub's rule. The fleet stays one query param away
// (?view=explore) and IS the page for everyone anonymous. A
// filter or sort in the URL means the reader is exploring, so
// those stay on the fleet too.
if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok && id.user != "" &&
req.URL.Query().Get("view") != "explore" && req.URL.Query().Get("q") == "" &&
req.URL.Query().Get("sort") == "" {
q := req.URL.Query()
kind, find := q.Get("kind"), strings.TrimSpace(q.Get("find"))
ctx := ops.WithUser(req.Context(), id.user)
mf, err := invoke(ctx, r, "me.feed", url.Values{"kind": {kind}, "find": {find}})
if err == nil {
var out dashData
remarshal(mf, &out)
out.Kind, out.Find = kind, find
out.CanCreate = id.isAdmin
// The band that leads the page: the caller's open
// tickets somebody else has answered and left with
// them. A dashboard is a glance, so it shows the few
// and links to the rest.
if aw, aerr := invoke(ctx, r, "ticket.across",
url.Values{"awaiting": {"1"}, "limit": {"6"}}); aerr == nil {
var tl struct {
Tickets []struct {
ticketSummary
Repo string `json:"repo"`
} `json:"tickets"`
}
remarshal(aw, &tl)
for _, t := range tl.Tickets {
out.Awaiting = append(out.Awaiting,
ticketRows(t.Repo, []ticketSummary{t.ticketSummary}, nil)...)
}
}
data := pageData{Title: "home", HideH1: true, Dash: &out}
// The explore link names the fleet's size, so "where are
// the rest of the repositories" answers itself.
if ro, rerr := invoke(req.Context(), r, "repo.list", url.Values{"limit": {"1"}}); rerr == nil {
var rl struct {
Total int `json:"total"`
}
remarshal(ro, &rl)
data.FleetTotal = rl.Total
}
render(r, w, req, dashboardPage, data)
return
}
// No grants, no accounts plane, any failure: the fleet is
// the honest fallback, never an error page for "/".
log.Printf("web: dashboard for %s: %v", id.user, err)
}
q, sortBy, kind := req.URL.Query().Get("q"), req.URL.Query().Get("sort"), req.URL.Query().Get("kind")
if sortBy == "" {
sortBy = "name"
}
out, err := invoke(req.Context(), r, "repo.list",
url.Values{"q": {q}, "sort": {sortBy}, "kind": {kind}, "limit": {"100"}})
if err != nil {
fail(r, w, req, err)
return
}
var fl struct {
Repos []repoInfo `json:"repos"`
Total int `json:"total"`
Served int `json:"served"`
}
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, Served: fl.Served,
Query: q, Sort: sortBy, Kind: kind}
if n != nil {
data.Live = true
}
render(r, w, req, fleetPage, data)
})
settingsFor := func(req *http.Request, repo string) (info repoInfo) {
out, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}})
if err != nil {
return repoInfo{}
}
remarshal(out, &info)
return info
}
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
}
info := settingsFor(req, repo)
render(r, w, req, settingsPage, pageData{
Title: "settings", HideH1: true, Repo: repo,
RepoDescription: info.Description, RepoPrivate: info.Private,
RepoHomepage: info.Homepage, RepoTopics: info.Topics,
Caps: capsPanelFor(capsPlane, repo),
CloneURL: cloneURL(req, repo), Deletable: deletable(r, req, repo),
})
})
mux.HandleFunc("POST /r/{repo}/settings/caps", func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
req, ok := gateCap(capsPlane, verify, sessions, repo, "a", ops.Admin, w, req)
if !ok {
return
}
admin, isAdmin := capsPlane.(CapsAdmin)
if !isAdmin {
http.Redirect(w, req, "/r/"+repo+"/settings", http.StatusSeeOther)
return
}
if err := req.ParseForm(); err != nil {
fail(r, w, req, err)
return
}
for key, vals := range req.Form {
if len(vals) == 0 {
continue
}
// Whitespace is not a capability; a stray space would
// silently become a letter nobody can read.
v := strings.Join(strings.Fields(vals[0]), "")
switch {
case strings.HasPrefix(key, "cat-"):
if err := admin.SetCategoryCaps(repo, strings.TrimPrefix(key, "cat-"), v); err != nil {
fail(r, w, req, err)
return
}
case strings.HasPrefix(key, "acct-"):
if err := admin.SetCaps(strings.TrimPrefix(key, "acct-"), repo, v); err != nil {
fail(r, w, req, err)
return
}
}
}
http.Redirect(w, req, "/r/"+repo+"/settings?saved=1", http.StatusSeeOther)
})
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 {
info := settingsFor(req, repo)
renderStatus(r, w, req, settingsPage, pageData{
Title: repo, Repo: repo, RepoDescription: info.Description, RepoPrivate: info.Private,
RepoHomepage: info.Homepage, RepoTopics: info.Topics,
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")},
"homepage": {req.FormValue("homepage")}, "topics": {req.FormValue("topics")}}
if req.FormValue("private") == "true" {
form.Set("private", "true")
}
data := pageData{Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
RepoDescription: req.FormValue("description"),
RepoHomepage: req.FormValue("homepage"),
RepoTopics: req.FormValue("topics"),
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")
}
switch req.FormValue("kind") {
case "clone":
form.Set("clone", req.FormValue("from"))
if every := req.FormValue("every"); every != "" {
form.Set("mirrorEvery", every)
}
case "own":
form.Set("clone", req.FormValue("from"))
form.Set("noMirror", "true")
default:
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, o.tracker)
})
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, o.tracker)
})
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/detach", func(w http.ResponseWriter, req *http.Request) {
req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
if !ok {
return
}
// No acknowledgement, no detach: the button on the table row
// leads to the confirmation page, never straight here.
if req.FormValue("ack") != "fork" {
http.Redirect(w, req, "/fleet?detach="+url.QueryEscape(req.FormValue("repo")), http.StatusSeeOther)
return
}
if _, err := invoke(req.Context(), r, "mirror.detach",
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/writes", 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.writes", url.Values{
"repo": {req.FormValue("repo")}, "accept": {req.FormValue("accept")}}); 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, o.tracker)
})
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.rev", func(w http.ResponseWriter, req *http.Request) {
if n == nil {
http.Error(w, "no live plane", http.StatusNotFound)
return
}
fleetRevStream(r, n, w, req)
})
mux.HandleFunc("GET /fleet.frag", func(w http.ResponseWriter, req *http.Request) {
fleetFrag(r, 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
}
// An empty version stays empty: the op substitutes the
// repository's DEFAULT branch, which is not always literally
// "trunk", and reports the name it used in Ref.
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) {
info := settingsFor(req, repo)
render(r, w, req, quickStartPage, pageData{
Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
RepoDescription: info.Description, RepoPrivate: info.Private,
})
return
}
fail(r, w, req, err)
return
}
var tl struct {
Version string `json:"version"`
Ref string `json:"ref"`
Path string `json:"path"`
Entries []treeRow `json:"entries"`
Readme string `json:"readme"`
ReadmeIn string `json:"readmeIn"`
}
remarshal(out, &tl)
if version == "" && tl.Ref != "" {
version = tl.Ref
}
data := pageData{
Title: repo, Repo: repo, Tree: tl.Entries, TreeVersion: version,
Readme: template.HTML(tl.Readme), ReadmeIn: tl.ReadmeIn,
CloneURL: cloneURL(req, repo),
}
for _, e := range tl.Entries {
if e.LastHash != "" {
data.TreeChanges = true
break
}
}
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 tl.Path == "" {
// GitHub's bar names the branch's tip; a hash version has no
// branch timeline, and the empty answer simply hides the bar.
if to, terr := invoke(req.Context(), r, "timeline.list", url.Values{
"repo": {repo}, "limit": {"1"}, "y": {"ci"}, "branch": {version}}); terr == nil {
var tt struct {
Entries []timelineEntry `json:"entries"`
}
remarshal(to, &tt)
// Only when the index's newest IS the tree being shown:
// while an index rebuild lags the store, the timeline's
// "newest" is years behind the listing zit just served,
// and a bar contradicting the files below it is worse
// than no bar.
if len(tt.Entries) == 1 && tt.Entries[0].Hash == tl.Version {
data.Tip = &tt.Entries[0]
}
}
}
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)
// The strip's count keeps ALL branches — the number a reader
// compares with the branch page — while the selector below
// lists only the open ones.
data.BranchTotal = len(bl.Branches)
bl.Branches = openBranches(bl.Branches, version)
// 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