Commit 70c014166e

70c014166e87d75e1380464af9be10f568d3ca91

parent: 255a74d8cf

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-30 06:04 UTC

web: comment on a diff line and reply to threads

The web was the last surface that could read inline review but not
participate. Line numbers in the diff gutter now open a comment form on
that line, and every thread carries a reply fold; both post to
/mrs/{n}/diff-comment, which dispatches mr diff-comment.

No JavaScript: the anchor travels in the query (cpath/cline/cside) and
the page renders the form where the reader asked for it.

Resolve controls were gated on write access while mr resolve also admits
the thread author and the MR author. reviewRights now decides per
thread, so the button appears wherever the command would succeed.

Closes #44

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&amp;cline=6&amp;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>