Detailed changes
@@ -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)
+}
@@ -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
}
@@ -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)
@@ -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()
}
@@ -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
@@ -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
@@ -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 }
+}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}} <{{.Commit.Author.Email}}></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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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}}
@@ -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
}
}
@@ -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)
+}
@@ -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)
+}
@@ -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())
+}
@@ -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)
+}
@@ -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)
+}
@@ -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)
+}
@@ -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
+}
@@ -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()
+}
@@ -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)
+}
@@ -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)
+}
@@ -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)
+}
@@ -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)
+}