web: content widths and the four reference pages !396
41 files changed, +543 −251
Layout: unified · split
e2e/bookmarks_test.go +1 −1
| @@ -61,7 +61,7 @@ func TestBookmarks(t *testing.T) { | |||
| 61 | } | 61 | } |
| 62 | 62 | ||
| 63 | // The count is public and shows on the repository page. | 63 | // The count is public and shows on the repository page. |
| 64 | if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, "1</strong> bookmark") { | 64 | if _, body := inst.get(t, "/alice/app"); !strings.Contains(body, "1</b> bookmark") { |
| 65 | t.Errorf("count not on the repo page:\n%s", body) | 65 | t.Errorf("count not on the repo page:\n%s", body) |
| 66 | } | 66 | } |
| 67 | 67 | ||
e2e/dashboard_test.go +2 −2
| @@ -61,7 +61,7 @@ func TestDashboard(t *testing.T) { | |||
| 61 | 61 | ||
| 62 | // Anonymous homepage: landing with explore link, repos on /explore. | 62 | // Anonymous homepage: landing with explore link, repos on /explore. |
| 63 | status, body := inst.get(t, "/") | 63 | status, body := inst.get(t, "/") |
| 64 | if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "CLI-first") { | 64 | if status != 200 || !strings.Contains(body, `href="/explore"`) || !strings.Contains(body, "A git forge you drive from the terminal") { |
| 65 | t.Fatalf("landing: %d", status) | 65 | t.Fatalf("landing: %d", status) |
| 66 | } | 66 | } |
| 67 | if strings.Contains(body, "alice/app") { | 67 | if strings.Contains(body, "alice/app") { |
| @@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) { | |||
| 93 | t.Fatalf("dashboard: %d", status) | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | } | 94 | } |
| 95 | for _, want := range []string{ | 95 | for _, want := range []string{ |
| 96 | ">Pinned</h2>", ">app<", // pinned group in the aside | 96 | `aria-labelledby="rail-pinned"`, `</span>app</a>`, // rail lists the pinned repo |
| 97 | "Waiting on your review", "Assigned to you", "Recent activity", | 97 | "Waiting on your review", "Assigned to you", "Recent activity", |
| 98 | "from bob", "alice/app!1", "todo one", "alice/app#1", | 98 | "from bob", "alice/app!1", "todo one", "alice/app#1", |
| 99 | `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, | 99 | `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, |
e2e/design_test.go +68 −7
| @@ -139,15 +139,42 @@ func TestReadmeRelativeLinks(t *testing.T) { | |||
| 139 | t.Errorf("explore row missing %q", want) | 139 | t.Errorf("explore row missing %q", want) |
| 140 | } | 140 | } |
| 141 | } | 141 | } |
| 142 | // The repo header renders the same on every tab of the repo. It sits | 142 | // The description and topics belong to the code tab; task tabs render |
| 143 | // above the tab bar, so anything that appears on one tab and not | 143 | // only the identity row and the tab bar. |
| 144 | // another moves the navigation between clicks of that navigation. | 144 | for _, p := range []string{"/alice/site/issues", "/alice/site/mrs", "/alice/site/releases"} { |
| 145 | for _, page := range []string{"", "/issues", "/mrs", "/releases"} { | 145 | if _, body := inst.get(t, p); strings.Contains(body, `class="chip topic"`) { |
| 146 | _, body := inst.get(t, "/alice/site"+page) | 146 | t.Errorf("%s: header still carries topics on a task tab", p) |
| 147 | if !strings.Contains(body, `href="/explore?q=web"`) { | ||
| 148 | t.Errorf("repo header topics missing on /alice/site%s", page) | ||
| 149 | } | 147 | } |
| 150 | } | 148 | } |
| 149 | if _, body := inst.get(t, "/alice/site"); !strings.Contains(body, `class="chip topic"`) { | ||
| 150 | t.Error("repo home lost its topics") | ||
| 151 | } | ||
| 152 | } | ||
| 153 | |||
| 154 | // TestLandingRoutes checks the landing page's copy and the two routes. | ||
| 155 | func TestLandingRoutes(t *testing.T) { | ||
| 156 | smtp := startFakeSMTP(t) | ||
| 157 | inst := startInstanceWith(t, fmt.Sprintf( | ||
| 158 | "[web]\nmode = \"accounts\"\n[registration]\nmode = \"open\"\n[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", | ||
| 159 | smtp.addr)) | ||
| 160 | _, body := inst.get(t, "/") | ||
| 161 | for _, want := range []string{ | ||
| 162 | "A git forge you drive from the terminal.", | ||
| 163 | `class="button primary" href="/explore">Explore repositories</a>`, | ||
| 164 | `class="button btn" href="/register">Create an account</a>`, | ||
| 165 | "web login</code>", | ||
| 166 | } { | ||
| 167 | if !strings.Contains(body, want) { | ||
| 168 | t.Errorf("landing lacks %q", want) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | if strings.Contains(body, "is the whole onboarding") { | ||
| 172 | t.Error("landing still calls repo create the whole onboarding") | ||
| 173 | } | ||
| 174 | _, reg := inst.get(t, "/register") | ||
| 175 | if !strings.Contains(reg, "Paste the contents of your public key file") || !strings.Contains(reg, "/krz/gitbay/wiki/SSH-keys") { | ||
| 176 | t.Error("register page lacks the key hint or the wiki link") | ||
| 177 | } | ||
| 151 | } | 178 | } |
| 152 | 179 | ||
| 153 | func TestWebInteractions(t *testing.T) { | 180 | func TestWebInteractions(t *testing.T) { |
| @@ -308,3 +335,37 @@ func TestAuthorNamesResolve(t *testing.T) { | |||
| 308 | t.Fatalf("tipbar name is not a link:\n%s", tree) | 335 | t.Fatalf("tipbar name is not a link:\n%s", tree) |
| 309 | } | 336 | } |
| 310 | } | 337 | } |
| 338 | |||
| 339 | // TestTreeSearchCodeAndClone: the overview links "Search code", not | ||
| 340 | // "Find file", and shows two labelled clone blocks after the file table. | ||
| 341 | func TestTreeSearchCodeAndClone(t *testing.T) { | ||
| 342 | inst := startInstance(t) | ||
| 343 | key := inst.newKey(t, "alice") | ||
| 344 | inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub") | ||
| 345 | inst.ssh(t, key, "", "repo", "create", "alice/app") | ||
| 346 | work := t.TempDir() | ||
| 347 | env := inst.gitEnv(key) | ||
| 348 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 349 | dir := filepath.Join(work, "w") | ||
| 350 | os.WriteFile(filepath.Join(dir, "README.md"), []byte("# app\n"), 0o644) | ||
| 351 | os.MkdirAll(filepath.Join(dir, "docs"), 0o755) | ||
| 352 | os.WriteFile(filepath.Join(dir, "docs", "README.md"), []byte("# docs\n"), 0o644) | ||
| 353 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 354 | mustGit(t, dir, env, "add", ".") | ||
| 355 | mustGit(t, dir, env, "commit", "-q", "-m", "init") | ||
| 356 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 357 | _, body := inst.get(t, "/alice/app") | ||
| 358 | if strings.Contains(body, ">Find file<") || !strings.Contains(body, ">Search code<") { | ||
| 359 | t.Error("overview still says Find file") | ||
| 360 | } | ||
| 361 | if i, j := strings.Index(body, `<table class="tree">`), strings.Index(body, `<div class="clone">`); i < 0 || j < i { | ||
| 362 | t.Error("clone block does not follow the file table") | ||
| 363 | } | ||
| 364 | if !strings.Contains(body, `<label>SSH</label>`) || !strings.Contains(body, `<label>HTTPS</label>`) { | ||
| 365 | t.Error("clone blocks are not labelled") | ||
| 366 | } | ||
| 367 | // Clone and about belong to the repository root, not a subdirectory. | ||
| 368 | if _, sub := inst.get(t, "/alice/app/tree/main/docs"); strings.Contains(sub, `<div class="facts">`) { | ||
| 369 | t.Error("subdirectory listing still shows the clone/about facts block") | ||
| 370 | } | ||
| 371 | } | ||
e2e/facts_test.go +3 −3
| @@ -46,9 +46,9 @@ func TestRepoFacts(t *testing.T) { | |||
| 46 | t.Fatalf("repo home: %d", status) | 46 | t.Fatalf("repo home: %d", status) |
| 47 | } | 47 | } |
| 48 | for _, want := range []string{ | 48 | for _, want := range []string{ |
| 49 | "<strong>2</strong> commit", // both commits counted | 49 | "<b>2</b> commit", // both commits counted |
| 50 | "<strong>1</strong> branch", | 50 | "<b>1</b> branch", |
| 51 | "<strong>1</strong> tag", | 51 | "<b>1</b> tag", |
| 52 | "0BSD", // license detected and surfaced | 52 | "0BSD", // license detected and surfaced |
| 53 | "Go", // language census | 53 | "Go", // language census |
| 54 | "Shell", // and it is not single-language | 54 | "Shell", // and it is not single-language |
e2e/mirror_test.go +2 −2
| @@ -119,10 +119,10 @@ func TestMirrors(t *testing.T) { | |||
| 119 | if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 { | 119 | if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 { |
| 120 | t.Fatalf("login: %d", status) | 120 | t.Fatalf("login: %d", status) |
| 121 | } | 121 | } |
| 122 | if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "Mirrors to <a href=\""+remoteURL) { | 122 | if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "mirrors to <a href=\""+remoteURL) { |
| 123 | t.Fatalf("admin repo page missing mirror line:\n%s", body) | 123 | t.Fatalf("admin repo page missing mirror line:\n%s", body) |
| 124 | } | 124 | } |
| 125 | if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "Mirrors to") { | 125 | if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "mirrors to") { |
| 126 | t.Fatalf("anonymous repo page shows mirror line:\n%s", body) | 126 | t.Fatalf("anonymous repo page shows mirror line:\n%s", body) |
| 127 | } | 127 | } |
| 128 | 128 | ||
e2e/mrweb_test.go +38 −3
| @@ -145,10 +145,10 @@ func TestMRWebReviewLoop(t *testing.T) { | |||
| 145 | t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0]) | 145 | t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0]) |
| 146 | } | 146 | } |
| 147 | _, body = browserGet(t, alice, mrURL) | 147 | _, body = browserGet(t, alice, mrURL) |
| 148 | if strings.Contains(body, "wants to merge") { | 148 | if strings.Contains(body, "opened by") { |
| 149 | t.Fatalf("merged MR still wants to merge:\n%s", body) | 149 | t.Fatalf("merged MR still says it was opened:\n%s", body) |
| 150 | } | 150 | } |
| 151 | if !strings.Contains(body, ">alice</a> merged") { | 151 | if !strings.Contains(body, "merged by <a href=\"/alice\">alice</a>") { |
| 152 | t.Fatalf("merged MR does not name the merger:\n%s", body) | 152 | t.Fatalf("merged MR does not name the merger:\n%s", body) |
| 153 | } | 153 | } |
| 154 | mustGit(t, dir, env, "pull", "-q", "origin", "main") | 154 | mustGit(t, dir, env, "pull", "-q", "origin", "main") |
| @@ -353,3 +353,38 @@ func (i *instance) mrThreads(t *testing.T, key, repo, n string) []mrThread { | |||
| 353 | } | 353 | } |
| 354 | return env.Data | 354 | return env.Data |
| 355 | } | 355 | } |
| 356 | |||
| 357 | // TestMRDiffEmptyExplained: a merge request whose head was fast-forwarded | ||
| 358 | // into the target outside the request shows why its diff is empty. | ||
| 359 | func TestMRDiffEmptyExplained(t *testing.T) { | ||
| 360 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 361 | key := inst.newKey(t, "alice") | ||
| 362 | inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--email", "alice@example.test", "--verified") | ||
| 363 | if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 { | ||
| 364 | t.Fatalf("repo create: %s", errOut) | ||
| 365 | } | ||
| 366 | env := inst.gitEnv(key) | ||
| 367 | work := t.TempDir() | ||
| 368 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | ||
| 369 | dir := filepath.Join(work, "w") | ||
| 370 | os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644) | ||
| 371 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 372 | mustGit(t, dir, env, "add", ".") | ||
| 373 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 374 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 375 | |||
| 376 | mustGit(t, dir, env, "checkout", "-q", "-b", "feature") | ||
| 377 | os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644) | ||
| 378 | mustGit(t, dir, env, "add", ".") | ||
| 379 | mustGit(t, dir, env, "commit", "-q", "-m", "one") | ||
| 380 | mustGit(t, dir, env, "push", "-q", "origin", "feature") | ||
| 381 | if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app", "--source", "feature", "--target", "main", "--title", "one"); code != 0 { | ||
| 382 | t.Fatal(errOut) | ||
| 383 | } | ||
| 384 | mustGit(t, dir, env, "push", "-q", "origin", "feature:main") | ||
| 385 | _, body := inst.get(t, "/alice/app/mrs/1?view=diff") | ||
| 386 | if !strings.Contains(body, "No changes between the source and target.") || | ||
| 387 | !strings.Contains(body, "already merged or fast-forwarded into <code>main</code>") { | ||
| 388 | t.Fatalf("empty diff unexplained:\n%s", body) | ||
| 389 | } | ||
| 390 | } | ||
e2e/settingsweb_test.go +15 −6
| @@ -39,15 +39,27 @@ func TestRepoSettingsWeb(t *testing.T) { | |||
| 39 | return body | 39 | return body |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | if body := post(url.Values{"field": {"description"}, "description": {"a thing"}}); !strings.Contains(body, `class="notice" role="status">Saved the description.`) { | ||
| 43 | t.Fatalf("no success flash after saving the description:\n%s", body) | ||
| 44 | } | ||
| 45 | if body := post(url.Values{"field": {"topics"}, "topics": {"cli, forge"}}); !strings.Contains(body, `value="cli, forge"`) { | ||
| 46 | t.Fatalf("topics field is not prefilled after save:\n%s", body) | ||
| 47 | } | ||
| 48 | if body := post(url.Values{"field": {"topics"}, "topics": {"forge"}}); strings.Contains(body, `>cli<`) || !strings.Contains(body, `value="forge"`) { | ||
| 49 | t.Fatalf("removing a topic through the field failed:\n%s", body) | ||
| 50 | } | ||
| 51 | if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) || !strings.Contains(body, `value="javascript:alert(1)"`) { | ||
| 52 | t.Fatalf("error does not keep the submitted website:\n%s", body) | ||
| 53 | } | ||
| 54 | |||
| 42 | post(url.Values{"field": {"description"}, "description": {"a fine tool"}}) | 55 | post(url.Values{"field": {"description"}, "description": {"a fine tool"}}) |
| 43 | post(url.Values{"field": {"website"}, "website": {"https://tool.example"}}) | 56 | post(url.Values{"field": {"website"}, "website": {"https://tool.example"}}) |
| 44 | post(url.Values{"field": {"topics"}, "add": {"cli forge"}}) | ||
| 45 | post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}}) | 57 | post(url.Values{"field": {"require-checks"}, "require-checks": {"on"}}) |
| 46 | post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}}) | 58 | post(url.Values{"field": {"require-approvals"}, "approvals": {"2"}}) |
| 47 | post(url.Values{"field": {"protect"}, "branch": {"main"}}) | 59 | post(url.Values{"field": {"protect"}, "branch": {"main"}}) |
| 48 | 60 | ||
| 49 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json") | 61 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/app", "--json") |
| 50 | for _, want := range []string{"a fine tool", "https://tool.example", `"cli"`, `"forge"`} { | 62 | for _, want := range []string{"a fine tool", "https://tool.example", `"forge"`} { |
| 51 | if !strings.Contains(out, want) { | 63 | if !strings.Contains(out, want) { |
| 52 | t.Fatalf("repo show missing %q:\n%s", want, out) | 64 | t.Fatalf("repo show missing %q:\n%s", want, out) |
| 53 | } | 65 | } |
| @@ -83,12 +95,9 @@ func TestRepoSettingsWeb(t *testing.T) { | |||
| 83 | t.Fatalf("still archived:\n%s", out) | 95 | t.Fatalf("still archived:\n%s", out) |
| 84 | } | 96 | } |
| 85 | 97 | ||
| 86 | // Unprotecting works, and a refusal surfaces the command's message. | 98 | // Unprotecting works. |
| 87 | post(url.Values{"field": {"unprotect"}, "branch": {"main"}}) | 99 | post(url.Values{"field": {"unprotect"}, "branch": {"main"}}) |
| 88 | if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) { | 100 | if out, _, _ := inst.ssh(t, aliceKey, "", "repo", "settings", "show", "alice/app", "--json"); strings.Contains(out, `"protected_branches"`) { |
| 89 | t.Fatalf("branch still protected:\n%s", out) | 101 | t.Fatalf("branch still protected:\n%s", out) |
| 90 | } | 102 | } |
| 91 | if body := post(url.Values{"field": {"website"}, "website": {"javascript:alert(1)"}}); !strings.Contains(body, `class="error"`) { | ||
| 92 | t.Fatalf("bad website accepted:\n%s", body) | ||
| 93 | } | ||
| 94 | } | 103 | } |
e2e/web_test.go +1 −1
| @@ -86,7 +86,7 @@ func TestWebUI(t *testing.T) { | |||
| 86 | t.Fatalf("repo home: %d\n%s", status, body) | 86 | t.Fatalf("repo home: %d\n%s", status, body) |
| 87 | } | 87 | } |
| 88 | // Both clone URLs: SSH for anyone with a key, HTTPS for reading. | 88 | // Both clone URLs: SSH for anyone with a key, HTTPS for reading. |
| 89 | if !strings.Contains(body, "git clone ssh://git@gitbay.test:") || !strings.Contains(body, "/alice/site.git</code> · <code>git clone https://gitbay.test/alice/site.git</code>") { | 89 | if !strings.Contains(body, "git clone ssh://git@gitbay.test:") || !strings.Contains(body, "/alice/site.git</code></pre>") || !strings.Contains(body, "<code>git clone https://gitbay.test/alice/site.git</code>") { |
| 90 | t.Fatalf("clone URLs missing:\n%s", body) | 90 | t.Fatalf("clone URLs missing:\n%s", body) |
| 91 | } | 91 | } |
| 92 | if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") { | 92 | if !strings.Contains(body, "<h2 id=\"hello-site\">hello site</h2>") || !strings.Contains(body, "<em>markdown</em>") { |
internal/httpd/mrpage_test.go +23 −9
| @@ -34,6 +34,8 @@ type mrPageData struct { | |||
| 34 | DetachedThreads []diffThread | 34 | DetachedThreads []diffThread |
| 35 | Gates *control.GatesOut | 35 | Gates *control.GatesOut |
| 36 | SourceGone bool | 36 | SourceGone bool |
| 37 | HeadMerged bool | ||
| 38 | Base string | ||
| 37 | } | 39 | } |
| 38 | 40 | ||
| 39 | func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { | 41 | func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string { |
| @@ -60,12 +62,13 @@ func testMR(state string) store.MR { | |||
| 60 | HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"} | 62 | HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"} |
| 61 | } | 63 | } |
| 62 | 64 | ||
| 63 | // The header states what happened to the MR. "wants to merge" is only true | 65 | // The header states what happened to the MR: who opened, merged, or closed |
| 64 | // while it is still open. | 66 | // it, and when. A merge or close with no stamp (imports, pre-0029 merges) |
| 67 | // names the state without claiming a byline it cannot back up. | ||
| 65 | func TestMRHeaderByState(t *testing.T) { | 68 | func TestMRHeaderByState(t *testing.T) { |
| 66 | open := renderMR(t, testMR("open"), nil, nil) | 69 | open := renderMR(t, testMR("open"), nil, nil) |
| 67 | if !strings.Contains(open, "wants to merge") { | 70 | if !strings.Contains(open, "opened by") { |
| 68 | t.Errorf("open MR does not say wants to merge:\n%s", open) | 71 | t.Errorf("open MR does not say who opened it:\n%s", open) |
| 69 | } | 72 | } |
| 70 | 73 | ||
| 71 | merged := testMR("merged") | 74 | merged := testMR("merged") |
| @@ -76,23 +79,34 @@ func TestMRHeaderByState(t *testing.T) { | |||
| 76 | t.Errorf("merged header missing %q:\n%s", want, out) | 79 | t.Errorf("merged header missing %q:\n%s", want, out) |
| 77 | } | 80 | } |
| 78 | } | 81 | } |
| 79 | if strings.Contains(out, "wants to merge") { | 82 | if strings.Contains(out, "opened by") { |
| 80 | t.Errorf("merged MR still wants to merge:\n%s", out) | 83 | t.Errorf("merged MR still says it was opened:\n%s", out) |
| 81 | } | 84 | } |
| 82 | 85 | ||
| 83 | closed := testMR("closed") | 86 | closed := testMR("closed") |
| 84 | closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc" | 87 | closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc" |
| 85 | out = renderMR(t, closed, nil, nil) | 88 | out = renderMR(t, closed, nil, nil) |
| 86 | if !strings.Contains(out, "without merging") || strings.Contains(out, "wants to merge") { | 89 | if !strings.Contains(out, "without merging") || strings.Contains(out, "opened by") { |
| 87 | t.Errorf("closed header:\n%s", out) | 90 | t.Errorf("closed header:\n%s", out) |
| 88 | } | 91 | } |
| 89 | 92 | ||
| 90 | // Imports and pre-0029 merges carry no stamp; the wording drops the | 93 | // Imports and pre-0029 merges carry no stamp; the wording drops the |
| 91 | // claim rather than inventing a time. | 94 | // byline claim rather than naming nobody or inventing a time. |
| 92 | out = renderMR(t, testMR("merged"), nil, nil) | 95 | out = renderMR(t, testMR("merged"), nil, nil) |
| 93 | if strings.Contains(out, "wants to merge") || strings.Contains(out, " on 20") { | 96 | if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "merged by") { |
| 94 | t.Errorf("unstamped merged header:\n%s", out) | 97 | t.Errorf("unstamped merged header:\n%s", out) |
| 95 | } | 98 | } |
| 99 | if !strings.Contains(out, "merged") { | ||
| 100 | t.Errorf("unstamped merged header drops the state entirely:\n%s", out) | ||
| 101 | } | ||
| 102 | |||
| 103 | out = renderMR(t, testMR("closed"), nil, nil) | ||
| 104 | if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "closed without merging by") { | ||
| 105 | t.Errorf("unstamped closed header:\n%s", out) | ||
| 106 | } | ||
| 107 | if !strings.Contains(out, "without merging") { | ||
| 108 | t.Errorf("unstamped closed header drops the state entirely:\n%s", out) | ||
| 109 | } | ||
| 96 | } | 110 | } |
| 97 | 111 | ||
| 98 | // Approvals and checks carry their times in the aside, so reading the MR | 112 | // Approvals and checks carry their times in the aside, so reading the MR |
internal/httpd/settings.go +109 −9
| @@ -3,6 +3,8 @@ package httpd | |||
| 3 | import ( | 3 | import ( |
| 4 | "fmt" | 4 | "fmt" |
| 5 | "net/http" | 5 | "net/http" |
| 6 | "net/url" | ||
| 7 | "slices" | ||
| 6 | "strings" | 8 | "strings" |
| 7 | 9 | ||
| 8 | "gitbay.org/gitbay/internal/control" | 10 | "gitbay.org/gitbay/internal/control" |
| @@ -23,9 +25,18 @@ type settingsPage struct { | |||
| 23 | Deps control.DepsOut | 25 | Deps control.DepsOut |
| 24 | Runners []store.RepoRunner | 26 | Runners []store.RepoRunner |
| 25 | Notice string | 27 | Notice string |
| 28 | Saved bool | ||
| 29 | Submitted map[string]string | ||
| 26 | } | 30 | } |
| 27 | 31 | ||
| 28 | func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { | 32 | func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| 33 | s.settingsFormWith(w, r, u, s.takeFlash(w, r), nil) | ||
| 34 | } | ||
| 35 | |||
| 36 | // settingsFormWith renders the page with the given notice. submitted is | ||
| 37 | // nil on a plain GET; on a failed POST it carries the values the visitor | ||
| 38 | // typed, so a rejected value is not silently dropped. | ||
| 39 | func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u store.User, notice string, submitted url.Values) { | ||
| 29 | repo, ok := s.repoForUser(w, r, u, policyCanAdmin) | 40 | repo, ok := s.repoForUser(w, r, u, policyCanAdmin) |
| 30 | if !ok { | 41 | if !ok { |
| 31 | return | 42 | return |
| @@ -44,11 +55,21 @@ func (s *Server) settingsForm(w http.ResponseWriter, r *http.Request, u store.Us | |||
| 44 | s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) | 55 | s.runControlInto(u, []string{"repo", "deps", "status", repo.Path()}, &deps) |
| 45 | var runners []store.RepoRunner | 56 | var runners []store.RepoRunner |
| 46 | s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners) | 57 | s.runControlInto(u, []string{"repo", "runner", "list", repo.Path()}, &runners) |
| 58 | var subm map[string]string | ||
| 59 | if submitted != nil { | ||
| 60 | subm = map[string]string{ | ||
| 61 | "description": submitted.Get("description"), | ||
| 62 | "website": submitted.Get("website"), | ||
| 63 | "topics": submitted.Get("topics"), | ||
| 64 | } | ||
| 65 | } | ||
| 47 | s.render(w, "settings.html", settingsPage{ | 66 | s.render(w, "settings.html", settingsPage{ |
| 48 | repoPage: p, Topics: topics, Branches: branches, | 67 | repoPage: p, Topics: topics, Branches: branches, |
| 49 | DepsEnabled: deps.Enabled, Deps: deps, | 68 | DepsEnabled: deps.Enabled, Deps: deps, |
| 50 | Runners: runners, | 69 | Runners: runners, |
| 51 | Notice: s.takeFlash(w, r), | 70 | Notice: notice, |
| 71 | Saved: strings.HasPrefix(notice, "Saved "), | ||
| 72 | Submitted: subm, | ||
| 52 | }) | 73 | }) |
| 53 | } | 74 | } |
| 54 | 75 | ||
| @@ -63,9 +84,10 @@ func (s *Server) settingsRedirect(w http.ResponseWriter, r *http.Request, msg st | |||
| 63 | func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 84 | func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 64 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | 85 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 65 | v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } | 86 | v := func(k string) string { return strings.TrimSpace(r.FormValue(k)) } |
| 87 | field := r.FormValue("field") | ||
| 66 | 88 | ||
| 67 | var argv []string | 89 | var argv []string |
| 68 | switch r.FormValue("field") { | 90 | switch field { |
| 69 | case "description": | 91 | case "description": |
| 70 | argv = []string{"repo", "settings", "description", repo, v("description")} | 92 | argv = []string{"repo", "settings", "description", repo, v("description")} |
| 71 | case "website": | 93 | case "website": |
| @@ -109,12 +131,45 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | |||
| 109 | } | 131 | } |
| 110 | argv = []string{"repo", verb, repo} | 132 | argv = []string{"repo", verb, repo} |
| 111 | case "topics": | 133 | case "topics": |
| 112 | if add := strings.Fields(v("add")); len(add) > 0 { | 134 | row, err := s.st.RepoByPath(repo) |
| 135 | if err != nil { | ||
| 136 | http.NotFound(w, r) | ||
| 137 | return | ||
| 138 | } | ||
| 139 | want := map[string]bool{} | ||
| 140 | var order []string | ||
| 141 | for _, t := range strings.Split(v("topics"), ",") { | ||
| 142 | if t = strings.ToLower(strings.TrimSpace(t)); t != "" && !want[t] { | ||
| 143 | want[t] = true | ||
| 144 | order = append(order, t) | ||
| 145 | } | ||
| 146 | } | ||
| 147 | have, err := s.st.ListTopics(row.ID) | ||
| 148 | if err != nil { | ||
| 149 | s.settingsRedirect(w, r, err.Error()) | ||
| 150 | return | ||
| 151 | } | ||
| 152 | var add, remove []string | ||
| 153 | for _, t := range order { | ||
| 154 | if !slices.Contains(have, t) { | ||
| 155 | add = append(add, t) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | for _, t := range have { | ||
| 159 | if !want[t] { | ||
| 160 | remove = append(remove, t) | ||
| 161 | } | ||
| 162 | } | ||
| 163 | if len(remove) > 0 { | ||
| 164 | if _, msg, ok := s.runControl(u, append([]string{"repo", "topics", "remove", repo}, remove...)); !ok { | ||
| 165 | s.settingsFormWith(w, r, u, msg, r.Form) | ||
| 166 | return | ||
| 167 | } | ||
| 168 | } | ||
| 169 | if len(add) > 0 { | ||
| 113 | argv = append([]string{"repo", "topics", "add", repo}, add...) | 170 | argv = append([]string{"repo", "topics", "add", repo}, add...) |
| 114 | } else if rm := strings.Fields(v("remove")); len(rm) > 0 { | ||
| 115 | argv = append([]string{"repo", "topics", "remove", repo}, rm...) | ||
| 116 | } else { | 171 | } else { |
| 117 | s.settingsRedirect(w, r, "name at least one topic") | 172 | s.settingsRedirect(w, r, "Saved the topics.") |
| 118 | return | 173 | return |
| 119 | } | 174 | } |
| 120 | case "runner-add": | 175 | case "runner-add": |
| @@ -138,9 +193,54 @@ func (s *Server) settingsSubmit(w http.ResponseWriter, r *http.Request, u store. | |||
| 138 | 193 | ||
| 139 | _, msg, ok := s.runControl(u, argv) | 194 | _, msg, ok := s.runControl(u, argv) |
| 140 | if ok { | 195 | if ok { |
| 141 | msg = "" | 196 | s.settingsRedirect(w, r, "Saved the "+fieldLabel(field)+".") |
| 197 | return | ||
| 198 | } | ||
| 199 | s.settingsFormWith(w, r, u, msg, r.Form) | ||
| 200 | } | ||
| 201 | |||
| 202 | // fieldLabel names a settings field for the saved flash and, on | ||
| 203 | // rejection, the error notice — lower case, matching the label beside | ||
| 204 | // its control. | ||
| 205 | func fieldLabel(field string) string { | ||
| 206 | switch field { | ||
| 207 | case "description": | ||
| 208 | return "description" | ||
| 209 | case "website": | ||
| 210 | return "website" | ||
| 211 | case "visibility": | ||
| 212 | return "visibility" | ||
| 213 | case "default-branch": | ||
| 214 | return "default branch" | ||
| 215 | case "git-daemon": | ||
| 216 | return "git:// serving" | ||
| 217 | case "require-checks": | ||
| 218 | return "required checks" | ||
| 219 | case "require-approvals": | ||
| 220 | return "approvals" | ||
| 221 | case "require-resolved": | ||
| 222 | return "review threads" | ||
| 223 | case "require-codeowners": | ||
| 224 | return "CODEOWNERS" | ||
| 225 | case "require-mr": | ||
| 226 | return "require-MR" | ||
| 227 | case "require-signed": | ||
| 228 | return "signed commits" | ||
| 229 | case "protect", "unprotect": | ||
| 230 | return "protected branch" | ||
| 231 | case "protect-tag", "unprotect-tag": | ||
| 232 | return "protected tag" | ||
| 233 | case "deps": | ||
| 234 | return "dependency scanning" | ||
| 235 | case "archive": | ||
| 236 | return "archive" | ||
| 237 | case "topics": | ||
| 238 | return "topics" | ||
| 239 | case "runner-add", "runner-remove": | ||
| 240 | return "runner" | ||
| 241 | default: | ||
| 242 | return field | ||
| 142 | } | 243 | } |
| 143 | s.settingsRedirect(w, r, msg) | ||
| 144 | } | 244 | } |
| 145 | 245 | ||
| 146 | // onOff normalises a checkbox to the on|off the commands take. | 246 | // onOff normalises a checkbox to the on|off the commands take. |
internal/httpd/web.go +24 −5
| @@ -161,13 +161,20 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | |||
| 161 | s.cfg.Server.SiteURL, "https://"), "http://"), "/") | 161 | s.cfg.Server.SiteURL, "https://"), "http://"), "/") |
| 162 | s.render(w, "landing.html", struct { | 162 | s.render(w, "landing.html", struct { |
| 163 | basePage | 163 | basePage |
| 164 | Host string | 164 | Host string |
| 165 | Accounts bool | 165 | Accounts bool |
| 166 | Signup bool | 166 | Signup bool |
| 167 | Picture bool | ||
| 168 | EmailLogin bool | ||
| 167 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts", | 169 | }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts", |
| 168 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) | 170 | s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed", |
| 171 | landingPicture, s.emailLoginEnabled()}) | ||
| 169 | } | 172 | } |
| 170 | 173 | ||
| 174 | // landingPicture says whether the landing page's screenshot images exist to | ||
| 175 | // show. Task 15 replaces this with a check of the embedded images. | ||
| 176 | var landingPicture = false | ||
| 177 | |||
| 171 | func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { | 178 | func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) { |
| 172 | pinned, _ := s.st.PinnedRepos(viewer.ID) | 179 | pinned, _ := s.st.PinnedRepos(viewer.ID) |
| 173 | var visible []store.Repo | 180 | var visible []store.Repo |
| @@ -1861,6 +1868,16 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1861 | files, diffTruncated = parseDiff(patch), truncated | 1868 | files, diffTruncated = parseDiff(patch), truncated |
| 1862 | } | 1869 | } |
| 1863 | } | 1870 | } |
| 1871 | // The head is already reachable from the target, so the diff is empty | ||
| 1872 | // by construction rather than because nothing changed. | ||
| 1873 | headMerged := false | ||
| 1874 | if len(files) == 0 && m.HeadSHA != "" { | ||
| 1875 | if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil { | ||
| 1876 | if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil { | ||
| 1877 | headMerged = ok | ||
| 1878 | } | ||
| 1879 | } | ||
| 1880 | } | ||
| 1864 | md := s.ugcFor(r, p.Repo) | 1881 | md := s.ugcFor(r, p.Repo) |
| 1865 | canWrite := s.canWriteRepo(r, p.Repo) | 1882 | canWrite := s.canWriteRepo(r, p.Repo) |
| 1866 | var detachedThreads []diffThread | 1883 | var detachedThreads []diffThread |
| @@ -1956,10 +1973,12 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 1956 | Stacked []store.MR | 1973 | Stacked []store.MR |
| 1957 | Gates *control.GatesOut | 1974 | Gates *control.GatesOut |
| 1958 | SourceGone bool | 1975 | SourceGone bool |
| 1976 | HeadMerged bool | ||
| 1977 | Base string | ||
| 1959 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), | 1978 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), |
| 1960 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), | 1979 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), |
| 1961 | canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates, | 1980 | canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates, |
| 1962 | sourceGone(p, m)}) | 1981 | sourceGone(p, m), headMerged, base}) |
| 1963 | } | 1982 | } |
| 1964 | 1983 | ||
| 1965 | // sourceGone reports whether an MR's source branch no longer exists: the | 1984 | // sourceGone reports whether an MR's source branch no longer exists: the |
internal/web/static/style.css +18 −11
| @@ -417,7 +417,6 @@ footer a:hover { color: var(--link); } | |||
| 417 | font-size: var(--fs-2); | 417 | font-size: var(--fs-2); |
| 418 | display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; | 418 | display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; |
| 419 | } | 419 | } |
| 420 | .repohead .topics { margin: var(--sp-2) 0 0; } | ||
| 421 | .repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); } | 420 | .repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); } |
| 422 | .toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); } | 421 | .toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); } |
| 423 | 422 | ||
| @@ -788,12 +787,17 @@ td.name a:hover { color: var(--link); } | |||
| 788 | table.tree td.name { width: 25%; white-space: nowrap; } | 787 | table.tree td.name { width: 25%; white-space: nowrap; } |
| 789 | table.tree td.name.dir a { color: var(--link); } | 788 | table.tree td.name.dir a { color: var(--link); } |
| 790 | table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; } | 789 | table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; } |
| 790 | table.tree td.lastcommit { | ||
| 791 | max-width: 0; | ||
| 792 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | ||
| 793 | } | ||
| 791 | table.tree td.lastcommit a { | 794 | table.tree td.lastcommit a { |
| 792 | color: var(--muted); | 795 | color: var(--muted); |
| 793 | display: block; | 796 | display: block; |
| 794 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | 797 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| 795 | } | 798 | } |
| 796 | table.tree td.lastcommit a:hover { color: var(--link); } | 799 | table.tree td.lastcommit a:hover { color: var(--link); } |
| 800 | table.tree th.age, table.tree td.age { width: 20%; } | ||
| 797 | td.age, th.age { text-align: right; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; } | 801 | td.age, th.age { text-align: right; white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; } |
| 798 | td.size, td.mode { color: var(--muted); white-space: nowrap; } | 802 | td.size, td.mode { color: var(--muted); white-space: nowrap; } |
| 799 | table.tree th.size, table.tree td.size { text-align: right; } | 803 | table.tree th.size, table.tree td.size { text-align: right; } |
| @@ -810,15 +814,18 @@ table.assets td.name { width: 100%; font-family: var(--mono); font-size: var(--f | |||
| 810 | table.assets { margin-top: var(--sp-2); } | 814 | table.assets { margin-top: var(--sp-2); } |
| 811 | 815 | ||
| 812 | /* the tip commit is the first row of the file table */ | 816 | /* the tip commit is the first row of the file table */ |
| 813 | .tipbar { | 817 | tr.tipbar td { |
| 818 | background: var(--surface); | ||
| 819 | padding: var(--sp-3) var(--sp-4); | ||
| 820 | border-bottom: 1px solid var(--line); | ||
| 821 | max-width: 0; | ||
| 822 | } | ||
| 823 | .tipbar .tip { | ||
| 814 | display: flex; | 824 | display: flex; |
| 815 | align-items: center; | 825 | align-items: center; |
| 816 | gap: var(--sp-3); | 826 | gap: var(--sp-2); |
| 817 | padding: var(--sp-2) var(--sp-4); | 827 | flex-wrap: wrap; |
| 818 | background: var(--surface); | 828 | min-width: 0; |
| 819 | border: 1px solid var(--line); | ||
| 820 | border-bottom: 0; | ||
| 821 | border-radius: var(--r-card) var(--r-card) 0 0; | ||
| 822 | font-size: var(--fs-2); | 829 | font-size: var(--fs-2); |
| 823 | } | 830 | } |
| 824 | .tipbar .who { font-weight: 600; } | 831 | .tipbar .who { font-weight: 600; } |
| @@ -826,8 +833,6 @@ table.assets { margin-top: var(--sp-2); } | |||
| 826 | .tipbar .subject:hover { color: var(--link); } | 833 | .tipbar .subject:hover { color: var(--link); } |
| 827 | .tipbar .spacer { flex: 1; } | 834 | .tipbar .spacer { flex: 1; } |
| 828 | .tipbar .age { color: var(--muted); white-space: nowrap; } | 835 | .tipbar .age { color: var(--muted); white-space: nowrap; } |
| 829 | .tipbar + table.tree, | ||
| 830 | .tipbar + .tablewrap table.tree { border-radius: 0 0 var(--r-card) var(--r-card); } | ||
| 831 | 836 | ||
| 832 | .readme { margin-top: var(--sp-5); } | 837 | .readme { margin-top: var(--sp-5); } |
| 833 | .readme .cardbody { max-width: 78ch; } | 838 | .readme .cardbody { max-width: 78ch; } |
| @@ -1263,6 +1268,7 @@ a.memberchip .role { color: var(--muted); } | |||
| 1263 | .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); } | 1268 | .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); } |
| 1264 | .pathbar .spacer { flex: 1; } | 1269 | .pathbar .spacer { flex: 1; } |
| 1265 | .pathbar .actions { display: flex; gap: var(--sp-2); } | 1270 | .pathbar .actions { display: flex; gap: var(--sp-2); } |
| 1271 | .pathbar .acts { display: flex; gap: var(--sp-2); } | ||
| 1266 | .crumbs { color: var(--muted); } | 1272 | .crumbs { color: var(--muted); } |
| 1267 | .crumbs a { color: var(--link); } | 1273 | .crumbs a { color: var(--link); } |
| 1268 | .crumbs strong { color: var(--fg); } | 1274 | .crumbs strong { color: var(--fg); } |
| @@ -1324,6 +1330,7 @@ details.refmenu .refdrop a.allrefs { | |||
| 1324 | color: var(--muted); | 1330 | color: var(--muted); |
| 1325 | } | 1331 | } |
| 1326 | .clone { margin: 0 0 var(--sp-3); } | 1332 | .clone { margin: 0 0 var(--sp-3); } |
| 1333 | .facts .about { min-width: 0; } | ||
| 1327 | .clone label { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; } | 1334 | .clone label { font-size: var(--fs-1); color: var(--muted); font-weight: 500; margin-bottom: 2px; } |
| 1328 | .clone pre { margin-bottom: var(--sp-3); user-select: all; } | 1335 | .clone pre { margin-bottom: var(--sp-3); user-select: all; } |
| 1329 | .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); } |
| @@ -1369,6 +1376,7 @@ details.refmenu .refdrop a.allrefs { | |||
| 1369 | readable on the templates that do not set one yet */ | 1376 | readable on the templates that do not set one yet */ |
| 1370 | .landing { max-width: 48rem; padding: var(--sp-6) 0; } | 1377 | .landing { max-width: 48rem; padding: var(--sp-6) 0; } |
| 1371 | .landing h1 { font-size: var(--fs-6); display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); } | 1378 | .landing h1 { font-size: var(--fs-6); display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-3); } |
| 1379 | .landing h1 .mark { width: 32px; height: 32px; } | ||
| 1372 | .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); } | 1380 | .lede { font-size: var(--fs-4); color: var(--muted); line-height: 1.5; max-width: 40rem; margin: 0 0 var(--sp-5); } |
| 1373 | pre.quickstart { margin: 0 0 var(--sp-4); } | 1381 | pre.quickstart { margin: 0 0 var(--sp-4); } |
| 1374 | .shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; } | 1382 | .shot { border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; margin: var(--sp-5) 0; } |
| @@ -1377,7 +1385,6 @@ pre.quickstart { margin: 0 0 var(--sp-4); } | |||
| 1377 | .facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } | 1385 | .facets h2 { font-size: var(--fs-3); margin: 0 0 var(--sp-1); } |
| 1378 | .facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; } | 1386 | .facets p { color: var(--muted); font-size: var(--fs-2); margin: 0; } |
| 1379 | .routes { display: flex; gap: var(--sp-3); align-items: center; margin: var(--sp-5) 0 var(--sp-3); } | 1387 | .routes { display: flex; gap: var(--sp-3); align-items: center; margin: var(--sp-5) 0 var(--sp-3); } |
| 1380 | .explorelink { font-weight: 500; } | ||
| 1381 | 1388 | ||
| 1382 | /* ---- profile ---- */ | 1389 | /* ---- profile ---- */ |
| 1383 | .profilehead { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-4); margin-bottom: var(--sp-4); } | 1390 | .profilehead { border-bottom: 1px solid var(--faint); padding-bottom: var(--sp-4); margin-bottom: var(--sp-4); } |
internal/web/templates/404.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}not found · {{.Site}}{{end}} | 2 | {{define "title"}}not found · {{.Site}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <div class="notfound"> | 4 | <div class="notfound"> |
internal/web/templates/account.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}account settings{{end}} | 2 | {{define "title"}}account settings{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>Account settings</h1> | 4 | <h1>Account settings</h1> |
internal/web/templates/admin.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}admin · {{.Site}}{{end}} | 2 | {{define "title"}}admin · {{.Site}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>Admin</h1> | 4 | <h1>Admin</h1> |
internal/web/templates/blame.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1 class="vh">Blame: {{.Path}}</h1> | 4 | <h1 class="vh">Blame: {{.Path}}</h1> |
internal/web/templates/blob.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1 class="vh">{{.Path}}</h1> | 4 | <h1 class="vh">{{.Path}}</h1> |
internal/web/templates/build.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
internal/web/templates/builds.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>Builds</h1> | 4 | <h1>Builds</h1> |
internal/web/templates/commit.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <div class="commithead"> | 4 | <div class="commithead"> |
internal/web/templates/compare.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}compare {{.Base}}...{{.Head}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1> | 4 | <h1>Compare <code>{{.Base}}</code> … <code>{{.Head}}</code></h1> |
internal/web/templates/dashboard.html −5
| @@ -31,11 +31,6 @@ | |||
| 31 | </div> | 31 | </div> |
| 32 | 32 | ||
| 33 | <aside class="aside"> | 33 | <aside class="aside"> |
| 34 | <div class="grp"> | ||
| 35 | <h2>Pinned</h2> | ||
| 36 | {{range .Pinned}}<p class="row"><a href="/{{.OwnerName}}/{{.Name}}"><span class="owner">{{.OwnerName}}/</span>{{.Name}}</a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}</p> | ||
| 37 | {{else}}<p class="none">Pin a repository to keep it here</p>{{end}} | ||
| 38 | </div> | ||
| 39 | <div class="grp"> | 34 | <div class="grp"> |
| 40 | <h2>Recent activity</h2> | 35 | <h2>Recent activity</h2> |
| 41 | {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p> | 36 | {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p> |
internal/web/templates/edit.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> | 4 | <h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> |
internal/web/templates/globalsearch.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}search · {{.Site}}{{end}} | 2 | {{define "title"}}search · {{.Site}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <div class="listhead"> | 4 | <div class="listhead"> |
internal/web/templates/issuenew.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>New issue</h1> | 4 | <h1>New issue</h1> |
internal/web/templates/landing.html +13 −10
| @@ -1,18 +1,21 @@ | |||
| 1 | {{define "title"}}{{.Site}}{{end}} | 1 | {{define "title"}}{{.Site}}{{end}} |
| 2 | {{define "width"}}bounded{{end}} | ||
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <div class="landing"> | 4 | <div class="landing"> |
| 4 | <h1>{{.Site}}</h1> | 5 | <h1>{{template "mark"}}{{.Site}}</h1> |
| 5 | <p class="lede">A CLI-first git forge. SSH is the API: repositories, issues, and | 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 | merge requests are managed from your terminal with nothing but OpenSSH — | 7 | <pre class="quickstart">ssh git@{{.Host}} help # every command, no client to install |
| 7 | the web is a fast, readable rendering of that state.</p> | ||
| 8 | <pre class="quickstart">ssh git@{{.Host}} help # every command, no client needed | ||
| 9 | 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> | ||
| 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."> | ||
| 12 | </picture></div>{{end}} | ||
| 10 | <div class="facets"> | 13 | <div class="facets"> |
| 11 | <section><h2>Read</h2><p>Browse and clone any public repository, over HTTPS or <code>git://</code>, with no account. Every commit shows whether its signature was 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> |
| 12 | <section><h2>Write</h2><p>Push over SSH with the key you already have. <code>ssh git@{{.Host}} repo create you/project</code> is the whole onboarding; issues, merge requests and reviews are commands too.</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> |
| 13 | <section><h2>Review</h2><p>The web is a rendering of the same state: read a diff, comment on a line, approve, merge. Everything it can do, the terminal can do first.</p></section> | 16 | <section><h2>Review</h2><p>Read a diff, comment on a line, approve, merge, in the browser or the terminal.</p></section> |
| 14 | </div> | 17 | </div> |
| 15 | <p><a class="explorelink" href="/explore">explore public repositories →</a></p> | 18 | <div class="routes"><a class="button primary" href="/explore">Explore repositories</a>{{if .Signup}}<a class="button btn" href="/register">Create an account</a>{{end}}</div> |
| 16 | {{if .Accounts}}<p class="meta">have an account? log in from your terminal: <code>gitbay web login</code>{{if .Signup}} · new here? <a href="/register">create an account</a>{{end}}</p>{{end}} | 19 | {{if .Accounts}}<p class="meta">Have an account? {{if .EmailLogin}}<a href="/login">Sign in with an emailed link</a>, or run{{else}}Run{{end}} <code>ssh git@{{.Host}} web login</code>.</p>{{end}} |
| 17 | </div> | 20 | </div> |
| 18 | {{end}} | 21 | {{end}} |
internal/web/templates/layout.html +9 −8
| @@ -71,13 +71,13 @@ | |||
| 71 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} | 71 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} |
| 72 | </div> | 72 | </div> |
| 73 | {{$top := topTab (str $ "Tab")}} | 73 | {{$top := topTab (str $ "Tab")}} |
| 74 | {{/* The header is the same on every tab: it sits above the tab bar, | 74 | {{/* Description, topics and metadata belong to the code tab. On task |
| 75 | and anything shown on one tab and not another would move the | 75 | tabs only the identity row and the tab bar render, and the header |
| 76 | navigation between clicks of that navigation. */}} | 76 | is identical on every page within a tab. */}} |
| 77 | {{with field $ "Desc"}}<p class="repodesc">{{.}}</p>{{end}} | 77 | {{if eq $top "code"}} |
| 78 | {{with field $ "Topics"}}<p class="topics">{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} | 78 | <p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p> |
| 79 | {{with $.Repo.Settings.Website}}<p class="repometa"><a href="{{.}}" rel="nofollow">{{.}}</a></p>{{end}} | 79 | {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range $i, $m := field $ "Mirrors"}}{{if or $i $.Repo.Settings.Website}} · {{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}} |
| 80 | {{range field $ "Mirrors"}}<p class="repometa">{{if eq .Direction "push"}}Mirrors to{{else}}Mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p> | 80 | {{if $.Viewer}}<p class="toggles">Pinned shows in your rail. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}} |
| 81 | {{end}} | 81 | {{end}} |
| 82 | <nav class="tabs" aria-label="Repository"> | 82 | <nav class="tabs" aria-label="Repository"> |
| 83 | <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a> | 83 | <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a> |
| @@ -91,7 +91,7 @@ | |||
| 91 | </header> | 91 | </header> |
| 92 | {{end}} | 92 | {{end}} |
| 93 | 93 | ||
| 94 | <main id="content" class="content"> | 94 | <main id="content" class="content {{template "width" .}}"> |
| 95 | {{template "content" .}} | 95 | {{template "content" .}} |
| 96 | </main> | 96 | </main> |
| 97 | 97 | ||
| @@ -104,6 +104,7 @@ | |||
| 104 | </html>{{end}} | 104 | </html>{{end}} |
| 105 | 105 | ||
| 106 | {{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}} | 106 | {{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}} |
| 107 | {{define "width"}}reading{{end}} | ||
| 107 | 108 | ||
| 108 | {{define "reporow"}}<li> | 109 | {{define "reporow"}}<li> |
| 109 | <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Archived}} <span class="chip">Archived</span>{{end}}</p> | 110 | <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Archived}} <span class="chip">Archived</span>{{end}}</p> |
internal/web/templates/log.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1 class="vh">Commits</h1> | 4 | <h1 class="vh">Commits</h1> |
internal/web/templates/login.html +3 −2
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}login · {{.Site}}{{end}} | 2 | {{define "title"}}login · {{.Site}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>Sign in</h1> | 4 | <h1>Sign in</h1> |
| @@ -9,8 +10,8 @@ expires in fifteen minutes.</p> | |||
| 9 | {{else}} | 10 | {{else}} |
| 10 | {{if .EmailLogin}} | 11 | {{if .EmailLogin}} |
| 11 | <form method="post" action="/login"> | 12 | <form method="post" action="/login"> |
| 12 | <label for="identifier">Username or email address</label> | 13 | <div class="field"><label for="identifier">Username or email address</label> |
| 13 | <input type="text" id="identifier" name="identifier" autocomplete="username" required> | 14 | <input type="text" id="identifier" name="identifier" autocomplete="username" required></div> |
| 14 | <button type="submit">Email me a link</button> | 15 | <button type="submit">Email me a link</button> |
| 15 | </form> | 16 | </form> |
| 16 | <p>Or, from a machine with your registered key:</p> | 17 | <p>Or, from a machine with your registered key:</p> |
internal/web/templates/mr.html +42 −49
| @@ -1,20 +1,13 @@ | |||
| 1 | {{define "mrrange"}}<code>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}}</code> into <code>{{.TargetRef}}</code>{{end}} | ||
| 2 | {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 1 | {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | {{define "content"}} | 2 | {{define "content"}} |
| 4 | {{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}} | 3 | {{$base := printf "/%s/%s/mrs/%d" .Repo.OwnerName .Repo.Name .MR.Number}} |
| 5 | <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> | 4 | <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> |
| 6 | <p class="issuemeta">{{if .MR.Draft}}<span class="chip chip-neutral">draft</span> {{end}}<span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> | 5 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{if eq .MR.State "source_gone"}}source gone{{else}}{{.MR.State}}{{end}}</span>{{if .MR.Draft}} <span class="chip chip-neutral">draft</span>{{end}} |
| 7 | {{if and (eq .MR.State "merged") .MR.MergedAt}} | 6 | {{if and (eq .MR.State "merged") .MR.MergedBy}}merged by <a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a>{{with .MR.MergedAt}} on {{when .}}{{end}} |
| 8 | {{if .MR.MergedBy}}<a href="/{{.MR.MergedBy}}">{{.MR.MergedBy}}</a> merged{{else}}Merged{{end}} | 7 | {{else if and (eq .MR.State "closed") .MR.ClosedBy}}closed without merging by <a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a>{{with .MR.ClosedAt}} on {{when .}}{{end}} |
| 9 | {{template "mrrange" .MR}} on {{when .MR.MergedAt}} | 8 | {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}}{{if eq .MR.State "merged"}}merged{{else}}closed without merging{{end}} |
| 10 | {{else if and (eq .MR.State "closed") .MR.ClosedAt}} | 9 | {{else}}opened by <a href="/{{.MR.Author}}">{{.MR.Author}}</a> on {{when .MR.CreatedAt}}{{end}} |
| 11 | {{if .MR.ClosedBy}}<a href="/{{.MR.ClosedBy}}">{{.MR.ClosedBy}}</a> closed this{{else}}Closed{{end}} | 10 | · <code>{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> |
| 12 | without merging {{template "mrrange" .MR}} on {{when .MR.ClosedAt}} | ||
| 13 | {{else if or (eq .MR.State "merged") (eq .MR.State "closed")}} | ||
| 14 | {{template "mrrange" .MR}} | ||
| 15 | {{else}} | ||
| 16 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{template "mrrange" .MR}} | ||
| 17 | {{end}}</p> | ||
| 18 | {{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}} | 11 | {{with field . "StackedOn"}}<p class="meta">Stacked on <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">!{{.Number}} {{.Title}}</a>: merges into its branch until that lands, then onto its target.</p>{{end}} |
| 19 | {{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}} | 12 | {{with field . "Stacked"}}<p class="meta">Builds on this: {{range $i, $k := .}}{{if $i}}, {{end}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{$k.Number}}">!{{$k.Number}} {{$k.Title}}</a>{{end}}. Merging with squash or rebase is refused while they are open.</p>{{end}} |
| 20 | 13 | ||
| @@ -30,6 +23,7 @@ | |||
| 30 | </nav> | 23 | </nav> |
| 31 | 24 | ||
| 32 | {{if eq .View "conversation"}} | 25 | {{if eq .View "conversation"}} |
| 26 | <div class="prose"> | ||
| 33 | {{if .CanEdit}}<details class="editbox"><summary>Edit</summary> | 27 | {{if .CanEdit}}<details class="editbox"><summary>Edit</summary> |
| 34 | <form method="post" action="{{$base}}/edit" class="commentform"> | 28 | <form method="post" action="{{$base}}/edit" class="commentform"> |
| 35 | <p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p> | 29 | <p><input type="text" name="title" aria-label="Title" value="{{.MR.Title}}" required></p> |
| @@ -55,6 +49,7 @@ | |||
| 55 | <p><button type="submit" class="btn">Comment</button></p> | 49 | <p><button type="submit" class="btn">Comment</button></p> |
| 56 | </form> | 50 | </form> |
| 57 | {{end}} | 51 | {{end}} |
| 52 | </div> | ||
| 58 | 53 | ||
| 59 | {{else if eq .View "commits"}} | 54 | {{else if eq .View "commits"}} |
| 60 | {{if gt .CommitsTotal (len .Commits)}}<p class="meta">first {{len .Commits}} of {{.CommitsTotal}} commits; the rest are in the branch</p>{{end}} | 55 | {{if gt .CommitsTotal (len .Commits)}}<p class="meta">first {{len .Commits}} of {{.CommitsTotal}} commits; the rest are in the branch</p>{{end}} |
| @@ -74,9 +69,10 @@ | |||
| 74 | </ul> | 69 | </ul> |
| 75 | 70 | ||
| 76 | {{else}} | 71 | {{else}} |
| 77 | <p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p> | 72 | {{if .DiffFiles}}<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p> |
| 78 | {{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}} | 73 | {{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}} |
| 79 | {{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}} | 74 | {{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}} |
| 75 | {{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}} | ||
| 80 | {{end}} | 76 | {{end}} |
| 81 | 77 | ||
| 82 | </div> | 78 | </div> |
| @@ -113,38 +109,6 @@ | |||
| 113 | </form> | 109 | </form> |
| 114 | </div> | 110 | </div> |
| 115 | {{end}} | 111 | {{end}} |
| 116 | {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}} | ||
| 117 | <div class="grp"> | ||
| 118 | <h2>Target</h2> | ||
| 119 | <form method="post" action="{{$base}}/retarget" class="actions"> | ||
| 120 | <label class="none" for="target">Branch</label> | ||
| 121 | <select id="target" name="target"> | ||
| 122 | {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}} | ||
| 123 | </select> | ||
| 124 | <button type="submit" class="btn">Retarget</button> | ||
| 125 | </form> | ||
| 126 | <p class="row none">Retargeting stales existing reviews.</p> | ||
| 127 | </div> | ||
| 128 | {{end}} | ||
| 129 | <div class="grp"> | ||
| 130 | <h2>Reviewers</h2> | ||
| 131 | {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p> | ||
| 132 | {{else}}<p class="none">nobody yet</p>{{end}} | ||
| 133 | {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}} | ||
| 134 | <form method="post" action="{{$base}}/review-request" class="actions"> | ||
| 135 | <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated"> | ||
| 136 | <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove"> | ||
| 137 | <button type="submit" class="btn">Apply</button> | ||
| 138 | </form> | ||
| 139 | {{end}} | ||
| 140 | </div> | ||
| 141 | <div class="grp"> | ||
| 142 | <h2>Reviews</h2> | ||
| 143 | {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}{{if not .Counts}} <span class="chip chip-neutral" title="This reviewer has no write access, so the merge gates do not count it">advisory</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p> | ||
| 144 | {{else}}<p class="none">none yet</p>{{end}} | ||
| 145 | {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two: | ||
| 146 | <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}} | ||
| 147 | </div> | ||
| 148 | {{with .Gates}}<div class="grp"> | 112 | {{with .Gates}}<div class="grp"> |
| 149 | <h2>Merge gates</h2> | 113 | <h2>Merge gates</h2> |
| 150 | {{if .Unmet}}{{range .Unmet}}<p class="row"><span class="dot bad"></span>{{.}}</p>{{end}} | 114 | {{if .Unmet}}{{range .Unmet}}<p class="row"><span class="dot bad"></span>{{.}}</p>{{end}} |
| @@ -160,9 +124,38 @@ | |||
| 160 | {{else}}<p class="none">No checks reported</p>{{end}} | 124 | {{else}}<p class="none">No checks reported</p>{{end}} |
| 161 | </div> | 125 | </div> |
| 162 | <div class="grp"> | 126 | <div class="grp"> |
| 163 | <h2>Source</h2> | 127 | <h2>Reviews</h2> |
| 164 | <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code></p> | 128 | {{range .Reviews}}<p class="row"><span class="dot {{if eq .Verdict "approve"}}ok{{else}}pend{{end}}"></span><a href="/{{.Reviewer}}">{{.Reviewer}}</a> {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}{{if not .Counts}} <span class="chip chip-neutral" title="This reviewer has no write access, so the merge gates do not count it">advisory</span>{{end}}<span class="sub">{{when .CreatedAt}}</span></p> |
| 165 | <p class="row none">into <code>{{.MR.TargetRef}}</code> · {{if eq .MR.State "merged"}}merged at{{else}}head{{end}} <code>{{short .MR.HeadSHA}}</code>{{if .SourceGone}} · <span class="chip chip-neutral">branch deleted</span>{{end}}</p> | 129 | {{else}}<p class="none">none yet</p>{{end}} |
| 130 | {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two: | ||
| 131 | <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}} | ||
| 132 | </div> | ||
| 133 | <div class="grp"> | ||
| 134 | <h2>Reviewers</h2> | ||
| 135 | {{if .MR.ReviewRequests}}<p class="row">{{range .MR.ReviewRequests}}<a href="/{{.}}">{{.}}</a> {{end}}</p> | ||
| 136 | {{else}}<p class="none">nobody yet</p>{{end}} | ||
| 137 | {{if and .CanWrite (or (eq .MR.State "open") (eq .MR.State "source_gone"))}} | ||
| 138 | <form method="post" action="{{$base}}/review-request" class="actions"> | ||
| 139 | <input type="text" name="add" aria-label="Add reviewers" placeholder="add, space-separated"> | ||
| 140 | <input type="text" name="remove" aria-label="Remove reviewers" placeholder="remove"> | ||
| 141 | <button type="submit" class="btn">Apply</button> | ||
| 142 | </form> | ||
| 143 | {{end}} | ||
| 144 | </div> | ||
| 145 | <div class="grp"> | ||
| 146 | <h2>Source and target</h2> | ||
| 147 | <p class="row"><code>{{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}}</code> into <code>{{.MR.TargetRef}}</code></p> | ||
| 148 | <p class="row none">{{if eq .MR.State "merged"}}merged at{{else}}head{{end}} <code>{{short .MR.HeadSHA}}</code>{{if .Base}} · base <code>{{short .Base}}</code>{{end}}{{if .SourceGone}} · <span class="chip chip-neutral">branch deleted</span>{{end}}</p> | ||
| 149 | {{if and .CanEdit (or (eq .MR.State "open") (eq .MR.State "source_gone"))}} | ||
| 150 | <form method="post" action="{{$base}}/retarget" class="actions"> | ||
| 151 | <label class="none" for="target">Branch</label> | ||
| 152 | <select id="target" name="target"> | ||
| 153 | {{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.MR.TargetRef}} selected{{end}}>{{.Name}}</option>{{end}} | ||
| 154 | </select> | ||
| 155 | <button type="submit" class="btn">Retarget</button> | ||
| 156 | </form> | ||
| 157 | <p class="row none">Retargeting stales existing reviews.</p> | ||
| 158 | {{end}} | ||
| 166 | </div> | 159 | </div> |
| 167 | {{if .MR.Milestone}}<div class="grp"> | 160 | {{if .MR.Milestone}}<div class="grp"> |
| 168 | <h2>Milestone</h2> | 161 | <h2>Milestone</h2> |
internal/web/templates/mrnew.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}new merge request · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}new merge request · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>New merge request</h1> | 4 | <h1>New merge request</h1> |
internal/web/templates/new.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}new repository · {{.Site}}{{end}} | 2 | {{define "title"}}new repository · {{.Site}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>New repository</h1> | 4 | <h1>New repository</h1> |
internal/web/templates/privacy.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}privacy · {{.Site}}{{end}} | 2 | {{define "title"}}privacy · {{.Site}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <div class="landing"> | 4 | <div class="landing"> |
internal/web/templates/register.html +9 −9
| @@ -1,18 +1,18 @@ | |||
| 1 | {{define "title"}}register · {{.Site}}{{end}} | 1 | {{define "title"}}register · {{.Site}}{{end}} |
| 2 | {{define "width"}}bounded{{end}} | ||
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <div class="landing"> | ||
| 4 | <h1>Create an account</h1> | 4 | <h1>Create an account</h1> |
| 5 | {{if eq .Mode "invite"}}<p class="lede">This instance is invite-only: you need an invite code from an admin.</p> | 5 | {{if eq .Mode "invite"}}<p class="lede">This instance is invite-only. You need an invite code from an admin.</p> |
| 6 | {{else}}<p class="lede">Open registration — your account activates once you verify your email.</p>{{end}} | 6 | {{else}}<p class="lede">Open registration. Your account activates once you verify your email.</p>{{end}} |
| 7 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} | 7 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 8 | <form method="post" action="/register" class="signupform"> | 8 | <form method="post" action="/register" class="signupform"> |
| 9 | <p><label>Username<br><input type="text" name="username" value="{{.Username}}" required autofocus></label></p> | 9 | <div class="field"><label for="username">Username</label><input type="text" id="username" name="username" value="{{.Username}}" required autofocus></div> |
| 10 | {{if eq .Mode "invite"}}<p><label>invite code<br><input type="text" name="invite" required></label></p> | 10 | {{if eq .Mode "invite"}}<div class="field"><label for="invite">Invite code</label><input type="text" id="invite" name="invite" required></div> |
| 11 | {{else}}<p><label>Email<br><input type="text" name="email" required></label></p>{{end}} | 11 | {{else}}<div class="field"><label for="email">Email</label><input type="text" id="email" name="email" required></div>{{end}} |
| 12 | <p><label>SSH public key — your key is your identity; paste e.g. <code>~/.ssh/id_ed25519.pub</code><br> | 12 | <div class="field"><label for="key">SSH public key</label> |
| 13 | <textarea name="key" aria-label="SSH public key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></label></p> | 13 | <p class="hint">Paste the contents of your public key file, usually <code>~/.ssh/id_ed25519.pub</code>. It starts with <code>ssh-ed25519</code> or <code>ssh-rsa</code>. No key yet? <a href="/krz/gitbay/wiki/SSH-keys">Make one</a>.</p> |
| 14 | <textarea id="key" name="key" rows="3" required placeholder="ssh-ed25519 AAAA... you@host"></textarea></div> | ||
| 14 | <p><button type="submit">Create account</button></p> | 15 | <p><button type="submit">Create account</button></p> |
| 15 | </form> | 16 | </form> |
| 16 | <p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite <code>{{else}}--email you@example.org{{end}}</code></p> | 17 | <p class="meta">Prefer the terminal? <code>ssh git@{{.Host}} register --username you {{if eq .Mode "invite"}}--invite <code>{{else}}--email you@example.org{{end}}</code></p> |
| 17 | </div> | ||
| 18 | {{end}} | 18 | {{end}} |
internal/web/templates/registered.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}welcome · {{.Site}}{{end}} | 2 | {{define "title"}}welcome · {{.Site}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <div class="landing"> | 4 | <div class="landing"> |
internal/web/templates/search.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>Search</h1> | 4 | <h1>Search</h1> |
internal/web/templates/settings.html +81 −83
| @@ -1,148 +1,143 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} | 4 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} |
| 4 | <h1>Settings</h1> | 5 | <h1>Settings</h1> |
| 5 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 6 | {{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}} |
| 7 | <nav class="sections" aria-label="Sections"><a href="#identity">Identity</a><a href="#access">Access</a><a href="#gates">Merge gates</a><a href="#branches">Protected branches</a><a href="#tags">Protected tags</a>{{if .DepsEnabled}}<a href="#deps">Dependencies</a>{{end}}<a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav> | ||
| 6 | 8 | ||
| 7 | <h2>Identity</h2> | 9 | <section id="identity"><h2>Identity</h2> |
| 8 | <form method="post" action="{{$base}}" class="setform"> | 10 | <form method="post" action="{{$base}}" class="setform"> |
| 9 | <input type="hidden" name="field" value="description"> | 11 | <input type="hidden" name="field" value="description"> |
| 10 | <label for="description">Description</label> | 12 | <div><label for="description">Description</label></div> |
| 11 | <input type="text" id="description" name="description" value="{{.Desc}}" placeholder="one line, shown in listings"> | 13 | <div><input type="text" id="description" name="description" value="{{or (index .Submitted "description") .Desc}}" placeholder="one line, shown in listings"></div> |
| 12 | <button type="submit" class="btn">Save description</button> | 14 | <div><button type="submit">Save</button></div> |
| 13 | </form> | 15 | </form> |
| 14 | <form method="post" action="{{$base}}" class="setform"> | 16 | <form method="post" action="{{$base}}" class="setform"> |
| 15 | <input type="hidden" name="field" value="website"> | 17 | <input type="hidden" name="field" value="website"> |
| 16 | <label for="website">Website</label> | 18 | <div><label for="website">Website</label></div> |
| 17 | <input type="text" id="website" name="website" value="{{.Repo.Settings.Website}}" placeholder="https://example.org"> | 19 | <div><input type="text" id="website" name="website" value="{{or (index .Submitted "website") .Repo.Settings.Website}}" placeholder="https://example.org"></div> |
| 18 | <button type="submit" class="btn">Save website</button> | 20 | <div><button type="submit" class="btn">Save</button></div> |
| 19 | </form> | 21 | </form> |
| 20 | {{if .Branches}}<form method="post" action="{{$base}}" class="setform"> | 22 | <form method="post" action="{{$base}}" class="setform"> |
| 21 | <input type="hidden" name="field" value="default-branch"> | ||
| 22 | <label for="default-branch">Default branch</label> | ||
| 23 | <select id="default-branch" name="default-branch"> | ||
| 24 | {{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}} | ||
| 25 | </select> | ||
| 26 | <button type="submit" class="btn">Save default branch</button> | ||
| 27 | </form> | ||
| 28 | {{end}}<form method="post" action="{{$base}}" class="setform stack"> | ||
| 29 | <input type="hidden" name="field" value="topics"> | 23 | <input type="hidden" name="field" value="topics"> |
| 30 | <label for="topics-add">Topics to add</label> | 24 | <div><label for="topics">Topics</label><p class="hint">Comma separated, lower case.</p></div> |
| 31 | <input type="text" id="topics-add" name="add" placeholder="add, space-separated"> | 25 | <div><input type="text" id="topics" name="topics" value="{{or (index .Submitted "topics") (join .Topics ", ")}}"></div> |
| 32 | <label for="topics-remove">Topics to remove</label> | 26 | <div><button type="submit" class="btn">Save</button></div> |
| 33 | <input type="text" id="topics-remove" name="remove" placeholder="remove"> | ||
| 34 | <button type="submit" class="btn">Apply</button> | ||
| 35 | </form> | 27 | </form> |
| 36 | {{if .Topics}}<p class="meta">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}} | 28 | {{if .Branches}}<form method="post" action="{{$base}}" class="setform"> |
| 29 | <input type="hidden" name="field" value="default-branch"> | ||
| 30 | <div><label for="default-branch">Default branch</label><p class="hint">What a clone checks out and what CI builds on trigger.</p></div> | ||
| 31 | <div><select id="default-branch" name="default-branch">{{$cur := .Repo.DefaultBranch}}{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $cur}} selected{{end}}>{{.Name}}</option>{{end}}</select></div> | ||
| 32 | <div><button type="submit" class="btn">Save</button></div> | ||
| 33 | </form>{{end}} | ||
| 34 | </section> | ||
| 37 | 35 | ||
| 38 | <h2>Access</h2> | 36 | <section id="access"><h2>Access</h2> |
| 39 | <form method="post" action="{{$base}}" class="setform"> | 37 | <form method="post" action="{{$base}}" class="setform"> |
| 40 | <input type="hidden" name="field" value="visibility"> | 38 | <input type="hidden" name="field" value="visibility"> |
| 41 | <label for="visibility">Visibility</label> | 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> |
| 42 | <select id="visibility" name="visibility"> | 40 | <div class="check"> |
| 43 | <option value="public"{{if eq .Repo.Visibility "public"}} selected{{end}}>Public</option> | 41 | <label><input type="radio" name="visibility" value="public"{{if eq .Repo.Visibility "public"}} checked{{end}}> Public</label> |
| 44 | <option value="private"{{if eq .Repo.Visibility "private"}} selected{{end}}>Private</option> | 42 | <label><input type="radio" name="visibility" value="private"{{if eq .Repo.Visibility "private"}} checked{{end}}> Private</label> |
| 45 | </select> | 43 | </div> |
| 46 | <button type="submit" class="btn">Save visibility</button> | 44 | <div><button type="submit" class="btn">Save</button></div> |
| 47 | </form> | 45 | </form> |
| 48 | <form method="post" action="{{$base}}" class="setform"> | 46 | <form method="post" action="{{$base}}" class="setform"> |
| 49 | <input type="hidden" name="field" value="git-daemon"> | 47 | <input type="hidden" name="field" value="git-daemon"> |
| 50 | <label for="git-daemon">Serve over git://</label> | 48 | <div><label for="git-daemon">Serve over git://</label><p class="hint">Unauthenticated, unencrypted read access on port 9418. Public repositories only.</p></div> |
| 51 | <input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}> | 49 | <div class="check"><input type="checkbox" id="git-daemon" name="git-daemon" value="on"{{if .Repo.Settings.GitDaemon}} checked{{end}}></div> |
| 52 | <button type="submit" class="btn">Save git://</button> | 50 | <div><button type="submit" class="btn">Save</button></div> |
| 53 | </form> | 51 | </form> |
| 52 | </section> | ||
| 54 | 53 | ||
| 55 | <h2>Merge gates</h2> | 54 | <section id="gates"><h2>Merge gates</h2> |
| 56 | <p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p> | 55 | <p class="meta">Checked before a merge, in this order: checks, approvals, resolved threads, signatures.</p> |
| 57 | <form method="post" action="{{$base}}" class="setform"> | 56 | <form method="post" action="{{$base}}" class="setform"> |
| 58 | <input type="hidden" name="field" value="require-checks"> | 57 | <input type="hidden" name="field" value="require-checks"> |
| 59 | <label for="require-checks">Require green checks</label> | 58 | <div><label for="require-checks">Required checks</label><p class="hint">A request waits until every CI job that would report on its head has succeeded.</p></div> |
| 60 | <input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}> | 59 | <div class="check"><input type="checkbox" id="require-checks" name="require-checks" value="on"{{if .Repo.Settings.RequireChecks}} checked{{end}}></div> |
| 61 | <button type="submit" class="btn">Save checks</button> | 60 | <div><button type="submit" class="btn">Save</button></div> |
| 62 | </form> | 61 | </form> |
| 63 | <form method="post" action="{{$base}}" class="setform"> | 62 | <form method="post" action="{{$base}}" class="setform"> |
| 64 | <input type="hidden" name="field" value="require-approvals"> | 63 | <input type="hidden" name="field" value="require-approvals"> |
| 65 | <label for="approvals">Required approvals</label> | 64 | <div><label for="approvals">Approvals</label><p class="hint">Approvals from people with write access. Zero means none required.</p></div> |
| 66 | <input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"> | 65 | <div><input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"></div> |
| 67 | <button type="submit" class="btn">Save approvals</button> | 66 | <div><button type="submit" class="btn">Save</button></div> |
| 68 | </form> | 67 | </form> |
| 69 | <form method="post" action="{{$base}}" class="setform"> | 68 | <form method="post" action="{{$base}}" class="setform"> |
| 70 | <input type="hidden" name="field" value="require-resolved"> | 69 | <input type="hidden" name="field" value="require-resolved"> |
| 71 | <label for="require-resolved">Require resolved threads</label> | 70 | <div><label for="require-resolved">Review threads</label><p class="hint">Every thread on the diff must be resolved before merging.</p></div> |
| 72 | <input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}> | 71 | <div class="check"><input type="checkbox" id="require-resolved" name="require-resolved" value="on"{{if .Repo.Settings.RequireResolved}} checked{{end}}></div> |
| 73 | <button type="submit" class="btn">Save threads</button> | 72 | <div><button type="submit" class="btn">Save</button></div> |
| 74 | </form> | 73 | </form> |
| 75 | <form method="post" action="{{$base}}" class="setform"> | 74 | <form method="post" action="{{$base}}" class="setform"> |
| 76 | <input type="hidden" name="field" value="require-codeowners"> | 75 | <input type="hidden" name="field" value="require-codeowners"> |
| 77 | <label for="require-codeowners">Require CODEOWNERS approval</label> | 76 | <div><label for="require-codeowners">CODEOWNERS</label><p class="hint">Owners of every touched path must approve.</p></div> |
| 78 | <input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}> | 77 | <div class="check"><input type="checkbox" id="require-codeowners" name="require-codeowners" value="on"{{if .Repo.Settings.RequireCodeowners}} checked{{end}}></div> |
| 79 | <button type="submit" class="btn">Save CODEOWNERS</button> | 78 | <div><button type="submit" class="btn">Save</button></div> |
| 80 | </form> | 79 | </form> |
| 81 | <form method="post" action="{{$base}}" class="setform"> | 80 | <form method="post" action="{{$base}}" class="setform"> |
| 82 | <input type="hidden" name="field" value="require-signed"> | 81 | <input type="hidden" name="field" value="require-signed"> |
| 83 | <label for="require-signed">Require signed commits</label> | 82 | <div><label for="require-signed">Signed commits</label><p class="hint">Every commit in the request must carry a verified signature. Squash and merge strategies are refused, since both mint an unsigned commit.</p></div> |
| 84 | <input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}> | 83 | <div class="check"><input type="checkbox" id="require-signed" name="require-signed" value="on"{{if .Repo.Settings.RequireSignedCommits}} checked{{end}}></div> |
| 85 | <button type="submit" class="btn">Save signing</button> | 84 | <div><button type="submit" class="btn">Save</button></div> |
| 86 | </form> | 85 | </form> |
| 86 | </section> | ||
| 87 | 87 | ||
| 88 | <h2>Protected branches</h2> | 88 | <section id="branches"><h2>Protected branches</h2> |
| 89 | {{if .Repo.Settings.ProtectedBranches}} | 89 | {{if .Repo.Settings.ProtectedBranches}} |
| 90 | <ul class="protlist"> | 90 | <ul class="protlist"> |
| 91 | {{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code> | 91 | {{$mr := .Repo.Settings.RequireMR}}{{range .Repo.Settings.ProtectedBranches}}<li><code>{{.}}</code>{{if $mr}} <span class="hint">Direct pushes refused, merge requests only.</span>{{end}} |
| 92 | <form method="post" action="{{$base}}" class="inline"> | 92 | <form method="post" action="{{$base}}" class="inline"> |
| 93 | <input type="hidden" name="field" value="unprotect"> | 93 | <input type="hidden" name="field" value="unprotect"> |
| 94 | <input type="hidden" name="branch" value="{{.}}"> | 94 | <input type="hidden" name="branch" value="{{.}}"> |
| 95 | <button type="submit" class="linklike">Unprotect</button> | 95 | <button type="submit" class="danger">Unprotect</button> |
| 96 | </form></li> | 96 | </form></li> |
| 97 | {{end}} | 97 | {{end}} |
| 98 | </ul> | 98 | </ul> |
| 99 | {{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}} | 99 | {{else}}<p class="meta">No protected branches. A protected branch refuses deletion and force-pushes.</p>{{end}} |
| 100 | <form method="post" action="{{$base}}" class="setform"> | 100 | <form method="post" action="{{$base}}" class="setform"> |
| 101 | <input type="hidden" name="field" value="protect"> | 101 | <input type="hidden" name="field" value="protect"> |
| 102 | <label for="branch">Protect a branch</label> | 102 | <div><label for="branch">Protect a branch</label></div> |
| 103 | <select id="branch" name="branch"> | 103 | <div><select id="branch" name="branch">{{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select></div> |
| 104 | {{range .Branches}}<option value="{{.Name}}">{{.Name}}</option>{{end}} | 104 | <div><button type="submit" class="btn">Protect</button></div> |
| 105 | </select> | ||
| 106 | <button type="submit" class="btn">Protect</button> | ||
| 107 | </form> | 105 | </form> |
| 108 | <form method="post" action="{{$base}}" class="setform"> | 106 | <form method="post" action="{{$base}}" class="setform"> |
| 109 | <input type="hidden" name="field" value="require-mr"> | 107 | <input type="hidden" name="field" value="require-mr"> |
| 110 | <label for="require-mr">Merge requests only</label> | 108 | <div><label for="require-mr">Require merge requests</label><p class="hint">Every protected branch changes through a merge request. A direct push is refused in pre-receive.</p></div> |
| 111 | <input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}> | 109 | <div class="check"><input type="checkbox" id="require-mr" name="require-mr" value="on"{{if .Repo.Settings.RequireMR}} checked{{end}}></div> |
| 112 | <button type="submit" class="btn">Save merge-only</button> | 110 | <div><button type="submit" class="btn">Save</button></div> |
| 113 | </form> | 111 | </form> |
| 114 | <p class="meta">With merge requests only, a protected branch refuses every direct push once it exists; the merge gates above are then what a change has to pass.</p> | 112 | </section> |
| 115 | 113 | ||
| 116 | <h2>Protected tags</h2> | 114 | <section id="tags"><h2>Protected tags</h2> |
| 117 | {{if .Repo.Settings.ProtectedTags}} | 115 | {{if .Repo.Settings.ProtectedTags}} |
| 118 | <ul class="protlist"> | 116 | <ul class="protlist"> |
| 119 | {{range .Repo.Settings.ProtectedTags}}<li><code>{{.}}</code> | 117 | {{range .Repo.Settings.ProtectedTags}}<li><code>{{.}}</code> |
| 120 | <form method="post" action="{{$base}}" class="inline"> | 118 | <form method="post" action="{{$base}}" class="inline"> |
| 121 | <input type="hidden" name="field" value="unprotect-tag"> | 119 | <input type="hidden" name="field" value="unprotect-tag"> |
| 122 | <input type="hidden" name="glob" value="{{.}}"> | 120 | <input type="hidden" name="glob" value="{{.}}"> |
| 123 | <button type="submit" class="linklike">Unprotect</button> | 121 | <button type="submit" class="danger">Unprotect</button> |
| 124 | </form></li> | 122 | </form></li> |
| 125 | {{end}} | 123 | {{end}} |
| 126 | </ul> | 124 | </ul> |
| 127 | {{else}}<p class="meta">No protected tags. A tag matching a protected glob is created once and refuses moves and deletion. A tag a release is anchored to refuses both regardless.</p>{{end}} | 125 | {{else}}<p class="meta">No protected tags. A tag matching a protected glob is created once and refuses moves and deletion. A tag a release is anchored to refuses both regardless.</p>{{end}} |
| 128 | <form method="post" action="{{$base}}" class="setform"> | 126 | <form method="post" action="{{$base}}" class="setform"> |
| 129 | <input type="hidden" name="field" value="protect-tag"> | 127 | <input type="hidden" name="field" value="protect-tag"> |
| 130 | <label for="glob">Protect tags matching</label> | 128 | <div><label for="glob">Protect tags matching</label></div> |
| 131 | <input type="text" id="glob" name="glob" placeholder="v*"> | 129 | <div><input type="text" id="glob" name="glob" placeholder="v*"></div> |
| 132 | <button type="submit" class="btn">Protect</button> | 130 | <div><button type="submit" class="btn">Protect</button></div> |
| 133 | </form> | 131 | </form> |
| 132 | </section> | ||
| 134 | 133 | ||
| 135 | <h2>Dependencies</h2> | 134 | <section id="deps"><h2>Dependencies</h2> |
| 136 | <form method="post" action="{{$base}}" class="setform"> | 135 | <form method="post" action="{{$base}}" class="setform"> |
| 137 | <input type="hidden" name="field" value="deps"> | 136 | <input type="hidden" name="field" value="deps"> |
| 138 | <label for="deps">Check for updates</label> | 137 | <div><label for="deps">Check for updates</label><p class="hint">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind in an issue. Checking a private repository tells those registries what it depends on.</p></div> |
| 139 | <input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}> | 138 | <div class="check"><input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div> |
| 140 | <button type="submit" class="btn">Save dependency checks</button> | 139 | <div><button type="submit" class="btn">Save</button></div> |
| 141 | </form> | 140 | </form> |
| 142 | <p class="meta">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against | ||
| 143 | proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind | ||
| 144 | in an issue. Checking a private repository tells those registries what it | ||
| 145 | depends on.</p> | ||
| 146 | {{if .DepsEnabled}} | 141 | {{if .DepsEnabled}} |
| 147 | <p class="meta">Last checked {{if .Deps.LastCheck}}{{when .Deps.LastCheck}}{{else}}never{{end}}{{if .Deps.IssueNumber}} · tracked in <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Deps.IssueNumber}}">#{{.Deps.IssueNumber}}</a>{{end}}</p> | 142 | <p class="meta">Last checked {{if .Deps.LastCheck}}{{when .Deps.LastCheck}}{{else}}never{{end}}{{if .Deps.IssueNumber}} · tracked in <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Deps.IssueNumber}}">#{{.Deps.IssueNumber}}</a>{{end}}</p> |
| 148 | {{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}} | 143 | {{if .Deps.LastError}}<p class="error" role="alert">{{.Deps.LastError}}</p>{{end}} |
| @@ -156,34 +151,37 @@ depends on.</p> | |||
| 156 | {{end}}</table></div> | 151 | {{end}}</table></div> |
| 157 | {{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}} | 152 | {{else}}<p class="none">Nothing behind{{if not .Deps.LastCheck}} — the first check has not run yet{{end}}.</p>{{end}} |
| 158 | {{end}} | 153 | {{end}} |
| 154 | </section> | ||
| 159 | 155 | ||
| 160 | <h2>Runners</h2> | 156 | <section id="runners"><h2>Runners</h2> |
| 161 | {{if .Runners}} | 157 | {{if .Runners}} |
| 162 | <ul class="protlist"> | 158 | <ul class="protlist"> |
| 163 | {{range .Runners}}<li><code>{{.Fingerprint}}</code> <span class="meta">{{.Username}}{{if .LastSeen}}, last poll {{when .LastSeen}}{{else}}, never polled{{end}}{{if .BuildNumber}}, running {{.BuildRepo}} #{{.BuildNumber}} {{.BuildJob}}{{end}}</span> | 159 | {{range .Runners}}<li><code>{{.Fingerprint}}</code> <span class="meta">{{.Username}}{{if .LastSeen}}, last poll {{when .LastSeen}}{{else}}, never polled{{end}}{{if .BuildNumber}}, running {{.BuildRepo}} #{{.BuildNumber}} {{.BuildJob}}{{end}}</span> |
| 164 | <form method="post" action="{{$base}}" class="inline"> | 160 | <form method="post" action="{{$base}}" class="inline"> |
| 165 | <input type="hidden" name="field" value="runner-remove"> | 161 | <input type="hidden" name="field" value="runner-remove"> |
| 166 | <input type="hidden" name="fingerprint" value="{{.Fingerprint}}"> | 162 | <input type="hidden" name="fingerprint" value="{{.Fingerprint}}"> |
| 167 | <button type="submit" class="linklike">Detach</button> | 163 | <button type="submit" class="danger">Detach</button> |
| 168 | </form></li> | 164 | </form></li> |
| 169 | {{end}} | 165 | {{end}} |
| 170 | </ul> | 166 | </ul> |
| 171 | {{else}}<p class="meta">No runners attached. Builds for this repository run on the runners attached here; a repository with none queues builds nothing claims.</p>{{end}} | 167 | {{else}}<p class="meta">No runners attached. Builds for this repository run on the runners attached here; a repository with none queues builds nothing claims.</p>{{end}} |
| 172 | <form method="post" action="{{$base}}" class="setform"> | 168 | <form method="post" action="{{$base}}" class="setform stack"> |
| 173 | <input type="hidden" name="field" value="runner-add"> | 169 | <input type="hidden" name="field" value="runner-add"> |
| 174 | <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> | ||
| 175 | <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)"></textarea> |
| 176 | <button type="submit" class="btn">Attach</button> | 173 | <button type="submit" class="btn">Attach</button> |
| 177 | </form> | 174 | </form> |
| 178 | <p class="meta">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> | 175 | </section> |
| 179 | 176 | ||
| 180 | <h2>Lifecycle</h2> | 177 | <section id="lifecycle"><h2>Lifecycle</h2> |
| 181 | <form method="post" action="{{$base}}" class="setform"> | 178 | <form method="post" action="{{$base}}" class="setform"> |
| 182 | <input type="hidden" name="field" value="archive"> | 179 | <input type="hidden" name="field" value="archive"> |
| 183 | <label for="archive">Archived (read-only)</label> | 180 | <div><label for="archive">Archived</label><p class="hint">Read-only for everyone. Issues and requests close to new activity. Reversible.</p></div> |
| 184 | <input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}> | 181 | <div class="check"><input type="checkbox" id="archive" name="archive" value="on"{{if .Repo.Settings.Archived}} checked{{end}}></div> |
| 185 | <button type="submit" class="btn">Save archive</button> | 182 | <div><button type="submit" class="danger">Save</button></div> |
| 186 | </form> | 183 | </form> |
| 187 | <p class="meta">Deleting or transferring a repository is a CLI operation: | 184 | <p class="meta">Deleting or transferring a repository is a CLI operation: |
| 188 | <code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> | 185 | <code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> |
| 186 | </section> | ||
| 189 | {{end}} | 187 | {{end}} |
internal/web/templates/snippetnew.html +1
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}bounded{{end}} | ||
| 1 | {{define "title"}}new snippet · {{.Owner}}{{end}} | 2 | {{define "title"}}new snippet · {{.Owner}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | <h1>New snippet</h1> | 4 | <h1>New snippet</h1> |
internal/web/templates/tree.html +29 −26
| @@ -1,3 +1,4 @@ | |||
| 1 | {{define "width"}}wide{{end}} | ||
| 1 | {{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} | 2 | {{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | {{define "content"}} | 3 | {{define "content"}} |
| 3 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 4 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| @@ -5,35 +6,15 @@ | |||
| 5 | <div class="pathbar"> | 6 | <div class="pathbar"> |
| 6 | {{template "refmenu" .}} | 7 | {{template "refmenu" .}} |
| 7 | <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span> | 8 | <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span> |
| 8 | <span class="spacer"></span> | 9 | <div class="acts"> |
| 9 | <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Find file</a> | 10 | <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search">Search code</a> |
| 10 | <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a> | 11 | <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.Ref}}">History</a> |
| 11 | <a class="act" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a> | 12 | <a class="button btn" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">Download</a> |
| 13 | </div> | ||
| 12 | </div> | 14 | </div> |
| 13 | {{if .Entries}}<p class="clone">Clone: <code>git clone {{.SSHCloneURL}}</code> · <code>git clone {{.CloneURL}}</code></p>{{end}} | ||
| 14 | {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="facts"> | ||
| 15 | <p class="counts"> | ||
| 16 | <a href="{{$r}}/log/{{.Ref}}"><strong>{{.Facts.Commits}}</strong> commit{{if ne .Facts.Commits 1}}s{{end}}</a> | ||
| 17 | <a href="{{$r}}/refs"><strong>{{.Facts.Branches}}</strong> branch{{if ne .Facts.Branches 1}}es{{end}}</a> | ||
| 18 | <a href="{{$r}}/refs"><strong>{{.Facts.Tags}}</strong> tag{{if ne .Facts.Tags 1}}s{{end}}</a> | ||
| 19 | {{if .Facts.Bookmarks}}<span><strong>{{.Facts.Bookmarks}}</strong> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}} | ||
| 20 | {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}} | ||
| 21 | {{with .Facts.Release}}<a href="{{$r}}/releases">latest <strong>{{.}}</strong></a>{{end}} | ||
| 22 | {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}} | ||
| 23 | </p> | ||
| 24 | {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p> | ||
| 25 | <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}} | ||
| 26 | {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" .Title}}{{end}}</p>{{end}} | ||
| 27 | </div>{{end}} | ||
| 28 | {{with .Tip}}{{if .SHA}}<div class="tipbar"> | ||
| 29 | <span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span> | ||
| 30 | <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a> | ||
| 31 | <span class="spacer"></span> | ||
| 32 | <a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> | ||
| 33 | <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span> | ||
| 34 | </div>{{end}}{{end}} | ||
| 35 | <div class="tablewrap"> | 15 | <div class="tablewrap"> |
| 36 | <table class="tree"> | 16 | <table class="tree"> |
| 17 | {{with .Tip}}{{if .SHA}}<tr class="tipbar"><td colspan="3"><div class="tip"><span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span> <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a><span class="spacer"></span><a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span></div></td></tr>{{end}}{{end}} | ||
| 37 | <tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr> | 18 | <tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr> |
| 38 | {{range .Entries}}{{$c := index $.LastCommits .Name}}<tr> | 19 | {{range .Entries}}{{$c := index $.LastCommits .Name}}<tr> |
| 39 | {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td> | 20 | {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td> |
| @@ -48,4 +29,26 @@ | |||
| 48 | <div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div> | 29 | <div class="cardhead"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.Prefix}}{{.ReadmeName}}">{{.ReadmeName}}</a></div> |
| 49 | <div class="rendered">{{.ReadmeHTML}}</div> | 30 | <div class="rendered">{{.ReadmeHTML}}</div> |
| 50 | </section>{{end}} | 31 | </section>{{end}} |
| 32 | {{if and .Entries (not .DirPath)}}<div class="facts"> | ||
| 33 | <div class="clone"> | ||
| 34 | <h2>Clone</h2> | ||
| 35 | <label>SSH</label><pre><code>git clone {{.SSHCloneURL}}</code></pre> | ||
| 36 | <label>HTTPS</label><pre><code>git clone {{.CloneURL}}</code></pre> | ||
| 37 | </div> | ||
| 38 | {{if .Facts.Commits}}{{$r := printf "/%s/%s" .Repo.OwnerName .Repo.Name}}<div class="about"> | ||
| 39 | <h2>About</h2> | ||
| 40 | <div class="factgrid"> | ||
| 41 | <a href="{{$r}}/log/{{.Ref}}"><b>{{.Facts.Commits}}</b> commit{{if ne .Facts.Commits 1}}s{{end}}</a> | ||
| 42 | <a href="{{$r}}/refs"><b>{{.Facts.Branches}}</b> branch{{if ne .Facts.Branches 1}}es{{end}}</a> | ||
| 43 | <a href="{{$r}}/refs"><b>{{.Facts.Tags}}</b> tag{{if ne .Facts.Tags 1}}s{{end}}</a> | ||
| 44 | {{if .Facts.Bookmarks}}<span><b>{{.Facts.Bookmarks}}</b> bookmark{{if ne .Facts.Bookmarks 1}}s{{end}}</span>{{end}} | ||
| 45 | {{with .Facts.License}}<span class="fact">{{.}}</span>{{end}} | ||
| 46 | {{with .Facts.Release}}<a href="{{$r}}/releases">latest <b>{{.}}</b></a>{{end}} | ||
| 47 | {{with .Facts.Build}}<a href="{{$r}}/builds">build <span class="badge badge-{{.}}">{{.}}</span></a>{{end}} | ||
| 48 | </div> | ||
| 49 | {{with .Facts.Languages}}<p class="langbar" aria-hidden="true">{{range .}}<span class="lang lang-{{slug .Name}}" style="width:{{pct .Percent}}%"></span>{{end}}</p> | ||
| 50 | <p class="langs">{{range .}}<span class="lang-name"><span class="dot lang-{{slug .Name}}"></span>{{.Name}} <span class="muted">{{pct .Percent}}%</span></span>{{end}}</p>{{end}} | ||
| 51 | {{with .Facts.Contributors}}<p class="contribs"><span class="label">{{len .}} contributor{{if ne (len .) 1}}s{{end}}</span>{{range .}}{{template "authorname" dict "Name" .Name "User" .User "Email" .Title}}{{end}}</p>{{end}} | ||
| 52 | </div>{{end}} | ||
| 53 | </div>{{end}} | ||
| 51 | {{end}} | 54 | {{end}} |
internal/web/web.go +1
| @@ -62,6 +62,7 @@ var fullVersion = sync.OnceValue(func() string { | |||
| 62 | var funcs = template.FuncMap{ | 62 | var funcs = template.FuncMap{ |
| 63 | "gitbayVersion": func() string { return version() }, | 63 | "gitbayVersion": func() string { return version() }, |
| 64 | "gitbayCommit": func() string { return fullVersion() }, | 64 | "gitbayCommit": func() string { return fullVersion() }, |
| 65 | "join": strings.Join, | ||
| 65 | // paragraphs splits plain text on blank lines for safe rich display. | 66 | // paragraphs splits plain text on blank lines for safe rich display. |
| 66 | "paragraphs": func(s string) []string { | 67 | "paragraphs": func(s string) []string { |
| 67 | var out []string | 68 | var out []string |
internal/web/web_test.go +33
| @@ -83,3 +83,36 @@ func TestWhenNamesTheZone(t *testing.T) { | |||
| 83 | t.Fatalf("passthrough: %q", got) | 83 | t.Fatalf("passthrough: %q", got) |
| 84 | } | 84 | } |
| 85 | } | 85 | } |
| 86 | |||
| 87 | // TestMainWidthClass renders every page against an empty struct and | ||
| 88 | // checks main carries exactly one width class, and that the pages the | ||
| 89 | // spec calls wide or bounded say so. | ||
| 90 | func TestMainWidthClass(t *testing.T) { | ||
| 91 | 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 | 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 | for _, name := range Pages() { | ||
| 94 | src, err := TemplateSource(name) | ||
| 95 | if err != nil { | ||
| 96 | t.Fatal(err) | ||
| 97 | } | ||
| 98 | has := strings.Contains(src, `{{define "width"}}`) | ||
| 99 | switch { | ||
| 100 | case wide[name] && !strings.Contains(src, `{{define "width"}}wide{{end}}`): | ||
| 101 | t.Errorf("%s: want width wide", name) | ||
| 102 | case bounded[name] && !strings.Contains(src, `{{define "width"}}bounded{{end}}`): | ||
| 103 | t.Errorf("%s: want width bounded", name) | ||
| 104 | case !wide[name] && !bounded[name] && has: | ||
| 105 | t.Errorf("%s: defines a width but the spec calls it reading", name) | ||
| 106 | } | ||
| 107 | } | ||
| 108 | layout, err := TemplateSource("layout.html") | ||
| 109 | if err != nil { | ||
| 110 | t.Fatal(err) | ||
| 111 | } | ||
| 112 | if !strings.Contains(layout, `<main id="content" class="content {{template "width" .}}">`) { | ||
| 113 | t.Error("layout.html: main does not carry the width block") | ||
| 114 | } | ||
| 115 | if !strings.Contains(layout, `{{define "width"}}reading{{end}}`) { | ||
| 116 | t.Error("layout.html: no default width") | ||
| 117 | } | ||
| 118 | } | ||