vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Build a landing page and simplify the forge chrome
074ae1605e177e99533af02d8c69ccd1a6a947bb · Keenan Nemetz · 14d ago · parent 62576fa8
Build a landing page and simplify the forge chrome The instance root is now a landing page rather than the full activity feed: forge identity, project cards with top language, last commit, issue/change counts and latest release, a short activity rail, and a first-run empty state. The full feed moves to /activity with type filters and pagination. Feeds now cover issues, changes, comments, and releases only; commits and CI runs already have their own pages. Configuration gains instance_name and instance_tagline. Navigation: the crowded global bar becomes Runners, Activity, search, and an account menu holding Admin, Status, and Log out. Repository pages show the name and description above the tabs at the home-page header scale, while tab-page titles stay small. Top-level pages keep the larger header. Drop the workflow run chip, assigned-issues strip, repository size stat, and footer Status link.
server/web/web.go +37−67
| @@ -131,6 +131,7 @@ func (w *Web) repoNav(name, tab string, canAdmin bool) repoNavData { | |||
| 131 | 131 | if err != nil { | |
| 132 | 132 | return n | |
| 133 | 133 | } | |
| 134 | n.Description = m.Description | ||
| 134 | 135 | if acl, err := m.ACL(); err == nil { | |
| 135 | 136 | n.Private = !acl.Can("", access.Read) | |
| 136 | 137 | } | |
| @@ -138,10 +139,11 @@ func (w *Web) repoNav(name, tab string, canAdmin bool) repoNavData { | |||
| 138 | 139 | } | |
| 139 | 140 | ||
| 140 | 141 | type repoNavData struct { | |
| 141 | Name string | ||
| 142 | Tab string | ||
| 143 | Private bool | ||
| 144 | CanAdmin bool | ||
| 142 | Name string | ||
| 143 | Description string | ||
| 144 | Tab string | ||
| 145 | Private bool | ||
| 146 | CanAdmin bool | ||
| 145 | 147 | } | |
| 146 | 148 | ||
| 147 | 149 | // Page is embedded by every view. Exported so html/template can promote its | |
| @@ -167,6 +169,28 @@ type Page struct { | |||
| 167 | 169 | // Query is the repository search on the index page. The global search box | |
| 168 | 170 | // echoes it; other pages leave it empty. | |
| 169 | 171 | Query string | |
| 172 | // Section is the global destination the current page belongs to, used by the | ||
| 173 | // rail to mark the current item. It is presentation only. | ||
| 174 | Section string | ||
| 175 | } | ||
| 176 | |||
| 177 | // pageSection maps a request path to the global destination it belongs to. A | ||
| 178 | // repository page belongs to Repositories: the repository's own tabs carry the | ||
| 179 | // finer context. | ||
| 180 | func pageSection(path string) string { | ||
| 181 | switch { | ||
| 182 | case path == "/" || strings.HasPrefix(path, "/r/"): | ||
| 183 | return "repos" | ||
| 184 | case strings.HasPrefix(path, "/runners"): | ||
| 185 | return "runners" | ||
| 186 | case strings.HasPrefix(path, "/activity"): | ||
| 187 | return "activity" | ||
| 188 | case strings.HasPrefix(path, "/admin"): | ||
| 189 | return "admin" | ||
| 190 | case strings.HasPrefix(path, "/account"): | ||
| 191 | return "account" | ||
| 192 | } | ||
| 193 | return "" | ||
| 170 | 194 | } | |
| 171 | 195 | ||
| 172 | 196 | // canRead reports whether the viewer may see the repository. An anonymous | |
| @@ -224,67 +248,6 @@ func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) | |||
| 224 | 248 | ||
| 225 | 249 | // ---------- handlers ---------- | |
| 226 | 250 | ||
| 227 | func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | ||
| 228 | entries, err := w.Index.List() | ||
| 229 | if err != nil { | ||
| 230 | w.serverError(rw, r, "index unavailable: "+err.Error()) | ||
| 231 | return | ||
| 232 | } | ||
| 233 | query := strings.TrimSpace(r.URL.Query().Get("q")) | ||
| 234 | needle := strings.ToLower(query) | ||
| 235 | var visible []index.Entry | ||
| 236 | for _, e := range entries { | ||
| 237 | m, err := w.Repos.Open(e.Name) | ||
| 238 | if err != nil { | ||
| 239 | continue | ||
| 240 | } | ||
| 241 | if !w.canRead(r, m) { | ||
| 242 | continue | ||
| 243 | } | ||
| 244 | if needle != "" && | ||
| 245 | !strings.Contains(strings.ToLower(e.Name), needle) && | ||
| 246 | !strings.Contains(strings.ToLower(e.Description), needle) { | ||
| 247 | continue | ||
| 248 | } | ||
| 249 | visible = append(visible, e) | ||
| 250 | } | ||
| 251 | page := w.page(r, "Repositories") | ||
| 252 | page.Query = query | ||
| 253 | var events []activityEvent | ||
| 254 | if query == "" { | ||
| 255 | events = w.instanceActivity(visible) | ||
| 256 | } | ||
| 257 | w.render(rw, "index", struct { | ||
| 258 | Page | ||
| 259 | Repos []index.Entry | ||
| 260 | Query string | ||
| 261 | Events []activityEvent | ||
| 262 | }{page, visible, query, events}, 0) | ||
| 263 | } | ||
| 264 | |||
| 265 | // instanceActivity merges the visible repositories' feeds. It is intentionally | ||
| 266 | // bounded after the merge: a single-tenant instance has few repositories, and a | ||
| 267 | // per-repository cap would hide a quiet repository's only event. | ||
| 268 | func (w *Web) instanceActivity(entries []index.Entry) []activityEvent { | ||
| 269 | const limit = 50 | ||
| 270 | var events []activityEvent | ||
| 271 | for _, e := range entries { | ||
| 272 | m, err := w.Repos.Open(e.Name) | ||
| 273 | if err != nil { | ||
| 274 | continue | ||
| 275 | } | ||
| 276 | for _, ev := range w.repoActivity(m, "", "", "").Events { | ||
| 277 | ev.Repo = m.Name | ||
| 278 | events = append(events, ev) | ||
| 279 | } | ||
| 280 | } | ||
| 281 | sort.SliceStable(events, func(i, j int) bool { return events[i].Time.After(events[j].Time) }) | ||
| 282 | if len(events) > limit { | ||
| 283 | events = events[:limit] | ||
| 284 | } | ||
| 285 | return events | ||
| 286 | } | ||
| 287 | |||
| 288 | 251 | // open resolves a repository for the viewer, answering 404 when it is missing | |
| 289 | 252 | // or not readable. A private repository is indistinguishable from a missing one. | |
| 290 | 253 | func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) { | |
| @@ -524,7 +487,10 @@ func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |||
| 524 | 487 | ||
| 525 | 488 | // ---------- helpers ---------- | |
| 526 | 489 | ||
| 527 | func cloneURL(r *http.Request, name string) string { | ||
| 490 | // cloneBase is the read-only HTTP transport root for this instance, as the | ||
| 491 | // browser reached it. It is what the landing page shows an anonymous visitor | ||
| 492 | // who wants to clone without an account. | ||
| 493 | func cloneBase(r *http.Request) string { | ||
| 528 | 494 | scheme := "http" | |
| 529 | 495 | if r.TLS != nil { | |
| 530 | 496 | scheme = "https" | |
| @@ -532,7 +498,11 @@ func cloneURL(r *http.Request, name string) string { | |||
| 532 | 498 | if p := r.Header.Get("X-Forwarded-Proto"); p != "" { | |
| 533 | 499 | scheme = p | |
| 534 | 500 | } | |
| 535 | return fmt.Sprintf("%s://%s/git/%s.git", scheme, r.Host, name) | ||
| 501 | return fmt.Sprintf("%s://%s/git/", scheme, r.Host) | ||
| 502 | } | ||
| 503 | |||
| 504 | func cloneURL(r *http.Request, name string) string { | ||
| 505 | return cloneBase(r) + name + ".git" | ||
| 536 | 506 | } | |
| 537 | 507 | ||
| 538 | 508 | func sshURL(addr, name string) string { | |