Download
275 lines 7.3 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
package render

import (
	"html/template"
	"path"
	"path/filepath"
	"slices"
	"strings"
	"time"

	"code.bruce-hill.com/repo-renderer/internal/gitx"
	"code.bruce-hill.com/repo-renderer/internal/highlight"
)

// ---- listings ----

type listingView struct {
	IsRoot  bool
	Entries []entryView
	More    int // entries beyond the fold (marked Extra), 0 when none
	Readme  *readmeView
}

type entryView struct {
	Name         string
	URL          string
	Kind         string // dir, file, symlink, submodule
	Exec         bool
	Size         string    // a type page's size, in its row's unit
	Bars         *sizeBars // a listing's Lines and Size, each with a bar
	Modified     string    // date of the last commit touching it
	ModifiedISO  string
	ModifiedFull string // date and time, for the tooltip
	Target       string
	Dirs         []crumb // folders leading to it, each linked, when listed by full path
	Note         string  // shown after the name, like a symlink's target
	Extra        bool    // hidden behind the "N more…" row
}

type readmeView struct {
	Name string
	URL  string
	HTML template.HTML
	Pre  bool
	Text string
}

// readme precedence, compared case-insensitively; "readme.*" is the catch-all.
var readmeOrder = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}

func pickReadme(n *node) *node {
	lower := map[string]*node{}
	for name, ch := range n.children {
		if ch.dir || ch.entry.IsSymlink() || ch.entry.IsSubmodule() {
			continue
		}
		l := strings.ToLower(name)
		if _, dup := lower[l]; !dup || name < lower[l].name {
			lower[l] = ch
		}
	}
	for _, want := range readmeOrder {
		if ch, ok := lower[want]; ok {
			return ch
		}
	}
	var first string
	for l := range lower {
		if strings.HasPrefix(l, "readme.") && (first == "" || l < first) {
			first = l
		}
	}
	return lower[first]
}

// fullTime is a commit time in a tooltip, until script (if any) replaces
// it with the reader's local time.
const fullTime = "2006-01-02 15:04 UTC"

// setModified fills in the Modified column from a commit time, if known.
func (ev *entryView) setModified(t int64) {
	if t > 0 {
		tm := time.Unix(t, 0).UTC()
		ev.Modified, ev.ModifiedISO, ev.ModifiedFull = tm.Format("2006-01-02"), tm.Format(time.RFC3339), tm.Format(fullTime)
	}
}

// lineCount formats a line count for the size column: "1 line", "4.4K lines".
func lineCount(n int) string {
	if n == 1 {
		return "1 line"
	}
	return humanCount(n) + " lines"
}

// maxSymlinkTarget bounds how much of a symlink blob is read for display;
// anything longer than a path can be is not worth a page's while.
const maxSymlinkTarget = 4096

// maxSymlinkHops bounds how many symlinks resolving one may pass through,
// as the kernel's ELOOP does, so a cycle ends.
const maxSymlinkHops = 40

// symlinkTarget returns the path a symlink entry points to, for display.
func (c *repoCtx) symlinkTarget(e *gitx.Entry) (string, error) {
	if e.Size > maxSymlinkTarget {
		return "(target too long)", nil
	}
	b, err := c.batch.Get(e.SHA)
	if err != nil {
		return "", err
	}
	return string(b), nil
}

// symlinkURL returns the page of what the symlink at link resolves to,
// following further symlinks along the way, or "" when it leaves the
// repository, dangles, loops, or ends at something without a page.
func (c *repoCtx) symlinkURL(link string) (string, error) {
	var done []string // resolved components, free of symlinks
	if d := path.Dir(link); d != "." {
		done = strings.Split(d, "/")
	}
	rest := []string{path.Base(link)}
	for hops := 0; len(rest) > 0; {
		comp := rest[0]
		rest = rest[1:]
		switch comp {
		case "", ".":
			continue
		case "..":
			if len(done) == 0 {
				return "", nil // above the repository root
			}
			done = done[:len(done)-1]
			continue
		}
		cand := strings.Join(append(slices.Clip(done), comp), "/")
		e := c.tree.byPath[cand]
		if e == nil || !e.IsSymlink() {
			done = append(done, comp)
			continue
		}
		if hops++; hops > maxSymlinkHops || e.Size > maxSymlinkTarget {
			return "", nil
		}
		b, err := c.batch.Get(e.SHA)
		if err != nil {
			return "", err
		}
		target := string(b)
		if strings.HasPrefix(target, "/") {
			return "", nil // absolute: somewhere on the author's machine
		}
		rest = append(strings.Split(target, "/"), rest...)
	}
	p := strings.Join(done, "/")
	if c.tree.dir(p) != nil {
		return c.s.CodeURL(c.key, p), nil
	}
	if e := c.tree.byPath[p]; e != nil && !e.IsSubmodule() {
		return c.s.CodeURL(c.key, p), nil
	}
	return "", nil
}

func (c *repoCtx) renderListing(dir string) error {
	lay := c.s.Lay
	n := c.tree.dir(dir)
	if n == nil {
		if c.tree.byPath[dir] != nil {
			// The directory became a file of the same name; its page is
			// the file page a worker has just written.
			return nil
		}
		// Directory vanished: drop its page.
		page := lay.Page(c.key, dir)
		if err := removeOutputs(page); err != nil {
			return err
		}
		pruneEmptyDirs(filepath.Dir(page), lay.RepoDir(c.key))
		return nil
	}
	var entries []entryView
	for _, ch := range sortedChildren(n) {
		name := ch.name
		full := name
		if dir != "" {
			full = dir + "/" + name
		}
		ev := entryView{Name: name}
		ev.setModified(c.mtime[full])
		switch {
		case ch.dir:
			ev.Name, ev.URL, ev.Kind = name+"/", c.s.CodeURL(c.key, full), "dir"
		case ch.entry.IsSymlink():
			target, err := c.symlinkTarget(ch.entry)
			if err != nil {
				return err
			}
			url, err := c.symlinkURL(full)
			if err != nil {
				return err
			}
			ev.URL, ev.Kind, ev.Target = url, "symlink", target
		case ch.entry.IsSubmodule():
			ev.Kind, ev.Target = "submodule", ch.entry.SHA[:7]
		default:
			ev.URL, ev.Kind, ev.Exec = c.s.CodeURL(c.key, full), "file", ch.entry.IsExecutable()
		}
		ev.Bars = c.sizeBars(dir, full, ev.URL)
		entries = append(entries, ev)
	}
	lv := &listingView{IsRoot: dir == "", Entries: entries}
	if rm := pickReadme(n); rm != nil {
		rv, err := c.readmeView(dir, rm)
		if err != nil {
			return err
		}
		lv.Readme = rv
	}
	// With a README below, the listing is a short table of contents and the
	// rest folds away; without one, the listing is the page, so show it all.
	if fold := c.s.Cfg.ListingTruncate; lv.Readme != nil && len(entries) > fold {
		for i := fold; i < len(entries); i++ {
			entries[i].Extra = true
		}
		lv.More = len(entries) - fold
	}
	page := c.page()
	page.BodyClass = "listing"
	page.Crumbs = c.s.crumbsFor(c.key, dir)
	page.Drawer = c.drawer(dir)
	page.Listing = lv
	if lv.IsRoot {
		page.Title = c.repo + c.branchSuffix()
		// Only the root shows lines of code: every push rewrites it, so its
		// counts stay current, where other pages would keep stale ones.
		page.Stats = c.stats
	} else {
		page.Title = c.repo + c.branchSuffix() + "/" + dir + "/"
	}
	page.URL = c.s.absURL(c.s.CodeURL(c.key, dir))
	html, err := c.s.render("listing", page)
	if err != nil {
		return err
	}
	return writePair(lay.Page(c.key, dir), html, true)
}

func (c *repoCtx) readmeView(dir string, rm *node) (*readmeView, error) {
	e := rm.entry
	if e.Size > c.s.Cfg.MarkdownMaxBytes {
		return nil, nil
	}
	data, err := c.batch.Get(e.SHA)
	if err != nil {
		return nil, err
	}
	if highlight.IsBinary(data) {
		return nil, nil
	}
	rv := &readmeView{Name: rm.name, URL: c.s.CodeURL(c.key, e.Path)}
	if isMarkdown(rm.name) {
		html, err := c.s.MD.Render(data, c.mdContext(dir))
		if err != nil {
			return nil, err
		}
		rv.HTML = html
	} else {
		rv.Pre = true
		rv.Text = string(data)
	}
	return rv, nil
}