A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 26b59e4e18

26b59e4e1845ca161d394403e2ef262040feebab

parent: a5b8bd2151

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24T16:41:17Z

web: repo header hierarchy and active tabs

repoheader becomes owner / repo title, description, and a tab bar; the
active tab is tracked per handler (repoPage.Tab) and underlined. The
clone line moves from the shared header to the files page. Private
repos get a visibility chip; chip component added (open/closed/merged/
neutral).
internal/httpd/web.go +11
@@ -105,6 +105,7 @@ type repoPage struct {
105105 Ref string
106106 CloneURL string
107107 Dir string
108 Tab string // active tab in the repo header
108109 }
109110
110111 // repoFor resolves the repo for a web request; false means 404 was sent.
@@ -221,6 +222,7 @@ func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
221222 if !ok {
222223 return
223224 }
225 p.Tab = "files"
224226 s.renderTree(w, r, p, "")
225227 }
226228
@@ -229,6 +231,7 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
229231 if !ok {
230232 return
231233 }
234 p.Tab = "files"
232235 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
233236 }
234237
@@ -275,6 +278,7 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
275278 if !ok {
276279 return
277280 }
281 p.Tab = "files"
278282 filePath := strings.Trim(r.PathValue("path"), "/")
279283 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
280284 if err != nil {
@@ -565,6 +569,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
565569 if !ok {
566570 return
567571 }
572 p.Tab = "log"
568573 const pageSize = 50
569574 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
570575 if err != nil {
@@ -604,6 +609,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
604609 if !ok {
605610 return
606611 }
612 p.Tab = "log"
607613 sha := r.PathValue("sha")
608614 full, err := gitutil.ResolveRef(p.Dir, sha)
609615 if err != nil {
@@ -641,6 +647,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
641647 if !ok {
642648 return
643649 }
650 p.Tab = "issues"
644651 state := r.URL.Query().Get("state")
645652 if state != "closed" && state != "all" {
646653 state = "open"
@@ -662,6 +669,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
662669 if !ok {
663670 return
664671 }
672 p.Tab = "issues"
665673 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
666674 if err != nil {
667675 http.NotFound(w, r)
@@ -690,6 +698,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
690698 if !ok {
691699 return
692700 }
701 p.Tab = "merge requests"
693702 state := r.URL.Query().Get("state")
694703 if state == "" {
695704 state = "open"
@@ -715,6 +724,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
715724 if !ok {
716725 return
717726 }
727 p.Tab = "merge requests"
718728 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
719729 if err != nil {
720730 http.NotFound(w, r)
@@ -763,6 +773,7 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
763773 if !ok {
764774 return
765775 }
776 p.Tab = "refs"
766777 branches, _ := gitutil.Refs(p.Dir, "heads")
767778 tags, _ := gitutil.Refs(p.Dir, "tags")
768779 s.render(w, "refs.html", struct {
internal/web/static/style.css +27 −3
@@ -122,14 +122,33 @@ td {
122122 td:last-child { padding-right: 0; }
123123 td.size, td.mode { color: var(--muted); white-space: nowrap; }
124124
125/* repo header */
126.repohead { margin-bottom: var(--sp-4); }
127h1.repotitle { margin: var(--sp-4) 0 var(--sp-1); font-weight: 400; }
128h1.repotitle a { color: var(--fg); }
129h1.repotitle a:last-of-type { font-weight: 650; }
130h1.repotitle a:hover { color: var(--accent); text-decoration: none; }
131h1.repotitle .sep { color: var(--muted); margin: 0 0.15em; }
132.repohead .desc { margin: 0 0 var(--sp-2); }
133
125134 /* repo tabs */
126nav.tabs { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
135nav.tabs {
136 display: flex;
137 flex-wrap: wrap;
138 gap: var(--sp-1);
139 border-bottom: 1px solid var(--faint);
140 margin-top: var(--sp-3);
141}
127142 nav.tabs a {
128143 color: var(--muted);
129144 padding: var(--sp-1) var(--sp-2);
130 border-radius: var(--r-md);
145 border-radius: var(--r-md) var(--r-md) 0 0;
146 border-bottom: 2px solid transparent;
147 margin-bottom: -1px;
148 font-size: var(--fs-2);
131149 }
132150 nav.tabs a:hover { color: var(--fg); background: var(--surface); text-decoration: none; }
151nav.tabs a.active { color: var(--fg); border-bottom-color: var(--accent); font-weight: 550; }
133152
134153 p.clone code {
135154 background: var(--surface);
@@ -203,7 +222,7 @@ pre.diff .meta { color: var(--muted); }
203222 /* badges: signature and check states. Semantic colors stay distinct:
204223 green verified/success, amber pending/stale, red bad/failure, muted
205224 unsigned. */
206.badge {
225.badge, .chip {
207226 --chip: var(--neutral);
208227 display: inline-block;
209228 padding: 0.05rem 0.55rem;
@@ -215,7 +234,12 @@ pre.diff .meta { color: var(--muted); }
215234 border: 1px solid color-mix(in srgb, var(--chip) 35%, transparent);
216235 background: color-mix(in srgb, var(--chip) 7%, transparent);
217236 white-space: nowrap;
237 vertical-align: middle;
218238 }
239.chip-open { --chip: var(--ok); }
240.chip-closed { --chip: var(--bad); }
241.chip-merged { --chip: var(--done); }
242.chip-neutral { --chip: var(--neutral); }
219243 .badge-verified { --chip: var(--ok); }
220244 .badge-unsigned { --chip: var(--neutral); }
221245 .badge-signed_unknown_key { --chip: var(--warn); }
internal/web/templates/layout.html +8 −7
@@ -21,17 +21,18 @@
2121 </html>{{end}}
2222
2323 {{define "repoheader"}}
24<h1><a href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a>/<a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a></h1>
24<div class="repohead">
25<h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}</h1>
2526 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
2627 <nav class="tabs">
27 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
28 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
29 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
30 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
31 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
28 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
29 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
30 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
31 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
32 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
3233 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
3334 </nav>
34<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
35</div>
3536 {{end}}
3637
3738 {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{.State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
internal/web/templates/tree.html +1
@@ -2,6 +2,7 @@
22 {{define "content"}}
33 {{template "repoheader" .}}
44 <p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</p>
5<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
56 <table class="tree">
67 {{range .Entries}}<tr>
78 <td class="mode">{{.Mode}}</td>