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.
Reorganise the web package around one implementation per rule
c717d4eb92b4257b05fa065918b0071f6302f53e · Keenan Nemetz · 13d ago · parent 4506a4d3
Reorganise the web package around one implementation per rule
server/web had grown by feature, so several rules and widgets existed twice: the
comment stack for issues and change requests, the pager, the file tree, the write
preamble, and the ACL predicates. Each pair agreed today and would drift
tomorrow. No route, stored format, or permission rule changes; the few behaviour
differences found along the way are listed at the end.
Files now say what they answer:
web.go the Web type, render, open, the error pages
page.go Page, pageSection, who, nav
templatefuncs.go the template function map, with the escaping rules noted
ui.go relative time, avatars, breadcrumbs, the pager
markdown.go the goldmark and bluemonday boundary
codebrowser.go the code browser, out of web.go
tokens.go the label/assignee token input
rolepicker.go the role control, out of admin.go
comments.go the comment view, thread builder, action menus, snippet
comment_writes.go add, edit and delete a comment, for both surfaces
write_context.go beginWrite/beginPost/beginAdminPost, fail, refuse
capabilities.go the ACL rules a page and a handler share
audit.go the audit log
request_view.go the change conversation page, out of requests.go
admin_access.go the instance-admin access rules, out of settings.go
A comment is one thing, so it has one view and one thread builder: metadata.Comment
is the same type on both surfaces and only a change request can anchor one to a
diff line. Adding, editing and deleting are one implementation each, with a
commentTopic naming what differs per surface. The pager is one control for issues,
changes and the instance feed; the file tree is one widget for the code browser
and a change's Files tab.
Every write starts at beginWrite (session, body bound, CSRF, repository, rules,
form) and asks capabilities.go for the one rule it needs, so the control a page
draws and the check a handler makes cannot disagree. A rejected write answers
through refuse; a write that fails for another reason answers through fail, which
re-renders the section for HTMX and returns text otherwise.
Templates follow the same split: chrome.html, components.html, diff.html and
role-picker.html hold what more than one surface renders, and each page owns its
fragments. The route table moved to routes.go, one function per surface.
Behaviour changes found while unifying:
- A reply cycle (two comments answering each other, possible through a pushed
metadata branch) used to drop both comments from the page; anything the walk
does not reach is now emitted as a root.
- A rejected anchored comment keeps its "Commenting on path:line" note and
hidden anchor fields, so the reviewer does not have to pick the line again.
- A repository whose access rules cannot be read answers 500 rather than 403;
the account was being blamed for a broken instance.
- Pager wording is now "Showing a-b of n / page x of y" with Previous/Next
everywhere, and the instance feed's page links keep their kind filter.
- The activity feed, issue list and change list share one page control.
Docs: docs/ui-plan.md gains a "Where the code lives" map, the write gate, the
shared comment model, the route table, and rules for rejected writes, shared
template fragments, and the stylesheet's section order. The passkey script is now
an ordinary asset (static/auth.js) under the same CSP as the rest.
server/web/comments.go +299
| @@ -0,0 +1,299 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // A comment is the same object on an issue and on a change request. | ||
| 4 | // | ||
| 5 | // metadata.Comment is one type for both surfaces — one file format, one set of | ||
| 6 | // fields — so the web has one view of it, one thread builder, and one rule for | ||
| 7 | // which actions a viewer gets. A change request adds an anchor and, with it, a | ||
| 8 | // snippet of the lines the comment was written about; an issue never has one, and | ||
| 9 | // the same template renders both. | ||
| 10 | // | ||
| 11 | // The thread is displayed one level deep. The metadata allows a reply to any | ||
| 12 | // comment, so a reply to a reply is flattened under the root and says who it | ||
| 13 | // answers; a reply to a deleted comment keeps its place as a tombstone. | ||
| 14 | |||
| 15 | import ( | ||
| 16 | "html/template" | ||
| 17 | "net/http" | ||
| 18 | "sort" | ||
| 19 | "strings" | ||
| 20 | |||
| 21 | "github.com/rodent-software/vole/core/git" | ||
| 22 | "github.com/rodent-software/vole/forge/repo" | ||
| 23 | "github.com/rodent-software/vole/metadata" | ||
| 24 | ) | ||
| 25 | |||
| 26 | // commentSnippet is a few lines around an anchored review comment. It comes | ||
| 27 | // from the blob the comment names, not the current file, so a stale comment | ||
| 28 | // still shows the code the reviewer actually saw. | ||
| 29 | type commentSnippet struct { | ||
| 30 | Path string | ||
| 31 | Line int | ||
| 32 | Side string | ||
| 33 | Lines []commentSnippetLine | ||
| 34 | FilesURL string | ||
| 35 | } | ||
| 36 | |||
| 37 | // commentSnippetLine is one line of a snippet; Anchor marks the line the comment | ||
| 38 | // was written about, so the card can show which of the surrounding lines it is. | ||
| 39 | type commentSnippetLine struct { | ||
| 40 | No int | ||
| 41 | Text string | ||
| 42 | Anchor bool | ||
| 43 | } | ||
| 44 | |||
| 45 | // commentView is one comment with everything its card needs: the body rendered | ||
| 46 | // through the markdown boundary, its replies, and the URLs and permissions for its | ||
| 47 | // menu. | ||
| 48 | type commentView struct { | ||
| 49 | metadata.Comment | ||
| 50 | Body template.HTML | ||
| 51 | // Source is the raw body, for the edit form and for "copy text". | ||
| 52 | Source string | ||
| 53 | // Current reports whether the file still holds the content this comment was | ||
| 54 | // written about. It is only meaningful on an anchored comment: a stale comment | ||
| 55 | // keeps its place in the thread and loses its marker on the diff. | ||
| 56 | Current bool | ||
| 57 | // Snippet is the anchored lines, when they can still be read. An issue comment | ||
| 58 | // has no anchor, so it has no snippet. | ||
| 59 | Snippet *commentSnippet | ||
| 60 | |||
| 61 | Replies []commentView | ||
| 62 | // ReplyToName names the account this comment answers. ReplyToDeleted marks a | ||
| 63 | // parent that is a tombstone, so the UI can say so instead of showing no one. | ||
| 64 | ReplyToName string | ||
| 65 | ReplyToDeleted bool | ||
| 66 | |||
| 67 | CanReply bool | ||
| 68 | CanEdit bool | ||
| 69 | CanDelete bool | ||
| 70 | CSRF string | ||
| 71 | EditURL string | ||
| 72 | DeleteURL string | ||
| 73 | // Target is the section an edit swaps, so the form can replace the thread it | ||
| 74 | // came from without the template knowing which surface that is. | ||
| 75 | Target string | ||
| 76 | } | ||
| 77 | |||
| 78 | // commentViews renders every comment's body through the markdown boundary. It | ||
| 79 | // does not thread them: a caller that needs to enrich a comment first (a change | ||
| 80 | // request adds the anchor state) works on the flat list, where every comment is | ||
| 81 | // reachable. | ||
| 82 | func (w *Web) commentViews(m *repo.Meta, comments []metadata.Comment) []commentView { | ||
| 83 | out := make([]commentView, 0, len(comments)) | ||
| 84 | for _, c := range comments { | ||
| 85 | out = append(out, commentView{ | ||
| 86 | Comment: c, | ||
| 87 | Body: w.renderMarkdown(m.Name, c.Body), | ||
| 88 | Source: c.Body, | ||
| 89 | }) | ||
| 90 | } | ||
| 91 | return out | ||
| 92 | } | ||
| 93 | |||
| 94 | // threadComments puts every descendant under its top-level root, oldest root | ||
| 95 | // first and replies in the order they were written. A reply whose parent is gone | ||
| 96 | // becomes a root: it is still a comment somebody made. | ||
| 97 | func threadComments(comments []commentView) []commentView { | ||
| 98 | nodes := make(map[string]commentView, len(comments)) | ||
| 99 | for _, c := range comments { | ||
| 100 | nodes[c.ID] = c | ||
| 101 | } | ||
| 102 | for _, c := range comments { | ||
| 103 | if c.ReplyTo != "" { | ||
| 104 | if _, ok := nodes[c.ReplyTo]; !ok { | ||
| 105 | nodes[c.ReplyTo] = commentView{Comment: metadata.Comment{ID: c.ReplyTo, Deleted: true}} | ||
| 106 | } | ||
| 107 | } | ||
| 108 | } | ||
| 109 | ids := make([]string, 0, len(nodes)) | ||
| 110 | for id := range nodes { | ||
| 111 | ids = append(ids, id) | ||
| 112 | } | ||
| 113 | sort.Strings(ids) | ||
| 114 | children := make(map[string][]string, len(nodes)) | ||
| 115 | var roots []string | ||
| 116 | for _, id := range ids { | ||
| 117 | c := nodes[id] | ||
| 118 | if c.ReplyTo == "" || c.ReplyTo == id { | ||
| 119 | roots = append(roots, id) | ||
| 120 | continue | ||
| 121 | } | ||
| 122 | if _, ok := nodes[c.ReplyTo]; ok { | ||
| 123 | children[c.ReplyTo] = append(children[c.ReplyTo], id) | ||
| 124 | } else { | ||
| 125 | roots = append(roots, id) | ||
| 126 | } | ||
| 127 | } | ||
| 128 | out := make([]commentView, 0, len(roots)) | ||
| 129 | placed := make(map[string]bool, len(nodes)) | ||
| 130 | emit := func(id string) { | ||
| 131 | root := nodes[id] | ||
| 132 | seen := map[string]bool{id: true} | ||
| 133 | var replies []string | ||
| 134 | var walk func(string) | ||
| 135 | walk = func(parent string) { | ||
| 136 | for _, child := range children[parent] { | ||
| 137 | if seen[child] { | ||
| 138 | continue | ||
| 139 | } | ||
| 140 | seen[child] = true | ||
| 141 | replies = append(replies, child) | ||
| 142 | walk(child) | ||
| 143 | } | ||
| 144 | } | ||
| 145 | walk(id) | ||
| 146 | sort.Strings(replies) | ||
| 147 | placed[id] = true | ||
| 148 | for _, rid := range replies { | ||
| 149 | reply := nodes[rid] | ||
| 150 | if parent, ok := nodes[reply.ReplyTo]; ok { | ||
| 151 | reply.ReplyToName = parent.Author | ||
| 152 | reply.ReplyToDeleted = parent.Deleted | ||
| 153 | } | ||
| 154 | root.Replies = append(root.Replies, reply) | ||
| 155 | placed[rid] = true | ||
| 156 | } | ||
| 157 | out = append(out, root) | ||
| 158 | } | ||
| 159 | for _, id := range roots { | ||
| 160 | emit(id) | ||
| 161 | } | ||
| 162 | // A cycle has no root: every comment in it answers another. The metadata branch | ||
| 163 | // is written by pushes as well as by this browser, so a hand-edited pair can | ||
| 164 | // arrive that way, and no comment may disappear because of it. Whatever the walk | ||
| 165 | // did not reach becomes a root of its own. | ||
| 166 | for _, id := range ids { | ||
| 167 | if !placed[id] { | ||
| 168 | emit(id) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | return out | ||
| 172 | } | ||
| 173 | |||
| 174 | // discussion is where a thread lives: the URL its comment actions post to and the | ||
| 175 | // section an edit replaces. The two surfaces differ only in those two strings. | ||
| 176 | type discussion struct { | ||
| 177 | base string | ||
| 178 | target string | ||
| 179 | } | ||
| 180 | |||
| 181 | // markCommentActions fills the menu state for a threaded list: who may reply, | ||
| 182 | // edit or delete, and where those forms post. It recurses into replies because | ||
| 183 | // they are rendered inside their root's card. | ||
| 184 | func (w *Web) markCommentActions(r *http.Request, m *repo.Meta, d discussion, comments []commentView, canDiscuss bool) { | ||
| 185 | pr := w.principal(r) | ||
| 186 | acl, _ := m.ACL() | ||
| 187 | name := w.accountName(r) | ||
| 188 | csrf := "" | ||
| 189 | if pr != nil { | ||
| 190 | csrf = pr.session.CSRF | ||
| 191 | } | ||
| 192 | for i := range comments { | ||
| 193 | c := &comments[i] | ||
| 194 | c.CSRF = csrf | ||
| 195 | c.Target = d.target | ||
| 196 | c.CanReply = canDiscuss && !c.Deleted | ||
| 197 | c.CanEdit = !c.Deleted && canModerateComment(acl, name, c.Author) | ||
| 198 | c.CanDelete = c.CanEdit | ||
| 199 | if c.CanEdit { | ||
| 200 | url := d.base + "/" + c.ID | ||
| 201 | c.EditURL = url + "/edit" | ||
| 202 | c.DeleteURL = url + "/delete" | ||
| 203 | } | ||
| 204 | w.markCommentActions(r, m, d, c.Replies, canDiscuss) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | |||
| 208 | // commentSnippet reads the anchored blob and returns the lines around the | ||
| 209 | // comment, with the anchored line marked. The blob cache is per page, so many | ||
| 210 | // comments on one file cost one cat-file. | ||
| 211 | func buildCommentSnippet(g *git.Repo, a *metadata.Anchor, cache map[string][]string) *commentSnippet { | ||
| 212 | const contextLines = 3 | ||
| 213 | if a == nil || a.Blob == "" || a.Line <= 0 { | ||
| 214 | return nil | ||
| 215 | } | ||
| 216 | lines, ok := cache[a.Blob] | ||
| 217 | if !ok { | ||
| 218 | data, err := g.BlobBySHA(a.Blob) | ||
| 219 | if err != nil || len(data) > 1<<20 { | ||
| 220 | cache[a.Blob] = nil | ||
| 221 | return nil | ||
| 222 | } | ||
| 223 | text := string(data) | ||
| 224 | if strings.IndexByte(text, 0) >= 0 { | ||
| 225 | cache[a.Blob] = nil | ||
| 226 | return nil | ||
| 227 | } | ||
| 228 | lines = strings.Split(text, "\n") | ||
| 229 | if len(lines) > 0 && lines[len(lines)-1] == "" { | ||
| 230 | lines = lines[:len(lines)-1] | ||
| 231 | } | ||
| 232 | cache[a.Blob] = lines | ||
| 233 | } | ||
| 234 | if a.Line > len(lines) { | ||
| 235 | return nil | ||
| 236 | } | ||
| 237 | start := a.Line - contextLines | ||
| 238 | if start < 1 { | ||
| 239 | start = 1 | ||
| 240 | } | ||
| 241 | end := a.Line + contextLines | ||
| 242 | if end > len(lines) { | ||
| 243 | end = len(lines) | ||
| 244 | } | ||
| 245 | out := &commentSnippet{Path: a.Path, Line: a.Line, Side: a.Side} | ||
| 246 | for no := start; no <= end; no++ { | ||
| 247 | out.Lines = append(out.Lines, commentSnippetLine{ | ||
| 248 | No: no, Text: lines[no-1], Anchor: no == a.Line, | ||
| 249 | }) | ||
| 250 | } | ||
| 251 | return out | ||
| 252 | } | ||
| 253 | |||
| 254 | // threadView is the discussion section of an issue or a change request: a | ||
| 255 | // heading, the threaded comments, and one composer. Both surfaces render the same | ||
| 256 | // fragment; what differs is the id of the section, the URL the composer posts to, | ||
| 257 | // the heading word, and the command the empty state suggests. | ||
| 258 | type threadView struct { | ||
| 259 | Page | ||
| 260 | Name string | ||
| 261 | // Section is the id of the element HTMX replaces, and Composer the id of the | ||
| 262 | // form a link can jump to. Keeping both explicit is what lets one template | ||
| 263 | // serve two pages without them colliding. | ||
| 264 | Section string | ||
| 265 | Composer string | ||
| 266 | // PostURL is where the composer posts. PushURL is the page a change request | ||
| 267 | // stays on after posting; an issue has no tabs to stay on, so it is empty. | ||
| 268 | PostURL string | ||
| 269 | PushURL string | ||
| 270 | // BackURL is where a "cancel this anchor" link returns. | ||
| 271 | BackURL string | ||
| 272 | // Title is the heading word; EmptyText and Hint are the empty state, where Hint | ||
| 273 | // is the command that writes the same thing from a clone. | ||
| 274 | Title string | ||
| 275 | EmptyText string | ||
| 276 | Hint string | ||
| 277 | // Topic is the issue or change request id the comments belong to. | ||
| 278 | Topic string | ||
| 279 | |||
| 280 | CommentCount int | ||
| 281 | Comments []commentView | ||
| 282 | CanDiscuss bool | ||
| 283 | // Target is the diff line the composer was opened from, when it was opened from | ||
| 284 | // a line rather than from the thread. | ||
| 285 | Target *commentTarget | ||
| 286 | ThreadError string | ||
| 287 | } | ||
| 288 | |||
| 289 | // thread fills the parts of a discussion section that are the same on both | ||
| 290 | // surfaces: the threaded comments and the action menus on their cards. | ||
| 291 | func (w *Web) thread(r *http.Request, m *repo.Meta, v threadView, views []commentView) threadView { | ||
| 292 | acl, _ := m.ACL() | ||
| 293 | v.Page = w.page(r, "") | ||
| 294 | v.CanDiscuss = w.canDiscuss(r, acl) | ||
| 295 | v.CommentCount = len(views) | ||
| 296 | v.Comments = threadComments(views) | ||
| 297 | w.markCommentActions(r, m, discussion{base: v.PostURL, target: "#" + v.Section}, v.Comments, v.CanDiscuss) | ||
| 298 | return v | ||
| 299 | } | ||