Commit 26b59e4e18

26b59e4e1845ca161d394403e2ef262040feebab

parent: a5b8bd2151

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 16:41 UTC

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).

Layout: unified · split

internal/httpd/web.go +11
@@ -105,6 +105,7 @@ type repoPage struct {
105 Ref string 105 Ref string
106 CloneURL string 106 CloneURL string
107 Dir string 107 Dir string
108 Tab string // active tab in the repo header
108} 109}
109 110
110// repoFor resolves the repo for a web request; false means 404 was sent. 111// 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) {
221 if !ok { 222 if !ok {
222 return 223 return
223 } 224 }
225 p.Tab = "files"
224 s.renderTree(w, r, p, "") 226 s.renderTree(w, r, p, "")
225} 227}
226 228
@@ -229,6 +231,7 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
229 if !ok { 231 if !ok {
230 return 232 return
231 } 233 }
234 p.Tab = "files"
232 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/")) 235 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
233} 236}
234 237
@@ -275,6 +278,7 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
275 if !ok { 278 if !ok {
276 return 279 return
277 } 280 }
281 p.Tab = "files"
278 filePath := strings.Trim(r.PathValue("path"), "/") 282 filePath := strings.Trim(r.PathValue("path"), "/")
279 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1) 283 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
280 if err != nil { 284 if err != nil {
@@ -565,6 +569,7 @@ func (s *Server) log(w http.ResponseWriter, r *http.Request) {
565 if !ok { 569 if !ok {
566 return 570 return
567 } 571 }
572 p.Tab = "log"
568 const pageSize = 50 573 const pageSize = 50
569 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1) 574 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
570 if err != nil { 575 if err != nil {
@@ -604,6 +609,7 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
604 if !ok { 609 if !ok {
605 return 610 return
606 } 611 }
612 p.Tab = "log"
607 sha := r.PathValue("sha") 613 sha := r.PathValue("sha")
608 full, err := gitutil.ResolveRef(p.Dir, sha) 614 full, err := gitutil.ResolveRef(p.Dir, sha)
609 if err != nil { 615 if err != nil {
@@ -641,6 +647,7 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
641 if !ok { 647 if !ok {
642 return 648 return
643 } 649 }
650 p.Tab = "issues"
644 state := r.URL.Query().Get("state") 651 state := r.URL.Query().Get("state")
645 if state != "closed" && state != "all" { 652 if state != "closed" && state != "all" {
646 state = "open" 653 state = "open"
@@ -662,6 +669,7 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
662 if !ok { 669 if !ok {
663 return 670 return
664 } 671 }
672 p.Tab = "issues"
665 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) 673 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
666 if err != nil { 674 if err != nil {
667 http.NotFound(w, r) 675 http.NotFound(w, r)
@@ -690,6 +698,7 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
690 if !ok { 698 if !ok {
691 return 699 return
692 } 700 }
701 p.Tab = "merge requests"
693 state := r.URL.Query().Get("state") 702 state := r.URL.Query().Get("state")
694 if state == "" { 703 if state == "" {
695 state = "open" 704 state = "open"
@@ -715,6 +724,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
715 if !ok { 724 if !ok {
716 return 725 return
717 } 726 }
727 p.Tab = "merge requests"
718 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64) 728 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
719 if err != nil { 729 if err != nil {
720 http.NotFound(w, r) 730 http.NotFound(w, r)
@@ -763,6 +773,7 @@ func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
763 if !ok { 773 if !ok {
764 return 774 return
765 } 775 }
776 p.Tab = "refs"
766 branches, _ := gitutil.Refs(p.Dir, "heads") 777 branches, _ := gitutil.Refs(p.Dir, "heads")
767 tags, _ := gitutil.Refs(p.Dir, "tags") 778 tags, _ := gitutil.Refs(p.Dir, "tags")
768 s.render(w, "refs.html", struct { 779 s.render(w, "refs.html", struct {
internal/web/static/style.css +27 −3
@@ -122,14 +122,33 @@ td {
122td:last-child { padding-right: 0; } 122td:last-child { padding-right: 0; }
123td.size, td.mode { color: var(--muted); white-space: nowrap; } 123td.size, td.mode { color: var(--muted); white-space: nowrap; }
124 124
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
125/* repo tabs */ 134/* 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}
127nav.tabs a { 142nav.tabs a {
128 color: var(--muted); 143 color: var(--muted);
129 padding: var(--sp-1) var(--sp-2); 144 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);
131} 149}
132nav.tabs a:hover { color: var(--fg); background: var(--surface); text-decoration: none; } 150nav.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; }
133 152
134p.clone code { 153p.clone code {
135 background: var(--surface); 154 background: var(--surface);
@@ -203,7 +222,7 @@ pre.diff .meta { color: var(--muted); }
203/* badges: signature and check states. Semantic colors stay distinct: 222/* badges: signature and check states. Semantic colors stay distinct:
204 green verified/success, amber pending/stale, red bad/failure, muted 223 green verified/success, amber pending/stale, red bad/failure, muted
205 unsigned. */ 224 unsigned. */
206.badge { 225.badge, .chip {
207 --chip: var(--neutral); 226 --chip: var(--neutral);
208 display: inline-block; 227 display: inline-block;
209 padding: 0.05rem 0.55rem; 228 padding: 0.05rem 0.55rem;
@@ -215,7 +234,12 @@ pre.diff .meta { color: var(--muted); }
215 border: 1px solid color-mix(in srgb, var(--chip) 35%, transparent); 234 border: 1px solid color-mix(in srgb, var(--chip) 35%, transparent);
216 background: color-mix(in srgb, var(--chip) 7%, transparent); 235 background: color-mix(in srgb, var(--chip) 7%, transparent);
217 white-space: nowrap; 236 white-space: nowrap;
237 vertical-align: middle;
218} 238}
239.chip-open { --chip: var(--ok); }
240.chip-closed { --chip: var(--bad); }
241.chip-merged { --chip: var(--done); }
242.chip-neutral { --chip: var(--neutral); }
219.badge-verified { --chip: var(--ok); } 243.badge-verified { --chip: var(--ok); }
220.badge-unsigned { --chip: var(--neutral); } 244.badge-unsigned { --chip: var(--neutral); }
221.badge-signed_unknown_key { --chip: var(--warn); } 245.badge-signed_unknown_key { --chip: var(--warn); }
internal/web/templates/layout.html +8 −7
@@ -21,17 +21,18 @@
21</html>{{end}} 21</html>{{end}}
22 22
23{{define "repoheader"}} 23{{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>
25{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} 26{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
26<nav class="tabs"> 27<nav class="tabs">
27 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a> 28 <a {{if eq .Tab "files"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
28 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a> 29 <a {{if eq .Tab "log"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
29 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a> 30 <a {{if eq .Tab "refs"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
30 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a> 31 <a {{if eq .Tab "issues"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues">issues</a>
31 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a> 32 <a {{if eq .Tab "merge requests"}}class="active" {{end}}href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs">merge requests</a>
32 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a> 33 <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
33</nav> 34</nav>
34<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p> 35</div>
35{{end}} 36{{end}}
36 37
37{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{.State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} 38{{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 @@
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{template "repoheader" .}}
4<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</p> 4<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</p>
5<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
5<table class="tree"> 6<table class="tree">
6{{range .Entries}}<tr> 7{{range .Entries}}<tr>
7 <td class="mode">{{.Mode}}</td> 8 <td class="mode">{{.Mode}}</td>