current commit
261101.1 · server.go splits by purpose
package main

import (
	"bytes"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"path"
	"strings"
	"time"
	"unicode/utf8"
)

// leadStyle sets a list's --lead to the width of its longest label, so
// the labels that lead its rows line up: in monospace a width in ch fits
// every label exactly.
func leadStyle(labels []string) string {
	n := 0
	for _, l := range labels {
		n = max(n, utf8.RuneCountInString(l))
	}
	return fmt.Sprintf(" style=\"--lead:%dch\"", n)
}

// errorLine is a failure's message as a page shows it.
func errorLine(msg string) string {
	return "<pre class=\"error\">" + template.HTMLEscapeString(msg) + "</pre>\n"
}

// segments writes a path or URL as one box per name, each ending in its
// slash, so a line breaks between names and not inside one. The // of a
// URL stays with its scheme.
func segments(p string) string {
	var parts []string
	for _, s := range strings.SplitAfter(p, "/") {
		switch {
		case s == "":
		case s == "/" && len(parts) > 0:
			parts[len(parts)-1] += s
		default:
			parts = append(parts, s)
		}
	}
	for i, s := range parts {
		parts[i] = `<span class="seg">` + template.HTMLEscapeString(s) + `</span>`
	}
	return strings.Join(parts, "<wbr>")
}

type crumb struct {
	Label string
	Href  string
	Title string // shown on hover, eg the tree's full path
}

// indexCrumb leads every tree page's breadcrumbs to the index, the parent
// of all trees. Its label is not www, which a tree may be called.
var indexCrumb = crumb{Label: "index", Href: "/"}

type page struct {
	Title   string
	Crumbs  []crumb
	Kind    string
	Body    template.HTML
	Raw     bool
	Tree    string        // prefix the stop button withdraws, or "" for every tree
	Search  string        // the tree's URL, which answers ?search=, or "" off a tree
	Recents string        // the tree whose pages a search lists as recent; a version's is its tree's
	Status  template.HTML // the server's age, on the index, or a repo's commit on the web address
	Style   template.CSS  // the stylesheet inline, for a page the server may not outlive
	Stopped bool
	Bug     bool   // the bug button at the page's foot
	Shared  bool   // a shared server's page, which has no button
	NoStop  bool   // a page on the web address, which has no button
	Live    string // the URL of the page's live reload stream, or ""
	Fixed   bool   // a version, which never changes, or a page that must not reload: the script runs but holds no stream
	// Forge is, for a page of a repo on the web address, the forge's own
	// permanent URL for it, which the share button copies; ForgeLines is
	// how that forge marks lines.
	Forge, ForgeLines string
	Built             *stamp // when the binary was built, above the header, with --debug; none on a share
}

func (t *tree) title(name string) string {
	at := ""
	if t.at != nil {
		at = " @ " + t.atLabel
	}
	if name == "." {
		return path.Base(t.label) + at
	}
	if at != "" {
		return name + at
	}
	return name
}

// rootCrumb is the tree's first breadcrumb: its base name, with the full
// path on hover. The address bar already shows the path.
func (t *tree) rootCrumb() crumb {
	if t.at != nil {
		return crumb{Label: path.Base(t.label) + " @ " + t.atLabel, Title: t.label + " as of " + t.at.hash}
	}
	return crumb{Label: path.Base(t.label), Title: t.label}
}

// crumbs links the index, the root and every parent directory; the last
// part is plain.
func (t *tree) crumbs(name string) []crumb {
	root := t.rootCrumb()
	out := []crumb{indexCrumb}
	// A repo on the web address links its host and account, which list
	// their repos.
	if t.web != nil {
		segs := strings.Split(t.web.key, "/")
		for i := range segs[:len(segs)-1] {
			out = append(out, crumb{Label: segs[i], Href: (&url.URL{Path: "/" + strings.Join(segs[:i+1], "/") + "/"}).String()})
		}
	}
	if name == "." {
		return append(out, root)
	}
	root.Href = t.prefixURL()
	out = append(out, root)
	parts := strings.Split(name, "/")
	for i, p := range parts {
		c := crumb{Label: p}
		if i < len(parts)-1 {
			c.Href = (&url.URL{Path: t.prefix + strings.Join(parts[:i+1], "/") + "/"}).String()
		}
		out = append(out, c)
	}
	return out
}

func render(w http.ResponseWriter, r *http.Request, status int, p page) {
	if p.Live == "" {
		p.Live = liveURL(r)
	}
	p.Bug = !bugOff && !p.Shared && !p.Stopped
	if debugOn && !p.Shared && !built.IsZero() {
		p.Built = &stamp{Clock: clock(built), Millis: built.UnixMilli(), Ago: int(time.Since(built).Seconds()),
			Commit: buildCommit, Rendered: rendered(r)}
	}
	var b bytes.Buffer
	// The body is most of the page, and can be megabytes.
	b.Grow(len(p.Body) + 16<<10)
	if err := layout.ExecuteTemplate(&b, "page", p); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	// no-store keeps rendered pages out of the back/forward cache, so Back
	// after Stop shows the server is gone instead of a dead snapshot.
	w.Header().Set("Cache-Control", "no-store")
	w.WriteHeader(status)
	w.Write(b.Bytes())
}

// render gives the page this tree's Delete lock button, unless the tree is
// shared, and a search box for the directory the page is in, with a button
// that copies the page's path on disk. A failed page searches the whole
// tree, since its directory may not exist, and has no path to copy.
// Recent pages stay within the tree, so one repo's reading never shows in
// another's search.
func (t *tree) render(w http.ResponseWriter, r *http.Request, status int, p page) {
	p.Tree, p.Shared, p.Search = t.prefix, t.shared, t.prefixURL()
	p.Recents = t.prefixURL()
	if t.base != nil {
		p.Recents = t.base.prefixURL()
	}
	if t.web != nil {
		p.NoStop, p.Status = true, t.web.status(r.Context(), t.prefix, r.URL.Path)
	}
	// A version never changes and has no lock: no live reload and no stop
	// button. Its page still runs the script, for the keys, the search box,
	// line marks and the copy button.
	if t.at != nil {
		p.NoStop, p.Fixed = true, true
	}
	if status == http.StatusOK {
		p.Search = searchURL(r.URL.Path)
		p.Forge, p.ForgeLines = t.forgeLink(r, p.Kind)
	}
	render(w, r, status, p)
}

// searchURL is the directory a page at urlPath is in: a listing itself,
// or a file's directory.
func searchURL(urlPath string) string {
	return (&url.URL{Path: urlPath[:strings.LastIndex(urlPath, "/")+1]}).String()
}

// why is what a refusal page says: the reason in full, or on a shared or
// public tree only that the name is refused, since the reason names paths
// outside it.
func (t *tree) why(ref *refusal) string {
	if t.shared || t.public {
		return "Refused: " + ref.link
	}
	return ref.Error()
}

// coin is a status code that turns like a coin above a 404's table, or a
// stop's 410.
func coin(code string) string {
	return `<p class="coin" aria-hidden="true"><span><span>` + code + `</span><span>` + code + `</span></span></p>`
}

// fullURL is the URL r asked for, without its query, so "not found"
// names it whole when copied away from the address bar.
func fullURL(r *http.Request) string {
	return (&url.URL{Scheme: "http", Host: r.Host, Path: r.URL.Path}).String()
}

// notFound is the page for a path the tree lacks: 404 turns like a coin
// above a table of labelled rows, the URL first, then where git says the
// path went and the names close to it. Other failures say why in the
// plain line alone.
func (t *tree) notFound(w http.ResponseWriter, r *http.Request, name string) {
	t.render(w, r, http.StatusNotFound, page{
		Title:  t.title(name),
		Crumbs: t.crumbs(name),
		Kind:   "error",
		Body:   template.HTML(coin("404") + `<table class="gone">` + "\n" + notFoundRow(r) + t.goneRows(r.Context(), name) + "</table>\n"),
	})
}

func (t *tree) fail(w http.ResponseWriter, r *http.Request, name string, status int, msg string) {
	// The request's own entry says not found; a refusal or fault says why
	// only here.
	switch {
	case status >= 500:
		logErr("%s: %s", t.dir, msg)
	case status != http.StatusNotFound:
		logInfo("%s: %s", t.dir, msg)
	}
	t.render(w, r, status, page{
		Title:  t.title(name),
		Crumbs: t.crumbs(name),
		Kind:   "error",
		Body:   template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(msg) + "</pre>"),
	})
}