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