bidet

commit 27221edbb187c64e1acc43018bf0e09ee1ac1193

tree

parent:
d75a654d61ba5dd1809a0bfb15f0eac2daa7ee4a

nmyk <nick@nmyk.io>

2026-02-16T10:27:29-05:00

refactor with ServeMux, breadcrumb nav

diff --git a/main.go b/main.go
index 98a8c6363ff121e630f2af9815e14c00297e7f93..324555e0c08b19a42c7eb98c65160bc629b863fb 100644
--- a/main.go
+++ b/main.go
@@ -8,9 +8,11 @@ 	"log"
 	"net/http"
 	"os"
 	"path"
+	"slices"
 	"strings"
 
 	git "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/filemode"
 	"github.com/go-git/go-git/v5/plumbing/object"
 )
@@ -19,12 +21,11 @@ //go:embed templates/*.tmpl
 var templates embed.FS
 
 const addr = ":8080"
-const dotGit = ".git"
+const DOT_GIT = ".git"
 
 func main() {
-	http.HandleFunc("/", router)
 	log.Printf("Serving on %s\n", addr)
-	log.Fatal(http.ListenAndServe(addr, nil))
+	log.Fatal(http.ListenAndServe(addr, router()))
 }
 
 type Repo struct {
@@ -32,56 +33,14 @@ 	*git.Repository
 	Name string
 }
 
-func router(w http.ResponseWriter, r *http.Request) {
-	path := strings.Trim(r.URL.Path, "/")
-
-	if path == "" {
-		listRepos(w)
-		return
-	}
-
-	parts := strings.Split(path, "/")
-	repoName := parts[0]
-	repoPath := repoName + dotGit
-
-	if _, err := os.Stat(repoPath); os.IsNotExist(err) {
-		http.NotFound(w, r)
-		return
-	}
-
-	gitRepo, err := git.PlainOpen(repoPath)
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-
-	repo := Repo{gitRepo, repoName}
-
-	ref, err := repo.Head()
-	if err != nil {
-		http.Error(w, "Cannot resolve HEAD", 500)
-		return
-	}
-
-	commit, err := repo.CommitObject(ref.Hash())
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-
-	tree, err := commit.Tree()
-	if err != nil {
-		http.Error(w, err.Error(), 500)
-		return
-	}
-
-	if len(parts) == 1 {
-		renderTree(w, &repo, "", tree)
-		return
-	}
-
-	subPath := strings.Join(parts[1:], "/")
-	handlePath(w, &repo, subPath, tree)
+func router() *http.ServeMux {
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /", listRepos)
+	mux.HandleFunc("GET /{name}", repoIndex)
+	mux.HandleFunc("GET /{name}/tree/{ref}", repoTree)
+	mux.HandleFunc("GET /{name}/tree/{ref}/{rest...}", repoTree)
+	mux.HandleFunc("GET /{name}/blob/{ref}/{rest...}", blob)
+	return mux
 }
 
 func serve(w http.ResponseWriter, name string, data any) {
@@ -99,7 +58,7 @@ 	w.WriteHeader(http.StatusOK)
 	buf.WriteTo(w)
 }
 
-func listRepos(w http.ResponseWriter) {
+func listRepos(w http.ResponseWriter, _ *http.Request) {
 	entries, err := os.ReadDir(".")
 	if err != nil {
 		http.Error(w, err.Error(), 500)
@@ -108,91 +67,276 @@ 	}
 	var repos []string
 	isBareRepo := func(e os.DirEntry) bool {
 		return (e.IsDir() &&
-			strings.HasSuffix(e.Name(), dotGit) &&
-			e.Name() != dotGit)
+			strings.HasSuffix(e.Name(), DOT_GIT) &&
+			e.Name() != DOT_GIT)
 	}
 	for _, e := range entries {
 		if isBareRepo(e) {
-			repos = append(repos, strings.TrimSuffix(e.Name(), dotGit))
+			repos = append(repos, strings.TrimSuffix(e.Name(), DOT_GIT))
 		}
 	}
 	serve(w, "repos", repos)
 }
 
-func handlePath(w http.ResponseWriter, repo *Repo, subPath string, rootTree *object.Tree) {
-	entry, err := rootTree.FindEntry(subPath)
+func load(repo *git.Repository, entry *object.TreeEntry) (string, error) {
+	file, err := repo.BlobObject(entry.Hash)
+	if err != nil {
+		return "", err
+	}
+	reader, err := file.Reader()
+	if err != nil {
+		return "", err
+	}
+	defer reader.Close()
+
+	content := make([]byte, file.Size)
+	reader.Read(content)
+	return string(content), nil
+}
+
+func blob(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
+		http.NotFound(w, r)
+		return
+	}
+	repo, err := git.PlainOpen(repoName + DOT_GIT)
 	if err != nil {
-		http.NotFound(w, nil)
+		http.Error(w, err.Error(), 500)
 		return
 	}
-	if entry.Mode == filemode.Dir {
-		tree, err := repo.TreeObject(entry.Hash)
-		if err != nil {
-			http.Error(w, err.Error(), 500)
-			return
-		}
-		renderTree(w, repo, subPath, tree)
+	refName := r.PathValue("ref")
+	branch := path.Join("refs", "heads", refName)
+	ref, err := repo.Reference(plumbing.ReferenceName(branch), true)
+	if err != nil {
+		http.NotFound(w, r)
 		return
 	}
-	file, err := repo.BlobObject(entry.Hash)
+	commit, err := repo.CommitObject(ref.Hash())
 	if err != nil {
 		http.Error(w, err.Error(), 500)
 		return
 	}
-	reader, err := file.Reader()
+	tree, err := commit.Tree()
 	if err != nil {
 		http.Error(w, err.Error(), 500)
 		return
 	}
-	defer reader.Close()
-
-	content := make([]byte, file.Size)
-	reader.Read(content)
-
+	filePath := r.PathValue("rest")
+	entry, err := tree.FindEntry(filePath)
+	if err != nil {
+		http.NotFound(w, nil)
+		return
+	}
+	content, err := load(repo, entry)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+	}
 	data := struct {
 		Repo    string
-		Dir     string
-		Path    string
+		Ref     string
+		Base    string
+		Crumbs  []Crumb
 		Content string
 	}{
-		Repo:    repo.Name,
-		Dir:     path.Dir(subPath),
-		Path:    subPath,
-		Content: string(content),
+		Repo:    repoName,
+		Ref:     refName,
+		Base:    filePath,
+		Crumbs:  breadcrumbs(repoName, refName, filePath),
+		Content: content,
 	}
+	serve(w, "blob", data)
+}
 
-	serve(w, "file", data)
+type TreeData struct {
+	Repo       string
+	Ref        string
+	Base       string
+	Crumbs     []Crumb
+	Entries    []EntryMeta
+	ReadmeName string
+	Readme     string
 }
 
-func renderTree(w http.ResponseWriter, repo *Repo, subPath string, tree *object.Tree) {
-	type Entry struct {
-		Name  string
-		Path  string
-		IsDir bool
-	}
+type EntryMeta struct {
+	Repo  string
+	Name  string
+	Ref   string
+	Path  string
+	IsDir bool
+}
 
-	var entries []Entry
-
+func repoTree(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
+		http.NotFound(w, r)
+		return
+	}
+	repo, err := git.PlainOpen(repoName + DOT_GIT)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	refName := r.PathValue("ref")
+	branch := path.Join("refs", "heads", refName)
+	ref, err := repo.Reference(plumbing.ReferenceName(branch), true)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	commit, err := repo.CommitObject(ref.Hash())
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	rootTree, err := commit.Tree()
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	treePath := r.PathValue("rest")
+	var tree *object.Tree
+	if treePath != "" {
+		treeEntry, err := rootTree.FindEntry(treePath)
+		if err != nil {
+			http.NotFound(w, r)
+			return
+		}
+		tree, err = repo.TreeObject(treeEntry.Hash)
+		if err != nil {
+			http.Error(w, err.Error(), 500)
+		}
+	} else {
+		tree = rootTree
+	}
+	var entries []EntryMeta
+	if treePath != "" {
+		entries = []EntryMeta{
+			{
+				Repo:  repoName,
+				Name:  "..",
+				Ref:   refName,
+				Path:  path.Dir(treePath),
+				IsDir: true,
+			},
+		}
+	}
+	var readme, readmeName string
 	for _, e := range tree.Entries {
-		fullPath := path.Join(subPath, e.Name)
-		entries = append(entries, Entry{
+		fullPath := path.Join(treePath, e.Name)
+		em := EntryMeta{
+			Repo:  repoName,
 			Name:  e.Name,
-			Path:  path.Join("/", repo.Name, fullPath),
+			Ref:   refName,
+			Path:  fullPath,
 			IsDir: e.Mode == filemode.Dir,
+		}
+		if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
+			readmeName = e.Name
+			var err error
+			readme, err = load(repo, &e)
+			if err != nil {
+				http.Error(w, err.Error(), 500)
+			}
+		}
+		entries = append(entries, em)
+	}
+	data := TreeData{
+		Repo:       repoName,
+		Ref:        refName,
+		Base:       treePath,
+		Crumbs:     breadcrumbs(repoName, refName, treePath),
+		Entries:    entries,
+		ReadmeName: readmeName,
+		Readme:     readme,
+	}
+	serve(w, "tree", data)
+}
+
+type Crumb struct {
+	Name   string
+	URL    string
+	IsLast bool
+}
+
+func breadcrumbs(repoName string, refName string, p string) []Crumb {
+	var crumbs []Crumb
+	last := true
+	cur := p
+	for {
+		base := path.Base(cur)
+		if base == "." || base == "/" {
+			break
+		}
+		crumbs = append(crumbs, Crumb{
+			Name:   base,
+			URL:    path.Join(repoName, "tree", refName, cur),
+			IsLast: last,
 		})
+		last = false
+		next := path.Dir(cur)
+		if next == cur {
+			break
+		}
+		cur = next
 	}
+	slices.Reverse(crumbs)
+	return crumbs
+}
 
-	data := struct {
-		Repo    string
-		Dir     string
-		Base    string
-		Entries []Entry
-	}{
-		Repo:    repo.Name,
-		Dir:     path.Dir(subPath),
-		Base:    subPath,
-		Entries: entries,
+func repoIndex(w http.ResponseWriter, r *http.Request) {
+	repoName := r.PathValue("name")
+	if _, err := os.Stat(repoName + DOT_GIT); os.IsNotExist(err) {
+		http.NotFound(w, r)
+		return
+	}
+	repo, err := git.PlainOpen(repoName + DOT_GIT)
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	ref, err := repo.Head()
+	if err != nil {
+		http.Error(w, "Cannot resolve HEAD", 500)
+		return
+	}
+	commit, err := repo.CommitObject(ref.Hash())
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	tree, err := commit.Tree()
+	if err != nil {
+		http.Error(w, err.Error(), 500)
+		return
+	}
+	refName := ref.Name().Short()
+	var entries []EntryMeta
+	var readme, readmeName string
+	for _, e := range tree.Entries {
+		em := EntryMeta{
+			Repo:  repoName,
+			Name:  e.Name,
+			Ref:   refName,
+			Path:  e.Name,
+			IsDir: e.Mode == filemode.Dir,
+		}
+		if strings.HasPrefix(strings.ToLower(e.Name), "readme") {
+			readmeName = e.Name
+			var err error
+			readme, err = load(repo, &e)
+			if err != nil {
+				http.Error(w, err.Error(), 500)
+			}
+		}
+		entries = append(entries, em)
+	}
+	data := TreeData{
+		Repo:       repoName,
+		Ref:        refName,
+		Entries:    entries,
+		ReadmeName: readmeName,
+		Readme:     readme,
 	}
-
 	serve(w, "tree", data)
 }
diff --git a/templates/blob.tmpl b/templates/blob.tmpl
new file mode 100644
index 0000000000000000000000000000000000000000..806e5090049ed586a8dcbc7432a93a06b400f6ef
--- /dev/null
+++ b/templates/blob.tmpl
@@ -0,0 +1,17 @@
+{{define "title"}}{{.Repo}}/{{.Base}}{{end}}
+{{define "content"}}
+<h1>{{if eq .Base ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a> /{{end}}
+{{range $i, $c := .Crumbs}}
+  {{if $i}} / {{end}}
+  {{if $c.IsLast}}
+    {{$c.Name}}
+  {{else}}
+    <a href="/{{$c.URL}}">{{$c.Name}}</a>
+  {{end}}
+{{end}}</h1>
+<pre>{{.Content}}</pre>
+{{end}}
+
+{{define "blob"}}
+{{template "base" .}}
+{{end}}
\ No newline at end of file
diff --git a/templates/file.tmpl b/templates/file.tmpl
deleted file mode 100644
index c5be3376d7429828b41c9c31cdedf5a6721ed7ea..0000000000000000000000000000000000000000
--- a/templates/file.tmpl
+++ /dev/null
@@ -1,10 +0,0 @@
-{{define "title"}}{{.Repo}}/{{.Path}}{{end}}
-{{define "content"}}
-<h1>{{.Repo}}/{{.Path}}</h1>
-<p><a href="/{{.Repo}}/{{.Dir}}">&lt;-- back</a></p>
-<pre>{{.Content}}</pre>
-{{end}}
-
-{{define "file"}}
-{{template "base" .}}
-{{end}}
\ No newline at end of file
diff --git a/templates/tree.tmpl b/templates/tree.tmpl
index 0cedd543b3eced06f51bb34fc8b9e57924a0377b..e492f157d050594093357fdc0a8a1fc32905804c 100644
--- a/templates/tree.tmpl
+++ b/templates/tree.tmpl
@@ -1,15 +1,33 @@
 {{define "title"}}{{.Repo}}/{{.Base}}{{end}}
 {{define "content"}}
-<h1>{{.Repo}}/{{.Base}}</h1>
-<p><a href="/{{if and (eq .Dir ".") (eq .Base "")}}{{else}}{{.Repo}}/{{.Dir}}{{end}}">&lt;-- back</a></p>
+<h1>{{if eq .Base ""}}{{.Repo}}{{else}}<a href="/{{.Repo}}/tree/{{.Ref}}">{{.Repo}}</a> /{{end}}
+{{range $i, $c := .Crumbs}}
+  {{if $i}} / {{end}}
+  {{if $c.IsLast}}
+    {{$c.Name}}
+  {{else}}
+    <a href="/{{$c.URL}}">{{$c.Name}}</a>
+  {{end}}
+{{end}}</h1>
+{{if eq .Base ""}}<p><a href="/">&lt;-- back</a></p>{{end}}
 <ul>
 {{range .Entries}}
 <li>
-{{if .IsDir}}📁{{else}}📄{{end}}
-<a href="{{.Path}}">{{.Name}}</a>
+    {{- if .IsDir -}}
+        📁 <a href="/{{.Repo}}/tree/{{.Ref}}/{{.Path}}">{{.Name}}</a>
+    {{- else -}}
+        📄 <a href="/{{.Repo}}/blob/{{.Ref}}/{{.Path}}">{{.Name}}</a>
+    {{- end -}}
 </li>
 {{end}}
 </ul>
+{{- if ne .Readme "" -}}
+<hr>
+<h2>{{.ReadmeName}}</h2>
+<pre>
+{{.Readme}}
+</pre>
+{{- end -}}
 {{end}}
 
 {{define "tree"}}