Commit 1cfdd9630b

1cfdd9630bcffc6f9e7c810fe4663cb4946576fc

parent: a251ca2f07

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 15:35 UTC

docs: desktop layout plan

Ref #226
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.