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.
Build out the forge web UI
f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264
Build out the forge web UI Turn the web surface into a compact, server-rendered forge with HTMX as the progressive dynamic layer. - Split templates and assets into embedded files; add a tokenised dark theme, Lucide icons, and vendored HTMX. - Add the repository Overview page and a separate chronological Activity feed with type filters and HTMX fragment updates. - Finish the issue and change workflows: create pages, comments, close/reopen, labels and assignees, approval, discussion, line-anchored review comments, and HTMX list filters. - Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware code browsing, and a repository Runs list. - Add a loopback-only development login for local write testing, with explicit configuration and listener checks. - Extend tests for the new pages, fragments, labels, assignees, anchors, and development login.
server/web/web.go +86−63
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "net/url" | |
| 14 | 14 | "sort" | |
| 15 | 15 | "strings" | |
| 16 | "sync" | ||
| 16 | 17 | "time" | |
| 17 | 18 | "unicode/utf8" | |
| 18 | 19 | ||
| @@ -47,6 +48,12 @@ type Web struct { | |||
| 47 | 48 | ||
| 48 | 49 | // sem bounds compressed archive streams; see releases.go. | |
| 49 | 50 | sem chan struct{} | |
| 51 | |||
| 52 | // Development login, configured by dev_login. It is resolved once at startup | ||
| 53 | // and only ever used for loopback requests; the session is created lazily. | ||
| 54 | devAccount *identity.Account | ||
| 55 | devSess *session.Session | ||
| 56 | devMu sync.Mutex | ||
| 50 | 57 | } | |
| 51 | 58 | ||
| 52 | 59 | func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Store, cfg *config.Config) (*Web, error) { | |
| @@ -60,17 +67,28 @@ func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Sto | |||
| 60 | 67 | // below can be a method: metadata files carry account names and approvals are | |
| 61 | 68 | // keyed by ids, and a page should show the display label for either. | |
| 62 | 69 | w.Tmpl, err = template.New("vole").Funcs(template.FuncMap{ | |
| 63 | "since": humanSince, | ||
| 64 | "short": format.SHA, | ||
| 65 | "esc": url.PathEscape, | ||
| 66 | "bytes": format.Bytes, | ||
| 67 | "credid": identity.CredentialIDString, | ||
| 68 | "who": w.who, | ||
| 69 | }).Parse(templates) | ||
| 70 | "since": humanSince, | ||
| 71 | "short": format.SHA, | ||
| 72 | "esc": url.PathEscape, | ||
| 73 | "bytes": format.Bytes, | ||
| 74 | "credid": identity.CredentialIDString, | ||
| 75 | "who": w.who, | ||
| 76 | "nav": repoNav, | ||
| 77 | "inc": func(i int) int { return i + 1 }, | ||
| 78 | "initial": initial, | ||
| 79 | "tone": tone, | ||
| 80 | "urlquery": url.QueryEscape, | ||
| 81 | "join": strings.Join, | ||
| 82 | "tokenPicker": tokenPicker, | ||
| 83 | "icon": icon, | ||
| 84 | }).ParseFS(templateFS, "templates/*.html") | ||
| 70 | 85 | if err != nil { | |
| 71 | 86 | return nil, err | |
| 72 | 87 | } | |
| 73 | 88 | w.Logger = func(string, ...any) {} | |
| 89 | if err := w.enableDevLogin(cfg); err != nil { | ||
| 90 | return nil, err | ||
| 91 | } | ||
| 74 | 92 | return w, nil | |
| 75 | 93 | } | |
| 76 | 94 | ||
| @@ -88,6 +106,15 @@ func (w *Web) who(idOrName string) string { | |||
| 88 | 106 | return a.Label() | |
| 89 | 107 | } | |
| 90 | 108 | ||
| 109 | // repoNav is the repository header's data: which repository, and which tab is | ||
| 110 | // current. Templates call it as {{template "repohead" (nav .Name "issues")}}. | ||
| 111 | func repoNav(name, tab string) repoNavData { return repoNavData{Name: name, Tab: tab} } | ||
| 112 | |||
| 113 | type repoNavData struct { | ||
| 114 | Name string | ||
| 115 | Tab string | ||
| 116 | } | ||
| 117 | |||
| 91 | 118 | // Page is embedded by every view. Exported so html/template can promote its | |
| 92 | 119 | // fields; an unexported embedded type is not reachable from a template. | |
| 93 | 120 | type Page struct { | |
| @@ -99,6 +126,9 @@ type Page struct { | |||
| 99 | 126 | // CSRF is derived from the session token and must be on every state-changing | |
| 100 | 127 | // form or fetch. | |
| 101 | 128 | CSRF string | |
| 129 | // DevLogin names the account a loopback browser is signed in as. Empty on a | ||
| 130 | // normal instance; the header uses it to make the development state visible. | ||
| 131 | DevLogin string | ||
| 102 | 132 | } | |
| 103 | 133 | ||
| 104 | 134 | // canRead reports whether the viewer may see the repository. An anonymous | |
| @@ -116,6 +146,22 @@ func (w *Web) canRead(r *http.Request, m *repo.Meta) bool { | |||
| 116 | 146 | return a.Can(accountID, access.Read) | |
| 117 | 147 | } | |
| 118 | 148 | ||
| 149 | // htmxRequest reports whether a request came from HTMX. The same handler still | ||
| 150 | // supports the ordinary form POST; the header only changes the response shape. | ||
| 151 | func htmxRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" } | ||
| 152 | |||
| 153 | // notFound is the one answer for a missing page and for a repository the viewer | ||
| 154 | // may not see: the two must be indistinguishable or a private repository leaks. | ||
| 155 | func (w *Web) notFound(rw http.ResponseWriter, r *http.Request) { | ||
| 156 | w.render(rw, "notfound", struct { | ||
| 157 | Page | ||
| 158 | Message string | ||
| 159 | }{w.page(r, "Not found"), "That page does not exist, or you do not have access to it."}, http.StatusNotFound) | ||
| 160 | } | ||
| 161 | |||
| 162 | // NotFoundPage is the catch-all route. It uses the same view as every other 404. | ||
| 163 | func (w *Web) NotFoundPage(rw http.ResponseWriter, r *http.Request) { w.notFound(rw, r) } | ||
| 164 | |||
| 119 | 165 | func (w *Web) render(rw http.ResponseWriter, name string, data any, status int) { | |
| 120 | 166 | rw.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 121 | 167 | if status != 0 { | |
| @@ -155,13 +201,15 @@ func (w *Web) IndexPage(rw http.ResponseWriter, r *http.Request) { | |||
| 155 | 201 | func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) { | |
| 156 | 202 | m, err := w.Repos.Open(r.PathValue("name")) | |
| 157 | 203 | if err != nil || !w.canRead(r, m) { | |
| 158 | http.NotFound(rw, r) | ||
| 204 | w.notFound(rw, r) | ||
| 159 | 205 | return nil, false | |
| 160 | 206 | } | |
| 161 | 207 | return m, true | |
| 162 | 208 | } | |
| 163 | 209 | ||
| 164 | func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | ||
| 210 | // Code is the file browser: the branch, its tree, and the README at the tip. | ||
| 211 | // The repository overview (Overview) is the page that summarises the project. | ||
| 212 | func (w *Web) Code(rw http.ResponseWriter, r *http.Request) { | ||
| 165 | 213 | m, ok := w.open(rw, r) | |
| 166 | 214 | if !ok { | |
| 167 | 215 | return | |
| @@ -173,56 +221,20 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |||
| 173 | 221 | } | |
| 174 | 222 | commit, _ := g.RevParse(ref) | |
| 175 | 223 | var entries []git.Entry | |
| 176 | var commits []git.Commit | ||
| 224 | var latest []git.Commit | ||
| 177 | 225 | count := 0 | |
| 178 | 226 | if commit != "" { | |
| 179 | 227 | entries, _ = g.LsTree(commit) | |
| 180 | commits, _ = g.Log(commit, 10) | ||
| 228 | latest, _ = g.Log(commit, 1) | ||
| 181 | 229 | count, _ = g.CountCommits(commit) | |
| 182 | 230 | } | |
| 183 | issueCount := 0 | ||
| 184 | if err := w.Index.EnsureIssues(m); err == nil { | ||
| 185 | issueCount, _ = w.Index.IssueCount(m.ID) | ||
| 186 | } | ||
| 187 | // Locks are worth a count on the front page: a held lock is the reason somebody's | ||
| 188 | // push was refused, and it is the last place they would think to look. | ||
| 189 | lockCount := 0 | ||
| 190 | if w.Locks != nil { | ||
| 191 | if held, err := w.Locks.Paths(m.ID); err == nil { | ||
| 192 | lockCount = len(held) | ||
| 193 | } | ||
| 194 | } | ||
| 195 | var recentRuns []*runs.Run | ||
| 196 | if w.Runs != nil { | ||
| 197 | recentRuns, _ = w.Runs.Recent(m.Name, 8) | ||
| 198 | } | ||
| 199 | // A repository's README is the front page of most projects; showing the file | ||
| 200 | // list without it makes the repo page look like a directory listing. | ||
| 201 | var readme template.HTML | ||
| 202 | if commit != "" { | ||
| 203 | for _, name := range []string{"README.md", "readme.md", "Readme.md", "README.markdown"} { | ||
| 204 | if data, err := g.BlobAt(commit, name); err == nil { | ||
| 205 | readme = w.renderMarkdown(m.Name, string(data)) | ||
| 206 | break | ||
| 207 | } | ||
| 208 | } | ||
| 209 | } | ||
| 231 | readme, readmeName := w.readmeAt(m, commit) | ||
| 210 | 232 | // A site is a branch, so the page can link to it whether or not the branch | |
| 211 | 233 | // exists yet: pushing it makes the link true, and until then it 404s. | |
| 212 | 234 | site := "" | |
| 213 | 235 | if w.Cfg.PagesDomain != "" { | |
| 214 | 236 | site = "http://" + m.Name + "." + w.Cfg.PagesDomain | |
| 215 | 237 | } | |
| 216 | // A release is an annotated tag, so the count comes from the tag list rather | ||
| 217 | // than from anything the web has to maintain. | ||
| 218 | releaseCount := 0 | ||
| 219 | if tags, err := g.Tags(); err == nil { | ||
| 220 | for _, t := range tags { | ||
| 221 | if t.Annotated { | ||
| 222 | releaseCount++ | ||
| 223 | } | ||
| 224 | } | ||
| 225 | } | ||
| 226 | 238 | branches, _ := g.ListRefs("refs/heads/") | |
| 227 | 239 | names := make([]string, 0, len(branches)) | |
| 228 | 240 | for k := range branches { | |
| @@ -241,44 +253,36 @@ func (w *Web) Repo(rw http.ResponseWriter, r *http.Request) { | |||
| 241 | 253 | canAllowCI = true | |
| 242 | 254 | } | |
| 243 | 255 | } | |
| 244 | w.render(rw, "repo", struct { | ||
| 256 | w.render(rw, "code", struct { | ||
| 245 | 257 | Page | |
| 246 | 258 | Name string | |
| 247 | Description string | ||
| 248 | 259 | Ref string | |
| 249 | 260 | CloneURL string | |
| 250 | 261 | SSHURL string | |
| 251 | 262 | Entries []git.Entry | |
| 252 | Commits []git.Commit | ||
| 263 | Latest []git.Commit | ||
| 253 | 264 | Commit string | |
| 254 | 265 | Count int | |
| 255 | 266 | Branches []string | |
| 256 | IssueCount int | ||
| 257 | ReleaseCount int | ||
| 258 | LockCount int | ||
| 259 | 267 | Site string | |
| 260 | 268 | Readme template.HTML | |
| 261 | Runs []*runs.Run | ||
| 269 | ReadmeName string | ||
| 262 | 270 | ContributorBranches []string | |
| 263 | 271 | CanAllowCI bool | |
| 264 | 272 | }{ | |
| 265 | Page: w.page(r, m.Name), | ||
| 273 | Page: w.page(r, m.Name+" · code"), | ||
| 266 | 274 | Name: m.Name, | |
| 267 | Description: m.Description, | ||
| 268 | 275 | Ref: ref, | |
| 269 | 276 | CloneURL: cloneURL(r, m.Name), | |
| 270 | 277 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | |
| 271 | 278 | Entries: entries, | |
| 272 | Commits: commits, | ||
| 279 | Latest: latest, | ||
| 273 | 280 | Commit: commit, | |
| 274 | 281 | Count: count, | |
| 275 | 282 | Branches: names, | |
| 276 | IssueCount: issueCount, | ||
| 277 | ReleaseCount: releaseCount, | ||
| 278 | LockCount: lockCount, | ||
| 279 | 283 | Site: site, | |
| 280 | 284 | Readme: readme, | |
| 281 | Runs: recentRuns, | ||
| 285 | ReadmeName: readmeName, | ||
| 282 | 286 | ContributorBranches: contributorBranches, | |
| 283 | 287 | CanAllowCI: canAllowCI, | |
| 284 | 288 | }, 0) | |
| @@ -358,6 +362,22 @@ func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | |||
| 358 | 362 | content = string(data[:maxInlineBytes]) | |
| 359 | 363 | truncated = true | |
| 360 | 364 | } | |
| 365 | // A table row per line is pleasant to read and cheap for a source file, but a | ||
| 366 | // million blank lines would be neither. Past the cap the page falls back to a | ||
| 367 | // preformatted block. | ||
| 368 | const maxNumberedLines = 5000 | ||
| 369 | lines := strings.Split(content, "\n") | ||
| 370 | lineCut := false | ||
| 371 | if len(lines) > maxNumberedLines { | ||
| 372 | lines = lines[:maxNumberedLines] | ||
| 373 | lineCut = true | ||
| 374 | } | ||
| 375 | // Highlight the same bytes the table would show. A language we cannot name | ||
| 376 | // stays plain, which is better than guessing wrong about its grammar. | ||
| 377 | var highlighted template.HTML | ||
| 378 | if !binary && !isMarkdown(p) { | ||
| 379 | highlighted = highlightCode(p, strings.Join(lines, "\n")) | ||
| 380 | } | ||
| 361 | 381 | // Markdown is the one thing a reader expects to see rendered; everything else | |
| 362 | 382 | // is shown as bytes in a <pre>, which is what a source file is. | |
| 363 | 383 | var rendered template.HTML | |
| @@ -373,9 +393,12 @@ func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | |||
| 373 | 393 | Markdown template.HTML | |
| 374 | 394 | Binary bool | |
| 375 | 395 | Truncated bool | |
| 396 | LineCut bool | ||
| 397 | Lines []string | ||
| 398 | Highlight template.HTML | ||
| 376 | 399 | Size int64 | |
| 377 | 400 | Crumbs []crumb | |
| 378 | }{w.page(r, m.Name+" · "+p), m.Name, ref, p, content, rendered, binary, truncated, int64(len(data)), crumbs(p)}, 0) | ||
| 401 | }{w.page(r, m.Name+" · "+p), m.Name, ref, p, content, rendered, binary, truncated, lineCut, lines, highlighted, int64(len(data)), crumbs(p)}, 0) | ||
| 379 | 402 | } | |
| 380 | 403 | ||
| 381 | 404 | func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |