vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Add repository search, list pagination, and theme/a11y polish
5cab7b18548e5263c01549cb61415fd7777d1758 · Keenan Nemetz · 15d ago · parent 0b26547e
Add repository search, list pagination, and theme/a11y polish The chrome and the index page search repository names and descriptions after the same read filter, so a private repository cannot appear. Issues and changes paginate at 25 rows with filters and HTMX navigation preserved. Polish from the UI plan: a light token block follows prefers-color-scheme, a skip link and focus-visible outlines help keyboard users, panels scroll horizontally on narrow screens, and the empty index/search states explain what is visible. Also fixes a flaky key assertion in the admin users test.
server/web/web.go +61−4
| @@ -48,7 +48,7 @@ type Web struct { | |||
| 48 | 48 | // index, queue, LFS namespace, and locks. The web owns the form and ACL check. | |
| 49 | 49 | RenameRepo func(oldName, newName string) error | |
| 50 | 50 | DeleteRepo func(name string) error | |
| 51 | Logger func(format string, args ...any) | ||
| 51 | Logger func(format string, args ...any) | ||
| 52 | 52 | ||
| 53 | 53 | // sem bounds compressed archive streams; see releases.go. | |
| 54 | 54 | sem chan struct{} | |
| @@ -159,6 +159,9 @@ type Page struct { | |||
| 159 | 159 | // InstanceAdmin reports whether the viewer may use the instance-wide admin | |
| 160 | 160 | // surface. The global bar uses it to show Admin. | |
| 161 | 161 | InstanceAdmin bool | |
| 162 | // Query is the repository search on the index page. The global search box | ||
| 163 | // echoes it; other pages leave it empty. | ||
| 164 | Query string | ||
| 162 | 165 | } | |
| 163 | 166 | ||
| 164 | 167 | // canRead reports whether the viewer may see the repository. An anonymous | |
| @@ -222,20 +225,31 @@ func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | |||
| 222 | 225 | w.serverError(rw, r, "index unavailable: "+err.Error()) | |
| 223 | 226 | return | |
| 224 | 227 | } | |
| 228 | query := strings.TrimSpace(r.URL.Query().Get("q")) | ||
| 229 | needle := strings.ToLower(query) | ||
| 225 | 230 | var visible []index.Entry | |
| 226 | 231 | for _, e := range entries { | |
| 227 | 232 | m, err := w.Repos.Open(e.Name) | |
| 228 | 233 | if err != nil { | |
| 229 | 234 | continue | |
| 230 | 235 | } | |
| 231 | if w.canRead(r, m) { | ||
| 232 | visible = append(visible, e) | ||
| 236 | if !w.canRead(r, m) { | ||
| 237 | continue | ||
| 238 | } | ||
| 239 | if needle != "" && | ||
| 240 | !strings.Contains(strings.ToLower(e.Name), needle) && | ||
| 241 | !strings.Contains(strings.ToLower(e.Description), needle) { | ||
| 242 | continue | ||
| 233 | 243 | } | |
| 244 | visible = append(visible, e) | ||
| 234 | 245 | } | |
| 246 | page := w.page(r, "Repositories") | ||
| 247 | page.Query = query | ||
| 235 | 248 | w.render(rw, "index", struct { | |
| 236 | 249 | Page | |
| 237 | 250 | Repos []index.Entry | |
| 238 | }{w.page(r, "Repositories"), visible}, 0) | ||
| 251 | Query string | ||
| 252 | }{page, visible, query}, 0) | ||
| 239 | 253 | } | |
| 240 | 254 | ||
| 241 | 255 | // open resolves a repository for the viewer, answering 404 when it is missing | |
| @@ -562,3 +576,46 @@ func isMarkdown(path string) bool { | |||
| 562 | 576 | } | |
| 563 | 577 | return false | |
| 564 | 578 | } | |
| 579 | |||
| 580 | // pager is the shared shape for a list split into pages. urlFor builds the URL | ||
| 581 | // for a page number so the handler keeps control of which filters survive. | ||
| 582 | type pager struct { | ||
| 583 | Page int | ||
| 584 | Pages int | ||
| 585 | Total int | ||
| 586 | From int | ||
| 587 | To int | ||
| 588 | PrevURL string | ||
| 589 | NextURL string | ||
| 590 | } | ||
| 591 | |||
| 592 | func newPager(total, per, page int, urlFor func(int) string) pager { | ||
| 593 | if per < 1 { | ||
| 594 | per = 1 | ||
| 595 | } | ||
| 596 | pages := (total + per - 1) / per | ||
| 597 | if pages < 1 { | ||
| 598 | pages = 1 | ||
| 599 | } | ||
| 600 | if page < 1 { | ||
| 601 | page = 1 | ||
| 602 | } | ||
| 603 | if page > pages { | ||
| 604 | page = pages | ||
| 605 | } | ||
| 606 | p := pager{Page: page, Pages: pages, Total: total} | ||
| 607 | if total > 0 { | ||
| 608 | p.From = (page-1)*per + 1 | ||
| 609 | p.To = page * per | ||
| 610 | if p.To > total { | ||
| 611 | p.To = total | ||
| 612 | } | ||
| 613 | } | ||
| 614 | if page > 1 { | ||
| 615 | p.PrevURL = urlFor(page - 1) | ||
| 616 | } | ||
| 617 | if page < pages { | ||
| 618 | p.NextURL = urlFor(page + 1) | ||
| 619 | } | ||
| 620 | return p | ||
| 621 | } | ||