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.
Build out change review and threaded discussions
f6f4b1db24292d1df877680714952a9c568e2b78 · Keenan Nemetz · 15d ago · parent 564acd4b
Build out change review and threaded discussions - Add Conversation, Files and Runs tabs to change requests. Files uses the code-browser shape with a changed-file tree and one selected-file diff; Runs lists the head commit's workflow results. - Show anchored review comments beneath the line, with a collapsed excerpt read from the exact blob that was reviewed, and return posted anchors to their line note. - Add comment threads: any comment can be replied to, the UI flattens the descendants under one root, reply context links back to the parent, and reply targets feed a single composer at the foot of the discussion. - Collapse reply groups while keeping the root visible, and scroll to the composer when Reply is selected. - Add comment context menus with Reply, Copy text, Copy link, Edit and Delete. Add web edit/delete routes with author or admin checks; deletion leaves a tombstone so replies keep their thread. - Support --reply-to in the issue and change CLI commands, and treat a missing parent as a tombstone rather than an invalid tree. - Fix the diff comment gutter, action button colours, reply labels, and keep the Files tree and diff pane aligned. Update metadata validation, web templates, CSS, JavaScript, tests, and the CLI/change-request/issue docs.
server/web/request_files.go +177
| @@ -0,0 +1,177 @@ | |||
| 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 | import ( | ||
| 9 | "net/http" | ||
| 10 | "strings" | ||
| 11 | |||
| 12 | "github.com/rodent-software/vole/metadata" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // reviewTreeView is the recursive context for the changed-file tree. The | ||
| 16 | // request id is carried alongside the repository name because every row links | ||
| 17 | // back into this change, not into the source browser. | ||
| 18 | type reviewTreeView struct { | ||
| 19 | Name string | ||
| 20 | RequestID string | ||
| 21 | Nodes []*treeNode | ||
| 22 | } | ||
| 23 | |||
| 24 | func reviewTreeContext(name, requestID string, nodes []*treeNode) reviewTreeView { | ||
| 25 | return reviewTreeView{Name: name, RequestID: requestID, Nodes: nodes} | ||
| 26 | } | ||
| 27 | |||
| 28 | // reviewFilePath is the path a changed file is known by in the review tree. | ||
| 29 | // Deleted files only have their old path; everything else uses the new one. | ||
| 30 | func reviewFilePath(f diffFile) string { | ||
| 31 | if f.NewPath != "" { | ||
| 32 | return f.NewPath | ||
| 33 | } | ||
| 34 | return f.OldPath | ||
| 35 | } | ||
| 36 | |||
| 37 | // reviewFileStatus is the one-letter mark shown beside a file in the tree. | ||
| 38 | func reviewFileStatus(f diffFile) string { | ||
| 39 | switch { | ||
| 40 | case f.OldPath == "": | ||
| 41 | return "A" | ||
| 42 | case f.NewPath == "": | ||
| 43 | return "D" | ||
| 44 | case f.OldPath != f.NewPath: | ||
| 45 | return "R" | ||
| 46 | default: | ||
| 47 | return "M" | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | // buildReviewTree folds the changed paths into the same nested shape the code | ||
| 52 | // browser uses. Only changed files are present, so the tree stays an index of | ||
| 53 | // the review rather than a second copy of the repository. | ||
| 54 | func buildReviewTree(files []diffFile, active string) []*treeNode { | ||
| 55 | root := &treeNode{Type: "tree"} | ||
| 56 | for _, f := range files { | ||
| 57 | path := reviewFilePath(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"} | ||
| 82 | cur.Children = append(cur.Children, next) | ||
| 83 | } | ||
| 84 | if active == nodePath || strings.HasPrefix(active, nodePath+"/") { | ||
| 85 | next.Open = true | ||
| 86 | } | ||
| 87 | cur = next | ||
| 88 | } | ||
| 89 | } | ||
| 90 | sortTree(root) | ||
| 91 | return root.Children | ||
| 92 | } | ||
| 93 | |||
| 94 | // reviewMainView is what the Files pane renders, both as the full page's main | ||
| 95 | // column and as the HTMX fragment a tree click swaps in. | ||
| 96 | type reviewMainView struct { | ||
| 97 | Name string | ||
| 98 | Request *metadata.Request | ||
| 99 | Path string | ||
| 100 | Files []diffFile | ||
| 101 | Added int | ||
| 102 | Removed int | ||
| 103 | Truncated bool | ||
| 104 | AlreadyMerged bool | ||
| 105 | LineCommentURL string | ||
| 106 | } | ||
| 107 | |||
| 108 | type requestFilesView struct { | ||
| 109 | requestSidebarView | ||
| 110 | Tab string | ||
| 111 | FileCount int | ||
| 112 | Tree []*treeNode | ||
| 113 | Main reviewMainView | ||
| 114 | } | ||
| 115 | |||
| 116 | // RequestFiles serves the Files tab for one change. /files opens the first | ||
| 117 | // changed file; /files/<path> deep-links one file, and tree clicks fetch only | ||
| 118 | // the diff pane over HTMX. | ||
| 119 | func (w *Web) RequestFiles(rw http.ResponseWriter, r *http.Request) { | ||
| 120 | m, ok := w.open(rw, r) | ||
| 121 | if !ok { | ||
| 122 | return | ||
| 123 | } | ||
| 124 | req, err := metadata.GetRequest(m.Git(), r.PathValue("id")) | ||
| 125 | if err != nil { | ||
| 126 | http.NotFound(rw, r) | ||
| 127 | return | ||
| 128 | } | ||
| 129 | files, truncated, alreadyMerged := w.requestDiff(m, req) | ||
| 130 | comments := w.requestComments(m, req) | ||
| 131 | attachRequestComments(files, comments) | ||
| 132 | |||
| 133 | selected := r.PathValue("path") | ||
| 134 | if selected == "" && len(files) > 0 { | ||
| 135 | selected = reviewFilePath(files[0]) | ||
| 136 | } | ||
| 137 | var chosen *diffFile | ||
| 138 | for i := range files { | ||
| 139 | if reviewFilePath(files[i]) == selected { | ||
| 140 | chosen = &files[i] | ||
| 141 | break | ||
| 142 | } | ||
| 143 | } | ||
| 144 | if selected != "" && chosen == nil { | ||
| 145 | http.NotFound(rw, r) | ||
| 146 | return | ||
| 147 | } | ||
| 148 | |||
| 149 | active := "" | ||
| 150 | if chosen != nil { | ||
| 151 | active = reviewFilePath(*chosen) | ||
| 152 | } | ||
| 153 | main := reviewMainView{ | ||
| 154 | Name: m.Name, | ||
| 155 | Request: req, | ||
| 156 | Path: active, | ||
| 157 | Truncated: truncated, | ||
| 158 | AlreadyMerged: alreadyMerged, | ||
| 159 | LineCommentURL: "/r/" + m.Name + "/requests/" + req.ID, | ||
| 160 | } | ||
| 161 | if chosen != nil { | ||
| 162 | main.Files = []diffFile{*chosen} | ||
| 163 | main.Added = chosen.Added | ||
| 164 | main.Removed = chosen.Removed | ||
| 165 | } | ||
| 166 | if htmxRequest(r) { | ||
| 167 | w.render(rw, "review_main", main, 0) | ||
| 168 | return | ||
| 169 | } | ||
| 170 | w.render(rw, "request_files", requestFilesView{ | ||
| 171 | requestSidebarView: w.requestSidebar(r, m, req, m.Name+" · "+req.Title+" · files", ""), | ||
| 172 | Tab: "files", | ||
| 173 | FileCount: len(files), | ||
| 174 | Tree: buildReviewTree(files, active), | ||
| 175 | Main: main, | ||
| 176 | }, 0) | ||
| 177 | } | ||