// difftree.go folds a check-in's changed files into a directory tree.
//
// The flat one-per-line list is right for the ordinary check-in and
// useless past a few dozen files: fossil's 62b38332 changes 600 files
// and the list scrolled for screens — 167 under src, 163 under
// compat/zlib, 111 under www, 68 of them deletions — with no way to see
// that shape or to skip the parts you did not come for.
//
// A pure fold, deliberately. It reads the file list the page already
// has and adds no request, no API field and no store read: the tree is
// a reading of the same data, so a page that renders it cannot disagree
// with the diff below it.
package web
import (
"sort"
"strings"
"craftdesign.group/quarry/internal/diff"
)
// treeCollapse is the file count past which groups render closed.
//
// Below it the list is the thing a reader wants — open, all of it, no
// clicking. Above it the SHAPE is what they want first, and thirty is
// about where a list stops fitting on a screen.
const treeCollapse = 30
// treeColumns is the group size past which an open group lays its files
// out in columns. A dozen names is a list; forty in one column is a
// scroll with nothing else on the screen beside it.
const treeColumns = 12
// fileLeaf is one changed file as the tree renders it.
type fileLeaf struct {
// Name is the basename, which is all a reader needs inside a
// directory group; Path is the whole path, for the title and the
// sort.
Name string
Path string
// Href is where the name links. Resolved by the CALLER, because the
// two lists that render this tree link differently — one to an
// anchor in the page, one to a per-file diff — and a tree that
// guessed would be wrong on one of them.
Href string
// Change is "new", "change" or "deleted".
Change string
}
// fileNode is one directory in the tree.
type fileNode struct {
// Name is this node's path segment, or several joined by "/" when a
// chain of single-child directories was compressed into one row.
// An empty Name is the root, which is never drawn.
Name string
Dirs []*fileNode
Files []fileLeaf
// Total is the file count at or below this node — what the row
// shows, and what decides whether the group opens.
Total int
// The A/M/D tally at or below this node, so a group can say what
// kind of change it holds without being opened.
Added, Changed, Deleted int
}
// Open reports whether this group should render expanded.
//
// Decided by the WHOLE tree's size, not this group's: a reader opening
// a 600-file check-in wants every group shut, including the small ones,
// because the point is to see the shape. Passing the total in rather
// than storing it keeps fileNode a plain fold with nothing remembered
// about the page that drew it.
func (n *fileNode) Open(total int) bool { return total <= treeCollapse }
// Columns reports whether an open group should lay its files out in
// columns.
func (n *fileNode) Columns() bool { return len(n.Files) > treeColumns }
// Label is the row's text: the compressed directory path, with a
// trailing slash so it reads as a directory.
func (n *fileNode) Label() string { return n.Name + "/" }
// fileTree folds leaves into a tree, compresses single-child directory
// chains, and sorts directories before files at every level.
//
// Files at the root come back on the root node's Files, which is how a
// check-in that touched only top-level files renders as the flat list
// it always was — the tree costs nothing when there is no tree.
func fileTree(leaves []fileLeaf) *fileNode {
root := &fileNode{}
for _, leaf := range leaves {
dir, _, hasDir := cutLast(leaf.Path)
node := root
if hasDir {
for _, seg := range strings.Split(dir, "/") {
node = node.child(seg)
}
}
node.Files = append(node.Files, leaf)
}
root.tally()
root.compress()
root.sortAll()
return root
}
// child finds or makes the directory node for one segment.
func (n *fileNode) child(name string) *fileNode {
for _, d := range n.Dirs {
if d.Name == name {
return d
}
}
d := &fileNode{Name: name}
n.Dirs = append(n.Dirs, d)
return d
}
// tally fills Total and the A/M/D counts bottom-up.
func (n *fileNode) tally() {
n.Total, n.Added, n.Changed, n.Deleted = 0, 0, 0, 0
for _, d := range n.Dirs {
d.tally()
n.Total += d.Total
n.Added += d.Added
n.Changed += d.Changed
n.Deleted += d.Deleted
}
for _, f := range n.Files {
n.Total++
switch f.Change {
case string(diff.Added):
n.Added++
case string(diff.Deleted):
n.Deleted++
default:
n.Changed++
}
}
}
// compress folds a chain of directories that each hold exactly one
// directory and no files into a single row: www → json-api becomes
// www/json-api.
//
// Without it a deep tree is mostly rows that say nothing — a reader
// clicks four times to reach the one place anything changed, and each
// click reveals a single arrow.
func (n *fileNode) compress() {
for _, d := range n.Dirs {
d.compress()
}
// The root is never drawn, so it must not absorb its only child's
// name — that would make a check-in under one directory render
// with no group row at all.
if n.Name == "" {
return
}
for len(n.Files) == 0 && len(n.Dirs) == 1 {
only := n.Dirs[0]
n.Name += "/" + only.Name
n.Dirs, n.Files = only.Dirs, only.Files
}
}
// sortAll orders directories and files by name at every level, so two
// renders of the same check-in read the same.
func (n *fileNode) sortAll() {
sort.Slice(n.Dirs, func(i, j int) bool { return n.Dirs[i].Name < n.Dirs[j].Name })
sort.Slice(n.Files, func(i, j int) bool { return n.Files[i].Path < n.Files[j].Path })
for _, d := range n.Dirs {
d.sortAll()
}
}
// cutLast splits a path into its directory and basename.
func cutLast(path string) (dir, base string, hasDir bool) {
i := strings.LastIndexByte(path, '/')
if i < 0 {
return "", path, false
}
return path[:i], path[i+1:], true
}
// statusLetter is the one letter a change is filtered and tallied by.
func statusLetter(change string) string {
switch change {
case string(diff.Added):
return "A"
case string(diff.Deleted):
return "D"
default:
return "M"
}
}
// keepStatus reports whether a change survives a ?status= filter. An
// empty or unrecognised filter keeps everything — a URL nobody can
// write must not be able to empty the page.
func keepStatus(change, want string) bool {
switch strings.ToUpper(want) {
case "A", "D", "M":
return statusLetter(change) == strings.ToUpper(want)
}
return true
}
// changeKind is one chip in the strip: how many files of that kind the
// change holds, the word for it, and the letter it filters by.
type changeKind struct {
Letter string
Word string
Count int
}
// kindsOf tallies the whole change by kind, dropping the kinds that
// are not present.
//
// Counted over the WHOLE file list rather than the filtered tree: the
// strip has to keep saying how many deletions there are while it is
// showing only the deletions, or narrowing to one kind would make the
// other two chips vanish and there would be no way back.
func kindsOf(changes []string) []changeKind {
n := map[string]int{}
for _, c := range changes {
n[statusLetter(c)]++
}
var out []changeKind
for _, k := range []changeKind{
{Letter: "A", Word: "added"},
{Letter: "M", Word: "changed"},
{Letter: "D", Word: "deleted"},
} {
if k.Count = n[k.Letter]; k.Count > 0 {
out = append(out, k)
}
}
// One kind is not a choice. A strip reading "4 changed" beside a
// list of four changed files is a label pretending to be a control.
if len(out) < 2 {
return nil
}
return out
}