Commit 9e644e467c
Verified · cmc ci/build: failure ci/test: failure
CHANGELOG.org +24
| @@ -74,6 +74,30 @@ Nothing is lost in between — the table keeps the text. | ||
| 74 | 74 | never been pushed to. An unresolvable branch is still an error |
| 75 | 75 | anywhere the repository already has refs. |
| 76 | 76 | |
| 77 | A 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 | ||
| 77 | 101 | * v1.30.0 — 2026-09-19 |
| 78 | 102 | |
| 79 | 103 | Every command runs on every surface, markup previews before it is |
cmd/gitbay/main.go +1 −1
| @@ -435,7 +435,7 @@ func repoCmd() *cobra.Command { | ||
| 435 | 435 | pass("transfer", "move a repository to another owner: <new-owner>", passOpts{server: []string{"repo", "transfer"}, needsRepo: true}), |
| 436 | 436 | pass("rename", "rename a repository: <new-name> (clone URLs change)", passOpts{server: []string{"repo", "rename"}, needsRepo: true}), |
| 437 | 437 | 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}), | |
| 439 | 439 | pass("search", "find repositories by name, description, or topic: <query>", passOpts{server: []string{"repo", "search"}}), |
| 440 | 440 | pass("grep", "search file contents: <query> [--ref <ref>]", passOpts{server: []string{"repo", "grep"}, needsRepo: true}), |
| 441 | 441 | 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) { | ||
| 69 | 69 | t.Fatalf("merge request not opened from the fork:\n%s", out) |
| 70 | 70 | } |
| 71 | 71 | |
| 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 | |
| 73 | 73 | // than failing silently. |
| 74 | 74 | _, body := browserPost(t, bob, base+"/fork", url.Values{}) |
| 75 | 75 | if !strings.Contains(body, `class="error"`) { |
| 76 | 76 | t.Errorf("a second fork reported nothing:\n%s", body) |
| 77 | 77 | } |
| 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 | } | |
| 78 | 101 | } |
e2e/orgweb_test.go +7 −6
| @@ -162,15 +162,16 @@ func TestOrgLifecycleWeb(t *testing.T) { | ||
| 162 | 162 | alice := loginBrowser(t, inst, aliceKey) |
| 163 | 163 | bob := loginBrowser(t, inst, bobKey) |
| 164 | 164 | |
| 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) | |
| 168 | 169 | } |
| 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") | |
| 171 | 172 | } |
| 172 | 173 | |
| 173 | if status, _ := browserPost(t, alice, inst.base()+"/alice", url.Values{ | |
| 174 | if status, _ := browserPost(t, alice, inst.base()+"/new", url.Values{ | |
| 174 | 175 | "field": {"org-create"}, "name": {"acmeco"}}); status != 200 { |
| 175 | 176 | t.Fatal("org create failed") |
| 176 | 177 | } |
internal/control/mr.go +20 −9
| @@ -20,7 +20,7 @@ import ( | ||
| 20 | 20 | func init() { |
| 21 | 21 | register(Command{Path: []string{"repo", "fork"}, |
| 22 | 22 | 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}) | |
| 24 | 24 | register(Command{Path: []string{"repo", "settings", "require-approvals"}, |
| 25 | 25 | Summary: "require N fresh approvals to merge", |
| 26 | 26 | Usage: "repo settings require-approvals <owner/name> <n> (0 = off)", Run: runRequireApprovals}) |
| @@ -102,11 +102,11 @@ type ForkOut struct { | ||
| 102 | 102 | } |
| 103 | 103 | |
| 104 | 104 | func 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>]"}) | |
| 106 | 106 | if err != nil { |
| 107 | 107 | return c.fail(protocol.ExitUsage, "%v", err) |
| 108 | 108 | } |
| 109 | path, name := f.pos(0), f.Value("--name") | |
| 109 | path, name, owner := f.pos(0), f.Value("--name"), f.Value("--owner") | |
| 110 | 110 | if path == "" { |
| 111 | 111 | return c.usage() |
| 112 | 112 | } |
| @@ -120,17 +120,28 @@ func runRepoFork(c *Ctx, args []string) int { | ||
| 120 | 120 | if err := policy.ValidateName(name); err != nil { |
| 121 | 121 | return c.failInput(err) |
| 122 | 122 | } |
| 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 { | |
| 126 | 128 | return code |
| 127 | 129 | } |
| 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) | |
| 129 | 140 | repoCreateMu.Unlock() |
| 130 | 141 | if err != nil { |
| 131 | 142 | return c.fail(protocol.ExitFailure, "%v", err) |
| 132 | 143 | } |
| 133 | dstDir := RepoDir(c.Cfg.Server.Root, c.User.Username, name) | |
| 144 | dstDir := RepoDir(c.Cfg.Server.Root, owner, name) | |
| 134 | 145 | srcDir := RepoDir(c.Cfg.Server.Root, src.OwnerName, src.Name) |
| 135 | 146 | if err := gitutil.InitBare(dstDir, "main", HooksDir(c.Cfg.Server.Root)); err != nil { |
| 136 | 147 | c.Store.DeleteRepo(id) |
| @@ -146,7 +157,7 @@ func runRepoFork(c *Ctx, args []string) int { | ||
| 146 | 157 | return c.fail(protocol.ExitFailure, "copying refs: %v", err) |
| 147 | 158 | } |
| 148 | 159 | } |
| 149 | forkPath := c.User.Username + "/" + name | |
| 160 | forkPath := owner + "/" + name | |
| 150 | 161 | return c.emit(ForkOut{Path: forkPath, ForkOf: src.Path()}, func(w io.Writer) { |
| 151 | 162 | fmt.Fprintf(w, "forked %s to %s\n", src.Path(), forkPath) |
| 152 | 163 | }) |
internal/control/repo.go +24 −14
| @@ -186,20 +186,9 @@ func runRepoCreate(c *Ctx, args []string) int { | ||
| 186 | 186 | if err := policyValidateRepoName(name); err != nil { |
| 187 | 187 | return c.failInput(err) |
| 188 | 188 | } |
| 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 | |
| 203 | 192 | } |
| 204 | 193 | repoCreateMu.Lock() |
| 205 | 194 | if ownerKind == "user" { |
| @@ -234,6 +223,27 @@ func runRepoCreate(c *Ctx, args []string) int { | ||
| 234 | 223 | }) |
| 235 | 224 | } |
| 236 | 225 | |
| 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. | |
| 229 | func 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 | ||
| 237 | 247 | func policyValidateRepoName(name string) error { return policy.ValidateName(name) } |
| 238 | 248 | |
| 239 | 249 | func 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 | ||
| 200 | 200 | s.renderNewRepo(w, u, "") |
| 201 | 201 | } |
| 202 | 202 | |
| 203 | func (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. | |
| 206 | func (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 | } | |
| 204 | 216 | owner := r.FormValue("owner") |
| 205 | 217 | if owner == "" { |
| 206 | 218 | owner = u.Username |
| @@ -260,19 +272,48 @@ func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.U | ||
| 260 | 272 | }{s.baseFor(u), "bookmarks", rows}) |
| 261 | 273 | } |
| 262 | 274 | |
| 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. | |
| 278 | func (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 | ||
| 289 | func (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 | |
| 264 | 298 | // 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). | |
| 267 | 301 | func (s *Server) forkSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 268 | 302 | repo, ok := s.repoForUser(w, r, u, policy.CanRead) |
| 269 | 303 | if !ok { |
| 270 | 304 | return |
| 271 | 305 | } |
| 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 | } | |
| 272 | 313 | 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) | |
| 276 | 317 | return |
| 277 | 318 | } |
| 278 | 319 | http.Redirect(w, r, "/"+fork.Path, http.StatusSeeOther) |
internal/httpd/routes.go +2 −1
| @@ -142,7 +142,7 @@ func (s *Server) Routes() []Route { | ||
| 142 | 142 | } |
| 143 | 143 | routes = append(routes, |
| 144 | 144 | Route{Method: "POST", Pattern: "/new", Mutating: true, |
| 145 | Handler: s.checkOrigin(s.requireUser(s.newRepoSubmit))}, | |
| 145 | Handler: s.checkOrigin(s.requireUser(s.newSubmit))}, | |
| 146 | 146 | Route{Method: "POST", Pattern: "/{owner}/{repo}/pin", Mutating: true, |
| 147 | 147 | Handler: s.checkOrigin(s.requireUser(s.pinToggle))}, |
| 148 | 148 | Route{Method: "POST", Pattern: "/{owner}/{repo}/watch", Mutating: true, |
| @@ -182,6 +182,7 @@ func (s *Server) Routes() []Route { | ||
| 182 | 182 | Handler: s.checkOrigin(s.requireUser(s.snippetFileRemoveSubmit))}, |
| 183 | 183 | Route{Method: "POST", Pattern: "/{owner}/{repo}/bookmark", Mutating: true, |
| 184 | 184 | Handler: s.checkOrigin(s.requireUser(s.bookmarkToggle))}, |
| 185 | Route{Method: "GET", Pattern: "/{owner}/{repo}/fork", Handler: s.requireUser(s.forkForm)}, | |
| 185 | 186 | Route{Method: "POST", Pattern: "/{owner}/{repo}/fork", Mutating: true, |
| 186 | 187 | Handler: s.checkOrigin(s.requireUser(s.forkSubmit))}, |
| 187 | 188 | Route{Method: "POST", Pattern: "/{owner}/{repo}/builds", Mutating: true, |
internal/web/static/style.css +58 −8
| @@ -120,7 +120,7 @@ | ||
| 120 | 120 | --sp-7: 48px; |
| 121 | 121 | |
| 122 | 122 | /* the rail: a column the width of one hit target, a row one tall */ |
| 123 | --rail-hit: 40px; | |
| 123 | --rail-hit: 44px; | |
| 124 | 124 | --rail-strip-h: 48px; |
| 125 | 125 | --rail-mark: 24px; |
| 126 | 126 | --rail-mark-box: 32px; |
| @@ -365,6 +365,44 @@ a.railuser { | ||
| 365 | 365 | height: var(--rail-strip-h); |
| 366 | 366 | } |
| 367 | 367 | a.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; } | |
| 368 | 406 | a.railuser .avatar { width: var(--rail-mark); height: var(--rail-mark); } |
| 369 | 407 | .avatar { |
| 370 | 408 | display: inline-grid; |
| @@ -846,6 +884,9 @@ table.keys, table.assets { background: none; border: 0; border-radius: 0; } | ||
| 846 | 884 | table.keys th, table.assets th, table.keys tr.cols th { background: transparent; padding-left: 0; } |
| 847 | 885 | table.keys td { padding-left: 0; } |
| 848 | 886 | table.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 */ | |
| 889 | table.keys.nowrap th, table.keys.nowrap td { white-space: nowrap; } | |
| 849 | 890 | /* The account list types a username to confirm, which is longer than |
| 850 | 891 | the key prefixes the confirm field is sized for elsewhere. */ |
| 851 | 892 | table.accounts input[name="confirm"] { width: 11rem; } |
| @@ -1075,7 +1116,7 @@ pre.message { | ||
| 1075 | 1116 | .pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
| 1076 | 1117 | .pins a:hover { color: var(--link); } |
| 1077 | 1118 | .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; } | |
| 1079 | 1120 | .pins .n b { font-weight: 600; color: var(--fg); } |
| 1080 | 1121 | .pins .n b.wants { color: var(--warn); } |
| 1081 | 1122 | .pins .dot { margin: 0; } |
| @@ -1384,7 +1425,7 @@ a.memberchip .role { color: var(--muted); } | ||
| 1384 | 1425 | |
| 1385 | 1426 | .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); } |
| 1386 | 1427 | .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); } | |
| 1388 | 1429 | /* history · blame · raw were 21px tall and "raw" 22px wide (#232) */ |
| 1389 | 1430 | .pathbar .actions a { display: inline-block; padding: 6px 4px; } |
| 1390 | 1431 | .pathbar .acts { display: flex; gap: var(--sp-2); } |
| @@ -1670,7 +1711,9 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1670 | 1711 | .dashgrid > .dashpins { grid-column: 1 / -1; position: static; max-height: none; overflow: visible; } |
| 1671 | 1712 | /* a pinned block that is not a column is the chip row (spec rule 7) */ |
| 1672 | 1713 | .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); } | |
| 1674 | 1717 | .pins .n, .dashpins .meta { display: none; } |
| 1675 | 1718 | } |
| 1676 | 1719 | |
| @@ -1743,8 +1786,15 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1743 | 1786 | ul.loglist .commitside { width: 100%; justify-content: flex-start; } |
| 1744 | 1787 | } |
| 1745 | 1788 | |
| 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); } | |
| 1750 | 1800 | } |
internal/web/templates/account.html +1 −1
| @@ -50,7 +50,7 @@ Write it with a push, or edit it here.</p> | ||
| 50 | 50 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run |
| 51 | 51 | commands and push; a <code>git</code> key can only move git data, which is what |
| 52 | 52 | a CI checkout wants.</p> |
| 53 | {{if .Keys}}<div class="tablewrap"><table class="keys"> | |
| 53 | {{if .Keys}}<div class="tablewrap"><table class="keys nowrap"> | |
| 54 | 54 | <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> |
| 55 | 55 | {{range .Keys}}<tr> |
| 56 | 56 | <td>{{.Label}}</td> |
internal/web/templates/adminusers.html +1 −1
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | <a {{if eq .State "admin"}}class="active" aria-current="page" {{end}}href="?state=admin">admins</a> |
| 14 | 14 | </nav> |
| 15 | 15 | |
| 16 | {{if .Users}}<div class="tablewrap"><table class="keys accounts"> | |
| 16 | {{if .Users}}<div class="tablewrap"><table class="keys accounts nowrap"> | |
| 17 | 17 | <thead><tr class="cols"><th>Account</th><th>State</th><th>Created</th><th>Last seen</th><th>Actions</th></tr></thead> |
| 18 | 18 | <tbody> |
| 19 | 19 | {{range .Users}}<tr> |
internal/web/templates/dashboard.html +3 −3
| @@ -22,10 +22,10 @@ | ||
| 22 | 22 | |
| 23 | 23 | <aside class="dashpins" aria-label="Pinned repositories"> |
| 24 | 24 | <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"> | |
| 26 | 27 | {{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> |
| 27 | 28 | {{end}}</ul> |
| 28 | <p class="meta">issues · merge requests · last build</p> | |
| 29 | 29 | {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}} |
| 30 | 30 | </aside> |
| 31 | 31 | |
| @@ -46,7 +46,7 @@ | ||
| 46 | 46 | |
| 47 | 47 | <aside class="feedcol" aria-label="Recent activity"> |
| 48 | 48 | <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> | |
| 50 | 50 | {{else}}<p class="none">No activity yet</p>{{end}} |
| 51 | 51 | </aside> |
| 52 | 52 | |
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 | |
| 7 | organization you administer. Its issues, merge requests and settings are its | |
| 8 | own.</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 @@ | ||
| 14 | 14 | |
| 15 | 15 | <nav class="rail" aria-label="Site"> |
| 16 | 16 | <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. */}} | |
| 17 | 20 | <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}} | |
| 24 | 28 | </ul> |
| 25 | 29 | <span class="railgap"></span> |
| 26 | 30 | <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}} | |
| 29 | 33 | </ul> |
| 30 | 34 | <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> | |
| 33 | 49 | {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}} |
| 34 | 50 | </div> |
| 35 | 51 | </nav> |
| @@ -51,7 +67,7 @@ | ||
| 51 | 67 | {{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> |
| 52 | 68 | <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> |
| 53 | 69 | <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}} | |
| 55 | 71 | </div> |
| 56 | 72 | {{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}} |
| 57 | 73 | <nav class="tabs" aria-label="Repository"> |
| @@ -93,9 +109,15 @@ | ||
| 93 | 109 | {{- 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"/> |
| 94 | 110 | {{- 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"/> |
| 95 | 111 | {{- 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"/> | |
| 96 | 114 | {{- 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"/> |
| 97 | 115 | {{- end}}</svg>{{end}} |
| 98 | 116 | |
| 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 | ||
| 99 | 121 | {{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}} |
| 100 | 122 | {{define "width"}}reading{{end}} |
| 101 | 123 | |
internal/web/templates/new.html +12 −1
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | {{define "width"}}bounded{{end}} |
| 2 | 2 | {{define "title"}}new repository · {{.Site}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | <h1>New repository</h1> | |
| 4 | <h1>New</h1> | |
| 5 | <h2>Repository</h2> | |
| 5 | 6 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 6 | 7 | <form method="post" action="/new"> |
| 7 | 8 | <p><label>Owner <select name="owner"> |
| @@ -19,4 +20,14 @@ | ||
| 19 | 20 | </fieldset> |
| 20 | 21 | <p><button type="submit">Create repository</button></p> |
| 21 | 22 | </form> |
| 23 | ||
| 24 | <h2 id="org">Organization</h2> | |
| 25 | <p class="meta">An organization owns repositories, labels and milestones, and | |
| 26 | grants 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> | |
| 22 | 33 | {{end}} |
internal/web/templates/owner.html −14
| @@ -26,20 +26,6 @@ | ||
| 26 | 26 | </ul> |
| 27 | 27 | {{if or .Snippets .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets">snippets{{if .Snippets}} <span class="count">{{.Snippets}}</span>{{end}}</a></p>{{end}} |
| 28 | 28 | |
| 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 | ||
| 43 | 29 | {{if .CanAdmin}}{{$org := .Owner}} |
| 44 | 30 | <h2>people <span class="count">{{len .Members}}</span></h2> |
| 45 | 31 | {{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) { | ||
| 90 | 90 | // a per-view define instead of a fixed one. |
| 91 | 91 | func TestMainWidthClass(t *testing.T) { |
| 92 | 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, "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} | |
| 94 | 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} |
| 95 | 95 | for _, name := range Pages() { |
| 96 | 96 | src, err := TemplateSource(name) |
| @@ -137,9 +137,32 @@ func TestRailIconsAreLabelled(t *testing.T) { | ||
| 137 | 137 | glyph := func(s string) bool { |
| 138 | 138 | return strings.Contains(s, `{{template "icon" `) || strings.Contains(s, "<svg") |
| 139 | 139 | } |
| 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>`) | |
| 141 | 164 | controls := tagRe.FindAllString(src, -1) |
| 142 | if len(controls) < 7 { | |
| 165 | if len(controls) < 3 { | |
| 143 | 166 | t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls)) |
| 144 | 167 | } |
| 145 | 168 | for _, c := range controls { |
| @@ -153,13 +176,14 @@ func TestRailIconsAreLabelled(t *testing.T) { | ||
| 153 | 176 | } |
| 154 | 177 | } |
| 155 | 178 | |
| 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. | |
| 158 | 182 | foot := src[strings.Index(src, `<div class="railfoot">`):] |
| 159 | 183 | 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) | |
| 161 | 185 | if len(buttons) == 0 { |
| 162 | t.Fatal("no button in the rail foot") | |
| 186 | t.Fatal("no icon button in the rail foot") | |
| 163 | 187 | } |
| 164 | 188 | for _, b := range buttons { |
| 165 | 189 | for _, want := range []string{`aria-label="`, `<span class="vh">`} { |