Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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) {