Commit 9e644e467c

9e644e467c32ef85f8b03621144bd7778fc06bac

parent: 0f02db6321

Verified · cmc ci/build: failure ci/test: failure

cmc <hello@cleberg.net> · 2026-09-20 04:20 UTC

web: a UI sweep across the rail, dashboard, tables and fork

The rail's squares are 44px at every width and its glyphs 20px on a
phone; below 34rem the overflow moves into a More menu so eleven
squares do not have to fit. Snippets reach the rail. /new creates an
organization as well as a repository and the form leaves the profile
page. repo fork takes --owner and the web's Fork button opens a form
with an owner and a name, checked the way repo create checks an owner.
The dashboard's pinned key reads above its list, the build dot sits on
the row's centre line, a chip on the second row has its bottom border,
and the feed drops its state dot. The SSH key and account tables keep
each row on one line and scroll sideways. .pathbar actions sit on the
centre line.
CHANGELOG.org +24
@@ -74,6 +74,30 @@ Nothing is lost in between — the table keeps the text.
7474 never been pushed to. An unresolvable branch is still an error
7575 anywhere the repository already has refs.
7676
77A web UI sweep.
78
79- The rail's squares are 44px at every width, and its glyphs are 20px
80 on a phone. Below 34rem eleven squares no longer fit, so explore,
81 bookmarks, snippets, new repository, settings, admin and log out
82 move into a More menu and the strip keeps dashboard, search,
83 notifications, More and your account.
84- Snippets are on the rail. The link on a profile page stays.
85- =/new= creates an organization as well as a repository; the form
86 leaves the profile page.
87- =repo fork= takes =--owner=, and the web's Fork button opens a form
88 with an owner and a name. The owner is you or an organization you
89 administer, checked as =repo create= checks it; an organization's
90 fork is not counted against your repository quota. A name of your
91 own is how you fork the same repository twice.
92- The dashboard's pinned key reads above the list rather than under
93 it, its build dot sits on the row's centre line, and a pinned chip
94 on the second row has its bottom border back.
95- The activity feed drops its state dot.
96- The SSH key and account tables keep each row on one line and scroll
97 sideways, rather than wrapping a fingerprint or a timestamp.
98- =.pathbar= actions sit on the centre line with the ref and the
99 crumbs.
100
77101* v1.30.0 — 2026-09-19
78102
79103Every command runs on every surface, markup previews before it is
cmd/gitbay/main.go +1 −1
@@ -435,7 +435,7 @@ func repoCmd() *cobra.Command {
435435 pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}),
436436 pass("rename", "rename a repository: <new-name> (clone URLs change)", passOpts{server: []string{"repo", "rename"}, needsRepo: true}),
437437 pass("delete", "delete a repository (--yes)", passOpts{server: []string{"repo", "delete"}, needsRepo: true}),
438 pass("fork", "fork a repository under your account", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
438 pass("fork", "fork a repository to you or an organization", passOpts{server: []string{"repo", "fork"}, needsRepo: true}),
439439 pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}),
440440 pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}),
441441 pass("diff", "the patch between two refs: <base> <head>", passOpts{server: []string{"repo", "diff"}, needsRepo: true}),
e2e/forkweb_test.go +24 −1
@@ -69,10 +69,33 @@ func TestForkWeb(t *testing.T) {
6969 t.Fatalf("merge request not opened from the fork:\n%s", out)
7070 }
7171
72 // Forking twice collides on the name, and says so on the page rather
72 // Forking twice collides on the name, and says so on the form rather
7373 // than failing silently.
7474 _, body := browserPost(t, bob, base+"/fork", url.Values{})
7575 if !strings.Contains(body, `class="error"`) {
7676 t.Errorf("a second fork reported nothing:\n%s", body)
7777 }
78
79 // A fork can land in an organization the forker administers, under a
80 // name of its own — which is also how you fork the same repository
81 // twice.
82 inst.ssh(t, bobKey, "", "org", "create", "bobco")
83 if _, page := browserGet(t, bob, base+"/fork"); !strings.Contains(page, `<option value="bobco"`) {
84 t.Fatalf("the fork form does not offer the organization:\n%s", page)
85 }
86 if status, body := browserPost(t, bob, base+"/fork", url.Values{
87 "owner": {"bobco"}, "name": {"app2"}}); status != 200 || !strings.Contains(body, "bobco/app2") {
88 t.Fatalf("fork into the organization did not land: %d\n%s", status, body)
89 }
90 if out, _, _ := inst.ssh(t, bobKey, "", "repo", "show", "bobco/app2", "--json"); !strings.Contains(out, `"fork_of":"alice/app"`) {
91 t.Fatalf("the organization's fork does not name its parent:\n%s", out)
92 }
93
94 // An organization you are only a member of is not somewhere you can
95 // put one: the owner is checked as it is on repo create.
96 inst.ssh(t, aliceKey, "", "org", "create", "aliceco")
97 inst.ssh(t, aliceKey, "", "org", "members", "add", "aliceco", "bob")
98 if _, _, code := inst.ssh(t, bobKey, "", "repo", "fork", "alice/app", "--owner", "aliceco"); code != 4 {
99 t.Errorf("fork into an org bob does not administer exited %d, want 4", code)
100 }
78101}
e2e/orgweb_test.go +7 −6
@@ -162,15 +162,16 @@ func TestOrgLifecycleWeb(t *testing.T) {
162162 alice := loginBrowser(t, inst, aliceKey)
163163 bob := loginBrowser(t, inst, bobKey)
164164
165 // The create form is on your own page and nobody else's.
166 if _, body := browserGet(t, alice, inst.base()+"/alice"); !strings.Contains(body, `value="org-create"`) {
167 t.Fatalf("no create form on your own page:\n%s", body)
165 // The create form is on /new, beside the repository form, and no
166 // profile page carries it.
167 if _, body := browserGet(t, alice, inst.base()+"/new"); !strings.Contains(body, `value="org-create"`) {
168 t.Fatalf("no create form on /new:\n%s", body)
168169 }
169 if _, body := browserGet(t, bob, inst.base()+"/alice"); strings.Contains(body, `value="org-create"`) {
170 t.Fatal("create form on someone else's page")
170 if _, body := browserGet(t, alice, inst.base()+"/alice"); strings.Contains(body, `value="org-create"`) {
171 t.Fatal("create form still on the profile page")
171172 }
172173
173 if status, _ := browserPost(t, alice, inst.base()+"/alice", url.Values{
174 if status, _ := browserPost(t, alice, inst.base()+"/new", url.Values{
174175 "field": {"org-create"}, "name": {"acmeco"}}); status != 200 {
175176 t.Fatal("org create failed")
176177 }
internal/control/mr.go +20 −9
@@ -20,7 +20,7 @@ import (
2020func init() {
2121 register(Command{Path: []string{"repo", "fork"},
2222 Summary: "fork a repository under your account",
23 Usage: "repo fork <owner/name> [--name <n>]", Run: runRepoFork})
23 Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]", Run: runRepoFork})
2424 register(Command{Path: []string{"repo", "settings", "require-approvals"},
2525 Summary: "require N fresh approvals to merge",
2626 Usage: "repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals})
@@ -102,11 +102,11 @@ type ForkOut struct {
102102}
103103
104104func runRepoFork(c *Ctx, args []string) int {
105 f, err := parseFlags(args, flagSpec{Values: []string{"--name"}, MaxPos: 1, Usage: "repo fork <owner/name> [--name <n>]"})
105 f, err := parseFlags(args, flagSpec{Values: []string{"--name", "--owner"}, MaxPos: 1, Usage: "repo fork <owner/name> [--owner <o>] [--name <n>]"})
106106 if err != nil {
107107 return c.fail(protocol.ExitUsage, "%v", err)
108108 }
109 path, name := f.pos(0), f.Value("--name")
109 path, name, owner := f.pos(0), f.Value("--name"), f.Value("--owner")
110110 if path == "" {
111111 return c.usage()
112112 }
@@ -120,17 +120,28 @@ func runRepoFork(c *Ctx, args []string) int {
120120 if err := policy.ValidateName(name); err != nil {
121121 return c.failInput(err)
122122 }
123 repoCreateMu.Lock()
124 if code := checkRepoQuota(c); code >= 0 {
125 repoCreateMu.Unlock()
123 if owner == "" {
124 owner = c.User.Username
125 }
126 ownerKind, ownerID, code := resolveNewRepoOwner(c, owner)
127 if code >= 0 {
126128 return code
127129 }
128 id, err := c.Store.CreateFork("user", c.User.ID, name, src.Visibility, src.ID)
130 repoCreateMu.Lock()
131 // An organization's repositories are not counted against the quota,
132 // the same as repo create.
133 if ownerKind == "user" {
134 if code := checkRepoQuota(c); code >= 0 {
135 repoCreateMu.Unlock()
136 return code
137 }
138 }
139 id, err := c.Store.CreateFork(ownerKind, ownerID, name, src.Visibility, src.ID)
129140 repoCreateMu.Unlock()
130141 if err != nil {
131142 return c.fail(protocol.ExitFailure, "%v", err)
132143 }
133 dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name)
144 dstDir := RepoDir(c.Cfg.Server.Root, owner, name)
134145 srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name)
135146 if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil {
136147 c.Store.DeleteRepo(id)
@@ -146,7 +157,7 @@ func runRepoFork(c *Ctx, args []string) int {
146157 return c.fail(protocol.ExitFailure, "copying refs: %v", err)
147158 }
148159 }
149 forkPath := c.User.Username + "/" + name
160 forkPath := owner + "/" + name
150161 return c.emit(ForkOut{Path: forkPath, ForkOf: src.Path()}, func(w io.Writer) {
151162 fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath)
152163 })
internal/control/repo.go +24 −14
@@ -186,20 +186,9 @@ func runRepoCreate(c *Ctx, args []string) int {
186186 if err := policyValidateRepoName(name); err != nil {
187187 return c.failInput(err)
188188 }
189 ownerKind, ownerID := "user", c.User.ID
190 if owner != c.User.Username {
191 org, err := c.Store.OrgByName(owner)
192 if err != nil {
193 return c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
194 }
195 role, err := c.Store.OrgRole(org.ID, c.User.ID)
196 if err != nil {
197 return c.fail(protocol.ExitFailure, "%v", err)
198 }
199 if role != "admin" {
200 return c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
201 }
202 ownerKind, ownerID = "org", org.ID
189 ownerKind, ownerID, code := resolveNewRepoOwner(c, owner)
190 if code >= 0 {
191 return code
203192 }
204193 repoCreateMu.Lock()
205194 if ownerKind == "user" {
@@ -234,6 +223,27 @@ func runRepoCreate(c *Ctx, args []string) int {
234223 })
235224}
236225
226// resolveNewRepoOwner answers who a new repository belongs to: the
227// caller, or an organization they administer. The returned code is -1
228// when the owner is good, and the exit code to return otherwise.
229func resolveNewRepoOwner(c *Ctx, owner string) (kind string, id int64, code int) {
230 if owner == c.User.Username {
231 return "user", c.User.ID, -1
232 }
233 org, err := c.Store.OrgByName(owner)
234 if err != nil {
235 return "", 0, c.fail(protocol.ExitDenied, "cannot create repositories under %q: not you and not an organization you can see", owner)
236 }
237 role, err := c.Store.OrgRole(org.ID, c.User.ID)
238 if err != nil {
239 return "", 0, c.fail(protocol.ExitFailure, "%v", err)
240 }
241 if role != "admin" {
242 return "", 0, c.fail(protocol.ExitDenied, "only admins of %s can create repositories there", owner)
243 }
244 return "org", org.ID, -1
245}
246
237247func policyValidateRepoName(name string) error { return policy.ValidateName(name) }
238248
239249func hostOf(siteURL string) string {
internal/httpd/accounts.go +48 −7
@@ -200,7 +200,19 @@ func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.Use
200200 s.renderNewRepo(w, u, "")
201201}
202202
203func (s *Server) newRepoSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
203// newSubmit creates a repository or an organization: /new carries both
204// forms, told apart by the org form's field. An organization's page is
205// the redirect, the same as org-create from anywhere else.
206func (s *Server) newSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
207 if r.FormValue("field") == "org-create" {
208 name := strings.TrimSpace(r.FormValue("name"))
209 if _, msg, ok := s.runControl(u, []string{"org", "create", name}); !ok {
210 s.renderNewRepo(w, u, msg)
211 return
212 }
213 http.Redirect(w, r, "/"+name, http.StatusSeeOther)
214 return
215 }
204216 owner := r.FormValue("owner")
205217 if owner == "" {
206218 owner = u.Username
@@ -260,19 +272,48 @@ func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.U
260272 }{s.baseFor(u), "bookmarks", rows})
261273}
262274
263// forkSubmit forks the repository under the viewer's account and sends
275// renderFork draws the fork form: where the copy lands and what it is
276// called. owner and name are what the field should hold, which after a
277// refusal is what was submitted.
278func (s *Server) renderFork(w http.ResponseWriter, u store.User, repo store.Repo, owner, name, errMsg string) {
279 s.render(w, "fork.html", struct {
280 basePage
281 Repo store.Repo
282 Orgs []string
283 Owner string
284 Name string
285 Error string
286 }{s.baseFor(u), repo, s.adminOrgs(u), owner, name, errMsg})
287}
288
289func (s *Server) forkForm(w http.ResponseWriter, r *http.Request, u store.User) {
290 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
291 if !ok {
292 return
293 }
294 s.renderFork(w, u, repo, u.Username, repo.Name, "")
295}
296
297// forkSubmit forks the repository to the owner the form picked and sends
264298// them to it. The command decides everything that matters — read access,
265// quota, name collisions — so a refusal comes back as its own message on
266// the page the button was pressed from (#174).
299// the right to create under that owner, quota, name collisions — so a
300// refusal comes back as its own message on the form (#174).
267301func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
268302 repo, ok := s.repoForUser(w, r, u, policy.CanRead)
269303 if !ok {
270304 return
271305 }
306 owner, name := r.FormValue("owner"), r.FormValue("name")
307 if owner == "" {
308 owner = u.Username
309 }
310 if name == "" {
311 name = repo.Name
312 }
272313 var fork control.ForkOut
273 if msg, ok := s.runControlInto(u, []string{"repo", "fork", repo.Path()}, &fork); !ok {
274 s.setFlash(w, msg)
275 http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
314 argv := []string{"repo", "fork", repo.Path(), "--owner", owner, "--name", name}
315 if msg, ok := s.runControlInto(u, argv, &fork); !ok {
316 s.renderFork(w, u, repo, owner, name, msg)
276317 return
277318 }
278319 http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther)
internal/httpd/routes.go +2 −1
@@ -142,7 +142,7 @@ func (s *Server) Routes() []Route {
142142 }
143143 routes = append(routes,
144144 Route{Method: "POST", Pattern: "/new", Mutating: true,
145 Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))},
145 Handler: s.checkOrigin(s.requireUser(s.newSubmit))},
146146 Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true,
147147 Handler: s.checkOrigin(s.requireUser(s.pinToggle))},
148148 Route{Method: "POST", Pattern: "/{owner}/{repo}/watch", Mutating: true,
@@ -182,6 +182,7 @@ func (s *Server) Routes() []Route {
182182 Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))},
183183 Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true,
184184 Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))},
185 Route{Method: "GET", Pattern: "/{owner}/{repo}/fork", Handler: s.requireUser(s.forkForm)},
185186 Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true,
186187 Handler: s.checkOrigin(s.requireUser(s.forkSubmit))},
187188 Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true,
internal/web/static/style.css +58 −8
@@ -120,7 +120,7 @@
120120 --sp-7: 48px;
121121
122122 /* the rail: a column the width of one hit target, a row one tall */
123 --rail-hit: 40px;
123 --rail-hit: 44px;
124124 --rail-strip-h: 48px;
125125 --rail-mark: 24px;
126126 --rail-mark-box: 32px;
@@ -365,6 +365,44 @@ a.railuser {
365365 height: var(--rail-strip-h);
366366}
367367a.railuser:hover { text-decoration: none; }
368/* the phone rail's overflow: every square .railopt hides, and log out,
369 as a labelled list under the rail. Shown only below 34rem. */
370.railmore { display: none; position: relative; }
371.railmore > summary { list-style: none; cursor: pointer; }
372.railmore > summary::-webkit-details-marker { display: none; }
373.railmore[open] > summary { background: var(--shell-hover); }
374.raildrop {
375 position: absolute;
376 top: 100%;
377 right: 0;
378 z-index: 20;
379 min-width: 12rem;
380 padding: var(--sp-1) 0;
381 background: var(--shell-bg);
382 border: 1px solid var(--shell-line);
383 border-radius: var(--r-card);
384 box-shadow: 0 6px 24px rgb(0 0 0 / 0.35);
385}
386.raildrop a, .raildrop button {
387 display: flex;
388 align-items: center;
389 /* a button's own default is centred; the rows read as one list */
390 justify-content: flex-start;
391 gap: var(--sp-3);
392 width: 100%;
393 min-height: var(--rail-hit);
394 padding: 0 var(--sp-4);
395 border: 0;
396 background: none;
397 font: inherit;
398 font-size: var(--fs-2);
399 text-align: left;
400 color: var(--shell-fg);
401 cursor: pointer;
402}
403.raildrop a:hover, .raildrop button:hover { background: var(--shell-hover); text-decoration: none; }
404.raildrop form { margin: 0; }
405.raildrop svg { flex: none; }
368406a.railuser .avatar { width: var(--rail-mark); height: var(--rail-mark); }
369407.avatar {
370408 display: inline-grid;
@@ -846,6 +884,9 @@ table.keys, table.assets { background: none; border: 0; border-radius: 0; }
846884table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; }
847885table.keys td { padding-left: 0; }
848886table.keys td.act, table.assets td.size { text-align: right; }
887/* a fingerprint or an account row is read across, not down: the cells
888 keep one line each and .tablewrap scrolls what does not fit */
889table.keys.nowrap th, table.keys.nowrap td { white-space: nowrap; }
849890/* The account list types a username to confirm, which is longer than
850891 the key prefixes the confirm field is sized for elsewhere. */
851892table.accounts input[name="confirm"] { width: 11rem; }
@@ -1075,7 +1116,7 @@ pre.message {
10751116.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10761117.pins a:hover { color: var(--link); }
10771118.pins .owner { color: var(--muted); }
1078.pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; }
1119.pins .n { margin-left: auto; flex: none; display: flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; }
10791120.pins .n b { font-weight: 600; color: var(--fg); }
10801121.pins .n b.wants { color: var(--warn); }
10811122.pins .dot { margin: 0; }
@@ -1384,7 +1425,7 @@ a.memberchip .role { color: var(--muted); }
13841425
13851426.pathbar { display: flex; align-items: center; gap: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-3); flex-wrap: wrap; font-size: var(--fs-2); }
13861427.pathbar .spacer { flex: 1; }
1387.pathbar .actions { display: flex; gap: var(--sp-2); }
1428.pathbar .actions { display: flex; align-items: center; gap: var(--sp-2); }
13881429/* history · blame · raw were 21px tall and "raw" 22px wide (#232) */
13891430.pathbar .actions a { display: inline-block; padding: 6px 4px; }
13901431.pathbar .acts { display: flex; gap: var(--sp-2); }
@@ -1670,7 +1711,9 @@ svg.icon { vertical-align: -0.125em; }
16701711 .dashgrid > .dashpins { grid-column: 1 / -1; position: static; max-height: none; overflow: visible; }
16711712 /* a pinned block that is not a column is the chip row (spec rule 7) */
16721713 .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
1673 .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
1714 /* the shorthand loses to .pins li:last-child, which drops the bottom
1715 border of the last chip -- and so of the second row */
1716 .pins li, .pins li:last-child { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
16741717 .pins .n, .dashpins .meta { display: none; }
16751718}
16761719
@@ -1743,8 +1786,15 @@ svg.icon { vertical-align: -0.125em; }
17431786 ul.loglist .commitside { width: 100%; justify-content: flex-start; }
17441787}
17451788
1746/* eleven squares at 40px overrun a phone; 32px keeps the row whole and
1747 the 48px height keeps the target big enough to hit */
1748@media (max-width: 30rem) {
1749 :root { --rail-hit: 32px; }
1789/* Eleven 44px squares and the mark are 524px, so below 34rem the rail
1790 keeps five — dashboard, search, notifications, More, your avatar — and
1791 the More menu holds the rest. Shrinking the squares instead is what
1792 this replaces: 32px was under the target every phone guideline asks
1793 for, and the count only grows. */
1794@media (max-width: 34rem) {
1795 .railopt { display: none; }
1796 .railmore { display: block; }
1797 .railicon svg { width: 20px; height: 20px; }
1798 /* the mark is a link home too, so it is a target like the rest */
1799 a.brand { width: var(--rail-hit); }
17501800}
internal/web/templates/account.html +1 −1
@@ -50,7 +50,7 @@ Write it with a push, or edit it here.</p>
5050<p class="meta">Your keys are your identity here. A <code>full</code> key can run
5151commands and push; a <code>git</code> key can only move git data, which is what
5252a CI checkout wants.</p>
53{{if .Keys}}<div class="tablewrap"><table class="keys">
53{{if .Keys}}<div class="tablewrap"><table class="keys nowrap">
5454<tr class="cols"><th scope="col">label</th><th scope="col">fingerprint</th><th scope="col">type</th><th scope="col">scope</th><th scope="col"></th></tr>
5555{{range .Keys}}<tr>
5656 <td>{{.Label}}</td>
internal/web/templates/adminusers.html +1 −1
@@ -13,7 +13,7 @@
1313 <a {{if eq .State "admin"}}class="active" aria-current="page" {{end}}href="?state=admin">admins</a>
1414</nav>
1515
16{{if .Users}}<div class="tablewrap"><table class="keys accounts">
16{{if .Users}}<div class="tablewrap"><table class="keys accounts nowrap">
1717<thead><tr class="cols"><th>Account</th><th>State</th><th>Created</th><th>Last seen</th><th>Actions</th></tr></thead>
1818<tbody>
1919{{range .Users}}<tr>
internal/web/templates/dashboard.html +3 −3
@@ -22,10 +22,10 @@
2222
2323<aside class="dashpins" aria-label="Pinned repositories">
2424 <h2 class="colhead">Pinned</h2>
25 {{if .Pins}}<ul class="pins">
25 {{if .Pins}}<p class="meta">issues · merge requests · last build</p>
26 <ul class="pins">
2627 {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}"><b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b> <span class="dot{{if eq .Build "success"}} ok{{else if or (eq .Build "failure") (eq .Build "error")}} bad{{else if or (eq .Build "pending") (eq .Build "running")}} pend{{end}}"><span class="vh">{{with .Build}}last build {{.}}{{else}}no builds{{end}}</span></span></span></li>
2728 {{end}}</ul>
28 <p class="meta">issues · merge requests · last build</p>
2929 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
3030</aside>
3131
@@ -46,7 +46,7 @@
4646
4747<aside class="feedcol" aria-label="Recent activity">
4848 <h2 class="colhead">Recent activity</h2>
49 {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
49 {{range .Feed}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
5050 {{else}}<p class="none">No activity yet</p>{{end}}
5151</aside>
5252
internal/web/templates/fork.html added +18
@@ -0,0 +1,18 @@
1{{define "width"}}bounded{{end}}
2{{define "title"}}fork {{.Repo.OwnerName}}/{{.Repo.Name}} · {{.Site}}{{end}}
3{{define "content"}}
4<h1>Fork {{.Repo.OwnerName}}/{{.Repo.Name}}</h1>
5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
6<p class="meta">A fork is a copy of every branch, owned by you or by an
7organization you administer. Its issues, merge requests and settings are its
8own.</p>
9<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">
10<p><label>Owner <select name="owner">
11 <option value="{{.Viewer}}">{{.Viewer}}</option>
12 {{range .Orgs}}<option value="{{.}}"{{if eq . $.Owner}} selected{{end}}>{{.}}</option>{{end}}
13</select></label>
14<label>/ Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" value="{{.Name}}" aria-describedby="forkhint"></label>
15<span class="hint" id="forkhint">Defaults to the source name; change it to fork twice into the same owner.</span></p>
16<p><button type="submit">Create fork</button></p>
17</form>
18{{end}}
internal/web/templates/layout.html +33 −11
@@ -14,22 +14,38 @@
1414
1515<nav class="rail" aria-label="Site">
1616 <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a>
17 {{/* A square is .railopt when the phone rail drops it into the More
18 menu below. The menu repeats those destinations, so the two lists
19 are kept in step by hand: add a square there and add it here. */}}
1720 <ul class="raillist">
18 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "dashboard"}}aria-current="page" {{end}}href="/" aria-label="Dashboard" title="Dashboard">{{template "icon" "home"}}<span class="vh">Dashboard</span></a></li>{{end}}
19 <li><a class="railicon" {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore" aria-label="Explore" title="Explore">{{template "icon" "compass"}}<span class="vh">Explore</span></a></li>
20 <li><a class="railicon" {{if eq (str . "Tab") "sitesearch"}}aria-current="page" {{end}}href="/search" aria-label="Search" title="Search">{{template "icon" "search"}}<span class="vh">Search</span></a></li>
21 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications" aria-label="Notifications" title="Notifications">{{template "icon" "bell"}}<span class="vh">Notifications</span>{{with .Rail.Unread}}<b class="count">{{.}}</b>{{end}}</a></li>
22 <li><a class="railicon" {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks" aria-label="Bookmarks" title="Bookmarks">{{template "icon" "bookmark"}}<span class="vh">Bookmarks</span></a></li>
23 <li><a class="railicon" href="/new" aria-label="New repository" title="New repository">{{template "icon" "plus"}}<span class="vh">New repository</span></a></li>{{end}}
21 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
22 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
23 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
24 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
25 <li class="railopt">{{template "raillink" dict "Href" "/bookmarks" "Icon" "bookmark" "Name" "Bookmarks" "Current" (eq (str . "Tab") "bookmarks")}}</li>
26 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/snippets" .Viewer) "Icon" "snippet" "Name" "Snippets" "Current" (eq (str . "Tab") "snippets")}}</li>
27 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
2428 </ul>
2529 <span class="railgap"></span>
2630 <ul class="raillist">
27 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "account"}}aria-current="page" {{end}}href="/settings" aria-label="Settings" title="Settings">{{template "icon" "gear"}}<span class="vh">Settings</span></a></li>{{end}}
28 {{if .Admin}}<li><a class="railicon" {{if eq (str . "Tab") "admin"}}aria-current="page" {{end}}href="/admin" aria-label="Admin" title="Admin">{{template "icon" "shield"}}<span class="vh">Admin</span></a></li>{{end}}
31 {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}}
32 {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}}
2933 </ul>
3034 <div class="railfoot">
31 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a>
32 <form method="post" action="/logout"><button type="submit" class="railicon" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></button></form>
35 {{if .Viewer}}<details class="railmore">
36 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
37 <div class="raildrop">
38 <a href="/explore">{{template "icon" "compass"}} Explore</a>
39 <a href="/bookmarks">{{template "icon" "bookmark"}} Bookmarks</a>
40 <a href="/{{.Viewer}}/-/snippets">{{template "icon" "snippet"}} Snippets</a>
41 <a href="/new">{{template "icon" "plus"}} New repository</a>
42 <a href="/settings">{{template "icon" "gear"}} Settings</a>
43 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
44 <form method="post" action="/logout"><button type="submit">{{template "icon" "signout"}} Log out</button></form>
45 </div>
46 </details>
47 <a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a>
48 <form class="railopt" method="post" action="/logout"><button type="submit" class="railicon" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></button></form>
3349 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}}
3450 </div>
3551</nav>
@@ -51,7 +67,7 @@
5167 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
5268 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
5369 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
54 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
70 <a class="button btn" href="/{{.OwnerName}}/{{.Name}}/fork">Fork</a>{{end}}
5571 </div>
5672 {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}}
5773 <nav class="tabs" aria-label="Repository">
@@ -93,9 +109,15 @@
93109{{- else if eq . "gear"}}<path d="M6.63 1.13L9.37 1.13L9.51 3.02L10.45 3.41L11.89 2.18L13.82 4.11L12.59 5.55L12.98 6.49L14.87 6.63L14.87 9.37L12.98 9.51L12.59 10.45L13.82 11.89L11.89 13.82L10.45 12.59L9.51 12.98L9.37 14.87L6.63 14.87L6.49 12.98L5.55 12.59L4.11 13.82L2.18 11.89L3.41 10.45L3.02 9.51L1.13 9.37L1.13 6.63L3.02 6.49L3.41 5.55L2.18 4.11L4.11 2.18L5.55 3.41L6.49 3.02Z"/><circle cx="8" cy="8" r="2.2"/>
94110{{- else if eq . "shield"}}<path d="M8 1.75 13 3.5v4.25c0 3.1-2 5.4-5 6.5-3-1.1-5-3.4-5-6.5V3.5Z"/><path d="m5.9 7.75 1.6 1.6 2.85-3.1"/>
95111{{- else if eq . "signout"}}<path d="M6.5 2.25H3.25a1 1 0 0 0-1 1v9.5a1 1 0 0 0 1 1H6.5"/><path d="M7.75 8h6M11.25 5.5 13.75 8l-2.5 2.5"/>
112{{- else if eq . "ellipsis"}}<circle cx="3.25" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="12.75" cy="8" r="1.1" fill="currentColor" stroke="none"/>
113{{- else if eq . "snippet"}}<path d="M3.25 2.75h6L12.75 6v7.25a.5.5 0 0 1-.5.5H3.75a.5.5 0 0 1-.5-.5Z"/><path d="M9 2.75V6h3.75"/><path d="m6.75 8.75-1.5 1.5 1.5 1.5M9.75 8.75l1.5 1.5-1.5 1.5"/>
96114{{- else if eq . "person"}}<circle cx="8" cy="5.5" r="2.75"/><path d="M2.75 14a5.25 5.25 0 0 1 10.5 0"/>
97115{{- end}}</svg>{{end}}
98116
117{{/* raillink draws one square in the rail: the icon, the name for a
118 screen reader and in the tooltip, and the bell's unread badge. */}}
119{{define "raillink"}}<a class="railicon" {{if .Current}}aria-current="page" {{end}}href="{{.Href}}" aria-label="{{.Name}}" title="{{.Name}}">{{template "icon" .Icon}}<span class="vh">{{.Name}}</span>{{with .Count}}<b class="count">{{.}}</b>{{end}}</a>{{end}}
120
99121{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}}
100122{{define "width"}}reading{{end}}
101123
internal/web/templates/new.html +12 −1
@@ -1,7 +1,8 @@
11{{define "width"}}bounded{{end}}
22{{define "title"}}new repository · {{.Site}}{{end}}
33{{define "content"}}
4<h1>New repository</h1>
4<h1>New</h1>
5<h2>Repository</h2>
56{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
67<form method="post" action="/new">
78<p><label>Owner <select name="owner">
@@ -19,4 +20,14 @@
1920</fieldset>
2021<p><button type="submit">Create repository</button></p>
2122</form>
23
24<h2 id="org">Organization</h2>
25<p class="meta">An organization owns repositories, labels and milestones, and
26grants access through teams. You are its first admin.</p>
27<form method="post" action="/new">
28<input type="hidden" name="field" value="org-create">
29<p><label>Name <input name="name" required maxlength="63" pattern="[a-z0-9][a-z0-9._\-]{0,62}" aria-describedby="orghint"></label>
30<span class="hint" id="orghint">The same rules as a repository name, and it becomes a top-level path.</span></p>
31<p><button type="submit">Create organization</button></p>
32</form>
2233{{end}}
internal/web/templates/owner.html −14
@@ -26,20 +26,6 @@
2626</ul>
2727{{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}}
2828
29{{if .Self}}
30<h2>organizations</h2>
31{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
32<details class="editbox">
33 <summary>New organization</summary>
34 <form method="post" action="/{{.Owner}}" class="setform">
35 <input type="hidden" name="field" value="org-create">
36 <label for="orgname">Name</label>
37 <input type="text" id="orgname" name="name" required>
38 <button type="submit" class="btn">Create</button>
39 </form>
40</details>
41{{end}}
42
4329{{if .CanAdmin}}{{$org := .Owner}}
4430<h2>people <span class="count">{{len .Members}}</span></h2>
4531{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
internal/web/web_test.go +31 −7
@@ -90,7 +90,7 @@ func TestWhenNamesTheZone(t *testing.T) {
9090// a per-view define instead of a fixed one.
9191func TestMainWidthClass(t *testing.T) {
9292 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, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.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, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
9494 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
9595 for _, name := range Pages() {
9696 src, err := TemplateSource(name)
@@ -137,9 +137,32 @@ func TestRailIconsAreLabelled(t *testing.T) {
137137 glyph := func(s string) bool {
138138 return strings.Contains(s, `{{template "icon" `) || strings.Contains(s, "<svg")
139139 }
140 tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<button [^>]*class="railicon".*?</button>`)
140 // Every square in the strip comes from the raillink partial, so the
141 // rule is checked once there and once for each control written out
142 // in full: the More summary, Log out and Sign in.
143 link := src[strings.Index(src, `{{define "raillink"}}`):]
144 link = link[:strings.Index(link, "\n")] // the partial is one line
145 for _, want := range []string{`aria-label="{{.Name}}"`, `<span class="vh">{{.Name}}</span>`} {
146 if !strings.Contains(link, want) {
147 t.Errorf("raillink partial missing %s", want)
148 }
149 }
150 if !strings.Contains(link, `{{template "icon" .Icon}}`) {
151 t.Error("raillink partial draws no glyph")
152 }
153 // A call site that names no icon or no name would render a square
154 // with neither, which the partial alone cannot catch.
155 for _, call := range regexp.MustCompile(`{{template "raillink" dict [^}]*}}`).FindAllString(src, -1) {
156 for _, want := range []string{`"Icon" `, `"Name" `} {
157 if !strings.Contains(call, want) {
158 t.Errorf("raillink call missing %s: %.90s", want, call)
159 }
160 }
161 }
162
163 tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<summary class="railicon".*?</summary>|<button [^>]*class="railicon".*?</button>`)
141164 controls := tagRe.FindAllString(src, -1)
142 if len(controls) < 7 {
165 if len(controls) < 3 {
143166 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls))
144167 }
145168 for _, c := range controls {
@@ -153,13 +176,14 @@ func TestRailIconsAreLabelled(t *testing.T) {
153176 }
154177 }
155178
156 // Every button in the rail's foot is an icon button under the same
157 // rule: the Log out form's submit is the only one today.
179 // An icon button in the rail's foot is under the same rule: Log out
180 // is the only one today. The More menu's own rows are not icon
181 // buttons — their visible text is their name.
158182 foot := src[strings.Index(src, `<div class="railfoot">`):]
159183 foot = foot[:strings.Index(foot, "</nav>")]
160 buttons := regexp.MustCompile(`(?s)<button.*?</button>`).FindAllString(foot, -1)
184 buttons := regexp.MustCompile(`(?s)<button [^>]*class="railicon".*?</button>`).FindAllString(foot, -1)
161185 if len(buttons) == 0 {
162 t.Fatal("no button in the rail foot")
186 t.Fatal("no icon button in the rail foot")
163187 }
164188 for _, b := range buttons {
165189 for _, want := range []string{`aria-label="`, `<span class="vh">`} {