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.
| 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 | "rodent.software/vole/core/git" |
| 22 | "rodent.software/vole/forge/repo" |
| 23 | "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 | // and the heading word. |
| 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 and EmptyText is what the section says before the |
| 273 | // first comment. |
| 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 | } |
| 299 |