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/issues.go +237−39

@@ -14,6 +14,7 @@ import (
14 14
15 15 "github.com/rodent-software/vole/core/git"
16 16 voleid "github.com/rodent-software/vole/core/id"
17 "github.com/rodent-software/vole/forge/access"
17 18 "github.com/rodent-software/vole/forge/index"
18 19 "github.com/rodent-software/vole/forge/repo"
19 20 "github.com/rodent-software/vole/metadata"
@@ -73,12 +74,32 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) {
73 74 http.Error(rw, "issue index unavailable: "+err.Error(), http.StatusInternalServerError)
74 75 return
75 76 }
77 state := strings.TrimSpace(r.URL.Query().Get("state"))
78 if state == "" {
79 state = "open"
80 }
81 if state != "open" && state != "closed" && state != "all" {
82 state = "open"
83 }
76 84 filter := index.Filter{
77 State: strings.TrimSpace(r.URL.Query().Get("state")),
85 State: state,
78 86 Label: strings.TrimSpace(r.URL.Query().Get("label")),
79 87 Assignee: strings.TrimSpace(r.URL.Query().Get("assignee")),
80 88 Query: strings.TrimSpace(r.URL.Query().Get("q")),
81 89 }
90 // Counts describe the whole repository, not the filtered page: an "open"
91 // badge that changes when you search would be a lie.
92 all, err := w.Index.Issues(m.ID, index.Filter{})
93 if err != nil {
94 http.Error(rw, err.Error(), http.StatusInternalServerError)
95 return
96 }
97 open := 0
98 for _, row := range all {
99 if row.State == "open" {
100 open++
101 }
102 }
82 103 rows, err := w.Index.Issues(m.ID, filter)
83 104 if err != nil {
84 105 http.Error(rw, err.Error(), http.StatusInternalServerError)
@@ -99,23 +120,18 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) {
99 120 if err != nil {
100 121 assignees = nil
101 122 }
102 total, _ := w.Index.IssueCount(m.ID)
103 open := 0
104 for _, row := range rows {
105 if row.State == "open" {
106 open++
107 }
108 }
109 123 acl, _ := m.ACL()
110 w.render(rw, "issues", struct {
124 view := struct {
111 125 Page
112 126 Name string
113 127 Issues []index.IssueRow
114 128 Labels []string
115 129 Assignees []string
116 130 Filter index.Filter
131 State string
117 132 Total int
118 133 Open int
134 Closed int
119 135 Filtered bool
120 136 CanDiscuss bool
121 137 }{
@@ -125,11 +141,97 @@ func (w *Web) Issues(rw http.ResponseWriter, r *http.Request) {
125 141 Labels: labels,
126 142 Assignees: assignees,
127 143 Filter: filter,
128 Total: total,
144 State: state,
145 Total: len(all),
129 146 Open: open,
130 Filtered: filter.State != "" || filter.Label != "" || filter.Assignee != "" || filter.Query != "",
147 Closed: len(all) - open,
148 Filtered: filter.Label != "" || filter.Assignee != "" || filter.Query != "" || (state != "open" && len(all) > 0),
131 149 CanDiscuss: acl != nil && w.canDiscuss(r, acl),
132 }, 0)
150 }
151 if htmxRequest(r) {
152 w.render(rw, "issue_list", view, 0)
153 return
154 }
155 w.render(rw, "issues", view, 0)
156 }
157
158 // issueCommentView is a comment with its body already rendered through the
159 // markdown boundary.
160 type issueCommentView struct {
161 metadata.Comment
162 Body template.HTML
163 }
164
165 // issueSidebarView is the data every sidebar fragment needs. Keeping it one
166 // type is what lets a fragment replace one section after an HTMX write without
167 // rebuilding the whole page.
168 type issueSidebarView struct {
169 Page
170 Name string
171 Issue *metadata.Issue
172 CanClose bool
173 CanDiscuss bool
174 CanEditLabels bool
175 CanEditAssignees bool
176 AllLabels []string
177 Assignable []string
178 Error string
179 }
180
181 // issuePageView is the full issue page: the sidebar plus the conversation.
182 type issuePageView struct {
183 issueSidebarView
184 Description template.HTML
185 Comments []issueCommentView
186 Backlinks []git.Commit
187 ThreadError string
188 }
189
190 func (w *Web) issueSidebar(r *http.Request, m *repo.Meta, iss *metadata.Issue, title, errMsg string) issueSidebarView {
191 acl, _ := m.ACL()
192 pr := w.principal(r)
193 canManage := pr != nil && acl != nil && (acl.CanTriage(pr.account.ID) || iss.Author == pr.account.Name)
194 _ = w.Index.EnsureIssues(m)
195 labels, _ := w.Index.Labels(m.ID)
196 assignable := mergeNames(w.assignableAccounts(m), iss.Assignees)
197 return issueSidebarView{
198 Page: w.page(r, title),
199 Name: m.Name,
200 Issue: iss,
201 CanClose: canManage,
202 CanDiscuss: acl != nil && w.canDiscuss(r, acl),
203 CanEditLabels: canManage,
204 CanEditAssignees: canManage,
205 AllLabels: labels,
206 Assignable: assignable,
207 Error: errMsg,
208 }
209 }
210
211 type issueThreadView struct {
212 Page
213 Name string
214 Issue *metadata.Issue
215 Comments []issueCommentView
216 CanDiscuss bool
217 ThreadError string
218 }
219
220 func (w *Web) issueComments(m *repo.Meta, iss *metadata.Issue) []issueCommentView {
221 comments := make([]issueCommentView, 0, len(iss.Comments))
222 for _, c := range iss.Comments {
223 comments = append(comments, issueCommentView{Comment: c, Body: w.renderMarkdown(m.Name, c.Body)})
224 }
225 return comments
226 }
227
228 func (w *Web) issueThread(r *http.Request, m *repo.Meta, iss *metadata.Issue, errMsg string) issueThreadView {
229 acl, _ := m.ACL()
230 return issueThreadView{
231 Page: w.page(r, ""), Name: m.Name, Issue: iss,
232 Comments: w.issueComments(m, iss), CanDiscuss: acl != nil && w.canDiscuss(r, acl),
233 ThreadError: errMsg,
234 }
133 235 }
134 236
135 237 // IssueView renders one issue and its thread. The issue is read from git rather
@@ -149,33 +251,12 @@ func (w *Web) IssueView(rw http.ResponseWriter, r *http.Request) {
149 251 http.NotFound(rw, r)
150 252 return
151 253 }
152 type rendered struct {
153 metadata.Comment
154 Body template.HTML
155 }
156 comments := make([]rendered, 0, len(iss.Comments))
157 for _, c := range iss.Comments {
158 comments = append(comments, rendered{Comment: c, Body: w.renderMarkdown(m.Name, c.Body)})
159 }
160 acl, _ := m.ACL()
161 w.render(rw, "issue", struct {
162 Page
163 Name string
164 Issue *metadata.Issue
165 Description template.HTML
166 Comments []rendered
167 Labels string
168 Backlinks []git.Commit
169 CanDiscuss bool
170 }{
171 Page: w.page(r, m.Name+" · "+iss.Title),
172 Name: m.Name,
173 Issue: iss,
174 Description: w.renderMarkdown(m.Name, iss.Description),
175 Comments: comments,
176 Labels: strings.Join(iss.Labels, ", "),
177 Backlinks: w.backlinks(m, iss.ID),
178 CanDiscuss: acl != nil && w.canDiscuss(r, acl),
254 comments := w.issueComments(m, iss)
255 w.render(rw, "issue", issuePageView{
256 issueSidebarView: w.issueSidebar(r, m, iss, m.Name+" · "+iss.Title, ""),
257 Description: w.renderMarkdown(m.Name, iss.Description),
258 Comments: comments,
259 Backlinks: w.backlinks(m, iss.ID),
179 260 }, 0)
180 261 }
181 262
@@ -191,3 +272,120 @@ func (w *Web) backlinks(m *repo.Meta, id string) []git.Commit {
191 272 }
192 273 return commits
193 274 }
275
276 // NewIssuePage serves the dedicated new-issue form. A form that creates a
277 // durable object belongs on its own page; a popover is for filters and menus.
278 func (w *Web) NewIssuePage(rw http.ResponseWriter, r *http.Request) {
279 m, ok := w.open(rw, r)
280 if !ok {
281 return
282 }
283 pr, ok := w.requireHTMLUser(rw, r)
284 if !ok {
285 return
286 }
287 acl, err := m.ACL()
288 if err != nil || !acl.CanDiscuss(pr.account.ID) {
289 http.Error(rw, "you do not have permission to file issues here", http.StatusForbidden)
290 return
291 }
292 _ = w.Index.EnsureIssues(m)
293 labels, _ := w.Index.Labels(m.ID)
294 w.render(rw, "issue_new", struct {
295 Page
296 Name string
297 Labels []string
298 Assignees []string
299 }{w.page(r, m.Name+" · new issue"), m.Name, labels, w.assignableAccounts(m)}, 0)
300 }
301
302 // tokenPickerData is the small view model behind the token input. It is used for
303 // labels and assignees: Selected is the current set, All is what to suggest, and
304 // Field/Item name the form values the browser and the no-JS fallback submit.
305 type tokenPickerData struct {
306 Selected []string
307 All []string
308 Field string
309 Item string
310 Placeholder string
311 }
312
313 func tokenPicker(selected, all []string, field, item, placeholder string) tokenPickerData {
314 return tokenPickerData{Selected: selected, All: all, Field: field, Item: item, Placeholder: placeholder}
315 }
316
317 // assignableAccounts lists active accounts that can see this repository. A
318 // read-only account is still a valid assignee: the point is that they can read
319 // the issue they were given, even if they cannot comment on it.
320 func (w *Web) assignableAccounts(m *repo.Meta) []string {
321 if w.Ident == nil {
322 return nil
323 }
324 acl, err := m.ACL()
325 if err != nil {
326 return nil
327 }
328 snap := w.Ident.Snapshot()
329 if snap == nil {
330 return nil
331 }
332 var names []string
333 for _, acct := range snap.ByID {
334 if acct.Active() && acl.Can(acct.ID, access.Read) {
335 names = append(names, acct.Name)
336 }
337 }
338 sort.Strings(names)
339 return names
340 }
341
342 // unknownNames reports names that are not in the allowed set. It keeps the
343 // browser from storing an assignee nobody can resolve.
344 func unknownNames(want, allowed []string) []string {
345 set := map[string]bool{}
346 for _, n := range allowed {
347 set[n] = true
348 }
349 var bad []string
350 for _, n := range want {
351 if !set[n] {
352 bad = append(bad, n)
353 }
354 }
355 return bad
356 }
357
358 // mergeNames returns the union of two name lists, sorted, without duplicates.
359 func mergeNames(a, b []string) []string {
360 set := map[string]bool{}
361 for _, n := range a {
362 set[n] = true
363 }
364 for _, n := range b {
365 set[n] = true
366 }
367 return metadata.SortedNames(set)
368 }
369
370 // normalizeLabelSet is what the browser writes: NormalizeLabels plus dedupe, so
371 // checking two spellings of the same label cannot store it twice.
372 func normalizeLabelSet(in []string) []string {
373 set := map[string]bool{}
374 for _, v := range metadata.NormalizeLabels(in) {
375 set[v] = true
376 }
377 return metadata.SortedNames(set)
378 }
379
380 // sameStrings compares the normalised label lists a writer would store.
381 func sameStrings(a, b []string) bool {
382 if len(a) != len(b) {
383 return false
384 }
385 for i := range a {
386 if a[i] != b[i] {
387 return false
388 }
389 }
390 return true
391 }