bidet

commit 04789f728342d5429f9f308edb4f0bc670338b70

tree

parent:
52ce9c42d764af35fd05c534baa5cb51ecfdcd47

nmyk <nick@nmyk.io>

2026-04-21T14:57:15-04:00

add commit history links to blob, tree
paginate commit history

diff --git a/internal/core/repo.go b/internal/core/repo.go
index 9ebdb909e99d3ee6cfbed6a0287efa70e93d403f..5d9e44bcd6bb04f7c3fd00bb666480dc49004065 100644
--- a/internal/core/repo.go
+++ b/internal/core/repo.go
@@ -91,6 +91,16 @@ 	Hash    plumbing.Hash
 	RefName string
 }
 
+func (c *Commit) RefLabel() string {
+	var label string
+	if c.RefName != "" {
+		label = c.RefName
+	} else {
+		label = c.Hash.String()
+	}
+	return label
+}
+
 func (repo *Repo) ParseRoute(route string) (*Commit, string, error) {
 	if route == "" {
 		return nil, "", errors.New("empty route")
diff --git a/internal/handlers/formatting.go b/internal/handlers/formatting.go
index e73959e58f88047130a8ed5efae9b1508745f823..304b6983ae768099155bc204c91639741661ed28 100644
--- a/internal/handlers/formatting.go
+++ b/internal/handlers/formatting.go
@@ -15,7 +15,7 @@ 	URL    string
 	IsLast bool
 }
 
-func breadcrumbs(repoName string, refLabel string, p string) []crumb {
+func breadcrumbs(urlType string, repoName string, refLabel string, p string) []crumb {
 	var crumbs []crumb
 	last := true
 	cur := p
@@ -26,7 +26,7 @@ 			break
 		}
 		crumbs = append(crumbs, crumb{
 			Name:   base,
-			URL:    path.Join(repoName, "tree", refLabel, cur),
+			URL:    path.Join(repoName, urlType, refLabel, cur),
 			IsLast: last,
 		})
 		last = false
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index d1d1d57863600490465145aff0a4b99a06499416..42ffcef08fe69a5cd48062bc594eed4a65a83b1b 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -4,12 +4,15 @@ import (
 	"bytes"
 	"compress/gzip"
 	"embed"
+	"errors"
 	"fmt"
 	"html/template"
 	"io"
 	"net/http"
 	"path"
+	"regexp"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -18,6 +21,7 @@ 	"github.com/go-git/go-git/v6"
 	"github.com/go-git/go-git/v6/plumbing"
 	"github.com/go-git/go-git/v6/plumbing/filemode"
 	"github.com/go-git/go-git/v6/plumbing/object"
+	"github.com/go-git/go-git/v6/plumbing/storer"
 	"github.com/go-git/go-git/v6/plumbing/transport"
 	"nmyk.io/bidet/internal/core"
 )
@@ -257,9 +261,90 @@ 	Message string
 }
 
 type CommitsData struct {
-	Repo    string
-	Ref     string
-	Commits []CommitMeta
+	Repo     string
+	RefLabel string
+	Commits  []CommitMeta
+	Path     string
+	Crumbs   []crumb
+	PrevPage *Cursor
+	NextPage *Cursor
+}
+
+func commitChangesDir(c *object.Commit, dir string) (bool, error) {
+	tree, err := c.Tree()
+	if err != nil {
+		return false, err
+	}
+	parentIter := c.Parents()
+	parentCount := 0
+	hasChange := false
+	err = parentIter.ForEach(func(p *object.Commit) error {
+		parentCount++
+		pt, err := p.Tree()
+		if err != nil {
+			return err
+		}
+		// Diff only the subtree for the directory
+		changes, err := object.DiffTree(pt, tree)
+		if err != nil {
+			return err
+		}
+		for _, ch := range changes {
+			if strings.HasPrefix(ch.From.Name, dir+"/") || strings.HasPrefix(ch.To.Name, dir+"/") {
+				hasChange = true
+				return storer.ErrStop // stop iteration: we found a relevant change
+			}
+		}
+		return nil
+	})
+	if err == storer.ErrStop {
+		return true, nil
+	}
+	if err != nil {
+		return false, err
+	}
+	// root commit
+	if parentCount == 0 {
+		return true, nil
+	}
+	return hasChange, nil
+}
+
+func pathIsFile(t *object.Tree, path string) (bool, error) {
+	if path == "" {
+		return false, nil
+	}
+	entry, err := t.FindEntry(path)
+	if err != nil {
+		return false, err
+	}
+	return entry.Mode.IsFile(), nil
+}
+
+const commitsPageSize = 35
+
+var page = regexp.MustCompile(`^([0-9a-fA-F]+) ([0-9]+)$`)
+
+type Cursor struct {
+	AnchorHash string
+	Offset     int
+}
+
+// parseCursor parses a cursor string like "<hash>+<offset>"
+func parseCursor(s string) *Cursor {
+	m := page.FindStringSubmatch(s)
+	if m == nil {
+		return nil
+	}
+	hash := m[1]
+	offset, err := strconv.Atoi(m[2])
+	if err != nil {
+		return nil
+	}
+	return &Cursor{
+		AnchorHash: hash,
+		Offset:     offset,
+	}
 }
 
 func (s Server) Commits(w http.ResponseWriter, r *http.Request) {
@@ -269,39 +354,148 @@ 	if err != nil {
 		s.error(w, err)
 		return
 	}
-	commit, _, err := repo.ParseRoute(r.PathValue("path"))
+	commit, filePath, err := repo.ParseRoute(r.PathValue("path"))
 	if err != nil {
-		http.NotFound(w, nil)
+		http.NotFound(w, r)
 		return
 	}
-	iter, err := repo.Log(&git.LogOptions{
-		From: commit.Hash,
-	})
+	beforeParam := r.URL.Query().Get("before")
+	afterParam := r.URL.Query().Get("after")
+	if beforeParam != "" && afterParam != "" {
+		http.NotFound(w, r)
+	}
+
+	beforeCursor := parseCursor(beforeParam)
+	afterCursor := parseCursor(afterParam)
+	isBefore := beforeCursor != nil
+	isAfter := afterCursor != nil
+
+	var cursor *Cursor
+	if isBefore && beforeCursor.Offset < commitsPageSize {
+		http.NotFound(w, r)
+	} else if isBefore {
+		cursor = beforeCursor
+	} else if isAfter {
+		cursor = afterCursor
+	}
+
+	logOpts := git.LogOptions{From: commit.Hash}
+	var dir string
+	if filePath != "" {
+		tree, err := commit.Tree()
+		if err != nil {
+			s.error(w, err)
+			return
+		}
+		isFile, err := pathIsFile(tree, filePath)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		if isFile {
+			logOpts.FileName = &filePath
+		} else {
+			dir = filePath
+		}
+	}
+
+	iter, err := repo.Log(&logOpts)
 	if err != nil {
 		s.error(w, err)
 		return
 	}
-	commits := []CommitMeta{}
+
+	var commits []CommitMeta
+	var foundAnchor bool
+	var start, offset, end int
+	if isBefore {
+		start = cursor.Offset - commitsPageSize
+		end = cursor.Offset - 1
+	} else if isAfter {
+		start = cursor.Offset + 1
+		end = cursor.Offset + commitsPageSize
+	} else {
+		start = 0
+		end = commitsPageSize - 1
+		foundAnchor = true
+	}
 	err = iter.ForEach(func(c *object.Commit) error {
-		commits = append(commits, CommitMeta{
-			Hash:    c.Hash.String(),
-			Author:  fmtAuthor(c.Author.Name, c.Author.Email),
-			When:    c.Author.When,
-			Message: c.Message,
-		})
+		if offset >= end {
+			return io.EOF
+		}
+		if dir != "" {
+			ok, err := commitChangesDir(c, dir)
+			if err != nil {
+				return err
+			}
+			if !ok {
+				return nil
+			}
+		}
+		if !foundAnchor {
+			foundAnchor = c.Hash.String() == cursor.AnchorHash
+		}
+		if foundAnchor && offset >= start && offset < end {
+			commits = append(commits, CommitMeta{
+				Hash:    c.Hash.String(),
+				Author:  fmtAuthor(c.Author.Name, c.Author.Email),
+				When:    c.Author.When,
+				Message: c.Message,
+			})
+		}
+		if foundAnchor {
+			offset++
+		}
 		return nil
 	})
-	if err != nil {
+
+	if err != nil && err != io.EOF {
 		s.error(w, err)
 		return
 	}
-	slices.SortFunc(commits, func(a CommitMeta, b CommitMeta) int {
-		return -1 * a.When.Compare(b.When)
-	})
+
+	var next, prev *Cursor
+	if len(commits) > 0 {
+		if isBefore {
+			prev = &Cursor{
+				AnchorHash: cursor.AnchorHash,
+				Offset:     cursor.Offset - commitsPageSize,
+			}
+			next = &Cursor{
+				AnchorHash: cursor.AnchorHash,
+				Offset:     cursor.Offset - 1,
+			}
+		} else if isAfter {
+			prev = &Cursor{
+				AnchorHash: cursor.AnchorHash,
+				Offset:     cursor.Offset + 1,
+			}
+			next = &Cursor{
+				AnchorHash: cursor.AnchorHash,
+				Offset:     cursor.Offset + commitsPageSize,
+			}
+		} else {
+			prev = nil
+			next = &Cursor{
+				AnchorHash: commit.Hash.String(),
+				Offset:     commitsPageSize - 1,
+			}
+		}
+	}
+	if prev != nil && prev.Offset < commitsPageSize {
+		prev = nil
+	}
+	if !errors.Is(err, io.EOF) {
+		next = nil
+	}
 	data := CommitsData{
-		Repo:    repoName,
-		Ref:     commit.RefName,
-		Commits: commits,
+		Repo:     repoName,
+		RefLabel: commit.RefLabel(),
+		Commits:  commits,
+		Path:     filePath,
+		Crumbs:   breadcrumbs("commits", repoName, commit.RefLabel(), filePath),
+		PrevPage: prev,
+		NextPage: next,
 	}
 	s.Serve(w, "commits", data)
 }
@@ -397,17 +591,11 @@ 	if err != nil {
 		s.error(w, err)
 		return
 	}
-	var refLabel string
-	if commit.RefName != "" {
-		refLabel = commit.RefName
-	} else {
-		refLabel = commit.Hash.String()
-	}
 	data := BlobData{
 		Repo:     repoName,
-		RefLabel: refLabel,
+		RefLabel: commit.RefLabel(),
 		Path:     filePath,
-		Crumbs:   breadcrumbs(repoName, refLabel, filePath),
+		Crumbs:   breadcrumbs("tree", repoName, commit.RefLabel(), filePath),
 		Content:  content,
 	}
 	s.Serve(w, "blob", data)
@@ -505,20 +693,13 @@ 			}
 		}
 		entries = append(entries, em)
 	}
-	var refLabel string
-	if commit.RefName != "" {
-		refLabel = commit.RefName
-	} else {
-		refLabel = commit.Hash.String()
-	}
 	data := TreeData{
 		Hostname:   s.Hostname,
 		Repo:       repoName,
-		RefLabel:   refLabel,
-		Ref:        commit.RefName,
+		RefLabel:   commit.RefLabel(),
 		HeadRef:    headRef,
 		Path:       treePath,
-		Crumbs:     breadcrumbs(repoName, refLabel, treePath),
+		Crumbs:     breadcrumbs("tree", repoName, commit.RefLabel(), treePath),
 		Entries:    entries,
 		ReadmeName: readmeName,
 		Readme:     readme,
diff --git a/internal/handlers/templates/blob.tmpl b/internal/handlers/templates/blob.tmpl
index b72158039d362822b4322feb3e707c3acd1beddb..4fd9f0a57fa0fb84548c724ccbc8a7cc0bdf512a 100644
--- a/internal/handlers/templates/blob.tmpl
+++ b/internal/handlers/templates/blob.tmpl
@@ -9,7 +9,7 @@   {{else}}
     <a href="/{{$c.URL}}">{{$c.Name}}</a>
   {{end}}
 {{end}}</h1>
-<p>@ {{.RefLabel}}</p>
+<p>@ {{.RefLabel}} | <a href="/{{.Repo}}/commits/{{.RefLabel}}/{{.Path}}">history</a></p>
 <hr>
 <pre>{{.Content}}</pre>
 {{end}}
diff --git a/internal/handlers/templates/commits.tmpl b/internal/handlers/templates/commits.tmpl
index 0ba11f5f750f9d8efd224bc0cae4798de7a55e26..f457d44251e83a26e2f4ed4732d0a09c8df35822 100644
--- a/internal/handlers/templates/commits.tmpl
+++ b/internal/handlers/templates/commits.tmpl
@@ -1,14 +1,35 @@
-{{define "title"}}{{.Repo}} @ {{.Ref}}{{end}}
+{{define "title"}}{{.Repo}} @ {{.RefLabel}}{{end}}
 {{define "content"}}
-<h1><a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a></h1>
-<p>@ {{.Ref}}</p>
+<h1><a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.Repo}}</a></h1>
+{{- if eq .Crumbs nil -}}
+<p>@ {{.RefLabel}}</p>
+{{- else -}}
+<h3>history for {{range $i, $c := .Crumbs}}
+  {{if $i}} / {{end}}
+  {{if eq $i (sub (len $.Crumbs) 1)}}
+    {{$c.Name}}
+  {{else}}
+    <a href="/{{$c.URL}}">{{$c.Name}}</a>
+  {{end}}
+{{end}} @ <a href="/{{.Repo}}/tree/{{.RefLabel}}">{{.RefLabel}}</a></h3>
+{{- end -}}
 <p><a href="/{{.Repo}}/refs">refs</a></p>
 <h2>commits</h2>
-{{range .Commits}}
+{{ range .Commits }}
     <p><a href="/{{$.Repo}}/commit/{{.Hash}}">{{.Hash}}</a></p>
-    <p>{{.Author}}</p>
-    <p title="{{rfc3339 .When}}">{{humanize .When}}</p>
+    <p>{{.Author}} | <span title="{{rfc3339 .When}}">{{humanize .When}}</span> </p>
     <pre>{{.Message}}</pre>
+{{end}}
+{{- if or .PrevPage .NextPage -}}
+    <p>
+        {{- if .PrevPage -}}
+            <a href="/{{.Repo}}/commits/{{.RefLabel}}/{{.Path}}?before={{.PrevPage.AnchorHash}}+{{.PrevPage.Offset}}">&lt;-- previous</a>
+        {{- end -}}
+        {{- if and .PrevPage .NextPage }} | {{ end -}}
+        {{- if .NextPage -}}
+            <a href="/{{.Repo}}/commits/{{.RefLabel}}/{{.Path}}?after={{.NextPage.AnchorHash}}+{{.NextPage.Offset}}">next --&gt;</a>
+        {{- end -}}
+    </p>
     <br>
 {{end}}
 {{end}}
diff --git a/internal/handlers/templates/tree.tmpl b/internal/handlers/templates/tree.tmpl
index d76cd6e7425643861de33ab53fde523065084a1f..dee93645569acc037c91dcaae9c175174c9edd2a 100644
--- a/internal/handlers/templates/tree.tmpl
+++ b/internal/handlers/templates/tree.tmpl
@@ -12,9 +12,9 @@   {{else}}
     <a href="/{{$c.URL}}">{{$c.Name}}</a>
   {{end}}
 {{end}}</h1>
-<p>@ {{.RefLabel}}</p>
+<p>@ {{.RefLabel}}{{if ne .Path ""}} | <a href="/{{.Repo}}/commits/{{.RefLabel}}/{{.Path}}">history</a>{{end}}</p>
 {{if eq .Path ""}}
-<p><a href="/{{.Repo}}/refs">refs</a>{{if ne .Ref ""}} | <a href="/{{.Repo}}/commits/{{.Ref}}">commits</a>{{end}}</p>
+<p><a href="/{{.Repo}}/refs">refs</a> | <a href="/{{.Repo}}/commits/{{.RefLabel}}">commits</a></p>
 {{end}}
 <ul>
 {{range .Entries}}