web: v1.23.0 polish follow-ups !403
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. | ||
| 189 | var 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 | |||
| 195 | func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { | 185 | func (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 | } |
| 514 | button.primary, .button.primary { background: var(--fill); color: var(--fill-fg); border-color: transparent; } | ||
| 515 | button:hover, .button.primary:hover { filter: brightness(1.12); text-decoration: none; } | 514 | button:hover, .button.primary:hover { filter: brightness(1.12); text-decoration: none; } |
| 516 | button.btn, .button.btn { | 515 | button.btn, .button.btn { |
| 517 | background: var(--canvas); | 516 | background: var(--canvas); |
| @@ -581,6 +580,7 @@ input[type="radio"] { accent-color: var(--fill); } | |||
| 581 | label { display: block; font-weight: 500; font-size: var(--fs-2); margin-bottom: 4px; } | 580 | label { display: block; font-weight: 500; font-size: var(--fs-2); margin-bottom: 4px; } |
| 582 | label:has(> input[type="radio"]), label:has(> input[type="checkbox"]), | 581 | label:has(> input[type="radio"]), label:has(> input[type="checkbox"]), |
| 583 | label:has(> select) { display: inline-flex; align-items: center; gap: var(--sp-2); } | 582 | label: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 |
| 8 | git clone ssh://git@{{.Host}}/owner/repo.git</pre> | 8 | git 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. |
| 91 | func TestMainWidthClass(t *testing.T) { | 91 | func 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) |