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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 23m ago
4b8c9e1d
| 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. |
| 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 | |
| 12 | import ( |
| 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. |
| 25 | func 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. |
| 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. |
| 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. |
| 54 | func 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. |
| 95 | func 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. |
| 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 | } |
| 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 | |
| 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 | } |
| 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. |
| 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 | } |
| 299 |