socle

pages.go

package main

import (
	"bytes"
	"errors"
	"fmt"
	"html/template"
	"mime"
	"net/http"
	"path"
	"sort"
	"strings"

	"github.com/go-enry/go-enry/v2"

	"vitrine/display"
)

// This file builds the browse pages: HTML content fragments (html/template throughout,
// so everything that came out of a repo is escaped) placed into pageData.Content,
// with the gallery-sketchbook chrome coming from chrome.go and markdown rendering
// from the display package.

// logLimit caps the commit log page; history older than this is reachable by
// following parent shas (and a real pager can come later if it's ever missed).
const logLimit = 100

// render executes one content-fragment template; a failure is an internal error (500).
// The snapshot supplies the site identity, so every page wears the face of the config
// it was served under.
func (s *server) render(w http.ResponseWriter, snap *snapshot, tmpl *template.Template, data any, pd pageData) {
	var buf bytes.Buffer
	if err := tmpl.Execute(&buf, data); err != nil {
		httpError(w, err)
		return
	}
	pd.SiteName = snap.face.siteName
	pd.Content = template.HTML(buf.String())
	s.renderPage(w, pd)
}

// treeHref builds the browse URL for a path at a ref; dirs get a trailing slash by the caller.
// Escaping is left to html/template's URL normalization in the templates.
func treeHref(name, ref, pth string) string {
	h := "/" + name + "/tree/" + ref
	if pth != "" {
		h += "/" + pth
	}
	return h
}

func rawHref(name, ref, pth string) string {
	return "/" + name + "/raw/" + ref + "/" + pth
}

// isImagePath reports whether a filename looks like an image a browser renders natively:
// those inline via the raw URL instead of getting a text or download treatment.
func isImagePath(pth string) bool {
	switch strings.ToLower(path.Ext(pth)) {
	case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".ico":
		return true
	}
	return false
}

// rawContentType picks the Content-Type for a raw blob response, by extension only
// (blob content is not consulted).  Markdown is forced to text/plain so browsers display
// rather than download it.  Empty means "let net/http sniff the first bytes".
func rawContentType(pth string) string {
	ext := strings.ToLower(path.Ext(pth))
	if ext == ".md" {
		return "text/plain; charset=utf-8"
	}
	return mime.TypeByExtension(ext)
}

// crumbLink is one segment of the breadcrumb nav; an empty Href renders as plain text.
type crumbLink = display.Crumb

// repoCrumbs starts a breadcrumb trail: site, then the repo (its full name as one crumb,
// slashes and all: intermediate name segments are not pages).
func repoCrumbs(site, name string, more ...crumbLink) []display.Crumb {
	cs := []display.Crumb{{Name: site, Href: "/"}, {Name: name, Href: "/" + name + "/"}}
	cs = append(cs, more...)
	cs[len(cs)-1].Href = "" // the last crumb is the page being viewed
	return cs
}

// ---- index ----

// The entrance: the marquee first -- the site's own name on the grandest pedestal,
// the gallery itself as the first exhibit -- then every repo as an exhibit stand:
// the name atop a pedestal (the ground glyph's family), the description as its
// placard, and the newest commit subject as a one-line "now showing" caption in soft ink.
var indexTmpl = template.Must(template.New("index").Parse(`<header class="marquee">
<p class="sitename">{{.SiteName}}</p>
<div class="pedestal"></div>
</header>
<h1>repositories</h1>
<ul class="entrance">
{{range .Rows}}<li class="stand">
<p class="name"><a href="/{{.Name}}/">{{.Name}}</a></p>
<div class="pedestal"></div>
{{if or .Description .LangColor}}<p class="placard">{{if .LangColor}}<span class="swatch" style="background:{{.LangColor}}" title="{{.Lang}}"></span>{{end}}{{.Description}}</p>
{{end}}{{if .Head}}<p class="showing" title="latest commit on {{.Branch}}">{{.Head}}</p>
{{end}}</li>
{{end}}</ul>
`))

type indexRow struct {
	Name        string
	Description string
	Branch      string // the default branch the caption was read from
	Head        string // subject of the newest commit on the default branch; empty if none
	Lang        string // dominant language at that commit; empty if nothing classified
	LangColor   string // its linguist color; the placard dot only shows when one exists
}

func (s *server) serveIndex(w http.ResponseWriter, r *http.Request, snap *snapshot) {
	var rows []indexRow
	for _, name := range sortedNames(snap.repos) {
		rp := snap.repos[name]
		row := indexRow{Name: name, Description: rp.description, Branch: rp.defaultBranch()}
		// Enrichment is best-effort, matching the caption's existing stance:
		// the entrance still stands when one repo cannot answer.
		if cs, err := rp.commits(row.Branch, 1); err == nil && len(cs) > 0 {
			row.Head = cs[0].subject
			if stats, err := s.langStats(rp, cs[0].hash); err == nil && len(stats) > 0 {
				row.Lang = stats[0].name
				row.LangColor = enry.GetColor(stats[0].name)
			}
		}
		rows = append(rows, row)
	}
	pd := pageData{
		Title:  snap.face.siteName,
		Desc:   "the " + snap.face.siteName + " code host: git repositories to browse and clone",
		Crumbs: []display.Crumb{{Name: snap.face.siteName}},
	}
	if snap.baseURL != "" {
		pd.Canon = snap.baseURL + "/"
	}
	s.render(w, snap, indexTmpl, struct {
		SiteName string
		Rows     []indexRow
	}{snap.face.siteName, rows}, pd)
}

// ---- the empty room (404) ----

// A 404 is an address with nothing on display: one bare pedestal (the footer's
// "nothing more" mark, center-room), the reason in the museum-label register,
// and the way back to the entrance.
var notFoundTmpl = template.Must(template.New("notfound").Parse(`<div class="emptyroom">
<div class="pedestal"></div>
<p class="nothing">{{.Reason}}</p>
<p class="back"><a class="boxed" href="/">to the entrance</a></p>
</div>
`))

func (s *server) notFoundPage(w http.ResponseWriter, snap *snapshot, reason string) {
	s.render(w, snap, notFoundTmpl, struct{ Reason string }{reason}, pageData{
		Title:  "nothing here · " + snap.face.siteName,
		Status: http.StatusNotFound,
		Crumbs: []display.Crumb{{Name: snap.face.siteName, Href: "/"}, {Name: "?"}},
	})
}

// ---- repo home ----

// The repo home: the vitals come first -- the clone line and the language meter in one
// compact card, the useful impressions offered before the README's exhibit takes over --
// then the framed README with its plaque, and the branch list at the bottom.
var homeTmpl = template.Must(template.New("home").Parse(`{{if .Description}}<p class="tagline">{{.Description}}</p>
{{end}}{{if .Empty}}<p>This repository has no commits yet.</p>
<p class="clone" id="clone">clone: <code>git clone {{.CloneURL}}</code></p>
{{else}}<div class="vitals boxed">
<p class="clone" id="clone">clone: <code>git clone {{.CloneURL}}</code></p>
{{if .Langs}}<div class="langmeter">
<div class="langbar">{{range .Langs}}<span class="seg{{if not .Color}} other{{end}}" style="flex-grow:{{.Pct}}{{if .Color}};background:{{.Color}}{{end}}" title="{{.Name}} {{.Pct}}%"></span>{{end}}</div>
<p class="langchips">{{range .Langs}}<span class="chip"><span class="swatch{{if not .Color}} other{{end}}"{{if .Color}} style="background:{{.Color}}"{{end}}></span>{{.Name}} {{.Pct}}%</span>{{end}}</p>
</div>
{{end}}</div>
{{if .Readme}}<div class="exhibit">{{.Readme}}</div>
<p class="plaque">{{.ReadmeName}} · {{.ReadmeSize}} · {{.Ref}} = <code>{{.ShortSha}}</code></p>
{{end}}<p id="branches">branches: {{range $i, $b := .Branches}}{{if $i}}, {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}}{{if .Tags}}
· tags: {{range $i, $t := .Tags}}{{if $i}}, {{end}}<a href="{{$t.Href}}">{{$t.Name}}</a>{{end}}{{end}}</p>
{{end}}`))

type refLink struct {
	Name string
	Href string
}

type homeData struct {
	Description string
	CloneURL    string
	Empty       bool // no commits yet: nothing to browse
	Branches    []refLink
	Tags        []refLink
	Readme      template.HTML
	ReadmeName  string
	ReadmeSize  string // human-readable; the plaque is a label, not a listing
	Ref         string
	ShortSha    string
	Langs       []langSeg
}

// meterMax caps the bar's segments; everything past it folds into "other".
const meterMax = 8

// langSeg is one segment of the language meter, as the template wants it.
type langSeg struct {
	Name  string
	Pct   string // share of counted bytes, one decimal; doubles as the segment's flex-grow
	Color string // linguist's conventional hex, a swatch only; empty wears the neutral class
}

// langSegments shapes language stats for the meter: the top languages by bytes,
// with the tail -- and anything whose share rounds below a visible 0.1% -- folded
// into one trailing "other" segment; an "other" that itself rounds invisible is
// dropped rather than shown as a 0.0% chip.  No classified bytes means no meter.
func langSegments(stats []langStat) []langSeg {
	var total int64
	for _, st := range stats {
		total += st.bytes
	}
	if total == 0 {
		return nil
	}
	pct := func(bytes int64) string {
		return fmt.Sprintf("%.1f", float64(bytes)*100/float64(total))
	}
	var segs []langSeg
	var other int64
	for i, st := range stats {
		if i >= meterMax-1 && len(stats) > meterMax || pct(st.bytes) == "0.0" {
			other += st.bytes
			continue
		}
		segs = append(segs, langSeg{Name: st.name, Pct: pct(st.bytes), Color: enry.GetColor(st.name)})
	}
	if p := pct(other); other > 0 && p != "0.0" {
		segs = append(segs, langSeg{Name: "other", Pct: p})
	}
	return segs
}

func (s *server) serveHome(w http.ResponseWriter, r *http.Request, snap *snapshot, rp *repo) {
	// The clone URL: the configured canonical address when there is one,
	// else derived from the request (right whenever no rewriting proxy intervenes).
	cloneURL := snap.baseURL + "/" + rp.name
	if snap.baseURL == "" {
		scheme := "http"
		if r.TLS != nil {
			scheme = "https"
		}
		cloneURL = scheme + "://" + r.Host + "/" + rp.name
	}
	db := rp.defaultBranch()
	d := homeData{
		Description: rp.description,
		CloneURL:    cloneURL,
		Ref:         db,
	}
	branches, tags, err := rp.refs()
	if err != nil {
		s.pageError(w, snap, err)
		return
	}
	for _, b := range branches {
		d.Branches = append(d.Branches, refLink{b.name, treeHref(rp.name, b.name, "") + "/"})
	}
	for _, t := range tags {
		d.Tags = append(d.Tags, refLink{t.name, treeHref(rp.name, t.name, "") + "/"})
	}
	sha, err := rp.resolveCommit("HEAD")
	switch {
	case err == nil:
		var rm readmeInfo
		d.Readme, rm, err = s.renderReadme(rp, db, sha)
		if err != nil {
			s.pageError(w, snap, err)
			return
		}
		d.ReadmeName, d.ReadmeSize, d.ShortSha = rm.name, humanSize(rm.size), shortSha(sha)
		stats, err := s.langStats(rp, sha)
		if err != nil {
			s.pageError(w, snap, err)
			return
		}
		d.Langs = langSegments(stats)
	case errors.Is(err, errNotFound):
		d.Empty = true // an unborn HEAD: a freshly-init'd repo is fine to show, just bare
	default:
		s.pageError(w, snap, err)
		return
	}
	pd := pageData{
		Title:  rp.name,
		Desc:   rp.description,
		Crumbs: repoCrumbs(snap.face.siteName, rp.name),
	}
	if pd.Desc == "" {
		pd.Desc = "a git repository on display at " + snap.face.siteName
	}
	if snap.baseURL != "" {
		pd.Canon = snap.baseURL + "/" + rp.name + "/"
	}
	// No clone button on the arc: the clone line sits right below it in the vitals card,
	// and a button that scrolls to something already in view is a non-move.
	if !d.Empty {
		pd.Dawn = true
		pd.Arc = dawnArc([]arcButton{
			{Label: "files", Title: "browse the tree at " + db, Href: treeHref(rp.name, db, "") + "/"},
			{Label: "log", Title: "commit log of " + db, Href: "/" + rp.name + "/log/" + db},
			{Label: "branches", Title: "branches and tags", Href: "#branches"},
		})
	}
	s.render(w, snap, homeTmpl, d, pd)
}

// readmeInfo is what the home plaque says about the README it accompanies.
type readmeInfo struct {
	name string
	size int64
}

// renderReadme finds and renders the README blob in the root tree at a commit, if any:
// the first root entry named readme.md (any case) renders as markdown;
// failing that, readme or readme.txt (any case) shows preformatted.  No README is fine.
// ref is used for link targets so browsing stays on the branch the viewer arrived at.
func (s *server) renderReadme(rp *repo, ref, sha string) (template.HTML, readmeInfo, error) {
	entries, err := rp.lsTree(sha, "")
	if err != nil {
		return "", readmeInfo{}, err
	}
	var info readmeInfo
	markdown := false
	for _, e := range entries {
		if e.typ != "blob" || e.size > display.MaxRenderSize {
			continue
		}
		switch strings.ToLower(e.name) {
		case "readme.md":
			info, markdown = readmeInfo{e.name, e.size}, true
		case "readme", "readme.txt":
			if info.name == "" {
				info = readmeInfo{e.name, e.size}
			}
		}
	}
	if info.name == "" {
		return "", info, nil
	}
	var buf bytes.Buffer
	if err := rp.catBlob(sha, info.name, &buf); err != nil {
		return "", info, err
	}
	if markdown {
		html, err := display.Markdown(buf.Bytes())
		return html, info, err
	}
	var out bytes.Buffer
	if err := preTmpl.Execute(&out, buf.String()); err != nil {
		return "", info, err
	}
	return template.HTML(out.String()), info, nil
}

// ---- tree listing ----

// The catalogue: listings keep their tabular bones, with light hand-ruled row
// separators and a drawer-tab mark ahead of directory names (the mark is the
// link's own background, so it is part of the tap target).
var treeTmpl = template.Must(template.New("tree").Parse(`{{template "kicker" .Kicker}}<h1>{{.Title}}</h1>
<table class="tree">
<tbody>
{{range .Rows}}<tr><td class="name{{if .Dir}} dir{{end}}">{{if .Href}}<a href="{{.Href}}">{{.Name}}</a>{{else}}{{.Name}}{{end}}</td><td class="size">{{.Size}}</td></tr>
{{end}}</tbody>
</table>
`))

// kickerTmpl is the shared context line above a page heading: which project this
// sheet belongs to, linking home.  Deep pages re-assert it because a file's name
// matters, but so does whose exhibit it is; empty means the heading already says so.
var kickerTmpl = template.Must(template.New("kicker").Parse(`{{if .}}<p class="kicker"><a href="/{{.}}/">{{.}}</a></p>
{{end}}`))

type treeRow struct {
	Name string
	Href string // empty renders unlinked (submodules)
	Size string // blob size in bytes; empty for non-blobs
	Dir  bool   // directories wear the drawer-tab mark; the ".." row does not (it is nav, not contents)
}

type treeData struct {
	Title  string
	Kicker string // the repo name, when the title doesn't already say it
	Rows   []treeRow
}

func (s *server) serveTreeListing(w http.ResponseWriter, snap *snapshot, rp *repo, ref, sha, pth string) {
	entries, err := rp.lsTree(sha, pth)
	if err != nil {
		s.pageError(w, snap, err)
		return
	}
	d := treeData{Title: pth, Kicker: rp.name}
	if pth == "" {
		// The root listing's heading is the repo name itself; a kicker would stutter.
		d.Title, d.Kicker = rp.name, ""
	}
	if pth != "" {
		d.Rows = append(d.Rows, treeRow{Name: "..", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"})
	}
	// Directories list before files (the usual forge convention), each group in git's own order.
	sort.SliceStable(entries, func(i, j int) bool {
		return entries[i].typ == "tree" && entries[j].typ != "tree"
	})
	for _, e := range entries {
		full := e.name
		if pth != "" {
			full = pth + "/" + e.name
		}
		row := treeRow{Name: e.name}
		switch e.typ {
		case "tree":
			row.Name += "/"
			row.Href = treeHref(rp.name, ref, full) + "/"
			row.Dir = true
		case "blob":
			row.Href = treeHref(rp.name, ref, full)
			row.Size = humanSize(e.size)
		default: // a submodule pointer: nothing here to link to
			row.Name += " @ " + shortSha(e.hash)
		}
		d.Rows = append(d.Rows, row)
	}
	btns := []arcButton{}
	if pth != "" {
		btns = append(btns, arcButton{Label: "up", Title: "parent directory", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"})
	}
	btns = append(btns,
		arcButton{Label: "log", Title: "commit log of " + shortSha(ref), Href: "/" + rp.name + "/log/" + ref},
		arcButton{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
	)
	s.render(w, snap, treeTmpl, d, pageData{
		Title:  pageTitle(rp.name, ref, pth),
		Crumbs: treeCrumbs(snap.face.siteName, rp.name, ref, pth),
		Arc:    cornerArc(btns),
	})
}

// treeCrumbs: site / repo / ref / path segments, each linking to its listing.
func treeCrumbs(site, name, ref, pth string, more ...crumbLink) []display.Crumb {
	cs := []crumbLink{{Name: ref, Href: treeHref(name, ref, "") + "/"}}
	if pth != "" {
		segs := strings.Split(pth, "/")
		for i, seg := range segs {
			cs = append(cs, crumbLink{Name: seg, Href: treeHref(name, ref, strings.Join(segs[:i+1], "/")) + "/"})
		}
	}
	return repoCrumbs(site, name, append(cs, more...)...)
}

// humanSize renders a byte count the way a listing wants it: exact while it is small,
// then one decimal of the binary unit.  (The raw view's Content-Length keeps the exact number.)
func humanSize(n int64) string {
	if n < 1024 {
		return fmt.Sprintf("%d B", n)
	}
	v, unit := float64(n)/1024, "KiB"
	for _, u := range []string{"MiB", "GiB", "TiB"} {
		if v < 1024 {
			break
		}
		v, unit = v/1024, u
	}
	return fmt.Sprintf("%.1f %s", v, unit)
}

// parentDir is path.Dir for tree paths: "" (the root), not ".", for a top-level entry.
func parentDir(pth string) string {
	if d := path.Dir(pth); d != "." {
		return d
	}
	return ""
}

func pageTitle(name, ref, pth string) string {
	t := name
	if pth != "" {
		t += "/" + pth
	}
	return t + " @ " + shortSha(ref)
}

// shortSha abbreviates full hex shas for display; anything else (branch names) passes through.
func shortSha(ref string) string {
	if len(ref) == 40 && !strings.ContainsFunc(ref, func(c rune) bool { return !isHex(c) }) {
		return ref[:10]
	}
	return ref
}

func isHex(c rune) bool {
	return c >= '0' && c <= '9' || c >= 'a' && c <= 'f'
}

// ---- file views ----

// File views are exhibits too: content in a rough frame, the plaque beneath carrying
// path, size, and the commit the blob was read at.
var (
	preTmpl    = template.Must(template.New("pre").Parse(`<pre class="file">{{.}}</pre>`))
	plaqueTmpl = template.Must(template.New("plaque").Parse(`<p class="plaque">{{.Path}} · {{.Size}} · <code>{{.ShortSha}}</code></p>
`))
	imageTmpl = template.Must(template.New("image").Parse(`{{template "kicker" .Kicker}}<h1>{{.Name}}</h1>
<div class="exhibit"><p><img class="file" src="{{.RawHref}}" alt="{{.Name}}"></p></div>
{{template "plaque" .Plaque}}`))
	fileTmpl = template.Must(template.New("file").Parse(`{{template "kicker" .Kicker}}<h1>{{.Name}}</h1>
<div class="exhibit">{{.Body}}</div>
{{template "plaque" .Plaque}}`))
	rawLinkTmpl = template.Must(template.New("rawlink").Parse(`{{template "kicker" .Kicker}}<h1>{{.Name}}</h1>
<p>{{.Size}} bytes of {{.Kind}}; <a href="{{.RawHref}}">view raw</a>.</p>
`))
)

func init() {
	// The plaque fragment is shared by the file-view templates,
	// and the kicker by every template that re-asserts its project above the h1.
	for _, t := range []*template.Template{imageTmpl, fileTmpl} {
		template.Must(t.AddParseTree("plaque", plaqueTmpl.Tree))
	}
	for _, t := range []*template.Template{treeTmpl, imageTmpl, fileTmpl, rawLinkTmpl, logTmpl, commitTmpl} {
		template.Must(t.AddParseTree("kicker", kickerTmpl.Tree))
	}
}

// plaqueData is the museum label under a file exhibit.
type plaqueData struct {
	Path     string
	Size     string
	ShortSha string
}

// serveFileView shows a blob as a page: markdown rendered, text preformatted, images inlined,
// and anything binary or oversized as a link to the raw view.
func (s *server) serveFileView(w http.ResponseWriter, snap *snapshot, rp *repo, ref, sha, pth string) {
	name := path.Base(pth)
	raw := rawHref(rp.name, ref, pth)
	pd := pageData{
		Title:  pageTitle(rp.name, ref, pth),
		Crumbs: treeCrumbs(snap.face.siteName, rp.name, ref, parentDir(pth), crumbLink{Name: name}),
		Arc: cornerArc([]arcButton{
			{Label: "raw", Title: "the raw bytes", Href: raw},
			{Label: "up", Title: "containing directory", Href: treeHref(rp.name, ref, parentDir(pth)) + "/"},
			{Label: "log", Title: "commit log of " + shortSha(ref), Href: "/" + rp.name + "/log/" + ref},
			{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
		}),
	}
	size, err := rp.blobSize(sha, pth)
	if err != nil {
		s.pageError(w, snap, err)
		return
	}
	plq := plaqueData{Path: pth, Size: humanSize(size), ShortSha: shortSha(sha)}
	if isImagePath(pth) {
		s.render(w, snap, imageTmpl, struct {
			Name, Kicker, RawHref string
			Plaque                plaqueData
		}{name, rp.name, raw, plq}, pd)
		return
	}
	rawLink := func(kind string) {
		s.render(w, snap, rawLinkTmpl, struct {
			Name, Kicker, Kind, RawHref string
			Size                        int64
		}{name, rp.name, kind, raw, size}, pd)
	}
	if size > display.MaxRenderSize {
		rawLink("file, too large to display inline")
		return
	}
	var buf bytes.Buffer
	if err := rp.catBlob(sha, pth, &buf); err != nil {
		s.pageError(w, snap, err)
		return
	}
	if looksBinary(buf.Bytes()) {
		rawLink("binary data")
		return
	}
	if strings.ToLower(path.Ext(pth)) == ".md" {
		body, err := display.Markdown(buf.Bytes())
		if err != nil {
			s.pageError(w, snap, err)
			return
		}
		s.render(w, snap, fileTmpl, struct {
			Name, Kicker string
			Body         template.HTML
			Plaque       plaqueData
		}{name, rp.name, body, plq}, pd)
		return
	}
	var pre bytes.Buffer
	if err := preTmpl.Execute(&pre, buf.String()); err != nil {
		s.pageError(w, snap, err)
		return
	}
	s.render(w, snap, fileTmpl, struct {
		Name, Kicker string
		Body         template.HTML
		Plaque       plaqueData
	}{name, rp.name, template.HTML(pre.String()), plq}, pd)
}

// looksBinary applies the classic cheap heuristic: a NUL byte in the leading bytes means
// "not text".  Content that dodges this (a NUL late in a big file) still renders;
// it is just ugly, not harmful, since everything goes through template escaping anyway.
func looksBinary(b []byte) bool {
	if len(b) > 8000 {
		b = b[:8000]
	}
	return bytes.IndexByte(b, 0) >= 0
}

// ---- log ----

// The provenance ledger: no table.  Each commit is a row under a light hand-ruled
// line -- the subject as prose, free to wrap, and a soft second line carrying the
// sha (a chip-outlined button to the commit page), author, and absolute date.
// This shape is also why the page survives phone widths.
var logTmpl = template.Must(template.New("log").Parse(`{{template "kicker" .Kicker}}<h1>log: {{.Title}}</h1>
<p class="provenance">provenance · newest first</p>
<ul class="ledger">
{{range .Rows}}<li>
<p class="subject"><a href="{{.Href}}">{{.Subject}}</a></p>
<p class="attrib"><a class="sha" href="{{.Href}}">{{.Sha}}</a> {{.Author}} · {{.Date}}</p>
</li>
{{end}}</ul>
{{if .Capped}}<p>Showing the {{.Limit}} most recent commits.</p>
{{end}}`))

type logRow struct {
	Sha     string
	Href    string
	Date    string
	Author  string
	Subject string
}

type logData struct {
	Title  string
	Kicker string
	Rows   []logRow
	Capped bool
	Limit  int
}

// renderLog writes the log page.  Commit shas link to the tree at that commit:
// commit-diff pages are a later stretch goal, and browsing the snapshot is useful today.
func (s *server) renderLog(w http.ResponseWriter, snap *snapshot, rp *repo, ref string, cs []commitInfo) {
	d := logData{Title: shortSha(ref), Kicker: rp.name, Capped: len(cs) == logLimit, Limit: logLimit}
	for _, c := range cs {
		d.Rows = append(d.Rows, logRow{
			Sha:     shortSha(c.hash),
			Href:    "/" + rp.name + "/commit/" + c.hash,
			Date:    c.time.Format("2006-01-02"),
			Author:  c.author,
			Subject: c.subject,
		})
	}
	s.render(w, snap, logTmpl, d, pageData{
		Title:  rp.name + " log @ " + shortSha(ref),
		Crumbs: repoCrumbs(snap.face.siteName, rp.name, crumbLink{Name: "log"}, crumbLink{Name: shortSha(ref)}),
		Arc: cornerArc([]arcButton{
			{Label: "files", Title: "browse the tree at " + shortSha(ref), Href: treeHref(rp.name, ref, "") + "/"},
			{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
		}),
	})
}

// ---- commit ----

var commitTmpl = template.Must(template.New("commit").Parse(`{{template "kicker" .Kicker}}<h1>{{.Subject}}</h1>
<p>commit <code>{{.Sha}}</code>{{range .Parents}} · parent <a href="{{.Href}}"><code>{{.Name}}</code></a>{{end}}
· <a href="{{.TreeHref}}">browse this tree</a><br>
{{.Author}}, {{.Date}}</p>
{{if .Rest}}<pre class="file">{{.Rest}}</pre>
{{end}}{{if .Diff}}<pre class="diff">{{.Diff}}</pre>
{{else}}<p>No changes against the first parent.</p>
{{end}}{{if .Truncated}}<p>The diff is larger than shown; it was cut off here.</p>
{{end}}`))

type commitData struct {
	Subject   string
	Kicker    string
	Rest      string // the message past its subject line, if any
	Sha       string
	Author    string
	Date      string
	Parents   []refLink
	TreeHref  string
	Diff      template.HTML
	Truncated bool
}

// renderCommit writes the commit page: subject, metadata, the rest of the message, the diff.
func (s *server) renderCommit(w http.ResponseWriter, snap *snapshot, rp *repo, c commitDetail, diff []byte) {
	subject, rest, _ := strings.Cut(c.message, "\n")
	d := commitData{
		Subject:  subject,
		Kicker:   rp.name,
		Rest:     strings.TrimSpace(rest),
		Sha:      c.hash,
		Author:   c.author,
		Date:     c.time.Format("2006-01-02 15:04:05 UTC"),
		TreeHref: treeHref(rp.name, c.hash, "") + "/",
	}
	for _, p := range c.parents {
		d.Parents = append(d.Parents, refLink{shortSha(p), "/" + rp.name + "/commit/" + p})
	}
	d.Diff, d.Truncated = diffHTML(diff)
	s.render(w, snap, commitTmpl, d, pageData{
		Title:  rp.name + " commit " + shortSha(c.hash),
		Crumbs: repoCrumbs(snap.face.siteName, rp.name, crumbLink{Name: "commit"}, crumbLink{Name: shortSha(c.hash)}),
		Arc: cornerArc([]arcButton{
			{Label: "files", Title: "browse the tree at this commit", Href: treeHref(rp.name, c.hash, "") + "/"},
			{Label: "log", Title: "the log from this commit", Href: "/" + rp.name + "/log/" + c.hash},
			{Label: "home", Title: "repo home", Href: "/" + rp.name + "/"},
		}),
	})
}

// diffHTML escapes a unified diff and wraps its notable lines in classed spans for color:
// added, removed, hunk headers, and everything else that isn't patch body (per-file header
// material -- in a unified diff, body lines only ever start with '+', '-', ' ', or '\\').
// A diff beyond MaxRenderSize is cut at a line boundary and flagged truncated.
func diffHTML(diff []byte) (html template.HTML, truncated bool) {
	if len(diff) > display.MaxRenderSize {
		cut := bytes.LastIndexByte(diff[:display.MaxRenderSize], '\n')
		if cut < 0 {
			cut = 0
		}
		diff, truncated = diff[:cut], true
	}
	var sb strings.Builder
	for _, line := range strings.Split(strings.TrimSuffix(string(diff), "\n"), "\n") {
		class := ""
		switch {
		case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"):
			class = "d-file"
		case strings.HasPrefix(line, "@@"):
			class = "d-hunk"
		case strings.HasPrefix(line, "+"):
			class = "d-add"
		case strings.HasPrefix(line, "-"):
			class = "d-del"
		case line == "" || line[0] == ' ' || line[0] == '\\':
			// patch body context: no class
		default:
			class = "d-file"
		}
		if class != "" {
			sb.WriteString(`<span class="` + class + `">`)
			sb.WriteString(template.HTMLEscapeString(line))
			sb.WriteString("</span>\n")
		} else {
			sb.WriteString(template.HTMLEscapeString(line))
			sb.WriteString("\n")
		}
	}
	return template.HTML(strings.TrimSuffix(sb.String(), "\n")), truncated
}

pages.go · 26.7 KiB · 6b3477bda1