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/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.

Blame · server/web/comments.go

Line attribution on main · 298 line(s)

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 }