web: the desktop layout !436
41 files changed, +3223 −168
Layout: unified · split
CHANGELOG.org +25
| @@ -4,6 +4,31 @@ Versioning follows semver from v0.1.0. Database migrations run | ||
| 4 | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | 5 | anything beyond "replace the binary and restart" is needed. |
| 6 | 6 | |
| 7 | * v1.31.0 — unreleased | |
| 8 | ||
| 9 | The desktop layout (#226): the web UI uses a wide screen. | |
| 10 | ||
| 11 | - One centered container at 100rem; the repository header, main and | |
| 12 | footer align on it. Text keeps its measure. | |
| 13 | - The repository header is two rows: name, description and buttons, | |
| 14 | then the tabs. | |
| 15 | - Issue, merge request, build, explore, search and notification rows | |
| 16 | are one line above 64rem, and those pages render at the container | |
| 17 | width. | |
| 18 | - The dashboard is three columns: pinned repositories with open issue, | |
| 19 | merge request and last-build counts; a tile per queue with the queue | |
| 20 | rows below; the activity feed. | |
| 21 | - A file navigator beside blob, blame and edit pages lists the file's | |
| 22 | directory and marks the file. | |
| 23 | - Side columns: state, labels and open milestones on the issue and | |
| 24 | merge request lists; status, jobs and branches on builds; topics on | |
| 25 | explore; kinds on search; sections on repository settings, account | |
| 26 | settings and admin. | |
| 27 | - Below 62rem the facet and section columns stack after their content | |
| 28 | and the navigator hides, since the tree page is the navigator on a | |
| 29 | phone. Below 80rem the dashboard's pinned repositories return to a | |
| 30 | chip row above the queues. | |
| 31 | ||
| 7 | 32 | * v1.30.0 — 2026-09-19 |
| 8 | 33 | |
| 9 | 34 | Every command runs on every surface, markup previews before it is |
docs/plans/2026-09-19-desktop-layout.md added +1896
| @@ -0,0 +1,1896 @@ | ||
| 1 | # Desktop Layout Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** Make the web UI use a desktop screen: one centered container, per-page left columns that each carry a feature, one-line list rows, a three-column dashboard, and a two-row repository header. | |
| 6 | ||
| 7 | **Architecture:** Every change is CSS on existing tokens plus template markup, with three handler additions: a directory listing beside a file, facet counts beside a list, and per-repository counts beside the pinned list. No new control command, no migration. Each page keeps working at phone widths because the columns stack below 64rem. | |
| 8 | ||
| 9 | **Tech Stack:** Go 1.2x, `html/template`, one stylesheet (`internal/web/static/style.css`), `go test` unit tests in `internal/httpd` and `internal/web`, e2e tests in `e2e/` against a real instance. | |
| 10 | ||
| 11 | **Spec:** `docs/specs/2026-09-19-desktop-layout-design.md` | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - Branch `desktop-layout-spec` already holds the spec commit; all work lands on it. Never push to `main`; the MR at the end merges with `--strategy ff` (signed commits required). | |
| 16 | - Commit subjects follow the log: `web: ...`, `httpd: ...`, `e2e: ...`, `CHANGELOG: ...`, lowercase after the prefix, no trailer, no attribution of any kind. Reference `Ref #226` in bodies. | |
| 17 | - `--container: 100rem`. Every left column is `15rem`, sticky. Text stays at 48rem / 78ch. Columns stack below `64rem`. | |
| 18 | - Colors and spacing use existing tokens only (`--sp-*`, `--fs-*`, `--surface`, `--line`, `--faint`, `--muted`, `--mark`, `--warn`, `--fg`, `--link`, `--hover`, `--r-ctl`, `--r-card`). Never a hex value in a rule. | |
| 19 | - `TestEveryTemplateClassHasARule` (`internal/web/classes_test.go`) fails on any class a template uses that no `style.css` selector names. Add the rule in the same step as the markup. | |
| 20 | - Local verification per task: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/`, plus at most the one e2e test the task touches (`go test ./e2e -run TestName -count=1`). The full suite runs in CI on push. | |
| 21 | - Do not mention Claude, LLMs or assistants anywhere: commits, comments, CHANGELOG, wiki. | |
| 22 | ||
| 23 | --- | |
| 24 | ||
| 25 | ### Task 1: One centered container | |
| 26 | ||
| 27 | **Files:** | |
| 28 | - Modify: `internal/web/static/style.css:366-388` (shell section), `:389-395` (repohead), `:1481-1489` (52rem breakpoint) | |
| 29 | - Modify: `internal/web/templates/layout.html:39-69` (repohead) | |
| 30 | - Test: `internal/web/layout_test.go` (create) | |
| 31 | ||
| 32 | **Interfaces:** | |
| 33 | - Produces: the `.repohead .wrap` element every later header change lives in; `--container` token used by Task 4 and Task 9. | |
| 34 | ||
| 35 | - [ ] **Step 1: Write the failing test** | |
| 36 | ||
| 37 | ```go | |
| 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. | |
docs/specs/2026-09-19-desktop-layout-design.md added +163
| @@ -0,0 +1,163 @@ | ||
| 1 | # Desktop layout | |
| 2 | ||
| 3 | Ref #226. The phone layout landed in v1.23.0 through v1.29.0; this is | |
| 4 | the desktop half. Measured on gitbay.org at 1a40b22 (v1.30.0 head) at | |
| 5 | 1600 and 1920 wide, and compared against GitHub, GitLab, Codeberg and | |
| 6 | SourceHut dashboards at the same window. | |
| 7 | ||
| 8 | ## Findings | |
| 9 | ||
| 10 | Three content widths, all pinned to the left edge. Nothing centers and | |
| 11 | nothing grows past its cap, so a wide screen puts every page in its | |
| 12 | top-left corner. | |
| 13 | ||
| 14 | | Width class | Pages | Content edge at 1920 | Used | | |
| 15 | |---|---|---|---| | |
| 16 | | `reading` (72rem, the default) | dashboard, issue and MR lists, explore, notifications, profile, refs, releases, milestones, labels | 1216px | 63% | | |
| 17 | | `reading`, then 48rem comments + 18rem aside | issue, MR conversation | 1184px | 62% | | |
| 18 | | `bounded` (48rem) | account settings, admin, new repository, login, 404 | 640px | 33% | | |
| 19 | | `wide` | tree, blob, blame, log, commit, compare, diff, builds, build, search | 1920px | 100% | | |
| 20 | ||
| 21 | - Text columns are at the right measure. A comment at 48rem is about 90 | |
| 22 | characters a line; wider hurts. Issue and MR pages need centering and | |
| 23 | a stronger aside, not wider text. | |
| 24 | - Lists are laid out as prose. Issue, MR, build and explore rows are two | |
| 25 | to four lines inside the 72rem cap: 14 issues a screen at 1080 tall, | |
| 26 | 9 repositories on explore. | |
| 27 | - The dashboard is one column. Queues stack, the feed follows at 809px, | |
| 28 | below the fold at 1080 tall. | |
| 29 | - The repository header is 182px on the code tab (identity, description, | |
| 30 | website, the toggles hint, tabs) and 110px on task tabs. | |
| 31 | - Account settings at 48rem wraps SSH fingerprints onto two lines beside | |
| 32 | 1280px of unused width. | |
| 33 | - The overview README card is 1216px wide while its prose caps at 78ch, | |
| 34 | so paragraphs and code blocks have different right edges. | |
| 35 | ||
| 36 | SourceHut uses the same share of the window as gitbay and reads as | |
| 37 | designed, because it centers and splits into two columns. GitLab's stat | |
| 38 | tiles are gitbay's three "0" lines done as a strip. Codeberg's one-line | |
| 39 | feed rows are the row format the lists should adopt. | |
| 40 | ||
| 41 | ## Rules | |
| 42 | ||
| 43 | 1. **One centered container.** `--container: 100rem`. The repository | |
| 44 | header's inner content, `main` and the footer share it, so all three | |
| 45 | align at every width. `reading` (72rem) and `bounded` (48rem) stay as | |
| 46 | caps inside it, centered. `wide` is the container. | |
| 47 | 2. **A left column is a feature.** A page gets a left column only when | |
| 48 | the column carries something the page already has as links, query | |
| 49 | parameters or sections. No site navigation in it; the top bar is the | |
| 50 | navigation. Every column is 15rem, sticky, and reads as one system. | |
| 51 | No column persists across pages. | |
| 52 | 3. **Text keeps its measure.** Comments, descriptions, README prose and | |
| 53 | wiki pages stay at 48rem or 78ch. Width goes to tables and lists. | |
| 54 | 4. **Lists are rows.** Above 64rem an issue, MR, build, explore, search | |
| 55 | and notification row is one line: title, then labels, then the meta | |
| 56 | pushed right, state or check badges at the far edge. The log keeps | |
| 57 | two lines; subject over author and date is the convention there. | |
| 58 | 5. **Counts are tiles.** Where the dashboard now prints an empty queue | |
| 59 | as a heading with a zero, a strip of four tiles carries the counts | |
| 60 | and links to the lists. A non-zero count uses `--warn`: what wants | |
| 61 | you. Only non-empty queues list rows. | |
| 62 | 6. **The repository header is two rows.** Row one: owner/name, the | |
| 63 | description with topics and website inline in muted text, the | |
| 64 | Pin/Watch/Bookmark/Fork buttons at the right. Row two: the tabs. The | |
| 65 | toggles hint moves to `title` text on the three buttons. | |
| 66 | 7. **Below 62rem** a facet or section column stacks after the content, | |
| 67 | the way the issue aside does; the file navigator disappears (the | |
| 68 | tree page exists). **Below 80rem** the dashboard's pinned column | |
| 69 | returns to the chip row. Nothing the phone layout fixed moves. | |
| 70 | ||
| 71 | ## Pages | |
| 72 | ||
| 73 | ### With a left column | |
| 74 | ||
| 75 | | Page | Column | Source of its contents | | |
| 76 | |---|---|---| | |
| 77 | | Dashboard | Pinned repositories with open issue count, open MR count, last build state | `Rail.Pinned` plus per-repository counts (new store read) | | |
| 78 | | Blob, blame, edit | File navigator: the file's directory, parent link, current file marked with `aria-current` | `gitutil.ListTree` on the directory, the call the tree page makes | | |
| 79 | | Issues list, MR list | State; labels with counts; milestones with open counts | The `state`, `label`, `milestone`, `assignee` parameters `activeFilters` already handles; counts from the labels and milestones pages' reads | | |
| 80 | | Builds list | Status; job; branch | The `status`, `job`, `ref` parameters `buildFilter` already handles; branches from the refs read | | |
| 81 | | Explore | Topics with counts | `?q=<topic>`, the link the topic chips already use | | |
| 82 | | Site search | Kind: repositories, issues, merge requests | `?kind=repo\|issue\|mr` | | |
| 83 | | Repository settings | Section nav: Identity, Access, Merge gates, Protected branches, Protected tags, Dependencies, Runners, Lifecycle | Anchors on the existing `h2` headings | | |
| 84 | | Account settings | Section nav: Profile, SSH keys, Email addresses, OpenPGP keys, Notifications, Appearance, Export | Same | | |
| 85 | | Admin | Section nav: Webhook deliveries, Mail, Mirrors, Builds, Dependency checks | Same | | |
| 86 | | Wiki | Page nav | Already there; adopt the 15rem width | | |
| 87 | ||
| 88 | Facet links carry the other active filters, the way `activeFilters` | |
| 89 | builds its clear links. The milestones and labels pages stay for | |
| 90 | management; the "milestones · labels" links leave the list head. | |
| 91 | ||
| 92 | ### Layout of the pages that gain a column | |
| 93 | ||
| 94 | - **Dashboard**: `15rem | 1fr | 20rem`. Pinned column, then the tile | |
| 95 | strip and queue rows, then the activity feed as a sticky aside with | |
| 96 | two-line entries (the aside is too narrow for one). | |
| 97 | - **Blob, blame, edit**: `15rem | 1fr`. Navigator entries in mono, the | |
| 98 | current file on a `--surface` ground with a 2px `--mark` edge. | |
| 99 | - **Lists**: `15rem | 1fr`. Rows fill the container. | |
| 100 | - **Settings and admin**: `15rem | minmax(0, 56rem)`. Forms and tables | |
| 101 | at up to 56rem, so the key table stops wrapping. | |
| 102 | ||
| 103 | ### Without one | |
| 104 | ||
| 105 | - **Repository overview**: the facts column stays on the right at | |
| 106 | 20rem; the README card caps at 88ch so prose and code share an edge. | |
| 107 | - **Issue, MR**: centered at 72rem; text at 48rem; the aside widens to | |
| 108 | 20rem so the two sit balanced in the container. | |
| 109 | - **Log, commit, compare, refs, releases, build, code search**: the | |
| 110 | container width, no column. | |
| 111 | - **Notifications**: the container width, no column; rows one line. | |
| 112 | - **Bookmarks, snippets, profile, org**: centered `reading`. | |
| 113 | - **Landing, login, register, new repository, 404, privacy**: centered | |
| 114 | `bounded`. | |
| 115 | ||
| 116 | ## Implementation | |
| 117 | ||
| 118 | - **CSS only where it can be.** The container, centering, header rows, | |
| 119 | row format, tiles, column grid and breakpoints are `style.css` rules | |
| 120 | on tokens that exist. New tokens: `--container` only. | |
| 121 | - **Templates** touched: `layout.html` (header rows, container wrapper), | |
| 122 | `dashboard.html`, `issues.html`, `mrs.html`, `builds.html`, | |
| 123 | `explore.html`, `globalsearch.html`, `blob.html`, `blame.html`, | |
| 124 | `edit.html`, `settings.html`, `account.html`, `admin.html`, | |
| 125 | `notifications.html`, `tree.html` (README cap). Width classes: the | |
| 126 | list pages and the dashboard move from the default `reading` to | |
| 127 | `wide`. | |
| 128 | - **Handlers**: `blob`, `blame` and the edit form gain the directory | |
| 129 | listing; the issue, MR and build list handlers gain facet counts; the | |
| 130 | dashboard gains per-pinned-repository counts. Every new read is a | |
| 131 | store query or a `gitutil` call that exists; no new control command, | |
| 132 | since none of this is a capability the CLI lacks. | |
| 133 | `TestReadOnlyCommandsWriteNothing` is unaffected. | |
| 134 | - **Facet counts** span only what the viewer can read, the same rule | |
| 135 | `control.ReadableOrgRepoIDs` applies to org label counts. | |
| 136 | ||
| 137 | ## Tests | |
| 138 | ||
| 139 | - `TestEveryTemplateClassHasARule` covers every new class. | |
| 140 | - A template test asserts the width class of each list page and the | |
| 141 | dashboard is `wide`, and that `layout.html` wraps the header in the | |
| 142 | container. | |
| 143 | - e2e: the blob page's navigator lists the file's directory and marks | |
| 144 | the file; a facet link on the issues list keeps `state` and the other | |
| 145 | active filters; the dashboard's tile count equals the queue length. | |
| 146 | - Recapture the `.claude/screenshots` set at 1280 and 1920 and rerun the | |
| 147 | axe scan; zero violations is the bar the last release set. | |
| 148 | ||
| 149 | ## Out of scope | |
| 150 | ||
| 151 | - A persistent context rail (option 2 in the mockups). Revisit if the | |
| 152 | dashboard's pinned column proves worth having on every page. | |
| 153 | - A density preference. | |
| 154 | - One-line log rows. | |
| 155 | - Explore facets beyond topics; an `owner` parameter would be new. | |
| 156 | ||
| 157 | ## Mockups | |
| 158 | ||
| 159 | `.claude/mock/desktop/` in the main checkout, served by the `mockups` | |
| 160 | entry in `.claude/launch.json`: `option1-dashboard.html`, | |
| 161 | `option1-blob.html`, the rejected `option2-*` pair, and `layouts.html` | |
| 162 | with one wireframe per page type. `desktop.css` there is the override | |
| 163 | layer the rules above were checked against. | |
e2e/dashboard_test.go +13 −14
| @@ -93,8 +93,8 @@ func TestDashboard(t *testing.T) { | ||
| 93 | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | 94 | } |
| 95 | 95 | for _, want := range []string{ |
| 96 | `aria-label="Pinned repositories"`, `</span>app</a>`, // the dashboard lists the pinned repo | |
| 97 | "Waiting on your review", "Assigned to you", "Recent activity", | |
| 96 | `aria-label="Pinned repositories"`, `class="pins"`, `</span>app</a>`, // the dashboard lists the pinned repo | |
| 97 | "Waiting on your review", "assigned to you", "Recent activity", | |
| 98 | 98 | "from bob", "alice/app!1", "todo one", "alice/app#1", |
| 99 | 99 | `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, |
| 100 | 100 | "Yours anywhere, and every one in a repository you can write to.", |
| @@ -104,17 +104,16 @@ func TestDashboard(t *testing.T) { | ||
| 104 | 104 | } |
| 105 | 105 | } |
| 106 | 106 | |
| 107 | // D01: alice has nothing assigned to her, so that section condenses to | |
| 108 | // an empty heading, and populated sections (she has a review waiting) | |
| 109 | // sort before it. | |
| 110 | emptyHeading := `<h2 class="empty">Assigned to you <span class="count">0</span></h2>` | |
| 111 | if !strings.Contains(body, emptyHeading) { | |
| 112 | t.Fatalf("dashboard missing condensed empty heading %q:\n%s", emptyHeading, body) | |
| 113 | } | |
| 114 | reviewIdx := strings.Index(body, "Waiting on your review") | |
| 115 | assignedIdx := strings.Index(body, emptyHeading) | |
| 116 | if reviewIdx < 0 || assignedIdx < 0 || reviewIdx > assignedIdx { | |
| 117 | t.Fatalf("populated heading should come before the empty one: review=%d assigned=%d", reviewIdx, assignedIdx) | |
| 107 | // The empty queue is a tile with a zero; a populated one is a tile | |
| 108 | // that links to its rows, which render in the middle column. | |
| 109 | if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) { | |
| 110 | t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body) | |
| 111 | } | |
| 112 | if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) { | |
| 113 | t.Fatalf("dashboard missing the review tile:\n%s", body) | |
| 114 | } | |
| 115 | if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) { | |
| 116 | t.Fatalf("queues do not render as tiles plus rows:\n%s", body) | |
| 118 | 117 | } |
| 119 | 118 | |
| 120 | 119 | // The diff has its own view rather than a fold at the foot of the |
| @@ -352,7 +351,7 @@ func TestDashboardQueues(t *testing.T) { | ||
| 352 | 351 | t.Fatalf("review: %s", errOut) |
| 353 | 352 | } |
| 354 | 353 | _, after := browserGet(t, inst.login(t, aliceKey), inst.base()+"/") |
| 355 | if !strings.Contains(after, `<h2 class="empty">Waiting on your review <span class="count">0</span></h2>`) { | |
| 354 | if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) { | |
| 356 | 355 | t.Fatalf("reviewed MR still waiting:\n%s", after) |
| 357 | 356 | } |
| 358 | 357 | } |
e2e/filenav_test.go added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | // A file page lists its directory beside the file, marks the file, and | |
| 11 | // links up (desktop layout spec). | |
| 12 | func TestFileNavigator(t *testing.T) { | |
| 13 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | |
| 14 | key := inst.newKey(t, "alice") | |
| 15 | inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub") | |
| 16 | if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/nav"); code != 0 { | |
| 17 | t.Fatalf("repo create: %s", errOut) | |
| 18 | } | |
| 19 | // the same clone-commit-push shape TestWebUI uses (e2e/web_test.go:41-50) | |
| 20 | work := t.TempDir() | |
| 21 | env := inst.gitEnv(key) | |
| 22 | mustGit(t, work, env, "clone", inst.sshURL("alice/nav"), "w") | |
| 23 | dir := filepath.Join(work, "w") | |
| 24 | os.MkdirAll(filepath.Join(dir, "cmd", "sub"), 0o755) | |
| 25 | os.WriteFile(filepath.Join(dir, "README.md"), []byte("# nav\n"), 0o644) | |
| 26 | os.WriteFile(filepath.Join(dir, "cmd", "main.go"), []byte("package main\n"), 0o644) | |
| 27 | os.WriteFile(filepath.Join(dir, "cmd", "sub", "x.go"), []byte("package sub\n"), 0o644) | |
| 28 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 29 | mustGit(t, dir, env, "add", ".") | |
| 30 | mustGit(t, dir, env, "commit", "-q", "-m", "one") | |
| 31 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 32 | ||
| 33 | status, body := inst.get(t, "/alice/nav/blob/main/cmd/main.go") | |
| 34 | if status != 200 { | |
| 35 | t.Fatalf("blob: %d", status) | |
| 36 | } | |
| 37 | for _, want := range []string{ | |
| 38 | `<nav class="filenav" aria-label="Files">`, | |
| 39 | `<h2 class="colhead">cmd</h2>`, | |
| 40 | `<a class="up" href="/alice/nav/tree/main">..</a>`, | |
| 41 | `<a class="dir" href="/alice/nav/tree/main/cmd/sub">sub/</a>`, | |
| 42 | `<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`, | |
| 43 | } { | |
| 44 | if !strings.Contains(body, want) { | |
| 45 | t.Errorf("blob page lacks %q", want) | |
| 46 | } | |
| 47 | } | |
| 48 | _, body = inst.get(t, "/alice/nav/blame/main/README.md") | |
| 49 | if !strings.Contains(body, `<h2 class="colhead">nav</h2>`) || !strings.Contains(body, `<a aria-current="page" href="/alice/nav/blob/main/README.md">README.md</a>`) { | |
| 50 | t.Errorf("blame page lacks the root navigator:\n%s", body) | |
| 51 | } | |
| 52 | ||
| 53 | alice := inst.login(t, key) | |
| 54 | status, body = browserGet(t, alice, inst.base()+"/alice/nav/edit/main/cmd/main.go") | |
| 55 | if status != 200 || !strings.Contains(body, `<h2 class="colhead">cmd</h2>`) || !strings.Contains(body, `<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`) { | |
| 56 | t.Errorf("edit page lacks the navigator: %d\n%s", status, body) | |
| 57 | } | |
| 58 | } | |
e2e/labelweb_test.go +14
| @@ -51,6 +51,20 @@ func TestLabelsWeb(t *testing.T) { | ||
| 51 | 51 | t.Errorf("bad colour accepted:\n%s", body) |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | // The issue list's column lists the label with its count and a link | |
| 55 | // that keeps the state (desktop layout spec). | |
| 56 | status, page = browserGet(t, alice, base+"/issues?state=open") | |
| 57 | if status != 200 || !strings.Contains(page, `<nav class="sidecol" aria-label="Filters">`) { | |
| 58 | t.Fatalf("issues page lacks the side column: %d", status) | |
| 59 | } | |
| 60 | if !strings.Contains(page, `href="?label=bug&state=open">bug <i>1</i></a>`) { | |
| 61 | t.Fatalf("issues column lacks the bug facet:\n%s", page) | |
| 62 | } | |
| 63 | status, page = browserGet(t, alice, base+"/issues?state=open&label=bug") | |
| 64 | if status != 200 || !strings.Contains(page, `aria-current="page" href="?state=open">bug <i>1</i></a>`) { | |
| 65 | t.Fatalf("active facet does not clear itself:\n%s", page) | |
| 66 | } | |
| 67 | ||
| 54 | 68 | // Removing a label needs its name typed; a bare post is refused and |
| 55 | 69 | // the label stays. |
| 56 | 70 | _, body = browserPost(t, alice, base+"/labels", url.Values{ |
e2e/mrweb_test.go +10
| @@ -575,6 +575,16 @@ func TestMRWebLabels(t *testing.T) { | ||
| 575 | 575 | t.Fatalf("removed label still lists the merge request:\n%s", body) |
| 576 | 576 | } |
| 577 | 577 | |
| 578 | // The list's column lists the label with its merge request count and a | |
| 579 | // link that keeps the state (desktop layout spec). | |
| 580 | _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs") | |
| 581 | if !strings.Contains(body, `<nav class="sidecol" aria-label="Filters">`) { | |
| 582 | t.Fatalf("merge request list lacks the side column:\n%s", body) | |
| 583 | } | |
| 584 | if !strings.Contains(body, `href="?label=bug&state=open">bug <i>1</i></a>`) { | |
| 585 | t.Fatalf("merge request column lacks the bug facet:\n%s", body) | |
| 586 | } | |
| 587 | ||
| 578 | 588 | // A reader gets the chips and no form. |
| 579 | 589 | _, body = browserGet(t, inst.login(t, bobKey), mrURL) |
| 580 | 590 | if !strings.Contains(body, `class="chip label"`) { |
internal/httpd/accounts.go +4
| @@ -525,6 +525,7 @@ type editPage struct { | ||
| 525 | 525 | // Preview button makes sense; Draft holds one when asked for (#235). |
| 526 | 526 | Markup bool |
| 527 | 527 | Draft *draft |
| 528 | Nav fileNav | |
| 528 | 529 | } |
| 529 | 530 | |
| 530 | 531 | func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) { |
| @@ -559,10 +560,13 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) | ||
| 559 | 560 | http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest) |
| 560 | 561 | return |
| 561 | 562 | } |
| 563 | navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath)) | |
| 564 | nav := fileNavFor(repo.Path(), ref, filePath, navEntries) | |
| 562 | 565 | s.render(w, "edit.html", editPage{ |
| 563 | 566 | basePage: s.baseFor(u), Repo: repo, |
| 564 | 567 | Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating, |
| 565 | 568 | Markup: markupFile(filePath), |
| 569 | Nav: nav, | |
| 566 | 570 | }) |
| 567 | 571 | } |
| 568 | 572 | |
internal/httpd/buildpages_test.go +9 −9
| @@ -30,16 +30,16 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) { | ||
| 30 | 30 | filter := buildFilter{} |
| 31 | 31 | err := web.Render(&sb, "builds.html", struct { |
| 32 | 32 | repoPage |
| 33 | Builds []control.BuildOut | |
| 34 | Jobs []control.JobOut | |
| 35 | Runs []buildRun | |
| 36 | Filter buildFilter | |
| 37 | FilterLinks []buildFilterLink | |
| 38 | Refs []string | |
| 39 | CanWrite bool | |
| 40 | Notice string | |
| 33 | Builds []control.BuildOut | |
| 34 | Jobs []control.JobOut | |
| 35 | Runs []buildRun | |
| 36 | Filter buildFilter | |
| 37 | Facets []facetGroup | |
| 38 | Refs []string | |
| 39 | CanWrite bool | |
| 40 | Notice string | |
| 41 | 41 | }{ |
| 42 | testRepoPage(), builds, jobs, groupRuns(builds), filter, filterLinks(filter, jobs), | |
| 42 | testRepoPage(), builds, jobs, groupRuns(builds), filter, nil, | |
| 43 | 43 | distinctRefs(builds, filter.Ref), true, "", |
| 44 | 44 | }) |
| 45 | 45 | if err != nil { |
internal/httpd/builds.go +46 −16
| @@ -16,9 +16,9 @@ type buildFilter struct { | ||
| 16 | 16 | Job string |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | // buildFilterLink is one entry in the nav.filters row above the build | |
| 20 | // list: a status or a job, with the other two parameters carried along so | |
| 21 | // clicking one never drops another. | |
| 19 | // buildFilterLink is one of the links buildFacets splits into the side | |
| 20 | // column's Status and Jobs groups: a status or a job, with the other two | |
| 21 | // parameters carried along so clicking one never drops another. | |
| 22 | 22 | type buildFilterLink struct { |
| 23 | 23 | Label string |
| 24 | 24 | Href string |
| @@ -26,12 +26,13 @@ type buildFilterLink struct { | ||
| 26 | 26 | } |
| 27 | 27 | |
| 28 | 28 | // buildStatuses is the fixed vocabulary a build's status takes, in the |
| 29 | // order the nav.filters row offers them. | |
| 29 | // order the Status group offers them. Its length is also where buildFacets | |
| 30 | // cuts filterLinks' rows apart. | |
| 30 | 31 | var buildStatuses = []string{"pending", "running", "success", "failure", "cancelled"} |
| 31 | 32 | |
| 32 | // filterLinks builds the nav.filters row: "all" (clears status and job), | |
| 33 | // one link per status, and one per job the repository's CI config names. | |
| 34 | // Each link keeps the filter's other two parameters and net/url encodes | |
| 33 | // filterLinks builds the status and job rows: "all" (clears status and | |
| 34 | // job), one link per status, and one per job the repository's CI config | |
| 35 | // names. Each link keeps the filter's other two parameters and net/url encodes | |
| 35 | 36 | // them, so a branch name or job name with an odd character does not break |
| 36 | 37 | // the query string it lands in. |
| 37 | 38 | func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink { |
| @@ -60,6 +61,33 @@ func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink { | ||
| 60 | 61 | return links |
| 61 | 62 | } |
| 62 | 63 | |
| 64 | // buildFacets is the builds page's side column: filterLinks' rows split | |
| 65 | // into their groups, plus one link per branch seen, which keeps status | |
| 66 | // and job and clears itself when active. | |
| 67 | func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup { | |
| 68 | links := filterLinks(f, jobs) | |
| 69 | n := 1 + len(buildStatuses) | |
| 70 | status := facetGroup{Title: "Status"} | |
| 71 | for _, l := range links[:n] { | |
| 72 | status.Items = append(status.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active}) | |
| 73 | } | |
| 74 | job := facetGroup{Title: "Jobs"} | |
| 75 | for _, l := range links[n:] { | |
| 76 | job.Items = append(job.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active}) | |
| 77 | } | |
| 78 | branch := facetGroup{Title: "Branches"} | |
| 79 | base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}} | |
| 80 | for _, ref := range refs { | |
| 81 | active := ref == f.Ref | |
| 82 | href := facetHref(base, "ref", ref) | |
| 83 | if active { | |
| 84 | href = facetHref(base, "ref", "") | |
| 85 | } | |
| 86 | branch.Items = append(branch.Items, facetItem{Label: ref, Href: href, Active: active}) | |
| 87 | } | |
| 88 | return []facetGroup{status, job, branch} | |
| 89 | } | |
| 90 | ||
| 63 | 91 | // distinctRefs lists each ref among builds once, in order, plus the |
| 64 | 92 | // current filter value if it is not already there. It backs the branch |
| 65 | 93 | // field's <datalist> suggestions, not a claim about what branches exist: |
| @@ -176,17 +204,19 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | ||
| 176 | 204 | var jobs []control.JobOut |
| 177 | 205 | s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs) |
| 178 | 206 | |
| 207 | refs := distinctRefs(builds, filter.Ref) | |
| 208 | ||
| 179 | 209 | s.render(w, "builds.html", struct { |
| 180 | 210 | repoPage |
| 181 | Builds []control.BuildOut | |
| 182 | Jobs []control.JobOut | |
| 183 | Runs []buildRun | |
| 184 | Filter buildFilter | |
| 185 | FilterLinks []buildFilterLink | |
| 186 | Refs []string | |
| 187 | CanWrite bool | |
| 188 | Notice string | |
| 189 | }{p, builds, jobs, groupRuns(builds), filter, filterLinks(filter, jobs), distinctRefs(builds, filter.Ref), | |
| 211 | Builds []control.BuildOut | |
| 212 | Jobs []control.JobOut | |
| 213 | Runs []buildRun | |
| 214 | Filter buildFilter | |
| 215 | Facets []facetGroup | |
| 216 | Refs []string | |
| 217 | CanWrite bool | |
| 218 | Notice string | |
| 219 | }{p, builds, jobs, groupRuns(builds), filter, buildFacets(filter, jobs, refs), refs, | |
| 190 | 220 | s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) |
| 191 | 221 | } |
| 192 | 222 | |
internal/httpd/builds_test.go +25
| @@ -149,3 +149,28 @@ func TestDistinctRefsNoDuplicateWhenCurrentAlreadyPresent(t *testing.T) { | ||
| 149 | 149 | t.Errorf("distinctRefs = %v, want %v", got, want) |
| 150 | 150 | } |
| 151 | 151 | } |
| 152 | ||
| 153 | // The builds column groups the same links filterLinks makes: "all" and | |
| 154 | // the statuses, then the jobs, then the branches seen (desktop layout spec). | |
| 155 | func TestBuildFacetsGroups(t *testing.T) { | |
| 156 | f := buildFilter{Ref: "main", Status: "success"} | |
| 157 | groups := buildFacets(f, []control.JobOut{{Name: "lint"}}, []string{"main", "dev"}) | |
| 158 | if len(groups) != 3 || groups[0].Title != "Status" || groups[1].Title != "Jobs" || groups[2].Title != "Branches" { | |
| 159 | t.Fatalf("groups: %+v", groups) | |
| 160 | } | |
| 161 | if groups[0].Items[0].Label != "all" || groups[0].Items[0].Href != "?ref=main" || groups[0].Items[0].Active { | |
| 162 | t.Errorf("all: %+v", groups[0].Items[0]) | |
| 163 | } | |
| 164 | if s := groups[0].Items[3]; s.Label != "success" || !s.Active { | |
| 165 | t.Errorf("success: %+v", s) | |
| 166 | } | |
| 167 | if j := groups[1].Items[0]; j.Label != "lint" || j.Href != "?job=lint&ref=main&status=success" || j.Active { | |
| 168 | t.Errorf("lint: %+v", j) | |
| 169 | } | |
| 170 | if b := groups[2].Items[0]; b.Label != "main" || !b.Active || b.Href != "?status=success" { | |
| 171 | t.Errorf("active branch clears itself: %+v", b) | |
| 172 | } | |
| 173 | if b := groups[2].Items[1]; b.Label != "dev" || b.Active || b.Href != "?ref=dev&status=success" { | |
| 174 | t.Errorf("dev: %+v", b) | |
| 175 | } | |
| 176 | } | |
internal/httpd/dashpins.go added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "gitbay.org/gitbay/internal/policy" | |
| 5 | "gitbay.org/gitbay/internal/store" | |
| 6 | ) | |
| 7 | ||
| 8 | // pinnedRow is one pinned repository on the dashboard with the counts | |
| 9 | // that say whether it wants attention: open issues, open merge requests | |
| 10 | // and the newest build's status ("" when it has none). | |
| 11 | type pinnedRow struct { | |
| 12 | Owner string | |
| 13 | Name string | |
| 14 | Issues int | |
| 15 | MRs int | |
| 16 | Build string | |
| 17 | } | |
| 18 | ||
| 19 | // pinnedRows reads the viewer's pinned repositories the way railFor does, | |
| 20 | // then adds the counts. Three reads per pinned repository, on the | |
| 21 | // dashboard only. | |
| 22 | func (s *Server) pinnedRows(viewer store.User) []pinnedRow { | |
| 23 | pinned, _ := s.st.PinnedRepos(viewer.ID) | |
| 24 | var rows []pinnedRow | |
| 25 | for _, rp := range pinned { | |
| 26 | grant, _ := s.st.AccessRole(rp.ID, viewer.ID) | |
| 27 | if !policy.CanRead(viewer, rp, grant) { | |
| 28 | continue | |
| 29 | } | |
| 30 | row := pinnedRow{Owner: rp.OwnerName, Name: rp.Name} | |
| 31 | row.Issues, row.MRs = s.st.OpenCounts(rp.ID) | |
| 32 | if builds, err := s.st.ListBuilds(rp.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 { | |
| 33 | row.Build = builds[0].Status | |
| 34 | } | |
| 35 | rows = append(rows, row) | |
| 36 | } | |
| 37 | return rows | |
| 38 | } | |
internal/httpd/dashpins_test.go added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/store" | |
| 8 | "gitbay.org/gitbay/internal/web" | |
| 9 | ) | |
| 10 | ||
| 11 | // The dashboard is three columns: pinned repositories with counts, the | |
| 12 | // tile strip and queue rows, the activity feed. Tiles carry every queue's | |
| 13 | // count; only a non-empty queue lists rows (desktop layout spec). | |
| 14 | func TestDashboardTilesAndPins(t *testing.T) { | |
| 15 | var sb strings.Builder | |
| 16 | var base basePage | |
| 17 | base.Viewer = "alice" | |
| 18 | err := web.Render(&sb, "dashboard.html", struct { | |
| 19 | basePage | |
| 20 | Tab string | |
| 21 | Pins []pinnedRow | |
| 22 | Reviews []store.DashboardItem | |
| 23 | Assigned []store.DashboardItem | |
| 24 | MRs []store.DashboardItem | |
| 25 | Issues []store.DashboardItem | |
| 26 | Feed []feedLine | |
| 27 | }{base, "dashboard", []pinnedRow{ | |
| 28 | {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}, | |
| 29 | {Owner: "krz", Name: "old", Build: "cancelled"}, | |
| 30 | }, nil, nil, nil, | |
| 31 | []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil}) | |
| 32 | if err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | out := sb.String() | |
| 36 | for _, want := range []string{ | |
| 37 | `<div class="dashgrid">`, | |
| 38 | `<aside class="dashpins" aria-label="Pinned repositories">`, | |
| 39 | `<span class="owner">krz/</span>gitbay</a>`, | |
| 40 | `<b class="wants">3 <span class="vh">open issues</span></b>`, | |
| 41 | `<b>0 <span class="vh">open merge requests</span></b>`, | |
| 42 | `<span class="dot ok"><span class="vh">last build success</span></span>`, | |
| 43 | // cancelled is neutral, not the pending orange | |
| 44 | `<span class="dot"><span class="vh">last build cancelled</span></span>`, | |
| 45 | `<a class="tile wants" href="#issues"><b>1</b><span>open issues</span></a>`, | |
| 46 | `<div class="tile"><b>0</b><span>waiting on your review</span></div>`, | |
| 47 | `<div class="tile"><b>0</b><span>assigned to you</span></div>`, | |
| 48 | `<div class="tile"><b>0</b><span>open merge requests</span></div>`, | |
| 49 | `<h2 id="issues">Open issues <span class="count">1</span></h2>`, | |
| 50 | `<aside class="feedcol" aria-label="Recent activity">`, | |
| 51 | } { | |
| 52 | if !strings.Contains(out, want) { | |
| 53 | t.Errorf("dashboard lacks %q", want) | |
| 54 | } | |
| 55 | } | |
| 56 | if strings.Contains(out, `<h2 class="empty">`) { | |
| 57 | t.Error("an empty queue still renders as a heading; the tile carries it") | |
| 58 | } | |
| 59 | // The tiles run in the queues' order, so the first tile does not jump | |
| 60 | // to the last section. | |
| 61 | at := 0 | |
| 62 | for _, label := range []string{"waiting on your review", "assigned to you", "open merge requests", "open issues"} { | |
| 63 | i := strings.Index(out, "<span>"+label+"</span>") | |
| 64 | if i < at { | |
| 65 | t.Errorf("tile %q is out of order", label) | |
| 66 | break | |
| 67 | } | |
| 68 | at = i | |
| 69 | } | |
| 70 | } | |
internal/httpd/facets.go added +74
| @@ -0,0 +1,74 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | ||
| 6 | "gitbay.org/gitbay/internal/store" | |
| 7 | ) | |
| 8 | ||
| 9 | // facetItem is one link in a list page's side column: a value the list | |
| 10 | // narrows to. Clicking an active item clears it. | |
| 11 | type facetItem struct { | |
| 12 | Label string | |
| 13 | Count int64 | |
| 14 | Href string | |
| 15 | Active bool | |
| 16 | } | |
| 17 | ||
| 18 | // facetGroup is one heading in the column: State, Labels, Milestones. | |
| 19 | type facetGroup struct { | |
| 20 | Title string | |
| 21 | Items []facetItem | |
| 22 | } | |
| 23 | ||
| 24 | // facetHref returns "?..." with every parameter of base kept, key set to | |
| 25 | // value, or dropped when value is "". url.Values encodes sorted, so the | |
| 26 | // tests and the links agree byte for byte. | |
| 27 | func facetHref(base url.Values, key, value string) string { | |
| 28 | q := url.Values{} | |
| 29 | for k, vs := range base { | |
| 30 | if k == key || len(vs) == 0 || vs[0] == "" { | |
| 31 | continue | |
| 32 | } | |
| 33 | q.Set(k, vs[0]) | |
| 34 | } | |
| 35 | if value != "" { | |
| 36 | q.Set(key, value) | |
| 37 | } | |
| 38 | return "?" + q.Encode() | |
| 39 | } | |
| 40 | ||
| 41 | // listFacets builds the issue or merge request list's column from the | |
| 42 | // active parameters, the states the page offers, and the repository's | |
| 43 | // labels and open milestones. Counts are the rows' own: a label's issue | |
| 44 | // count on the issue list, its MR count on the MR list. | |
| 45 | func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup { | |
| 46 | var st facetGroup | |
| 47 | st.Title = "State" | |
| 48 | for _, s := range states { | |
| 49 | st.Items = append(st.Items, facetItem{Label: s, Href: facetHref(base, "state", s), Active: s == state}) | |
| 50 | } | |
| 51 | lb := facetGroup{Title: "Labels"} | |
| 52 | for _, l := range labels { | |
| 53 | n := l.Issues | |
| 54 | if forMRs { | |
| 55 | n = l.MRs | |
| 56 | } | |
| 57 | active := base.Get("label") == l.Name | |
| 58 | href := facetHref(base, "label", l.Name) | |
| 59 | if active { | |
| 60 | href = facetHref(base, "label", "") | |
| 61 | } | |
| 62 | lb.Items = append(lb.Items, facetItem{Label: l.Name, Count: n, Href: href, Active: active}) | |
| 63 | } | |
| 64 | mg := facetGroup{Title: "Milestones"} | |
| 65 | for _, m := range ms { | |
| 66 | active := base.Get("milestone") == m.Title | |
| 67 | href := facetHref(base, "milestone", m.Title) | |
| 68 | if active { | |
| 69 | href = facetHref(base, "milestone", "") | |
| 70 | } | |
| 71 | mg.Items = append(mg.Items, facetItem{Label: m.Title, Count: int64(m.OpenItems), Href: href, Active: active}) | |
| 72 | } | |
| 73 | return []facetGroup{st, lb, mg} | |
| 74 | } | |
internal/httpd/facets_test.go added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/store" | |
| 8 | ) | |
| 9 | ||
| 10 | // A facet link keeps every other active filter, sets its own, and clears | |
| 11 | // its own when it is already active (desktop layout spec). | |
| 12 | func TestFacetHrefKeepsOtherFilters(t *testing.T) { | |
| 13 | base := url.Values{"state": {"open"}, "label": {"bug"}, "q": {"crash"}} | |
| 14 | if got := facetHref(base, "milestone", "v2"); got != "?label=bug&milestone=v2&q=crash&state=open" { | |
| 15 | t.Errorf("set: %q", got) | |
| 16 | } | |
| 17 | if got := facetHref(base, "label", ""); got != "?q=crash&state=open" { | |
| 18 | t.Errorf("clear: %q", got) | |
| 19 | } | |
| 20 | if got := facetHref(base, "state", "closed"); got != "?label=bug&q=crash&state=closed" { | |
| 21 | t.Errorf("replace: %q", got) | |
| 22 | } | |
| 23 | } | |
| 24 | ||
| 25 | func TestListFacetsGroups(t *testing.T) { | |
| 26 | base := url.Values{"state": {"open"}, "label": {"bug"}} | |
| 27 | labels := []store.Label{{Name: "bug", Issues: 2, MRs: 1}, {Name: "docs", Issues: 0, MRs: 3}} | |
| 28 | ms := []store.Milestone{{Title: "v2", OpenItems: 4}} | |
| 29 | groups := listFacets(base, []string{"open", "closed", "all"}, "open", labels, ms, false) | |
| 30 | if len(groups) != 3 || groups[0].Title != "State" || groups[1].Title != "Labels" || groups[2].Title != "Milestones" { | |
| 31 | t.Fatalf("groups: %+v", groups) | |
| 32 | } | |
| 33 | st := groups[0].Items | |
| 34 | if !st[0].Active || st[0].Href != "?label=bug&state=open" || st[1].Active || st[1].Href != "?label=bug&state=closed" { | |
| 35 | t.Errorf("state items: %+v", st) | |
| 36 | } | |
| 37 | lb := groups[1].Items | |
| 38 | if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" { | |
| 39 | t.Errorf("active label clears itself: %+v", lb[0]) | |
| 40 | } | |
| 41 | if lb[1].Label != "docs" || lb[1].Count != 0 || lb[1].Active || lb[1].Href != "?label=docs&state=open" { | |
| 42 | t.Errorf("inactive label: %+v", lb[1]) | |
| 43 | } | |
| 44 | if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" { | |
| 45 | t.Errorf("milestone: %+v", m) | |
| 46 | } | |
| 47 | // on the MR list a label's count is its MR count | |
| 48 | mr := listFacets(base, []string{"open"}, "open", labels, nil, true) | |
| 49 | if mr[1].Items[1].Count != 3 { | |
| 50 | t.Errorf("mr count: %+v", mr[1].Items[1]) | |
| 51 | } | |
| 52 | } | |
internal/httpd/filenav.go added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "path" | |
| 5 | "sort" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/gitutil" | |
| 8 | ) | |
| 9 | ||
| 10 | // fileNav is the column beside a file: its directory's entries, the file | |
| 11 | // marked, and a link up. It is the tree page's listing rendered as a | |
| 12 | // list, so reading a repository does not mean going back for each file. | |
| 13 | type fileNav struct { | |
| 14 | Title string // the directory, or the repository name at the root | |
| 15 | Parent string // URL of the parent tree; "" at the root | |
| 16 | Entries []fileNavEntry | |
| 17 | } | |
| 18 | ||
| 19 | type fileNavEntry struct { | |
| 20 | Name string // directories carry a trailing slash | |
| 21 | URL string | |
| 22 | Dir bool | |
| 23 | Current bool | |
| 24 | } | |
| 25 | ||
| 26 | // sortDirsFirst orders a listing by shape before name, stably, so each | |
| 27 | // group keeps the order git gave it. The tree page and the navigator | |
| 28 | // share it. | |
| 29 | func sortDirsFirst(entries []gitutil.TreeEntry) { | |
| 30 | sort.SliceStable(entries, func(i, j int) bool { | |
| 31 | return entries[i].Type == "tree" && entries[j].Type != "tree" | |
| 32 | }) | |
| 33 | } | |
| 34 | ||
| 35 | // navDir is the directory ListTree wants for filePath: "" at the root. | |
| 36 | func navDir(filePath string) string { | |
| 37 | if d := path.Dir(filePath); d != "." { | |
| 38 | return d | |
| 39 | } | |
| 40 | return "" | |
| 41 | } | |
| 42 | ||
| 43 | // fileNavFor builds the navigator for filePath from its directory's | |
| 44 | // entries. repoPath is owner/name. | |
| 45 | func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav { | |
| 46 | dir := path.Dir(filePath) | |
| 47 | if dir == "." { | |
| 48 | dir = "" | |
| 49 | } | |
| 50 | base := "/" + repoPath | |
| 51 | nav := fileNav{Title: dir} | |
| 52 | if dir == "" { | |
| 53 | nav.Title = path.Base(repoPath) | |
| 54 | } else if up := path.Dir(dir); up == "." { | |
| 55 | nav.Parent = base + "/tree/" + ref | |
| 56 | } else { | |
| 57 | nav.Parent = base + "/tree/" + ref + "/" + up | |
| 58 | } | |
| 59 | sortDirsFirst(entries) | |
| 60 | for _, e := range entries { | |
| 61 | full := path.Join(dir, e.Name) | |
| 62 | ent := fileNavEntry{Name: e.Name, Dir: e.Type == "tree", Current: full == filePath} | |
| 63 | if ent.Dir { | |
| 64 | ent.Name += "/" | |
| 65 | ent.URL = base + "/tree/" + ref + "/" + full | |
| 66 | } else { | |
| 67 | ent.URL = base + "/blob/" + ref + "/" + full | |
| 68 | } | |
| 69 | nav.Entries = append(nav.Entries, ent) | |
| 70 | } | |
| 71 | return nav | |
| 72 | } | |
internal/httpd/filenav_test.go added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "testing" | |
| 5 | ||
| 6 | "gitbay.org/gitbay/internal/gitutil" | |
| 7 | ) | |
| 8 | ||
| 9 | // The navigator lists the file's directory, directories first, links each | |
| 10 | // entry to its tree or blob page, marks the file itself, and links the | |
| 11 | // parent (the tree root when the file is at the top). | |
| 12 | func TestFileNavMarksCurrentAndLinksParent(t *testing.T) { | |
| 13 | entries := []gitutil.TreeEntry{ | |
| 14 | {Type: "blob", Name: "main.go"}, | |
| 15 | {Type: "tree", Name: "sub"}, | |
| 16 | {Type: "blob", Name: "util.go"}, | |
| 17 | } | |
| 18 | nav := fileNavFor("krz/gitbay", "main", "cmd/gitbay/util.go", entries) | |
| 19 | if nav.Title != "cmd/gitbay" { | |
| 20 | t.Errorf("title = %q", nav.Title) | |
| 21 | } | |
| 22 | if nav.Parent != "/krz/gitbay/tree/main/cmd" { | |
| 23 | t.Errorf("parent = %q", nav.Parent) | |
| 24 | } | |
| 25 | if len(nav.Entries) != 3 || nav.Entries[0].Name != "sub/" || !nav.Entries[0].Dir { | |
| 26 | t.Fatalf("entries not directories-first: %+v", nav.Entries) | |
| 27 | } | |
| 28 | if nav.Entries[0].URL != "/krz/gitbay/tree/main/cmd/gitbay/sub" { | |
| 29 | t.Errorf("dir url = %q", nav.Entries[0].URL) | |
| 30 | } | |
| 31 | if nav.Entries[2].Name != "util.go" || !nav.Entries[2].Current || nav.Entries[2].URL != "/krz/gitbay/blob/main/cmd/gitbay/util.go" { | |
| 32 | t.Errorf("current entry: %+v", nav.Entries[2]) | |
| 33 | } | |
| 34 | if nav.Entries[1].Current { | |
| 35 | t.Error("main.go marked current") | |
| 36 | } | |
| 37 | ||
| 38 | root := fileNavFor("krz/gitbay", "main", "Makefile", []gitutil.TreeEntry{{Type: "blob", Name: "Makefile"}}) | |
| 39 | if root.Title != "gitbay" || root.Parent != "" { | |
| 40 | t.Errorf("root nav: title %q parent %q", root.Title, root.Parent) | |
| 41 | } | |
| 42 | } | |
internal/httpd/mrsrow_test.go +1
| @@ -13,6 +13,7 @@ type mrsPageData struct { | ||
| 13 | 13 | State string |
| 14 | 14 | Query string |
| 15 | 15 | Filters []listFilter |
| 16 | Facets []facetGroup | |
| 16 | 17 | MRs []mrRow |
| 17 | 18 | Older string |
| 18 | 19 | } |
internal/httpd/page.go +1 −17
| @@ -3,21 +3,13 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | 5 | |
| 6 | "gitbay.org/gitbay/internal/policy" | |
| 7 | 6 | "gitbay.org/gitbay/internal/store" |
| 8 | 7 | ) |
| 9 | 8 | |
| 10 | // railRepo is one pinned repository in the rail. | |
| 11 | type railRepo struct { | |
| 12 | Owner string | |
| 13 | Name string | |
| 14 | } | |
| 15 | ||
| 16 | 9 | // rail is the viewer's cross-repo state the layout needs. The rail itself |
| 17 | 10 | // is icons only, so what is left is the unread count on its bell; the |
| 18 | 11 | // pinned repositories it used to list are rendered by the dashboard. |
| 19 | 12 | type rail struct { |
| 20 | Pinned []railRepo | |
| 21 | 13 | Unread int |
| 22 | 14 | } |
| 23 | 15 | |
| @@ -61,17 +53,9 @@ func (s *Server) baseFor(viewer store.User) basePage { | ||
| 61 | 53 | return b |
| 62 | 54 | } |
| 63 | 55 | |
| 64 | // railFor collects the viewer's pinned repositories and unread count, | |
| 65 | // dropping anything they may no longer read. | |
| 56 | // railFor collects the viewer's unread count. | |
| 66 | 57 | func (s *Server) railFor(viewer store.User) rail { |
| 67 | 58 | var rl rail |
| 68 | pinned, _ := s.st.PinnedRepos(viewer.ID) | |
| 69 | for _, rp := range pinned { | |
| 70 | grant, _ := s.st.AccessRole(rp.ID, viewer.ID) | |
| 71 | if policy.CanRead(viewer, rp, grant) { | |
| 72 | rl.Pinned = append(rl.Pinned, railRepo{Owner: rp.OwnerName, Name: rp.Name}) | |
| 73 | } | |
| 74 | } | |
| 75 | 59 | rl.Unread = s.st.UnreadNotices(viewer.ID) |
| 76 | 60 | return rl |
| 77 | 61 | } |
internal/httpd/repohead_test.go added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/control" | |
| 8 | "gitbay.org/gitbay/internal/web" | |
| 9 | ) | |
| 10 | ||
| 11 | // The repository header is two rows: identity with the description and | |
| 12 | // the buttons, then the tabs. The toggles hint is title text on the | |
| 13 | // buttons, not a line of its own (desktop layout spec). | |
| 14 | func TestRepoHeaderTwoRows(t *testing.T) { | |
| 15 | var sb strings.Builder | |
| 16 | p := testRepoPage() | |
| 17 | p.Viewer = "alice" | |
| 18 | p.Desc = "A CLI-first git forge." | |
| 19 | p.Topics = []string{"cli"} | |
| 20 | p.Tab = "files" | |
| 21 | err := web.Render(&sb, "builds.html", struct { | |
| 22 | repoPage | |
| 23 | Builds []control.BuildOut | |
| 24 | Jobs []control.JobOut | |
| 25 | Runs []buildRun | |
| 26 | Filter buildFilter | |
| 27 | Facets []facetGroup | |
| 28 | Refs []string | |
| 29 | CanWrite bool | |
| 30 | Notice string | |
| 31 | }{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""}) | |
| 32 | if err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | out := sb.String() | |
| 36 | if strings.Contains(out, `class="toggles"`) || strings.Contains(out, "Pinned shows on your dashboard.") { | |
| 37 | t.Error("the toggles hint still renders as a line") | |
| 38 | } | |
| 39 | for _, want := range []string{ | |
| 40 | `title="Pinned repositories show on your dashboard"`, | |
| 41 | `title="Watching sends every issue, request and build to your inbox"`, | |
| 42 | `title="Bookmarked lists it under Bookmarks"`, | |
| 43 | `<p class="repodesc">A CLI-first git forge.`, | |
| 44 | } { | |
| 45 | if !strings.Contains(out, want) { | |
| 46 | t.Errorf("header lacks %q", want) | |
| 47 | } | |
| 48 | } | |
| 49 | // the description sits inside the identity row, before the buttons | |
| 50 | if strings.Index(out, `class="repodesc"`) > strings.Index(out, `action="/krz/gitbay/pin"`) { | |
| 51 | t.Error("description renders after the buttons; it belongs in the identity row") | |
| 52 | } | |
| 53 | } | |
internal/httpd/topics.go added +40
| @@ -0,0 +1,40 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 4 | "net/url" | |
| 5 | "sort" | |
| 6 | ) | |
| 7 | ||
| 8 | // topicFacets counts the topics across repos for explore's column. The | |
| 9 | // links are the ones topic chips already use, ?q=<topic>; the active | |
| 10 | // topic links to explore with no query. | |
| 11 | func topicFacets(repos []describedRepo, q string) facetGroup { | |
| 12 | counts := map[string]int64{} | |
| 13 | for _, r := range repos { | |
| 14 | for _, t := range r.Topics { | |
| 15 | counts[t]++ | |
| 16 | } | |
| 17 | } | |
| 18 | names := make([]string, 0, len(counts)) | |
| 19 | for t := range counts { | |
| 20 | names = append(names, t) | |
| 21 | } | |
| 22 | sort.Slice(names, func(i, j int) bool { | |
| 23 | if counts[names[i]] != counts[names[j]] { | |
| 24 | return counts[names[i]] > counts[names[j]] | |
| 25 | } | |
| 26 | return names[i] < names[j] | |
| 27 | }) | |
| 28 | if len(names) > 20 { | |
| 29 | names = names[:20] | |
| 30 | } | |
| 31 | g := facetGroup{Title: "Topics"} | |
| 32 | for _, t := range names { | |
| 33 | item := facetItem{Label: t, Count: counts[t], Href: "/explore?q=" + url.QueryEscape(t), Active: t == q} | |
| 34 | if item.Active { | |
| 35 | item.Href = "/explore" | |
| 36 | } | |
| 37 | g.Items = append(g.Items, item) | |
| 38 | } | |
| 39 | return g | |
| 40 | } | |
internal/httpd/topics_test.go added +34
| @@ -0,0 +1,34 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import "testing" | |
| 4 | ||
| 5 | // Explore's column counts topics across the visible repositories, most | |
| 6 | // used first then by name, capped at twenty, each linking to ?q=<topic> | |
| 7 | // and the active one clearing the query (desktop layout spec). | |
| 8 | func TestTopicFacets(t *testing.T) { | |
| 9 | repos := []describedRepo{ | |
| 10 | {Topics: []string{"cli", "git"}}, | |
| 11 | {Topics: []string{"git", "swift"}}, | |
| 12 | {Topics: []string{"git"}}, | |
| 13 | } | |
| 14 | g := topicFacets(repos, "cli") | |
| 15 | if g.Title != "Topics" || len(g.Items) != 3 { | |
| 16 | t.Fatalf("group: %+v", g) | |
| 17 | } | |
| 18 | if g.Items[0].Label != "git" || g.Items[0].Count != 3 || g.Items[0].Href != "/explore?q=git" { | |
| 19 | t.Errorf("git: %+v", g.Items[0]) | |
| 20 | } | |
| 21 | if g.Items[1].Label != "cli" || !g.Items[1].Active || g.Items[1].Href != "/explore" { | |
| 22 | t.Errorf("cli: %+v", g.Items[1]) | |
| 23 | } | |
| 24 | if g.Items[2].Label != "swift" || g.Items[2].Count != 1 { | |
| 25 | t.Errorf("swift: %+v", g.Items[2]) | |
| 26 | } | |
| 27 | var many []describedRepo | |
| 28 | for i := 0; i < 30; i++ { | |
| 29 | many = append(many, describedRepo{Topics: []string{string(rune('a' + i))}}) | |
| 30 | } | |
| 31 | if n := len(topicFacets(many, "").Items); n != 20 { | |
| 32 | t.Errorf("cap: %d", n) | |
| 33 | } | |
| 34 | } | |
internal/httpd/web.go +31 −16
| @@ -20,7 +20,6 @@ import ( | ||
| 20 | 20 | "net/url" |
| 21 | 21 | "path" |
| 22 | 22 | "regexp" |
| 23 | "sort" | |
| 24 | 23 | "strconv" |
| 25 | 24 | "strings" |
| 26 | 25 | "time" |
| @@ -191,12 +190,13 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | ||
| 191 | 190 | s.render(w, "dashboard.html", struct { |
| 192 | 191 | basePage |
| 193 | 192 | Tab string |
| 193 | Pins []pinnedRow | |
| 194 | 194 | Reviews []store.DashboardItem |
| 195 | 195 | Assigned []store.DashboardItem |
| 196 | 196 | MRs []store.DashboardItem |
| 197 | 197 | Issues []store.DashboardItem |
| 198 | 198 | Feed []feedLine |
| 199 | }{s.baseFor(viewer), "dashboard", reviews, assigned, mrs, issues, feedLines(events)}) | |
| 199 | }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)}) | |
| 200 | 200 | } |
| 201 | 201 | |
| 202 | 202 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
| @@ -210,12 +210,14 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | ||
| 210 | 210 | viewer = s.viewer(r) |
| 211 | 211 | } |
| 212 | 212 | q := strings.TrimSpace(r.URL.Query().Get("q")) |
| 213 | described := s.describeAll(repos) | |
| 213 | 214 | s.render(w, "explore.html", struct { |
| 214 | 215 | basePage |
| 215 | Tab string | |
| 216 | Query string | |
| 217 | Repos []describedRepo | |
| 218 | }{s.baseFor(viewer), "explore", q, s.filterRepos(q, s.describeAll(repos))}) | |
| 216 | Tab string | |
| 217 | Query string | |
| 218 | Facets []facetGroup | |
| 219 | Repos []describedRepo | |
| 220 | }{s.baseFor(viewer), "explore", q, []facetGroup{topicFacets(described, q)}, s.filterRepos(q, described)}) | |
| 219 | 221 | } |
| 220 | 222 | |
| 221 | 223 | // privacy renders the privacy page: what the gitbay software does with |
| @@ -520,12 +522,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, | ||
| 520 | 522 | s.notFound(w, r) |
| 521 | 523 | return |
| 522 | 524 | } |
| 523 | // Directories first. git's tree order interleaves them with files, but | |
| 524 | // a listing is scanned by shape before name. Stable, so each group | |
| 525 | // keeps the ordering git gave it. | |
| 526 | sort.SliceStable(entries, func(i, j int) bool { | |
| 527 | return entries[i].Type == "tree" && entries[j].Type != "tree" | |
| 528 | }) | |
| 525 | sortDirsFirst(entries) | |
| 529 | 526 | prefix := "" |
| 530 | 527 | if dirPath != "" { |
| 531 | 528 | prefix = dirPath + "/" |
| @@ -590,6 +587,8 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 590 | 587 | cs = cs[:len(cs)-1] |
| 591 | 588 | } |
| 592 | 589 | branches, _ := gitutil.Refs(p.Dir, "heads") |
| 590 | navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath)) | |
| 591 | nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries) | |
| 593 | 592 | lines := 0 |
| 594 | 593 | if !binary && !image && len(data) > 0 { |
| 595 | 594 | lines = bytes.Count(data, []byte("\n")) |
| @@ -618,8 +617,9 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 618 | 617 | Renderable bool // markdown or org: the toggle is offered |
| 619 | 618 | Rendered bool // this response shows the rendering |
| 620 | 619 | RenderedHTML template.HTML |
| 620 | Nav fileNav | |
| 621 | 621 | }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines, |
| 622 | entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML}) | |
| 622 | entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML, nav}) | |
| 623 | 623 | } |
| 624 | 624 | |
| 625 | 625 | // releases lists tag-anchored releases with notes and assets. |
| @@ -923,6 +923,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) { | ||
| 923 | 923 | base = cs[len(cs)-1].Name |
| 924 | 924 | cs = cs[:len(cs)-1] |
| 925 | 925 | } |
| 926 | navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath)) | |
| 927 | nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries) | |
| 926 | 928 | s.render(w, "blame.html", struct { |
| 927 | 929 | repoPage |
| 928 | 930 | Crumbs []crumb |
| @@ -931,7 +933,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) { | ||
| 931 | 933 | Binary bool |
| 932 | 934 | Hunks []hunkView |
| 933 | 935 | Page, Pages int |
| 934 | }{p, cs, base, filePath, binary, hunks, page, pages}) | |
| 936 | Nav fileNav | |
| 937 | }{p, cs, base, filePath, binary, hunks, page, pages, nav}) | |
| 935 | 938 | } |
| 936 | 939 | |
| 937 | 940 | type numberedLine struct { |
| @@ -1737,18 +1740,24 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | ||
| 1737 | 1740 | issues[i].Labels = labels[issues[i].ID] |
| 1738 | 1741 | } |
| 1739 | 1742 | } |
| 1743 | base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}} | |
| 1744 | readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo) | |
| 1745 | allLabels, _ := s.st.ListLabels(p.Repo, readable) | |
| 1746 | openMS, _ := s.st.ListMilestones(p.Repo, "open", readable) | |
| 1747 | facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false) | |
| 1740 | 1748 | s.render(w, "issues.html", struct { |
| 1741 | 1749 | repoPage |
| 1742 | 1750 | State string |
| 1743 | 1751 | Label string |
| 1744 | 1752 | Query string |
| 1745 | 1753 | Filters []listFilter |
| 1754 | Facets []facetGroup | |
| 1746 | 1755 | Issues []store.Issue |
| 1747 | 1756 | LabelColors map[string]template.CSS |
| 1748 | 1757 | Older string |
| 1749 | 1758 | }{p, state, f.Label, f.Search, |
| 1750 | 1759 | activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}), |
| 1751 | issues, s.labelColors(p.Repo), older}) | |
| 1760 | facets, issues, s.labelColors(p.Repo), older}) | |
| 1752 | 1761 | } |
| 1753 | 1762 | |
| 1754 | 1763 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { |
| @@ -1900,17 +1909,23 @@ func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { | ||
| 1900 | 1909 | m.Labels = labels[m.ID] |
| 1901 | 1910 | rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]} |
| 1902 | 1911 | } |
| 1912 | base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}} | |
| 1913 | readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo) | |
| 1914 | allLabels, _ := s.st.ListLabels(p.Repo, readable) | |
| 1915 | openMS, _ := s.st.ListMilestones(p.Repo, "open", readable) | |
| 1916 | facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true) | |
| 1903 | 1917 | s.render(w, "mrs.html", struct { |
| 1904 | 1918 | repoPage |
| 1905 | 1919 | State string |
| 1906 | 1920 | Query string |
| 1907 | 1921 | Filters []listFilter |
| 1922 | Facets []facetGroup | |
| 1908 | 1923 | MRs []mrRow |
| 1909 | 1924 | LabelColors map[string]template.CSS |
| 1910 | 1925 | Older string |
| 1911 | 1926 | }{p, state, mf.Search, |
| 1912 | 1927 | activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}), |
| 1913 | rows, s.labelColors(p.Repo), older}) | |
| 1928 | facets, rows, s.labelColors(p.Repo), older}) | |
| 1914 | 1929 | } |
| 1915 | 1930 | |
| 1916 | 1931 | func (s *Server) mr(w http.ResponseWriter, r *http.Request) { |
internal/web/layout_test.go added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | package web | |
| 2 | ||
| 3 | import ( | |
| 4 | "strings" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // The repository header, main and footer share one centered container: | |
| 9 | // the header's inner content is wrapped, and the stylesheet caps and | |
| 10 | // centers all three on the same token (desktop layout spec). | |
| 11 | func TestSharedCenteredContainer(t *testing.T) { | |
| 12 | layout, err := templateFS.ReadFile("templates/layout.html") | |
| 13 | if err != nil { | |
| 14 | t.Fatal(err) | |
| 15 | } | |
| 16 | if !strings.Contains(string(layout), "<header class=\"repohead\">\n<div class=\"wrap\">") { | |
| 17 | t.Fatalf("repohead is not wrapped in .wrap") | |
| 18 | } | |
| 19 | css := string(StyleCSS) | |
| 20 | for _, want := range []string{ | |
| 21 | "--container: 100rem;", | |
| 22 | "main.content, main.wide, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }", | |
| 23 | ".repohead .wrap { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; padding: 0 var(--sp-6); }", | |
| 24 | "main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }", | |
| 25 | "main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }", | |
| 26 | } { | |
| 27 | if !strings.Contains(css, want) { | |
| 28 | t.Errorf("style.css lacks %q", want) | |
| 29 | } | |
| 30 | } | |
| 31 | } | |
internal/web/static/style.css +163 −22
| @@ -125,6 +125,9 @@ | ||
| 125 | 125 | --rail-mark: 24px; |
| 126 | 126 | --rail-mark-box: 32px; |
| 127 | 127 | |
| 128 | /* the one page container: header content, main and footer align on it */ | |
| 129 | --container: 100rem; | |
| 130 | ||
| 128 | 131 | --r-ctl: 4px; /* controls and chips */ |
| 129 | 132 | --r-card: 6px; /* cards, lists, code blocks */ |
| 130 | 133 | --shadow: 0 4px 16px rgba(0, 0, 0, 0.12); |
| @@ -372,9 +375,11 @@ main.content { | ||
| 372 | 375 | width: 100%; |
| 373 | 376 | padding: var(--sp-5) var(--sp-6) var(--sp-7); |
| 374 | 377 | } |
| 375 | main.wide { max-width: none; } | |
| 376 | main.reading { max-width: calc(72rem + 2 * var(--sp-6)); } | |
| 377 | main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); } | |
| 378 | /* the container is the default width and what `wide` names; reading and | |
| 379 | bounded follow, so equal specificity gives them the cascade */ | |
| 380 | main.content, main.wide, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; } | |
| 381 | main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; } | |
| 382 | main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; } | |
| 378 | 383 | footer { |
| 379 | 384 | width: 100%; |
| 380 | 385 | padding: var(--sp-4) var(--sp-6); |
| @@ -388,9 +393,10 @@ footer a:hover { color: var(--link); } | ||
| 388 | 393 | |
| 389 | 394 | /* ---- repository header ---- */ |
| 390 | 395 | .repohead { |
| 391 | padding: var(--sp-4) var(--sp-6) 0; | |
| 396 | padding: var(--sp-4) 0 0; | |
| 392 | 397 | border-bottom: 1px solid var(--line); |
| 393 | 398 | } |
| 399 | .repohead .wrap { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; padding: 0 var(--sp-6); } | |
| 394 | 400 | .identity { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } |
| 395 | 401 | .grow { flex: 1; } |
| 396 | 402 | /* h1 on the repo home, p elsewhere so each page keeps its own h1 — but |
| @@ -407,13 +413,15 @@ footer a:hover { color: var(--link); } | ||
| 407 | 413 | .repotitle a:hover { color: var(--link); text-decoration: none; } |
| 408 | 414 | .repotitle .sep { color: var(--muted); margin: 0 2px; font-weight: 400; } |
| 409 | 415 | .repodesc { |
| 410 | margin: var(--sp-2) 0 0; | |
| 411 | color: var(--fg); | |
| 416 | margin: 0 0 0 var(--sp-2); | |
| 417 | color: var(--muted); | |
| 412 | 418 | font-size: var(--fs-2); |
| 413 | display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; | |
| 419 | display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; | |
| 420 | min-width: 0; | |
| 414 | 421 | } |
| 422 | .repodesc a.site { color: var(--muted); } | |
| 423 | .repodesc a.site:hover { color: var(--link); } | |
| 415 | 424 | .repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); } |
| 416 | .toggles { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-1); } | |
| 417 | 425 | |
| 418 | 426 | nav.tabs { |
| 419 | 427 | display: flex; |
| @@ -713,7 +721,6 @@ form.setform input[type="text"], form.setform input[type="number"], form.setform | ||
| 713 | 721 | form.setform > button, form.setform > .btngroup { justify-self: start; } |
| 714 | 722 | form.setform.stack { grid-template-columns: 1fr; } |
| 715 | 723 | form.setform.stack textarea, form.setform.stack select { width: 100%; max-width: 48rem; } |
| 716 | nav.sections { display: flex; gap: var(--sp-4); flex-wrap: wrap; font-size: var(--fs-2); margin-bottom: var(--sp-5); } | |
| 717 | 724 | ul.protlist { list-style: none; margin: var(--sp-2) 0; padding: 0; } |
| 718 | 725 | ul.protlist li { |
| 719 | 726 | display: flex; |
| @@ -857,6 +864,8 @@ tr.tipbar td { | ||
| 857 | 864 | |
| 858 | 865 | .readme { margin-top: var(--sp-5); } |
| 859 | 866 | .readme .cardbody { max-width: 78ch; } |
| 867 | /* the overview README caps as a card, so prose and code share an edge */ | |
| 868 | .overview .readme { max-width: 88ch; } | |
| 860 | 869 | /* until every card wraps its body in .cardbody, the rendered block is |
| 861 | 870 | the body */ |
| 862 | 871 | .readme > .rendered { padding: var(--sp-4) var(--sp-5); } |
| @@ -890,6 +899,10 @@ ul.loglist .subject a { color: var(--fg); font-weight: 500; } | ||
| 890 | 899 | ul.loglist .subject a:hover { color: var(--link); } |
| 891 | 900 | ul.loglist .commitside { flex: none; display: flex; gap: var(--sp-2); align-items: center; white-space: nowrap; } |
| 892 | 901 | |
| 902 | ul.loglist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); } | |
| 903 | ul.loglist.rows .commitmain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; } | |
| 904 | ul.loglist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; } | |
| 905 | ||
| 893 | 906 | ul.repolist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; } |
| 894 | 907 | ul.repolist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } |
| 895 | 908 | ul.repolist li:last-child { border-bottom: 0; } |
| @@ -902,6 +915,12 @@ ul.repolist .reponame .sep { color: var(--muted); margin: 0 2px; } | ||
| 902 | 915 | ul.repolist .desc { margin-top: 2px; } |
| 903 | 916 | ul.repolist .topics, ul.repolist .meta { margin-top: var(--sp-1); } |
| 904 | 917 | |
| 918 | ul.repolist.rows li { display: flex; align-items: baseline; gap: var(--sp-3); padding: var(--sp-2) var(--sp-4); } | |
| 919 | ul.repolist.rows .reponame { flex: none; font-size: var(--fs-2); } | |
| 920 | 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; } | |
| 921 | ul.repolist.rows .topics, ul.repolist.rows .meta { flex: none; margin: 0; white-space: nowrap; } | |
| 922 | ul.repolist.rows .meta { font-size: var(--fs-1); } | |
| 923 | ||
| 905 | 924 | /* pinned repos: a compact wrap of names, no description or meta */ |
| 906 | 925 | ul.pinlist { list-style: none; margin: var(--sp-3) 0 var(--sp-5); padding: 0; display: flex; flex-wrap: wrap; gap: var(--sp-2); } |
| 907 | 926 | ul.pinlist li { |
| @@ -932,6 +951,15 @@ ul.issuelist .issueside { display: flex; gap: var(--sp-2); align-items: center; | ||
| 932 | 951 | ul.issuelist .title a { color: var(--fg); font-weight: 500; } |
| 933 | 952 | ul.issuelist .title a:hover { color: var(--link); } |
| 934 | 953 | |
| 954 | /* one-line rows: title, labels, then the meta pushed right. Above 64rem | |
| 955 | a list is a table, not prose (desktop layout spec). */ | |
| 956 | ul.issuelist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); } | |
| 957 | ul.issuelist.rows .issuemain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; } | |
| 958 | ul.issuelist.rows .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 959 | ul.issuelist.rows .title .chip { margin-left: var(--sp-1); } | |
| 960 | ul.issuelist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; } | |
| 961 | ul.issuelist.rows .meta .repo { color: var(--fg); } | |
| 962 | ||
| 935 | 963 | ul.milestonelist { list-style: none; margin: var(--sp-3) 0; padding: 0; } |
| 936 | 964 | ul.milestonelist li { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--faint); } |
| 937 | 965 | ul.milestonelist li:last-child { border-bottom: 0; } |
| @@ -1015,12 +1043,50 @@ pre.message { | ||
| 1015 | 1043 | .blobimage { text-align: center; } |
| 1016 | 1044 | .blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); } |
| 1017 | 1045 | |
| 1018 | /* pinned repositories, on the dashboard now that the rail is icons only */ | |
| 1019 | .pinned { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-4); } | |
| 1020 | .pinned .owner { color: var(--muted); } | |
| 1046 | /* ---- dashboard: pinned column, tiles and queues, feed ---- */ | |
| 1047 | .dashgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; } | |
| 1048 | .dashgrid h1 { margin-top: 0; } | |
| 1049 | .dashgrid > aside { position: sticky; top: var(--sp-5); } | |
| 1050 | .dashgrid .dashmain h2 { margin-top: var(--sp-5); } | |
| 1051 | .dashgrid .dashmain .tiles + h2 { margin-top: 0; } | |
| 1052 | .colhead { | |
| 1053 | margin: 0 0 var(--sp-2); | |
| 1054 | font-size: var(--fs-0); | |
| 1055 | font-weight: 500; | |
| 1056 | letter-spacing: 0.08em; | |
| 1057 | text-transform: uppercase; | |
| 1058 | color: var(--muted); | |
| 1059 | } | |
| 1060 | .pins { list-style: none; margin: 0; padding: 0; font-size: var(--fs-2); } | |
| 1061 | .pins li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; border-bottom: 1px solid var(--faint); } | |
| 1062 | .pins li:last-child { border-bottom: 0; } | |
| 1063 | .pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 1064 | .pins a:hover { color: var(--link); } | |
| 1065 | .pins .owner { color: var(--muted); } | |
| 1066 | .pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; } | |
| 1067 | .pins .n b { font-weight: 600; color: var(--fg); } | |
| 1068 | .pins .n b.wants { color: var(--warn); } | |
| 1069 | .pins .dot { margin: 0; } | |
| 1070 | /* count tiles: the queues' sizes in one strip; a non-zero count is orange, | |
| 1071 | what wants you */ | |
| 1072 | .tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin: var(--sp-4) 0 var(--sp-5); } | |
| 1073 | .tile { | |
| 1074 | display: block; | |
| 1075 | background: var(--surface); | |
| 1076 | border: 1px solid var(--line); | |
| 1077 | border-radius: var(--r-card); | |
| 1078 | padding: var(--sp-3) var(--sp-4); | |
| 1079 | color: var(--fg); | |
| 1080 | } | |
| 1081 | a.tile:hover { background: var(--hover); text-decoration: none; } | |
| 1082 | .tile b { display: block; font-size: var(--fs-5); font-weight: 600; line-height: 1.2; } | |
| 1083 | .tile.wants b { color: var(--warn); } | |
| 1084 | .tile span { font-size: var(--fs-1); color: var(--muted); } | |
| 1085 | .feedcol .feedline { font-size: var(--fs-2); margin-bottom: var(--sp-2); } | |
| 1086 | .feedcol .feedline .none { font-size: var(--fs-1); } | |
| 1021 | 1087 | |
| 1022 | 1088 | /* ---- two-column: conversation and aside ---- */ |
| 1023 | .withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; } | |
| 1089 | .withaside { display: grid; grid-template-columns: minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; } | |
| 1024 | 1090 | .withaside .mainside { min-width: 0; } |
| 1025 | 1091 | aside.aside { font-size: var(--fs-2); } |
| 1026 | 1092 | .aside .grp { border-top: 1px solid var(--line); padding: var(--sp-3) 0; } |
| @@ -1049,11 +1115,6 @@ form.actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var | ||
| 1049 | 1115 | form.actions button { flex: 1 1 auto; } |
| 1050 | 1116 | form.actions select, form.actions textarea { width: 100%; } |
| 1051 | 1117 | .feedline { margin-bottom: var(--sp-2); } |
| 1052 | /* the dashboard's activity feed is a section after the queues, one column */ | |
| 1053 | .feed { margin-top: var(--sp-6); max-width: 78ch; } | |
| 1054 | .feed h2 { margin-bottom: var(--sp-3); } | |
| 1055 | .feed .feedline { font-size: var(--fs-2); } | |
| 1056 | .feed .none { color: var(--muted); font-size: var(--fs-1); } | |
| 1057 | 1118 | |
| 1058 | 1119 | /* ---- comments and threads ---- */ |
| 1059 | 1120 | article.comment { |
| @@ -1311,6 +1372,53 @@ a.memberchip .role { color: var(--muted); } | ||
| 1311 | 1372 | .crumbs { color: var(--muted); } |
| 1312 | 1373 | .crumbs a { color: var(--link); } |
| 1313 | 1374 | .crumbs strong { color: var(--fg); } |
| 1375 | ||
| 1376 | /* ---- file navigator: the directory beside a file ---- */ | |
| 1377 | .blobgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; } | |
| 1378 | .blobmain { min-width: 0; } | |
| 1379 | .filenav { position: sticky; top: var(--sp-4); font-size: var(--fs-2); } | |
| 1380 | .filenav ul { list-style: none; margin: 0; padding: 0; } | |
| 1381 | .filenav li a { | |
| 1382 | display: block; | |
| 1383 | padding: 3px var(--sp-2); | |
| 1384 | border-radius: var(--r-ctl); | |
| 1385 | color: var(--fg); | |
| 1386 | font-family: var(--mono); | |
| 1387 | font-size: var(--fs-1); | |
| 1388 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 1389 | } | |
| 1390 | .filenav li a:hover { background: var(--hover); text-decoration: none; } | |
| 1391 | .filenav li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 600; } | |
| 1392 | .filenav li a.dir { color: var(--link); } | |
| 1393 | .filenav li a.up { color: var(--muted); } | |
| 1394 | ||
| 1395 | /* ---- side column: facets or sections beside a list or a form ---- */ | |
| 1396 | .withcol { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; } | |
| 1397 | .withcol.narrow { grid-template-columns: 15rem minmax(0, 56rem); } | |
| 1398 | .colmain { min-width: 0; } | |
| 1399 | .sidecol { position: sticky; top: var(--sp-4); font-size: var(--fs-2); } | |
| 1400 | .sidecol .grp { margin-bottom: var(--sp-4); } | |
| 1401 | .sidecol ul { list-style: none; margin: 0; padding: 0; } | |
| 1402 | .sidecol li a { | |
| 1403 | display: flex; align-items: baseline; gap: var(--sp-2); | |
| 1404 | padding: 4px var(--sp-2); | |
| 1405 | border-radius: var(--r-ctl); | |
| 1406 | color: var(--fg); | |
| 1407 | } | |
| 1408 | .sidecol li a:hover { background: var(--hover); text-decoration: none; } | |
| 1409 | .sidecol li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 500; } | |
| 1410 | .sidecol li a i { margin-left: auto; font-style: normal; color: var(--muted); font-size: var(--fs-1); font-variant-numeric: tabular-nums; } | |
| 1411 | .sidecol form.searchform { margin-top: var(--sp-2); } | |
| 1412 | .sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; } | |
| 1413 | ||
| 1414 | /* a sticky column taller than the viewport scrolls inside itself, or its | |
| 1415 | tail is unreachable once the page scrolls. Above 62rem only: below it | |
| 1416 | .filenav is hidden and the other two are static. .dashpins stops being | |
| 1417 | a column at 80rem and resets both properties there. */ | |
| 1418 | @media (min-width: 62.01rem) { | |
| 1419 | .filenav, .sidecol, .dashgrid > aside { max-height: calc(100vh - 2 * var(--sp-4)); overflow-y: auto; } | |
| 1420 | } | |
| 1421 | ||
| 1314 | 1422 | .refchip { |
| 1315 | 1423 | display: inline-flex; |
| 1316 | 1424 | align-items: center; |
| @@ -1364,7 +1472,7 @@ details.refmenu .refdrop a.allrefs { | ||
| 1364 | 1472 | /* the side column exists only above 62rem; below it .facts keeps its |
| 1365 | 1473 | own two-up layout and the 40rem factgrid rule applies untouched */ |
| 1366 | 1474 | @media (min-width: 62.01rem) { |
| 1367 | .overview.withfacts { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; } | |
| 1475 | .overview.withfacts { display: grid; grid-template-columns: minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; } | |
| 1368 | 1476 | .overview.withfacts .facts { display: block; margin-top: 0; max-width: none; } |
| 1369 | 1477 | .overview.withfacts .facts .factgrid { grid-template-columns: repeat(2, auto); } |
| 1370 | 1478 | .overview.withfacts .clone pre { white-space: pre-wrap; word-break: break-all; } |
| @@ -1444,7 +1552,6 @@ pre.quickstart { margin: 0 0 var(--sp-4); } | ||
| 1444 | 1552 | .profilehead .meta { margin: var(--sp-1) 0 0; } |
| 1445 | 1553 | .activity { margin-bottom: var(--sp-5); } |
| 1446 | 1554 | h2 .count { background: none; color: var(--muted); font-weight: 400; font-size: var(--fs-2); margin-left: var(--sp-2); padding: 0; } |
| 1447 | h2.empty { color: var(--muted); font-weight: 500; font-size: var(--fs-3); margin: var(--sp-2) 0; } | |
| 1448 | 1555 | /* 53 week columns x 7 day rows, tinted with the link blue */ |
| 1449 | 1556 | .actgraph-scroll { overflow-x: auto; padding-bottom: var(--sp-1); } |
| 1450 | 1557 | .actgraph { display: flex; gap: 3px; width: max-content; padding-top: 18px; } |
| @@ -1464,7 +1571,7 @@ h2.empty { color: var(--muted); font-weight: 500; font-size: var(--fs-3); margin | ||
| 1464 | 1571 | /* ---- wiki ---- */ |
| 1465 | 1572 | .wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; } |
| 1466 | 1573 | .wikipage { flex: 1; min-width: 0; } |
| 1467 | .wikinav { flex: none; width: 14rem; } | |
| 1574 | .wikinav { flex: none; width: 15rem; } | |
| 1468 | 1575 | .wikinav ul { list-style: none; margin: var(--sp-1) 0 var(--sp-4); padding: 0; } |
| 1469 | 1576 | .wikinav li a { |
| 1470 | 1577 | display: block; |
| @@ -1498,20 +1605,54 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1498 | 1605 | .review { font-size: var(--fs-2); } |
| 1499 | 1606 | |
| 1500 | 1607 | /* ---- breakpoints ---- */ |
| 1608 | @media (max-width: 80rem) { | |
| 1609 | .dashgrid { grid-template-columns: minmax(0, 1fr) 20rem; } | |
| 1610 | /* not a column any more, so it neither sticks nor scrolls in itself */ | |
| 1611 | .dashgrid > .dashpins { grid-column: 1 / -1; position: static; max-height: none; overflow: visible; } | |
| 1612 | /* a pinned block that is not a column is the chip row (spec rule 7) */ | |
| 1613 | .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); } | |
| 1614 | .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); } | |
| 1615 | .pins .n, .dashpins .meta { display: none; } | |
| 1616 | } | |
| 1617 | ||
| 1501 | 1618 | @media (max-width: 62rem) { |
| 1502 | 1619 | /* one column, the aside after the thread: a phone shows the |
| 1503 | 1620 | description first, checks and reviewers after it (#232) */ |
| 1504 | 1621 | .withaside { grid-template-columns: 1fr; } |
| 1622 | ||
| 1623 | /* rows go back to two lines where one does not fit */ | |
| 1624 | ul.issuelist.rows .issuemain, ul.repolist.rows li, ul.loglist.rows .commitmain { display: block; } | |
| 1625 | ul.issuelist.rows .title, ul.repolist.rows .desc { white-space: normal; } | |
| 1626 | /* a nowrap meta or topic line on one of these rows forces horizontal | |
| 1627 | page scroll on a phone: a long branch name, a long owner/repo#N */ | |
| 1628 | ul.issuelist.rows .meta, ul.loglist.rows .meta, | |
| 1629 | ul.repolist.rows .meta, ul.repolist.rows .topics { white-space: normal; } | |
| 1630 | ||
| 1631 | .dashgrid { grid-template-columns: 1fr; } | |
| 1632 | .dashgrid > aside { position: static; } | |
| 1633 | .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 1634 | ||
| 1635 | /* the tree page is the navigator on a phone */ | |
| 1636 | .blobgrid { grid-template-columns: 1fr; } | |
| 1637 | .filenav { display: none; } | |
| 1638 | ||
| 1639 | /* the column follows the content on a phone, the way the aside does */ | |
| 1640 | .withcol, .withcol.narrow { grid-template-columns: 1fr; } | |
| 1641 | .sidecol { position: static; order: 2; } | |
| 1642 | .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); } | |
| 1505 | 1643 | } |
| 1506 | 1644 | |
| 1507 | 1645 | @media (max-width: 52rem) { |
| 1508 | 1646 | main.content { padding: var(--sp-4) var(--sp-4) var(--sp-5); } |
| 1509 | 1647 | footer { padding: var(--sp-4); } |
| 1510 | .repohead { padding: var(--sp-3) var(--sp-4) 0; } | |
| 1648 | .repohead { padding: var(--sp-3) 0 0; } | |
| 1649 | .repohead .wrap { padding: 0 var(--sp-4); } | |
| 1511 | 1650 | /* seven tabs do not fit a phone in one row; a hidden scrollbar made the |
| 1512 | 1651 | ones past the edge look absent. Two rows show all of them. */ |
| 1513 | 1652 | nav.tabs { flex-wrap: wrap; overflow: visible; } |
| 1514 | 1653 | .facts { grid-template-columns: 1fr; } |
| 1654 | /* a phone shows the description under the name, not beside it */ | |
| 1655 | .repodesc { flex-basis: 100%; margin-left: 0; } | |
| 1515 | 1656 | } |
| 1516 | 1657 | |
| 1517 | 1658 | @media (max-width: 40rem) { |
internal/web/templates/account.html +34 −9
| @@ -1,11 +1,26 @@ | ||
| 1 | {{define "width"}}bounded{{end}} | |
| 1 | {{define "width"}}wide{{end}} | |
| 2 | 2 | {{define "title"}}account settings{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1>Account settings</h1> |
| 5 | 5 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 6 | 6 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} |
| 7 | 7 | |
| 8 | <h2>Profile</h2> | |
| 8 | <div class="withcol narrow"> | |
| 9 | <nav class="sidecol" aria-label="Sections"> | |
| 10 | <div class="grp"><h2 class="colhead">Sections</h2> | |
| 11 | <ul> | |
| 12 | <li><a href="#profile">Profile</a></li> | |
| 13 | <li><a href="#keys">SSH keys</a></li> | |
| 14 | <li><a href="#emails">Email addresses</a></li> | |
| 15 | <li><a href="#pgp">OpenPGP keys</a></li> | |
| 16 | <li><a href="#notifications">Notifications</a></li> | |
| 17 | <li><a href="#appearance">Appearance</a></li> | |
| 18 | <li><a href="#export">Export</a></li> | |
| 19 | <li><a href="#cli">On the command line</a></li> | |
| 20 | </ul></div> | |
| 21 | </nav> | |
| 22 | <div class="colmain"> | |
| 23 | <section id="profile"><h2>Profile</h2> | |
| 9 | 24 | <p class="meta">Shown on your profile page, <a href="/{{.Viewer}}">/{{.Viewer}}</a>.</p> |
| 10 | 25 | {{if .Draft.Is "about"}}{{template "previewblock" .Draft.HTML}}{{end}} |
| 11 | 26 | <form method="post" action="/settings" class="setform stack"> |
| @@ -21,8 +36,9 @@ | ||
| 21 | 36 | {{template "formatpicker" (.Draft.Or "about" "format" .Profile.AboutFormat)}} |
| 22 | 37 | <span class="btngroup"><button type="submit" class="btn">Save profile</button>{{template "previewbtn"}}</span> |
| 23 | 38 | </form> |
| 39 | </section> | |
| 24 | 40 | |
| 25 | <h2>SSH keys</h2> | |
| 41 | <section id="keys"><h2>SSH keys</h2> | |
| 26 | 42 | <p class="meta">Your keys are your identity here. A <code>full</code> key can run |
| 27 | 43 | commands and push; a <code>git</code> key can only move git data, which is what |
| 28 | 44 | a CI checkout wants.</p> |
| @@ -53,8 +69,9 @@ a CI checkout wants.</p> | ||
| 53 | 69 | <button type="submit" class="btn">Add key</button> |
| 54 | 70 | </form> |
| 55 | 71 | </details> |
| 72 | </section> | |
| 56 | 73 | |
| 57 | <h2>Email addresses</h2> | |
| 74 | <section id="emails"><h2>Email addresses</h2> | |
| 58 | 75 | <p class="meta">A verified address is what ties your signed commits to this |
| 59 | 76 | account, and where notifications go.</p> |
| 60 | 77 | {{if .Emails}}<ul class="plain"> |
| @@ -80,8 +97,9 @@ account, and where notifications go.</p> | ||
| 80 | 97 | <button type="submit" class="btn">Verify</button> |
| 81 | 98 | </form> |
| 82 | 99 | </details> |
| 100 | </section> | |
| 83 | 101 | |
| 84 | <h2>OpenPGP keys</h2> | |
| 102 | <section id="pgp"><h2>OpenPGP keys</h2> | |
| 85 | 103 | <p class="meta">Only needed if you sign commits with GPG. SSH signing |
| 86 | 104 | (<code>gpg.format = ssh</code>) uses the keys above.</p> |
| 87 | 105 | {{if .PGP}}<div class="tablewrap"><table class="keys"> |
| @@ -102,8 +120,9 @@ account, and where notifications go.</p> | ||
| 102 | 120 | <button type="submit" class="btn">Add key</button> |
| 103 | 121 | </form> |
| 104 | 122 | </details> |
| 123 | </section> | |
| 105 | 124 | |
| 106 | <h2>Notifications</h2> | |
| 125 | <section id="notifications"><h2>Notifications</h2> | |
| 107 | 126 | <form method="post" action="/settings" class="setform"> |
| 108 | 127 | <input type="hidden" name="field" value="notify-mail"> |
| 109 | 128 | <label for="notify-mail">Activity by mail</label> |
| @@ -118,8 +137,9 @@ account, and where notifications go.</p> | ||
| 118 | 137 | <button type="submit" class="btn">Save</button> |
| 119 | 138 | </form> |
| 120 | 139 | <p class="meta">Every issue and merge request on those repositories, as if you had watched each. A watch or mute on a repository still wins.</p> |
| 140 | </section> | |
| 121 | 141 | |
| 122 | <h2>Appearance</h2> | |
| 142 | <section id="appearance"><h2>Appearance</h2> | |
| 123 | 143 | <form method="post" action="/settings" class="setform"> |
| 124 | 144 | <input type="hidden" name="field" value="theme"> |
| 125 | 145 | <label for="theme">Colour scheme</label> |
| @@ -131,14 +151,16 @@ account, and where notifications go.</p> | ||
| 131 | 151 | <button type="submit" class="btn">Save</button> |
| 132 | 152 | </form> |
| 133 | 153 | <p class="meta">The same setting as <code>gitbay web theme set</code>. Following the browser takes its light or dark preference.</p> |
| 154 | </section> | |
| 134 | 155 | |
| 135 | <h2>Export</h2> | |
| 156 | <section id="export"><h2>Export</h2> | |
| 136 | 157 | <p class="meta">Your profile, repositories, issues and merge requests as one |
| 137 | 158 | JSON bundle, the same one <code>gitbay account export</code> writes. Keys are |
| 138 | 159 | never included; a replayed bundle's emails arrive unverified.</p> |
| 139 | 160 | <p><a href="/settings/export">Download bundle</a></p> |
| 161 | </section> | |
| 140 | 162 | |
| 141 | <h2>On the command line</h2> | |
| 163 | <section id="cli"><h2>On the command line</h2> | |
| 142 | 164 | <p class="meta">No page here yet, and nothing refusing one: a credential is |
| 143 | 165 | easier to pipe than to paste, and a minted token is shown once.</p> |
| 144 | 166 | <pre class="message">gitbay auth token create --name laptop # API tokens |
| @@ -146,4 +168,7 @@ gitbay web sessions list # browser sessions | ||
| 146 | 168 | gitbay admin ... # instance administration</pre> |
| 147 | 169 | <p class="meta">All of it works from stock OpenSSH too: |
| 148 | 170 | <code>ssh git@{{.Host}} auth whoami</code>.</p> |
| 171 | </section> | |
| 172 | </div> | |
| 173 | </div> | |
| 149 | 174 | {{end}} |
internal/web/templates/admin.html +26 −1
| @@ -1,46 +1,71 @@ | ||
| 1 | {{define "width"}}bounded{{end}} | |
| 1 | {{define "width"}}wide{{end}} | |
| 2 | 2 | {{define "title"}}admin · {{.Site}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1>Admin</h1> |
| 5 | 5 | <p class="meta">Server build {{.Commit}}. Every background worker's backlog, the same read as <code>gitbay dashboard --json</code> for an admin.</p> |
| 6 | 6 | <p class="meta"><a href="/admin/users">Accounts</a> — every account, with promote, demote, disable and enable.</p> |
| 7 | 7 | |
| 8 | <div class="withcol narrow"> | |
| 9 | <nav class="sidecol" aria-label="Sections"> | |
| 10 | <div class="grp"><h2 class="colhead">Sections</h2> | |
| 11 | <ul> | |
| 12 | <li><a href="#webhooks">Webhook deliveries</a></li> | |
| 13 | <li><a href="#mail">Mail</a></li> | |
| 14 | <li><a href="#mirrors">Mirrors</a></li> | |
| 15 | <li><a href="#builds">Builds</a></li> | |
| 16 | <li><a href="#deps">Dependency checks</a></li> | |
| 17 | </ul></div> | |
| 18 | </nav> | |
| 19 | <div class="colmain"> | |
| 20 | ||
| 8 | 21 | {{with .Queues.Webhooks}} |
| 22 | <section id="webhooks"> | |
| 9 | 23 | <h2>Webhook deliveries <span class="count">{{.Pending}}</span></h2> |
| 10 | 24 | <p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} dead-lettered{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> |
| 11 | 25 | {{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>URL</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody> |
| 12 | 26 | {{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.URL}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}dead-lettered {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{if .Status}}{{.Status}} {{end}}{{.LastError}}</td></tr> |
| 13 | 27 | {{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or dead-lettered</p>{{end}} |
| 28 | </section> | |
| 14 | 29 | {{end}} |
| 15 | 30 | |
| 16 | 31 | {{with .Queues.Mail}} |
| 32 | <section id="mail"> | |
| 17 | 33 | <h2>Mail <span class="count">{{.Pending}}</span></h2> |
| 18 | 34 | <p class="meta">{{.Pending}} pending · {{.Retrying}} retrying · {{.Failed}} failed{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> |
| 19 | 35 | {{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Mail</th><th>Recipient</th><th>Subject</th><th>Attempts</th><th>State</th><th>Last error</th></tr></thead><tbody> |
| 20 | 36 | {{range .Items}}<tr><td class="mono">{{.ID}}</td><td>{{.Recipient}}</td><td>{{.Subject}}</td><td>{{.Attempts}}</td><td>{{if .FailedAt}}failed {{when .FailedAt}}{{else}}retrying{{end}}</td><td>{{.LastError}}</td></tr> |
| 21 | 37 | {{end}}</tbody></table></div>{{else}}<p class="none">Nothing retrying or failed</p>{{end}} |
| 38 | </section> | |
| 22 | 39 | {{end}} |
| 23 | 40 | |
| 24 | 41 | {{with .Queues.Mirrors}} |
| 42 | <section id="mirrors"> | |
| 25 | 43 | <h2>Mirrors <span class="count">{{.Errors}}</span></h2> |
| 26 | 44 | <p class="meta">{{.Dirty}} waiting for a sync · {{.Errors}} with a failed last sync</p> |
| 27 | 45 | {{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Direction</th><th>URL</th><th>Last sync</th><th>Error</th></tr></thead><tbody> |
| 28 | 46 | {{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{.Direction}}</td><td>{{.URL}}</td><td>{{if .LastSync}}{{when .LastSync}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr> |
| 29 | 47 | {{end}}</tbody></table></div>{{else}}<p class="none">Every mirror's last sync succeeded</p>{{end}} |
| 48 | </section> | |
| 30 | 49 | {{end}} |
| 31 | 50 | |
| 32 | 51 | {{with .Queues.Builds}} |
| 52 | <section id="builds"> | |
| 33 | 53 | <h2>Builds <span class="count">{{.Pending}}</span></h2> |
| 34 | 54 | <p class="meta">{{.Pending}} pending · {{.Running}} running{{if .OldestPending}} · oldest pending {{when .OldestPending}}{{end}}</p> |
| 35 | 55 | {{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Build</th><th>Job</th><th>Status</th><th>Since</th></tr></thead><tbody> |
| 36 | 56 | {{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td><a href="/{{.Repo}}/builds/{{.Number}}">#{{.Number}}</a></td><td>{{.Job}}</td><td>{{.Status}}</td><td>{{if .StartedAt}}{{when .StartedAt}}{{else}}{{when .CreatedAt}}{{end}}</td></tr> |
| 37 | 57 | {{end}}</tbody></table></div>{{else}}<p class="none">No build running or pending</p>{{end}} |
| 58 | </section> | |
| 38 | 59 | {{end}} |
| 39 | 60 | |
| 40 | 61 | {{with .Queues.Deps}} |
| 62 | <section id="deps"> | |
| 41 | 63 | <h2>Dependency checks <span class="count">{{.Errors}}</span></h2> |
| 42 | 64 | {{if .Items}}<div class="tablewrap"><table class="keys"><thead><tr class="cols"><th>Repository</th><th>Last check</th><th>Error</th></tr></thead><tbody> |
| 43 | 65 | {{range .Items}}<tr><td><a href="/{{.Repo}}">{{.Repo}}</a></td><td>{{if .LastCheck}}{{when .LastCheck}}{{else}}never{{end}}</td><td>{{.LastError}}</td></tr> |
| 44 | 66 | {{end}}</tbody></table></div>{{else}}<p class="none">No check has failed</p>{{end}} |
| 67 | </section> | |
| 45 | 68 | {{end}} |
| 69 | </div> | |
| 70 | </div> | |
| 46 | 71 | {{end}} |
internal/web/templates/blame.html +5
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | {{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1 class="vh">Blame: {{.Path}}</h1> |
| 5 | <div class="blobgrid"> | |
| 6 | {{template "filenav" .Nav}} | |
| 7 | <div class="blobmain"> | |
| 5 | 8 | <div class="pathbar"> |
| 6 | 9 | <span class="refchip">{{template "branchicon"}} {{.Ref}}</span> |
| 7 | 10 | <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> |
| @@ -23,4 +26,6 @@ | ||
| 23 | 26 | </div> |
| 24 | 27 | {{if gt .Pages 1}}<p class="pager">page {{.Page}} of {{.Pages}}{{if gt .Page 1}} · <a href="?page={{sub .Page 1}}">← earlier lines</a>{{end}}{{if lt .Page .Pages}} · <a href="?page={{add .Page 1}}">later lines →</a>{{end}}</p>{{end}} |
| 25 | 28 | {{end}} |
| 29 | </div> | |
| 30 | </div> | |
| 26 | 31 | {{end}} |
internal/web/templates/blob.html +5
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | {{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1 class="vh">{{.Path}}</h1> |
| 5 | <div class="blobgrid"> | |
| 6 | {{template "filenav" .Nav}} | |
| 7 | <div class="blobmain"> | |
| 5 | 8 | <div class="pathbar"> |
| 6 | 9 | {{template "refmenu" .}} |
| 7 | 10 | <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> |
| @@ -13,4 +16,6 @@ | ||
| 13 | 16 | {{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> |
| 14 | 17 | {{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section> |
| 15 | 18 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} |
| 19 | </div> | |
| 20 | </div> | |
| 16 | 21 | {{end}} |
internal/web/templates/builds.html +14 −7
| @@ -1,19 +1,24 @@ | ||
| 1 | 1 | {{define "width"}}wide{{end}} |
| 2 | 2 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | <div class="listhead"> | |
| 5 | <h1>Builds</h1> | |
| 6 | <nav class="filters"> | |
| 7 | {{range .FilterLinks}}<a {{if .Active}}class="active" aria-current="page" {{end}}href="{{.Href}}">{{.Label}}</a>{{end}} | |
| 8 | </nav> | |
| 4 | <div class="withcol"> | |
| 5 | <nav class="sidecol" aria-label="Filters"> | |
| 6 | {{range .Facets}}{{if .Items}}<div class="grp"> | |
| 7 | <h2 class="colhead">{{.Title}}</h2> | |
| 8 | <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul> | |
| 9 | </div>{{end}}{{end}} | |
| 9 | 10 | <form method="get" class="searchform compact"> |
| 10 | <label for="ref">Branch</label> | |
| 11 | <label for="ref" class="colhead">Branch</label> | |
| 11 | 12 | <input type="text" id="ref" name="ref" value="{{.Filter.Ref}}" list="buildrefs"> |
| 12 | 13 | <datalist id="buildrefs">{{range .Refs}}<option value="{{.}}">{{end}}</datalist> |
| 13 | 14 | <input type="hidden" name="status" value="{{.Filter.Status}}"> |
| 14 | 15 | <input type="hidden" name="job" value="{{.Filter.Job}}"> |
| 15 | 16 | <button type="submit" class="btn">Filter</button> |
| 16 | 17 | </form> |
| 18 | </nav> | |
| 19 | <div class="colmain"> | |
| 20 | <div class="listhead"> | |
| 21 | <h1>Builds</h1> | |
| 17 | 22 | </div> |
| 18 | 23 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} |
| 19 | 24 | {{if and .CanWrite .Jobs}} |
| @@ -31,7 +36,7 @@ | ||
| 31 | 36 | <p class="meta">Add <code>?job=name</code> for one job.</p> |
| 32 | 37 | </details> |
| 33 | 38 | <p class="meta">{{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p> |
| 34 | <ul class="loglist"> | |
| 39 | <ul class="loglist rows"> | |
| 35 | 40 | {{range .Runs}}<li> |
| 36 | 41 | <div class="commitmain"> |
| 37 | 42 | <p class="subject"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{printf "%.10s" .SHA}}</a></code></p> |
| @@ -44,4 +49,6 @@ | ||
| 44 | 49 | </li> |
| 45 | 50 | {{else}}<li class="empty">no builds — push a commit with a <code>.gitbay/ci.yml</code></li>{{end}} |
| 46 | 51 | </ul> |
| 52 | </div> | |
| 53 | </div> | |
| 47 | 54 | {{end}} |
internal/web/templates/dashboard.html +33 −16
| @@ -1,37 +1,54 @@ | ||
| 1 | {{define "width"}}wide{{end}} | |
| 1 | 2 | {{define "title"}}dashboard · {{.Site}}{{end}} |
| 2 | 3 | {{define "itemlist"}} |
| 3 | <ul class="issuelist"> | |
| 4 | <ul class="issuelist rows"> | |
| 4 | 5 | {{range .Items}}<li> |
| 5 | 6 | <div class="issuemain"> |
| 6 | 7 | <p class="title"><a href="/{{.RepoPath}}/{{$.Kind}}/{{.Number}}">{{.Title}}</a></p> |
| 7 | <p class="meta">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}} · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> | |
| 8 | <p class="meta"><span class="repo">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}}</span> · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p> | |
| 8 | 9 | </div> |
| 9 | 10 | </li> |
| 10 | 11 | {{else}}<li class="empty">{{$.Empty}}</li>{{end}} |
| 11 | 12 | </ul> |
| 12 | 13 | {{end}} |
| 13 | 14 | {{define "queue"}} |
| 14 | <h2>{{$.Title}} <span class="count">{{len $.Items}}</span></h2> | |
| 15 | <h2 id="{{$.ID}}">{{$.Title}} <span class="count">{{len $.Items}}</span></h2> | |
| 15 | 16 | {{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}} |
| 16 | 17 | {{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}} |
| 17 | 18 | {{end}} |
| 19 | {{define "tile"}}{{if $.N}}<a class="tile wants" href="#{{$.ID}}"><b>{{$.N}}</b><span>{{$.Label}}</span></a>{{else}}<div class="tile"><b>0</b><span>{{$.Label}}</span></div>{{end}}{{end}} | |
| 18 | 20 | {{define "content"}} |
| 19 | <h1>Dashboard</h1> | |
| 20 | {{with .Rail.Pinned}}<p class="pinned" aria-label="Pinned repositories">{{range .}}<a class="chip" href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a> {{end}}</p>{{end}} | |
| 21 | <div class="dashgrid"> | |
| 21 | 22 | |
| 22 | {{if .Reviews}}{{template "queue" dict "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}} | |
| 23 | {{if .Assigned}}{{template "queue" dict "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}} | |
| 24 | {{if .MRs}}{{template "queue" dict "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 25 | {{if .Issues}}{{template "queue" dict "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 23 | <aside class="dashpins" aria-label="Pinned repositories"> | |
| 24 | <h2 class="colhead">Pinned</h2> | |
| 25 | {{if .Pins}}<ul class="pins"> | |
| 26 | {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}"><b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b> <span class="dot{{if eq .Build "success"}} ok{{else if or (eq .Build "failure") (eq .Build "error")}} bad{{else if or (eq .Build "pending") (eq .Build "running")}} pend{{end}}"><span class="vh">{{with .Build}}last build {{.}}{{else}}no builds{{end}}</span></span></span></li> | |
| 27 | {{end}}</ul> | |
| 28 | <p class="meta">issues · merge requests · last build</p> | |
| 29 | {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}} | |
| 30 | </aside> | |
| 26 | 31 | |
| 27 | {{if not .Reviews}}<h2 class="empty">Waiting on your review <span class="count">0</span></h2>{{end}} | |
| 28 | {{if not .Assigned}}<h2 class="empty">Assigned to you <span class="count">0</span></h2>{{end}} | |
| 29 | {{if not .MRs}}<h2 class="empty">Open merge requests <span class="count">0</span></h2>{{end}} | |
| 30 | {{if not .Issues}}<h2 class="empty">Open issues <span class="count">0</span></h2>{{end}} | |
| 32 | <section class="dashmain"> | |
| 33 | <h1>Dashboard</h1> | |
| 34 | <div class="tiles"> | |
| 35 | {{template "tile" dict "N" (len .Reviews) "ID" "reviews" "Label" "waiting on your review"}} | |
| 36 | {{template "tile" dict "N" (len .Assigned) "ID" "assigned" "Label" "assigned to you"}} | |
| 37 | {{template "tile" dict "N" (len .MRs) "ID" "mrs" "Label" "open merge requests"}} | |
| 38 | {{template "tile" dict "N" (len .Issues) "ID" "issues" "Label" "open issues"}} | |
| 39 | </div> | |
| 40 | {{if .Reviews}}{{template "queue" dict "ID" "reviews" "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}} | |
| 41 | {{if .Assigned}}{{template "queue" dict "ID" "assigned" "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}} | |
| 42 | {{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 43 | {{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}} | |
| 44 | {{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open anywhere you can write to.</p>{{end}} | |
| 45 | </section> | |
| 31 | 46 | |
| 32 | <section class="feed"> | |
| 33 | <h2>Recent activity</h2> | |
| 47 | <aside class="feedcol" aria-label="Recent activity"> | |
| 48 | <h2 class="colhead">Recent activity</h2> | |
| 34 | 49 | {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p> |
| 35 | 50 | {{else}}<p class="none">No activity yet</p>{{end}} |
| 36 | </section> | |
| 51 | </aside> | |
| 52 | ||
| 53 | </div> | |
| 37 | 54 | {{end}} |
internal/web/templates/edit.html +5
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | {{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> |
| 5 | <div class="blobgrid"> | |
| 6 | {{template "filenav" .Nav}} | |
| 7 | <div class="blobmain"> | |
| 5 | 8 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 6 | 9 | {{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}} |
| 7 | 10 | {{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}} |
| @@ -13,4 +16,6 @@ | ||
| 13 | 16 | <p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> |
| 14 | 17 | </form> |
| 15 | 18 | {{end}} |
| 19 | </div> | |
| 20 | </div> | |
| 16 | 21 | {{end}} |
internal/web/templates/explore.html +7 −1
| @@ -1,5 +1,9 @@ | ||
| 1 | {{define "width"}}wide{{end}} | |
| 1 | 2 | {{define "title"}}explore · {{.Site}}{{end}} |
| 2 | 3 | {{define "content"}} |
| 4 | <div class="withcol"> | |
| 5 | {{template "sidecol" .Facets}} | |
| 6 | <div class="colmain"> | |
| 3 | 7 | <div class="headrow"> |
| 4 | 8 | <h1>Explore</h1> |
| 5 | 9 | <form method="get" action="/explore" class="searchform compact"> |
| @@ -8,8 +12,10 @@ | ||
| 8 | 12 | </form> |
| 9 | 13 | <span class="spacer"></span> |
| 10 | 14 | </div> |
| 11 | <ul class="repolist"> | |
| 15 | <ul class="repolist rows"> | |
| 12 | 16 | {{range .Repos}}{{template "reporow" .}} |
| 13 | 17 | {{else}}<li class="empty">no public repositories yet</li>{{end}} |
| 14 | 18 | </ul> |
| 19 | </div> | |
| 20 | </div> | |
| 15 | 21 | {{end}} |
internal/web/templates/globalsearch.html +14 −7
| @@ -1,14 +1,19 @@ | ||
| 1 | 1 | {{define "width"}}wide{{end}} |
| 2 | 2 | {{define "title"}}search · {{.Site}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | <div class="withcol"> | |
| 5 | <nav class="sidecol" aria-label="Filters"> | |
| 6 | <div class="grp"><h2 class="colhead">Kind</h2> | |
| 7 | <ul> | |
| 8 | <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li> | |
| 9 | <li><a{{if eq .Kind "repo"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=repo">repositories</a></li> | |
| 10 | <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=issue">issues</a></li> | |
| 11 | <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=mr">merge requests</a></li> | |
| 12 | </ul></div> | |
| 13 | </nav> | |
| 14 | <div class="colmain"> | |
| 4 | 15 | <div class="listhead"> |
| 5 | 16 | <h1>Search</h1> |
| 6 | <nav class="filters"> | |
| 7 | <a {{if eq .Kind ""}}class="active" aria-current="page" {{end}}href="?q={{.Query}}">everything</a> | |
| 8 | <a {{if eq .Kind "repo"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=repo">repositories</a> | |
| 9 | <a {{if eq .Kind "issue"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=issue">issues</a> | |
| 10 | <a {{if eq .Kind "mr"}}class="active" aria-current="page" {{end}}href="?q={{.Query}}&kind=mr">merge requests</a> | |
| 11 | </nav> | |
| 12 | 17 | {{if and .Query (not .QueryErr)}}<p class="meta">{{len .Results}} {{if eq (len .Results) 1}}result{{else}}results{{end}} for <q>{{.Query}}</q>{{if .Kind}} in {{.Kind}}{{end}}</p>{{end}} |
| 13 | 18 | </div> |
| 14 | 19 | <form method="get" action="/search" class="searchform"> |
| @@ -19,7 +24,7 @@ | ||
| 19 | 24 | {{if .QueryErr}}<p class="error" role="alert">{{.QueryErr}}</p> |
| 20 | 25 | {{else if .Query}} |
| 21 | 26 | {{if .Results}} |
| 22 | <ul class="issuelist"> | |
| 27 | <ul class="issuelist rows"> | |
| 23 | 28 | {{range .Results}}<li> |
| 24 | 29 | <div class="issuemain"> |
| 25 | 30 | <p class="title"><a href="{{.Href}}">{{if .Number}}{{.Title}}{{else}}{{.Repo}}{{end}}</a></p> |
| @@ -33,4 +38,6 @@ | ||
| 33 | 38 | {{else}} |
| 34 | 39 | <p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p> |
| 35 | 40 | {{end}} |
| 41 | </div> | |
| 42 | </div> | |
| 36 | 43 | {{end}} |
internal/web/templates/issues.html +8 −7
| @@ -1,22 +1,21 @@ | ||
| 1 | {{define "width"}}wide{{end}} | |
| 1 | 2 | {{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 3 | {{define "content"}} |
| 4 | <div class="withcol"> | |
| 5 | {{template "sidecol" .Facets}} | |
| 6 | <div class="colmain"> | |
| 3 | 7 | <div class="listhead"> |
| 4 | 8 | <h1>Issues</h1> |
| 5 | <nav class="filters"> | |
| 6 | <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a> | |
| 7 | <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a> | |
| 8 | <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> | |
| 9 | </nav> | |
| 10 | {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} | |
| 11 | 9 | <form method="get" class="searchform compact"> |
| 12 | 10 | <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body"> |
| 13 | 11 | <button type="submit" class="btn">Search</button> |
| 14 | 12 | <input type="hidden" name="state" value="{{.State}}"> |
| 15 | 13 | </form> |
| 14 | {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} | |
| 16 | 15 | <span class="spacer"></span> |
| 17 | 16 | <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels">labels</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p> |
| 18 | 17 | </div> |
| 19 | <ul class="issuelist"> | |
| 18 | <ul class="issuelist rows"> | |
| 20 | 19 | {{range .Issues}}<li> |
| 21 | 20 | <div class="issuemain"> |
| 22 | 21 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> |
| @@ -29,4 +28,6 @@ | ||
| 29 | 28 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}}{{end}} |
| 30 | 29 | </ul> |
| 31 | 30 | {{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} |
| 31 | </div> | |
| 32 | </div> | |
| 32 | 33 | {{end}} |
internal/web/templates/layout.html +25 −12
| @@ -37,26 +37,23 @@ | ||
| 37 | 37 | <div class="pane"> |
| 38 | 38 | {{with field . "Repo"}} |
| 39 | 39 | <header class="repohead"> |
| 40 | <div class="wrap"> | |
| 41 | {{$top := topTab (str $ "Tab")}} | |
| 40 | 42 | <div class="identity"> |
| 41 | 43 | {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1> |
| 42 | 44 | {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}} |
| 43 | 45 | {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}} |
| 44 | 46 | {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}} |
| 47 | {{/* Description, topics and website belong to the code tab, inline | |
| 48 | with the name so the header is two rows on every page. */}} | |
| 49 | {{if eq $top "code"}}{{if or (field $ "Desc") (field $ "Topics") $.Repo.Settings.Website}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}{{with $.Repo.Settings.Website}}<a class="site" href="{{.}}" rel="nofollow">{{.}}</a>{{end}}</p>{{end}}{{end}} | |
| 45 | 50 | <span class="grow"></span> |
| 46 | {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> | |
| 47 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> | |
| 48 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form> | |
| 51 | {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form> | |
| 52 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form> | |
| 53 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form> | |
| 49 | 54 | <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}} |
| 50 | 55 | </div> |
| 51 | {{$top := topTab (str $ "Tab")}} | |
| 52 | {{/* Description, topics and metadata belong to the code tab. On task | |
| 53 | tabs only the identity row and the tab bar render, and the header | |
| 54 | is identical on every page within a tab. */}} | |
| 55 | {{if eq $top "code"}} | |
| 56 | {{if or (field $ "Desc") (field $ "Topics")}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>{{end}} | |
| 57 | {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range $i, $m := field $ "Mirrors"}}{{if or $i $.Repo.Settings.Website}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}} | |
| 58 | {{if $.Viewer}}<p class="toggles">Pinned shows on your dashboard. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}} | |
| 59 | {{end}} | |
| 56 | {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}} | |
| 60 | 57 | <nav class="tabs" aria-label="Repository"> |
| 61 | 58 | <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a> |
| 62 | 59 | <a {{if eq $top "issues"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">Issues{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a> |
| @@ -66,6 +63,7 @@ | ||
| 66 | 63 | {{if field $ "HasWiki"}}<a {{if eq $top "wiki"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">Wiki</a>{{end}} |
| 67 | 64 | {{if field $ "CanAdmin"}}<a {{if eq $top "settings"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/settings">Settings</a>{{end}} |
| 68 | 65 | </nav> |
| 66 | </div> | |
| 69 | 67 | </header> |
| 70 | 68 | {{end}} |
| 71 | 69 | |
| @@ -116,6 +114,21 @@ | ||
| 116 | 114 | </div> |
| 117 | 115 | </details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}} |
| 118 | 116 | |
| 117 | {{define "filenav"}}<nav class="filenav" aria-label="Files"> | |
| 118 | <h2 class="colhead">{{.Title}}</h2> | |
| 119 | <ul> | |
| 120 | {{with .Parent}}<li><a class="up" href="{{.}}">..</a></li>{{end}} | |
| 121 | {{range .Entries}}<li><a{{if .Dir}} class="dir"{{end}}{{if .Current}} aria-current="page"{{end}} href="{{.URL}}">{{.Name}}</a></li> | |
| 122 | {{end}}</ul> | |
| 123 | </nav>{{end}} | |
| 124 | ||
| 125 | {{define "sidecol"}}<nav class="sidecol" aria-label="Filters"> | |
| 126 | {{range .}}{{if .Items}}<div class="grp"> | |
| 127 | <h2 class="colhead">{{.Title}}</h2> | |
| 128 | <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul> | |
| 129 | </div>{{end}}{{end}} | |
| 130 | </nav>{{end}} | |
| 131 | ||
| 119 | 132 | {{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}} |
| 120 | 133 | |
| 121 | 134 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} |
internal/web/templates/mrs.html +7 −7
| @@ -1,13 +1,11 @@ | ||
| 1 | {{define "width"}}wide{{end}} | |
| 1 | 2 | {{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 3 | {{define "content"}} |
| 4 | <div class="withcol"> | |
| 5 | {{template "sidecol" .Facets}} | |
| 6 | <div class="colmain"> | |
| 3 | 7 | <div class="listhead"> |
| 4 | 8 | <h1>Merge requests</h1> |
| 5 | <nav class="filters"> | |
| 6 | <a {{if eq .State "open"}}class="active" aria-current="page" {{end}}href="?state=open">open</a> | |
| 7 | <a {{if eq .State "merged"}}class="active" aria-current="page" {{end}}href="?state=merged">merged</a> | |
| 8 | <a {{if eq .State "closed"}}class="active" aria-current="page" {{end}}href="?state=closed">closed</a> | |
| 9 | <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> | |
| 10 | </nav> | |
| 11 | 9 | <form method="get" class="searchform compact"> |
| 12 | 10 | <input type="search" name="q" aria-label="Search merge requests" value="{{.Query}}" placeholder="search title and body"> |
| 13 | 11 | <button type="submit" class="btn">Search</button> |
| @@ -16,7 +14,7 @@ | ||
| 16 | 14 | {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}} |
| 17 | 15 | </div> |
| 18 | 16 | {{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}} |
| 19 | <ul class="issuelist"> | |
| 17 | <ul class="issuelist rows"> | |
| 20 | 18 | {{range .MRs}}{{$n := .Number}}<li> |
| 21 | 19 | <div class="issuemain"> |
| 22 | 20 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a> |
| @@ -31,4 +29,6 @@ | ||
| 31 | 29 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}} |
| 32 | 30 | </ul> |
| 33 | 31 | {{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}} |
| 32 | </div> | |
| 33 | </div> | |
| 34 | 34 | {{end}} |
internal/web/templates/notifications.html +2 −1
| @@ -1,3 +1,4 @@ | ||
| 1 | {{define "width"}}wide{{end}} | |
| 1 | 2 | {{define "title"}}notifications · {{.Site}}{{end}} |
| 2 | 3 | {{define "content"}} |
| 3 | 4 | <div class="listhead"> |
| @@ -9,7 +10,7 @@ | ||
| 9 | 10 | <span class="spacer"></span> |
| 10 | 11 | {{if .Notices}}<form method="post" action="/notifications" class="inline"><button type="submit" class="btn">Mark all read</button></form>{{end}} |
| 11 | 12 | </div> |
| 12 | <ul class="issuelist"> | |
| 13 | <ul class="issuelist rows"> | |
| 13 | 14 | {{range .Notices}}<li> |
| 14 | 15 | <div class="issuemain"> |
| 15 | 16 | <p class="title"><a href="{{.Href}}">{{.Actor}} {{.Summary}}</a></p> |
internal/web/templates/settings.html +20 −4
| @@ -1,10 +1,24 @@ | ||
| 1 | {{define "width"}}bounded{{end}} | |
| 1 | {{define "width"}}wide{{end}} | |
| 2 | 2 | {{define "title"}}settings · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} |
| 5 | 5 | <h1>Settings</h1> |
| 6 | 6 | {{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}} |
| 7 | <nav class="sections" aria-label="Sections"><a href="#identity">Identity</a><a href="#access">Access</a><a href="#gates">Merge gates</a><a href="#branches">Protected branches</a><a href="#tags">Protected tags</a><a href="#deps">Dependencies</a><a href="#runners">Runners</a><a href="#lifecycle">Lifecycle</a></nav> | |
| 7 | <div class="withcol narrow"> | |
| 8 | <nav class="sidecol" aria-label="Sections"> | |
| 9 | <div class="grp"><h2 class="colhead">Sections</h2> | |
| 10 | <ul> | |
| 11 | <li><a href="#identity">Identity</a></li> | |
| 12 | <li><a href="#access">Access</a></li> | |
| 13 | <li><a href="#gates">Merge gates</a></li> | |
| 14 | <li><a href="#branches">Protected branches</a></li> | |
| 15 | <li><a href="#tags">Protected tags</a></li> | |
| 16 | <li><a href="#deps">Dependencies</a></li> | |
| 17 | <li><a href="#runners">Runners</a></li> | |
| 18 | <li><a href="#lifecycle">Lifecycle</a></li> | |
| 19 | </ul></div> | |
| 20 | </nav> | |
| 21 | <div class="colmain"> | |
| 8 | 22 | |
| 9 | 23 | <section id="identity"><h2>Identity</h2> |
| 10 | 24 | <form method="post" action="{{$base}}" class="setform"> |
| @@ -134,8 +148,8 @@ | ||
| 134 | 148 | <section id="deps"><h2>Dependencies</h2> |
| 135 | 149 | <form method="post" action="{{$base}}" class="setform"> |
| 136 | 150 | <input type="hidden" name="field" value="deps"> |
| 137 | <div><label for="deps">Check for updates</label><p class="hint">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind in an issue. Checking a private repository tells those registries what it depends on.</p></div> | |
| 138 | <div class="check"><input type="checkbox" id="deps" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div> | |
| 151 | <div><label for="deps-enabled">Check for updates</label><p class="hint">Compares the manifests on <code>{{.Repo.DefaultBranch}}</code> against proxy.golang.org, npm, crates.io, and PyPI once a day, and tracks what is behind in an issue. Checking a private repository tells those registries what it depends on.</p></div> | |
| 152 | <div class="check"><input type="checkbox" id="deps-enabled" name="deps" value="on"{{if .DepsEnabled}} checked{{end}}></div> | |
| 139 | 153 | <div><button type="submit" class="btn">Save</button></div> |
| 140 | 154 | </form> |
| 141 | 155 | {{if .DepsEnabled}} |
| @@ -184,4 +198,6 @@ | ||
| 184 | 198 | <p class="meta">Deleting or transferring a repository is a CLI operation: |
| 185 | 199 | <code>gitbay repo delete {{.Repo.OwnerName}}/{{.Repo.Name}} --yes</code></p> |
| 186 | 200 | </section> |
| 201 | </div> | |
| 202 | </div> | |
| 187 | 203 | {{end}} |
internal/web/web_test.go +2 −2
| @@ -89,8 +89,8 @@ func TestWhenNamesTheZone(t *testing.T) { | ||
| 89 | 89 | // none. The merge request page picks wide for its diff view, so it gets |
| 90 | 90 | // a per-view define instead of a fixed one. |
| 91 | 91 | func TestMainWidthClass(t *testing.T) { |
| 92 | wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true} | |
| 93 | bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} | |
| 92 | wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true} | |
| 93 | bounded := map[string]bool{"landing.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true} | |
| 94 | 94 | perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} |
| 95 | 95 | for _, name := range Pages() { |
| 96 | 96 | src, err := TemplateSource(name) |
internal/web/widths_test.go added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | package web | |
| 2 | ||
| 3 | import ( | |
| 4 | "regexp" | |
| 5 | "strings" | |
| 6 | "testing" | |
| 7 | ) | |
| 8 | ||
| 9 | // List pages and the dashboard render at the container width; text pages | |
| 10 | // keep the reading cap (desktop layout spec). | |
| 11 | func TestListPagesAreWide(t *testing.T) { | |
| 12 | wide := []string{"dashboard.html", "issues.html", "mrs.html", "explore.html", "notifications.html", "globalsearch.html", "builds.html"} | |
| 13 | for _, name := range wide { | |
| 14 | src, err := templateFS.ReadFile("templates/" + name) | |
| 15 | if err != nil { | |
| 16 | t.Fatal(err) | |
| 17 | } | |
| 18 | if !strings.HasPrefix(string(src), `{{define "width"}}wide{{end}}`) { | |
| 19 | t.Errorf("%s does not declare width wide", name) | |
| 20 | } | |
| 21 | } | |
| 22 | for _, name := range []string{"issue.html", "wiki.html", "owner.html"} { | |
| 23 | src, _ := templateFS.ReadFile("templates/" + name) | |
| 24 | if strings.Contains(string(src), `{{define "width"}}wide{{end}}`) { | |
| 25 | t.Errorf("%s is a text page and must not be wide", name) | |
| 26 | } | |
| 27 | } | |
| 28 | for _, name := range []string{"issues.html", "mrs.html", "notifications.html", "globalsearch.html", "dashboard.html"} { | |
| 29 | src, _ := templateFS.ReadFile("templates/" + name) | |
| 30 | if !strings.Contains(string(src), `<ul class="issuelist rows">`) { | |
| 31 | t.Errorf("%s does not use one-line rows", name) | |
| 32 | } | |
| 33 | } | |
| 34 | if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) { | |
| 35 | t.Error("explore.html does not use one-line rows") | |
| 36 | } | |
| 37 | // Settings pages carry a section column: every section id has a link | |
| 38 | // in the column, and the page is wide with the narrow grid. | |
| 39 | for _, name := range []string{"settings.html", "account.html", "admin.html"} { | |
| 40 | src, _ := templateFS.ReadFile("templates/" + name) | |
| 41 | s := string(src) | |
| 42 | if !strings.HasPrefix(s, `{{define "width"}}wide{{end}}`) || !strings.Contains(s, `<div class="withcol narrow">`) { | |
| 43 | t.Errorf("%s lacks the narrow column layout", name) | |
| 44 | } | |
| 45 | for _, m := range regexp.MustCompile(`<section id="([a-z]+)"`).FindAllStringSubmatch(s, -1) { | |
| 46 | if !strings.Contains(s, `href="#`+m[1]+`"`) { | |
| 47 | t.Errorf("%s: section %q has no link in the column", name, m[1]) | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | } | |