Download
169 lines 5 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
package render

import (
	"html"
	"html/template"
	"maps"
	"slices"
	"strings"

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

// The file drawer is a document of its own, shown in an <iframe> and
// shared by every page of the repository, so a change to the tree
// rewrites one document instead of every page. It carries the whole tree,
// each folder a closed <details> whose name links to its page.
//
// A page selects its own entry with the fragment of the frame's URL, its
// escaped path. Files carry it as an id, and folders on the list of their
// entries, so that a folder's page opens it. The browser opens the
// <details> around the target and scrolls to it, and the stylesheet marks
// it, without script. (Browsers that do not open them show the tree shut,
// with the page's breadcrumbs still there.) The ids are on <li>s and
// <ul>s, not links: a browser focuses a target it can, which would pull
// the page's focus into the frame. The header has id ".", for the root.

// sortedChildren returns a directory's children, directories first, then by
// name.
func sortedChildren(n *node) []*node {
	out := slices.Collect(maps.Values(n.children))
	slices.SortFunc(out, func(a, b *node) int {
		if a.dir != b.dir {
			if a.dir {
				return -1
			}
			return 1
		}
		return strings.Compare(a.name, b.name)
	})
	return out
}

// drawerView is the frame showing the drawer document.
type drawerView struct {
	Src string
}

// drawerID is the fragment that selects path p. The browser matches a
// fragment against ids before decoding it, so the escaped form is the id
// too.
func drawerID(p string) string { return escapePath(p) }

// drawer is the frame for the page at current ("" is the root).
func (c *repoCtx) drawer(current string) *drawerView {
	id := "."
	if current != "" {
		id = drawerID(current)
	}
	return &drawerView{Src: c.s.FileTreeURL(c.key) + "#" + id}
}

type drawerDoc struct {
	Name string // repository (and branch) shown at the top
	URL  string
	Tree template.HTML
}

// drawerChanged reports whether changes add, remove or retype entries,
// or repoint a symlink, which changes the drawer; edits leave it alone.
func drawerChanged(changes []gitx.Change, t *tree) bool {
	for _, ch := range changes {
		if ch.Status != 'M' {
			return true
		}
		if e := t.byPath[ch.Path]; e != nil && e.IsSymlink() {
			return true
		}
	}
	return false
}

// writeDrawer writes the repository's drawer document.
func (c *repoCtx) writeDrawer() error {
	var b strings.Builder
	if err := c.writeTree(&b, c.tree.root, ""); err != nil {
		return err
	}
	html, err := c.s.renderDoc("drawer", drawerDoc{
		Name: c.repo + c.branchSuffix(),
		URL:  c.s.CodeURL(c.key, ""),
		Tree: template.HTML(b.String()),
	})
	if err != nil {
		return err
	}
	return writePair(c.s.Lay.FileTree(c.key), html, true)
}

// writeEntry writes a file, symlink or submodule. Symlinks and submodules
// have no page: a symlink links to its target's, when that is in the
// repository, and is otherwise plain text, as a submodule is. A file
// carries id, if any: the fragment its page selects.
func (c *repoCtx) writeEntry(b *strings.Builder, ch *node, full, id string) error {
	switch {
	case ch.entry.IsSymlink():
		url, err := c.symlinkURL(full)
		if err != nil {
			return err
		}
		if url == "" {
			b.WriteString(`<li class="s"><span>` + iconLink)
			b.WriteString(html.EscapeString(ch.name))
			b.WriteString(`</span></li>`)
			break
		}
		b.WriteString(`<li class="s"><a href="`)
		b.WriteString(html.EscapeString(url))
		b.WriteString(`">` + iconLink)
		b.WriteString(html.EscapeString(ch.name))
		b.WriteString(`</a></li>`)
	case ch.entry.IsSubmodule():
		b.WriteString(`<li class="m"><span>` + iconModule)
		b.WriteString(html.EscapeString(ch.name))
		b.WriteString(`</span></li>`)
	default:
		b.WriteString(`<li class="f"`)
		if id != "" {
			b.WriteString(` id="` + html.EscapeString(id) + `"`)
		}
		b.WriteString(`><a href="`)
		b.WriteString(html.EscapeString(c.s.CodeURL(c.key, full)))
		b.WriteString(`">` + iconFile)
		b.WriteString(html.EscapeString(ch.name))
		b.WriteString(`</a></li>`)
	}
	return nil
}

// writeTree writes n's entries and, inside each folder's closed
// <details>, the folder's own. A folder's icon is outside its link, so a
// click on it toggles the folder, and one on the name opens its page.
func (c *repoCtx) writeTree(b *strings.Builder, n *node, prefix string) error {
	b.WriteString(`<ul`)
	if prefix != "" {
		b.WriteString(` id="` + html.EscapeString(drawerID(prefix)) + `"`)
	}
	b.WriteString(`>`)
	for _, ch := range sortedChildren(n) {
		full := ch.name
		if prefix != "" {
			full = prefix + "/" + ch.name
		}
		if !ch.dir {
			if err := c.writeEntry(b, ch, full, drawerID(full)); err != nil {
				return err
			}
			continue
		}
		b.WriteString(`<li class="d"><details><summary>` + iconDir + `<a href="`)
		b.WriteString(html.EscapeString(c.s.CodeURL(c.key, full)))
		b.WriteString(`">` + html.EscapeString(ch.name) + `/</a></summary>`)
		if err := c.writeTree(b, ch, full); err != nil {
			return err
		}
		b.WriteString(`</details></li>`)
	}
	b.WriteString(`</ul>`)
	return nil
}