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.
|
f6f4b1db
Keenan Nemetz
15d ago
|
1 | package 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. | |
|
c717d4eb
Keenan Nemetz
13d ago
|
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. | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
11 | |
| 12 | import ( | |
|
c717d4eb
Keenan Nemetz
13d ago
|
13 | "fmt" |
|
f6f4b1db
Keenan Nemetz
15d ago
|
14 | "net/http" |
|
c717d4eb
Keenan Nemetz
13d ago
|
15 | "strconv" |
|
f6f4b1db
Keenan Nemetz
15d ago
|
16 | "strings" |
| 17 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
18 | "rodent.software/vole/core/git" |
| 19 | "rodent.software/vole/forge/repo" | |
| 20 | "rodent.software/vole/metadata" | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
21 | ) |
| 22 | ||
|
628d4523
Keenan Nemetz
11d ago
|
23 | // diffFilePath is the path a changed file is known by in a changed-file tree. |
|
f6f4b1db
Keenan Nemetz
15d ago
|
24 | // Deleted files only have their old path; everything else uses the new one. |
|
628d4523
Keenan Nemetz
11d ago
|
25 | func diffFilePath(f diffFile) string { |
|
f6f4b1db
Keenan Nemetz
15d ago
|
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. | |
| 33 | func 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. | |
|
43f98bcd
Keenan Nemetz
13d ago
|
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. | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
54 | func buildReviewTree(files []diffFile, active string) []*treeNode { |
| 55 | root := &treeNode{Type: "tree"} | |
| 56 | for _, f := range files { | |
|
628d4523
Keenan Nemetz
11d ago
|
57 | path := diffFilePath(f) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
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 { | |
|
43f98bcd
Keenan Nemetz
13d ago
|
81 | next = &treeNode{Name: part, Path: nodePath, Type: "tree", Open: true} |
|
f6f4b1db
Keenan Nemetz
15d ago
|
82 | cur.Children = append(cur.Children, next) |
| 83 | } | |
| 84 | cur = next | |
| 85 | } | |
| 86 | } | |
| 87 | sortTree(root) | |
| 88 | return root.Children | |
| 89 | } | |
| 90 | ||
|
f64c77e4
Keenan Nemetz
8d ago
|
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. | |
| 95 | func reviewFileDiff(g *git.Repo, change reviewChange, f diffFile) ([]diffFile, error) { | |
|
bef0c093
Keenan Nemetz
8d ago
|
96 | if len(f.pathspec()) == 0 { |
| 97 | return []diffFile{f}, nil | |
| 98 | } | |
|
f64c77e4
Keenan Nemetz
8d ago
|
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 | ||
|
f6f4b1db
Keenan Nemetz
15d ago
|
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. | |
| 115 | type 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 | ||
| 126 | type 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. | |
| 137 | func (w *Web) RequestFiles(rw http.ResponseWriter, r *http.Request) { | |
| 138 | m, ok := w.open(rw, r) | |
| 139 | if !ok { | |
| 140 | return | |
| 141 | } | |
|
5d440e37
Keenan Nemetz
10d ago
|
142 | req, err := w.requestAt(memoOf(r), m, r.PathValue("id")) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
143 | if err != nil { |
|
e84a66f5
Keenan Nemetz
15d ago
|
144 | w.notFound(rw, r) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
145 | return |
| 146 | } | |
|
f64c77e4
Keenan Nemetz
8d ago
|
147 | change := w.reviewDiff(m, req) |
| 148 | files := change.Files | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
149 | comments := w.requestComments(m, req) |
| 150 | ||
| 151 | selected := r.PathValue("path") | |
| 152 | if selected == "" && len(files) > 0 { | |
|
628d4523
Keenan Nemetz
11d ago
|
153 | selected = diffFilePath(files[0]) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
154 | } |
| 155 | var chosen *diffFile | |
| 156 | for i := range files { | |
|
628d4523
Keenan Nemetz
11d ago
|
157 | if diffFilePath(files[i]) == selected { |
|
f6f4b1db
Keenan Nemetz
15d ago
|
158 | chosen = &files[i] |
| 159 | break | |
| 160 | } | |
| 161 | } | |
| 162 | if selected != "" && chosen == nil { | |
|
e84a66f5
Keenan Nemetz
15d ago
|
163 | w.notFound(rw, r) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
164 | return |
| 165 | } | |
| 166 | ||
| 167 | active := "" | |
| 168 | if chosen != nil { | |
|
628d4523
Keenan Nemetz
11d ago
|
169 | active = diffFilePath(*chosen) |
|
f6f4b1db
Keenan Nemetz
15d ago
|
170 | } |
| 171 | main := reviewMainView{ | |
| 172 | Name: m.Name, | |
| 173 | Request: req, | |
| 174 | Path: active, | |
|
f64c77e4
Keenan Nemetz
8d ago
|
175 | AlreadyMerged: change.Merged, |
|
ed03b3d1
Keenan Nemetz
2h ago
|
176 | LineCommentURL: "/" + m.Name + "/changes/" + req.ID, |
|
f6f4b1db
Keenan Nemetz
15d ago
|
177 | } |
| 178 | if chosen != nil { | |
|
f64c77e4
Keenan Nemetz
8d ago
|
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) | |
|
f6f4b1db
Keenan Nemetz
15d ago
|
186 | } |
| 187 | if htmxRequest(r) { | |
| 188 | w.render(rw, "review_main", main, 0) | |
| 189 | return | |
| 190 | } | |
| 191 | w.render(rw, "request_files", requestFilesView{ | |
|
de84c32b
Keenan Nemetz
5d ago
|
192 | requestSidebarView: w.requestSidebar(r, m, req, "changes", ""), |
|
f6f4b1db
Keenan Nemetz
15d ago
|
193 | Tab: "files", |
| 194 | FileCount: len(files), | |
| 195 | Tree: buildReviewTree(files, active), | |
| 196 | Main: main, | |
| 197 | }, 0) | |
| 198 | } | |
|
c717d4eb
Keenan Nemetz
13d ago
|
199 | |
| 200 | type 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. | |
| 209 | type requestInlineCommentView struct { | |
| 210 | Page | |
| 211 | Name string | |
| 212 | Request *metadata.Request | |
| 213 | Target *commentTarget | |
| 214 | } | |
| 215 | ||
| 216 | func 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. | |
| 233 | func (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 | } | |
|
5d440e37
Keenan Nemetz
10d ago
|
247 | req, err := w.requestAt(memoOf(r), m, r.PathValue("id")) |
|
c717d4eb
Keenan Nemetz
13d ago
|
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. | |
| 269 | func (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 | } |