web: comment on a diff line and reply to threads !120

merged merged by cmc on 2026-08-30 06:07 UTC · krz/gitbay:web-diff-threads into main

10 files changed, +271 −22

Layout: unified · split

e2e/mrweb_test.go +117
@@ -7,6 +7,7 @@ import (
7 "os" 7 "os"
8 "path/filepath" 8 "path/filepath"
9 "regexp" 9 "regexp"
10 "strconv"
10 "strings" 11 "strings"
11 "testing" 12 "testing"
12) 13)
@@ -214,3 +215,119 @@ func TestMRWebCreate(t *testing.T) {
214 t.Fatalf("created MR wrong: %+v", show) 215 t.Fatalf("created MR wrong: %+v", show)
215 } 216 }
216} 217}
218
219// TestMRWebDiffThreads opens a review thread on a diff line and replies to
220// it from the browser. The CLI's view of the threads afterwards is what
221// proves the page dispatched mr diff-comment rather than writing its own
222// rows.
223func TestMRWebDiffThreads(t *testing.T) {
224 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
225 aliceKey := inst.newKey(t, "alice")
226 inst.admin(t, "admin", "user", "create", "alice",
227 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
228
229 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
230 t.Fatalf("repo create: %s", errOut)
231 }
232 env := inst.gitEnv(aliceKey)
233 work := t.TempDir()
234 mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
235 dir := filepath.Join(work, "w")
236 os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {\n}\n"), 0o644)
237 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
238 mustGit(t, dir, env, "add", ".")
239 mustGit(t, dir, env, "commit", "-q", "-m", "base")
240 mustGit(t, dir, env, "push", "-q", "origin", "main")
241 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
242 os.WriteFile(filepath.Join(dir, "main.go"),
243 []byte("package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n"), 0o644)
244 mustGit(t, dir, env, "add", ".")
245 mustGit(t, dir, env, "commit", "-q", "-m", "add greeting")
246 mustGit(t, dir, env, "push", "-q", "origin", "feat")
247 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib",
248 "--source", "feat", "--target", "main", "--title", "'greeting'"); code != 0 {
249 t.Fatalf("mr create: %s", errOut)
250 }
251
252 mrURL := inst.base() + "/alice/lib/mrs/1"
253 alice := inst.login(t, aliceKey)
254
255 // The gutter carries the handle, and following it renders the form
256 // anchored to that line. There is no JavaScript, so the anchor has to
257 // survive a round trip in the query.
258 _, body := browserGet(t, alice, mrURL+"?view=diff")
259 if !strings.Contains(body, "cpath=main.go&cline=6&cside=new") {
260 t.Fatalf("no comment handle in the diff gutter:\n%s", body)
261 }
262 _, body = browserGet(t, alice, mrURL+"?view=diff&cpath=main.go&cline=6&cside=new")
263 if !strings.Contains(body, `id="compose"`) || !strings.Contains(body, `name="line" value="6"`) {
264 t.Fatalf("compose form not rendered:\n%s", body)
265 }
266
267 if status, _ := browserPost(t, alice, mrURL+"/diff-comment", url.Values{
268 "path": {"main.go"}, "line": {"6"}, "side": {"new"},
269 "body": {"use log instead of fmt"}}); status != 200 {
270 t.Fatalf("open thread: %d", status)
271 }
272 threads := inst.mrThreads(t, aliceKey, "alice/lib", "1")
273 if len(threads) != 1 || threads[0].Path != "main.go" || threads[0].Line != 6 {
274 t.Fatalf("thread not anchored: %+v", threads)
275 }
276 if len(threads[0].Comments) != 1 || threads[0].Comments[0].Body != "use log instead of fmt" {
277 t.Fatalf("comment body not stored: %+v", threads[0].Comments)
278 }
279
280 // The rendered thread offers reply and resolve to its author.
281 _, body = browserGet(t, alice, mrURL+"?view=diff")
282 if !strings.Contains(body, `name="reply" value="`+strconv.FormatInt(threads[0].ID, 10)+`"`) {
283 t.Fatalf("no reply form on the thread:\n%s", body)
284 }
285 if !strings.Contains(body, `value="resolve"`) {
286 t.Fatalf("no resolve control on the thread:\n%s", body)
287 }
288
289 if status, _ := browserPost(t, alice, mrURL+"/diff-comment", url.Values{
290 "reply": {strconv.FormatInt(threads[0].ID, 10)}, "body": {"agreed, switching"}}); status != 200 {
291 t.Fatalf("reply: %d", status)
292 }
293 threads = inst.mrThreads(t, aliceKey, "alice/lib", "1")
294 if len(threads) != 1 || len(threads[0].Comments) != 2 ||
295 threads[0].Comments[1].Body != "agreed, switching" {
296 t.Fatalf("reply not on the thread: %+v", threads)
297 }
298
299 // An empty body is refused, and says so on the page it returns to.
300 _, body = browserPost(t, alice, mrURL+"/diff-comment", url.Values{
301 "reply": {strconv.FormatInt(threads[0].ID, 10)}, "body": {" "}})
302 if !strings.Contains(body, "empty comment") {
303 t.Fatalf("empty reply not refused:\n%s", body)
304 }
305}
306
307// mrThread is one review thread as mr threads --json reports it.
308type mrThread struct {
309 ID int64 `json:"id"`
310 Path string `json:"path"`
311 Side string `json:"side"`
312 Line int64 `json:"line"`
313 Comments []struct {
314 Author string `json:"author"`
315 Body string `json:"body"`
316 } `json:"comments"`
317}
318
319// mrThreads reads the review threads on a merge request over SSH.
320func (i *instance) mrThreads(t *testing.T, key, repo, n string) []mrThread {
321 t.Helper()
322 out, errOut, code := i.ssh(t, key, "", "mr", "threads", repo, n, "--json")
323 if code != 0 {
324 t.Fatalf("mr threads: %s", errOut)
325 }
326 var env struct {
327 Data []mrThread `json:"data"`
328 }
329 if err := json.Unmarshal([]byte(out), &env); err != nil {
330 t.Fatalf("mr threads json: %v", err)
331 }
332 return env.Data
333}
internal/httpd/control.go +4 −4
@@ -41,10 +41,10 @@ func (s *Server) runControl(u store.User, argv []string) (out string, msg string
41} 41}
42 42
43// runControlStdin is runControl for the handful of commands whose input 43// runControlStdin is runControl for the handful of commands whose input
44// arrives on stdin. Public keys are the only such input the web accepts: 44// arrives on stdin: public keys, and review comment bodies. Neither is
45// they are not secret, and pasting one into a browser is how people who 45// secret, and both are prose or paste rather than a flag value. Secrets,
46// have not set up the CLI get their first key registered. Secrets, tokens 46// tokens and mirror credentials remain SSHOnly and are refused by the
47// and mirror credentials remain SSHOnly and are refused by the dispatcher. 47// dispatcher.
48func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { 48func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
49 var stdout, stderr bytes.Buffer 49 var stdout, stderr bytes.Buffer
50 ctx := &control.Ctx{ 50 ctx := &control.Ctx{
internal/httpd/diff.go +1
@@ -22,6 +22,7 @@ type diffLine struct {
22 NewLine int64 // line number in the new file (0 when absent) 22 NewLine int64 // line number in the new file (0 when absent)
23 OldLine int64 // line number in the old file (0 when absent) 23 OldLine int64 // line number in the old file (0 when absent)
24 Threads []diffThread 24 Threads []diffThread
25 Compose bool // render the new-thread form under this line
25} 26}
26 27
27// diffFile is one file's worth of a unified diff: the header lines are 28// diffFile is one file's worth of a unified diff: the header lines are
internal/httpd/mractions.go +48
@@ -30,6 +30,19 @@ func (s *Server) mrRedirect(w http.ResponseWriter, r *http.Request, msg string)
30 http.Redirect(w, r, dest, http.StatusSeeOther) 30 http.Redirect(w, r, dest, http.StatusSeeOther)
31} 31}
32 32
33// mrDiffRedirect returns to the diff view, where the thread controls are.
34func (s *Server) mrDiffRedirect(w http.ResponseWriter, r *http.Request, msg string) {
35 dest := fmt.Sprintf("/%s/%s/mrs/%s?view=diff",
36 r.PathValue("owner"), r.PathValue("repo"), r.PathValue("n"))
37 if msg != "" {
38 if len(msg) > 300 {
39 msg = msg[:300]
40 }
41 dest += "&e=" + url.QueryEscape(msg)
42 }
43 http.Redirect(w, r, dest, http.StatusSeeOther)
44}
45
33// mrArgs builds "<verb> owner/name <n>" for the mr command family. 46// mrArgs builds "<verb> owner/name <n>" for the mr command family.
34func mrArgs(r *http.Request, verb string, extra ...string) []string { 47func mrArgs(r *http.Request, verb string, extra ...string) []string {
35 repo := r.PathValue("owner") + "/" + r.PathValue("repo") 48 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
@@ -73,6 +86,41 @@ func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.U
73 s.mrRedirect(w, r, msg) 86 s.mrRedirect(w, r, msg)
74} 87}
75 88
89// mrDiffCommentSubmit opens a review thread on a diff line, or replies to
90// one. The body goes in on stdin: it is user prose, and argv is visible in
91// /proc.
92func (s *Server) mrDiffCommentSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
93 body := strings.TrimSpace(r.FormValue("body"))
94 if body == "" {
95 s.mrDiffRedirect(w, r, "empty comment")
96 return
97 }
98 var extra []string
99 if reply := strings.TrimSpace(r.FormValue("reply")); reply != "" {
100 if _, err := strconv.ParseInt(reply, 10, 64); err != nil {
101 s.mrDiffRedirect(w, r, "bad thread id")
102 return
103 }
104 extra = []string{"--reply", reply}
105 } else {
106 path := strings.TrimSpace(r.FormValue("path"))
107 line := strings.TrimSpace(r.FormValue("line"))
108 if n, err := strconv.ParseInt(line, 10, 64); path == "" || err != nil || n < 1 {
109 s.mrDiffRedirect(w, r, "pick a line to comment on")
110 return
111 }
112 extra = []string{"--path", path, "--line", line}
113 if r.FormValue("side") == "old" {
114 extra = append(extra, "--old")
115 }
116 }
117 msg, ok := s.runControlStdin(u, mrArgs(r, "diff-comment", append(extra, "--file", "-")...), body)
118 if ok {
119 msg = ""
120 }
121 s.mrDiffRedirect(w, r, msg)
122}
123
76// mrThreadSubmit resolves or reopens one review thread. 124// mrThreadSubmit resolves or reopens one review thread.
77func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.User) { 125func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
78 verb := "resolve" 126 verb := "resolve"
internal/httpd/routes.go +2
@@ -151,6 +151,8 @@ func (s *Server) Routes() []Route {
151 Handler: s.checkOrigin(s.requireUser(s.mrCloseSubmit))}, 151 Handler: s.checkOrigin(s.requireUser(s.mrCloseSubmit))},
152 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/thread", Mutating: true, 152 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/thread", Mutating: true,
153 Handler: s.checkOrigin(s.requireUser(s.mrThreadSubmit))}, 153 Handler: s.checkOrigin(s.requireUser(s.mrThreadSubmit))},
154 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/diff-comment", Mutating: true,
155 Handler: s.checkOrigin(s.requireUser(s.mrDiffCommentSubmit))},
154 Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", 156 Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}",
155 Handler: s.requireUser(s.editForm)}, 157 Handler: s.requireUser(s.editForm)},
156 Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true, 158 Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true,
internal/httpd/web.go +55 −8
@@ -13,6 +13,7 @@ import (
13 "gitbay.org/gitbay/internal/policy" 13 "gitbay.org/gitbay/internal/policy"
14 "html/template" 14 "html/template"
15 "net/http" 15 "net/http"
16 "net/url"
16 "path" 17 "path"
17 "regexp" 18 "regexp"
18 "sort" 19 "sort"
@@ -1191,16 +1192,30 @@ func renderReadme(name string, raw []byte) template.HTML {
1191} 1192}
1192 1193
1193type diffThread struct { 1194type diffThread struct {
1194 ID int64 1195 ID int64
1195 Resolved string 1196 Resolved string
1196 Stale bool 1197 Stale bool
1197 Comments []renderedComment 1198 CanResolve bool
1199 Comments []renderedComment
1200}
1201
1202// reviewRights decides which thread controls a viewer sees. mr resolve
1203// admits the thread author, the MR author, or anyone with write, so the
1204// page needs all three to render the button truthfully.
1205type reviewRights struct {
1206 Viewer string
1207 MRAuthor string
1208 Write bool
1209}
1210
1211func (r reviewRights) canResolve(threadAuthor string) bool {
1212 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1198} 1213}
1199 1214
1200// attachThreads injects review threads under their anchored diff lines; 1215// attachThreads injects review threads under their anchored diff lines;
1201// threads whose anchor no longer appears (stale after force-push, or on a 1216// threads whose anchor no longer appears (stale after force-push, or on a
1202// context line outside the current diff) are returned separately. 1217// context line outside the current diff) are returned separately.
1203func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer) ([]diffFile, []diffThread) { 1218func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1204 type anchor struct { 1219 type anchor struct {
1205 path string 1220 path string
1206 side string 1221 side string
@@ -1214,7 +1229,8 @@ func attachThreads(files []diffFile, comments []store.DiffComment, headSHA strin
1214 for _, cm := range comments { 1229 for _, cm := range comments {
1215 if cm.ReplyTo == 0 { 1230 if cm.ReplyTo == 0 {
1216 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA, 1231 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1217 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}} 1232 CanResolve: rights.canResolve(cm.Author),
1233 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1218 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} 1234 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1219 order = append(order, cm.ID) 1235 order = append(order, cm.ID)
1220 } else if th, ok := threads[cm.ReplyTo]; ok { 1236 } else if th, ok := threads[cm.ReplyTo]; ok {
@@ -1252,6 +1268,32 @@ func attachThreads(files []diffFile, comments []store.DiffComment, headSHA strin
1252 return files, unplaced 1268 return files, unplaced
1253} 1269}
1254 1270
1271// markCompose opens the new-thread form under one diff line. There is no
1272// JavaScript, so "comment on this line" is a plain GET carrying the
1273// anchor and the page renders the form where the reader asked for it.
1274func markCompose(files []diffFile, q url.Values) {
1275 path := q.Get("cpath")
1276 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1277 if path == "" || line < 1 {
1278 return
1279 }
1280 old := q.Get("cside") == "old"
1281 for f := range files {
1282 for i := range files[f].Lines {
1283 ln := &files[f].Lines[i]
1284 if ln.Path != path {
1285 continue
1286 }
1287 if (old && ln.Class == "del" && ln.OldLine == line) ||
1288 (!old && ln.Class != "del" && ln.NewLine == line) {
1289 ln.Compose = true
1290 files[f].Open = true
1291 return
1292 }
1293 }
1294 }
1295}
1296
1255type sigView struct { 1297type sigView struct {
1256 State string 1298 State string
1257 Signer string 1299 Signer string
@@ -1577,8 +1619,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1577 } 1619 }
1578 } 1620 }
1579 md := s.ugcFor(r, p.Repo) 1621 md := s.ugcFor(r, p.Repo)
1622 canWrite := s.canWriteRepo(r, p.Repo)
1580 var detachedThreads []diffThread 1623 var detachedThreads []diffThread
1581 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md) 1624 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1625 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1626 if p.Viewer != "" {
1627 markCompose(files, r.URL.Query())
1628 }
1582 stat := statOf(files) 1629 stat := statOf(files)
1583 // The commits this MR carries: base..head, the same range as the diff. 1630 // The commits this MR carries: base..head, the same range as the diff.
1584 type commitRow struct { 1631 type commitRow struct {
@@ -1632,7 +1679,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1632 DetachedThreads []diffThread 1679 DetachedThreads []diffThread
1633 }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md), 1680 }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md),
1634 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author), 1681 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1635 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads}) 1682 canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads})
1636} 1683}
1637 1684
1638func (s *Server) refs(w http.ResponseWriter, r *http.Request) { 1685func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
internal/web/static/style.css +11
@@ -972,6 +972,12 @@ table.difftable tr.hunk td, table.difftable tr.dmeta td {
972} 972}
973table.difftable tr.hunk td.src { color: var(--accent); } 973table.difftable tr.hunk td.src { color: var(--accent); }
974table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background: var(--bg); } 974table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background: var(--bg); }
975/* a line number is the handle for commenting on that line; it stays a
976 plain number until pointed at, so the gutter does not read as a wall of
977 links */
978table.difftable td.ln a.cmt { color: inherit; text-decoration: none; }
979table.difftable td.ln a.cmt:hover,
980table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: underline; }
975 981
976/* badges: signature and check states. Semantic colors stay distinct: 982/* badges: signature and check states. Semantic colors stay distinct:
977 green verified/success, amber pending/stale, red bad/failure, muted 983 green verified/success, amber pending/stale, red bad/failure, muted
@@ -1156,6 +1162,11 @@ details.editbox input[type="text"] { width: 100%; }
1156.thread .when { color: var(--muted); } 1162.thread .when { color: var(--muted); }
1157.thread .rendered p { margin: var(--sp-1) 0; } 1163.thread .rendered p { margin: var(--sp-1) 0; }
1158.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; } 1164.thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; }
1165.thread.composing { border-left-color: var(--mark); }
1166.thread details.threadreply { margin: var(--sp-2) 0 0; }
1167.thread details.threadreply > summary { color: var(--accent); font-size: var(--fs-1); cursor: pointer; }
1168.thread textarea { width: 100%; }
1169.thread.composing p, .thread details.threadreply p { margin: var(--sp-2) 0 0; }
1159 1170
1160/* forms: every control shares one shell, so a select reads as a sibling 1171/* forms: every control shares one shell, so a select reads as a sibling
1161 of the text inputs beside it rather than as an OS control dropped in */ 1172 of the text inputs beside it rather than as an OS control dropped in */
internal/web/templates/commit.html +1 −1
@@ -9,5 +9,5 @@
9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p> 9 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
10</div> 10</div>
11<pre class="message">{{.Message}}</pre> 11<pre class="message">{{.Message}}</pre>
12{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Can" false}} 12{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}}
13{{end}} 13{{end}}
internal/web/templates/layout.html +30 −7
@@ -114,10 +114,10 @@
114{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}} 114{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
115 115
116{{/* difffiles renders a parsed diff: one foldable section per file, with 116{{/* difffiles renders a parsed diff: one foldable section per file, with
117 line-number gutters and review threads inline. Base and Can are the 117 line-number gutters and review threads inline. Base is the MR's
118 MR's comment endpoint and the viewer's write permission; the commit 118 endpoint and Viewer the signed-in account; the commit page passes
119 page passes neither and gets the same diff without thread controls. */}} 119 neither and gets the same diff without review controls. */}}
120{{define "difffiles"}}{{$base := .Base}}{{$can := .Can}} 120{{define "difffiles"}}{{$base := .Base}}{{$viewer := .Viewer}}
121{{range .Files}}<details class="difffold"{{if .Open}} open{{end}}> 121{{range .Files}}<details class="difffold"{{if .Open}} open{{end}}>
122 <summary> 122 <summary>
123 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span> 123 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span>
@@ -128,11 +128,34 @@
128 {{else}}<div class="tablewrap"><table class="difftable"> 128 {{else}}<div class="tablewrap"><table class="difftable">
129 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr> 129 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
130 {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr> 130 {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
131 {{else}}<tr class="{{.Class}}"><td class="ln">{{if .OldLine}}{{.OldLine}}{{end}}</td><td class="ln">{{if .NewLine}}{{.NewLine}}{{end}}</td><td class="src chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td></tr> 131 {{else}}<tr class="{{.Class}}"><td class="ln">{{if .OldLine}}{{if eq .Class "del"}}{{template "cmtln" dict "N" .OldLine "Path" .Path "Side" "old" "Base" $base "Viewer" $viewer}}{{else}}{{.OldLine}}{{end}}{{end}}</td><td class="ln">{{if .NewLine}}{{template "cmtln" dict "N" .NewLine "Path" .Path "Side" "new" "Base" $base "Viewer" $viewer}}{{end}}</td><td class="src chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td></tr>
132 {{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3"><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $can}}</div></td></tr> 132 {{if .Compose}}<tr class="threadrow"><td colspan="3"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing">
133 <input type="hidden" name="path" value="{{.Path}}">
134 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
135 <input type="hidden" name="side" value="{{if eq .Class "del"}}old{{else}}new{{end}}">
136 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p>
137 <p><button type="submit">Comment</button> <a href="{{$base}}?view=diff">Cancel</a></p>
138 </form></td></tr>
139 {{end}}{{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3">{{template "thread" dict "T" . "Base" $base "Viewer" $viewer "Class" ""}}</td></tr>
133 {{end}}{{end}} 140 {{end}}{{end}}
134 </table></div>{{end}} 141 </table></div>{{end}}
135</details> 142</details>
136{{end}}{{end}} 143{{end}}{{end}}
137 144
138{{define "threadact"}}{{if .Can}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{.ID}}"><button type="submit" name="action" value="{{if .Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if .Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}{{end}} 145{{/* cmtln turns a line number into the link that opens the comment form
146 on that line. No JavaScript: the anchor travels in the query. */}}
147{{define "cmtln"}}{{if and .Viewer .Base}}<a class="cmt" title="Comment on this line" href="{{.Base}}?view=diff&amp;cpath={{.Path}}&amp;cline={{.N}}&amp;cside={{.Side}}#compose">{{.N}}</a>{{else}}{{.N}}{{end}}{{end}}
148
149{{/* thread renders one review thread with its reply and resolve controls.
150 Class carries "stale" for threads whose anchor is gone. */}}
151{{define "thread"}}{{$t := .T}}<div class="thread{{if $t.Resolved}} resolved{{end}}{{if .Class}} {{.Class}}{{end}}">
152{{if or $t.Resolved (and .Class $t.Stale)}}<p class="threadstate">{{if and .Class $t.Stale}}stale{{end}}{{if $t.Resolved}}{{if and .Class $t.Stale}} · {{end}}resolved by {{$t.Resolved}}{{end}}</p>{{end}}
153{{range $t.Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}
154{{if .Viewer}}<details class="threadreply"><summary>Reply</summary>
155<form method="post" action="{{.Base}}/diff-comment">
156 <input type="hidden" name="reply" value="{{$t.ID}}">
157 <p><textarea name="body" aria-label="Reply to thread {{$t.ID}}" rows="2" placeholder="Reply as {{.Viewer}}"></textarea></p>
158 <p><button type="submit">Reply</button></p>
159</form></details>{{end}}
160{{if $t.CanResolve}}<form method="post" action="{{.Base}}/thread" class="threadact"><input type="hidden" name="thread" value="{{$t.ID}}"><button type="submit" name="action" value="{{if $t.Resolved}}unresolve{{else}}resolve{{end}}" class="linklike">{{if $t.Resolved}}Reopen thread{{else}}Resolve thread{{end}}</button></form>{{end}}
161</div>{{end}}
internal/web/templates/mr.html +2 −2
@@ -35,7 +35,7 @@
35</article>{{end}} 35</article>{{end}}
36{{end}} 36{{end}}
37{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> 37{{if .DetachedThreads}}<h2>Threads on earlier revisions</h2>
38{{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}{{template "threadact" dict "ID" .ID "Resolved" .Resolved "Base" $base "Can" $.CanWrite}}</div>{{end}}{{end}} 38{{range .DetachedThreads}}{{template "thread" dict "T" . "Base" $base "Viewer" $.Viewer "Class" "stale"}}{{end}}{{end}}
39{{if .Viewer}} 39{{if .Viewer}}
40<form method="post" action="{{$base}}/comment" class="commentform"> 40<form method="post" action="{{$base}}/comment" class="commentform">
41<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> 41<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
@@ -61,7 +61,7 @@
61 61
62{{else}} 62{{else}}
63<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></p> 63<p class="diffstat">{{.Stat.Files}} file{{if ne .Stat.Files 1}}s{{end}} changed, <span class="add">+{{.Stat.Adds}}</span> <span class="del">−{{.Stat.Dels}}</span></p>
64{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Can" .CanWrite}} 64{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}}
65{{end}} 65{{end}}
66 66
67</div> 67</div>