Commit 1cfdd9630b
Verified · cmc
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 | |
| 38 | package web | |
| 39 | ||
| 40 | import ( | |
| 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). | |
| 48 | func 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 | ||
| 74 | Run: `go test ./internal/web/ -run TestSharedCenteredContainer` | |
| 75 | Expected: FAIL, "repohead is not wrapped in .wrap" | |
| 76 | ||
| 77 | - [ ] **Step 3: Add the token and the container rules** | |
| 78 | ||
| 79 | In `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 | ||
| 86 | Replace lines 371-377 (the `main.content` rule and the three width rules) with: | |
| 87 | ||
| 88 | ```css | |
| 89 | main.content { | |
| 90 | flex: 1; | |
| 91 | width: 100%; | |
| 92 | padding: var(--sp-5) var(--sp-6) var(--sp-7); | |
| 93 | } | |
| 94 | main.content, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; } | |
| 95 | main.wide { max-width: calc(var(--container) + 2 * var(--sp-6)); } | |
| 96 | main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; } | |
| 97 | main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; } | |
| 98 | ``` | |
| 99 | ||
| 100 | Replace 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 | ||
| 110 | In 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 | ||
| 119 | In `layout.html`, line 39 `<header class="repohead">` becomes: | |
| 120 | ||
| 121 | ```html | |
| 122 | <header class="repohead"> | |
| 123 | <div class="wrap"> | |
| 124 | ``` | |
| 125 | ||
| 126 | and line 69 `</header>` becomes: | |
| 127 | ||
| 128 | ```html | |
| 129 | </div> | |
| 130 | </header> | |
| 131 | ``` | |
| 132 | ||
| 133 | - [ ] **Step 5: Run the tests** | |
| 134 | ||
| 135 | Run: `go test ./internal/web/ ./internal/httpd/` | |
| 136 | Expected: PASS (the classes test sees `.wrap` in the stylesheet) | |
| 137 | ||
| 138 | - [ ] **Step 6: Commit** | |
| 139 | ||
| 140 | ```bash | |
| 141 | git add internal/web/static/style.css internal/web/templates/layout.html internal/web/layout_test.go | |
| 142 | git commit -m "web: one centered container for header, main and footer | |
| 143 | ||
| 144 | Ref #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 | |
| 162 | package httpd | |
| 163 | ||
| 164 | import ( | |
| 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). | |
| 175 | func 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 | ||
| 219 | Run: `go test ./internal/httpd/ -run TestRepoHeaderTwoRows` | |
| 220 | Expected: FAIL on the `title=` strings | |
| 221 | ||
| 222 | - [ ] **Step 3: Rewrite the identity row** | |
| 223 | ||
| 224 | Replace `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 | ||
| 245 | Delete 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 | ||
| 249 | In `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 | ||
| 263 | Add 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 | ||
| 272 | Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestTreeSearchCodeAndClone' -count=1` | |
| 273 | Expected: PASS | |
| 274 | ||
| 275 | - [ ] **Step 6: Commit** | |
| 276 | ||
| 277 | ```bash | |
| 278 | git add internal/web/templates/layout.html internal/web/static/style.css internal/httpd/repohead_test.go | |
| 279 | git commit -m "web: the repository header is two rows | |
| 280 | ||
| 281 | Description, topics and website sit beside the name; the toggles hint is | |
| 282 | title text on the buttons. | |
| 283 | ||
| 284 | Ref #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 | |
| 302 | package web | |
| 303 | ||
| 304 | import ( | |
| 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). | |
| 311 | func 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 | ||
| 342 | Run: `go test ./internal/web/ -run TestListPagesAreWide` | |
| 343 | Expected: FAIL for every listed template | |
| 344 | ||
| 345 | - [ ] **Step 3: Add the row rules** | |
| 346 | ||
| 347 | After 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). */ | |
| 352 | ul.issuelist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); } | |
| 353 | ul.issuelist.rows .issuemain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; } | |
| 354 | ul.issuelist.rows .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 355 | ul.issuelist.rows .title .chip { margin-left: var(--sp-1); } | |
| 356 | ul.issuelist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; } | |
| 357 | ul.issuelist.rows .meta .repo { color: var(--fg); } | |
| 358 | ``` | |
| 359 | ||
| 360 | After the `ul.repolist .topics, ul.repolist .meta` rule (line 877) add: | |
| 361 | ||
| 362 | ```css | |
| 363 | ul.repolist.rows li { display: flex; align-items: baseline; gap: var(--sp-3); padding: var(--sp-2) var(--sp-4); } | |
| 364 | ul.repolist.rows .reponame { flex: none; font-size: var(--fs-2); } | |
| 365 | ul.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; } | |
| 366 | ul.repolist.rows .topics, ul.repolist.rows .meta { flex: none; margin: 0; white-space: nowrap; } | |
| 367 | ul.repolist.rows .meta { font-size: var(--fs-1); } | |
| 368 | ``` | |
| 369 | ||
| 370 | After the `ul.loglist .commitside` rule (line 865) add, for the builds page (Task 7 applies it): | |
| 371 | ||
| 372 | ```css | |
| 373 | ul.loglist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); } | |
| 374 | ul.loglist.rows .commitmain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; } | |
| 375 | ul.loglist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; } | |
| 376 | ``` | |
| 377 | ||
| 378 | In 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 | ||
| 395 | and `<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 | ||
| 413 | Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestMRListRows|TestIssueWebTriage|TestDashboard$' -count=1` | |
| 414 | Expected: PASS | |
| 415 | ||
| 416 | - [ ] **Step 6: Commit** | |
| 417 | ||
| 418 | ```bash | |
| 419 | git add internal/web | |
| 420 | git commit -m "web: one-line rows on the list pages, at the container width | |
| 421 | ||
| 422 | Ref #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 | |
| 445 | package httpd | |
| 446 | ||
| 447 | import ( | |
| 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). | |
| 458 | func 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 | ||
| 501 | Run: `go test ./internal/httpd/ -run TestDashboardTilesAndPins` | |
| 502 | Expected: FAIL, "undefined: pinnedRow" | |
| 503 | ||
| 504 | - [ ] **Step 3: The pinned rows read** | |
| 505 | ||
| 506 | `internal/httpd/dashpins.go`: | |
| 507 | ||
| 508 | ```go | |
| 509 | package httpd | |
| 510 | ||
| 511 | import ( | |
| 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). | |
| 519 | type 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. | |
| 530 | func (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 | ||
| 549 | In `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 | ||
| 566 | Replace `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 | ||
| 612 | The `dict` helper takes key/value pairs; `len .Issues` inside `dict` needs parentheses, as written. | |
| 613 | ||
| 614 | - [ ] **Step 5: The stylesheet** | |
| 615 | ||
| 616 | Replace 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 | } | |
| 654 | a.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 | ||
| 662 | Delete the `.feed` rules (lines 1026-1030): nothing uses `.feed` any more. Keep `.feedline`. | |
| 663 | ||
| 664 | Add 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 | ||
| 676 | And 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 | ||
| 687 | In `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 | ||
| 703 | Replace 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 | ||
| 711 | In the `want` list at line 96-100, keep every entry; add `` `class="pins"` ``. | |
| 712 | ||
| 713 | - [ ] **Step 7: Run the tests** | |
| 714 | ||
| 715 | Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestDashboard' -count=1` | |
| 716 | Expected: PASS | |
| 717 | ||
| 718 | - [ ] **Step 8: Commit** | |
| 719 | ||
| 720 | ```bash | |
| 721 | git add internal/httpd/dashpins.go internal/httpd/dashpins_test.go internal/httpd/web.go internal/web e2e/dashboard_test.go | |
| 722 | git commit -m "web: the dashboard is three columns | |
| 723 | ||
| 724 | Pinned repositories with counts, a tile per queue, the feed as an aside. | |
| 725 | ||
| 726 | Ref #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 | |
| 750 | package httpd | |
| 751 | ||
| 752 | import ( | |
| 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). | |
| 761 | func 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 | ||
| 796 | Run: `go test ./internal/httpd/ -run TestFileNavMarksCurrentAndLinksParent` | |
| 797 | Expected: FAIL, "undefined: fileNavFor" | |
| 798 | ||
| 799 | - [ ] **Step 3: The navigator** | |
| 800 | ||
| 801 | `internal/httpd/filenav.go`: | |
| 802 | ||
| 803 | ```go | |
| 804 | package httpd | |
| 805 | ||
| 806 | import ( | |
| 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. | |
| 816 | type 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 | ||
| 822 | type 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. | |
| 832 | func 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. | |
| 840 | func 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 | ||
| 870 | In `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 | ||
| 874 | In `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 | ||
| 881 | and 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. | |
| 885 | func navDir(filePath string) string { | |
| 886 | if d := path.Dir(filePath); d != "." { | |
| 887 | return d | |
| 888 | } | |
| 889 | return "" | |
| 890 | } | |
| 891 | ``` | |
| 892 | ||
| 893 | In `blame`, before the render add the same two lines and add `Nav fileNav` to its struct, passing `nav`. | |
| 894 | ||
| 895 | In `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 | ||
| 902 | and `Nav: nav,` in the `editPage{...}` literal. | |
| 903 | ||
| 904 | - [ ] **Step 5: The partial and the templates** | |
| 905 | ||
| 906 | In `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 | ||
| 926 | After 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 | ||
| 949 | In 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 | |
| 962 | package e2e | |
| 963 | ||
| 964 | import ( | |
| 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). | |
| 973 | func 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 | ||
| 1020 | Run: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestFileNavigator|TestWebUI' -count=1` | |
| 1021 | Expected: PASS | |
| 1022 | ||
| 1023 | - [ ] **Step 9: Commit** | |
| 1024 | ||
| 1025 | ```bash | |
| 1026 | git add internal/httpd/filenav.go internal/httpd/filenav_test.go internal/httpd/web.go internal/httpd/accounts.go internal/web e2e/filenav_test.go | |
| 1027 | git commit -m "web: a file navigator beside blob, blame and edit | |
| 1028 | ||
| 1029 | Ref #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 | |
| 1052 | package httpd | |
| 1053 | ||
| 1054 | import ( | |
| 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). | |
| 1063 | func 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 | ||
| 1076 | func 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 | ||
| 1108 | Run: `go test ./internal/httpd/ -run 'TestFacetHref|TestListFacets'` | |
| 1109 | Expected: FAIL, "undefined: facetHref" | |
| 1110 | ||
| 1111 | - [ ] **Step 3: The facets** | |
| 1112 | ||
| 1113 | `internal/httpd/facets.go`: | |
| 1114 | ||
| 1115 | ```go | |
| 1116 | package httpd | |
| 1117 | ||
| 1118 | import ( | |
| 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. | |
| 1126 | type 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. | |
| 1134 | type 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. | |
| 1142 | func 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. | |
| 1160 | func 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 | ||
| 1194 | In `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 | ||
| 1204 | add `Facets []facetGroup` to the render struct after `Filters`, passing `facets`. Add `"net/url"` to the imports if absent. | |
| 1205 | ||
| 1206 | In `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 | ||
| 1216 | and `Facets []facetGroup` in its struct. | |
| 1217 | ||
| 1218 | - [ ] **Step 5: The partial and the templates** | |
| 1219 | ||
| 1220 | In `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 | ||
| 1256 | The `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 | ||
| 1262 | After 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 | ||
| 1285 | In 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 | ||
| 1296 | At 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&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 | ||
| 1314 | If `TestLabelsWeb` removes the `bug` label before its end, place the block before that removal. | |
| 1315 | ||
| 1316 | - [ ] **Step 8: Run the tests** | |
| 1317 | ||
| 1318 | Run: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestLabelsWeb|TestMRListRows|TestMRWebLabels' -count=1` | |
| 1319 | Expected: PASS | |
| 1320 | ||
| 1321 | - [ ] **Step 9: Commit** | |
| 1322 | ||
| 1323 | ```bash | |
| 1324 | git add internal/httpd/facets.go internal/httpd/facets_test.go internal/httpd/web.go internal/web e2e/labelweb_test.go | |
| 1325 | git commit -m "web: a facet column on the issue and merge request lists | |
| 1326 | ||
| 1327 | State, labels with counts and open milestones beside the rows; a facet | |
| 1328 | keeps the other filters and clears itself when active. | |
| 1329 | ||
| 1330 | Ref #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 | ||
| 1349 | Append 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). | |
| 1354 | func 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 | ||
| 1380 | Run: `go test ./internal/httpd/ -run TestBuildFacetsGroups` | |
| 1381 | Expected: FAIL, "undefined: buildFacets" | |
| 1382 | ||
| 1383 | - [ ] **Step 3: The grouping** | |
| 1384 | ||
| 1385 | Append 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. | |
| 1391 | func 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 | ||
| 1416 | In `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 | ||
| 1420 | Replace `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 | ||
| 1443 | and 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 | ||
| 1447 | Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestBuildCancelWeb|TestRunnerSettingsWeb' -count=1` | |
| 1448 | Expected: PASS | |
| 1449 | ||
| 1450 | - [ ] **Step 6: Commit** | |
| 1451 | ||
| 1452 | ```bash | |
| 1453 | git 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 | |
| 1454 | git commit -m "web: the builds page filters in a side column | |
| 1455 | ||
| 1456 | Ref #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 | |
| 1477 | package httpd | |
| 1478 | ||
| 1479 | import "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). | |
| 1484 | func 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 | ||
| 1515 | Run: `go test ./internal/httpd/ -run TestTopicFacets` | |
| 1516 | Expected: FAIL, "undefined: topicFacets" | |
| 1517 | ||
| 1518 | - [ ] **Step 3: The counter** | |
| 1519 | ||
| 1520 | `internal/httpd/topics.go`: | |
| 1521 | ||
| 1522 | ```go | |
| 1523 | package httpd | |
| 1524 | ||
| 1525 | import ( | |
| 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. | |
| 1533 | func 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 | ||
| 1565 | In `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}}&kind=repo">repositories</a></li> | |
| 1600 | <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=issue">issues</a></li> | |
| 1601 | <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&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 | ||
| 1617 | Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestGlobalSearchAndNotificationsWeb' -count=1` | |
| 1618 | Expected: PASS | |
| 1619 | ||
| 1620 | - [ ] **Step 6: Commit** | |
| 1621 | ||
| 1622 | ```bash | |
| 1623 | git add internal/httpd/topics.go internal/httpd/topics_test.go internal/httpd/web.go internal/web/templates/explore.html internal/web/templates/globalsearch.html | |
| 1624 | git commit -m "web: topic and kind columns on explore and search | |
| 1625 | ||
| 1626 | Ref #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 | ||
| 1643 | Append 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 | ||
| 1662 | Add `"regexp"` to the imports. | |
| 1663 | ||
| 1664 | - [ ] **Step 2: Run it to see it fail** | |
| 1665 | ||
| 1666 | Run: `go test ./internal/web/ -run TestListPagesAreWide` | |
| 1667 | Expected: 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 | ||
| 1691 | and 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 | ||
| 1703 | Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestRepoSettingsWeb|TestAccountSettingsWeb|TestWebTheme' -count=1` | |
| 1704 | Expected: PASS | |
| 1705 | ||
| 1706 | - [ ] **Step 7: Commit** | |
| 1707 | ||
| 1708 | ```bash | |
| 1709 | git add internal/web | |
| 1710 | git commit -m "web: section columns on settings, account and admin | |
| 1711 | ||
| 1712 | Ref #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 | ||
| 1727 | After `.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 | ||
| 1738 | In 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 | ``` | |
| 1741 | 7. **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 | ||
| 1747 | In 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 | ||
| 1751 | Run: `go test ./internal/web/ ./internal/httpd/` | |
| 1752 | Expected: PASS | |
| 1753 | ||
| 1754 | ```bash | |
| 1755 | git add internal/web/static/style.css docs/specs/2026-09-19-desktop-layout-design.md | |
| 1756 | git commit -m "web: wider aside, capped README card, 15rem wiki nav | |
| 1757 | ||
| 1758 | Ref #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 | ||
| 1771 | Run from the repository root: | |
| 1772 | ||
| 1773 | ```bash | |
| 1774 | sh .claude/screenshots/local.sh | |
| 1775 | ``` | |
| 1776 | ||
| 1777 | It 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 | |
| 1782 | cd .claude/screenshots | |
| 1783 | python3 shoot.py desktop/dark-1920 dark 1920 local.txt "<login url>" | |
| 1784 | python3 shoot.py desktop/light-1920 light 1920 local.txt "<login url>" | |
| 1785 | python3 shoot.py desktop/dark-1280 dark 1280 local.txt "<login url>" | |
| 1786 | python3 shoot.py desktop/mobile dark 375 local-mobile.txt "<login url>" | |
| 1787 | ``` | |
| 1788 | ||
| 1789 | Add these lines to `local.txt` first if absent: | |
| 1790 | ||
| 1791 | ``` | |
| 1792 | dashboard http://127.0.0.1:8090/ | |
| 1793 | blob http://127.0.0.1:8090/krz/gitbay/blob/main/Makefile | |
| 1794 | builds http://127.0.0.1:8090/krz/gitbay/builds | |
| 1795 | settings http://127.0.0.1:8090/settings | |
| 1796 | repo-settings http://127.0.0.1:8090/krz/gitbay/settings | |
| 1797 | ``` | |
| 1798 | ||
| 1799 | Open 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 | |
| 1804 | python3 audit/audit.py desktop/axe dark 1280 local.txt | |
| 1805 | python3 audit/audit.py desktop/axe-mobile dark 375 local-mobile.txt | |
| 1806 | python3 audit/summ.py desktop/axe desktop/axe-mobile | |
| 1807 | ``` | |
| 1808 | ||
| 1809 | Expected: 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 | ||
| 1813 | Under `* v1.30.0 — unreleased` in `CHANGELOG.org`, before the existing bullet list, add a paragraph and bullets: | |
| 1814 | ||
| 1815 | ``` | |
| 1816 | The 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 | |
| 1841 | git add CHANGELOG.org | |
| 1842 | git commit -m "CHANGELOG: the desktop layout | |
| 1843 | ||
| 1844 | Ref #226" | |
| 1845 | ``` | |
| 1846 | ||
| 1847 | --- | |
| 1848 | ||
| 1849 | ### Task 12: Push, CI, merge request | |
| 1850 | ||
| 1851 | - [ ] **Step 1: Rebase onto main and push** | |
| 1852 | ||
| 1853 | ```bash | |
| 1854 | git 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 | |
| 1860 | gitbay mr create --source desktop-layout-spec --target main --title "web: the desktop layout" --file - <<'EOF' | |
| 1861 | Ref #226. Spec docs/specs/2026-09-19-desktop-layout-design.md, plan | |
| 1862 | docs/plans/2026-09-19-desktop-layout.md. | |
| 1863 | ||
| 1864 | One centered container, a two-row repository header, one-line list rows, | |
| 1865 | a three-column dashboard with count tiles and pinned counts, a file | |
| 1866 | navigator on blob/blame/edit, facet columns on the issue, MR, builds, | |
| 1867 | explore and search lists, section columns on the settings pages. | |
| 1868 | EOF | |
| 1869 | ``` | |
| 1870 | ||
| 1871 | - [ ] **Step 3: Wait for CI** | |
| 1872 | ||
| 1873 | Poll once every few minutes, one ssh call per tick: | |
| 1874 | ||
| 1875 | ```bash | |
| 1876 | gitbay build list --json | head -c 2000 | |
| 1877 | ``` | |
| 1878 | ||
| 1879 | Expected: `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 | |
| 1884 | gitbay mr merge <n> --strategy ff | |
| 1885 | git checkout main && git pull && git branch -d desktop-layout-spec && git push origin --delete desktop-layout-spec | |
| 1886 | ``` | |
| 1887 | ||
| 1888 | If the merge reports the branch is behind, rebase, push, merge again. | |
| 1889 | ||
| 1890 | - [ ] **Step 5: Deploy** | |
| 1891 | ||
| 1892 | ```bash | |
| 1893 | make deploy | |
| 1894 | ``` | |
| 1895 | ||
| 1896 | Then recapture the live site at 1920 with `shoot.py` against `public.txt` and compare with the local captures from Task 11. | |