feat: introduce web UI

Amolith and Crush created

Co-Authored-By: Crush <crush@charm.land>

Change summary

pkg/web/gen_syntax_css.go       |  51 ++++++
pkg/web/git.go                  |  31 +++
pkg/web/git_lfs.go              |  28 +-
pkg/web/goget.go                |  58 +++---
pkg/web/logging.go              |   4 
pkg/web/server.go               |   4 
pkg/web/static/syntax.css       | 157 +++++++++++++++++++
pkg/web/templates/about.html    |  12 +
pkg/web/templates/base.html     |  61 +++++++
pkg/web/templates/blob.html     |  55 ++++++
pkg/web/templates/branches.html |  62 +++++++
pkg/web/templates/commit.html   |  61 +++++++
pkg/web/templates/commits.html  |  58 +++++++
pkg/web/templates/error.html    |   9 +
pkg/web/templates/home.html     |  59 +++++++
pkg/web/templates/overview.html |  48 ++++++
pkg/web/templates/tags.html     |  49 ++++++
pkg/web/templates/tree.html     |  49 ++++++
pkg/web/util.go                 |   2 
pkg/web/webui.go                | 270 +++++++++++++++++++++++++++++++++
pkg/web/webui_about.go          |  44 +++++
pkg/web/webui_blob.go           | 228 ++++++++++++++++++++++++++++
pkg/web/webui_branches.go       | 111 +++++++++++++
pkg/web/webui_commit.go         |  74 +++++++++
pkg/web/webui_commits.go        | 105 +++++++++++++
pkg/web/webui_git.go            | 280 +++++++++++++++++++++++++++++++++++
pkg/web/webui_helpers.go        |  78 +++++++++
pkg/web/webui_home.go           | 169 +++++++++++++++++++++
pkg/web/webui_overview.go       |  68 ++++++++
pkg/web/webui_tags.go           | 131 ++++++++++++++++
pkg/web/webui_tree.go           |  88 +++++++++++
31 files changed, 2,451 insertions(+), 53 deletions(-)

Detailed changes

pkg/web/gen_syntax_css.go 🔗

@@ -0,0 +1,51 @@
+//go:build ignore
+
+package main
+
+import (
+	"bytes"
+	"log"
+	"os"
+	"path/filepath"
+
+	"github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/alecthomas/chroma/v2/styles"
+)
+
+func main() {
+	lightStyle := styles.Get("github")
+	if lightStyle == nil {
+		log.Fatal("github style not found")
+	}
+
+	darkStyle := styles.Get("github-dark")
+	if darkStyle == nil {
+		log.Fatal("github-dark style not found")
+	}
+
+	formatter := html.New(html.WithClasses(true))
+
+	var buf bytes.Buffer
+
+	buf.WriteString("/* Auto-generated syntax highlighting CSS */\n")
+	buf.WriteString("/* Generated by go generate - do not edit manually */\n\n")
+
+	buf.WriteString("@media (prefers-color-scheme: light) {\n")
+	if err := formatter.WriteCSS(&buf, lightStyle); err != nil {
+		log.Fatalf("failed to write light style CSS: %v", err)
+	}
+	buf.WriteString("}\n\n")
+
+	buf.WriteString("@media (prefers-color-scheme: dark) {\n")
+	if err := formatter.WriteCSS(&buf, darkStyle); err != nil {
+		log.Fatalf("failed to write dark style CSS: %v", err)
+	}
+	buf.WriteString("}\n")
+
+	outputPath := filepath.Join("static", "syntax.css")
+	if err := os.WriteFile(outputPath, buf.Bytes(), 0o644); err != nil {
+		log.Fatalf("failed to write CSS file: %v", err)
+	}
+
+	log.Printf("Generated %s successfully", outputPath)
+}

pkg/web/git.go 🔗

@@ -103,6 +103,24 @@ func withParams(next http.Handler) http.Handler {
 	})
 }
 
+// withRepoVars is a lighter middleware for Web UI routes that sets repo vars
+// without rewriting the URL path (which would break Web UI route matching).
+func withRepoVars(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+		logger := log.FromContext(ctx)
+		cfg := config.FromContext(ctx)
+		vars := mux.Vars(r)
+		logger.Debug("withRepoVars called", "path", r.URL.Path, "repo_raw", vars["repo"])
+		repo := utils.SanitizeRepo(vars["repo"])
+		vars["repo"] = repo
+		vars["dir"] = filepath.Join(cfg.DataPath, "repos", repo+".git")
+		r = mux.SetURLVars(r, vars)
+		logger.Debug("withRepoVars set", "repo", repo, "dir", vars["dir"])
+		next.ServeHTTP(w, r)
+	})
+}
+
 // GitController is a router for git services.
 func GitController(_ context.Context, r *mux.Router) {
 	basePrefix := "/{repo:.*}"
@@ -112,8 +130,10 @@ func GitController(_ context.Context, r *mux.Router) {
 		r.Handle(basePrefix+route.path, withParams(withAccess(route)))
 	}
 
-	// Handle go-get
-	r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).Methods(http.MethodGet)
+	// Handle go-get (only when explicitly requested with ?go-get=1)
+	r.Handle(basePrefix, withParams(withAccess(http.HandlerFunc(GoGetHandler)))).
+		Methods(http.MethodGet).
+		Queries("go-get", "1")
 }
 
 var gitRoutes = []GitRoute{
@@ -208,7 +228,8 @@ func withAccess(next http.Handler) http.HandlerFunc {
 		// We're not checking for errors here because we want to allow
 		// repo creation on the fly.
 		repoName := mux.Vars(r)["repo"]
-		repo, _ := be.Repository(ctx, repoName)
+		logger.Debug("withAccess loading repo", "repoName", repoName)
+		repo, err := be.Repository(ctx, repoName)
 		ctx = proto.WithRepositoryContext(ctx, repo)
 		r = r.WithContext(ctx)
 
@@ -453,7 +474,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 }
 
 // Handle buffered output
-// Useful when using proxies
+// Useful when using proxies.
 type flushResponseWriter struct {
 	http.ResponseWriter
 }
@@ -468,7 +489,7 @@ func (f *flushResponseWriter) ReadFrom(r io.Reader) (int64, error) {
 		if err == io.EOF {
 			break
 		}
-		nWrite, err := f.ResponseWriter.Write(p[:nRead])
+		nWrite, err := f.Write(p[:nRead])
 		if err != nil {
 			return n, err
 		}

pkg/web/git_lfs.go 🔗

@@ -29,7 +29,7 @@ import (
 // serviceLfsBatch handles a Git LFS batch requests.
 // https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
 // TODO: support refname
-// POST: /<repo>.git/info/lfs/objects/batch
+// POST: /<repo>.git/info/lfs/objects/batch.
 func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 	logger := log.FromContext(ctx).WithPrefix("http.lfs")
@@ -41,7 +41,7 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
 	}
 
 	var batchRequest lfs.BatchRequest
-	defer r.Body.Close() // nolint: errcheck
+	defer r.Body.Close() //nolint: errcheck
 	if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
 		logger.Errorf("error decoding json: %s", err)
 		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
@@ -249,7 +249,7 @@ func serviceLfsBasic(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// GET: /<repo>.git/info/lfs/objects/basic/<oid>
+// GET: /<repo>.git/info/lfs/objects/basic/<oid>.
 func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 	oid := mux.Vars(r)["oid"]
@@ -282,7 +282,7 @@ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
 
 	w.Header().Set("Content-Type", "application/octet-stream")
 	w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10))
-	defer f.Close() // nolint: errcheck
+	defer f.Close() //nolint: errcheck
 	if _, err := io.Copy(w, f); err != nil {
 		logger.Error("error copying object to response", "oid", oid, "err", err)
 		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
@@ -292,7 +292,7 @@ func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// PUT: /<repo>.git/info/lfs/objects/basic/<oid>
+// PUT: /<repo>.git/info/lfs/objects/basic/<oid>.
 func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
 	if !isBinary(r) {
 		renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{
@@ -313,7 +313,7 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
 	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs", repoID))
 	name := mux.Vars(r)["repo"]
 
-	defer r.Body.Close() // nolint: errcheck
+	defer r.Body.Close() //nolint: errcheck
 	repo, err := be.Repository(ctx, name)
 	if err != nil {
 		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
@@ -326,7 +326,7 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
 	// partial error, so we need to skip existing objects.
 	if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {
 		// Object exists, skip request
-		io.Copy(io.Discard, r.Body) // nolint: errcheck
+		io.Copy(io.Discard, r.Body) //nolint: errcheck
 		renderStatus(http.StatusOK)(w, nil)
 		return
 	} else if !errors.Is(err, db.ErrRecordNotFound) {
@@ -366,7 +366,7 @@ func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
 	renderStatus(http.StatusOK)(w, nil)
 }
 
-// POST: /<repo>.git/info/lfs/objects/basic/verify
+// POST: /<repo>.git/info/lfs/objects/basic/verify.
 func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
 	if !isLfs(r) {
 		renderNotAcceptable(w)
@@ -385,7 +385,7 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	defer r.Body.Close() // nolint: errcheck
+	defer r.Body.Close() //nolint: errcheck
 	if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {
 		logger.Error("error decoding json", "err", err)
 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
@@ -454,7 +454,7 @@ func serviceLfsLocks(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// POST: /<repo>.git/info/lfs/objects/locks
+// POST: /<repo>.git/info/lfs/objects/locks.
 func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
 	if !isLfs(r) {
 		renderNotAcceptable(w)
@@ -555,7 +555,7 @@ func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
 	})
 }
 
-// GET: /<repo>.git/info/lfs/objects/locks
+// GET: /<repo>.git/info/lfs/objects/locks.
 func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
 	accept := r.Header.Get("Accept")
 	if !strings.HasPrefix(accept, lfs.MediaType) {
@@ -578,7 +578,7 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
 			limit, _ = strconv.Atoi(limitStr)
 		}
 		refspec = values.Get("refspec")
-		return
+		return path, id, cursor, limit, refspec
 	}
 
 	ctx := r.Context()
@@ -730,7 +730,7 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
 	renderJSON(w, http.StatusOK, resp)
 }
 
-// POST: /<repo>.git/info/lfs/objects/locks/verify
+// POST: /<repo>.git/info/lfs/objects/locks/verify.
 func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
 	if !isLfs(r) {
 		renderNotAcceptable(w)
@@ -827,7 +827,7 @@ func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
 	renderJSON(w, http.StatusOK, resp)
 }
 
-// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock
+// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock.
 func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
 	if !isLfs(r) {
 		renderNotAcceptable(w)

pkg/web/goget.go 🔗

@@ -44,6 +44,7 @@ func GoGetHandler(w http.ResponseWriter, r *http.Request) {
 	repo := mux.Vars(r)["repo"]
 
 	// Handle go get requests.
+	// This handler is only reached when ?go-get=1 is present due to route matcher.
 	//
 	// Always return a 200 status code, even if the repo path doesn't exist.
 	// It will try to find the repo by walking up the path until it finds one.
@@ -51,45 +52,40 @@ func GoGetHandler(w http.ResponseWriter, r *http.Request) {
 	//
 	// https://golang.org/cmd/go/#hdr-Remote_import_paths
 	// https://go.dev/ref/mod#vcs-branch
-	if r.URL.Query().Get("go-get") == "1" {
-		repo := repo
-		importRoot, err := url.Parse(cfg.HTTP.PublicURL)
-		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-			return
-		}
 
-		// find the repo
-		for {
-			if _, err := be.Repository(ctx, repo); err == nil {
-				break
-			}
-
-			if repo == "" || repo == "." || repo == "/" {
-				renderNotFound(w, r)
-				return
-			}
+	importRoot, err := url.Parse(cfg.HTTP.PublicURL)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
 
-			repo = path.Dir(repo)
+	// find the repo
+	for {
+		if _, err := be.Repository(ctx, repo); err == nil {
+			break
 		}
 
-		if err := repoIndexHTMLTpl.Execute(w, struct {
-			Repo       string
-			Config     *config.Config
-			ImportRoot string
-		}{
-			Repo:       utils.SanitizeRepo(repo),
-			Config:     cfg,
-			ImportRoot: importRoot.Host,
-		}); err != nil {
-			logger.Error("failed to render go get template", "err", err)
-			renderInternalServerError(w, r)
+		if repo == "" || repo == "." || repo == "/" {
+			renderNotFound(w, r)
 			return
 		}
 
-		goGetCounter.WithLabelValues(repo).Inc()
+		repo = path.Dir(repo)
+	}
+
+	if err := repoIndexHTMLTpl.Execute(w, struct {
+		Repo       string
+		Config     *config.Config
+		ImportRoot string
+	}{
+		Repo:       utils.SanitizeRepo(repo),
+		Config:     cfg,
+		ImportRoot: importRoot.Host,
+	}); err != nil {
+		logger.Debug("failed to render go get template", "err", err)
+		renderInternalServerError(w, r)
 		return
 	}
 
-	renderNotFound(w, r)
+	goGetCounter.WithLabelValues(repo).Inc()
 }

pkg/web/logging.go 🔗

@@ -24,7 +24,7 @@ var _ http.Flusher = (*logWriter)(nil)
 
 var _ http.Hijacker = (*logWriter)(nil)
 
-var _ http.CloseNotifier = (*logWriter)(nil) // nolint: staticcheck
+var _ http.CloseNotifier = (*logWriter)(nil) //nolint: staticcheck
 
 // Write implements http.ResponseWriter.
 func (r *logWriter) Write(p []byte) (int, error) {
@@ -54,7 +54,7 @@ func (r *logWriter) Flush() {
 
 // CloseNotify implements http.CloseNotifier.
 func (r *logWriter) CloseNotify() <-chan bool {
-	if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { // nolint: staticcheck
+	if cn, ok := r.ResponseWriter.(http.CloseNotifier); ok { //nolint: staticcheck
 		return cn.CloseNotify()
 	}
 	return nil

pkg/web/server.go 🔗

@@ -20,6 +20,10 @@ func NewRouter(ctx context.Context) http.Handler {
 	// Git routes
 	GitController(ctx, router)
 
+	// Web UI routes (must be after Git routes)
+	WebUIController(ctx, router)
+
+	// Catch-all route (must be last)
 	router.PathPrefix("/").HandlerFunc(renderNotFound)
 
 	// Context handler

pkg/web/static/syntax.css 🔗

@@ -0,0 +1,157 @@
+/* Auto-generated syntax highlighting CSS */
+/* Generated by go generate - do not edit manually */
+
+@media (prefers-color-scheme: light) {
+/* Background */ .bg { background-color: #ffffff; }
+/* PreWrapper */ .chroma { background-color: #ffffff; }
+/* Error */ .chroma .err { color: #f6f8fa; background-color: #82071e }
+/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
+/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
+/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
+/* LineHighlight */ .chroma .hl { background-color: #e5e5e5 }
+/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
+/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f }
+/* Line */ .chroma .line { display: flex; }
+/* Keyword */ .chroma .k { color: #cf222e }
+/* KeywordConstant */ .chroma .kc { color: #cf222e }
+/* KeywordDeclaration */ .chroma .kd { color: #cf222e }
+/* KeywordNamespace */ .chroma .kn { color: #cf222e }
+/* KeywordPseudo */ .chroma .kp { color: #cf222e }
+/* KeywordReserved */ .chroma .kr { color: #cf222e }
+/* KeywordType */ .chroma .kt { color: #cf222e }
+/* NameAttribute */ .chroma .na { color: #1f2328 }
+/* NameClass */ .chroma .nc { color: #1f2328 }
+/* NameConstant */ .chroma .no { color: #0550ae }
+/* NameDecorator */ .chroma .nd { color: #0550ae }
+/* NameEntity */ .chroma .ni { color: #6639ba }
+/* NameLabel */ .chroma .nl { color: #990000; font-weight: bold }
+/* NameNamespace */ .chroma .nn { color: #24292e }
+/* NameOther */ .chroma .nx { color: #1f2328 }
+/* NameTag */ .chroma .nt { color: #0550ae }
+/* NameBuiltin */ .chroma .nb { color: #6639ba }
+/* NameBuiltinPseudo */ .chroma .bp { color: #6a737d }
+/* NameVariable */ .chroma .nv { color: #953800 }
+/* NameVariableClass */ .chroma .vc { color: #953800 }
+/* NameVariableGlobal */ .chroma .vg { color: #953800 }
+/* NameVariableInstance */ .chroma .vi { color: #953800 }
+/* NameVariableMagic */ .chroma .vm { color: #953800 }
+/* NameFunction */ .chroma .nf { color: #6639ba }
+/* NameFunctionMagic */ .chroma .fm { color: #6639ba }
+/* LiteralString */ .chroma .s { color: #0a3069 }
+/* LiteralStringAffix */ .chroma .sa { color: #0a3069 }
+/* LiteralStringBacktick */ .chroma .sb { color: #0a3069 }
+/* LiteralStringChar */ .chroma .sc { color: #0a3069 }
+/* LiteralStringDelimiter */ .chroma .dl { color: #0a3069 }
+/* LiteralStringDoc */ .chroma .sd { color: #0a3069 }
+/* LiteralStringDouble */ .chroma .s2 { color: #0a3069 }
+/* LiteralStringEscape */ .chroma .se { color: #0a3069 }
+/* LiteralStringHeredoc */ .chroma .sh { color: #0a3069 }
+/* LiteralStringInterpol */ .chroma .si { color: #0a3069 }
+/* LiteralStringOther */ .chroma .sx { color: #0a3069 }
+/* LiteralStringRegex */ .chroma .sr { color: #0a3069 }
+/* LiteralStringSingle */ .chroma .s1 { color: #0a3069 }
+/* LiteralStringSymbol */ .chroma .ss { color: #032f62 }
+/* LiteralNumber */ .chroma .m { color: #0550ae }
+/* LiteralNumberBin */ .chroma .mb { color: #0550ae }
+/* LiteralNumberFloat */ .chroma .mf { color: #0550ae }
+/* LiteralNumberHex */ .chroma .mh { color: #0550ae }
+/* LiteralNumberInteger */ .chroma .mi { color: #0550ae }
+/* LiteralNumberIntegerLong */ .chroma .il { color: #0550ae }
+/* LiteralNumberOct */ .chroma .mo { color: #0550ae }
+/* Operator */ .chroma .o { color: #0550ae }
+/* OperatorWord */ .chroma .ow { color: #0550ae }
+/* Punctuation */ .chroma .p { color: #1f2328 }
+/* Comment */ .chroma .c { color: #57606a }
+/* CommentHashbang */ .chroma .ch { color: #57606a }
+/* CommentMultiline */ .chroma .cm { color: #57606a }
+/* CommentSingle */ .chroma .c1 { color: #57606a }
+/* CommentSpecial */ .chroma .cs { color: #57606a }
+/* CommentPreproc */ .chroma .cp { color: #57606a }
+/* CommentPreprocFile */ .chroma .cpf { color: #57606a }
+/* GenericDeleted */ .chroma .gd { color: #82071e; background-color: #ffebe9 }
+/* GenericEmph */ .chroma .ge { color: #1f2328 }
+/* GenericInserted */ .chroma .gi { color: #116329; background-color: #dafbe1 }
+/* GenericOutput */ .chroma .go { color: #1f2328 }
+/* GenericUnderline */ .chroma .gl { text-decoration: underline }
+/* TextWhitespace */ .chroma .w { color: #ffffff }
+}
+
+@media (prefers-color-scheme: dark) {
+/* Background */ .bg { color: #e6edf3; background-color: #0d1117; }
+/* PreWrapper */ .chroma { color: #e6edf3; background-color: #0d1117; }
+/* Error */ .chroma .err { color: #f85149 }
+/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit }
+/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; }
+/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; }
+/* LineHighlight */ .chroma .hl { background-color: #6e7681 }
+/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #737679 }
+/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #6e7681 }
+/* Line */ .chroma .line { display: flex; }
+/* Keyword */ .chroma .k { color: #ff7b72 }
+/* KeywordConstant */ .chroma .kc { color: #79c0ff }
+/* KeywordDeclaration */ .chroma .kd { color: #ff7b72 }
+/* KeywordNamespace */ .chroma .kn { color: #ff7b72 }
+/* KeywordPseudo */ .chroma .kp { color: #79c0ff }
+/* KeywordReserved */ .chroma .kr { color: #ff7b72 }
+/* KeywordType */ .chroma .kt { color: #ff7b72 }
+/* NameClass */ .chroma .nc { color: #f0883e; font-weight: bold }
+/* NameConstant */ .chroma .no { color: #79c0ff; font-weight: bold }
+/* NameDecorator */ .chroma .nd { color: #d2a8ff; font-weight: bold }
+/* NameEntity */ .chroma .ni { color: #ffa657 }
+/* NameException */ .chroma .ne { color: #f0883e; font-weight: bold }
+/* NameLabel */ .chroma .nl { color: #79c0ff; font-weight: bold }
+/* NameNamespace */ .chroma .nn { color: #ff7b72 }
+/* NameProperty */ .chroma .py { color: #79c0ff }
+/* NameTag */ .chroma .nt { color: #7ee787 }
+/* NameVariable */ .chroma .nv { color: #79c0ff }
+/* NameVariableClass */ .chroma .vc { color: #79c0ff }
+/* NameVariableGlobal */ .chroma .vg { color: #79c0ff }
+/* NameVariableInstance */ .chroma .vi { color: #79c0ff }
+/* NameVariableMagic */ .chroma .vm { color: #79c0ff }
+/* NameFunction */ .chroma .nf { color: #d2a8ff; font-weight: bold }
+/* NameFunctionMagic */ .chroma .fm { color: #d2a8ff; font-weight: bold }
+/* Literal */ .chroma .l { color: #a5d6ff }
+/* LiteralDate */ .chroma .ld { color: #79c0ff }
+/* LiteralString */ .chroma .s { color: #a5d6ff }
+/* LiteralStringAffix */ .chroma .sa { color: #79c0ff }
+/* LiteralStringBacktick */ .chroma .sb { color: #a5d6ff }
+/* LiteralStringChar */ .chroma .sc { color: #a5d6ff }
+/* LiteralStringDelimiter */ .chroma .dl { color: #79c0ff }
+/* LiteralStringDoc */ .chroma .sd { color: #a5d6ff }
+/* LiteralStringDouble */ .chroma .s2 { color: #a5d6ff }
+/* LiteralStringEscape */ .chroma .se { color: #79c0ff }
+/* LiteralStringHeredoc */ .chroma .sh { color: #79c0ff }
+/* LiteralStringInterpol */ .chroma .si { color: #a5d6ff }
+/* LiteralStringOther */ .chroma .sx { color: #a5d6ff }
+/* LiteralStringRegex */ .chroma .sr { color: #79c0ff }
+/* LiteralStringSingle */ .chroma .s1 { color: #a5d6ff }
+/* LiteralStringSymbol */ .chroma .ss { color: #a5d6ff }
+/* LiteralNumber */ .chroma .m { color: #a5d6ff }
+/* LiteralNumberBin */ .chroma .mb { color: #a5d6ff }
+/* LiteralNumberFloat */ .chroma .mf { color: #a5d6ff }
+/* LiteralNumberHex */ .chroma .mh { color: #a5d6ff }
+/* LiteralNumberInteger */ .chroma .mi { color: #a5d6ff }
+/* LiteralNumberIntegerLong */ .chroma .il { color: #a5d6ff }
+/* LiteralNumberOct */ .chroma .mo { color: #a5d6ff }
+/* Operator */ .chroma .o { color: #ff7b72; font-weight: bold }
+/* OperatorWord */ .chroma .ow { color: #ff7b72; font-weight: bold }
+/* Comment */ .chroma .c { color: #8b949e; font-style: italic }
+/* CommentHashbang */ .chroma .ch { color: #8b949e; font-style: italic }
+/* CommentMultiline */ .chroma .cm { color: #8b949e; font-style: italic }
+/* CommentSingle */ .chroma .c1 { color: #8b949e; font-style: italic }
+/* CommentSpecial */ .chroma .cs { color: #8b949e; font-weight: bold; font-style: italic }
+/* CommentPreproc */ .chroma .cp { color: #8b949e; font-weight: bold; font-style: italic }
+/* CommentPreprocFile */ .chroma .cpf { color: #8b949e; font-weight: bold; font-style: italic }
+/* GenericDeleted */ .chroma .gd { color: #ffa198; background-color: #490202 }
+/* GenericEmph */ .chroma .ge { font-style: italic }
+/* GenericError */ .chroma .gr { color: #ffa198 }
+/* GenericHeading */ .chroma .gh { color: #79c0ff; font-weight: bold }
+/* GenericInserted */ .chroma .gi { color: #56d364; background-color: #0f5323 }
+/* GenericOutput */ .chroma .go { color: #8b949e }
+/* GenericPrompt */ .chroma .gp { color: #8b949e }
+/* GenericStrong */ .chroma .gs { font-weight: bold }
+/* GenericSubheading */ .chroma .gu { color: #79c0ff }
+/* GenericTraceback */ .chroma .gt { color: #ff7b72 }
+/* GenericUnderline */ .chroma .gl { text-decoration: underline }
+/* TextWhitespace */ .chroma .w { color: #6e7681 }
+}

pkg/web/templates/about.html 🔗

@@ -0,0 +1,12 @@
+{{define "content"}}
+<section aria-labelledby="about-heading">
+  <h2 id="about-heading">About</h2>
+  
+  {{if .ReadmeHTML}}
+  {{.ReadmeHTML}}
+  {{else}}
+  <p>No readme found.</p>
+  <p>Create a <code>.soft-serve</code> repository and add a <code>README.md</code> file to display information here.</p>
+  {{end}}
+</section>
+{{end}}

pkg/web/templates/base.html 🔗

@@ -0,0 +1,61 @@
+{{define "layout"}}
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta name="color-scheme" content="light dark">
+  <title>{{if .Repo}}{{if .Repo.ProjectName}}{{.Repo.ProjectName}}{{else}}{{.Repo.Name}}{{end}}{{else}}{{.ServerName}}{{end}}</title>
+  {{if .Repo}}{{if .Repo.Description}}<meta name="description" content="{{.Repo.Description}}">{{end}}{{end}}
+  <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍦</text></svg>">
+
+  <link rel="stylesheet" href="/static/syntax.css">
+</head>
+<body class="container">
+  <a href="#main-content" class="skip-link">Skip to main content</a>
+
+  <header>
+    <nav aria-label="Site navigation">
+      <ul>
+        <li><strong>{{.ServerName}}</strong></li>
+      </ul>
+      <ul>
+        <li><a href="/"{{if eq .ActiveTab "repositories"}} aria-current="page"{{end}}>Repositories</a></li>
+        <li><a href="/about"{{if eq .ActiveTab "about"}} aria-current="page"{{end}}>About</a></li>
+      </ul>
+    </nav>
+    {{if .Repo}}
+    <nav aria-label="Repository navigation">
+      <ul>
+        <li>
+          {{if .Repo.Description}}
+          <hgroup>
+            <h1>{{if .Repo.ProjectName}}{{.Repo.ProjectName}}{{else}}{{.Repo.Name}}{{end}}</h1>
+            <p>{{.Repo.Description}}</p>
+          </hgroup>
+          {{else}}
+          <h1>{{if .Repo.ProjectName}}{{.Repo.ProjectName}}{{else}}{{.Repo.Name}}{{end}}</h1>
+          {{end}}
+        </li>
+      </ul>
+      <ul>
+        <li><a href="/{{.Repo.Name}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>README</a></li>
+        <li><a href="/{{.Repo.Name}}/tree/{{.DefaultBranch}}"{{if eq .ActiveTab "tree"}} aria-current="page"{{end}}>Files</a></li>
+        <li><a href="/{{.Repo.Name}}/commits/{{.DefaultBranch}}"{{if eq .ActiveTab "commits"}} aria-current="page"{{end}}>Commits</a></li>
+        <li><a href="/{{.Repo.Name}}/branches"{{if eq .ActiveTab "branches"}} aria-current="page"{{end}}>Branches</a></li>
+        <li><a href="/{{.Repo.Name}}/tags"{{if eq .ActiveTab "tags"}} aria-current="page"{{end}}>Tags</a></li>
+      </ul>
+    </nav>
+    {{end}}
+  </header>
+
+  <main id="main-content">
+    {{template "content" .}}
+  </main>
+
+  <footer>
+    <p>Powered by <a href="https://github.com/charmbracelet/soft-serve">Soft Serve</a></p>
+  </footer>
+</body>
+</html>
+{{end}}

pkg/web/templates/blob.html 🔗

@@ -0,0 +1,55 @@
+{{define "content"}}
+<nav aria-label="breadcrumb">
+  <ul>
+    <li><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></li>
+    <li><a href="/{{.Repo.Name}}/tree/{{.Ref}}">{{.Ref | shortHash}}</a></li>
+    {{range $i, $part := splitPath .Path}}
+    <li{{if eq $i (dec (len (splitPath $.Path)))}} aria-current="page"{{end}}>
+      {{if eq $i (dec (len (splitPath $.Path)))}}
+        {{$part}}
+      {{else}}
+        <a href="/{{$.Repo.Name}}/tree/{{$.Ref}}/{{joinPath $i $}}">{{$part}}</a>
+      {{end}}
+    </li>
+    {{end}}
+  </ul>
+</nav>
+
+<section aria-labelledby="file-content-heading">
+  {{$parts := splitPath .Path}}
+  <h2 id="file-content-heading">{{index $parts (dec (len $parts))}}</h2>
+  
+  <nav aria-label="File actions">
+    <ul>
+      {{if .IsMarkdown}}
+        {{if .ShowSource}}
+      <li><a href="/{{.Repo.Name}}/blob/{{.Ref}}/{{.Path}}">View rendered Markdown</a></li>
+        {{else}}
+      <li><a href="/{{.Repo.Name}}/blob/{{.Ref}}/{{.Path}}?source=1">View source</a></li>
+        {{end}}
+      {{end}}
+      <li><a href="/{{.Repo.Name}}/blob/{{.Ref}}/{{.Path}}?raw=1">View raw</a></li>
+    </ul>
+  </nav>
+  
+  {{if .IsBinary}}
+  <p role="status" aria-live="polite">This is a binary file and cannot be displayed as text.</p>
+  {{else if .IsMarkdown}}
+    {{if .ShowSource}}
+  <div aria-label="Syntax highlighted code">
+    {{.RenderedHTML}}
+  </div>
+    {{else}}
+  <article aria-label="Markdown rendered content">
+    {{.RenderedHTML}}
+  </article>
+    {{end}}
+  {{else if .RenderedHTML}}
+  <div aria-label="Syntax highlighted code">
+    {{.RenderedHTML}}
+  </div>
+  {{else}}
+  <pre aria-label="File contents"><code>{{.Content}}</code></pre>
+  {{end}}
+</section>
+{{end}}

pkg/web/templates/branches.html 🔗

@@ -0,0 +1,62 @@
+{{define "content"}}
+<section aria-labelledby="branches-heading">
+  <h2 id="branches-heading">Branches <span aria-label="Total count">({{.TotalBranches}})</span></h2>
+  {{if .Branches}}
+  {{range .Branches}}
+  <article>
+    <h3>
+      {{.Ref.Name.Short}}
+      {{if eq .Ref.Name.Short $.DefaultBranch}}
+      <span aria-label="Default branch">(default)</span>
+      {{end}}
+    </h3>
+    {{if .Commit}}
+    <p>
+      <code><a href="/{{$.Repo.Name}}/commit/{{.Commit.ID}}">{{.Commit.ID | shortHash}}</a></code>
+      {{.Commit.Message | commitSubject}}
+    </p>
+    {{$body := .Commit.Message | commitBody}}
+    {{if $body}}
+    <details>
+      <summary>Click to expand commit body</summary>
+      <pre>{{$body}}</pre>
+    </details>
+    {{end}}
+    <p>
+      by <strong>{{.Commit.Author.Name}}</strong> on
+      <time datetime="{{.Commit.Author.When | rfc3339}}">{{.Commit.Author.When | formatDate}}</time>
+    </p>
+    {{end}}
+    <div class="grid">
+      <a href="/{{$.Repo.Name}}/tree/{{.Ref.Name.Short}}" role="button" class="secondary">Files</a>
+      <a href="/{{$.Repo.Name}}/commits/{{.Ref.Name.Short}}" role="button" class="secondary">Commits</a>
+    </div>
+  </article>
+  {{end}}
+
+  {{if or .HasPrevPage .HasNextPage}}
+  <nav aria-label="Pagination">
+    <p>Page {{.Page}} of {{.TotalPages}}</p>
+    <ul>
+      {{if .HasPrevPage}}
+      <li>
+        <a href="/{{.Repo.Name}}/branches?page={{dec .Page}}" rel="prev"
+          >Previous page</a
+        >
+      </li>
+      {{end}}
+      {{if .HasNextPage}}
+      <li>
+        <a href="/{{.Repo.Name}}/branches?page={{inc .Page}}" rel="next"
+          >Next page</a
+        >
+      </li>
+      {{end}}
+    </ul>
+  </nav>
+  {{end}}
+  {{else}}
+  <p>No branches found</p>
+  {{end}}
+</section>
+{{end}}

pkg/web/templates/commit.html 🔗

@@ -0,0 +1,61 @@
+{{define "content"}}
+<nav aria-label="breadcrumb">
+  <ul>
+    <li><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></li>
+    <li aria-current="page">{{.Commit.ID | shortHash}}</li>
+  </ul>
+</nav>
+
+<header>
+  <h2 id="commit-heading">{{.Commit.Message | commitSubject}}</h2>
+  {{$body := .Commit.Message | commitBody}}
+  {{if $body}}
+  <pre><code>{{$body}}</code></pre>
+  {{end}}
+  <dl aria-label="Commit metadata">
+    <dt>Author</dt>
+    <dd>{{.Commit.Author.Name}} &lt;{{.Commit.Author.Email}}&gt;</dd>
+    
+    <dt>Date</dt>
+    <dd><time datetime="{{.Commit.Author.When | rfc3339}}">{{.Commit.Author.When | formatDate}}</time></dd>
+    
+    <dt>Commit</dt>
+    <dd><code>{{.Commit.ID}}</code></dd>
+    
+    {{if .ParentIDs}}
+    <dt>Parent{{if gt (len .ParentIDs) 1}}s{{end}}</dt>
+    <dd>
+      {{range $i, $parent := .ParentIDs}}{{if $i}}, {{end}}<code><a href="/{{$.Repo.Name}}/commit/{{$parent}}">{{$parent | shortHash}}</a></code>{{end}}
+    </dd>
+    {{end}}
+  </dl>
+  
+  <nav aria-label="Commit actions">
+    <ul>
+      <li><a href="/{{.Repo.Name}}/tree/{{.Commit.ID}}">Browse file tree</a></li>
+    </ul>
+  </nav>
+</header>
+
+<section aria-labelledby="changes-heading">
+  <h3 id="changes-heading">Change summary</h3>
+  <pre aria-label="Diff statistics"><code>{{.Diff.Stats}}</code></pre>
+</section>
+
+<section aria-labelledby="diff-heading">
+  <h3 id="diff-heading">Detailed changes</h3>
+  {{range .Diff.Files}}
+  <article aria-labelledby="file-{{.Name}}">
+    <h4 id="file-{{.Name}}">
+      {{if ne .OldName .Name}}
+        {{.OldName}} → <a href="/{{$.Repo.Name}}/blob/{{$.Commit.ID}}/{{.Name}}">{{.Name}}</a>
+      {{else}}
+        <a href="/{{$.Repo.Name}}/blob/{{$.Commit.ID}}/{{.Name}}">{{.Name}}</a>
+      {{end}}
+    </h4>
+    <pre aria-label="Diff for {{.Name}}"><code>{{range .Sections}}{{range .Lines}}{{if eq .Type 2}}<ins>{{.Content}}</ins>{{else if eq .Type 3}}<del>{{.Content}}</del>{{else}}{{.Content}}{{end}}
+{{end}}{{end}}</code></pre>
+  </article>
+  {{end}}
+</section>
+{{end}}

pkg/web/templates/commits.html 🔗

@@ -0,0 +1,58 @@
+{{define "content"}}
+<nav aria-label="breadcrumb">
+  <ul>
+    <li><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></li>
+    <li>{{.Ref}}</li>
+  </ul>
+</nav>
+
+<section aria-labelledby="commits-heading">
+  <h2 id="commits-heading">Commit log</h2>
+
+  {{if .Commits}}
+  {{range .Commits}}
+  <article aria-labelledby="commit-{{.ID | shortHash}}">
+    <h3 id="commit-{{.ID | shortHash}}">
+      <code><a href="/{{$.Repo.Name}}/commit/{{.ID}}">{{.ID | shortHash}}</a></code>
+      {{.Message | commitSubject}}
+    </h3>
+    {{$body := .Message | commitBody}}
+    {{if $body}}
+    <details>
+      <summary>Click to expand commit body</summary>
+      <pre>{{$body}}</pre>
+    </details>
+    {{end}}
+    <p>
+      by <strong>{{.Author.Name}}</strong> on
+      <time datetime="{{.Author.When | rfc3339}}">{{.Author.When | formatDate}}</time>
+    </p>
+  </article>
+  {{end}}
+
+  {{if or .HasPrevPage .HasNextPage}}
+  <nav aria-label="Pagination">
+    <p>Page {{.Page}} of {{.TotalPages}}</p>
+    <ul>
+      {{if .HasPrevPage}}
+      <li>
+        <a href="/{{.Repo.Name}}/commits/{{.Ref}}?page={{dec .Page}}" rel="prev"
+          >Previous page</a
+        >
+      </li>
+      {{end}}
+      {{if .HasNextPage}}
+      <li>
+        <a href="/{{.Repo.Name}}/commits/{{.Ref}}?page={{inc .Page}}" rel="next"
+          >Next page</a
+        >
+      </li>
+      {{end}}
+    </ul>
+  </nav>
+  {{end}}
+  {{else}}
+  <p>No commits yet</p>
+  {{end}}
+</section>
+{{end}}

pkg/web/templates/error.html 🔗

@@ -0,0 +1,9 @@
+{{define "content"}}
+<section aria-labelledby="error-heading" role="alert">
+  <h2 id="error-heading">Error {{.ErrorCode}}</h2>
+  <p>{{.ErrorMessage}}</p>
+  <nav aria-label="Error page navigation">
+    <a href="/">← Return to home page</a>
+  </nav>
+</section>
+{{end}}

pkg/web/templates/home.html 🔗

@@ -0,0 +1,59 @@
+{{define "content"}}
+<section aria-labelledby="repositories-heading">
+  <h2 id="repositories-heading">Repositories</h2>
+  
+  {{if .Repositories}}
+  {{range .Repositories}}
+  <article>
+    <hgroup>
+      <h3>
+        <a href="/{{.Name}}">
+          {{if .ProjectName}}{{.ProjectName}}{{else}}{{.Name}}{{end}}
+          {{if .IsPrivate}} 🔒{{end}}
+        </a>
+      </h3>
+      {{if .Description}}
+      <p>{{.Description}}</p>
+      {{end}}
+    </hgroup>
+    
+    <footer>
+      <small>
+        {{if .CloneURL}}
+        <code>git clone {{.CloneURL}}</code>
+        <br>
+        {{end}}
+        {{if .UpdatedAt}}
+        Updated {{.UpdatedAt}}
+        {{end}}
+      </small>
+    </footer>
+  </article>
+  {{end}}
+
+  {{if or .HasPrevPage .HasNextPage}}
+  <nav aria-label="Pagination">
+    <p>Page {{.Page}} of {{.TotalPages}}</p>
+    <ul>
+      {{if .HasPrevPage}}
+      <li>
+        <a href="/?page={{dec .Page}}" rel="prev"
+          >Previous page</a
+        >
+      </li>
+      {{end}}
+      {{if .HasNextPage}}
+      <li>
+        <a href="/?page={{inc .Page}}" rel="next"
+          >Next page</a
+        >
+      </li>
+      {{end}}
+    </ul>
+  </nav>
+  {{end}}
+  {{else}}
+  <p>No public repositories available.</p>
+  {{end}}
+</section>
+{{end}}

pkg/web/templates/overview.html 🔗

@@ -0,0 +1,48 @@
+{{define "content"}}
+{{if .IsEmpty}}
+<section aria-labelledby="empty-state-heading">
+  <h2 id="empty-state-heading">Empty repository</h2>
+  <p>This repository is empty. Initialize it with your first commit:</p>
+  <pre><code>git clone {{.SSHURL}}
+cd {{.Repo.Name}}
+touch README.md
+git add README.md
+git commit -m "Initial commit"
+git push -u origin main</code></pre>
+</section>
+{{else}}
+<fieldset>
+  <h3>Clone</h3>
+  <label>
+    <input name="url-type" type="checkbox" role="switch" />
+    Use HTTP
+  </label>
+  <p id="ssh-url"><code>git clone {{.SSHURL}}</code></p>
+  <p id="http-url" style="display: none;"><code>git clone {{.HTTPURL}}</code></p>
+</fieldset>
+
+<script>
+document.querySelector('input[name="url-type"]').addEventListener('change', function() {
+  const sshUrl = document.getElementById('ssh-url');
+  const httpUrl = document.getElementById('http-url');
+  
+  if (this.checked) {
+    sshUrl.style.display = 'none';
+    httpUrl.style.display = 'block';
+  } else {
+    sshUrl.style.display = 'block';
+    httpUrl.style.display = 'none';
+  }
+});
+</script>
+
+{{if .ReadmeHTML}}
+<section aria-labelledby="readme-heading">
+  <h2 id="readme-heading">README</h2>
+  <article>
+    {{.ReadmeHTML}}
+  </article>
+</section>
+{{end}}
+{{end}}
+{{end}}

pkg/web/templates/tags.html 🔗

@@ -0,0 +1,49 @@
+{{define "content"}}
+<section aria-labelledby="tags-heading">
+  <h2 id="tags-heading">Tags <span aria-label="Total count">({{.TotalTags}})</span></h2>
+  {{if .Tags}}
+  {{range .Tags}}
+  <article>
+    <h3>{{.Ref.Name.Short}}</h3>
+    {{if .TagMessage}}
+    <pre><code>{{.TagMessage}}</code></pre>
+    {{end}}
+    {{if .Commit}}
+    <p>
+      by <strong>{{.Commit.Author.Name}}</strong> on
+      <time datetime="{{.TagDate | rfc3339}}">{{.TagDate | formatDate}}</time>
+    </p>
+    {{end}}
+    <div class="grid">
+      <a href="/{{$.Repo.Name}}/tree/{{.Ref.Name.Short}}" role="button" class="secondary">Files</a>
+      <a href="/{{$.Repo.Name}}/commits/{{.Ref.Name.Short}}" role="button" class="secondary">Commits</a>
+    </div>
+  </article>
+  {{end}}
+
+  {{if or .HasPrevPage .HasNextPage}}
+  <nav aria-label="Pagination">
+    <p>Page {{.Page}} of {{.TotalPages}}</p>
+    <ul>
+      {{if .HasPrevPage}}
+      <li>
+        <a href="/{{.Repo.Name}}/tags?page={{dec .Page}}" rel="prev"
+          >Previous page</a
+        >
+      </li>
+      {{end}}
+      {{if .HasNextPage}}
+      <li>
+        <a href="/{{.Repo.Name}}/tags?page={{inc .Page}}" rel="next"
+          >Next page</a
+        >
+      </li>
+      {{end}}
+    </ul>
+  </nav>
+  {{end}}
+  {{else}}
+  <p>No tags found</p>
+  {{end}}
+</section>
+{{end}}

pkg/web/templates/tree.html 🔗

@@ -0,0 +1,49 @@
+{{define "content"}}
+<nav aria-label="breadcrumb">
+  <ul>
+    <li><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></li>
+    {{if ne .Path "."}}
+    <li><a href="/{{.Repo.Name}}/tree/{{.Ref}}">{{.Ref | shortHash}}</a></li>
+    {{range $i, $part := splitPath .Path}}
+    <li><a href="/{{$.Repo.Name}}/tree/{{$.Ref}}/{{joinPath $i $}}">{{$part}}</a></li>
+    {{end}}
+    {{else}}
+    <li>{{.Ref | shortHash}}</li>
+    {{end}}
+  </ul>
+</nav>
+
+<section aria-labelledby="file-list-heading">
+  <h2 id="file-list-heading">File tree</h2>
+  {{if .Entries}}
+  <table aria-label="Directory contents">
+    <thead>
+      <tr>
+        <th scope="col">Name</th>
+        <th scope="col">Size</th>
+      </tr>
+    </thead>
+    <tbody>
+      {{if ne .Path "."}}
+      <tr>
+        <td><a href="/{{.Repo.Name}}/tree/{{.Ref}}/{{parentPath .Path}}" aria-label="Parent directory">..</a></td>
+        <td aria-label="Directory">-</td>
+      </tr>
+      {{end}}
+      {{range .Entries}}
+      <tr>
+        <td>
+          <a href="/{{$.Repo.Name}}/{{if .IsTree}}tree{{else}}blob{{end}}/{{$.Ref}}/{{if ne $.Path "."}}{{$.Path}}/{{end}}{{.Name}}"{{if .IsTree}} aria-label="Directory: {{.Name}}"{{else}} aria-label="File: {{.Name}}"{{end}}>
+            {{.Name}}{{if .IsTree}}/{{end}}
+          </a>
+        </td>
+        <td>{{if .IsTree}}-{{else}}{{.Size | humanizeSize}}{{end}}</td>
+      </tr>
+      {{end}}
+    </tbody>
+  </table>
+  {{else}}
+  <p>Empty directory</p>
+  {{end}}
+</section>
+{{end}}

pkg/web/util.go 🔗

@@ -9,6 +9,6 @@ import (
 func renderStatus(code int) http.HandlerFunc {
 	return func(w http.ResponseWriter, _ *http.Request) {
 		w.WriteHeader(code)
-		io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) // nolint: errcheck
+		io.WriteString(w, fmt.Sprintf("%d %s", code, http.StatusText(code))) //nolint: errcheck
 	}
 }

pkg/web/webui.go 🔗

@@ -0,0 +1,270 @@
+// Package web implements the HTTP server and web UI for Soft Serve.
+package web
+
+//go:generate go run gen_syntax_css.go
+
+import (
+	"context"
+	"embed"
+	"fmt"
+	"html/template"
+	"mime"
+	"net/http"
+	"path/filepath"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/gorilla/mux"
+)
+
+//go:embed templates/*.html
+var templatesFS embed.FS
+
+//go:embed static/*
+var staticFS embed.FS
+
+const (
+	// defaultCommitsPerPage is the number of commits shown per page in commit history view.
+	defaultCommitsPerPage = 50
+	// defaultReposPerPage is the number of repositories shown per page on the home page.
+	defaultReposPerPage = 20
+	// defaultTagsPerPage is the number of tags shown per page on the tags page.
+	defaultTagsPerPage = 20
+	// defaultBranchesPerPage is the number of branches shown per page on the branches page.
+	defaultBranchesPerPage = 20
+)
+
+// templateFuncs defines template helper functions available in HTML templates.
+// Functions include: splitPath (split path into components), joinPath (join path components),
+// parentPath (get parent directory), shortHash (truncate commit hash), formatDate (format timestamp),
+// and humanizeSize (format file size in human-readable format).
+var templateFuncs = template.FuncMap{
+	"splitPath": func(path string) []string {
+		if path == "." {
+			return []string{}
+		}
+		return strings.Split(path, "/")
+	},
+	"joinPath": func(index int, data interface{}) string {
+		var path string
+
+		switch v := data.(type) {
+		case BlobData:
+			path = v.Path
+		case TreeData:
+			path = v.Path
+		default:
+			return ""
+		}
+
+		parts := strings.Split(path, "/")
+		if index >= len(parts) {
+			return path
+		}
+		return strings.Join(parts[:index+1], "/")
+	},
+	"parentPath": func(path string) string {
+		if path == "." || !strings.Contains(path, "/") {
+			return "."
+		}
+		return filepath.Dir(path)
+	},
+	"shortHash": func(hash interface{}) string {
+		var hashStr string
+		switch v := hash.(type) {
+		case string:
+			hashStr = v
+		case fmt.Stringer:
+			hashStr = v.String()
+		default:
+			hashStr = fmt.Sprintf("%v", hash)
+		}
+		if len(hashStr) > 8 {
+			return hashStr[:8]
+		}
+		return hashStr
+	},
+	"formatDate": func(t interface{}) string {
+		if time, ok := t.(fmt.Stringer); ok {
+			return time.String()
+		}
+		return fmt.Sprintf("%v", t)
+	},
+	"humanizeSize": func(size int64) string {
+		const unit = 1024
+		if size < unit {
+			return fmt.Sprintf("%d B", size)
+		}
+		div, exp := int64(unit), 0
+		for n := size / unit; n >= unit; n /= unit {
+			div *= unit
+			exp++
+		}
+		return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
+	},
+	"inc": func(i int) int {
+		return i + 1
+	},
+	"dec": func(i int) int {
+		return i - 1
+	},
+	"commitSubject": func(message string) string {
+		lines := strings.Split(message, "\n")
+		if len(lines) > 0 {
+			return lines[0]
+		}
+		return message
+	},
+	"commitBody": func(message string) string {
+		lines := strings.Split(message, "\n")
+		if len(lines) <= 1 {
+			return ""
+		}
+		// Skip the subject line and join the rest
+		body := strings.Join(lines[1:], "\n")
+		return strings.TrimSpace(body)
+	},
+	"rfc3339": func(t interface{}) string {
+		switch v := t.(type) {
+		case time.Time:
+			return v.Format(time.RFC3339)
+		default:
+			return ""
+		}
+	},
+}
+
+// renderHTML renders an HTML template with the given data.
+func renderHTML(w http.ResponseWriter, templateName string, data interface{}) {
+	tmpl, err := template.New("").Funcs(templateFuncs).ParseFS(templatesFS, "templates/base.html", "templates/"+templateName)
+	if err != nil {
+		log.Debug("failed to parse template", "template", templateName, "err", err)
+		renderInternalServerError(w, nil)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
+		log.Debug("template execution failed", "template", templateName, "err", err)
+		// Already started writing response, so we can't render an error page
+	}
+}
+
+// staticFiles handles static file serving.
+func staticFiles(w http.ResponseWriter, r *http.Request) {
+	// Strip /static/ prefix
+	path := strings.TrimPrefix(r.URL.Path, "/static/")
+
+	data, err := staticFS.ReadFile("static/" + path)
+	if err != nil {
+		renderNotFound(w, r)
+		return
+	}
+
+	// Set cache headers
+	w.Header().Set("Cache-Control", "public, max-age=31536000")
+
+	// Detect content type from extension
+	contentType := mime.TypeByExtension(filepath.Ext(path))
+	if contentType != "" {
+		w.Header().Set("Content-Type", contentType)
+	}
+
+	w.Write(data)
+}
+
+// withWebUIAccess wraps withAccess and hides 401/403 as 404 for Web UI routes.
+func withWebUIAccess(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		ctx := r.Context()
+		logger := log.FromContext(ctx)
+		logger.Debug("withWebUIAccess called", "path", r.URL.Path)
+
+		// Wrap the writer to suppress 401/403
+		hrw := &hideAuthWriter{ResponseWriter: w}
+		// Run access first so repo/user are injected into context
+		withAccess(next).ServeHTTP(hrw, r)
+
+		if hrw.suppressed {
+			logger.Debug("suppressed 401/403, rendering 404")
+			// Remove auth challenge headers so we don't leak private info
+			w.Header().Del("WWW-Authenticate")
+			w.Header().Del("LFS-Authenticate")
+			// Now render 404 once; no double WriteHeader occurs
+			renderNotFound(w, r)
+		}
+	})
+}
+
+// hideAuthWriter suppresses 401/403 responses to convert them to 404.
+type hideAuthWriter struct {
+	http.ResponseWriter
+	suppressed bool
+}
+
+func (w *hideAuthWriter) WriteHeader(code int) {
+	if code == http.StatusUnauthorized || code == http.StatusForbidden {
+		// Suppress original status/body; we'll render 404 afterwards
+		w.suppressed = true
+		return
+	}
+	w.ResponseWriter.WriteHeader(code)
+}
+
+func (w *hideAuthWriter) Write(p []byte) (int, error) {
+	if w.suppressed {
+		// Drop body of 401/403
+		return len(p), nil
+	}
+	return w.ResponseWriter.Write(p)
+}
+
+// WebUIController registers HTTP routes for the web-based repository browser.
+// It provides HTML views for repository overview, file browsing, commits, and references.
+func WebUIController(ctx context.Context, r *mux.Router) {
+	basePrefix := "/{repo:.*}"
+
+	// Static files (most specific, should be first)
+	r.PathPrefix("/static/").HandlerFunc(staticFiles).Methods(http.MethodGet)
+
+	// Home page (root path, before other routes)
+	r.HandleFunc("/", home).Methods(http.MethodGet)
+
+	// About page
+	r.HandleFunc("/about", about).Methods(http.MethodGet)
+
+	// More specific routes must be registered before catch-all patterns
+	// Middleware order: withRepoVars (set vars) -> withWebUIAccess (auth + load context) -> handler
+	// Tree routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
+	r.Handle(basePrefix+"/tree/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
+		Methods(http.MethodGet)
+	r.Handle(basePrefix+"/tree", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTree)))).
+		Methods(http.MethodGet)
+
+	// Blob routes - use catch-all pattern and parse ref/path in handler to support refs with slashes
+	r.Handle(basePrefix+"/blob/{refAndPath:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBlob)))).
+		Methods(http.MethodGet)
+
+	// Commits routes - use catch-all pattern to support refs with slashes
+	r.Handle(basePrefix+"/commits/{ref:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
+		Methods(http.MethodGet)
+	r.Handle(basePrefix+"/commits", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommits)))).
+		Methods(http.MethodGet)
+
+	// Commit route
+	r.Handle(basePrefix+"/commit/{hash:[0-9a-f]+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoCommit)))).
+		Methods(http.MethodGet)
+
+	// Branches route
+	r.Handle(basePrefix+"/branches", withRepoVars(withWebUIAccess(http.HandlerFunc(repoBranches)))).
+		Methods(http.MethodGet)
+
+	// Tags route
+	r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))).
+		Methods(http.MethodGet)
+
+	// Repository overview (catch-all, must be last)
+	r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))).
+		Methods(http.MethodGet)
+}

pkg/web/webui_about.go 🔗

@@ -0,0 +1,44 @@
+package web
+
+import (
+	"html/template"
+	"net/http"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/pkg/backend"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+)
+
+type AboutData struct {
+	ReadmeHTML template.HTML
+	ActiveTab  string
+	ServerName string
+}
+
+func about(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	be := backend.FromContext(ctx)
+
+	if be == nil {
+		logger.Debug("backend not found in context")
+		renderInternalServerError(w, r)
+		return
+	}
+
+	readmeHTML, err := getServerReadme(ctx, be)
+	if err != nil {
+		logger.Debug("failed to get server README", "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	data := AboutData{
+		ReadmeHTML: readmeHTML,
+		ActiveTab:  "about",
+		ServerName: cfg.Name,
+	}
+
+	renderHTML(w, "about.html", data)
+}

pkg/web/webui_blob.go 🔗

@@ -0,0 +1,228 @@
+package web
+
+import (
+	"bytes"
+	"html/template"
+	"net/http"
+	"path/filepath"
+	"strings"
+
+	"github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/alecthomas/chroma/v2/lexers"
+	"github.com/alecthomas/chroma/v2/styles"
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/gorilla/mux"
+)
+
+// BlobData contains data for rendering file content view.
+type BlobData struct {
+	Repo          proto.Repository
+	DefaultBranch string
+	Ref           string
+	Path          string
+	Content       string
+	RenderedHTML  template.HTML
+	IsBinary      bool
+	IsMarkdown    bool
+	ShowSource    bool
+	ActiveTab     string
+	ServerName    string
+}
+
+// repoBlob handles file content view.
+func repoBlob(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+
+	if r.URL.Query().Get("raw") == "1" {
+		repoBlobRaw(w, r)
+		return
+	}
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	vars := mux.Vars(r)
+	refAndPath := vars["refAndPath"]
+	ref, path := parseRefAndPath(gr, refAndPath)
+
+	refObj, err := resolveAndBuildRef(gr, ref)
+	if err != nil {
+		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	tree, err := gr.Tree(refObj)
+	if err != nil {
+		logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	entry, err := tree.TreeEntry(path)
+	if err != nil {
+		logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	if entry.IsTree() {
+		renderNotFound(w, r)
+		return
+	}
+
+	content, err := entry.Contents()
+	if err != nil {
+		logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	defaultBranch := getDefaultBranch(gr)
+
+	isMarkdown := isMarkdownFile(path)
+	showSource := r.URL.Query().Get("source") == "1"
+	var renderedHTML template.HTML
+
+	if isMarkdown && !isBinaryContent(content) && !showSource {
+		renderedHTML, _ = renderMarkdown(content)
+	} else if !isBinaryContent(content) {
+		renderedHTML = highlightCode(path, content)
+	}
+
+	data := BlobData{
+		Repo:          repo,
+		DefaultBranch: defaultBranch,
+		Ref:           ref,
+		Path:          path,
+		Content:       string(content),
+		RenderedHTML:  renderedHTML,
+		IsBinary:      isBinaryContent(content),
+		IsMarkdown:    isMarkdown,
+		ShowSource:    showSource,
+		ActiveTab:     "tree",
+		ServerName:    cfg.Name,
+	}
+
+	renderHTML(w, "blob.html", data)
+}
+
+// repoBlobRaw handles raw file download.
+func repoBlobRaw(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	vars := mux.Vars(r)
+	refAndPath := vars["refAndPath"]
+	ref, path := parseRefAndPath(gr, refAndPath)
+
+	refObj, err := resolveAndBuildRef(gr, ref)
+	if err != nil {
+		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	tree, err := gr.Tree(refObj)
+	if err != nil {
+		logger.Debug("failed to get tree for ref", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	entry, err := tree.TreeEntry(path)
+	if err != nil {
+		logger.Debug("failed to get tree entry", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	if entry.IsTree() {
+		renderNotFound(w, r)
+		return
+	}
+
+	content, err := entry.Contents()
+	if err != nil {
+		logger.Debug("failed to get file contents", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Write(content)
+}
+
+// isBinaryContent detects if file content is binary using a simple heuristic.
+// Returns true if content contains null bytes in the first 8KB.
+func isBinaryContent(content []byte) bool {
+	// Check first 8KB for null bytes (Git's detection method)
+	size := len(content)
+	if size > 8000 {
+		size = 8000
+	}
+	for i := 0; i < size; i++ {
+		if content[i] == 0 {
+			return true
+		}
+	}
+	return false
+}
+
+// isMarkdownFile checks if a file has a markdown extension.
+func isMarkdownFile(path string) bool {
+	ext := strings.ToLower(filepath.Ext(path))
+	return ext == ".md" || ext == ".markdown"
+}
+
+// highlightCode applies syntax highlighting to code and returns HTML.
+func highlightCode(path string, content []byte) template.HTML {
+	lexer := lexers.Match(path)
+	if lexer == nil {
+		lexer = lexers.Analyse(string(content))
+	}
+	if lexer == nil {
+		lexer = lexers.Fallback
+	}
+
+	style := styles.Get("github")
+	if style == nil {
+		style = styles.Fallback
+	}
+
+	formatter := html.New(
+		html.WithClasses(true),
+		html.WithLineNumbers(true),
+		html.WithLinkableLineNumbers(true, ""),
+	)
+
+	iterator, err := lexer.Tokenise(nil, string(content))
+	if err != nil {
+		return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
+	}
+
+	var buf bytes.Buffer
+	err = formatter.Format(&buf, style, iterator)
+	if err != nil {
+		return template.HTML("<pre><code>" + template.HTMLEscapeString(string(content)) + "</code></pre>")
+	}
+
+	return template.HTML(buf.String())
+}

pkg/web/webui_branches.go 🔗

@@ -0,0 +1,111 @@
+package web
+
+import (
+	"math"
+	"net/http"
+	"strconv"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+)
+
+// BranchInfo contains branch reference and its latest commit.
+type BranchInfo struct {
+	Ref    *git.Reference
+	Commit *git.Commit
+}
+
+// BranchesData contains data for rendering branches listing.
+type BranchesData struct {
+	Repo          proto.Repository
+	DefaultBranch string
+	Branches      []BranchInfo
+	ActiveTab     string
+	Page          int
+	TotalPages    int
+	TotalBranches int
+	HasPrevPage   bool
+	HasNextPage   bool
+	ServerName    string
+}
+
+// repoBranches handles branches listing page.
+func repoBranches(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	defaultBranch := getDefaultBranch(gr)
+
+	page := 1
+	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
+		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+			page = p
+		}
+	}
+
+	// First fetch to get total count and calculate pages
+	paginatedRefItems, totalBranches, err := FetchRefsPaginated(gr, RefTypeBranch, 0, 1, defaultBranch)
+	if err != nil {
+		logger.Debug("failed to fetch branches", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	totalPages := int(math.Ceil(float64(totalBranches) / float64(defaultBranchesPerPage)))
+	if totalPages < 1 {
+		totalPages = 1
+	}
+
+	// Clamp page before computing offset
+	if page > totalPages {
+		page = totalPages
+	}
+	if page < 1 {
+		page = 1
+	}
+
+	// Calculate offset for pagination
+	offset := (page - 1) * defaultBranchesPerPage
+
+	// Fetch only the branches we need for this page, pre-sorted
+	paginatedRefItems, totalBranches, err = FetchRefsPaginated(gr, RefTypeBranch, offset, defaultBranchesPerPage, defaultBranch)
+	if err != nil {
+		logger.Debug("failed to fetch branches", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	var paginatedBranches []BranchInfo
+	for _, refItem := range paginatedRefItems {
+		paginatedBranches = append(paginatedBranches, BranchInfo{
+			Ref:    refItem.Reference,
+			Commit: refItem.Commit,
+		})
+	}
+
+	data := BranchesData{
+		Repo:          repo,
+		DefaultBranch: defaultBranch,
+		Branches:      paginatedBranches,
+		ActiveTab:     "branches",
+		Page:          page,
+		TotalPages:    totalPages,
+		TotalBranches: totalBranches,
+		HasPrevPage:   page > 1,
+		HasNextPage:   page < totalPages,
+		ServerName:    cfg.Name,
+	}
+
+	renderHTML(w, "branches.html", data)
+}

pkg/web/webui_commit.go 🔗

@@ -0,0 +1,74 @@
+package web
+
+import (
+	"net/http"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/gorilla/mux"
+)
+
+// CommitData contains data for rendering individual commit view.
+type CommitData struct {
+	Repo          proto.Repository
+	DefaultBranch string
+	Commit        *git.Commit
+	Diff          *git.Diff
+	ParentIDs     []string
+	ActiveTab     string
+	ServerName    string
+}
+
+func repoCommit(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+	vars := mux.Vars(r)
+	hash := vars["hash"]
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	commit, err := gr.CatFileCommit(hash)
+	if err != nil {
+		logger.Debug("failed to get commit", "repo", repo.Name(), "hash", hash, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	diff, err := gr.Diff(commit)
+	if err != nil {
+		logger.Debug("failed to get diff", "repo", repo.Name(), "hash", hash, "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	defaultBranch := getDefaultBranch(gr)
+
+	parentIDs := make([]string, commit.ParentsCount())
+	for i := 0; i < commit.ParentsCount(); i++ {
+		parentID, err := commit.ParentID(i)
+		if err == nil && parentID != nil {
+			parentIDs[i] = parentID.String()
+		}
+	}
+
+	data := CommitData{
+		Repo:          repo,
+		DefaultBranch: defaultBranch,
+		Commit:        commit,
+		Diff:          diff,
+		ParentIDs:     parentIDs,
+		ActiveTab:     "commits",
+		ServerName:    cfg.Name,
+	}
+
+	renderHTML(w, "commit.html", data)
+}

pkg/web/webui_commits.go 🔗

@@ -0,0 +1,105 @@
+package web
+
+import (
+	"math"
+	"net/http"
+	"strconv"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/gorilla/mux"
+)
+
+// CommitsData contains data for rendering commit history.
+type CommitsData struct {
+	Repo          proto.Repository
+	DefaultBranch string
+	Ref           string
+	Commits       git.Commits
+	ActiveTab     string
+	Page          int
+	TotalPages    int
+	HasPrevPage   bool
+	HasNextPage   bool
+	ServerName    string
+}
+
+// repoCommits handles commit history view.
+func repoCommits(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		renderNotFound(w, r)
+		return
+	}
+	vars := mux.Vars(r)
+	ref := vars["ref"]
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	refObj, err := resolveAndBuildRef(gr, ref)
+	if err != nil {
+		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	page := 1
+	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
+		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+			page = p
+		}
+	}
+
+	totalCommits, err := gr.CountCommits(refObj)
+	if err != nil {
+		logger.Debug("failed to count commits", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	totalPages := int(math.Ceil(float64(totalCommits) / float64(defaultCommitsPerPage)))
+	if totalPages < 1 {
+		totalPages = 1
+	}
+
+	if page > totalPages {
+		page = totalPages
+	}
+	if page < 1 {
+		page = 1
+	}
+
+	commits, err := gr.CommitsByPage(refObj, page, defaultCommitsPerPage)
+	if err != nil {
+		logger.Debug("failed to get commits", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	defaultBranch := getDefaultBranch(gr)
+
+	data := CommitsData{
+		Repo:          repo,
+		DefaultBranch: defaultBranch,
+		Ref:           ref,
+		Commits:       commits,
+		ActiveTab:     "commits",
+		Page:          page,
+		TotalPages:    totalPages,
+		HasPrevPage:   page > 1,
+		HasNextPage:   page < totalPages,
+		ServerName:    cfg.Name,
+	}
+
+	renderHTML(w, "commits.html", data)
+}

pkg/web/webui_git.go 🔗

@@ -0,0 +1,280 @@
+package web
+
+import (
+	"fmt"
+	"strings"
+
+	gitmodule "github.com/aymanbagabas/git-module"
+	"github.com/charmbracelet/soft-serve/git"
+)
+
+// RefType specifies the type of git reference to fetch.
+type RefType string
+
+const (
+	RefTypeBranch RefType = "branch"
+	RefTypeTag    RefType = "tag"
+)
+
+// RefItem represents a git reference with its associated metadata.
+type RefItem struct {
+	Reference *git.Reference
+	Tag       *git.Tag
+	Commit    *git.Commit
+}
+
+// resolveRefOrHash resolves a ref name or commit hash to a commit hash.
+// Returns the hash and whether it was resolved as a ref (true) or commit hash (false).
+func resolveRefOrHash(gr *git.Repository, refOrHash string) (hash string, isRef bool, err error) {
+	if refOrHash == "" {
+		return "", false, fmt.Errorf("empty ref or hash")
+	}
+
+	normalizedRef := refOrHash
+	if !strings.HasPrefix(refOrHash, "refs/") {
+		if gr.HasTag(refOrHash) {
+			normalizedRef = "refs/tags/" + refOrHash
+		} else {
+			normalizedRef = "refs/heads/" + refOrHash
+		}
+	}
+
+	hash, err = gr.ShowRefVerify(normalizedRef)
+	if err == nil {
+		return hash, true, nil
+	}
+
+	if _, err := gr.CatFileCommit(refOrHash); err == nil {
+		return refOrHash, false, nil
+	}
+
+	return "", false, fmt.Errorf("failed to resolve %s as ref or commit", refOrHash)
+}
+
+// parseRefAndPath splits a combined ref+path string into separate ref and path components.
+// It tries progressively longer prefixes as the ref name, checking if each is a valid ref or commit.
+// This allows branch names with forward slashes (e.g., "feature/branch-name") to work correctly.
+// Returns the ref (short name) and path. If no valid ref is found, returns the whole string as ref.
+func parseRefAndPath(gr *git.Repository, refAndPath string) (ref string, path string) {
+	if refAndPath == "" {
+		return "", "."
+	}
+
+	parts := strings.Split(refAndPath, "/")
+
+	for i := len(parts); i > 0; i-- {
+		potentialRef := strings.Join(parts[:i], "/")
+		potentialPath := "."
+		if i < len(parts) {
+			potentialPath = strings.Join(parts[i:], "/")
+		}
+
+		if _, _, err := resolveRefOrHash(gr, potentialRef); err == nil {
+			return potentialRef, potentialPath
+		}
+	}
+
+	return refAndPath, "."
+}
+
+// resolveAndBuildRef resolves a ref or hash and builds a git.Reference object.
+func resolveAndBuildRef(gr *git.Repository, refOrHash string) (*git.Reference, error) {
+	hash, isRef, err := resolveRefOrHash(gr, refOrHash)
+	if err != nil {
+		return nil, err
+	}
+
+	refSpec := refOrHash
+	if isRef {
+		if !strings.HasPrefix(refOrHash, "refs/") {
+			if gr.HasTag(refOrHash) {
+				refSpec = "refs/tags/" + refOrHash
+			} else {
+				refSpec = "refs/heads/" + refOrHash
+			}
+		}
+	}
+
+	return &git.Reference{
+		Reference: &gitmodule.Reference{
+			ID:      hash,
+			Refspec: refSpec,
+		},
+	}, nil
+}
+
+// FetchRefsPaginated efficiently fetches a paginated subset of refs sorted by date.
+// It uses git for-each-ref to get pre-sorted refs without loading all objects upfront.
+// refType specifies whether to fetch branches or tags.
+// offset and limit control pagination (set limit to -1 to fetch all remaining refs).
+// defaultBranch specifies which branch to pin to the top (empty string to disable pinning).
+// Returns the paginated ref items and the total count of refs.
+func FetchRefsPaginated(gr *git.Repository, refType RefType, offset, limit int, defaultBranch string) ([]RefItem, int, error) {
+	var refPattern, sortField, format string
+	var checkRefFunc func(*git.Reference) bool
+
+	switch refType {
+	case RefTypeBranch:
+		refPattern = "refs/heads"
+		sortField = "-committerdate"
+		format = "%(refname:short)%09%(objectname)%09%(committerdate:unix)"
+		checkRefFunc = (*git.Reference).IsBranch
+	case RefTypeTag:
+		refPattern = "refs/tags"
+		sortField = "-creatordate"
+		format = "%(refname:short)%09%(*objectname)%09%(objectname)%09%(*authordate:unix)%09%(authordate:unix)%09%(contents:subject)"
+		checkRefFunc = (*git.Reference).IsTag
+	default:
+		return nil, 0, fmt.Errorf("unsupported ref type: %s", refType)
+	}
+
+	args := []string{"for-each-ref", "--sort=" + sortField, "--format=" + format, refPattern}
+
+	cmd := git.NewCommand(args...)
+	output, err := cmd.RunInDir(gr.Path)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+	if len(lines) == 1 && lines[0] == "" {
+		return []RefItem{}, 0, nil
+	}
+
+	// Build reference map once
+	refs, err := gr.References()
+	if err != nil {
+		return nil, 0, err
+	}
+
+	refMap := make(map[string]*git.Reference)
+	for _, r := range refs {
+		if checkRefFunc(r) {
+			refMap[r.Name().Short()] = r
+		}
+	}
+
+	// Separate default branch from others if pinning is requested
+	var defaultBranchLine string
+	var otherLines []string
+
+	if refType == RefTypeBranch && defaultBranch != "" {
+		for _, line := range lines {
+			fields := strings.Split(line, "\t")
+			if len(fields) < 1 {
+				continue
+			}
+			refName := fields[0]
+			if refName == defaultBranch {
+				defaultBranchLine = line
+			} else {
+				otherLines = append(otherLines, line)
+			}
+		}
+	} else {
+		otherLines = lines
+	}
+
+	// Total count includes default branch if present
+	totalCount := len(otherLines)
+	hasDefaultBranch := defaultBranchLine != ""
+	if hasDefaultBranch {
+		totalCount++
+	}
+
+	items := make([]RefItem, 0)
+
+	// Add default branch to page 1 (offset 0)
+	if hasDefaultBranch && offset == 0 {
+		fields := strings.Split(defaultBranchLine, "\t")
+		if len(fields) >= 2 {
+			refName := fields[0]
+			commitID := fields[1]
+
+			if ref := refMap[refName]; ref != nil {
+				item := RefItem{Reference: ref}
+				if commitID != "" {
+					item.Commit, _ = gr.CatFileCommit(commitID)
+				}
+				items = append(items, item)
+			}
+		}
+	}
+
+	// Calculate pagination for non-default branches
+	// On page 1, we have one less slot because default branch takes the first position
+	adjustedOffset := offset
+	adjustedLimit := limit
+
+	if hasDefaultBranch {
+		if offset == 0 {
+			// Page 1: we already added default branch, so fetch limit-1 items
+			adjustedLimit = limit - 1
+		} else {
+			// Page 2+: offset needs to account for default branch being removed from the list
+			adjustedOffset = offset - 1
+		}
+	}
+
+	if adjustedLimit <= 0 {
+		return items, totalCount, nil
+	}
+
+	// Apply pagination to non-default branches
+	start := adjustedOffset
+	if start >= len(otherLines) {
+		return items, totalCount, nil
+	}
+
+	end := len(otherLines)
+	if adjustedLimit > 0 {
+		end = start + adjustedLimit
+		if end > len(otherLines) {
+			end = len(otherLines)
+		}
+	}
+
+	// Process only the paginated subset of non-default branches
+	for _, line := range otherLines[start:end] {
+		fields := strings.Split(line, "\t")
+
+		var refName, commitID string
+
+		if refType == RefTypeTag {
+			if len(fields) < 6 {
+				continue
+			}
+			refName = fields[0]
+			peeledCommitID := fields[1]
+			commitID = fields[2]
+			if peeledCommitID != "" {
+				commitID = peeledCommitID
+			}
+		} else {
+			if len(fields) < 2 {
+				continue
+			}
+			refName = fields[0]
+			commitID = fields[1]
+		}
+
+		ref := refMap[refName]
+		if ref == nil {
+			continue
+		}
+
+		item := RefItem{Reference: ref}
+
+		if refType == RefTypeTag {
+			item.Tag, _ = gr.Tag(refName)
+		}
+
+		if commitID != "" {
+			item.Commit, _ = gr.CatFileCommit(commitID)
+		}
+
+		items = append(items, item)
+	}
+
+	return items, totalCount, nil
+}

pkg/web/webui_helpers.go 🔗

@@ -0,0 +1,78 @@
+package web
+
+import (
+	"bytes"
+	"context"
+	"html/template"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/backend"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/microcosm-cc/bluemonday"
+	"github.com/yuin/goldmark"
+	extension "github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	goldmarkhtml "github.com/yuin/goldmark/renderer/html"
+)
+
+// renderMarkdown converts markdown content to sanitized HTML.
+func renderMarkdown(content []byte) (template.HTML, error) {
+	var buf bytes.Buffer
+	md := goldmark.New(
+		goldmark.WithExtensions(extension.GFM),
+		goldmark.WithParserOptions(
+			parser.WithAutoHeadingID(),
+		),
+		goldmark.WithRendererOptions(
+			goldmarkhtml.WithUnsafe(),
+		),
+	)
+
+	if err := md.Convert(content, &buf); err != nil {
+		return "", err
+	}
+
+	policy := bluemonday.UGCPolicy()
+	policy.AllowStyling()
+	policy.RequireNoFollowOnLinks(false)
+	policy.AllowElements("center")
+	sanitized := policy.SanitizeBytes(buf.Bytes())
+
+	return template.HTML(sanitized), nil
+}
+
+// getServerReadme loads and renders the README from the .soft-serve repository.
+func getServerReadme(ctx context.Context, be *backend.Backend) (template.HTML, error) {
+	repos, err := be.Repositories(ctx)
+	if err != nil {
+		return "", err
+	}
+
+	for _, r := range repos {
+		if r.Name() == ".soft-serve" {
+			readme, _, err := backend.Readme(r, nil)
+			if err != nil {
+				return "", err
+			}
+			if readme != "" {
+				return renderMarkdown([]byte(readme))
+			}
+		}
+	}
+
+	return "", nil
+}
+
+// openRepository opens a git repository.
+func openRepository(repo proto.Repository) (*git.Repository, error) {
+	return repo.Open()
+}
+
+// getDefaultBranch returns the default branch name, or empty string if none exists.
+func getDefaultBranch(gr *git.Repository) string {
+	head, err := gr.HEAD()
+	if err != nil || head == nil {
+		return ""
+	}
+	return head.Name().Short()
+}

pkg/web/webui_home.go 🔗

@@ -0,0 +1,169 @@
+package web
+
+import (
+	"html/template"
+	"math"
+	"net/http"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/pkg/access"
+	"github.com/charmbracelet/soft-serve/pkg/backend"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/charmbracelet/soft-serve/pkg/ui/common"
+	"github.com/dustin/go-humanize"
+)
+
+type HomeRepository struct {
+	Name        string
+	ProjectName string
+	Description string
+	IsPrivate   bool
+	CloneURL    string
+	UpdatedAt   string
+}
+
+type HomeData struct {
+	Repo         proto.Repository
+	Repositories []HomeRepository
+	ReadmeHTML   template.HTML
+	ActiveTab    string
+	ServerName   string
+	Page         int
+	TotalPages   int
+	HasPrevPage  bool
+	HasNextPage  bool
+}
+
+type repoItem struct {
+	repo       proto.Repository
+	lastUpdate *time.Time
+}
+
+func home(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	be := backend.FromContext(ctx)
+
+	if be == nil {
+		logger.Debug("backend not found in context")
+		renderInternalServerError(w, r)
+		return
+	}
+
+	repos, err := be.Repositories(ctx)
+	if err != nil {
+		logger.Debug("failed to get repositories", "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	var readmeHTML template.HTML
+	homeRepos := make([]HomeRepository, 0)
+	items := make([]repoItem, 0)
+
+	readmeHTML, err = getServerReadme(ctx, be)
+	if err != nil {
+		logger.Debug("failed to get server README", "err", err)
+	}
+
+	for _, r := range repos {
+		if r.IsHidden() {
+			continue
+		}
+
+		al := be.AccessLevelByPublicKey(ctx, r.Name(), nil)
+		if al >= access.ReadOnlyAccess {
+			var lastUpdate *time.Time
+			lu := r.UpdatedAt()
+			if !lu.IsZero() {
+				lastUpdate = &lu
+			}
+			items = append(items, repoItem{
+				repo:       r,
+				lastUpdate: lastUpdate,
+			})
+		}
+	}
+
+	sort.Slice(items, func(i, j int) bool {
+		if items[i].lastUpdate == nil && items[j].lastUpdate != nil {
+			return false
+		}
+		if items[i].lastUpdate != nil && items[j].lastUpdate == nil {
+			return true
+		}
+		if items[i].lastUpdate == nil && items[j].lastUpdate == nil {
+			return items[i].repo.Name() < items[j].repo.Name()
+		}
+		return items[i].lastUpdate.After(*items[j].lastUpdate)
+	})
+
+	page := 1
+	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
+		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+			page = p
+		}
+	}
+
+	totalRepos := len(items)
+	totalPages := int(math.Ceil(float64(totalRepos) / float64(defaultReposPerPage)))
+	if totalPages < 1 {
+		totalPages = 1
+	}
+
+	if page > totalPages {
+		page = totalPages
+	}
+	if page < 1 {
+		page = 1
+	}
+
+	startIdx := (page - 1) * defaultReposPerPage
+	endIdx := startIdx + defaultReposPerPage
+	if endIdx > totalRepos {
+		endIdx = totalRepos
+	}
+
+	paginatedItems := items[startIdx:endIdx]
+
+	for _, item := range paginatedItems {
+		repo := item.repo
+		name := repo.Name()
+		projectName := repo.ProjectName()
+		description := strings.TrimSpace(repo.Description())
+		cloneURL := common.RepoURL(cfg.SSH.PublicURL, name)
+
+		var updatedAt string
+		if item.lastUpdate != nil {
+			updatedAt = humanize.Time(*item.lastUpdate)
+		}
+
+		homeRepos = append(homeRepos, HomeRepository{
+			Name:        name,
+			ProjectName: projectName,
+			Description: description,
+			IsPrivate:   repo.IsPrivate(),
+			CloneURL:    cloneURL,
+			UpdatedAt:   updatedAt,
+		})
+	}
+
+	data := HomeData{
+		Repositories: homeRepos,
+		ReadmeHTML:   readmeHTML,
+		ActiveTab:    "repositories",
+		ServerName:   cfg.Name,
+		Page:         page,
+		TotalPages:   totalPages,
+		HasPrevPage:  page > 1,
+		HasNextPage:  page < totalPages,
+	}
+
+	renderHTML(w, "home.html", data)
+}

pkg/web/webui_overview.go 🔗

@@ -0,0 +1,68 @@
+package web
+
+import (
+	"fmt"
+	"html/template"
+	"net/http"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/pkg/backend"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/charmbracelet/soft-serve/pkg/ui/common"
+)
+
+// OverviewData contains data for rendering repository overview page.
+type OverviewData struct {
+	Repo          proto.Repository
+	DefaultBranch string
+	IsEmpty       bool
+	SSHURL        string
+	HTTPURL       string
+	ActiveTab     string
+	ReadmeHTML    template.HTML
+	ServerName    string
+}
+
+// repoOverview handles repository overview page.
+func repoOverview(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+	cfg := config.FromContext(ctx)
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	defaultBranch := getDefaultBranch(gr)
+	isEmpty := defaultBranch == ""
+
+	sshURL := common.RepoURL(cfg.SSH.PublicURL, repo.Name())
+	httpURL := fmt.Sprintf("%s/%s.git", cfg.HTTP.PublicURL, repo.Name())
+
+	var readmeHTML template.HTML
+	if !isEmpty {
+		head, _ := gr.HEAD()
+		readmeContent, _, err := backend.Readme(repo, head)
+		if err == nil && readmeContent != "" {
+			readmeHTML, _ = renderMarkdown([]byte(readmeContent))
+		}
+	}
+
+	data := OverviewData{
+		Repo:          repo,
+		DefaultBranch: defaultBranch,
+		IsEmpty:       isEmpty,
+		SSHURL:        sshURL,
+		HTTPURL:       httpURL,
+		ActiveTab:     "overview",
+		ReadmeHTML:    readmeHTML,
+		ServerName:    cfg.Name,
+	}
+
+	renderHTML(w, "overview.html", data)
+}

pkg/web/webui_tags.go 🔗

@@ -0,0 +1,131 @@
+package web
+
+import (
+	"math"
+	"net/http"
+	"strconv"
+	"time"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+)
+
+// TagInfo contains tag reference and its annotation message.
+type TagInfo struct {
+	Ref        *git.Reference
+	TagMessage string
+	Commit     *git.Commit
+	TagDate    time.Time
+}
+
+// TagsData contains data for rendering tags listing.
+type TagsData struct {
+	Repo          proto.Repository
+	DefaultBranch string
+	Tags          []TagInfo
+	ActiveTab     string
+	Page          int
+	TotalPages    int
+	TotalTags     int
+	HasPrevPage   bool
+	HasNextPage   bool
+	ServerName    string
+}
+
+// repoTags handles tags listing page.
+func repoTags(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	defaultBranch := getDefaultBranch(gr)
+
+	page := 1
+	if pageStr := r.URL.Query().Get("page"); pageStr != "" {
+		if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
+			page = p
+		}
+	}
+
+	// First fetch to get total count and calculate pages
+	paginatedRefItems, totalTags, err := FetchRefsPaginated(gr, RefTypeTag, 0, 1, "")
+	if err != nil {
+		logger.Debug("failed to fetch tags", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	totalPages := int(math.Ceil(float64(totalTags) / float64(defaultTagsPerPage)))
+	if totalPages < 1 {
+		totalPages = 1
+	}
+
+	// Clamp page before computing offset
+	if page > totalPages {
+		page = totalPages
+	}
+	if page < 1 {
+		page = 1
+	}
+
+	// Calculate offset for pagination
+	offset := (page - 1) * defaultTagsPerPage
+
+	// Fetch only the tags we need for this page, pre-sorted
+	paginatedRefItems, totalTags, err = FetchRefsPaginated(gr, RefTypeTag, offset, defaultTagsPerPage, "")
+	if err != nil {
+		logger.Debug("failed to fetch tags", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	var paginatedTags []TagInfo
+	for _, refItem := range paginatedRefItems {
+		tagMessage := ""
+		var tagDate time.Time
+
+		if refItem.Tag != nil {
+			tagMessage = refItem.Tag.Message()
+			if tagger := refItem.Tag.Tagger(); tagger != nil {
+				tagDate = tagger.When
+			}
+		}
+
+		// Fallback to commit date if tag date not available
+		if tagDate.IsZero() && refItem.Commit != nil {
+			tagDate = refItem.Commit.Author.When
+		}
+
+		paginatedTags = append(paginatedTags, TagInfo{
+			Ref:        refItem.Reference,
+			TagMessage: tagMessage,
+			Commit:     refItem.Commit,
+			TagDate:    tagDate,
+		})
+	}
+
+	data := TagsData{
+		Repo:          repo,
+		DefaultBranch: defaultBranch,
+		Tags:          paginatedTags,
+		ActiveTab:     "tags",
+		Page:          page,
+		TotalPages:    totalPages,
+		TotalTags:     totalTags,
+		HasPrevPage:   page > 1,
+		HasNextPage:   page < totalPages,
+		ServerName:    cfg.Name,
+	}
+
+	renderHTML(w, "tags.html", data)
+}

pkg/web/webui_tree.go 🔗

@@ -0,0 +1,88 @@
+package web
+
+import (
+	"net/http"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/gorilla/mux"
+)
+
+// TreeData contains data for rendering directory tree view.
+type TreeData struct {
+	Repo          proto.Repository
+	DefaultBranch string
+	Ref           string
+	Path          string
+	Entries       git.Entries
+	ActiveTab     string
+	ServerName    string
+}
+
+func repoTree(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		renderNotFound(w, r)
+		return
+	}
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	vars := mux.Vars(r)
+	refAndPath := vars["refAndPath"]
+	ref, path := parseRefAndPath(gr, refAndPath)
+
+	if ref == "" {
+		head, err := gr.HEAD()
+		if err == nil && head != nil {
+			ref = head.Name().Short()
+		}
+	}
+
+	refObj, err := resolveAndBuildRef(gr, ref)
+	if err != nil {
+		logger.Debug("failed to resolve ref or commit", "repo", repo.Name(), "ref", ref, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	tree, err := gr.TreePath(refObj, path)
+	if err != nil {
+		logger.Debug("failed to get tree path", "repo", repo.Name(), "ref", ref, "path", path, "err", err)
+		renderNotFound(w, r)
+		return
+	}
+
+	entries, err := tree.Entries()
+	if err != nil {
+		logger.Debug("failed to get tree entries", "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	entries.Sort()
+
+	defaultBranch := getDefaultBranch(gr)
+
+	data := TreeData{
+		Repo:          repo,
+		DefaultBranch: defaultBranch,
+		Ref:           ref,
+		Path:          path,
+		Entries:       entries,
+		ActiveTab:     "tree",
+		ServerName:    cfg.Name,
+	}
+
+	renderHTML(w, "tree.html", data)
+}