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