A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 70c014166e

70c014166e87d75e1380464af9be10f568d3ca91

parent: 255a74d8cf

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-30T06:04:57Z

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
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&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
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.
4848 func (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.
3447 func 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.
77125 func (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
11931194 type 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
12551297 type 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
16381685 func (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 }
973973 table.difftable tr.hunk td.src { color: var(--accent); }
974974 table.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>