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}}"><-- previous</a>
+ {{- end -}}
+ {{- if and .PrevPage .NextPage }} | {{ end -}}
+ {{- if .NextPage -}}
+ <a href="/{{.Repo}}/commits/{{.RefLabel}}/{{.Path}}?after={{.NextPage.AnchorHash}}+{{.NextPage.Offset}}">next --></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}}