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 (
77 "os"
88 "path/filepath"
99 "regexp"
10 "strconv"
1011 "strings"
1112 "testing"
1213)
@@ -214,3 +215,119 @@ func TestMRWebCreate(t *testing.T) {
214215 t.Fatalf("created MR wrong: %+v", show)
215216 }
216217}
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
4141}
4242
4343// runControlStdin is runControl for the handful of commands whose input
44// arrives on stdin. Public keys are the only such input the web accepts:
45// they are not secret, and pasting one into a browser is how people who
46// have not set up the CLI get their first key registered. Secrets, tokens
47// and mirror credentials remain SSHOnly and are refused by the dispatcher.
44// arrives on stdin: public keys, and review comment bodies. Neither is
45// secret, and both are prose or paste rather than a flag value. Secrets,
46// tokens and mirror credentials remain SSHOnly and are refused by the
47// dispatcher.
4848func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) {
4949 var stdout, stderr bytes.Buffer
5050 ctx := &control.Ctx{
internal/httpd/diff.go +1
@@ -22,6 +22,7 @@ type diffLine struct {
2222 NewLine int64 // line number in the new file (0 when absent)
2323 OldLine int64 // line number in the old file (0 when absent)
2424 Threads []diffThread
25 Compose bool // render the new-thread form under this line
2526}
2627
2728// 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)
3030 http.Redirect(w, r, dest, http.StatusSeeOther)
3131}
3232
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
3346// mrArgs builds "<verb> owner/name <n>" for the mr command family.
3447func mrArgs(r *http.Request, verb string, extra ...string) []string {
3548 repo := r.PathValue("owner") + "/" + r.PathValue("repo")
@@ -73,6 +86,41 @@ func (s *Server) mrCloseSubmit(w http.ResponseWriter, r *http.Request, u store.U
7386 s.mrRedirect(w, r, msg)
7487}
7588
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
76124// mrThreadSubmit resolves or reopens one review thread.
77125func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
78126 verb := "resolve"
internal/httpd/routes.go +2
@@ -151,6 +151,8 @@ func (s *Server) Routes() []Route {
151151 Handler: s.checkOrigin(s.requireUser(s.mrCloseSubmit))},
152152 Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/thread", Mutating: true,
153153 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))},
154156 Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}",
155157 Handler: s.requireUser(s.editForm)},
156158 Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true,
internal/httpd/web.go +55 −8
@@ -13,6 +13,7 @@ import (
1313 "gitbay.org/gitbay/internal/policy"
1414 "html/template"
1515 "net/http"
16 "net/url"
1617 "path"
1718 "regexp"
1819 "sort"
@@ -1191,16 +1192,30 @@ func renderReadme(name string, raw []byte) template.HTML {
11911192}
11921193
11931194type diffThread struct {
1194 ID int64
1195 Resolved string
1196 Stale bool
1197 Comments []renderedComment
1195 ID int64
1196 Resolved string
1197 Stale bool
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)
11981213}
11991214
12001215// attachThreads injects review threads under their anchored diff lines;
12011216// threads whose anchor no longer appears (stale after force-push, or on a
12021217// 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) {
12041219 type anchor struct {
12051220 path string
12061221 side string
@@ -1214,7 +1229,8 @@ func attachThreads(files []diffFile, comments []store.DiffComment, headSHA strin
12141229 for _, cm := range comments {
12151230 if cm.ReplyTo == 0 {
12161231 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")}}}
12181234 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
12191235 order = append(order, cm.ID)
12201236 } else if th, ok := threads[cm.ReplyTo]; ok {
@@ -1252,6 +1268,32 @@ func attachThreads(files []diffFile, comments []store.DiffComment, headSHA strin
12521268 return files, unplaced
12531269}
12541270
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
12551297type sigView struct {
12561298 State string
12571299 Signer string
@@ -1577,8 +1619,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
15771619 }
15781620 }
15791621 md := s.ugcFor(r, p.Repo)
1622 canWrite := s.canWriteRepo(r, p.Repo)
15801623 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 }
15821629 stat := statOf(files)
15831630 // The commits this MR carries: base..head, the same range as the diff.
15841631 type commitRow struct {
@@ -1632,7 +1679,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
16321679 DetachedThreads []diffThread
16331680 }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md),
16341681 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})
16361683}
16371684
16381685func (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 {
972972}
973973table.difftable tr.hunk td.src { color: var(--accent); }
974974table.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; }
975981
976982/* badges: signature and check states. Semantic colors stay distinct:
977983 green verified/success, amber pending/stale, red bad/failure, muted
@@ -1156,6 +1162,11 @@ details.editbox input[type="text"] { width: 100%; }
11561162.thread .when { color: var(--muted); }
11571163.thread .rendered p { margin: var(--sp-1) 0; }
11581164.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; }
11591170
11601171/* forms: every control shares one shell, so a select reads as a sibling
11611172 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 @@
99 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
1010</div>
1111<pre class="message">{{.Message}}</pre>
12{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Can" false}}
12{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}}
1313{{end}}
internal/web/templates/layout.html +30 −7
@@ -114,10 +114,10 @@
114114{{define "authorname"}}{{if .User}}<a class="authorlink" href="/{{.User}}" title="{{.Email}}">{{.Name}}</a>{{else}}<span title="{{.Email}}">{{.Name}}</span>{{end}}{{end}}
115115
116116{{/* difffiles renders a parsed diff: one foldable section per file, with
117 line-number gutters and review threads inline. Base and Can are the
118 MR's comment endpoint and the viewer's write permission; the commit
119 page passes neither and gets the same diff without thread controls. */}}
120{{define "difffiles"}}{{$base := .Base}}{{$can := .Can}}
117 line-number gutters and review threads inline. Base is the MR's
118 endpoint and Viewer the signed-in account; the commit page passes
119 neither and gets the same diff without review controls. */}}
120{{define "difffiles"}}{{$base := .Base}}{{$viewer := .Viewer}}
121121{{range .Files}}<details class="difffold"{{if .Open}} open{{end}}>
122122 <summary>
123123 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span>
@@ -128,11 +128,34 @@
128128 {{else}}<div class="tablewrap"><table class="difftable">
129129 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
130130 {{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>
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>
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 {{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>
133140 {{end}}{{end}}
134141 </table></div>{{end}}
135142</details>
136143{{end}}{{end}}
137144
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 @@
3535</article>{{end}}
3636{{end}}
3737{{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}}
3939{{if .Viewer}}
4040<form method="post" action="{{$base}}/comment" class="commentform">
4141<p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p>
@@ -61,7 +61,7 @@
6161
6262{{else}}
6363<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}}
6565{{end}}
6666
6767</div>