web: the desktop layout !436

merged merged by cmc on 2026-09-19 23:21 UTC · krz/gitbay:desktop-layout-spec into main

41 files changed, +3223 −168

Layout: unified · split

CHANGELOG.org +25
@@ -4,6 +4,31 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* v1.31.0 — unreleased
8
9The desktop layout (#226): the web UI uses a wide screen.
10
11- One centered container at 100rem; the repository header, main and
12 footer align on it. Text keeps its measure.
13- The repository header is two rows: name, description and buttons,
14 then the tabs.
15- Issue, merge request, build, explore, search and notification rows
16 are one line above 64rem, and those pages render at the container
17 width.
18- The dashboard is three columns: pinned repositories with open issue,
19 merge request and last-build counts; a tile per queue with the queue
20 rows below; the activity feed.
21- A file navigator beside blob, blame and edit pages lists the file's
22 directory and marks the file.
23- Side columns: state, labels and open milestones on the issue and
24 merge request lists; status, jobs and branches on builds; topics on
25 explore; kinds on search; sections on repository settings, account
26 settings and admin.
27- Below 62rem the facet and section columns stack after their content
28 and the navigator hides, since the tree page is the navigator on a
29 phone. Below 80rem the dashboard's pinned repositories return to a
30 chip row above the queues.
31
7* v1.30.0 — 2026-09-19 32* v1.30.0 — 2026-09-19
8 33
9Every command runs on every surface, markup previews before it is 34Every command runs on every surface, markup previews before it is
docs/plans/2026-09-19-desktop-layout.md added +1896
@@ -0,0 +1,1896 @@
1# Desktop Layout Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Make the web UI use a desktop screen: one centered container, per-page left columns that each carry a feature, one-line list rows, a three-column dashboard, and a two-row repository header.
6
7**Architecture:** Every change is CSS on existing tokens plus template markup, with three handler additions: a directory listing beside a file, facet counts beside a list, and per-repository counts beside the pinned list. No new control command, no migration. Each page keeps working at phone widths because the columns stack below 64rem.
8
9**Tech Stack:** Go 1.2x, `html/template`, one stylesheet (`internal/web/static/style.css`), `go test` unit tests in `internal/httpd` and `internal/web`, e2e tests in `e2e/` against a real instance.
10
11**Spec:** `docs/specs/2026-09-19-desktop-layout-design.md`
12
13## Global Constraints
14
15- Branch `desktop-layout-spec` already holds the spec commit; all work lands on it. Never push to `main`; the MR at the end merges with `--strategy ff` (signed commits required).
16- Commit subjects follow the log: `web: ...`, `httpd: ...`, `e2e: ...`, `CHANGELOG: ...`, lowercase after the prefix, no trailer, no attribution of any kind. Reference `Ref #226` in bodies.
17- `--container: 100rem`. Every left column is `15rem`, sticky. Text stays at 48rem / 78ch. Columns stack below `64rem`.
18- Colors and spacing use existing tokens only (`--sp-*`, `--fs-*`, `--surface`, `--line`, `--faint`, `--muted`, `--mark`, `--warn`, `--fg`, `--link`, `--hover`, `--r-ctl`, `--r-card`). Never a hex value in a rule.
19- `TestEveryTemplateClassHasARule` (`internal/web/classes_test.go`) fails on any class a template uses that no `style.css` selector names. Add the rule in the same step as the markup.
20- Local verification per task: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/`, plus at most the one e2e test the task touches (`go test ./e2e -run TestName -count=1`). The full suite runs in CI on push.
21- Do not mention Claude, LLMs or assistants anywhere: commits, comments, CHANGELOG, wiki.
22
23---
24
25### Task 1: One centered container
26
27**Files:**
28- Modify: `internal/web/static/style.css:366-388` (shell section), `:389-395` (repohead), `:1481-1489` (52rem breakpoint)
29- Modify: `internal/web/templates/layout.html:39-69` (repohead)
30- Test: `internal/web/layout_test.go` (create)
31
32**Interfaces:**
33- Produces: the `.repohead .wrap` element every later header change lives in; `--container` token used by Task 4 and Task 9.
34
35- [ ] **Step 1: Write the failing test**
36
37```go
38package web
39
40import (
41 "strings"
42 "testing"
43)
44
45// The repository header, main and footer share one centered container:
46// the header's inner content is wrapped, and the stylesheet caps and
47// centers all three on the same token (desktop layout spec).
48func TestSharedCenteredContainer(t *testing.T) {
49 layout, err := templateFS.ReadFile("templates/layout.html")
50 if err != nil {
51 t.Fatal(err)
52 }
53 if !strings.Contains(string(layout), `<header class="repohead">\n<div class="wrap">`) &&
54 !strings.Contains(string(layout), "<header class=\"repohead\">\n<div class=\"wrap\">") {
55 t.Fatalf("repohead is not wrapped in .wrap")
56 }
57 css := string(StyleCSS)
58 for _, want := range []string{
59 "--container: 100rem;",
60 "main.content, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }",
61 ".repohead .wrap { max-width: var(--container); margin: 0 auto; }",
62 "main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }",
63 "main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }",
64 } {
65 if !strings.Contains(css, want) {
66 t.Errorf("style.css lacks %q", want)
67 }
68 }
69}
70```
71
72- [ ] **Step 2: Run it to see it fail**
73
74Run: `go test ./internal/web/ -run TestSharedCenteredContainer`
75Expected: FAIL, "repohead is not wrapped in .wrap"
76
77- [ ] **Step 3: Add the token and the container rules**
78
79In `style.css`, inside `:root {` after the `--rail-mark-box: 32px;` line (around line 111), add:
80
81```css
82 /* the one page container: header content, main and footer align on it */
83 --container: 100rem;
84```
85
86Replace lines 371-377 (the `main.content` rule and the three width rules) with:
87
88```css
89main.content {
90 flex: 1;
91 width: 100%;
92 padding: var(--sp-5) var(--sp-6) var(--sp-7);
93}
94main.content, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }
95main.wide { max-width: calc(var(--container) + 2 * var(--sp-6)); }
96main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }
97main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }
98```
99
100Replace the `.repohead` rule (line 390-393) with:
101
102```css
103.repohead {
104 padding: var(--sp-4) 0 0;
105 border-bottom: 1px solid var(--line);
106}
107.repohead .wrap { max-width: var(--container); margin: 0 auto; padding: 0 var(--sp-6); }
108```
109
110In the `@media (max-width: 52rem)` block, change `.repohead { padding: var(--sp-3) var(--sp-4) 0; }` to:
111
112```css
113 .repohead { padding: var(--sp-3) 0 0; }
114 .repohead .wrap { padding: 0 var(--sp-4); }
115```
116
117- [ ] **Step 4: Wrap the header content**
118
119In `layout.html`, line 39 `<header class="repohead">` becomes:
120
121```html
122<header class="repohead">
123<div class="wrap">
124```
125
126and line 69 `</header>` becomes:
127
128```html
129</div>
130</header>
131```
132
133- [ ] **Step 5: Run the tests**
134
135Run: `go test ./internal/web/ ./internal/httpd/`
136Expected: PASS (the classes test sees `.wrap` in the stylesheet)
137
138- [ ] **Step 6: Commit**
139
140```bash
141git add internal/web/static/style.css internal/web/templates/layout.html internal/web/layout_test.go
142git commit -m "web: one centered container for header, main and footer
143
144Ref #226"
145```
146
147---
148
149### Task 2: Repository header on two rows
150
151**Files:**
152- Modify: `internal/web/templates/layout.html:41-58`
153- Modify: `internal/web/static/style.css:396-425` (identity, repodesc, repometa, toggles rules)
154- Test: `internal/httpd/repohead_test.go` (create)
155
156**Interfaces:**
157- Consumes: `testRepoPage()` from `internal/httpd/buildpages_test.go:12`.
158
159- [ ] **Step 1: Write the failing test**
160
161```go
162package httpd
163
164import (
165 "strings"
166 "testing"
167
168 "gitbay.org/gitbay/internal/control"
169 "gitbay.org/gitbay/internal/web"
170)
171
172// The repository header is two rows: identity with the description and
173// the buttons, then the tabs. The toggles hint is title text on the
174// buttons, not a line of its own (desktop layout spec).
175func TestRepoHeaderTwoRows(t *testing.T) {
176 var sb strings.Builder
177 p := testRepoPage()
178 p.Viewer = "alice"
179 p.Desc = "A CLI-first git forge."
180 p.Topics = []string{"cli"}
181 p.Tab = "files"
182 err := web.Render(&sb, "builds.html", struct {
183 repoPage
184 Builds []control.BuildOut
185 Jobs []control.JobOut
186 Runs []buildRun
187 Filter buildFilter
188 FilterLinks []buildFilterLink
189 Refs []string
190 CanWrite bool
191 Notice string
192 }{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""})
193 if err != nil {
194 t.Fatal(err)
195 }
196 out := sb.String()
197 if strings.Contains(out, `class="toggles"`) || strings.Contains(out, "Pinned shows on your dashboard.") {
198 t.Error("the toggles hint still renders as a line")
199 }
200 for _, want := range []string{
201 `title="Pinned repositories show on your dashboard"`,
202 `title="Watching sends every issue, request and build to your inbox"`,
203 `title="Bookmarked lists it under Bookmarks"`,
204 `<p class="repodesc">A CLI-first git forge.`,
205 } {
206 if !strings.Contains(out, want) {
207 t.Errorf("header lacks %q", want)
208 }
209 }
210 // the description sits inside the identity row, before the buttons
211 if strings.Index(out, `class="repodesc"`) > strings.Index(out, `action="/krz/gitbay/pin"`) {
212 t.Error("description renders after the buttons; it belongs in the identity row")
213 }
214}
215```
216
217- [ ] **Step 2: Run it to see it fail**
218
219Run: `go test ./internal/httpd/ -run TestRepoHeaderTwoRows`
220Expected: FAIL on the `title=` strings
221
222- [ ] **Step 3: Rewrite the identity row**
223
224Replace `layout.html` lines 41-58 (from `<div class="identity">` through the `{{end}}` that closes `{{if eq $top "code"}}`) with:
225
226```html
227 {{$top := topTab (str $ "Tab")}}
228 <div class="identity">
229 {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
230 {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
231 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
232 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
233 {{/* Description, topics and website belong to the code tab, inline
234 with the name so the header is two rows on every page. */}}
235 {{if eq $top "code"}}{{if or (field $ "Desc") (field $ "Topics") $.Repo.Settings.Website}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}{{with $.Repo.Settings.Website}}<a class="site" href="{{.}}" rel="nofollow">{{.}}</a>{{end}}</p>{{end}}{{end}}
236 <span class="grow"></span>
237 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
238 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
239 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
240 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
241 </div>
242 {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}}
243```
244
245Delete the old `{{$top := ...}}` line that followed the identity block (it now sits above it) and the `<p class="toggles">` line.
246
247- [ ] **Step 4: Style the inline description and remove the toggles rule**
248
249In `style.css` replace the `.repodesc` rule (lines 412-417) and delete the `.toggles` rule (line 419):
250
251```css
252.repodesc {
253 margin: 0 0 0 var(--sp-2);
254 color: var(--muted);
255 font-size: var(--fs-2);
256 display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
257 min-width: 0;
258}
259.repodesc a.site { color: var(--muted); }
260.repodesc a.site:hover { color: var(--link); }
261```
262
263Add under the `@media (max-width: 52rem)` block:
264
265```css
266 /* a phone shows the description under the name, not beside it */
267 .repodesc { flex-basis: 100%; margin-left: 0; }
268```
269
270- [ ] **Step 5: Run the tests**
271
272Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestTreeSearchCodeAndClone' -count=1`
273Expected: PASS
274
275- [ ] **Step 6: Commit**
276
277```bash
278git add internal/web/templates/layout.html internal/web/static/style.css internal/httpd/repohead_test.go
279git commit -m "web: the repository header is two rows
280
281Description, topics and website sit beside the name; the toggles hint is
282title text on the buttons.
283
284Ref #226"
285```
286
287---
288
289### Task 3: One-line list rows and wide list pages
290
291**Files:**
292- Modify: `internal/web/templates/issues.html:24-32`, `mrs.html:19-32`, `notifications.html:12-21`, `globalsearch.html:22-31`, `dashboard.html:2-12`, `explore.html:1`
293- Modify: `internal/web/static/style.css:892-907` (issuelist), `:867-877` (repolist), `:104-109` of `layout.html` (reporow)
294- Test: `internal/web/widths_test.go` (create)
295
296**Interfaces:**
297- Produces: `ul.issuelist.rows` and `ul.repolist.rows`, the row format every list task after this reuses.
298
299- [ ] **Step 1: Write the failing test**
300
301```go
302package web
303
304import (
305 "strings"
306 "testing"
307)
308
309// List pages and the dashboard render at the container width; text pages
310// keep the reading cap (desktop layout spec).
311func TestListPagesAreWide(t *testing.T) {
312 wide := []string{"dashboard.html", "issues.html", "mrs.html", "explore.html", "notifications.html", "globalsearch.html", "builds.html"}
313 for _, name := range wide {
314 src, err := templateFS.ReadFile("templates/" + name)
315 if err != nil {
316 t.Fatal(err)
317 }
318 if !strings.HasPrefix(string(src), `{{define "width"}}wide{{end}}`) {
319 t.Errorf("%s does not declare width wide", name)
320 }
321 }
322 for _, name := range []string{"issue.html", "wiki.html", "owner.html"} {
323 src, _ := templateFS.ReadFile("templates/" + name)
324 if strings.Contains(string(src), `{{define "width"}}wide{{end}}`) {
325 t.Errorf("%s is a text page and must not be wide", name)
326 }
327 }
328 for _, name := range []string{"issues.html", "mrs.html", "notifications.html", "globalsearch.html", "dashboard.html"} {
329 src, _ := templateFS.ReadFile("templates/" + name)
330 if !strings.Contains(string(src), `<ul class="issuelist rows">`) {
331 t.Errorf("%s does not use one-line rows", name)
332 }
333 }
334 if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) {
335 t.Error("explore.html does not use one-line rows")
336 }
337}
338```
339
340- [ ] **Step 2: Run it to see it fail**
341
342Run: `go test ./internal/web/ -run TestListPagesAreWide`
343Expected: FAIL for every listed template
344
345- [ ] **Step 3: Add the row rules**
346
347After the `ul.issuelist .title a:hover` rule (line 907) add:
348
349```css
350/* one-line rows: title, labels, then the meta pushed right. Above 64rem
351 a list is a table, not prose (desktop layout spec). */
352ul.issuelist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); }
353ul.issuelist.rows .issuemain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
354ul.issuelist.rows .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
355ul.issuelist.rows .title .chip { margin-left: var(--sp-1); }
356ul.issuelist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; }
357ul.issuelist.rows .meta .repo { color: var(--fg); }
358```
359
360After the `ul.repolist .topics, ul.repolist .meta` rule (line 877) add:
361
362```css
363ul.repolist.rows li { display: flex; align-items: baseline; gap: var(--sp-3); padding: var(--sp-2) var(--sp-4); }
364ul.repolist.rows .reponame { flex: none; font-size: var(--fs-2); }
365ul.repolist.rows .desc { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: var(--fs-2); margin: 0; }
366ul.repolist.rows .topics, ul.repolist.rows .meta { flex: none; margin: 0; white-space: nowrap; }
367ul.repolist.rows .meta { font-size: var(--fs-1); }
368```
369
370After the `ul.loglist .commitside` rule (line 865) add, for the builds page (Task 7 applies it):
371
372```css
373ul.loglist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); }
374ul.loglist.rows .commitmain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
375ul.loglist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; }
376```
377
378In the `@media (max-width: 62rem)` block add:
379
380```css
381 /* rows go back to two lines where one does not fit */
382 ul.issuelist.rows .issuemain, ul.repolist.rows li, ul.loglist.rows .commitmain { display: block; }
383 ul.issuelist.rows .title, ul.repolist.rows .desc { white-space: normal; }
384```
385
386- [ ] **Step 4: Change the templates**
387
388`issues.html`: line 1 becomes two lines:
389
390```
391{{define "width"}}wide{{end}}
392{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
393```
394
395and `<ul class="issuelist">` becomes `<ul class="issuelist rows">`. The `<li>` body is unchanged: the CSS makes it one line.
396
397`mrs.html`: same two changes (`merge requests ·` title).
398
399`notifications.html`: add `{{define "width"}}wide{{end}}` as line 1; `<ul class="issuelist">` → `<ul class="issuelist rows">`.
400
401`globalsearch.html`: `<ul class="issuelist">` → `<ul class="issuelist rows">` (already wide).
402
403`dashboard.html`: add `{{define "width"}}wide{{end}}` as line 1; in the `itemlist` define, `<ul class="issuelist">` → `<ul class="issuelist rows">`, and the meta line becomes:
404
405```html
406 <p class="meta"><span class="repo">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}}</span> · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p>
407```
408
409`explore.html`: add `{{define "width"}}wide{{end}}` as line 1; `<ul class="repolist">` → `<ul class="repolist rows">`.
410
411- [ ] **Step 5: Run the tests**
412
413Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestMRListRows|TestIssueWebTriage|TestDashboard$' -count=1`
414Expected: PASS
415
416- [ ] **Step 6: Commit**
417
418```bash
419git add internal/web
420git commit -m "web: one-line rows on the list pages, at the container width
421
422Ref #226"
423```
424
425---
426
427### Task 4: Dashboard as three columns with count tiles and pinned counts
428
429**Files:**
430- Modify: `internal/httpd/web.go:185-200` (dashboard handler)
431- Create: `internal/httpd/dashpins.go`, `internal/httpd/dashpins_test.go`
432- Modify: `internal/web/templates/dashboard.html`
433- Modify: `internal/web/static/style.css` (after the `.pinned` rules, line 994; after `.feed` rules, line 1030)
434- Modify: `e2e/dashboard_test.go:95-119`, `:354-357`
435
436**Interfaces:**
437- Consumes: `s.st.PinnedRepos(userID int64) ([]store.Repo, error)`, `s.st.OpenCounts(repoID int64) (issues, mrs int)`, `s.st.ListBuilds(repoID int64, f store.BuildFilter, limit int) ([]store.Build, error)`, `policy.CanRead(viewer, repo, grant)`, `s.st.AccessRole(repoID, userID)`.
438- Produces: `pinnedRow` and `func (s *Server) pinnedRows(viewer store.User) []pinnedRow`.
439
440- [ ] **Step 1: Write the failing test**
441
442`internal/httpd/dashpins_test.go`:
443
444```go
445package httpd
446
447import (
448 "strings"
449 "testing"
450
451 "gitbay.org/gitbay/internal/store"
452 "gitbay.org/gitbay/internal/web"
453)
454
455// The dashboard is three columns: pinned repositories with counts, the
456// tile strip and queue rows, the activity feed. Tiles carry every queue's
457// count; only a non-empty queue lists rows (desktop layout spec).
458func TestDashboardTilesAndPins(t *testing.T) {
459 var sb strings.Builder
460 var base basePage
461 base.Viewer = "alice"
462 err := web.Render(&sb, "dashboard.html", struct {
463 basePage
464 Tab string
465 Pins []pinnedRow
466 Reviews []store.DashboardItem
467 Assigned []store.DashboardItem
468 MRs []store.DashboardItem
469 Issues []store.DashboardItem
470 Feed []feedLine
471 }{base, "dashboard", []pinnedRow{{Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}}, nil, nil, nil,
472 []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil})
473 if err != nil {
474 t.Fatal(err)
475 }
476 out := sb.String()
477 for _, want := range []string{
478 `<div class="dashgrid">`,
479 `<aside class="dashpins" aria-label="Pinned repositories">`,
480 `<span class="owner">krz/</span>gitbay</a>`,
481 `<b class="wants">3</b>`, `<span class="dot ok"></span>`,
482 `<a class="tile wants" href="#issues"><b>1</b><span>open issues</span></a>`,
483 `<div class="tile"><b>0</b><span>waiting on your review</span></div>`,
484 `<div class="tile"><b>0</b><span>assigned to you</span></div>`,
485 `<div class="tile"><b>0</b><span>open merge requests</span></div>`,
486 `<h2 id="issues">Open issues <span class="count">1</span></h2>`,
487 `<aside class="feedcol" aria-label="Recent activity">`,
488 } {
489 if !strings.Contains(out, want) {
490 t.Errorf("dashboard lacks %q", want)
491 }
492 }
493 if strings.Contains(out, `<h2 class="empty">`) {
494 t.Error("an empty queue still renders as a heading; the tile carries it")
495 }
496}
497```
498
499- [ ] **Step 2: Run it to see it fail**
500
501Run: `go test ./internal/httpd/ -run TestDashboardTilesAndPins`
502Expected: FAIL, "undefined: pinnedRow"
503
504- [ ] **Step 3: The pinned rows read**
505
506`internal/httpd/dashpins.go`:
507
508```go
509package httpd
510
511import (
512 "gitbay.org/gitbay/internal/policy"
513 "gitbay.org/gitbay/internal/store"
514)
515
516// pinnedRow is one pinned repository on the dashboard with the counts
517// that say whether it wants attention: open issues, open merge requests
518// and the newest build's status ("" when it has none).
519type pinnedRow struct {
520 Owner string
521 Name string
522 Issues int
523 MRs int
524 Build string
525}
526
527// pinnedRows reads the viewer's pinned repositories the way railFor does,
528// then adds the counts. Three reads per pinned repository, on the
529// dashboard only.
530func (s *Server) pinnedRows(viewer store.User) []pinnedRow {
531 pinned, _ := s.st.PinnedRepos(viewer.ID)
532 var rows []pinnedRow
533 for _, rp := range pinned {
534 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
535 if !policy.CanRead(viewer, rp, grant) {
536 continue
537 }
538 row := pinnedRow{Owner: rp.OwnerName, Name: rp.Name}
539 row.Issues, row.MRs = s.st.OpenCounts(rp.ID)
540 if builds, err := s.st.ListBuilds(rp.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 {
541 row.Build = builds[0].Status
542 }
543 rows = append(rows, row)
544 }
545 return rows
546}
547```
548
549In `web.go` `dashboard`, add `Pins []pinnedRow` to the struct after `Tab` and pass `s.pinnedRows(viewer)`:
550
551```go
552 s.render(w, "dashboard.html", struct {
553 basePage
554 Tab string
555 Pins []pinnedRow
556 Reviews []store.DashboardItem
557 Assigned []store.DashboardItem
558 MRs []store.DashboardItem
559 Issues []store.DashboardItem
560 Feed []feedLine
561 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)})
562```
563
564- [ ] **Step 4: The template**
565
566Replace `dashboard.html` from `{{define "queue"}}` to the end with:
567
568```
569{{define "queue"}}
570<h2 id="{{$.ID}}">{{$.Title}} <span class="count">{{len $.Items}}</span></h2>
571{{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}}
572{{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}}
573{{end}}
574{{define "tile"}}{{if $.N}}<a class="tile wants" href="#{{$.ID}}"><b>{{$.N}}</b><span>{{$.Label}}</span></a>{{else}}<div class="tile"><b>0</b><span>{{$.Label}}</span></div>{{end}}{{end}}
575{{define "content"}}
576<div class="dashgrid">
577
578<aside class="dashpins" aria-label="Pinned repositories">
579 <h2 class="colhead">Pinned</h2>
580 {{if .Pins}}<ul class="pins">
581 {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}">{{if .Issues}}<b class="wants">{{.Issues}}</b>{{else}}<b>0</b>{{end}} {{if .MRs}}<b class="wants">{{.MRs}}</b>{{else}}<b>0</b>{{end}} <span class="dot{{if eq .Build "success"}} ok{{else if eq .Build "failure"}} bad{{else if .Build}} pend{{end}}"></span></span></li>
582 {{end}}</ul>
583 <p class="meta">issues · merge requests · last build</p>
584 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
585</aside>
586
587<section class="dashmain">
588<h1>Dashboard</h1>
589<div class="tiles">
590 {{template "tile" dict "N" (len .Issues) "ID" "issues" "Label" "open issues"}}
591 {{template "tile" dict "N" (len .Reviews) "ID" "reviews" "Label" "waiting on your review"}}
592 {{template "tile" dict "N" (len .Assigned) "ID" "assigned" "Label" "assigned to you"}}
593 {{template "tile" dict "N" (len .MRs) "ID" "mrs" "Label" "open merge requests"}}
594</div>
595{{if .Reviews}}{{template "queue" dict "ID" "reviews" "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}}
596{{if .Assigned}}{{template "queue" dict "ID" "assigned" "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}}
597{{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
598{{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
599{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open anywhere you can write to.</p>{{end}}
600</section>
601
602<aside class="feedcol" aria-label="Recent activity">
603 <h2 class="colhead">Recent activity</h2>
604 {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
605 {{else}}<p class="none">No activity yet</p>{{end}}
606</aside>
607
608</div>
609{{end}}
610```
611
612The `dict` helper takes key/value pairs; `len .Issues` inside `dict` needs parentheses, as written.
613
614- [ ] **Step 5: The stylesheet**
615
616Replace the `.pinned` rules (lines 992-994) with:
617
618```css
619/* ---- dashboard: pinned column, tiles and queues, feed ---- */
620.dashgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; }
621.dashgrid h1 { margin-top: 0; }
622.dashgrid > aside { position: sticky; top: var(--sp-5); }
623.dashgrid .dashmain h2 { margin-top: var(--sp-5); }
624.dashgrid .dashmain .tiles + h2 { margin-top: 0; }
625.colhead {
626 margin: 0 0 var(--sp-2);
627 font-size: var(--fs-0);
628 font-weight: 500;
629 letter-spacing: 0.08em;
630 text-transform: uppercase;
631 color: var(--muted);
632}
633.pins { list-style: none; margin: 0; padding: 0; font-size: var(--fs-2); }
634.pins li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; border-bottom: 1px solid var(--faint); }
635.pins li:last-child { border-bottom: 0; }
636.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
637.pins a:hover { color: var(--link); }
638.pins .owner { color: var(--muted); }
639.pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; }
640.pins .n b { font-weight: 600; color: var(--fg); }
641.pins .n b.wants { color: var(--warn); }
642.pins .dot { margin: 0; }
643/* count tiles: the queues' sizes in one strip; a non-zero count is orange,
644 what wants you */
645.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin: var(--sp-4) 0 var(--sp-5); }
646.tile {
647 display: block;
648 background: var(--surface);
649 border: 1px solid var(--line);
650 border-radius: var(--r-card);
651 padding: var(--sp-3) var(--sp-4);
652 color: var(--fg);
653}
654a.tile:hover { background: var(--hover); text-decoration: none; }
655.tile b { display: block; font-size: var(--fs-5); font-weight: 600; line-height: 1.2; }
656.tile.wants b { color: var(--warn); }
657.tile span { font-size: var(--fs-1); color: var(--muted); }
658.feedcol .feedline { font-size: var(--fs-2); margin-bottom: var(--sp-2); }
659.feedcol .feedline .none { font-size: var(--fs-1); }
660```
661
662Delete the `.feed` rules (lines 1026-1030): nothing uses `.feed` any more. Keep `.feedline`.
663
664Add to the `@media (max-width: 62rem)` block:
665
666```css
667 .dashgrid { grid-template-columns: 1fr; }
668 .dashgrid > aside { position: static; }
669 /* the pinned column goes back to a chip row on a phone */
670 .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
671 .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
672 .pins .n, .dashpins .meta { display: none; }
673 .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
674```
675
676And a new block, above the 62rem one:
677
678```css
679@media (max-width: 80rem) {
680 .dashgrid { grid-template-columns: minmax(0, 1fr) 20rem; }
681 .dashgrid > .dashpins { grid-column: 1 / -1; position: static; }
682}
683```
684
685- [ ] **Step 6: Update the e2e assertions**
686
687In `e2e/dashboard_test.go` replace lines 110-118 (the `emptyHeading` block) with:
688
689```go
690 // The empty queue is a tile with a zero; a populated one is a tile
691 // that links to its rows, which render in the middle column.
692 if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) {
693 t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body)
694 }
695 if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) {
696 t.Fatalf("dashboard missing the review tile:\n%s", body)
697 }
698 if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) {
699 t.Fatalf("queues do not render as tiles plus rows:\n%s", body)
700 }
701```
702
703Replace lines 354-357 with:
704
705```go
706 if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) {
707 t.Fatalf("reviewed MR still waiting:\n%s", after)
708 }
709```
710
711In the `want` list at line 96-100, keep every entry; add `` `class="pins"` ``.
712
713- [ ] **Step 7: Run the tests**
714
715Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestDashboard' -count=1`
716Expected: PASS
717
718- [ ] **Step 8: Commit**
719
720```bash
721git add internal/httpd/dashpins.go internal/httpd/dashpins_test.go internal/httpd/web.go internal/web e2e/dashboard_test.go
722git commit -m "web: the dashboard is three columns
723
724Pinned repositories with counts, a tile per queue, the feed as an aside.
725
726Ref #226"
727```
728
729---
730
731### Task 5: File navigator beside blob, blame and edit
732
733**Files:**
734- Create: `internal/httpd/filenav.go`, `internal/httpd/filenav_test.go`
735- Modify: `internal/httpd/web.go:512-527` (extract the sort), `:559-604` (blob), `:813-896` (blame)
736- Modify: `internal/httpd/accounts.go:474-518` (editPage, editForm)
737- Modify: `internal/web/templates/layout.html` (new `filenav` partial), `blob.html`, `blame.html`, `edit.html`
738- Modify: `internal/web/static/style.css` (after the `.pathbar` rules, line 1287)
739- Create: `e2e/filenav_test.go`
740
741**Interfaces:**
742- Consumes: `gitutil.ListTree(dir, ref, path string) ([]gitutil.TreeEntry, error)`, `gitutil.TreeEntry{Type, Name}`, `store.Repo.Path() string`.
743- Produces: `fileNav`, `func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav`, `func sortDirsFirst(entries []gitutil.TreeEntry)`.
744
745- [ ] **Step 1: Write the failing unit test**
746
747`internal/httpd/filenav_test.go`:
748
749```go
750package httpd
751
752import (
753 "testing"
754
755 "gitbay.org/gitbay/internal/gitutil"
756)
757
758// The navigator lists the file's directory, directories first, links each
759// entry to its tree or blob page, marks the file itself, and links the
760// parent (the tree root when the file is at the top).
761func TestFileNavMarksCurrentAndLinksParent(t *testing.T) {
762 entries := []gitutil.TreeEntry{
763 {Type: "blob", Name: "main.go"},
764 {Type: "tree", Name: "sub"},
765 {Type: "blob", Name: "util.go"},
766 }
767 nav := fileNavFor("krz/gitbay", "main", "cmd/gitbay/util.go", entries)
768 if nav.Title != "cmd/gitbay" {
769 t.Errorf("title = %q", nav.Title)
770 }
771 if nav.Parent != "/krz/gitbay/tree/main/cmd" {
772 t.Errorf("parent = %q", nav.Parent)
773 }
774 if len(nav.Entries) != 3 || nav.Entries[0].Name != "sub/" || !nav.Entries[0].Dir {
775 t.Fatalf("entries not directories-first: %+v", nav.Entries)
776 }
777 if nav.Entries[0].URL != "/krz/gitbay/tree/main/cmd/gitbay/sub" {
778 t.Errorf("dir url = %q", nav.Entries[0].URL)
779 }
780 if nav.Entries[2].Name != "util.go" || !nav.Entries[2].Current || nav.Entries[2].URL != "/krz/gitbay/blob/main/cmd/gitbay/util.go" {
781 t.Errorf("current entry: %+v", nav.Entries[2])
782 }
783 if nav.Entries[1].Current {
784 t.Error("main.go marked current")
785 }
786
787 root := fileNavFor("krz/gitbay", "main", "Makefile", []gitutil.TreeEntry{{Type: "blob", Name: "Makefile"}})
788 if root.Title != "gitbay" || root.Parent != "" {
789 t.Errorf("root nav: title %q parent %q", root.Title, root.Parent)
790 }
791}
792```
793
794- [ ] **Step 2: Run it to see it fail**
795
796Run: `go test ./internal/httpd/ -run TestFileNavMarksCurrentAndLinksParent`
797Expected: FAIL, "undefined: fileNavFor"
798
799- [ ] **Step 3: The navigator**
800
801`internal/httpd/filenav.go`:
802
803```go
804package httpd
805
806import (
807 "path"
808 "sort"
809
810 "gitbay.org/gitbay/internal/gitutil"
811)
812
813// fileNav is the column beside a file: its directory's entries, the file
814// marked, and a link up. It is the tree page's listing rendered as a
815// list, so reading a repository does not mean going back for each file.
816type fileNav struct {
817 Title string // the directory, or the repository name at the root
818 Parent string // URL of the parent tree; "" at the root
819 Entries []fileNavEntry
820}
821
822type fileNavEntry struct {
823 Name string // directories carry a trailing slash
824 URL string
825 Dir bool
826 Current bool
827}
828
829// sortDirsFirst orders a listing by shape before name, stably, so each
830// group keeps the order git gave it. The tree page and the navigator
831// share it.
832func sortDirsFirst(entries []gitutil.TreeEntry) {
833 sort.SliceStable(entries, func(i, j int) bool {
834 return entries[i].Type == "tree" && entries[j].Type != "tree"
835 })
836}
837
838// fileNavFor builds the navigator for filePath from its directory's
839// entries. repoPath is owner/name.
840func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav {
841 dir := path.Dir(filePath)
842 if dir == "." {
843 dir = ""
844 }
845 base := "/" + repoPath
846 nav := fileNav{Title: dir}
847 if dir == "" {
848 nav.Title = path.Base(repoPath)
849 } else if up := path.Dir(dir); up == "." {
850 nav.Parent = base + "/tree/" + ref
851 } else {
852 nav.Parent = base + "/tree/" + ref + "/" + up
853 }
854 sortDirsFirst(entries)
855 for _, e := range entries {
856 full := path.Join(dir, e.Name)
857 ent := fileNavEntry{Name: e.Name, Dir: e.Type == "tree", Current: full == filePath}
858 if ent.Dir {
859 ent.Name += "/"
860 ent.URL = base + "/tree/" + ref + "/" + full
861 } else {
862 ent.URL = base + "/blob/" + ref + "/" + full
863 }
864 nav.Entries = append(nav.Entries, ent)
865 }
866 return nav
867}
868```
869
870In `web.go` `renderTree` replace the inline `sort.SliceStable(...)` call and its comment (lines 522-527) with `sortDirsFirst(entries)`. Remove the `sort` import if nothing else in the file uses it (check with `go build`).
871
872- [ ] **Step 4: Attach it to blob, blame and edit**
873
874In `blob` (`web.go`), after `branches, _ := gitutil.Refs(p.Dir, "heads")` add:
875
876```go
877 navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
878 nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
879```
880
881and add `Nav fileNav` as the last field of the anonymous struct, passing `nav` last. Add to `filenav.go`:
882
883```go
884// navDir is the directory ListTree wants for filePath: "" at the root.
885func navDir(filePath string) string {
886 if d := path.Dir(filePath); d != "." {
887 return d
888 }
889 return ""
890}
891```
892
893In `blame`, before the render add the same two lines and add `Nav fileNav` to its struct, passing `nav`.
894
895In `accounts.go`, add `Nav fileNav` to `editPage` and in `editForm`, before `s.render`, add:
896
897```go
898 navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
899 nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
900```
901
902and `Nav: nav,` in the `editPage{...}` literal.
903
904- [ ] **Step 5: The partial and the templates**
905
906In `layout.html` after the `refmenu` define add:
907
908```
909{{define "filenav"}}<nav class="filenav" aria-label="Files">
910 <h2 class="colhead">{{.Title}}</h2>
911 <ul>
912 {{with .Parent}}<li><a class="up" href="{{.}}">..</a></li>{{end}}
913 {{range .Entries}}<li><a{{if .Dir}} class="dir"{{end}}{{if .Current}} aria-current="page"{{end}} href="{{.URL}}">{{.Name}}</a></li>
914 {{end}}</ul>
915</nav>{{end}}
916```
917
918`blob.html`: after `<h1 class="vh">{{.Path}}</h1>` insert `<div class="blobgrid">` then `{{template "filenav" .Nav}}` then `<div class="blobmain">`; before the closing `{{end}}` of the content define add `</div>\n</div>`.
919
920`blame.html`: same wrapping around everything after the `<h1 class="vh">`.
921
922`edit.html`: same wrapping around the `.pathbar`/form block; the `.Nav` field is on `editPage`.
923
924- [ ] **Step 6: The stylesheet**
925
926After the `.crumbs strong` rule add:
927
928```css
929/* ---- file navigator: the directory beside a file ---- */
930.blobgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
931.blobmain { min-width: 0; }
932.filenav { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
933.filenav ul { list-style: none; margin: 0; padding: 0; }
934.filenav li a {
935 display: block;
936 padding: 3px var(--sp-2);
937 border-radius: var(--r-ctl);
938 color: var(--fg);
939 font-family: var(--mono);
940 font-size: var(--fs-1);
941 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
942}
943.filenav li a:hover { background: var(--hover); text-decoration: none; }
944.filenav li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 600; }
945.filenav li a.dir { color: var(--link); }
946.filenav li a.up { color: var(--muted); }
947```
948
949In the `@media (max-width: 62rem)` block add:
950
951```css
952 /* the tree page is the navigator on a phone */
953 .blobgrid { grid-template-columns: 1fr; }
954 .filenav { display: none; }
955```
956
957- [ ] **Step 7: The e2e test**
958
959`e2e/filenav_test.go`:
960
961```go
962package e2e
963
964import (
965 "os"
966 "path/filepath"
967 "strings"
968 "testing"
969)
970
971// A file page lists its directory beside the file, marks the file, and
972// links up (desktop layout spec).
973func TestFileNavigator(t *testing.T) {
974 inst := startInstance(t)
975 key := inst.newKey(t, "alice")
976 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
977 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/nav"); code != 0 {
978 t.Fatalf("repo create: %s", errOut)
979 }
980 // the same clone-commit-push shape TestWebUI uses (e2e/web_test.go:41-50)
981 work := t.TempDir()
982 env := inst.gitEnv(key)
983 mustGit(t, work, env, "clone", inst.sshURL("alice/nav"), "w")
984 dir := filepath.Join(work, "w")
985 os.MkdirAll(filepath.Join(dir, "cmd", "sub"), 0o755)
986 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# nav\n"), 0o644)
987 os.WriteFile(filepath.Join(dir, "cmd", "main.go"), []byte("package main\n"), 0o644)
988 os.WriteFile(filepath.Join(dir, "cmd", "sub", "x.go"), []byte("package sub\n"), 0o644)
989 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
990 mustGit(t, dir, env, "add", ".")
991 mustGit(t, dir, env, "commit", "-q", "-m", "one")
992 mustGit(t, dir, env, "push", "-q", "origin", "main")
993
994 status, body := inst.get(t, "/alice/nav/blob/main/cmd/main.go")
995 if status != 200 {
996 t.Fatalf("blob: %d", status)
997 }
998 for _, want := range []string{
999 `<nav class="filenav" aria-label="Files">`,
1000 `<h2 class="colhead">cmd</h2>`,
1001 `<a class="up" href="/alice/nav/tree/main">..</a>`,
1002 `<a class="dir" href="/alice/nav/tree/main/cmd/sub">sub/</a>`,
1003 `<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`,
1004 } {
1005 if !strings.Contains(body, want) {
1006 t.Errorf("blob page lacks %q", want)
1007 }
1008 }
1009 _, body = inst.get(t, "/alice/nav/blame/main/README.md")
1010 if !strings.Contains(body, `<h2 class="colhead">nav</h2>`) || !strings.Contains(body, `<a aria-current="page" href="/alice/nav/blob/main/README.md">README.md</a>`) {
1011 t.Errorf("blame page lacks the root navigator:\n%s", body)
1012 }
1013}
1014```
1015
1016`mustGit(t, dir, env, args...) string`, `inst.gitEnv(key) []string` and `inst.sshURL(repo) string` are in `e2e/git_test.go`; `gitEnv` sets the author and committer, so the commit needs no `-c user.*` flags.
1017
1018- [ ] **Step 8: Run the tests**
1019
1020Run: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestFileNavigator|TestWebUI' -count=1`
1021Expected: PASS
1022
1023- [ ] **Step 9: Commit**
1024
1025```bash
1026git add internal/httpd/filenav.go internal/httpd/filenav_test.go internal/httpd/web.go internal/httpd/accounts.go internal/web e2e/filenav_test.go
1027git commit -m "web: a file navigator beside blob, blame and edit
1028
1029Ref #226"
1030```
1031
1032---
1033
1034### Task 6: Facet column on the issue and merge request lists
1035
1036**Files:**
1037- Create: `internal/httpd/facets.go`, `internal/httpd/facets_test.go`
1038- Modify: `internal/httpd/web.go:1690-1735` (issues), `:1814-1866` (mrs)
1039- Modify: `internal/web/templates/layout.html` (new `sidecol` partial), `issues.html`, `mrs.html`
1040- Modify: `internal/web/static/style.css` (after the `.filenav` rules from Task 5)
1041- Modify: `e2e/labelweb_test.go` (add assertions at the end of `TestLabelsWeb`)
1042
1043**Interfaces:**
1044- Consumes: `control.ReadableScope(st, user, repo) ([]int64, error)`, `s.st.ListLabels(repo, readable) ([]store.Label, error)` with `Label{Name, Issues, MRs}`, `s.st.ListMilestones(repo, "open", readable) ([]store.Milestone, error)` with `Milestone{Title, OpenItems}`, `s.labelColors(repo)`.
1045- Produces: `facetGroup`, `facetItem`, `func facetHref(base url.Values, key, value string) string`, `func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup`. Task 7 and Task 8 reuse `facetGroup`.
1046
1047- [ ] **Step 1: Write the failing test**
1048
1049`internal/httpd/facets_test.go`:
1050
1051```go
1052package httpd
1053
1054import (
1055 "net/url"
1056 "testing"
1057
1058 "gitbay.org/gitbay/internal/store"
1059)
1060
1061// A facet link keeps every other active filter, sets its own, and clears
1062// its own when it is already active (desktop layout spec).
1063func TestFacetHrefKeepsOtherFilters(t *testing.T) {
1064 base := url.Values{"state": {"open"}, "label": {"bug"}, "q": {"crash"}}
1065 if got := facetHref(base, "milestone", "v2"); got != "?label=bug&milestone=v2&q=crash&state=open" {
1066 t.Errorf("set: %q", got)
1067 }
1068 if got := facetHref(base, "label", ""); got != "?q=crash&state=open" {
1069 t.Errorf("clear: %q", got)
1070 }
1071 if got := facetHref(base, "state", "closed"); got != "?label=bug&q=crash&state=closed" {
1072 t.Errorf("replace: %q", got)
1073 }
1074}
1075
1076func TestListFacetsGroups(t *testing.T) {
1077 base := url.Values{"state": {"open"}, "label": {"bug"}}
1078 labels := []store.Label{{Name: "bug", Issues: 2, MRs: 1}, {Name: "docs", Issues: 0, MRs: 3}}
1079 ms := []store.Milestone{{Title: "v2", OpenItems: 4}}
1080 groups := listFacets(base, []string{"open", "closed", "all"}, "open", labels, ms, false)
1081 if len(groups) != 3 || groups[0].Title != "State" || groups[1].Title != "Labels" || groups[2].Title != "Milestones" {
1082 t.Fatalf("groups: %+v", groups)
1083 }
1084 st := groups[0].Items
1085 if !st[0].Active || st[0].Href != "?label=bug&state=open" || st[1].Active || st[1].Href != "?label=bug&state=closed" {
1086 t.Errorf("state items: %+v", st)
1087 }
1088 lb := groups[1].Items
1089 if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" {
1090 t.Errorf("active label clears itself: %+v", lb[0])
1091 }
1092 if lb[1].Label != "docs" || lb[1].Count != 0 || lb[1].Active || lb[1].Href != "?label=docs&state=open" {
1093 t.Errorf("inactive label: %+v", lb[1])
1094 }
1095 if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" {
1096 t.Errorf("milestone: %+v", m)
1097 }
1098 // on the MR list a label's count is its MR count
1099 mr := listFacets(base, []string{"open"}, "open", labels, nil, true)
1100 if mr[1].Items[1].Count != 3 {
1101 t.Errorf("mr count: %+v", mr[1].Items[1])
1102 }
1103}
1104```
1105
1106- [ ] **Step 2: Run it to see it fail**
1107
1108Run: `go test ./internal/httpd/ -run 'TestFacetHref|TestListFacets'`
1109Expected: FAIL, "undefined: facetHref"
1110
1111- [ ] **Step 3: The facets**
1112
1113`internal/httpd/facets.go`:
1114
1115```go
1116package httpd
1117
1118import (
1119 "net/url"
1120
1121 "gitbay.org/gitbay/internal/store"
1122)
1123
1124// facetItem is one link in a list page's side column: a value the list
1125// narrows to. Clicking an active item clears it.
1126type facetItem struct {
1127 Label string
1128 Count int64
1129 Href string
1130 Active bool
1131}
1132
1133// facetGroup is one heading in the column: State, Labels, Milestones.
1134type facetGroup struct {
1135 Title string
1136 Items []facetItem
1137}
1138
1139// facetHref returns "?..." with every parameter of base kept, key set to
1140// value, or dropped when value is "". url.Values encodes sorted, so the
1141// tests and the links agree byte for byte.
1142func facetHref(base url.Values, key, value string) string {
1143 q := url.Values{}
1144 for k, vs := range base {
1145 if k == key || len(vs) == 0 || vs[0] == "" {
1146 continue
1147 }
1148 q.Set(k, vs[0])
1149 }
1150 if value != "" {
1151 q.Set(key, value)
1152 }
1153 return "?" + q.Encode()
1154}
1155
1156// listFacets builds the issue or merge request list's column from the
1157// active parameters, the states the page offers, and the repository's
1158// labels and open milestones. Counts are the rows' own: a label's issue
1159// count on the issue list, its MR count on the MR list.
1160func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup {
1161 var st facetGroup
1162 st.Title = "State"
1163 for _, s := range states {
1164 st.Items = append(st.Items, facetItem{Label: s, Href: facetHref(base, "state", s), Active: s == state})
1165 }
1166 lb := facetGroup{Title: "Labels"}
1167 for _, l := range labels {
1168 n := l.Issues
1169 if forMRs {
1170 n = l.MRs
1171 }
1172 active := base.Get("label") == l.Name
1173 href := facetHref(base, "label", l.Name)
1174 if active {
1175 href = facetHref(base, "label", "")
1176 }
1177 lb.Items = append(lb.Items, facetItem{Label: l.Name, Count: n, Href: href, Active: active})
1178 }
1179 mg := facetGroup{Title: "Milestones"}
1180 for _, m := range ms {
1181 active := base.Get("milestone") == m.Title
1182 href := facetHref(base, "milestone", m.Title)
1183 if active {
1184 href = facetHref(base, "milestone", "")
1185 }
1186 mg.Items = append(mg.Items, facetItem{Label: m.Title, Count: int64(m.OpenItems), Href: href, Active: active})
1187 }
1188 return []facetGroup{st, lb, mg}
1189}
1190```
1191
1192- [ ] **Step 4: Wire the handlers**
1193
1194In `issues` (`web.go`), after the `if labels, err := s.st.ListIssueLabels(p.Repo)` block, add:
1195
1196```go
1197 base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
1198 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1199 allLabels, _ := s.st.ListLabels(p.Repo, readable)
1200 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1201 facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
1202```
1203
1204add `Facets []facetGroup` to the render struct after `Filters`, passing `facets`. Add `"net/url"` to the imports if absent.
1205
1206In `mrs`, after the `labels, err := s.st.ListMRLabels(p.Repo)` block, add the same with:
1207
1208```go
1209 base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
1210 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1211 allLabels, _ := s.st.ListLabels(p.Repo, readable)
1212 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1213 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
1214```
1215
1216and `Facets []facetGroup` in its struct.
1217
1218- [ ] **Step 5: The partial and the templates**
1219
1220In `layout.html` after the `filenav` define add:
1221
1222```
1223{{define "sidecol"}}<nav class="sidecol" aria-label="Filters">
1224 {{range .}}{{if .Items}}<div class="grp">
1225 <h2 class="colhead">{{.Title}}</h2>
1226 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul>
1227 </div>{{end}}{{end}}
1228</nav>{{end}}
1229```
1230
1231`issues.html`: replace the `content` define body with:
1232
1233```html
1234<div class="withcol">
1235{{template "sidecol" .Facets}}
1236<div class="colmain">
1237<div class="listhead">
1238 <h1>Issues</h1>
1239 <form method="get" class="searchform compact">
1240 <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body">
1241 <button type="submit" class="btn">Search</button>
1242 <input type="hidden" name="state" value="{{.State}}">
1243 </form>
1244 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
1245 <span class="spacer"></span>
1246 <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels">labels</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p>
1247</div>
1248<ul class="issuelist rows">
1249... the existing {{range .Issues}} block, unchanged ...
1250</ul>
1251{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
1252</div>
1253</div>
1254```
1255
1256The `nav.filters` block moves into the column; the state links there are the facet group.
1257
1258`mrs.html`: the same shape. Keep its `{{range .MRs}}` block; drop its `nav.filters`.
1259
1260- [ ] **Step 6: The stylesheet**
1261
1262After the `.filenav` rules add:
1263
1264```css
1265/* ---- side column: facets or sections beside a list or a form ---- */
1266.withcol { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
1267.withcol.narrow { grid-template-columns: 15rem minmax(0, 56rem); }
1268.colmain { min-width: 0; }
1269.sidecol { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
1270.sidecol .grp { margin-bottom: var(--sp-4); }
1271.sidecol ul { list-style: none; margin: 0; padding: 0; }
1272.sidecol li a {
1273 display: flex; align-items: baseline; gap: var(--sp-2);
1274 padding: 4px var(--sp-2);
1275 border-radius: var(--r-ctl);
1276 color: var(--fg);
1277}
1278.sidecol li a:hover { background: var(--hover); text-decoration: none; }
1279.sidecol li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 500; }
1280.sidecol li a i { margin-left: auto; font-style: normal; color: var(--muted); font-size: var(--fs-1); font-variant-numeric: tabular-nums; }
1281.sidecol form.searchform { margin-top: var(--sp-2); }
1282.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; }
1283```
1284
1285In the `@media (max-width: 62rem)` block add:
1286
1287```css
1288 /* the column follows the content on a phone, the way the aside does */
1289 .withcol, .withcol.narrow { grid-template-columns: 1fr; }
1290 .sidecol { position: static; order: 2; }
1291 .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); }
1292```
1293
1294- [ ] **Step 7: The e2e assertion**
1295
1296At the end of `TestLabelsWeb` in `e2e/labelweb_test.go`, after the label management assertions, add:
1297
1298```go
1299 // The issue list's column lists the label with its count and a link
1300 // that keeps the state (desktop layout spec).
1301 status, page = browserGet(t, alice, base+"/issues?state=open")
1302 if status != 200 || !strings.Contains(page, `<nav class="sidecol" aria-label="Filters">`) {
1303 t.Fatalf("issues page lacks the side column: %d", status)
1304 }
1305 if !strings.Contains(page, `href="?label=bug&amp;state=open">bug <i>1</i></a>`) {
1306 t.Fatalf("issues column lacks the bug facet:\n%s", page)
1307 }
1308 status, page = browserGet(t, alice, base+"/issues?state=open&label=bug")
1309 if status != 200 || !strings.Contains(page, `aria-current="page" href="?state=open">bug <i>1</i></a>`) {
1310 t.Fatalf("active facet does not clear itself:\n%s", page)
1311 }
1312```
1313
1314If `TestLabelsWeb` removes the `bug` label before its end, place the block before that removal.
1315
1316- [ ] **Step 8: Run the tests**
1317
1318Run: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestLabelsWeb|TestMRListRows|TestMRWebLabels' -count=1`
1319Expected: PASS
1320
1321- [ ] **Step 9: Commit**
1322
1323```bash
1324git add internal/httpd/facets.go internal/httpd/facets_test.go internal/httpd/web.go internal/web e2e/labelweb_test.go
1325git commit -m "web: a facet column on the issue and merge request lists
1326
1327State, labels with counts and open milestones beside the rows; a facet
1328keeps the other filters and clears itself when active.
1329
1330Ref #226"
1331```
1332
1333---
1334
1335### Task 7: Facet column on the builds list
1336
1337**Files:**
1338- Modify: `internal/httpd/builds.go:12-60`, `:147-178`
1339- Modify: `internal/httpd/builds_test.go` (add one test), `internal/httpd/buildpages_test.go:22-58`
1340- Modify: `internal/web/templates/builds.html:4-17`
1341- Modify: `internal/httpd/repohead_test.go` (Task 2's struct gains the field)
1342
1343**Interfaces:**
1344- Consumes: `facetGroup`, `facetItem` from Task 6; `filterLinks`, `distinctRefs`, `buildStatuses` in `builds.go`.
1345- Produces: `func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup`.
1346
1347- [ ] **Step 1: Write the failing test**
1348
1349Append to `internal/httpd/builds_test.go`:
1350
1351```go
1352// The builds column groups the same links filterLinks makes: "all" and
1353// the statuses, then the jobs, then the branches seen (desktop layout spec).
1354func TestBuildFacetsGroups(t *testing.T) {
1355 f := buildFilter{Ref: "main", Status: "success"}
1356 groups := buildFacets(f, []control.JobOut{{Name: "lint"}}, []string{"main", "dev"})
1357 if len(groups) != 3 || groups[0].Title != "Status" || groups[1].Title != "Jobs" || groups[2].Title != "Branches" {
1358 t.Fatalf("groups: %+v", groups)
1359 }
1360 if groups[0].Items[0].Label != "all" || groups[0].Items[0].Href != "?ref=main" || groups[0].Items[0].Active {
1361 t.Errorf("all: %+v", groups[0].Items[0])
1362 }
1363 if s := groups[0].Items[3]; s.Label != "success" || !s.Active {
1364 t.Errorf("success: %+v", s)
1365 }
1366 if j := groups[1].Items[0]; j.Label != "lint" || j.Href != "?job=lint&ref=main&status=success" || j.Active {
1367 t.Errorf("lint: %+v", j)
1368 }
1369 if b := groups[2].Items[0]; b.Label != "main" || !b.Active || b.Href != "?status=success" {
1370 t.Errorf("active branch clears itself: %+v", b)
1371 }
1372 if b := groups[2].Items[1]; b.Label != "dev" || b.Active || b.Href != "?ref=dev&status=success" {
1373 t.Errorf("dev: %+v", b)
1374 }
1375}
1376```
1377
1378- [ ] **Step 2: Run it to see it fail**
1379
1380Run: `go test ./internal/httpd/ -run TestBuildFacetsGroups`
1381Expected: FAIL, "undefined: buildFacets"
1382
1383- [ ] **Step 3: The grouping**
1384
1385Append to `builds.go`:
1386
1387```go
1388// buildFacets is the builds page's side column: filterLinks' rows split
1389// into their groups, plus one link per branch seen, which keeps status
1390// and job and clears itself when active.
1391func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup {
1392 links := filterLinks(f, jobs)
1393 n := 1 + len(buildStatuses)
1394 status := facetGroup{Title: "Status"}
1395 for _, l := range links[:n] {
1396 status.Items = append(status.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
1397 }
1398 job := facetGroup{Title: "Jobs"}
1399 for _, l := range links[n:] {
1400 job.Items = append(job.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
1401 }
1402 branch := facetGroup{Title: "Branches"}
1403 base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}}
1404 for _, ref := range refs {
1405 active := ref == f.Ref
1406 href := facetHref(base, "ref", ref)
1407 if active {
1408 href = facetHref(base, "ref", "")
1409 }
1410 branch.Items = append(branch.Items, facetItem{Label: ref, Href: href, Active: active})
1411 }
1412 return []facetGroup{status, job, branch}
1413}
1414```
1415
1416In `builds()` add `Facets []facetGroup` to the render struct after `FilterLinks`, passing `buildFacets(filter, jobs, distinctRefs(builds, filter.Ref))`. Update the two test structs that render `builds.html` (`buildpages_test.go:30-40`, `repohead_test.go`) to carry `Facets []facetGroup` in the same position, passing `nil`.
1417
1418- [ ] **Step 4: The template**
1419
1420Replace `builds.html` lines 4-17 (the `listhead`) with:
1421
1422```html
1423<div class="withcol">
1424<nav class="sidecol" aria-label="Filters">
1425 {{range .Facets}}{{if .Items}}<div class="grp">
1426 <h2 class="colhead">{{.Title}}</h2>
1427 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>
1428 </div>{{end}}{{end}}
1429 <form method="get" class="searchform compact">
1430 <label for="ref" class="colhead">Branch</label>
1431 <input type="text" id="ref" name="ref" value="{{.Filter.Ref}}" list="buildrefs">
1432 <datalist id="buildrefs">{{range .Refs}}<option value="{{.}}">{{end}}</datalist>
1433 <input type="hidden" name="status" value="{{.Filter.Status}}">
1434 <input type="hidden" name="job" value="{{.Filter.Job}}">
1435 <button type="submit" class="btn">Filter</button>
1436 </form>
1437</nav>
1438<div class="colmain">
1439<div class="listhead">
1440 <h1>Builds</h1>
1441```
1442
1443and close `</div>\n</div>` before the content define's `{{end}}`. The rest of the page (status badge button, run count, the `loglist`) stays inside `.colmain`. Change its `<ul class="loglist">` to `<ul class="loglist rows">`: sha, branch and date on one line, the badges right (rule 4 of the spec; the CSS landed in Task 3).
1444
1445- [ ] **Step 5: Run the tests**
1446
1447Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestBuildCancelWeb|TestRunnerSettingsWeb' -count=1`
1448Expected: PASS
1449
1450- [ ] **Step 6: Commit**
1451
1452```bash
1453git add internal/httpd/builds.go internal/httpd/builds_test.go internal/httpd/buildpages_test.go internal/httpd/repohead_test.go internal/web/templates/builds.html
1454git commit -m "web: the builds page filters in a side column
1455
1456Ref #226"
1457```
1458
1459---
1460
1461### Task 8: Facet columns on explore and site search
1462
1463**Files:**
1464- Create: `internal/httpd/topics.go`, `internal/httpd/topics_test.go`
1465- Modify: `internal/httpd/web.go:202-220` (explore)
1466- Modify: `internal/web/templates/explore.html`, `globalsearch.html:4-18`
1467
1468**Interfaces:**
1469- Consumes: `describedRepo{Topics []string}`, `facetGroup`, `facetItem`.
1470- Produces: `func topicFacets(repos []describedRepo, q string) facetGroup`.
1471
1472- [ ] **Step 1: Write the failing test**
1473
1474`internal/httpd/topics_test.go`:
1475
1476```go
1477package httpd
1478
1479import "testing"
1480
1481// Explore's column counts topics across the visible repositories, most
1482// used first then by name, capped at twenty, each linking to ?q=<topic>
1483// and the active one clearing the query (desktop layout spec).
1484func TestTopicFacets(t *testing.T) {
1485 repos := []describedRepo{
1486 {Topics: []string{"cli", "git"}},
1487 {Topics: []string{"git", "swift"}},
1488 {Topics: []string{"git"}},
1489 }
1490 g := topicFacets(repos, "cli")
1491 if g.Title != "Topics" || len(g.Items) != 3 {
1492 t.Fatalf("group: %+v", g)
1493 }
1494 if g.Items[0].Label != "git" || g.Items[0].Count != 3 || g.Items[0].Href != "/explore?q=git" {
1495 t.Errorf("git: %+v", g.Items[0])
1496 }
1497 if g.Items[1].Label != "cli" || !g.Items[1].Active || g.Items[1].Href != "/explore" {
1498 t.Errorf("cli: %+v", g.Items[1])
1499 }
1500 if g.Items[2].Label != "swift" || g.Items[2].Count != 1 {
1501 t.Errorf("swift: %+v", g.Items[2])
1502 }
1503 var many []describedRepo
1504 for i := 0; i < 30; i++ {
1505 many = append(many, describedRepo{Topics: []string{string(rune('a' + i))}})
1506 }
1507 if n := len(topicFacets(many, "").Items); n != 20 {
1508 t.Errorf("cap: %d", n)
1509 }
1510}
1511```
1512
1513- [ ] **Step 2: Run it to see it fail**
1514
1515Run: `go test ./internal/httpd/ -run TestTopicFacets`
1516Expected: FAIL, "undefined: topicFacets"
1517
1518- [ ] **Step 3: The counter**
1519
1520`internal/httpd/topics.go`:
1521
1522```go
1523package httpd
1524
1525import (
1526 "net/url"
1527 "sort"
1528)
1529
1530// topicFacets counts the topics across repos for explore's column. The
1531// links are the ones topic chips already use, ?q=<topic>; the active
1532// topic links to explore with no query.
1533func topicFacets(repos []describedRepo, q string) facetGroup {
1534 counts := map[string]int64{}
1535 for _, r := range repos {
1536 for _, t := range r.Topics {
1537 counts[t]++
1538 }
1539 }
1540 names := make([]string, 0, len(counts))
1541 for t := range counts {
1542 names = append(names, t)
1543 }
1544 sort.Slice(names, func(i, j int) bool {
1545 if counts[names[i]] != counts[names[j]] {
1546 return counts[names[i]] > counts[names[j]]
1547 }
1548 return names[i] < names[j]
1549 })
1550 if len(names) > 20 {
1551 names = names[:20]
1552 }
1553 g := facetGroup{Title: "Topics"}
1554 for _, t := range names {
1555 item := facetItem{Label: t, Count: counts[t], Href: "/explore?q=" + url.QueryEscape(t), Active: t == q}
1556 if item.Active {
1557 item.Href = "/explore"
1558 }
1559 g.Items = append(g.Items, item)
1560 }
1561 return g
1562}
1563```
1564
1565In `explore` (`web.go`), compute `described := s.describeAll(repos)` once, add `Facets []facetGroup` to the struct after `Query`, and pass `[]facetGroup{topicFacets(described, q)}` and `s.filterRepos(q, described)`. The counts cover every public repository, not the filtered set, so the column stays stable while narrowing.
1566
1567- [ ] **Step 4: The templates**
1568
1569`explore.html` content define:
1570
1571```html
1572<div class="withcol">
1573{{template "sidecol" .Facets}}
1574<div class="colmain">
1575<div class="headrow">
1576<h1>Explore</h1>
1577<form method="get" action="/explore" class="searchform compact">
1578 <input type="search" name="q" aria-label="Filter repositories" value="{{.Query}}" placeholder="filter by name, description, topic">
1579 <button type="submit" class="btn">Search</button>
1580</form>
1581<span class="spacer"></span>
1582</div>
1583<ul class="repolist rows">
1584{{range .Repos}}{{template "reporow" .}}
1585{{else}}<li class="empty">no public repositories yet</li>{{end}}
1586</ul>
1587</div>
1588</div>
1589```
1590
1591`globalsearch.html`: wrap the content in `.withcol`; the column is the kinds, static:
1592
1593```html
1594<div class="withcol">
1595<nav class="sidecol" aria-label="Filters">
1596 <div class="grp"><h2 class="colhead">Kind</h2>
1597 <ul>
1598 <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li>
1599 <li><a{{if eq .Kind "repo"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=repo">repositories</a></li>
1600 <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=issue">issues</a></li>
1601 <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=mr">merge requests</a></li>
1602 </ul></div>
1603</nav>
1604<div class="colmain">
1605<div class="listhead">
1606 <h1>Search</h1>
1607 {{if and .Query (not .QueryErr)}}<p class="meta">... unchanged ...</p>{{end}}
1608</div>
1609<form method="get" action="/search" class="searchform"> ... unchanged ... </form>
1610... the results list, unchanged ...
1611</div>
1612</div>
1613```
1614
1615- [ ] **Step 5: Run the tests**
1616
1617Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestGlobalSearchAndNotificationsWeb' -count=1`
1618Expected: PASS
1619
1620- [ ] **Step 6: Commit**
1621
1622```bash
1623git add internal/httpd/topics.go internal/httpd/topics_test.go internal/httpd/web.go internal/web/templates/explore.html internal/web/templates/globalsearch.html
1624git commit -m "web: topic and kind columns on explore and search
1625
1626Ref #226"
1627```
1628
1629---
1630
1631### Task 9: Section nav column on repository settings, account settings and admin
1632
1633**Files:**
1634- Modify: `internal/web/templates/settings.html:1-7`, `account.html`, `admin.html`
1635- Modify: `internal/web/static/style.css:694` (`nav.sections`)
1636- Modify: `internal/web/widths_test.go` (extend)
1637
1638**Interfaces:**
1639- Consumes: `.withcol.narrow` and `.sidecol` from Task 6.
1640
1641- [ ] **Step 1: Extend the failing test**
1642
1643Append to `TestListPagesAreWide` in `internal/web/widths_test.go`:
1644
1645```go
1646 // Settings pages carry a section column: every section id has a link
1647 // in the column, and the page is wide with the narrow grid.
1648 for _, name := range []string{"settings.html", "account.html", "admin.html"} {
1649 src, _ := templateFS.ReadFile("templates/" + name)
1650 s := string(src)
1651 if !strings.HasPrefix(s, `{{define "width"}}wide{{end}}`) || !strings.Contains(s, `<div class="withcol narrow">`) {
1652 t.Errorf("%s lacks the narrow column layout", name)
1653 }
1654 for _, m := range regexp.MustCompile(`<section id="([a-z]+)"`).FindAllStringSubmatch(s, -1) {
1655 if !strings.Contains(s, `href="#`+m[1]+`"`) {
1656 t.Errorf("%s: section %q has no link in the column", name, m[1])
1657 }
1658 }
1659 }
1660```
1661
1662Add `"regexp"` to the imports.
1663
1664- [ ] **Step 2: Run it to see it fail**
1665
1666Run: `go test ./internal/web/ -run TestListPagesAreWide`
1667Expected: FAIL, "settings.html lacks the narrow column layout"
1668
1669- [ ] **Step 3: Repository settings**
1670
1671`settings.html` line 1 → `{{define "width"}}wide{{end}}`. Replace line 7 (`<nav class="sections" ...>`) with:
1672
1673```html
1674<div class="withcol narrow">
1675<nav class="sidecol" aria-label="Sections">
1676 <div class="grp"><h2 class="colhead">Sections</h2>
1677 <ul>
1678 <li><a href="#identity">Identity</a></li>
1679 <li><a href="#access">Access</a></li>
1680 <li><a href="#gates">Merge gates</a></li>
1681 <li><a href="#branches">Protected branches</a></li>
1682 <li><a href="#tags">Protected tags</a></li>
1683 <li><a href="#deps">Dependencies</a></li>
1684 <li><a href="#runners">Runners</a></li>
1685 <li><a href="#lifecycle">Lifecycle</a></li>
1686 </ul></div>
1687</nav>
1688<div class="colmain">
1689```
1690
1691and add `</div>\n</div>` before the content define's final `{{end}}`. Delete the `nav.sections` rule from `style.css` (line 694): nothing uses it.
1692
1693- [ ] **Step 4: Account settings**
1694
1695`account.html` line 1 → `{{define "width"}}wide{{end}}`. After the notice lines (line 6) insert the same `withcol narrow` opener with links `#profile` Profile, `#keys` SSH keys, `#emails` Email addresses, `#pgp` OpenPGP keys, `#notifications` Notifications, `#appearance` Appearance, `#export` Export, `#ssh` On SSH only. Wrap each block from its `<h2>` to the line before the next `<h2>` in `<section id="...">` … `</section>` with those ids in order. Close `</div>\n</div>` before the final `{{end}}`.
1696
1697- [ ] **Step 5: Admin**
1698
1699`admin.html` line 1 → `{{define "width"}}wide{{end}}`. After the `<p class="meta">Server build` line insert the opener with links `#webhooks` Webhook deliveries, `#mail` Mail, `#mirrors` Mirrors, `#builds` Builds, `#deps` Dependency checks. Inside each `{{with .Queues.X}}` block wrap the content in `<section id="...">` … `</section>`. Close `</div>\n</div>` before the final `{{end}}`.
1700
1701- [ ] **Step 6: Run the tests**
1702
1703Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestRepoSettingsWeb|TestAccountSettingsWeb|TestWebTheme' -count=1`
1704Expected: PASS
1705
1706- [ ] **Step 7: Commit**
1707
1708```bash
1709git add internal/web
1710git commit -m "web: section columns on settings, account and admin
1711
1712Ref #226"
1713```
1714
1715---
1716
1717### Task 10: The pages without a column
1718
1719**Files:**
1720- Modify: `internal/web/static/style.css:997` (`.withaside`), `:832-836` (`.readme`), `:1441` (`.wikinav`)
1721- Modify: `docs/specs/2026-09-19-desktop-layout-design.md` (rule 7)
1722
1723- [ ] **Step 1: The rules**
1724
1725`.withaside` grid: `minmax(0, 1fr) 18rem` → `minmax(0, 1fr) 20rem`.
1726
1727After `.readme .cardbody { max-width: 78ch; }` add:
1728
1729```css
1730/* the overview README caps as a card, so prose and code share an edge */
1731.overview .readme { max-width: 88ch; }
1732```
1733
1734`.wikinav { flex: none; width: 14rem; }` → `width: 15rem;`.
1735
1736- [ ] **Step 2: Align the spec with what shipped**
1737
1738In the spec, rule 7 reads "a facet or section column folds into a `details` element above the content". Without JavaScript a `details` cannot open on desktop and close on a phone from one markup, so the column stacks after the content instead, the way the issue aside does since #232. Replace that rule with:
1739
1740```
17417. **Below 64rem** a facet or section column stacks after the content,
1742 the way the issue aside does; the file navigator disappears (the
1743 tree page exists); the dashboard's pinned column returns to the chip
1744 row. Nothing the phone layout fixed moves.
1745```
1746
1747In the spec's page table, the issue list row reads "State; labels with counts; milestones with open counts; assignees (issues)". No store read counts issues per assignee, and adding one is a feature the CLI does not have, so drop "assignees (issues)" from that row and from the "Source of its contents" cell. The `assignee` parameter still works from an author link; it is not a facet.
1748
1749- [ ] **Step 3: Run the tests and commit**
1750
1751Run: `go test ./internal/web/ ./internal/httpd/`
1752Expected: PASS
1753
1754```bash
1755git add internal/web/static/style.css docs/specs/2026-09-19-desktop-layout-design.md
1756git commit -m "web: wider aside, capped README card, 15rem wiki nav
1757
1758Ref #226"
1759```
1760
1761---
1762
1763### Task 11: Captures, axe scan, CHANGELOG
1764
1765**Files:**
1766- Modify: `CHANGELOG.org:7-9`
1767- Create (gitignored, not committed): `.claude/screenshots/desktop/`
1768
1769- [ ] **Step 1: Start a local instance with this branch**
1770
1771Run from the repository root:
1772
1773```bash
1774sh .claude/screenshots/local.sh
1775```
1776
1777It prints `base http://127.0.0.1:8090` and a login URL. Keep the URL.
1778
1779- [ ] **Step 2: Capture at 1920 and 1280, dark and light**
1780
1781```bash
1782cd .claude/screenshots
1783python3 shoot.py desktop/dark-1920 dark 1920 local.txt "<login url>"
1784python3 shoot.py desktop/light-1920 light 1920 local.txt "<login url>"
1785python3 shoot.py desktop/dark-1280 dark 1280 local.txt "<login url>"
1786python3 shoot.py desktop/mobile dark 375 local-mobile.txt "<login url>"
1787```
1788
1789Add these lines to `local.txt` first if absent:
1790
1791```
1792dashboard http://127.0.0.1:8090/
1793blob http://127.0.0.1:8090/krz/gitbay/blob/main/Makefile
1794builds http://127.0.0.1:8090/krz/gitbay/builds
1795settings http://127.0.0.1:8090/settings
1796repo-settings http://127.0.0.1:8090/krz/gitbay/settings
1797```
1798
1799Open each PNG and check: no horizontal scroll at 375, the column after the content at 375, the container centered at 1920, one-line rows at 1280 and 1920, the navigator marking `Makefile`.
1800
1801- [ ] **Step 3: Axe scan**
1802
1803```bash
1804python3 audit/audit.py desktop/axe dark 1280 local.txt
1805python3 audit/audit.py desktop/axe-mobile dark 375 local-mobile.txt
1806python3 audit/summ.py desktop/axe desktop/axe-mobile
1807```
1808
1809Expected: zero violations on every page except the 404 numeral already recorded. A `link-name` or `landmark` finding on the new columns is a defect in the markup, not a scan quirk: fix it and rescan.
1810
1811- [ ] **Step 4: CHANGELOG**
1812
1813Under `* v1.30.0 — unreleased` in `CHANGELOG.org`, before the existing bullet list, add a paragraph and bullets:
1814
1815```
1816The desktop layout (#226): the web UI uses a wide screen.
1817
1818- One centered container at 100rem; the repository header, main and
1819 footer align on it. Text keeps its measure.
1820- The repository header is two rows: name, description and buttons,
1821 then the tabs.
1822- Issue, merge request, build, explore, search and notification rows
1823 are one line above 64rem, and those pages render at the container
1824 width.
1825- The dashboard is three columns: pinned repositories with open issue,
1826 merge request and last-build counts; a tile per queue with the queue
1827 rows below; the activity feed.
1828- A file navigator beside blob, blame and edit pages lists the file's
1829 directory and marks the file.
1830- Side columns: state, labels and open milestones on the issue and
1831 merge request lists; status, jobs and branches on builds; topics on
1832 explore; kinds on search; sections on repository settings, account
1833 settings and admin.
1834- Below 64rem every column stacks after its content; the navigator
1835 hides, since the tree page is the navigator on a phone.
1836```
1837
1838- [ ] **Step 5: Commit**
1839
1840```bash
1841git add CHANGELOG.org
1842git commit -m "CHANGELOG: the desktop layout
1843
1844Ref #226"
1845```
1846
1847---
1848
1849### Task 12: Push, CI, merge request
1850
1851- [ ] **Step 1: Rebase onto main and push**
1852
1853```bash
1854git fetch origin && git rebase origin/main && git push -u origin desktop-layout-spec
1855```
1856
1857- [ ] **Step 2: Open the merge request**
1858
1859```bash
1860gitbay mr create --source desktop-layout-spec --target main --title "web: the desktop layout" --file - <<'EOF'
1861Ref #226. Spec docs/specs/2026-09-19-desktop-layout-design.md, plan
1862docs/plans/2026-09-19-desktop-layout.md.
1863
1864One centered container, a two-row repository header, one-line list rows,
1865a three-column dashboard with count tiles and pinned counts, a file
1866navigator on blob/blame/edit, facet columns on the issue, MR, builds,
1867explore and search lists, section columns on the settings pages.
1868EOF
1869```
1870
1871- [ ] **Step 3: Wait for CI**
1872
1873Poll once every few minutes, one ssh call per tick:
1874
1875```bash
1876gitbay build list --json | head -c 2000
1877```
1878
1879Expected: `test` and `build` succeed on the branch head. A failure: `gitbay build log <n>`, fix on the branch, push, wait again.
1880
1881- [ ] **Step 4: Merge and clean up**
1882
1883```bash
1884gitbay mr merge <n> --strategy ff
1885git checkout main && git pull && git branch -d desktop-layout-spec && git push origin --delete desktop-layout-spec
1886```
1887
1888If the merge reports the branch is behind, rebase, push, merge again.
1889
1890- [ ] **Step 5: Deploy**
1891
1892```bash
1893make deploy
1894```
1895
1896Then recapture the live site at 1920 with `shoot.py` against `public.txt` and compare with the local captures from Task 11.
docs/specs/2026-09-19-desktop-layout-design.md added +163
@@ -0,0 +1,163 @@
1# Desktop layout
2
3Ref #226. The phone layout landed in v1.23.0 through v1.29.0; this is
4the desktop half. Measured on gitbay.org at 1a40b22 (v1.30.0 head) at
51600 and 1920 wide, and compared against GitHub, GitLab, Codeberg and
6SourceHut dashboards at the same window.
7
8## Findings
9
10Three content widths, all pinned to the left edge. Nothing centers and
11nothing grows past its cap, so a wide screen puts every page in its
12top-left corner.
13
14| Width class | Pages | Content edge at 1920 | Used |
15|---|---|---|---|
16| `reading` (72rem, the default) | dashboard, issue and MR lists, explore, notifications, profile, refs, releases, milestones, labels | 1216px | 63% |
17| `reading`, then 48rem comments + 18rem aside | issue, MR conversation | 1184px | 62% |
18| `bounded` (48rem) | account settings, admin, new repository, login, 404 | 640px | 33% |
19| `wide` | tree, blob, blame, log, commit, compare, diff, builds, build, search | 1920px | 100% |
20
21- Text columns are at the right measure. A comment at 48rem is about 90
22 characters a line; wider hurts. Issue and MR pages need centering and
23 a stronger aside, not wider text.
24- Lists are laid out as prose. Issue, MR, build and explore rows are two
25 to four lines inside the 72rem cap: 14 issues a screen at 1080 tall,
26 9 repositories on explore.
27- The dashboard is one column. Queues stack, the feed follows at 809px,
28 below the fold at 1080 tall.
29- The repository header is 182px on the code tab (identity, description,
30 website, the toggles hint, tabs) and 110px on task tabs.
31- Account settings at 48rem wraps SSH fingerprints onto two lines beside
32 1280px of unused width.
33- The overview README card is 1216px wide while its prose caps at 78ch,
34 so paragraphs and code blocks have different right edges.
35
36SourceHut uses the same share of the window as gitbay and reads as
37designed, because it centers and splits into two columns. GitLab's stat
38tiles are gitbay's three "0" lines done as a strip. Codeberg's one-line
39feed rows are the row format the lists should adopt.
40
41## Rules
42
431. **One centered container.** `--container: 100rem`. The repository
44 header's inner content, `main` and the footer share it, so all three
45 align at every width. `reading` (72rem) and `bounded` (48rem) stay as
46 caps inside it, centered. `wide` is the container.
472. **A left column is a feature.** A page gets a left column only when
48 the column carries something the page already has as links, query
49 parameters or sections. No site navigation in it; the top bar is the
50 navigation. Every column is 15rem, sticky, and reads as one system.
51 No column persists across pages.
523. **Text keeps its measure.** Comments, descriptions, README prose and
53 wiki pages stay at 48rem or 78ch. Width goes to tables and lists.
544. **Lists are rows.** Above 64rem an issue, MR, build, explore, search
55 and notification row is one line: title, then labels, then the meta
56 pushed right, state or check badges at the far edge. The log keeps
57 two lines; subject over author and date is the convention there.
585. **Counts are tiles.** Where the dashboard now prints an empty queue
59 as a heading with a zero, a strip of four tiles carries the counts
60 and links to the lists. A non-zero count uses `--warn`: what wants
61 you. Only non-empty queues list rows.
626. **The repository header is two rows.** Row one: owner/name, the
63 description with topics and website inline in muted text, the
64 Pin/Watch/Bookmark/Fork buttons at the right. Row two: the tabs. The
65 toggles hint moves to `title` text on the three buttons.
667. **Below 62rem** a facet or section column stacks after the content,
67 the way the issue aside does; the file navigator disappears (the
68 tree page exists). **Below 80rem** the dashboard's pinned column
69 returns to the chip row. Nothing the phone layout fixed moves.
70
71## Pages
72
73### With a left column
74
75| Page | Column | Source of its contents |
76|---|---|---|
77| Dashboard | Pinned repositories with open issue count, open MR count, last build state | `Rail.Pinned` plus per-repository counts (new store read) |
78| Blob, blame, edit | File navigator: the file's directory, parent link, current file marked with `aria-current` | `gitutil.ListTree` on the directory, the call the tree page makes |
79| Issues list, MR list | State; labels with counts; milestones with open counts | The `state`, `label`, `milestone`, `assignee` parameters `activeFilters` already handles; counts from the labels and milestones pages' reads |
80| Builds list | Status; job; branch | The `status`, `job`, `ref` parameters `buildFilter` already handles; branches from the refs read |
81| Explore | Topics with counts | `?q=<topic>`, the link the topic chips already use |
82| Site search | Kind: repositories, issues, merge requests | `?kind=repo\|issue\|mr` |
83| Repository settings | Section nav: Identity, Access, Merge gates, Protected branches, Protected tags, Dependencies, Runners, Lifecycle | Anchors on the existing `h2` headings |
84| Account settings | Section nav: Profile, SSH keys, Email addresses, OpenPGP keys, Notifications, Appearance, Export | Same |
85| Admin | Section nav: Webhook deliveries, Mail, Mirrors, Builds, Dependency checks | Same |
86| Wiki | Page nav | Already there; adopt the 15rem width |
87
88Facet links carry the other active filters, the way `activeFilters`
89builds its clear links. The milestones and labels pages stay for
90management; the "milestones · labels" links leave the list head.
91
92### Layout of the pages that gain a column
93
94- **Dashboard**: `15rem | 1fr | 20rem`. Pinned column, then the tile
95 strip and queue rows, then the activity feed as a sticky aside with
96 two-line entries (the aside is too narrow for one).
97- **Blob, blame, edit**: `15rem | 1fr`. Navigator entries in mono, the
98 current file on a `--surface` ground with a 2px `--mark` edge.
99- **Lists**: `15rem | 1fr`. Rows fill the container.
100- **Settings and admin**: `15rem | minmax(0, 56rem)`. Forms and tables
101 at up to 56rem, so the key table stops wrapping.
102
103### Without one
104
105- **Repository overview**: the facts column stays on the right at
106 20rem; the README card caps at 88ch so prose and code share an edge.
107- **Issue, MR**: centered at 72rem; text at 48rem; the aside widens to
108 20rem so the two sit balanced in the container.
109- **Log, commit, compare, refs, releases, build, code search**: the
110 container width, no column.
111- **Notifications**: the container width, no column; rows one line.
112- **Bookmarks, snippets, profile, org**: centered `reading`.
113- **Landing, login, register, new repository, 404, privacy**: centered
114 `bounded`.
115
116## Implementation
117
118- **CSS only where it can be.** The container, centering, header rows,
119 row format, tiles, column grid and breakpoints are `style.css` rules
120 on tokens that exist. New tokens: `--container` only.
121- **Templates** touched: `layout.html` (header rows, container wrapper),
122 `dashboard.html`, `issues.html`, `mrs.html`, `builds.html`,
123 `explore.html`, `globalsearch.html`, `blob.html`, `blame.html`,
124 `edit.html`, `settings.html`, `account.html`, `admin.html`,
125 `notifications.html`, `tree.html` (README cap). Width classes: the
126 list pages and the dashboard move from the default `reading` to
127 `wide`.
128- **Handlers**: `blob`, `blame` and the edit form gain the directory
129 listing; the issue, MR and build list handlers gain facet counts; the
130 dashboard gains per-pinned-repository counts. Every new read is a
131 store query or a `gitutil` call that exists; no new control command,
132 since none of this is a capability the CLI lacks.
133 `TestReadOnlyCommandsWriteNothing` is unaffected.
134- **Facet counts** span only what the viewer can read, the same rule
135 `control.ReadableOrgRepoIDs` applies to org label counts.
136
137## Tests
138
139- `TestEveryTemplateClassHasARule` covers every new class.
140- A template test asserts the width class of each list page and the
141 dashboard is `wide`, and that `layout.html` wraps the header in the
142 container.
143- e2e: the blob page's navigator lists the file's directory and marks
144 the file; a facet link on the issues list keeps `state` and the other
145 active filters; the dashboard's tile count equals the queue length.
146- Recapture the `.claude/screenshots` set at 1280 and 1920 and rerun the
147 axe scan; zero violations is the bar the last release set.
148
149## Out of scope
150
151- A persistent context rail (option 2 in the mockups). Revisit if the
152 dashboard's pinned column proves worth having on every page.
153- A density preference.
154- One-line log rows.
155- Explore facets beyond topics; an `owner` parameter would be new.
156
157## Mockups
158
159`.claude/mock/desktop/` in the main checkout, served by the `mockups`
160entry in `.claude/launch.json`: `option1-dashboard.html`,
161`option1-blob.html`, the rejected `option2-*` pair, and `layouts.html`
162with one wireframe per page type. `desktop.css` there is the override
163layer the rules above were checked against.
e2e/dashboard_test.go +13 −14
@@ -93,8 +93,8 @@ 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 `aria-label="Pinned repositories"`, `</span>app</a>`, // the dashboard lists the pinned repo 96 `aria-label="Pinned repositories"`, `class="pins"`, `</span>app</a>`, // the dashboard 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"`,
100 "Yours anywhere, and every one in a repository you can write to.", 100 "Yours anywhere, and every one in a repository you can write to.",
@@ -104,17 +104,16 @@ func TestDashboard(t *testing.T) {
104 } 104 }
105 } 105 }
106 106
107 // D01: alice has nothing assigned to her, so that section condenses to 107 // The empty queue is a tile with a zero; a populated one is a tile
108 // an empty heading, and populated sections (she has a review waiting) 108 // that links to its rows, which render in the middle column.
109 // sort before it. 109 if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) {
110 emptyHeading := `<h2 class="empty">Assigned to you <span class="count">0</span></h2>` 110 t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body)
111 if !strings.Contains(body, emptyHeading) { 111 }
112 t.Fatalf("dashboard missing condensed empty heading %q:\n%s", emptyHeading, body) 112 if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) {
113 } 113 t.Fatalf("dashboard missing the review tile:\n%s", body)
114 reviewIdx := strings.Index(body, "Waiting on your review") 114 }
115 assignedIdx := strings.Index(body, emptyHeading) 115 if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) {
116 if reviewIdx < 0 || assignedIdx < 0 || reviewIdx > assignedIdx { 116 t.Fatalf("queues do not render as tiles plus rows:\n%s", body)
117 t.Fatalf("populated heading should come before the empty one: review=%d assigned=%d", reviewIdx, assignedIdx)
118 } 117 }
119 118
120 // The diff has its own view rather than a fold at the foot of the 119 // The diff has its own view rather than a fold at the foot of the
@@ -352,7 +351,7 @@ func TestDashboardQueues(t *testing.T) {
352 t.Fatalf("review: %s", errOut) 351 t.Fatalf("review: %s", errOut)
353 } 352 }
354 _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/") 353 _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/")
355 if !strings.Contains(after, `<h2 class="empty">Waiting on your review <span class="count">0</span></h2>`) { 354 if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) {
356 t.Fatalf("reviewed MR still waiting:\n%s", after) 355 t.Fatalf("reviewed MR still waiting:\n%s", after)
357 } 356 }
358} 357}
e2e/filenav_test.go added +58
@@ -0,0 +1,58 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// A file page lists its directory beside the file, marks the file, and
11// links up (desktop layout spec).
12func TestFileNavigator(t *testing.T) {
13 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
14 key := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
16 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/nav"); code != 0 {
17 t.Fatalf("repo create: %s", errOut)
18 }
19 // the same clone-commit-push shape TestWebUI uses (e2e/web_test.go:41-50)
20 work := t.TempDir()
21 env := inst.gitEnv(key)
22 mustGit(t, work, env, "clone", inst.sshURL("alice/nav"), "w")
23 dir := filepath.Join(work, "w")
24 os.MkdirAll(filepath.Join(dir, "cmd", "sub"), 0o755)
25 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# nav\n"), 0o644)
26 os.WriteFile(filepath.Join(dir, "cmd", "main.go"), []byte("package main\n"), 0o644)
27 os.WriteFile(filepath.Join(dir, "cmd", "sub", "x.go"), []byte("package sub\n"), 0o644)
28 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
29 mustGit(t, dir, env, "add", ".")
30 mustGit(t, dir, env, "commit", "-q", "-m", "one")
31 mustGit(t, dir, env, "push", "-q", "origin", "main")
32
33 status, body := inst.get(t, "/alice/nav/blob/main/cmd/main.go")
34 if status != 200 {
35 t.Fatalf("blob: %d", status)
36 }
37 for _, want := range []string{
38 `<nav class="filenav" aria-label="Files">`,
39 `<h2 class="colhead">cmd</h2>`,
40 `<a class="up" href="/alice/nav/tree/main">..</a>`,
41 `<a class="dir" href="/alice/nav/tree/main/cmd/sub">sub/</a>`,
42 `<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`,
43 } {
44 if !strings.Contains(body, want) {
45 t.Errorf("blob page lacks %q", want)
46 }
47 }
48 _, body = inst.get(t, "/alice/nav/blame/main/README.md")
49 if !strings.Contains(body, `<h2 class="colhead">nav</h2>`) || !strings.Contains(body, `<a aria-current="page" href="/alice/nav/blob/main/README.md">README.md</a>`) {
50 t.Errorf("blame page lacks the root navigator:\n%s", body)
51 }
52
53 alice := inst.login(t, key)
54 status, body = browserGet(t, alice, inst.base()+"/alice/nav/edit/main/cmd/main.go")
55 if status != 200 || !strings.Contains(body, `<h2 class="colhead">cmd</h2>`) || !strings.Contains(body, `<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`) {
56 t.Errorf("edit page lacks the navigator: %d\n%s", status, body)
57 }
58}
e2e/labelweb_test.go +14
@@ -51,6 +51,20 @@ func TestLabelsWeb(t *testing.T) {
51 t.Errorf("bad colour accepted:\n%s", body) 51 t.Errorf("bad colour accepted:\n%s", body)
52 } 52 }
53 53
54 // The issue list's column lists the label with its count and a link
55 // that keeps the state (desktop layout spec).
56 status, page = browserGet(t, alice, base+"/issues?state=open")
57 if status != 200 || !strings.Contains(page, `<nav class="sidecol" aria-label="Filters">`) {
58 t.Fatalf("issues page lacks the side column: %d", status)
59 }
60 if !strings.Contains(page, `href="?label=bug&amp;state=open">bug <i>1</i></a>`) {
61 t.Fatalf("issues column lacks the bug facet:\n%s", page)
62 }
63 status, page = browserGet(t, alice, base+"/issues?state=open&label=bug")
64 if status != 200 || !strings.Contains(page, `aria-current="page" href="?state=open">bug <i>1</i></a>`) {
65 t.Fatalf("active facet does not clear itself:\n%s", page)
66 }
67
54 // Removing a label needs its name typed; a bare post is refused and 68 // Removing a label needs its name typed; a bare post is refused and
55 // the label stays. 69 // the label stays.
56 _, body = browserPost(t, alice, base+"/labels", url.Values{ 70 _, body = browserPost(t, alice, base+"/labels", url.Values{
e2e/mrweb_test.go +10
@@ -575,6 +575,16 @@ func TestMRWebLabels(t *testing.T) {
575 t.Fatalf("removed label still lists the merge request:\n%s", body) 575 t.Fatalf("removed label still lists the merge request:\n%s", body)
576 } 576 }
577 577
578 // The list's column lists the label with its merge request count and a
579 // link that keeps the state (desktop layout spec).
580 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs")
581 if !strings.Contains(body, `<nav class="sidecol" aria-label="Filters">`) {
582 t.Fatalf("merge request list lacks the side column:\n%s", body)
583 }
584 if !strings.Contains(body, `href="?label=bug&amp;state=open">bug <i>1</i></a>`) {
585 t.Fatalf("merge request column lacks the bug facet:\n%s", body)
586 }
587
578 // A reader gets the chips and no form. 588 // A reader gets the chips and no form.
579 _, body = browserGet(t, inst.login(t, bobKey), mrURL) 589 _, body = browserGet(t, inst.login(t, bobKey), mrURL)
580 if !strings.Contains(body, `class="chip label"`) { 590 if !strings.Contains(body, `class="chip label"`) {
internal/httpd/accounts.go +4
@@ -525,6 +525,7 @@ type editPage struct {
525 // Preview button makes sense; Draft holds one when asked for (#235). 525 // Preview button makes sense; Draft holds one when asked for (#235).
526 Markup bool 526 Markup bool
527 Draft *draft 527 Draft *draft
528 Nav fileNav
528} 529}
529 530
530func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) { 531func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -559,10 +560,13 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
559 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest) 560 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
560 return 561 return
561 } 562 }
563 navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
564 nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
562 s.render(w, "edit.html", editPage{ 565 s.render(w, "edit.html", editPage{
563 basePage: s.baseFor(u), Repo: repo, 566 basePage: s.baseFor(u), Repo: repo,
564 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating, 567 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
565 Markup: markupFile(filePath), 568 Markup: markupFile(filePath),
569 Nav: nav,
566 }) 570 })
567} 571}
568 572
internal/httpd/buildpages_test.go +9 −9
@@ -30,16 +30,16 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) {
30 filter := buildFilter{} 30 filter := buildFilter{}
31 err := web.Render(&sb, "builds.html", struct { 31 err := web.Render(&sb, "builds.html", struct {
32 repoPage 32 repoPage
33 Builds []control.BuildOut 33 Builds []control.BuildOut
34 Jobs []control.JobOut 34 Jobs []control.JobOut
35 Runs []buildRun 35 Runs []buildRun
36 Filter buildFilter 36 Filter buildFilter
37 FilterLinks []buildFilterLink 37 Facets []facetGroup
38 Refs []string 38 Refs []string
39 CanWrite bool 39 CanWrite bool
40 Notice string 40 Notice string
41 }{ 41 }{
42 testRepoPage(), builds, jobs, groupRuns(builds), filter, filterLinks(filter, jobs), 42 testRepoPage(), builds, jobs, groupRuns(builds), filter, nil,
43 distinctRefs(builds, filter.Ref), true, "", 43 distinctRefs(builds, filter.Ref), true, "",
44 }) 44 })
45 if err != nil { 45 if err != nil {
internal/httpd/builds.go +46 −16
@@ -16,9 +16,9 @@ type buildFilter struct {
16 Job string 16 Job string
17} 17}
18 18
19// buildFilterLink is one entry in the nav.filters row above the build 19// buildFilterLink is one of the links buildFacets splits into the side
20// list: a status or a job, with the other two parameters carried along so 20// column's Status and Jobs groups: a status or a job, with the other two
21// clicking one never drops another. 21// parameters carried along so clicking one never drops another.
22type buildFilterLink struct { 22type buildFilterLink struct {
23 Label string 23 Label string
24 Href string 24 Href string
@@ -26,12 +26,13 @@ type buildFilterLink struct {
26} 26}
27 27
28// buildStatuses is the fixed vocabulary a build's status takes, in the 28// buildStatuses is the fixed vocabulary a build's status takes, in the
29// order the nav.filters row offers them. 29// order the Status group offers them. Its length is also where buildFacets
30// cuts filterLinks' rows apart.
30var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"} 31var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"}
31 32
32// filterLinks builds the nav.filters row: "all" (clears status and job), 33// filterLinks builds the status and job rows: "all" (clears status and
33// one link per status, and one per job the repository's CI config names. 34// job), one link per status, and one per job the repository's CI config
34// Each link keeps the filter's other two parameters and net/url encodes 35// names. Each link keeps the filter's other two parameters and net/url encodes
35// them, so a branch name or job name with an odd character does not break 36// them, so a branch name or job name with an odd character does not break
36// the query string it lands in. 37// the query string it lands in.
37func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink { 38func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
@@ -60,6 +61,33 @@ func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
60 return links 61 return links
61} 62}
62 63
64// buildFacets is the builds page's side column: filterLinks' rows split
65// into their groups, plus one link per branch seen, which keeps status
66// and job and clears itself when active.
67func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup {
68 links := filterLinks(f, jobs)
69 n := 1 + len(buildStatuses)
70 status := facetGroup{Title: "Status"}
71 for _, l := range links[:n] {
72 status.Items = append(status.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
73 }
74 job := facetGroup{Title: "Jobs"}
75 for _, l := range links[n:] {
76 job.Items = append(job.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
77 }
78 branch := facetGroup{Title: "Branches"}
79 base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}}
80 for _, ref := range refs {
81 active := ref == f.Ref
82 href := facetHref(base, "ref", ref)
83 if active {
84 href = facetHref(base, "ref", "")
85 }
86 branch.Items = append(branch.Items, facetItem{Label: ref, Href: href, Active: active})
87 }
88 return []facetGroup{status, job, branch}
89}
90
63// distinctRefs lists each ref among builds once, in order, plus the 91// distinctRefs lists each ref among builds once, in order, plus the
64// current filter value if it is not already there. It backs the branch 92// current filter value if it is not already there. It backs the branch
65// field's <datalist> suggestions, not a claim about what branches exist: 93// field's <datalist> suggestions, not a claim about what branches exist:
@@ -176,17 +204,19 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
176 var jobs []control.JobOut 204 var jobs []control.JobOut
177 s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs) 205 s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs)
178 206
207 refs := distinctRefs(builds, filter.Ref)
208
179 s.render(w, "builds.html", struct { 209 s.render(w, "builds.html", struct {
180 repoPage 210 repoPage
181 Builds []control.BuildOut 211 Builds []control.BuildOut
182 Jobs []control.JobOut 212 Jobs []control.JobOut
183 Runs []buildRun 213 Runs []buildRun
184 Filter buildFilter 214 Filter buildFilter
185 FilterLinks []buildFilterLink 215 Facets []facetGroup
186 Refs []string 216 Refs []string
187 CanWrite bool 217 CanWrite bool
188 Notice string 218 Notice string
189 }{p, builds, jobs, groupRuns(builds), filter, filterLinks(filter, jobs), distinctRefs(builds, filter.Ref), 219 }{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs,
190 s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) 220 s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
191} 221}
192 222
internal/httpd/builds_test.go +25
@@ -149,3 +149,28 @@ func TestDistinctRefsNoDuplicateWhenCurrentAlreadyPresent(t *testing.T) {
149 t.Errorf("distinctRefs = %v, want %v", got, want) 149 t.Errorf("distinctRefs = %v, want %v", got, want)
150 } 150 }
151} 151}
152
153// The builds column groups the same links filterLinks makes: "all" and
154// the statuses, then the jobs, then the branches seen (desktop layout spec).
155func TestBuildFacetsGroups(t *testing.T) {
156 f := buildFilter{Ref: "main", Status: "success"}
157 groups := buildFacets(f, []control.JobOut{{Name: "lint"}}, []string{"main", "dev"})
158 if len(groups) != 3 || groups[0].Title != "Status" || groups[1].Title != "Jobs" || groups[2].Title != "Branches" {
159 t.Fatalf("groups: %+v", groups)
160 }
161 if groups[0].Items[0].Label != "all" || groups[0].Items[0].Href != "?ref=main" || groups[0].Items[0].Active {
162 t.Errorf("all: %+v", groups[0].Items[0])
163 }
164 if s := groups[0].Items[3]; s.Label != "success" || !s.Active {
165 t.Errorf("success: %+v", s)
166 }
167 if j := groups[1].Items[0]; j.Label != "lint" || j.Href != "?job=lint&ref=main&status=success" || j.Active {
168 t.Errorf("lint: %+v", j)
169 }
170 if b := groups[2].Items[0]; b.Label != "main" || !b.Active || b.Href != "?status=success" {
171 t.Errorf("active branch clears itself: %+v", b)
172 }
173 if b := groups[2].Items[1]; b.Label != "dev" || b.Active || b.Href != "?ref=dev&status=success" {
174 t.Errorf("dev: %+v", b)
175 }
176}
internal/httpd/dashpins.go added +38
@@ -0,0 +1,38 @@
1package httpd
2
3import (
4 "gitbay.org/gitbay/internal/policy"
5 "gitbay.org/gitbay/internal/store"
6)
7
8// pinnedRow is one pinned repository on the dashboard with the counts
9// that say whether it wants attention: open issues, open merge requests
10// and the newest build's status ("" when it has none).
11type pinnedRow struct {
12 Owner string
13 Name string
14 Issues int
15 MRs int
16 Build string
17}
18
19// pinnedRows reads the viewer's pinned repositories the way railFor does,
20// then adds the counts. Three reads per pinned repository, on the
21// dashboard only.
22func (s *Server) pinnedRows(viewer store.User) []pinnedRow {
23 pinned, _ := s.st.PinnedRepos(viewer.ID)
24 var rows []pinnedRow
25 for _, rp := range pinned {
26 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
27 if !policy.CanRead(viewer, rp, grant) {
28 continue
29 }
30 row := pinnedRow{Owner: rp.OwnerName, Name: rp.Name}
31 row.Issues, row.MRs = s.st.OpenCounts(rp.ID)
32 if builds, err := s.st.ListBuilds(rp.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 {
33 row.Build = builds[0].Status
34 }
35 rows = append(rows, row)
36 }
37 return rows
38}
internal/httpd/dashpins_test.go added +70
@@ -0,0 +1,70 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/store"
8 "gitbay.org/gitbay/internal/web"
9)
10
11// The dashboard is three columns: pinned repositories with counts, the
12// tile strip and queue rows, the activity feed. Tiles carry every queue's
13// count; only a non-empty queue lists rows (desktop layout spec).
14func TestDashboardTilesAndPins(t *testing.T) {
15 var sb strings.Builder
16 var base basePage
17 base.Viewer = "alice"
18 err := web.Render(&sb, "dashboard.html", struct {
19 basePage
20 Tab string
21 Pins []pinnedRow
22 Reviews []store.DashboardItem
23 Assigned []store.DashboardItem
24 MRs []store.DashboardItem
25 Issues []store.DashboardItem
26 Feed []feedLine
27 }{base, "dashboard", []pinnedRow{
28 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"},
29 {Owner: "krz", Name: "old", Build: "cancelled"},
30 }, nil, nil, nil,
31 []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil})
32 if err != nil {
33 t.Fatal(err)
34 }
35 out := sb.String()
36 for _, want := range []string{
37 `<div class="dashgrid">`,
38 `<aside class="dashpins" aria-label="Pinned repositories">`,
39 `<span class="owner">krz/</span>gitbay</a>`,
40 `<b class="wants">3 <span class="vh">open issues</span></b>`,
41 `<b>0 <span class="vh">open merge requests</span></b>`,
42 `<span class="dot ok"><span class="vh">last build success</span></span>`,
43 // cancelled is neutral, not the pending orange
44 `<span class="dot"><span class="vh">last build cancelled</span></span>`,
45 `<a class="tile wants" href="#issues"><b>1</b><span>open issues</span></a>`,
46 `<div class="tile"><b>0</b><span>waiting on your review</span></div>`,
47 `<div class="tile"><b>0</b><span>assigned to you</span></div>`,
48 `<div class="tile"><b>0</b><span>open merge requests</span></div>`,
49 `<h2 id="issues">Open issues <span class="count">1</span></h2>`,
50 `<aside class="feedcol" aria-label="Recent activity">`,
51 } {
52 if !strings.Contains(out, want) {
53 t.Errorf("dashboard lacks %q", want)
54 }
55 }
56 if strings.Contains(out, `<h2 class="empty">`) {
57 t.Error("an empty queue still renders as a heading; the tile carries it")
58 }
59 // The tiles run in the queues' order, so the first tile does not jump
60 // to the last section.
61 at := 0
62 for _, label := range []string{"waiting on your review", "assigned to you", "open merge requests", "open issues"} {
63 i := strings.Index(out, "<span>"+label+"</span>")
64 if i < at {
65 t.Errorf("tile %q is out of order", label)
66 break
67 }
68 at = i
69 }
70}
internal/httpd/facets.go added +74
@@ -0,0 +1,74 @@
1package httpd
2
3import (
4 "net/url"
5
6 "gitbay.org/gitbay/internal/store"
7)
8
9// facetItem is one link in a list page's side column: a value the list
10// narrows to. Clicking an active item clears it.
11type facetItem struct {
12 Label string
13 Count int64
14 Href string
15 Active bool
16}
17
18// facetGroup is one heading in the column: State, Labels, Milestones.
19type facetGroup struct {
20 Title string
21 Items []facetItem
22}
23
24// facetHref returns "?..." with every parameter of base kept, key set to
25// value, or dropped when value is "". url.Values encodes sorted, so the
26// tests and the links agree byte for byte.
27func facetHref(base url.Values, key, value string) string {
28 q := url.Values{}
29 for k, vs := range base {
30 if k == key || len(vs) == 0 || vs[0] == "" {
31 continue
32 }
33 q.Set(k, vs[0])
34 }
35 if value != "" {
36 q.Set(key, value)
37 }
38 return "?" + q.Encode()
39}
40
41// listFacets builds the issue or merge request list's column from the
42// active parameters, the states the page offers, and the repository's
43// labels and open milestones. Counts are the rows' own: a label's issue
44// count on the issue list, its MR count on the MR list.
45func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup {
46 var st facetGroup
47 st.Title = "State"
48 for _, s := range states {
49 st.Items = append(st.Items, facetItem{Label: s, Href: facetHref(base, "state", s), Active: s == state})
50 }
51 lb := facetGroup{Title: "Labels"}
52 for _, l := range labels {
53 n := l.Issues
54 if forMRs {
55 n = l.MRs
56 }
57 active := base.Get("label") == l.Name
58 href := facetHref(base, "label", l.Name)
59 if active {
60 href = facetHref(base, "label", "")
61 }
62 lb.Items = append(lb.Items, facetItem{Label: l.Name, Count: n, Href: href, Active: active})
63 }
64 mg := facetGroup{Title: "Milestones"}
65 for _, m := range ms {
66 active := base.Get("milestone") == m.Title
67 href := facetHref(base, "milestone", m.Title)
68 if active {
69 href = facetHref(base, "milestone", "")
70 }
71 mg.Items = append(mg.Items, facetItem{Label: m.Title, Count: int64(m.OpenItems), Href: href, Active: active})
72 }
73 return []facetGroup{st, lb, mg}
74}
internal/httpd/facets_test.go added +52
@@ -0,0 +1,52 @@
1package httpd
2
3import (
4 "net/url"
5 "testing"
6
7 "gitbay.org/gitbay/internal/store"
8)
9
10// A facet link keeps every other active filter, sets its own, and clears
11// its own when it is already active (desktop layout spec).
12func TestFacetHrefKeepsOtherFilters(t *testing.T) {
13 base := url.Values{"state": {"open"}, "label": {"bug"}, "q": {"crash"}}
14 if got := facetHref(base, "milestone", "v2"); got != "?label=bug&milestone=v2&q=crash&state=open" {
15 t.Errorf("set: %q", got)
16 }
17 if got := facetHref(base, "label", ""); got != "?q=crash&state=open" {
18 t.Errorf("clear: %q", got)
19 }
20 if got := facetHref(base, "state", "closed"); got != "?label=bug&q=crash&state=closed" {
21 t.Errorf("replace: %q", got)
22 }
23}
24
25func TestListFacetsGroups(t *testing.T) {
26 base := url.Values{"state": {"open"}, "label": {"bug"}}
27 labels := []store.Label{{Name: "bug", Issues: 2, MRs: 1}, {Name: "docs", Issues: 0, MRs: 3}}
28 ms := []store.Milestone{{Title: "v2", OpenItems: 4}}
29 groups := listFacets(base, []string{"open", "closed", "all"}, "open", labels, ms, false)
30 if len(groups) != 3 || groups[0].Title != "State" || groups[1].Title != "Labels" || groups[2].Title != "Milestones" {
31 t.Fatalf("groups: %+v", groups)
32 }
33 st := groups[0].Items
34 if !st[0].Active || st[0].Href != "?label=bug&state=open" || st[1].Active || st[1].Href != "?label=bug&state=closed" {
35 t.Errorf("state items: %+v", st)
36 }
37 lb := groups[1].Items
38 if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" {
39 t.Errorf("active label clears itself: %+v", lb[0])
40 }
41 if lb[1].Label != "docs" || lb[1].Count != 0 || lb[1].Active || lb[1].Href != "?label=docs&state=open" {
42 t.Errorf("inactive label: %+v", lb[1])
43 }
44 if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" {
45 t.Errorf("milestone: %+v", m)
46 }
47 // on the MR list a label's count is its MR count
48 mr := listFacets(base, []string{"open"}, "open", labels, nil, true)
49 if mr[1].Items[1].Count != 3 {
50 t.Errorf("mr count: %+v", mr[1].Items[1])
51 }
52}
internal/httpd/filenav.go added +72
@@ -0,0 +1,72 @@
1package httpd
2
3import (
4 "path"
5 "sort"
6
7 "gitbay.org/gitbay/internal/gitutil"
8)
9
10// fileNav is the column beside a file: its directory's entries, the file
11// marked, and a link up. It is the tree page's listing rendered as a
12// list, so reading a repository does not mean going back for each file.
13type fileNav struct {
14 Title string // the directory, or the repository name at the root
15 Parent string // URL of the parent tree; "" at the root
16 Entries []fileNavEntry
17}
18
19type fileNavEntry struct {
20 Name string // directories carry a trailing slash
21 URL string
22 Dir bool
23 Current bool
24}
25
26// sortDirsFirst orders a listing by shape before name, stably, so each
27// group keeps the order git gave it. The tree page and the navigator
28// share it.
29func sortDirsFirst(entries []gitutil.TreeEntry) {
30 sort.SliceStable(entries, func(i, j int) bool {
31 return entries[i].Type == "tree" && entries[j].Type != "tree"
32 })
33}
34
35// navDir is the directory ListTree wants for filePath: "" at the root.
36func navDir(filePath string) string {
37 if d := path.Dir(filePath); d != "." {
38 return d
39 }
40 return ""
41}
42
43// fileNavFor builds the navigator for filePath from its directory's
44// entries. repoPath is owner/name.
45func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav {
46 dir := path.Dir(filePath)
47 if dir == "." {
48 dir = ""
49 }
50 base := "/" + repoPath
51 nav := fileNav{Title: dir}
52 if dir == "" {
53 nav.Title = path.Base(repoPath)
54 } else if up := path.Dir(dir); up == "." {
55 nav.Parent = base + "/tree/" + ref
56 } else {
57 nav.Parent = base + "/tree/" + ref + "/" + up
58 }
59 sortDirsFirst(entries)
60 for _, e := range entries {
61 full := path.Join(dir, e.Name)
62 ent := fileNavEntry{Name: e.Name, Dir: e.Type == "tree", Current: full == filePath}
63 if ent.Dir {
64 ent.Name += "/"
65 ent.URL = base + "/tree/" + ref + "/" + full
66 } else {
67 ent.URL = base + "/blob/" + ref + "/" + full
68 }
69 nav.Entries = append(nav.Entries, ent)
70 }
71 return nav
72}
internal/httpd/filenav_test.go added +42
@@ -0,0 +1,42 @@
1package httpd
2
3import (
4 "testing"
5
6 "gitbay.org/gitbay/internal/gitutil"
7)
8
9// The navigator lists the file's directory, directories first, links each
10// entry to its tree or blob page, marks the file itself, and links the
11// parent (the tree root when the file is at the top).
12func TestFileNavMarksCurrentAndLinksParent(t *testing.T) {
13 entries := []gitutil.TreeEntry{
14 {Type: "blob", Name: "main.go"},
15 {Type: "tree", Name: "sub"},
16 {Type: "blob", Name: "util.go"},
17 }
18 nav := fileNavFor("krz/gitbay", "main", "cmd/gitbay/util.go", entries)
19 if nav.Title != "cmd/gitbay" {
20 t.Errorf("title = %q", nav.Title)
21 }
22 if nav.Parent != "/krz/gitbay/tree/main/cmd" {
23 t.Errorf("parent = %q", nav.Parent)
24 }
25 if len(nav.Entries) != 3 || nav.Entries[0].Name != "sub/" || !nav.Entries[0].Dir {
26 t.Fatalf("entries not directories-first: %+v", nav.Entries)
27 }
28 if nav.Entries[0].URL != "/krz/gitbay/tree/main/cmd/gitbay/sub" {
29 t.Errorf("dir url = %q", nav.Entries[0].URL)
30 }
31 if nav.Entries[2].Name != "util.go" || !nav.Entries[2].Current || nav.Entries[2].URL != "/krz/gitbay/blob/main/cmd/gitbay/util.go" {
32 t.Errorf("current entry: %+v", nav.Entries[2])
33 }
34 if nav.Entries[1].Current {
35 t.Error("main.go marked current")
36 }
37
38 root := fileNavFor("krz/gitbay", "main", "Makefile", []gitutil.TreeEntry{{Type: "blob", Name: "Makefile"}})
39 if root.Title != "gitbay" || root.Parent != "" {
40 t.Errorf("root nav: title %q parent %q", root.Title, root.Parent)
41 }
42}
internal/httpd/mrsrow_test.go +1
@@ -13,6 +13,7 @@ type mrsPageData struct {
13 State string 13 State string
14 Query string 14 Query string
15 Filters []listFilter 15 Filters []listFilter
16 Facets []facetGroup
16 MRs []mrRow 17 MRs []mrRow
17 Older string 18 Older string
18} 19}
internal/httpd/page.go +1 −17
@@ -3,21 +3,13 @@ package httpd
3import ( 3import (
4 "net/http" 4 "net/http"
5 5
6 "gitbay.org/gitbay/internal/policy"
7 "gitbay.org/gitbay/internal/store" 6 "gitbay.org/gitbay/internal/store"
8) 7)
9 8
10// railRepo is one pinned repository in the rail.
11type railRepo struct {
12 Owner string
13 Name string
14}
15
16// rail is the viewer's cross-repo state the layout needs. The rail itself 9// rail is the viewer's cross-repo state the layout needs. The rail itself
17// is icons only, so what is left is the unread count on its bell; the 10// is icons only, so what is left is the unread count on its bell; the
18// pinned repositories it used to list are rendered by the dashboard. 11// pinned repositories it used to list are rendered by the dashboard.
19type rail struct { 12type rail struct {
20 Pinned []railRepo
21 Unread int 13 Unread int
22} 14}
23 15
@@ -61,17 +53,9 @@ func (s *Server) baseFor(viewer store.User) basePage {
61 return b 53 return b
62} 54}
63 55
64// railFor collects the viewer's pinned repositories and unread count, 56// railFor collects the viewer's unread count.
65// dropping anything they may no longer read.
66func (s *Server) railFor(viewer store.User) rail { 57func (s *Server) railFor(viewer store.User) rail {
67 var rl rail 58 var rl rail
68 pinned, _ := s.st.PinnedRepos(viewer.ID)
69 for _, rp := range pinned {
70 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
71 if policy.CanRead(viewer, rp, grant) {
72 rl.Pinned = append(rl.Pinned, railRepo{Owner: rp.OwnerName, Name: rp.Name})
73 }
74 }
75 rl.Unread = s.st.UnreadNotices(viewer.ID) 59 rl.Unread = s.st.UnreadNotices(viewer.ID)
76 return rl 60 return rl
77} 61}
internal/httpd/repohead_test.go added +53
@@ -0,0 +1,53 @@
1package httpd
2
3import (
4 "strings"
5 "testing"
6
7 "gitbay.org/gitbay/internal/control"
8 "gitbay.org/gitbay/internal/web"
9)
10
11// The repository header is two rows: identity with the description and
12// the buttons, then the tabs. The toggles hint is title text on the
13// buttons, not a line of its own (desktop layout spec).
14func TestRepoHeaderTwoRows(t *testing.T) {
15 var sb strings.Builder
16 p := testRepoPage()
17 p.Viewer = "alice"
18 p.Desc = "A CLI-first git forge."
19 p.Topics = []string{"cli"}
20 p.Tab = "files"
21 err := web.Render(&sb, "builds.html", struct {
22 repoPage
23 Builds []control.BuildOut
24 Jobs []control.JobOut
25 Runs []buildRun
26 Filter buildFilter
27 Facets []facetGroup
28 Refs []string
29 CanWrite bool
30 Notice string
31 }{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""})
32 if err != nil {
33 t.Fatal(err)
34 }
35 out := sb.String()
36 if strings.Contains(out, `class="toggles"`) || strings.Contains(out, "Pinned shows on your dashboard.") {
37 t.Error("the toggles hint still renders as a line")
38 }
39 for _, want := range []string{
40 `title="Pinned repositories show on your dashboard"`,
41 `title="Watching sends every issue, request and build to your inbox"`,
42 `title="Bookmarked lists it under Bookmarks"`,
43 `<p class="repodesc">A CLI-first git forge.`,
44 } {
45 if !strings.Contains(out, want) {
46 t.Errorf("header lacks %q", want)
47 }
48 }
49 // the description sits inside the identity row, before the buttons
50 if strings.Index(out, `class="repodesc"`) > strings.Index(out, `action="/krz/gitbay/pin"`) {
51 t.Error("description renders after the buttons; it belongs in the identity row")
52 }
53}
internal/httpd/topics.go added +40
@@ -0,0 +1,40 @@
1package httpd
2
3import (
4 "net/url"
5 "sort"
6)
7
8// topicFacets counts the topics across repos for explore's column. The
9// links are the ones topic chips already use, ?q=<topic>; the active
10// topic links to explore with no query.
11func topicFacets(repos []describedRepo, q string) facetGroup {
12 counts := map[string]int64{}
13 for _, r := range repos {
14 for _, t := range r.Topics {
15 counts[t]++
16 }
17 }
18 names := make([]string, 0, len(counts))
19 for t := range counts {
20 names = append(names, t)
21 }
22 sort.Slice(names, func(i, j int) bool {
23 if counts[names[i]] != counts[names[j]] {
24 return counts[names[i]] > counts[names[j]]
25 }
26 return names[i] < names[j]
27 })
28 if len(names) > 20 {
29 names = names[:20]
30 }
31 g := facetGroup{Title: "Topics"}
32 for _, t := range names {
33 item := facetItem{Label: t, Count: counts[t], Href: "/explore?q=" + url.QueryEscape(t), Active: t == q}
34 if item.Active {
35 item.Href = "/explore"
36 }
37 g.Items = append(g.Items, item)
38 }
39 return g
40}
internal/httpd/topics_test.go added +34
@@ -0,0 +1,34 @@
1package httpd
2
3import "testing"
4
5// Explore's column counts topics across the visible repositories, most
6// used first then by name, capped at twenty, each linking to ?q=<topic>
7// and the active one clearing the query (desktop layout spec).
8func TestTopicFacets(t *testing.T) {
9 repos := []describedRepo{
10 {Topics: []string{"cli", "git"}},
11 {Topics: []string{"git", "swift"}},
12 {Topics: []string{"git"}},
13 }
14 g := topicFacets(repos, "cli")
15 if g.Title != "Topics" || len(g.Items) != 3 {
16 t.Fatalf("group: %+v", g)
17 }
18 if g.Items[0].Label != "git" || g.Items[0].Count != 3 || g.Items[0].Href != "/explore?q=git" {
19 t.Errorf("git: %+v", g.Items[0])
20 }
21 if g.Items[1].Label != "cli" || !g.Items[1].Active || g.Items[1].Href != "/explore" {
22 t.Errorf("cli: %+v", g.Items[1])
23 }
24 if g.Items[2].Label != "swift" || g.Items[2].Count != 1 {
25 t.Errorf("swift: %+v", g.Items[2])
26 }
27 var many []describedRepo
28 for i := 0; i < 30; i++ {
29 many = append(many, describedRepo{Topics: []string{string(rune('a' + i))}})
30 }
31 if n := len(topicFacets(many, "").Items); n != 20 {
32 t.Errorf("cap: %d", n)
33 }
34}
internal/httpd/web.go +31 −16
@@ -20,7 +20,6 @@ import (
20 "net/url" 20 "net/url"
21 "path" 21 "path"
22 "regexp" 22 "regexp"
23 "sort"
24 "strconv" 23 "strconv"
25 "strings" 24 "strings"
26 "time" 25 "time"
@@ -191,12 +190,13 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
191 s.render(w, "dashboard.html", struct { 190 s.render(w, "dashboard.html", struct {
192 basePage 191 basePage
193 Tab string 192 Tab string
193 Pins []pinnedRow
194 Reviews []store.DashboardItem 194 Reviews []store.DashboardItem
195 Assigned []store.DashboardItem 195 Assigned []store.DashboardItem
196 MRs []store.DashboardItem 196 MRs []store.DashboardItem
197 Issues []store.DashboardItem 197 Issues []store.DashboardItem
198 Feed []feedLine 198 Feed []feedLine
199 }{s.baseFor(viewer), "dashboard", reviews, assigned, mrs, issues, feedLines(events)}) 199 }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)})
200} 200}
201 201
202func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 202func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -210,12 +210,14 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
210 viewer = s.viewer(r) 210 viewer = s.viewer(r)
211 } 211 }
212 q := strings.TrimSpace(r.URL.Query().Get("q")) 212 q := strings.TrimSpace(r.URL.Query().Get("q"))
213 described := s.describeAll(repos)
213 s.render(w, "explore.html", struct { 214 s.render(w, "explore.html", struct {
214 basePage 215 basePage
215 Tab string 216 Tab string
216 Query string 217 Query string
217 Repos []describedRepo 218 Facets []facetGroup
218 }{s.baseFor(viewer), "explore", q, s.filterRepos(q, s.describeAll(repos))}) 219 Repos []describedRepo
220 }{s.baseFor(viewer), "explore", q, []facetGroup{topicFacets(described, q)}, s.filterRepos(q, described)})
219} 221}
220 222
221// privacy renders the privacy page: what the gitbay software does with 223// privacy renders the privacy page: what the gitbay software does with
@@ -520,12 +522,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
520 s.notFound(w, r) 522 s.notFound(w, r)
521 return 523 return
522 } 524 }
523 // Directories first. git's tree order interleaves them with files, but 525 sortDirsFirst(entries)
524 // a listing is scanned by shape before name. Stable, so each group
525 // keeps the ordering git gave it.
526 sort.SliceStable(entries, func(i, j int) bool {
527 return entries[i].Type == "tree" && entries[j].Type != "tree"
528 })
529 prefix := "" 526 prefix := ""
530 if dirPath != "" { 527 if dirPath != "" {
531 prefix = dirPath + "/" 528 prefix = dirPath + "/"
@@ -590,6 +587,8 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
590 cs = cs[:len(cs)-1] 587 cs = cs[:len(cs)-1]
591 } 588 }
592 branches, _ := gitutil.Refs(p.Dir, "heads") 589 branches, _ := gitutil.Refs(p.Dir, "heads")
590 navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
591 nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
593 lines := 0 592 lines := 0
594 if !binary && !image && len(data) > 0 { 593 if !binary && !image && len(data) > 0 {
595 lines = bytes.Count(data, []byte("\n")) 594 lines = bytes.Count(data, []byte("\n"))
@@ -618,8 +617,9 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
618 Renderable bool // markdown or org: the toggle is offered 617 Renderable bool // markdown or org: the toggle is offered
619 Rendered bool // this response shows the rendering 618 Rendered bool // this response shows the rendering
620 RenderedHTML template.HTML 619 RenderedHTML template.HTML
620 Nav fileNav
621 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines, 621 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
622 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML}) 622 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML, nav})
623} 623}
624 624
625// releases lists tag-anchored releases with notes and assets. 625// releases lists tag-anchored releases with notes and assets.
@@ -923,6 +923,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
923 base = cs[len(cs)-1].Name 923 base = cs[len(cs)-1].Name
924 cs = cs[:len(cs)-1] 924 cs = cs[:len(cs)-1]
925 } 925 }
926 navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
927 nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
926 s.render(w, "blame.html", struct { 928 s.render(w, "blame.html", struct {
927 repoPage 929 repoPage
928 Crumbs []crumb 930 Crumbs []crumb
@@ -931,7 +933,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
931 Binary bool 933 Binary bool
932 Hunks []hunkView 934 Hunks []hunkView
933 Page, Pages int 935 Page, Pages int
934 }{p, cs, base, filePath, binary, hunks, page, pages}) 936 Nav fileNav
937 }{p, cs, base, filePath, binary, hunks, page, pages, nav})
935} 938}
936 939
937type numberedLine struct { 940type numberedLine struct {
@@ -1737,18 +1740,24 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1737 issues[i].Labels = labels[issues[i].ID] 1740 issues[i].Labels = labels[issues[i].ID]
1738 } 1741 }
1739 } 1742 }
1743 base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
1744 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1745 allLabels, _ := s.st.ListLabels(p.Repo, readable)
1746 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1747 facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
1740 s.render(w, "issues.html", struct { 1748 s.render(w, "issues.html", struct {
1741 repoPage 1749 repoPage
1742 State string 1750 State string
1743 Label string 1751 Label string
1744 Query string 1752 Query string
1745 Filters []listFilter 1753 Filters []listFilter
1754 Facets []facetGroup
1746 Issues []store.Issue 1755 Issues []store.Issue
1747 LabelColors map[string]template.CSS 1756 LabelColors map[string]template.CSS
1748 Older string 1757 Older string
1749 }{p, state, f.Label, f.Search, 1758 }{p, state, f.Label, f.Search,
1750 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}), 1759 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1751 issues, s.labelColors(p.Repo), older}) 1760 facets, issues, s.labelColors(p.Repo), older})
1752} 1761}
1753 1762
1754func (s *Server) issue(w http.ResponseWriter, r *http.Request) { 1763func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
@@ -1900,17 +1909,23 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1900 m.Labels = labels[m.ID] 1909 m.Labels = labels[m.ID]
1901 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]} 1910 rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
1902 } 1911 }
1912 base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
1913 readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1914 allLabels, _ := s.st.ListLabels(p.Repo, readable)
1915 openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1916 facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
1903 s.render(w, "mrs.html", struct { 1917 s.render(w, "mrs.html", struct {
1904 repoPage 1918 repoPage
1905 State string 1919 State string
1906 Query string 1920 Query string
1907 Filters []listFilter 1921 Filters []listFilter
1922 Facets []facetGroup
1908 MRs []mrRow 1923 MRs []mrRow
1909 LabelColors map[string]template.CSS 1924 LabelColors map[string]template.CSS
1910 Older string 1925 Older string
1911 }{p, state, mf.Search, 1926 }{p, state, mf.Search,
1912 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}), 1927 activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
1913 rows, s.labelColors(p.Repo), older}) 1928 facets, rows, s.labelColors(p.Repo), older})
1914} 1929}
1915 1930
1916func (s *Server) mr(w http.ResponseWriter, r *http.Request) { 1931func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
internal/web/layout_test.go added +31
@@ -0,0 +1,31 @@
1package web
2
3import (
4 "strings"
5 "testing"
6)
7
8// The repository header, main and footer share one centered container:
9// the header's inner content is wrapped, and the stylesheet caps and
10// centers all three on the same token (desktop layout spec).
11func TestSharedCenteredContainer(t *testing.T) {
12 layout, err := templateFS.ReadFile("templates/layout.html")
13 if err != nil {
14 t.Fatal(err)
15 }
16 if !strings.Contains(string(layout), "<header class=\"repohead\">\n<div class=\"wrap\">") {
17 t.Fatalf("repohead is not wrapped in .wrap")
18 }
19 css := string(StyleCSS)
20 for _, want := range []string{
21 "--container: 100rem;",
22 "main.content, main.wide, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }",
23 ".repohead .wrap { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; padding: 0 var(--sp-6); }",
24 "main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }",
25 "main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }",
26 } {
27 if !strings.Contains(css, want) {
28 t.Errorf("style.css lacks %q", want)
29 }
30 }
31}
internal/web/static/style.css +163 −22
@@ -125,6 +125,9 @@
125 --rail-mark: 24px; 125 --rail-mark: 24px;
126 --rail-mark-box: 32px; 126 --rail-mark-box: 32px;
127 127
128 /* the one page container: header content, main and footer align on it */
129 --container: 100rem;
130
128 --r-ctl: 4px; /* controls and chips */ 131 --r-ctl: 4px; /* controls and chips */
129 --r-card: 6px; /* cards, lists, code blocks */ 132 --r-card: 6px; /* cards, lists, code blocks */
130 --shadow: 0 4px 16px rgba(0, 0, 0, 0.12); 133 --shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
@@ -372,9 +375,11 @@ main.content {
372 width: 100%; 375 width: 100%;
373 padding: var(--sp-5) var(--sp-6) var(--sp-7); 376 padding: var(--sp-5) var(--sp-6) var(--sp-7);
374} 377}
375main.wide { max-width: none; } 378/* the container is the default width and what `wide` names; reading and
376main.reading { max-width: calc(72rem + 2 * var(--sp-6)); } 379 bounded follow, so equal specificity gives them the cascade */
377main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); } 380main.content, main.wide, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }
381main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }
382main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }
378footer { 383footer {
379 width: 100%; 384 width: 100%;
380 padding: var(--sp-4) var(--sp-6); 385 padding: var(--sp-4) var(--sp-6);
@@ -388,9 +393,10 @@ footer a:hover { color: var(--link); }
388 393
389/* ---- repository header ---- */ 394/* ---- repository header ---- */
390.repohead { 395.repohead {
391 padding: var(--sp-4) var(--sp-6) 0; 396 padding: var(--sp-4) 0 0;
392 border-bottom: 1px solid var(--line); 397 border-bottom: 1px solid var(--line);
393} 398}
399.repohead .wrap { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; padding: 0 var(--sp-6); }
394.identity { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } 400.identity { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
395.grow { flex: 1; } 401.grow { flex: 1; }
396/* h1 on the repo home, p elsewhere so each page keeps its own h1 — but 402/* h1 on the repo home, p elsewhere so each page keeps its own h1 — but
@@ -407,13 +413,15 @@ footer a:hover { color: var(--link); }
407.repotitle a:hover { color: var(--link); text-decoration: none; } 413.repotitle a:hover { color: var(--link); text-decoration: none; }
408.repotitle .sep { color: var(--muted); margin: 0 2px; font-weight: 400; } 414.repotitle .sep { color: var(--muted); margin: 0 2px; font-weight: 400; }
409.repodesc { 415.repodesc {
410 margin: var(--sp-2) 0 0; 416 margin: 0 0 0 var(--sp-2);
411 color: var(--fg); 417 color: var(--muted);
412 font-size: var(--fs-2); 418 font-size: var(--fs-2);
413 display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; 419 display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
420 min-width: 0;
414} 421}
422.repodesc a.site { color: var(--muted); }
423.repodesc a.site:hover { color: var(--link); }
415.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); } 424.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); }
416.toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); }
417 425
418nav.tabs { 426nav.tabs {
419 display: flex; 427 display: flex;
@@ -713,7 +721,6 @@ form.setform input[type="text"], form.setform input[type="number"], form.setform
713form.setform > button, form.setform > .btngroup { justify-self: start; } 721form.setform > button, form.setform > .btngroup { justify-self: start; }
714form.setform.stack { grid-template-columns: 1fr; } 722form.setform.stack { grid-template-columns: 1fr; }
715form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; } 723form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; }
716nav.sections { display: flex; gap: var(--sp-4); flex-wrap: wrap; font-size: var(--fs-2); margin-bottom: var(--sp-5); }
717ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; } 724ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; }
718ul.protlist li { 725ul.protlist li {
719 display: flex; 726 display: flex;
@@ -857,6 +864,8 @@ tr.tipbar td {
857 864
858.readme { margin-top: var(--sp-5); } 865.readme { margin-top: var(--sp-5); }
859.readme .cardbody { max-width: 78ch; } 866.readme .cardbody { max-width: 78ch; }
867/* the overview README caps as a card, so prose and code share an edge */
868.overview .readme { max-width: 88ch; }
860/* until every card wraps its body in .cardbody, the rendered block is 869/* until every card wraps its body in .cardbody, the rendered block is
861 the body */ 870 the body */
862.readme > .rendered { padding: var(--sp-4) var(--sp-5); } 871.readme > .rendered { padding: var(--sp-4) var(--sp-5); }
@@ -890,6 +899,10 @@ ul.loglist .subject a { color: var(--fg); font-weight: 500; }
890ul.loglist .subject a:hover { color: var(--link); } 899ul.loglist .subject a:hover { color: var(--link); }
891ul.loglist .commitside { flex: none; display: flex; gap: var(--sp-2); align-items: center; white-space: nowrap; } 900ul.loglist .commitside { flex: none; display: flex; gap: var(--sp-2); align-items: center; white-space: nowrap; }
892 901
902ul.loglist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); }
903ul.loglist.rows .commitmain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
904ul.loglist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; }
905
893ul.repolist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; } 906ul.repolist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; }
894ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } 907ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
895ul.repolist li:last-child { border-bottom: 0; } 908ul.repolist li:last-child { border-bottom: 0; }
@@ -902,6 +915,12 @@ ul.repolist .reponame .sep { color: var(--muted); margin: 0 2px; }
902ul.repolist .desc { margin-top: 2px; } 915ul.repolist .desc { margin-top: 2px; }
903ul.repolist .topics, ul.repolist .meta { margin-top: var(--sp-1); } 916ul.repolist .topics, ul.repolist .meta { margin-top: var(--sp-1); }
904 917
918ul.repolist.rows li { display: flex; align-items: baseline; gap: var(--sp-3); padding: var(--sp-2) var(--sp-4); }
919ul.repolist.rows .reponame { flex: none; font-size: var(--fs-2); }
920ul.repolist.rows .desc { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: var(--fs-2); margin: 0; }
921ul.repolist.rows .topics, ul.repolist.rows .meta { flex: none; margin: 0; white-space: nowrap; }
922ul.repolist.rows .meta { font-size: var(--fs-1); }
923
905/* pinned repos: a compact wrap of names, no description or meta */ 924/* pinned repos: a compact wrap of names, no description or meta */
906ul.pinlist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; display: flex; flex-wrap: wrap; gap: var(--sp-2); } 925ul.pinlist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; display: flex; flex-wrap: wrap; gap: var(--sp-2); }
907ul.pinlist li { 926ul.pinlist li {
@@ -932,6 +951,15 @@ ul.issuelist .issueside { display: flex; gap: var(--sp-2); align-items: center;
932ul.issuelist .title a { color: var(--fg); font-weight: 500; } 951ul.issuelist .title a { color: var(--fg); font-weight: 500; }
933ul.issuelist .title a:hover { color: var(--link); } 952ul.issuelist .title a:hover { color: var(--link); }
934 953
954/* one-line rows: title, labels, then the meta pushed right. Above 64rem
955 a list is a table, not prose (desktop layout spec). */
956ul.issuelist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); }
957ul.issuelist.rows .issuemain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
958ul.issuelist.rows .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
959ul.issuelist.rows .title .chip { margin-left: var(--sp-1); }
960ul.issuelist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; }
961ul.issuelist.rows .meta .repo { color: var(--fg); }
962
935ul.milestonelist { list-style: none; margin: var(--sp-3) 0; padding: 0; } 963ul.milestonelist { list-style: none; margin: var(--sp-3) 0; padding: 0; }
936ul.milestonelist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } 964ul.milestonelist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); }
937ul.milestonelist li:last-child { border-bottom: 0; } 965ul.milestonelist li:last-child { border-bottom: 0; }
@@ -1015,12 +1043,50 @@ pre.message {
1015.blobimage { text-align: center; } 1043.blobimage { text-align: center; }
1016.blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); } 1044.blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); }
1017 1045
1018/* pinned repositories, on the dashboard now that the rail is icons only */ 1046/* ---- dashboard: pinned column, tiles and queues, feed ---- */
1019.pinned { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-4); } 1047.dashgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; }
1020.pinned .owner { color: var(--muted); } 1048.dashgrid h1 { margin-top: 0; }
1049.dashgrid > aside { position: sticky; top: var(--sp-5); }
1050.dashgrid .dashmain h2 { margin-top: var(--sp-5); }
1051.dashgrid .dashmain .tiles + h2 { margin-top: 0; }
1052.colhead {
1053 margin: 0 0 var(--sp-2);
1054 font-size: var(--fs-0);
1055 font-weight: 500;
1056 letter-spacing: 0.08em;
1057 text-transform: uppercase;
1058 color: var(--muted);
1059}
1060.pins { list-style: none; margin: 0; padding: 0; font-size: var(--fs-2); }
1061.pins li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; border-bottom: 1px solid var(--faint); }
1062.pins li:last-child { border-bottom: 0; }
1063.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1064.pins a:hover { color: var(--link); }
1065.pins .owner { color: var(--muted); }
1066.pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; }
1067.pins .n b { font-weight: 600; color: var(--fg); }
1068.pins .n b.wants { color: var(--warn); }
1069.pins .dot { margin: 0; }
1070/* count tiles: the queues' sizes in one strip; a non-zero count is orange,
1071 what wants you */
1072.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin: var(--sp-4) 0 var(--sp-5); }
1073.tile {
1074 display: block;
1075 background: var(--surface);
1076 border: 1px solid var(--line);
1077 border-radius: var(--r-card);
1078 padding: var(--sp-3) var(--sp-4);
1079 color: var(--fg);
1080}
1081a.tile:hover { background: var(--hover); text-decoration: none; }
1082.tile b { display: block; font-size: var(--fs-5); font-weight: 600; line-height: 1.2; }
1083.tile.wants b { color: var(--warn); }
1084.tile span { font-size: var(--fs-1); color: var(--muted); }
1085.feedcol .feedline { font-size: var(--fs-2); margin-bottom: var(--sp-2); }
1086.feedcol .feedline .none { font-size: var(--fs-1); }
1021 1087
1022/* ---- two-column: conversation and aside ---- */ 1088/* ---- two-column: conversation and aside ---- */
1023.withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; } 1089.withaside { display: grid; grid-template-columns: minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; }
1024.withaside .mainside { min-width: 0; } 1090.withaside .mainside { min-width: 0; }
1025aside.aside { font-size: var(--fs-2); } 1091aside.aside { font-size: var(--fs-2); }
1026.aside .grp { border-top: 1px solid var(--line); padding: var(--sp-3) 0; } 1092.aside .grp { border-top: 1px solid var(--line); padding: var(--sp-3) 0; }
@@ -1049,11 +1115,6 @@ form.actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var
1049form.actions button { flex: 1 1 auto; } 1115form.actions button { flex: 1 1 auto; }
1050form.actions select, form.actions textarea { width: 100%; } 1116form.actions select, form.actions textarea { width: 100%; }
1051.feedline { margin-bottom: var(--sp-2); } 1117.feedline { margin-bottom: var(--sp-2); }
1052/* the dashboard's activity feed is a section after the queues, one column */
1053.feed { margin-top: var(--sp-6); max-width: 78ch; }
1054.feed h2 { margin-bottom: var(--sp-3); }
1055.feed .feedline { font-size: var(--fs-2); }
1056.feed .none { color: var(--muted); font-size: var(--fs-1); }
1057 1118
1058/* ---- comments and threads ---- */ 1119/* ---- comments and threads ---- */
1059article.comment { 1120article.comment {
@@ -1311,6 +1372,53 @@ a.memberchip .role { color: var(--muted); }
1311.crumbs { color: var(--muted); } 1372.crumbs { color: var(--muted); }
1312.crumbs a { color: var(--link); } 1373.crumbs a { color: var(--link); }
1313.crumbs strong { color: var(--fg); } 1374.crumbs strong { color: var(--fg); }
1375
1376/* ---- file navigator: the directory beside a file ---- */
1377.blobgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
1378.blobmain { min-width: 0; }
1379.filenav { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
1380.filenav ul { list-style: none; margin: 0; padding: 0; }
1381.filenav li a {
1382 display: block;
1383 padding: 3px var(--sp-2);
1384 border-radius: var(--r-ctl);
1385 color: var(--fg);
1386 font-family: var(--mono);
1387 font-size: var(--fs-1);
1388 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1389}
1390.filenav li a:hover { background: var(--hover); text-decoration: none; }
1391.filenav li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 600; }
1392.filenav li a.dir { color: var(--link); }
1393.filenav li a.up { color: var(--muted); }
1394
1395/* ---- side column: facets or sections beside a list or a form ---- */
1396.withcol { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
1397.withcol.narrow { grid-template-columns: 15rem minmax(0, 56rem); }
1398.colmain { min-width: 0; }
1399.sidecol { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
1400.sidecol .grp { margin-bottom: var(--sp-4); }
1401.sidecol ul { list-style: none; margin: 0; padding: 0; }
1402.sidecol li a {
1403 display: flex; align-items: baseline; gap: var(--sp-2);
1404 padding: 4px var(--sp-2);
1405 border-radius: var(--r-ctl);
1406 color: var(--fg);
1407}
1408.sidecol li a:hover { background: var(--hover); text-decoration: none; }
1409.sidecol li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 500; }
1410.sidecol li a i { margin-left: auto; font-style: normal; color: var(--muted); font-size: var(--fs-1); font-variant-numeric: tabular-nums; }
1411.sidecol form.searchform { margin-top: var(--sp-2); }
1412.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; }
1413
1414/* a sticky column taller than the viewport scrolls inside itself, or its
1415 tail is unreachable once the page scrolls. Above 62rem only: below it
1416 .filenav is hidden and the other two are static. .dashpins stops being
1417 a column at 80rem and resets both properties there. */
1418@media (min-width: 62.01rem) {
1419 .filenav, .sidecol, .dashgrid > aside { max-height: calc(100vh - 2 * var(--sp-4)); overflow-y: auto; }
1420}
1421
1314.refchip { 1422.refchip {
1315 display: inline-flex; 1423 display: inline-flex;
1316 align-items: center; 1424 align-items: center;
@@ -1364,7 +1472,7 @@ details.refmenu .refdrop a.allrefs {
1364/* the side column exists only above 62rem; below it .facts keeps its 1472/* the side column exists only above 62rem; below it .facts keeps its
1365 own two-up layout and the 40rem factgrid rule applies untouched */ 1473 own two-up layout and the 40rem factgrid rule applies untouched */
1366@media (min-width: 62.01rem) { 1474@media (min-width: 62.01rem) {
1367 .overview.withfacts { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; } 1475 .overview.withfacts { display: grid; grid-template-columns: minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; }
1368 .overview.withfacts .facts { display: block; margin-top: 0; max-width: none; } 1476 .overview.withfacts .facts { display: block; margin-top: 0; max-width: none; }
1369 .overview.withfacts .facts .factgrid { grid-template-columns: repeat(2, auto); } 1477 .overview.withfacts .facts .factgrid { grid-template-columns: repeat(2, auto); }
1370 .overview.withfacts .clone pre { white-space: pre-wrap; word-break: break-all; } 1478 .overview.withfacts .clone pre { white-space: pre-wrap; word-break: break-all; }
@@ -1444,7 +1552,6 @@ pre.quickstart { margin: 0 0 var(--sp-4); }
1444.profilehead .meta { margin: var(--sp-1) 0 0; } 1552.profilehead .meta { margin: var(--sp-1) 0 0; }
1445.activity { margin-bottom: var(--sp-5); } 1553.activity { margin-bottom: var(--sp-5); }
1446h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: var(--fs-2); margin-left: var(--sp-2); padding: 0; } 1554h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: var(--fs-2); margin-left: var(--sp-2); padding: 0; }
1447h2.empty { color: var(--muted); font-weight: 500; font-size: var(--fs-3); margin: var(--sp-2) 0; }
1448/* 53 week columns x 7 day rows, tinted with the link blue */ 1555/* 53 week columns x 7 day rows, tinted with the link blue */
1449.actgraph-scroll { overflow-x: auto; padding-bottom: var(--sp-1); } 1556.actgraph-scroll { overflow-x: auto; padding-bottom: var(--sp-1); }
1450.actgraph { display: flex; gap: 3px; width: max-content; padding-top: 18px; } 1557.actgraph { display: flex; gap: 3px; width: max-content; padding-top: 18px; }
@@ -1464,7 +1571,7 @@ h2.empty { color: var(--muted); font-weight: 500; font-size: var(--fs-3); margin
1464/* ---- wiki ---- */ 1571/* ---- wiki ---- */
1465.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; } 1572.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; }
1466.wikipage { flex: 1; min-width: 0; } 1573.wikipage { flex: 1; min-width: 0; }
1467.wikinav { flex: none; width: 14rem; } 1574.wikinav { flex: none; width: 15rem; }
1468.wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; } 1575.wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; }
1469.wikinav li a { 1576.wikinav li a {
1470 display: block; 1577 display: block;
@@ -1498,20 +1605,54 @@ svg.icon { vertical-align: -0.125em; }
1498.review { font-size: var(--fs-2); } 1605.review { font-size: var(--fs-2); }
1499 1606
1500/* ---- breakpoints ---- */ 1607/* ---- breakpoints ---- */
1608@media (max-width: 80rem) {
1609 .dashgrid { grid-template-columns: minmax(0, 1fr) 20rem; }
1610 /* not a column any more, so it neither sticks nor scrolls in itself */
1611 .dashgrid > .dashpins { grid-column: 1 / -1; position: static; max-height: none; overflow: visible; }
1612 /* a pinned block that is not a column is the chip row (spec rule 7) */
1613 .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
1614 .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
1615 .pins .n, .dashpins .meta { display: none; }
1616}
1617
1501@media (max-width: 62rem) { 1618@media (max-width: 62rem) {
1502 /* one column, the aside after the thread: a phone shows the 1619 /* one column, the aside after the thread: a phone shows the
1503 description first, checks and reviewers after it (#232) */ 1620 description first, checks and reviewers after it (#232) */
1504 .withaside { grid-template-columns: 1fr; } 1621 .withaside { grid-template-columns: 1fr; }
1622
1623 /* rows go back to two lines where one does not fit */
1624 ul.issuelist.rows .issuemain, ul.repolist.rows li, ul.loglist.rows .commitmain { display: block; }
1625 ul.issuelist.rows .title, ul.repolist.rows .desc { white-space: normal; }
1626 /* a nowrap meta or topic line on one of these rows forces horizontal
1627 page scroll on a phone: a long branch name, a long owner/repo#N */
1628 ul.issuelist.rows .meta, ul.loglist.rows .meta,
1629 ul.repolist.rows .meta, ul.repolist.rows .topics { white-space: normal; }
1630
1631 .dashgrid { grid-template-columns: 1fr; }
1632 .dashgrid > aside { position: static; }
1633 .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1634
1635 /* the tree page is the navigator on a phone */
1636 .blobgrid { grid-template-columns: 1fr; }
1637 .filenav { display: none; }
1638
1639 /* the column follows the content on a phone, the way the aside does */
1640 .withcol, .withcol.narrow { grid-template-columns: 1fr; }
1641 .sidecol { position: static; order: 2; }
1642 .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); }
1505} 1643}
1506 1644
1507@media (max-width: 52rem) { 1645@media (max-width: 52rem) {
1508 main.content { padding: var(--sp-4) var(--sp-4) var(--sp-5); } 1646 main.content { padding: var(--sp-4) var(--sp-4) var(--sp-5); }
1509 footer { padding: var(--sp-4); } 1647 footer { padding: var(--sp-4); }
1510 .repohead { padding: var(--sp-3) var(--sp-4) 0; } 1648 .repohead { padding: var(--sp-3) 0 0; }
1649 .repohead .wrap { padding: 0 var(--sp-4); }
1511 /* seven tabs do not fit a phone in one row; a hidden scrollbar made the 1650 /* seven tabs do not fit a phone in one row; a hidden scrollbar made the
1512 ones past the edge look absent. Two rows show all of them. */ 1651 ones past the edge look absent. Two rows show all of them. */
1513 nav.tabs { flex-wrap: wrap; overflow: visible; } 1652 nav.tabs { flex-wrap: wrap; overflow: visible; }
1514 .facts { grid-template-columns: 1fr; } 1653 .facts { grid-template-columns: 1fr; }
1654 /* a phone shows the description under the name, not beside it */
1655 .repodesc { flex-basis: 100%; margin-left: 0; }
1515} 1656}
1516 1657
1517@media (max-width: 40rem) { 1658@media (max-width: 40rem) {
internal/web/templates/account.html +34 −9
@@ -1,11 +1,26 @@
1{{define "width"}}bounded{{end}} 1{{define "width"}}wide{{end}}
2{{define "title"}}account settings{{end}} 2{{define "title"}}account settings{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>Account settings</h1> 4<h1>Account settings</h1>
5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 5{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
6{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} 6{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
7 7
8<h2>Profile</h2> 8<div class="withcol narrow">
9<nav class="sidecol" aria-label="Sections">
10 <div class="grp"><h2 class="colhead">Sections</h2>
11 <ul>
12 <li><a href="#profile">Profile</a></li>
13 <li><a href="#keys">SSH keys</a></li>
14 <li><a href="#emails">Email addresses</a></li>
15 <li><a href="#pgp">OpenPGP keys</a></li>
16 <li><a href="#notifications">Notifications</a></li>
17 <li><a href="#appearance">Appearance</a></li>
18 <li><a href="#export">Export</a></li>
19 <li><a href="#cli">On the command line</a></li>
20 </ul></div>
21</nav>
22<div class="colmain">
23<section id="profile"><h2>Profile</h2>
9<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p> 24<p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p>
10{{if .Draft.Is "about"}}{{template "previewblock" .Draft.HTML}}{{end}} 25{{if .Draft.Is "about"}}{{template "previewblock" .Draft.HTML}}{{end}}
11<form method="post" action="/settings" class="setform stack"> 26<form method="post" action="/settings" class="setform stack">
@@ -21,8 +36,9 @@
21 {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}} 36 {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}}
22 <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span> 37 <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span>
23</form> 38</form>
39</section>
24 40
25<h2>SSH keys</h2> 41<section id="keys"><h2>SSH keys</h2>
26<p class="meta">Your keys are your identity here. A <code>full</code> key can run 42<p class="meta">Your keys are your identity here. A <code>full</code> key can run
27commands and push; a <code>git</code> key can only move git data, which is what 43commands and push; a <code>git</code> key can only move git data, which is what
28a CI checkout wants.</p> 44a CI checkout wants.</p>
@@ -53,8 +69,9 @@ a CI checkout wants.</p>
53 <button type="submit" class="btn">Add key</button> 69 <button type="submit" class="btn">Add key</button>
54 </form> 70 </form>
55</details> 71</details>
72</section>
56 73
57<h2>Email addresses</h2> 74<section id="emails"><h2>Email addresses</h2>
58<p class="meta">A verified address is what ties your signed commits to this 75<p class="meta">A verified address is what ties your signed commits to this
59account, and where notifications go.</p> 76account, and where notifications go.</p>
60{{if .Emails}}<ul class="plain"> 77{{if .Emails}}<ul class="plain">
@@ -80,8 +97,9 @@ account, and where notifications go.</p>
80 <button type="submit" class="btn">Verify</button> 97 <button type="submit" class="btn">Verify</button>
81 </form> 98 </form>
82</details> 99</details>
100</section>
83 101
84<h2>OpenPGP keys</h2> 102<section id="pgp"><h2>OpenPGP keys</h2>
85<p class="meta">Only needed if you sign commits with GPG. SSH signing 103<p class="meta">Only needed if you sign commits with GPG. SSH signing
86(<code>gpg.format = ssh</code>) uses the keys above.</p> 104(<code>gpg.format = ssh</code>) uses the keys above.</p>
87{{if .PGP}}<div class="tablewrap"><table class="keys"> 105{{if .PGP}}<div class="tablewrap"><table class="keys">
@@ -102,8 +120,9 @@ account, and where notifications go.</p>
102 <button type="submit" class="btn">Add key</button> 120 <button type="submit" class="btn">Add key</button>
103 </form> 121 </form>
104</details> 122</details>
123</section>
105 124
106<h2>Notifications</h2> 125<section id="notifications"><h2>Notifications</h2>
107<form method="post" action="/settings" class="setform"> 126<form method="post" action="/settings" class="setform">
108 <input type="hidden" name="field" value="notify-mail"> 127 <input type="hidden" name="field" value="notify-mail">
109 <label for="notify-mail">Activity by mail</label> 128 <label for="notify-mail">Activity by mail</label>
@@ -118,8 +137,9 @@ account, and where notifications go.</p>
118 <button type="submit" class="btn">Save</button> 137 <button type="submit" class="btn">Save</button>
119</form> 138</form>
120<p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p> 139<p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p>
140</section>
121 141
122<h2>Appearance</h2> 142<section id="appearance"><h2>Appearance</h2>
123<form method="post" action="/settings" class="setform"> 143<form method="post" action="/settings" class="setform">
124 <input type="hidden" name="field" value="theme"> 144 <input type="hidden" name="field" value="theme">
125 <label for="theme">Colour scheme</label> 145 <label for="theme">Colour scheme</label>
@@ -131,14 +151,16 @@ account, and where notifications go.</p>
131 <button type="submit" class="btn">Save</button> 151 <button type="submit" class="btn">Save</button>
132</form> 152</form>
133<p class="meta">The same setting as <code>gitbay web theme set</code>. Following the browser takes its light or dark preference.</p> 153<p class="meta">The same setting as <code>gitbay web theme set</code>. Following the browser takes its light or dark preference.</p>
154</section>
134 155
135<h2>Export</h2> 156<section id="export"><h2>Export</h2>
136<p class="meta">Your profile, repositories, issues and merge requests as one 157<p class="meta">Your profile, repositories, issues and merge requests as one
137JSON bundle, the same one <code>gitbay account export</code> writes. Keys are 158JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
138never included; a replayed bundle's emails arrive unverified.</p> 159never included; a replayed bundle's emails arrive unverified.</p>
139<p><a href="/settings/export">Download bundle</a></p> 160<p><a href="/settings/export">Download bundle</a></p>
161</section>
140 162
141<h2>On the command line</h2> 163<section id="cli"><h2>On the command line</h2>
142<p class="meta">No page here yet, and nothing refusing one: a credential is 164<p class="meta">No page here yet, and nothing refusing one: a credential is
143easier to pipe than to paste, and a minted token is shown once.</p> 165easier to pipe than to paste, and a minted token is shown once.</p>
144<pre class="message">gitbay auth token create --name laptop # API tokens 166<pre class="message">gitbay auth token create --name laptop # API tokens
@@ -146,4 +168,7 @@ gitbay web sessions list # browser sessions
146gitbay admin ... # instance administration</pre> 168gitbay admin ... # instance administration</pre>
147<p class="meta">All of it works from stock OpenSSH too: 169<p class="meta">All of it works from stock OpenSSH too:
148<code>ssh git@{{.Host}} auth whoami</code>.</p> 170<code>ssh git@{{.Host}} auth whoami</code>.</p>
171</section>
172</div>
173</div>
149{{end}} 174{{end}}
internal/web/templates/admin.html +26 −1
@@ -1,46 +1,71 @@
1{{define "width"}}bounded{{end}} 1{{define "width"}}wide{{end}}
2{{define "title"}}admin · {{.Site}}{{end}} 2{{define "title"}}admin · {{.Site}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>Admin</h1> 4<h1>Admin</h1>
5<p class="meta">Server build {{.Commit}}. Every background worker's backlog, the same read as <code>gitbay dashboard --json</code> for an admin.</p> 5<p class="meta">Server build {{.Commit}}. Every background worker's backlog, the same read as <code>gitbay dashboard --json</code> for an admin.</p>
6<p class="meta"><a href="/admin/users">Accounts</a> — every account, with promote, demote, disable and enable.</p> 6<p class="meta"><a href="/admin/users">Accounts</a> — every account, with promote, demote, disable and enable.</p>
7 7
8<div class="withcol narrow">
9<nav class="sidecol" aria-label="Sections">
10 <div class="grp"><h2 class="colhead">Sections</h2>
11 <ul>
12 <li><a href="#webhooks">Webhook deliveries</a></li>
13 <li><a href="#mail">Mail</a></li>
14 <li><a href="#mirrors">Mirrors</a></li>
15 <li><a href="#builds">Builds</a></li>
16 <li><a href="#deps">Dependency checks</a></li>
17 </ul></div>
18</nav>
19<div class="colmain">
20
8{{with .Queues.Webhooks}} 21{{with .Queues.Webhooks}}
22<section id="webhooks">
9<h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2> 23<h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2>
10<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} dead-lettered{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> 24<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} dead-lettered{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p>
11{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>URL</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody> 25{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>URL</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody>
12{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.URL}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}dead-lettered {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{if .Status}}{{.Status}} {{end}}{{.LastError}}</td></tr> 26{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.URL}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}dead-lettered {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{if .Status}}{{.Status}} {{end}}{{.LastError}}</td></tr>
13{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or dead-lettered</p>{{end}} 27{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or dead-lettered</p>{{end}}
28</section>
14{{end}} 29{{end}}
15 30
16{{with .Queues.Mail}} 31{{with .Queues.Mail}}
32<section id="mail">
17<h2>Mail <span class="count">{{.Pending}}</span></h2> 33<h2>Mail <span class="count">{{.Pending}}</span></h2>
18<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> 34<p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p>
19{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Mail</th><th>Recipient</th><th>Subject</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody> 35{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Mail</th><th>Recipient</th><th>Subject</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody>
20{{range .Items}}<tr><td class="mono">{{.ID}}</td><td>{{.Recipient}}</td><td>{{.Subject}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}failed {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{.LastError}}</td></tr> 36{{range .Items}}<tr><td class="mono">{{.ID}}</td><td>{{.Recipient}}</td><td>{{.Subject}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}failed {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{.LastError}}</td></tr>
21{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}} 37{{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}}
38</section>
22{{end}} 39{{end}}
23 40
24{{with .Queues.Mirrors}} 41{{with .Queues.Mirrors}}
42<section id="mirrors">
25<h2>Mirrors <span class="count">{{.Errors}}</span></h2> 43<h2>Mirrors <span class="count">{{.Errors}}</span></h2>
26<p class="meta">{{.Dirty}} waiting for a sync · {{.Errors}} with a failed last sync</p> 44<p class="meta">{{.Dirty}} waiting for a sync · {{.Errors}} with a failed last sync</p>
27{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Direction</th><th>URL</th><th>Last sync</th><th>Error</th></tr></thead><tbody> 45{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Direction</th><th>URL</th><th>Last sync</th><th>Error</th></tr></thead><tbody>
28{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.Direction}}</td><td>{{.URL}}</td><td>{{if .LastSync}}{{when .LastSync}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr> 46{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.Direction}}</td><td>{{.URL}}</td><td>{{if .LastSync}}{{when .LastSync}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr>
29{{end}}</tbody></table></div>{{else}}<p class="none">Every mirror's last sync succeeded</p>{{end}} 47{{end}}</tbody></table></div>{{else}}<p class="none">Every mirror's last sync succeeded</p>{{end}}
48</section>
30{{end}} 49{{end}}
31 50
32{{with .Queues.Builds}} 51{{with .Queues.Builds}}
52<section id="builds">
33<h2>Builds <span class="count">{{.Pending}}</span></h2> 53<h2>Builds <span class="count">{{.Pending}}</span></h2>
34<p class="meta">{{.Pending}} pending · {{.Running}} running{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> 54<p class="meta">{{.Pending}} pending · {{.Running}} running{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p>
35{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Build</th><th>Job</th><th>Status</th><th>Since</th></tr></thead><tbody> 55{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Build</th><th>Job</th><th>Status</th><th>Since</th></tr></thead><tbody>
36{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td><a href="/{{.Repo}}/builds/{{.Number}}">#{{.Number}}</a></td><td>{{.Job}}</td><td>{{.Status}}</td><td>{{if .StartedAt}}{{when .StartedAt}}{{else}}{{when .CreatedAt}}{{end}}</td></tr> 56{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td><a href="/{{.Repo}}/builds/{{.Number}}">#{{.Number}}</a></td><td>{{.Job}}</td><td>{{.Status}}</td><td>{{if .StartedAt}}{{when .StartedAt}}{{else}}{{when .CreatedAt}}{{end}}</td></tr>
37{{end}}</tbody></table></div>{{else}}<p class="none">No build running or pending</p>{{end}} 57{{end}}</tbody></table></div>{{else}}<p class="none">No build running or pending</p>{{end}}
58</section>
38{{end}} 59{{end}}
39 60
40{{with .Queues.Deps}} 61{{with .Queues.Deps}}
62<section id="deps">
41<h2>Dependency checks <span class="count">{{.Errors}}</span></h2> 63<h2>Dependency checks <span class="count">{{.Errors}}</span></h2>
42{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Last check</th><th>Error</th></tr></thead><tbody> 64{{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Last check</th><th>Error</th></tr></thead><tbody>
43{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{if .LastCheck}}{{when .LastCheck}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr> 65{{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{if .LastCheck}}{{when .LastCheck}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr>
44{{end}}</tbody></table></div>{{else}}<p class="none">No check has failed</p>{{end}} 66{{end}}</tbody></table></div>{{else}}<p class="none">No check has failed</p>{{end}}
67</section>
45{{end}} 68{{end}}
69</div>
70</div>
46{{end}} 71{{end}}
internal/web/templates/blame.html +5
@@ -2,6 +2,9 @@
2{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1 class="vh">Blame: {{.Path}}</h1> 4<h1 class="vh">Blame: {{.Path}}</h1>
5<div class="blobgrid">
6{{template "filenav" .Nav}}
7<div class="blobmain">
5<div class="pathbar"> 8<div class="pathbar">
6 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span> 9 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span>
7 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 10 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
@@ -23,4 +26,6 @@
23</div> 26</div>
24{{if gt .Pages 1}}<p class="pager">page {{.Page}} of {{.Pages}}{{if gt .Page 1}} · <a href="?page={{sub .Page 1}}">← earlier lines</a>{{end}}{{if lt .Page .Pages}} · <a href="?page={{add .Page 1}}">later lines →</a>{{end}}</p>{{end}} 27{{if gt .Pages 1}}<p class="pager">page {{.Page}} of {{.Pages}}{{if gt .Page 1}} · <a href="?page={{sub .Page 1}}">← earlier lines</a>{{end}}{{if lt .Page .Pages}} · <a href="?page={{add .Page 1}}">later lines →</a>{{end}}</p>{{end}}
25{{end}} 28{{end}}
29</div>
30</div>
26{{end}} 31{{end}}
internal/web/templates/blob.html +5
@@ -2,6 +2,9 @@
2{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1 class="vh">{{.Path}}</h1> 4<h1 class="vh">{{.Path}}</h1>
5<div class="blobgrid">
6{{template "filenav" .Nav}}
7<div class="blobmain">
5<div class="pathbar"> 8<div class="pathbar">
6 {{template "refmenu" .}} 9 {{template "refmenu" .}}
7 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 10 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
@@ -13,4 +16,6 @@
13{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> 16{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
14{{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section> 17{{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section>
15{{else}}<div class="code">{{.CodeHTML}}</div>{{end}} 18{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
19</div>
20</div>
16{{end}} 21{{end}}
internal/web/templates/builds.html +14 −7
@@ -1,19 +1,24 @@
1{{define "width"}}wide{{end}} 1{{define "width"}}wide{{end}}
2{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<div class="listhead"> 4<div class="withcol">
5 <h1>Builds</h1> 5<nav class="sidecol" aria-label="Filters">
6 <nav class="filters"> 6 {{range .Facets}}{{if .Items}}<div class="grp">
7 {{range .FilterLinks}}<a {{if .Active}}class="active" aria-current="page" {{end}}href="{{.Href}}">{{.Label}}</a>{{end}} 7 <h2 class="colhead">{{.Title}}</h2>
8 </nav> 8 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>
9 </div>{{end}}{{end}}
9 <form method="get" class="searchform compact"> 10 <form method="get" class="searchform compact">
10 <label for="ref">Branch</label> 11 <label for="ref" class="colhead">Branch</label>
11 <input type="text" id="ref" name="ref" value="{{.Filter.Ref}}" list="buildrefs"> 12 <input type="text" id="ref" name="ref" value="{{.Filter.Ref}}" list="buildrefs">
12 <datalist id="buildrefs">{{range .Refs}}<option value="{{.}}">{{end}}</datalist> 13 <datalist id="buildrefs">{{range .Refs}}<option value="{{.}}">{{end}}</datalist>
13 <input type="hidden" name="status" value="{{.Filter.Status}}"> 14 <input type="hidden" name="status" value="{{.Filter.Status}}">
14 <input type="hidden" name="job" value="{{.Filter.Job}}"> 15 <input type="hidden" name="job" value="{{.Filter.Job}}">
15 <button type="submit" class="btn">Filter</button> 16 <button type="submit" class="btn">Filter</button>
16 </form> 17 </form>
18</nav>
19<div class="colmain">
20<div class="listhead">
21 <h1>Builds</h1>
17</div> 22</div>
18{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 23{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
19{{if and .CanWrite .Jobs}} 24{{if and .CanWrite .Jobs}}
@@ -31,7 +36,7 @@
31<p class="meta">Add <code>?job=name</code> for one job.</p> 36<p class="meta">Add <code>?job=name</code> for one job.</p>
32</details> 37</details>
33<p class="meta">{{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p> 38<p class="meta">{{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p>
34<ul class="loglist"> 39<ul class="loglist rows">
35{{range .Runs}}<li> 40{{range .Runs}}<li>
36 <div class="commitmain"> 41 <div class="commitmain">
37 <p class="subject"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code></p> 42 <p class="subject"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code></p>
@@ -44,4 +49,6 @@
44</li> 49</li>
45{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}} 50{{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}}
46</ul> 51</ul>
52</div>
53</div>
47{{end}} 54{{end}}
internal/web/templates/dashboard.html +33 −16
@@ -1,37 +1,54 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}dashboard · {{.Site}}{{end}} 2{{define "title"}}dashboard · {{.Site}}{{end}}
2{{define "itemlist"}} 3{{define "itemlist"}}
3<ul class="issuelist"> 4<ul class="issuelist rows">
4{{range .Items}}<li> 5{{range .Items}}<li>
5 <div class="issuemain"> 6 <div class="issuemain">
6 <p class="title"><a href="/{{.RepoPath}}/{{$.Kind}}/{{.Number}}">{{.Title}}</a></p> 7 <p class="title"><a href="/{{.RepoPath}}/{{$.Kind}}/{{.Number}}">{{.Title}}</a></p>
7 <p class="meta">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}} · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> 8 <p class="meta"><span class="repo">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}}</span> · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p>
8 </div> 9 </div>
9</li> 10</li>
10{{else}}<li class="empty">{{$.Empty}}</li>{{end}} 11{{else}}<li class="empty">{{$.Empty}}</li>{{end}}
11</ul> 12</ul>
12{{end}} 13{{end}}
13{{define "queue"}} 14{{define "queue"}}
14<h2>{{$.Title}} <span class="count">{{len $.Items}}</span></h2> 15<h2 id="{{$.ID}}">{{$.Title}} <span class="count">{{len $.Items}}</span></h2>
15{{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}} 16{{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}}
16{{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}} 17{{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}}
17{{end}} 18{{end}}
19{{define "tile"}}{{if $.N}}<a class="tile wants" href="#{{$.ID}}"><b>{{$.N}}</b><span>{{$.Label}}</span></a>{{else}}<div class="tile"><b>0</b><span>{{$.Label}}</span></div>{{end}}{{end}}
18{{define "content"}} 20{{define "content"}}
19<h1>Dashboard</h1> 21<div class="dashgrid">
20{{with .Rail.Pinned}}<p class="pinned" aria-label="Pinned repositories">{{range .}}<a class="chip" href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a> {{end}}</p>{{end}}
21 22
22{{if .Reviews}}{{template "queue" dict "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}} 23<aside class="dashpins" aria-label="Pinned repositories">
23{{if .Assigned}}{{template "queue" dict "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}} 24 <h2 class="colhead">Pinned</h2>
24{{if .MRs}}{{template "queue" dict "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} 25 {{if .Pins}}<ul class="pins">
25{{if .Issues}}{{template "queue" dict "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} 26 {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}"><b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b> <span class="dot{{if eq .Build "success"}} ok{{else if or (eq .Build "failure") (eq .Build "error")}} bad{{else if or (eq .Build "pending") (eq .Build "running")}} pend{{end}}"><span class="vh">{{with .Build}}last build {{.}}{{else}}no builds{{end}}</span></span></span></li>
27 {{end}}</ul>
28 <p class="meta">issues · merge requests · last build</p>
29 {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
30</aside>
26 31
27{{if not .Reviews}}<h2 class="empty">Waiting on your review <span class="count">0</span></h2>{{end}} 32<section class="dashmain">
28{{if not .Assigned}}<h2 class="empty">Assigned to you <span class="count">0</span></h2>{{end}} 33<h1>Dashboard</h1>
29{{if not .MRs}}<h2 class="empty">Open merge requests <span class="count">0</span></h2>{{end}} 34<div class="tiles">
30{{if not .Issues}}<h2 class="empty">Open issues <span class="count">0</span></h2>{{end}} 35 {{template "tile" dict "N" (len .Reviews) "ID" "reviews" "Label" "waiting on your review"}}
36 {{template "tile" dict "N" (len .Assigned) "ID" "assigned" "Label" "assigned to you"}}
37 {{template "tile" dict "N" (len .MRs) "ID" "mrs" "Label" "open merge requests"}}
38 {{template "tile" dict "N" (len .Issues) "ID" "issues" "Label" "open issues"}}
39</div>
40{{if .Reviews}}{{template "queue" dict "ID" "reviews" "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}}
41{{if .Assigned}}{{template "queue" dict "ID" "assigned" "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}}
42{{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
43{{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
44{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open anywhere you can write to.</p>{{end}}
45</section>
31 46
32<section class="feed"> 47<aside class="feedcol" aria-label="Recent activity">
33 <h2>Recent activity</h2> 48 <h2 class="colhead">Recent activity</h2>
34 {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p> 49 {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
35 {{else}}<p class="none">No activity yet</p>{{end}} 50 {{else}}<p class="none">No activity yet</p>{{end}}
36</section> 51</aside>
52
53</div>
37{{end}} 54{{end}}
internal/web/templates/edit.html +5
@@ -2,6 +2,9 @@
2{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> 4<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
5<div class="blobgrid">
6{{template "filenav" .Nav}}
7<div class="blobmain">
5{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} 8{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
6{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}} 9{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}}
7{{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}} 10{{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}}
@@ -13,4 +16,6 @@
13<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> 16<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
14</form> 17</form>
15{{end}} 18{{end}}
19</div>
20</div>
16{{end}} 21{{end}}
internal/web/templates/explore.html +7 −1
@@ -1,5 +1,9 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}explore · {{.Site}}{{end}} 2{{define "title"}}explore · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
4<div class="withcol">
5{{template "sidecol" .Facets}}
6<div class="colmain">
3<div class="headrow"> 7<div class="headrow">
4<h1>Explore</h1> 8<h1>Explore</h1>
5<form method="get" action="/explore" class="searchform compact"> 9<form method="get" action="/explore" class="searchform compact">
@@ -8,8 +12,10 @@
8</form> 12</form>
9<span class="spacer"></span> 13<span class="spacer"></span>
10</div> 14</div>
11<ul class="repolist"> 15<ul class="repolist rows">
12{{range .Repos}}{{template "reporow" .}} 16{{range .Repos}}{{template "reporow" .}}
13{{else}}<li class="empty">no public repositories yet</li>{{end}} 17{{else}}<li class="empty">no public repositories yet</li>{{end}}
14</ul> 18</ul>
19</div>
20</div>
15{{end}} 21{{end}}
internal/web/templates/globalsearch.html +14 −7
@@ -1,14 +1,19 @@
1{{define "width"}}wide{{end}} 1{{define "width"}}wide{{end}}
2{{define "title"}}search · {{.Site}}{{end}} 2{{define "title"}}search · {{.Site}}{{end}}
3{{define "content"}} 3{{define "content"}}
4<div class="withcol">
5<nav class="sidecol" aria-label="Filters">
6 <div class="grp"><h2 class="colhead">Kind</h2>
7 <ul>
8 <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li>
9 <li><a{{if eq .Kind "repo"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=repo">repositories</a></li>
10 <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=issue">issues</a></li>
11 <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=mr">merge requests</a></li>
12 </ul></div>
13</nav>
14<div class="colmain">
4<div class="listhead"> 15<div class="listhead">
5 <h1>Search</h1> 16 <h1>Search</h1>
6 <nav class="filters">
7 <a {{if eq .Kind ""}}class="active" aria-current="page" {{end}}href="?q={{.Query}}">everything</a>
8 <a {{if eq .Kind "repo"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=repo">repositories</a>
9 <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=issue">issues</a>
10 <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&amp;kind=mr">merge requests</a>
11 </nav>
12 {{if and .Query (not .QueryErr)}}<p class="meta">{{len .Results}} {{if eq (len .Results) 1}}result{{else}}results{{end}} for <q>{{.Query}}</q>{{if .Kind}} in {{.Kind}}{{end}}</p>{{end}} 17 {{if and .Query (not .QueryErr)}}<p class="meta">{{len .Results}} {{if eq (len .Results) 1}}result{{else}}results{{end}} for <q>{{.Query}}</q>{{if .Kind}} in {{.Kind}}{{end}}</p>{{end}}
13</div> 18</div>
14<form method="get" action="/search" class="searchform"> 19<form method="get" action="/search" class="searchform">
@@ -19,7 +24,7 @@
19{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p> 24{{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p>
20{{else if .Query}} 25{{else if .Query}}
21{{if .Results}} 26{{if .Results}}
22<ul class="issuelist"> 27<ul class="issuelist rows">
23{{range .Results}}<li> 28{{range .Results}}<li>
24 <div class="issuemain"> 29 <div class="issuemain">
25 <p class="title"><a href="{{.Href}}">{{if .Number}}{{.Title}}{{else}}{{.Repo}}{{end}}</a></p> 30 <p class="title"><a href="{{.Href}}">{{if .Number}}{{.Title}}{{else}}{{.Repo}}{{end}}</a></p>
@@ -33,4 +38,6 @@
33{{else}} 38{{else}}
34<p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p> 39<p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
35{{end}} 40{{end}}
41</div>
42</div>
36{{end}} 43{{end}}
internal/web/templates/issues.html +8 −7
@@ -1,22 +1,21 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
4<div class="withcol">
5{{template "sidecol" .Facets}}
6<div class="colmain">
3<div class="listhead"> 7<div class="listhead">
4 <h1>Issues</h1> 8 <h1>Issues</h1>
5 <nav class="filters">
6 <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a>
7 <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a>
8 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
9 </nav>
10 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
11 <form method="get" class="searchform compact"> 9 <form method="get" class="searchform compact">
12 <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body"> 10 <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body">
13 <button type="submit" class="btn">Search</button> 11 <button type="submit" class="btn">Search</button>
14 <input type="hidden" name="state" value="{{.State}}"> 12 <input type="hidden" name="state" value="{{.State}}">
15 </form> 13 </form>
14 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
16 <span class="spacer"></span> 15 <span class="spacer"></span>
17 <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels">labels</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p> 16 <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels">labels</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p>
18</div> 17</div>
19<ul class="issuelist"> 18<ul class="issuelist rows">
20{{range .Issues}}<li> 19{{range .Issues}}<li>
21 <div class="issuemain"> 20 <div class="issuemain">
22 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> 21 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a>
@@ -29,4 +28,6 @@
29{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}}{{end}} 28{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}}{{end}}
30</ul> 29</ul>
31{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} 30{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
31</div>
32</div>
32{{end}} 33{{end}}
internal/web/templates/layout.html +25 −12
@@ -37,26 +37,23 @@
37<div class="pane"> 37<div class="pane">
38{{with field . "Repo"}} 38{{with field . "Repo"}}
39<header class="repohead"> 39<header class="repohead">
40<div class="wrap">
41 {{$top := topTab (str $ "Tab")}}
40 <div class="identity"> 42 <div class="identity">
41 {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1> 43 {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
42 {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}} 44 {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
43 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}} 45 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
44 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} 46 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
47 {{/* Description, topics and website belong to the code tab, inline
48 with the name so the header is two rows on every page. */}}
49 {{if eq $top "code"}}{{if or (field $ "Desc") (field $ "Topics") $.Repo.Settings.Website}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}{{with $.Repo.Settings.Website}}<a class="site" href="{{.}}" rel="nofollow">{{.}}</a>{{end}}</p>{{end}}{{end}}
45 <span class="grow"></span> 50 <span class="grow"></span>
46 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> 51 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
47 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> 52 <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
48 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form> 53 <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
49 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} 54 <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
50 </div> 55 </div>
51 {{$top := topTab (str $ "Tab")}} 56 {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}}
52 {{/* Description, topics and metadata belong to the code tab. On task
53 tabs only the identity row and the tab bar render, and the header
54 is identical on every page within a tab. */}}
55 {{if eq $top "code"}}
56 {{if or (field $ "Desc") (field $ "Topics")}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>{{end}}
57 {{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}}
58 {{if $.Viewer}}<p class="toggles">Pinned shows on your dashboard. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}}
59 {{end}}
60 <nav class="tabs" aria-label="Repository"> 57 <nav class="tabs" aria-label="Repository">
61 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a> 58 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
62 <a {{if eq $top "issues"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">Issues{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a> 59 <a {{if eq $top "issues"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">Issues{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a>
@@ -66,6 +63,7 @@
66 {{if field $ "HasWiki"}}<a {{if eq $top "wiki"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">Wiki</a>{{end}} 63 {{if field $ "HasWiki"}}<a {{if eq $top "wiki"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">Wiki</a>{{end}}
67 {{if field $ "CanAdmin"}}<a {{if eq $top "settings"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/settings">Settings</a>{{end}} 64 {{if field $ "CanAdmin"}}<a {{if eq $top "settings"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/settings">Settings</a>{{end}}
68 </nav> 65 </nav>
66</div>
69</header> 67</header>
70{{end}} 68{{end}}
71 69
@@ -116,6 +114,21 @@
116 </div> 114 </div>
117</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}} 115</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}}
118 116
117{{define "filenav"}}<nav class="filenav" aria-label="Files">
118 <h2 class="colhead">{{.Title}}</h2>
119 <ul>
120 {{with .Parent}}<li><a class="up" href="{{.}}">..</a></li>{{end}}
121 {{range .Entries}}<li><a{{if .Dir}} class="dir"{{end}}{{if .Current}} aria-current="page"{{end}} href="{{.URL}}">{{.Name}}</a></li>
122 {{end}}</ul>
123</nav>{{end}}
124
125{{define "sidecol"}}<nav class="sidecol" aria-label="Filters">
126 {{range .}}{{if .Items}}<div class="grp">
127 <h2 class="colhead">{{.Title}}</h2>
128 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul>
129 </div>{{end}}{{end}}
130</nav>{{end}}
131
119{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}} 132{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}}
120 133
121{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} 134{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
internal/web/templates/mrs.html +7 −7
@@ -1,13 +1,11 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 3{{define "content"}}
4<div class="withcol">
5{{template "sidecol" .Facets}}
6<div class="colmain">
3<div class="listhead"> 7<div class="listhead">
4 <h1>Merge requests</h1> 8 <h1>Merge requests</h1>
5 <nav class="filters">
6 <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a>
7 <a {{if eq .State "merged"}}class="active" aria-current="page" {{end}}href="?state=merged">merged</a>
8 <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a>
9 <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a>
10 </nav>
11 <form method="get" class="searchform compact"> 9 <form method="get" class="searchform compact">
12 <input type="search" name="q" aria-label="Search merge requests" value="{{.Query}}" placeholder="search title and body"> 10 <input type="search" name="q" aria-label="Search merge requests" value="{{.Query}}" placeholder="search title and body">
13 <button type="submit" class="btn">Search</button> 11 <button type="submit" class="btn">Search</button>
@@ -16,7 +14,7 @@
16 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} 14 {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
17</div> 15</div>
18{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} 16{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
19<ul class="issuelist"> 17<ul class="issuelist rows">
20{{range .MRs}}{{$n := .Number}}<li> 18{{range .MRs}}{{$n := .Number}}<li>
21 <div class="issuemain"> 19 <div class="issuemain">
22 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a> 20 <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a>
@@ -31,4 +29,6 @@
31{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}} 29{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}}
32</ul> 30</ul>
33{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} 31{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
32</div>
33</div>
34{{end}} 34{{end}}
internal/web/templates/notifications.html +2 −1
@@ -1,3 +1,4 @@
1{{define "width"}}wide{{end}}
1{{define "title"}}notifications · {{.Site}}{{end}} 2{{define "title"}}notifications · {{.Site}}{{end}}
2{{define "content"}} 3{{define "content"}}
3<div class="listhead"> 4<div class="listhead">
@@ -9,7 +10,7 @@
9 <span class="spacer"></span> 10 <span class="spacer"></span>
10 {{if .Notices}}<form method="post" action="/notifications" class="inline"><button type="submit" class="btn">Mark all read</button></form>{{end}} 11 {{if .Notices}}<form method="post" action="/notifications" class="inline"><button type="submit" class="btn">Mark all read</button></form>{{end}}
11</div> 12</div>
12<ul class="issuelist"> 13<ul class="issuelist rows">
13{{range .Notices}}<li> 14{{range .Notices}}<li>
14 <div class="issuemain"> 15 <div class="issuemain">
15 <p class="title"><a href="{{.Href}}">{{.Actor}} {{.Summary}}</a></p> 16 <p class="title"><a href="{{.Href}}">{{.Actor}} {{.Summary}}</a></p>
internal/web/templates/settings.html +20 −4
@@ -1,10 +1,24 @@
1{{define "width"}}bounded{{end}} 1{{define "width"}}wide{{end}}
2{{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 2{{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
3{{define "content"}} 3{{define "content"}}
4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} 4{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
5<h1>Settings</h1> 5<h1>Settings</h1>
6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{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><a href="#deps">Dependencies</a><a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav> 7<div class="withcol narrow">
8<nav class="sidecol" aria-label="Sections">
9 <div class="grp"><h2 class="colhead">Sections</h2>
10 <ul>
11 <li><a href="#identity">Identity</a></li>
12 <li><a href="#access">Access</a></li>
13 <li><a href="#gates">Merge gates</a></li>
14 <li><a href="#branches">Protected branches</a></li>
15 <li><a href="#tags">Protected tags</a></li>
16 <li><a href="#deps">Dependencies</a></li>
17 <li><a href="#runners">Runners</a></li>
18 <li><a href="#lifecycle">Lifecycle</a></li>
19 </ul></div>
20</nav>
21<div class="colmain">
8 22
9<section id="identity"><h2>Identity</h2> 23<section id="identity"><h2>Identity</h2>
10<form method="post" action="{{$base}}" class="setform"> 24<form method="post" action="{{$base}}" class="setform">
@@ -134,8 +148,8 @@
134<section id="deps"><h2>Dependencies</h2> 148<section id="deps"><h2>Dependencies</h2>
135<form method="post" action="{{$base}}" class="setform"> 149<form method="post" action="{{$base}}" class="setform">
136 <input type="hidden" name="field" value="deps"> 150 <input type="hidden" name="field" value="deps">
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> 151 <div><label for="deps-enabled">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>
138 <div class="check"><input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div> 152 <div class="check"><input type="checkbox" id="deps-enabled" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div>
139 <div><button type="submit" class="btn">Save</button></div> 153 <div><button type="submit" class="btn">Save</button></div>
140</form> 154</form>
141{{if .DepsEnabled}} 155{{if .DepsEnabled}}
@@ -184,4 +198,6 @@
184<p class="meta">Deleting or transferring a repository is a CLI operation: 198<p class="meta">Deleting or transferring a repository is a CLI operation:
185<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> 199<code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p>
186</section> 200</section>
201</div>
202</div>
187{{end}} 203{{end}}
internal/web/web_test.go +2 −2
@@ -89,8 +89,8 @@ func TestWhenNamesTheZone(t *testing.T) {
89// none. The merge request page picks wide for its diff view, so it gets 89// none. The merge request page picks wide for its diff view, so it gets
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true} 92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true}
93 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} 93 bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {
96 src, err := TemplateSource(name) 96 src, err := TemplateSource(name)
internal/web/widths_test.go added +51
@@ -0,0 +1,51 @@
1package web
2
3import (
4 "regexp"
5 "strings"
6 "testing"
7)
8
9// List pages and the dashboard render at the container width; text pages
10// keep the reading cap (desktop layout spec).
11func TestListPagesAreWide(t *testing.T) {
12 wide := []string{"dashboard.html", "issues.html", "mrs.html", "explore.html", "notifications.html", "globalsearch.html", "builds.html"}
13 for _, name := range wide {
14 src, err := templateFS.ReadFile("templates/" + name)
15 if err != nil {
16 t.Fatal(err)
17 }
18 if !strings.HasPrefix(string(src), `{{define "width"}}wide{{end}}`) {
19 t.Errorf("%s does not declare width wide", name)
20 }
21 }
22 for _, name := range []string{"issue.html", "wiki.html", "owner.html"} {
23 src, _ := templateFS.ReadFile("templates/" + name)
24 if strings.Contains(string(src), `{{define "width"}}wide{{end}}`) {
25 t.Errorf("%s is a text page and must not be wide", name)
26 }
27 }
28 for _, name := range []string{"issues.html", "mrs.html", "notifications.html", "globalsearch.html", "dashboard.html"} {
29 src, _ := templateFS.ReadFile("templates/" + name)
30 if !strings.Contains(string(src), `<ul class="issuelist rows">`) {
31 t.Errorf("%s does not use one-line rows", name)
32 }
33 }
34 if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) {
35 t.Error("explore.html does not use one-line rows")
36 }
37 // Settings pages carry a section column: every section id has a link
38 // in the column, and the page is wide with the narrow grid.
39 for _, name := range []string{"settings.html", "account.html", "admin.html"} {
40 src, _ := templateFS.ReadFile("templates/" + name)
41 s := string(src)
42 if !strings.HasPrefix(s, `{{define "width"}}wide{{end}}`) || !strings.Contains(s, `<div class="withcol narrow">`) {
43 t.Errorf("%s lacks the narrow column layout", name)
44 }
45 for _, m := range regexp.MustCompile(`<section id="([a-z]+)"`).FindAllStringSubmatch(s, -1) {
46 if !strings.Contains(s, `href="#`+m[1]+`"`) {
47 t.Errorf("%s: section %q has no link in the column", name, m[1])
48 }
49 }
50 }
51}