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 | } | ||