Skip to content
quarry Artifact [3b5681633e]
Login

Artifact [3b5681633e]

Artifact 3b5681633e5842748406b57f62cfe9125f936b754f1c56cdea142c8935a01306:


// 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
}