vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | ||
|
d37d301f
Keenan Nemetz
19h ago
|
21 | "rodent.software/vole/core/git" |
| 22 | "rodent.software/vole/forge/repo" | |
| 23 | "rodent.software/vole/metadata" | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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, | |
|
c7b9094f
Keenan Nemetz
1d ago
|
257 | // and the heading word. |
|
c717d4eb
Keenan Nemetz
13d ago
|
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 | |
|
c7b9094f
Keenan Nemetz
1d ago
|
272 | // Title is the heading word and EmptyText is what the section says before the |
| 273 | // first comment. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
274 | Title string |
| 275 | EmptyText string | |
| 276 | // Topic is the issue or change request id the comments belong to. | |
| 277 | Topic string | |
| 278 | ||
| 279 | CommentCount int | |
| 280 | Comments []commentView | |
| 281 | CanDiscuss bool | |
| 282 | // Target is the diff line the composer was opened from, when it was opened from | |
| 283 | // a line rather than from the thread. | |
| 284 | Target *commentTarget | |
| 285 | ThreadError string | |
| 286 | } | |
| 287 | ||
| 288 | // thread fills the parts of a discussion section that are the same on both | |
| 289 | // surfaces: the threaded comments and the action menus on their cards. | |
| 290 | func (w *Web) thread(r *http.Request, m *repo.Meta, v threadView, views []commentView) threadView { | |
| 291 | acl, _ := m.ACL() | |
| 292 | v.Page = w.page(r, "") | |
| 293 | v.CanDiscuss = w.canDiscuss(r, acl) | |
| 294 | v.CommentCount = len(views) | |
| 295 | v.Comments = threadComments(views) | |
| 296 | w.markCommentActions(r, m, discussion{base: v.PostURL, target: "#" + v.Section}, v.Comments, v.CanDiscuss) | |
| 297 | return v | |
| 298 | } |