web: v1.23.0 polish follow-ups !403

merged merged by cmc on 2026-09-18 04:32 UTC · krz/gitbay:stack-221 into main

9 files changed, +48 −27

Layout: unified · split

e2e/design_test.go +25 −1
@@ -149,6 +149,30 @@ func TestReadmeRelativeLinks(t *testing.T) {
149 if _, body := inst.get(t, "/alice/site"); !strings.Contains(body, `class="chip topic"`) { 149 if _, body := inst.get(t, "/alice/site"); !strings.Contains(body, `class="chip topic"`) {
150 t.Error("repo home lost its topics") 150 t.Error("repo home lost its topics")
151 } 151 }
152 // The repohead is the same markup on every tab within a repository —
153 // aria-current on the tab link and the watch/bookmark toggles are the
154 // only things that could vary, and they depend on who is signed in,
155 // not which tab is shown. inst.get is anonymous, so two tabs' headers
156 // must be byte-identical.
157 inst.ssh(t, aliceKey, "", "issue", "create", "alice/site", "--title", "one")
158 header := func(body string) string {
159 i := strings.Index(body, `<header class="repohead">`)
160 j := strings.Index(body, "</header>")
161 if i < 0 || j < 0 || j < i {
162 t.Fatalf("repohead not found:\n%.2000s", body)
163 }
164 return body[i:j]
165 }
166 _, issuesBody := inst.get(t, "/alice/site/issues")
167 _, issueBody := inst.get(t, "/alice/site/issues/1")
168 if a, b := header(issuesBody), header(issueBody); a != b {
169 t.Errorf("issues list and issue page headers differ:\n%s\nvs\n%s", a, b)
170 }
171 _, homeBody := inst.get(t, "/alice/site")
172 _, treeBody := inst.get(t, "/alice/site/tree/main/")
173 if a, b := header(homeBody), header(treeBody); a != b {
174 t.Errorf("repo home and tree headers differ:\n%s\nvs\n%s", a, b)
175 }
152} 176}
153 177
154// TestLandingRoutes checks the landing page's copy and the two routes. 178// TestLandingRoutes checks the landing page's copy and the two routes.
@@ -363,7 +387,7 @@ func TestTreeSearchCodeAndClone(t *testing.T) {
363 if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i { 387 if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i {
364 t.Error("clone block does not follow the file table") 388 t.Error("clone block does not follow the file table")
365 } 389 }
366 if !strings.Contains(body, `<label>SSH</label>`) || !strings.Contains(body, `<label>HTTPS</label>`) { 390 if !strings.Contains(body, `<span class="fieldname">SSH</span>`) || !strings.Contains(body, `<span class="fieldname">HTTPS</span>`) {
367 t.Error("clone blocks are not labelled") 391 t.Error("clone blocks are not labelled")
368 } 392 }
369 // Clone and about belong to the repository root, not a subdirectory. 393 // Clone and about belong to the repository root, not a subdirectory.
internal/httpd/settings.go +5 −3
@@ -61,6 +61,7 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
61 "description": submitted.Get("description"), 61 "description": submitted.Get("description"),
62 "website": submitted.Get("website"), 62 "website": submitted.Get("website"),
63 "topics": submitted.Get("topics"), 63 "topics": submitted.Get("topics"),
64 "key": submitted.Get("key"),
64 } 65 }
65 } 66 }
66 s.render(w, "settings.html", settingsPage{ 67 s.render(w, "settings.html", settingsPage{
@@ -188,10 +189,11 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.
188 return 189 return
189 } 190 }
190 msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n") 191 msg, ok := s.runControlStdin(u, []string{"repo", "runner", "add", repo}, body+"\n")
191 if ok { 192 if !ok {
192 msg = "" 193 s.settingsFormWith(w, r, u, msg, r.Form)
194 return
193 } 195 }
194 s.settingsRedirect(w, r, msg) 196 s.settingsRedirect(w, r, "Saved the runner.")
195 return 197 return
196 case "runner-remove": 198 case "runner-remove":
197 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")} 199 argv = []string{"repo", "runner", "remove", repo, v("fingerprint")}
internal/httpd/web.go +7 −12
@@ -8,7 +8,6 @@ import (
8 "fmt" 8 "fmt"
9 "hash/fnv" 9 "hash/fnv"
10 "io" 10 "io"
11 "io/fs"
12 "log" 11 "log"
13 "math" 12 "math"
14 "os" 13 "os"
@@ -177,21 +176,12 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
177 Host string 176 Host string
178 Accounts bool 177 Accounts bool
179 Signup bool 178 Signup bool
180 Picture bool
181 EmailLogin bool 179 EmailLogin bool
182 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts", 180 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
183 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed", 181 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed",
184 landingPicture, s.emailLoginEnabled()}) 182 s.emailLoginEnabled()})
185} 183}
186 184
187// landingPicture says whether the landing page's screenshot images exist to
188// show, checked once against the embedded images.
189var landingPicture = func() bool {
190 _, e1 := fs.Stat(web.ImageFS, "static/img/mr-dark.png")
191 _, e2 := fs.Stat(web.ImageFS, "static/img/mr-light.png")
192 return e1 == nil && e2 == nil
193}()
194
195func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { 185func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
196 mrs, _ := s.st.DashboardMRs(viewer.ID) 186 mrs, _ := s.st.DashboardMRs(viewer.ID)
197 issues, _ := s.st.DashboardIssues(viewer.ID) 187 issues, _ := s.st.DashboardIssues(viewer.ID)
@@ -491,7 +481,12 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
491 return 481 return
492 } 482 }
493 p.Tab = "files" 483 p.Tab = "files"
494 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/")) 484 path := strings.Trim(r.PathValue("path"), "/")
485 // The root of the default branch is the same page as the bare repo
486 // URL, so its header must match: RepoHome is what picks the h1 over
487 // the p+link identity, not which route was typed.
488 p.RepoHome = path == "" && p.Ref == p.Repo.DefaultBranch
489 s.renderTree(w, r, p, path)
495} 490}
496 491
497// treePage is shared by the populated and empty-repository renders: two 492// treePage is shared by the populated and empty-repository renders: two
internal/web/static/style.css +2 −2
@@ -511,7 +511,6 @@ button, .button {
511 background: var(--fill); 511 background: var(--fill);
512 color: var(--fill-fg); 512 color: var(--fill-fg);
513} 513}
514button.primary, .button.primary { background: var(--fill); color: var(--fill-fg); border-color: transparent; }
515button:hover, .button.primary:hover { filter: brightness(1.12); text-decoration: none; } 514button:hover, .button.primary:hover { filter: brightness(1.12); text-decoration: none; }
516button.btn, .button.btn { 515button.btn, .button.btn {
517 background: var(--canvas); 516 background: var(--canvas);
@@ -581,6 +580,7 @@ input[type="radio"] { accent-color: var(--fill); }
581label { display: block; font-weight: 500; font-size: var(--fs-2); margin-bottom: 4px; } 580label { display: block; font-weight: 500; font-size: var(--fs-2); margin-bottom: 4px; }
582label:has(> input[type="radio"]), label:has(> input[type="checkbox"]), 581label:has(> input[type="radio"]), label:has(> input[type="checkbox"]),
583label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); } 582label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); }
583.fieldname { display: block; font-size: var(--fs-2); font-weight: 500; }
584.hint { display: block; color: var(--muted); font-size: var(--fs-1); font-weight: 400; margin: 0 0 6px; } 584.hint { display: block; color: var(--muted); font-size: var(--fs-1); font-weight: 400; margin: 0 0 6px; }
585.field { margin-bottom: var(--sp-4); } 585.field { margin-bottom: var(--sp-4); }
586.field input[type="text"], .field input[type="email"], .field select { width: 100%; } 586.field input[type="text"], .field input[type="email"], .field select { width: 100%; }
@@ -1331,7 +1331,7 @@ details.refmenu .refdrop a.allrefs {
1331} 1331}
1332.clone { margin: 0 0 var(--sp-3); } 1332.clone { margin: 0 0 var(--sp-3); }
1333.facts .about { min-width: 0; } 1333.facts .about { min-width: 0; }
1334.clone label { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; } 1334.clone .fieldname { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; }
1335.clone pre { margin-bottom: var(--sp-3); user-select: all; } 1335.clone pre { margin-bottom: var(--sp-3); user-select: all; }
1336.factgrid { display: grid; grid-template-columns: repeat(4, auto); gap: var(--sp-2) var(--sp-4); font-size: var(--fs-2); margin-bottom: var(--sp-3); } 1336.factgrid { display: grid; grid-template-columns: repeat(4, auto); gap: var(--sp-2) var(--sp-4); font-size: var(--fs-2); margin-bottom: var(--sp-3); }
1337.factgrid b { font-weight: 600; } 1337.factgrid b { font-weight: 600; }
internal/web/templates/edit.html +1 −1
@@ -1,4 +1,4 @@
1{{define "width"}}bounded{{end}} 1{{define "width"}}wide{{end}}
2{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> 4<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
internal/web/templates/landing.html +2 −2
@@ -6,10 +6,10 @@
6<p class="lede">A git forge you drive from the terminal. Repositories, issues, merge requests and CI over SSH, with a fast, readable web view of the same state.</p> 6<p class="lede">A git forge you drive from the terminal. Repositories, issues, merge requests and CI over SSH, with a fast, readable web view of the same state.</p>
7<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install 7<pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install
8git clone ssh://git@{{.Host}}/owner/repo.git</pre> 8git clone ssh://git@{{.Host}}/owner/repo.git</pre>
9{{if .Picture}}<div class="shot"><picture> 9<div class="shot"><picture>
10 <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)"> 10 <source srcset="/static/img/mr-dark.png" media="(prefers-color-scheme: dark)">
11 <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merge request page: the conversation on the left, checks and reviewers on the right."> 11 <img src="/static/img/mr-light.png" width="1280" height="900" alt="A merge request page: the conversation on the left, checks and reviewers on the right.">
12</picture></div>{{end}} 12</picture></div>
13<div class="facets"> 13<div class="facets">
14 <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section> 14 <section><h2>Read</h2><p>Browse and clone any public repository over HTTPS or <code>git://</code>, no account. Every commit shows whether its signature verified.</p></section>
15 <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</p></section> 15 <section><h2>Write</h2><p>Push over SSH with the key you already have. Create a repository, file an issue, open and merge a request, all as commands.</p></section>
internal/web/templates/settings.html +2 −2
@@ -36,7 +36,7 @@
36<section id="access"><h2>Access</h2> 36<section id="access"><h2>Access</h2>
37<form method="post" action="{{$base}}" class="setform"> 37<form method="post" action="{{$base}}" class="setform">
38 <input type="hidden" name="field" value="visibility"> 38 <input type="hidden" name="field" value="visibility">
39 <div><label>Visibility</label><p class="hint">Private repositories answer not found to everyone without access, including in search and on your profile.</p></div> 39 <div><span class="fieldname">Visibility</span><p class="hint">Private repositories answer not found to everyone without access, including in search and on your profile.</p></div>
40 <div class="check"> 40 <div class="check">
41 <label><input type="radio" name="visibility" value="public"{{if eq .Repo.Visibility "public"}} checked{{end}}> Public</label> 41 <label><input type="radio" name="visibility" value="public"{{if eq .Repo.Visibility "public"}} checked{{end}}> Public</label>
42 <label><input type="radio" name="visibility" value="private"{{if eq .Repo.Visibility "private"}} checked{{end}}> Private</label> 42 <label><input type="radio" name="visibility" value="private"{{if eq .Repo.Visibility "private"}} checked{{end}}> Private</label>
@@ -169,7 +169,7 @@
169 <input type="hidden" name="field" value="runner-add"> 169 <input type="hidden" name="field" value="runner-add">
170 <label for="runner-key">Attach a runner</label> 170 <label for="runner-key">Attach a runner</label>
171 <p class="hint">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p> 171 <p class="hint">Install <code>gitbay-runner</code>, run <code>gitbay-runner init</code>, and paste the key it prints. The runner builds your commits with the repository's secrets; merge requests from forks wait unless it runs with <code>-untrusted</code>.</p>
172 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)"></textarea> 172 <textarea id="runner-key" name="key" rows="3" placeholder="ssh-ed25519 AAAA… (from gitbay-runner init)">{{index .Submitted "key"}}</textarea>
173 <button type="submit" class="btn">Attach</button> 173 <button type="submit" class="btn">Attach</button>
174</form> 174</form>
175</section> 175</section>
internal/web/templates/tree.html +2 −2
@@ -32,8 +32,8 @@
32{{if and .Entries (not .DirPath)}}<div class="facts"> 32{{if and .Entries (not .DirPath)}}<div class="facts">
33 <div class="clone"> 33 <div class="clone">
34 <h2>Clone</h2> 34 <h2>Clone</h2>
35 <label>SSH</label><pre><code>git clone {{.SSHCloneURL}}</code></pre> 35 <span class="fieldname">SSH</span><pre><code>git clone {{.SSHCloneURL}}</code></pre>
36 <label>HTTPS</label><pre><code>git clone {{.CloneURL}}</code></pre> 36 <span class="fieldname">HTTPS</span><pre><code>git clone {{.CloneURL}}</code></pre>
37 </div> 37 </div>
38 {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="about"> 38 {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="about">
39 <h2>About</h2> 39 <h2>About</h2>
internal/web/web_test.go +2 −2
@@ -89,8 +89,8 @@ func TestWhenNamesTheZone(t *testing.T) {
89// none. The merge request page picks wide for its diff view, so it gets 89// none. The merge request page picks wide for its diff view, so it gets
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true} 92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true}
93 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "edit.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} 93 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {
96 src, err := TemplateSource(name) 96 src, err := TemplateSource(name)