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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 23m ago 4b8c9e1d
history blame raw 8.6 KiB
1package web
2
3// The Files tab is the review counterpart of the code browser: a tree of the
4// files this change touches beside the diff for the selected file. The change
5// conversation stays on its own page, so neither surface has to share width
6// with the other.
7//
8// The inline review comment lives here too: its composer opens beneath a diff
9// line, and the anchor it carries is what ties a comment to the code the reviewer
10// was looking at.
11
12import (
13 "fmt"
14 "net/http"
15 "strconv"
16 "strings"
17
18 "rodent.software/vole/core/git"
19 "rodent.software/vole/forge/repo"
20 "rodent.software/vole/metadata"
21)
22
23// diffFilePath is the path a changed file is known by in a changed-file tree.
24// Deleted files only have their old path; everything else uses the new one.
25func diffFilePath(f diffFile) string {
26 if f.NewPath != "" {
27 return f.NewPath
28 }
29 return f.OldPath
30}
31
32// reviewFileStatus is the one-letter mark shown beside a file in the tree.
33func reviewFileStatus(f diffFile) string {
34 switch {
35 case f.OldPath == "":
36 return "A"
37 case f.NewPath == "":
38 return "D"
39 case f.OldPath != f.NewPath:
40 return "R"
41 default:
42 return "M"
43 }
44}
45
46// buildReviewTree folds the changed paths into the same nested shape the code
47// browser uses. Only changed files are present, so the tree stays an index of
48// the review rather than a second copy of the repository.
49//
50// Every directory starts open. The tree is an index of what the change touches, and
51// a reviewer looking for a file should not have to unfold each folder to find out
52// whether something is in it; a folder can still be collapsed by hand, and the tree
53// opens the way back to the selected file when a link moves it.
54func buildReviewTree(files []diffFile, active string) []*treeNode {
55 root := &treeNode{Type: "tree"}
56 for _, f := range files {
57 path := diffFilePath(f)
58 if path == "" {
59 continue
60 }
61 parts := strings.Split(path, "/")
62 cur := root
63 for i, part := range parts {
64 nodePath := strings.Join(parts[:i+1], "/")
65 last := i == len(parts)-1
66 if last {
67 cur.Children = append(cur.Children, &treeNode{
68 Name: part, Path: nodePath, Type: "blob",
69 Status: reviewFileStatus(f), Active: nodePath == active,
70 })
71 continue
72 }
73 var next *treeNode
74 for _, child := range cur.Children {
75 if child.Type == "tree" && child.Name == part {
76 next = child
77 break
78 }
79 }
80 if next == nil {
81 next = &treeNode{Name: part, Path: nodePath, Type: "tree", Open: true}
82 cur.Children = append(cur.Children, next)
83 }
84 cur = next
85 }
86 }
87 sortTree(root)
88 return root.Children
89}
90
91// reviewFileDiff reads the patch of the one file the pane shows, and only that
92// file's. Reading every file's hunks to display one is what made a broad change
93// expensive, and cutting the result to fit is what hid the files that came after
94// the cut.
95func reviewFileDiff(g *git.Repo, change reviewChange, f diffFile) ([]diffFile, error) {
96 if len(f.pathspec()) == 0 {
97 return []diffFile{f}, nil
98 }
99 patch, err := g.DiffPaths(change.From, change.To, f.pathspec())
100 if err != nil {
101 return nil, err
102 }
103 for _, p := range parseDiff(patch) {
104 if diffFilePath(p) == diffFilePath(f) {
105 return []diffFile{p}, nil
106 }
107 }
108 // A file git prints no patch for (a mode change, for instance) still belongs
109 // in the pane: the tree lists it, so the pane cannot pretend it is not there.
110 return []diffFile{f}, nil
111}
112
113// reviewMainView is what the Files pane renders, both as the full page's main
114// column and as the HTMX fragment a tree click swaps in.
115type reviewMainView struct {
116 Name string
117 Request *metadata.Request
118 Path string
119 Files []diffFile
120 Added int
121 Removed int
122 AlreadyMerged bool
123 LineCommentURL string
124}
125
126type requestFilesView struct {
127 requestSidebarView
128 Tab string
129 FileCount int
130 Tree []*treeNode
131 Main reviewMainView
132}
133
134// RequestFiles serves the Files tab for one change. /files opens the first
135// changed file; /files/<path> deep-links one file, and tree clicks fetch only
136// the diff pane over HTMX.
137func (w *Web) RequestFiles(rw http.ResponseWriter, r *http.Request) {
138 m, ok := w.open(rw, r)
139 if !ok {
140 return
141 }
142 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
143 if err != nil {
144 w.notFound(rw, r)
145 return
146 }
147 change := w.reviewDiff(m, req)
148 files := change.Files
149 comments := w.requestComments(m, req)
150
151 selected := r.PathValue("path")
152 if selected == "" && len(files) > 0 {
153 selected = diffFilePath(files[0])
154 }
155 var chosen *diffFile
156 for i := range files {
157 if diffFilePath(files[i]) == selected {
158 chosen = &files[i]
159 break
160 }
161 }
162 if selected != "" && chosen == nil {
163 w.notFound(rw, r)
164 return
165 }
166
167 active := ""
168 if chosen != nil {
169 active = diffFilePath(*chosen)
170 }
171 main := reviewMainView{
172 Name: m.Name,
173 Request: req,
174 Path: active,
175 AlreadyMerged: change.Merged,
176 LineCommentURL: "/" + m.Name + "/changes/" + req.ID,
177 }
178 if chosen != nil {
179 main.Files, err = reviewFileDiff(m.Git(), change, *chosen)
180 if err != nil {
181 w.serverError(rw, r, "cannot read that change: "+err.Error())
182 return
183 }
184 attachRequestComments(main.Files, comments)
185 main.Added, main.Removed = diffSummary(main.Files)
186 }
187 if htmxRequest(r) {
188 w.render(rw, "review_main", main, 0)
189 return
190 }
191 w.render(rw, "request_files", requestFilesView{
192 requestSidebarView: w.requestSidebar(r, m, req, "changes", ""),
193 Tab: "files",
194 FileCount: len(files),
195 Tree: buildReviewTree(files, active),
196 Main: main,
197 }, 0)
198}
199
200type commentTarget struct {
201 Path string
202 Line int
203 Side string
204}
205
206// requestInlineCommentView is the row-shaped composer inserted under a diff
207// line. It carries the anchor in hidden fields so the posted comment lands on
208// the same line the reviewer clicked.
209type requestInlineCommentView struct {
210 Page
211 Name string
212 Request *metadata.Request
213 Target *commentTarget
214}
215
216func commentTargetFromQuery(r *http.Request) *commentTarget {
217 q := r.URL.Query()
218 path := strings.TrimSpace(q.Get("path"))
219 side := strings.TrimSpace(q.Get("side"))
220 if side == "" {
221 side = "new"
222 }
223 line, _ := strconv.Atoi(q.Get("line"))
224 if path == "" || line <= 0 || (side != "new" && side != "old") || !safePath(path) {
225 return nil
226 }
227 return &commentTarget{Path: path, Line: line, Side: side}
228}
229
230// RequestCommentForm returns the inline composer for one diff line. The link in
231// the diff is a normal request fallback; with HTMX the row is inserted directly
232// beneath the line instead of sending the reviewer to the bottom of the page.
233func (w *Web) RequestCommentForm(rw http.ResponseWriter, r *http.Request) {
234 pr, ok := w.requireHTMLUser(rw, r)
235 if !ok {
236 return
237 }
238 m, ok := w.open(rw, r)
239 if !ok {
240 return
241 }
242 acl, err := m.ACL()
243 if err != nil || !acl.CanDiscuss(pr.account.Name) {
244 http.Error(rw, "you do not have permission to comment here", http.StatusForbidden)
245 return
246 }
247 req, err := w.requestAt(memoOf(r), m, r.PathValue("id"))
248 if err != nil {
249 w.notFound(rw, r)
250 return
251 }
252 target := commentTargetFromQuery(r)
253 if target == nil {
254 http.Error(rw, "choose a line to comment on", http.StatusBadRequest)
255 return
256 }
257 if _, err := w.commentAnchor(m, req, target.Path, target.Line, target.Side); err != nil {
258 http.Error(rw, err.Error(), http.StatusBadRequest)
259 return
260 }
261 w.render(rw, "request_inline_comment", requestInlineCommentView{
262 Page: w.page(r, ""), Name: m.Name, Request: req, Target: target,
263 }, 0)
264}
265
266// commentAnchor resolves a line anchor against the content the reviewer saw.
267// The blob is what later answers "has this comment gone stale?"; storing only a
268// path and line would make the comment slide to whatever now occupies that line.
269func (w *Web) commentAnchor(m *repo.Meta, req *metadata.Request, path string, line int, side string) (*metadata.Anchor, error) {
270 if !safePath(path) {
271 return nil, fmt.Errorf("invalid anchor path")
272 }
273 if line <= 0 {
274 return nil, fmt.Errorf("invalid anchor line")
275 }
276 commit := req.Head
277 if side == "old" {
278 baseTip, err := m.Git().RevParse("refs/heads/" + req.Base)
279 if err != nil || baseTip == "" {
280 return nil, fmt.Errorf("the base branch is not available")
281 }
282 mergeBase, err := m.Git().MergeBase(baseTip, req.Head)
283 if err != nil || mergeBase == "" {
284 return nil, fmt.Errorf("cannot find the merge base for this change")
285 }
286 commit = mergeBase
287 } else if side != "new" {
288 return nil, fmt.Errorf("anchor side must be new or old")
289 }
290 if commit == "" {
291 return nil, fmt.Errorf("this change has no head commit to comment on")
292 }
293 blob, err := m.Git().RevParseAny(commit + ":" + path)
294 if err != nil || blob == "" {
295 return nil, fmt.Errorf("that file is not part of this change")
296 }
297 return &metadata.Anchor{Path: path, Line: line, Side: side, Blob: blob}, nil
298}
299