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