Commit 70c014166e
Verified · cmc ci/build: success
e2e/mrweb_test.go +117
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "os" |
| 8 | 8 | "path/filepath" |
| 9 | 9 | "regexp" |
| 10 | "strconv" | |
| 10 | 11 | "strings" |
| 11 | 12 | "testing" |
| 12 | 13 | ) |
| @@ -214,3 +215,119 @@ func TestMRWebCreate(t *testing.T) { | ||
| 214 | 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. | |
| 223 | func 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. | |
| 308 | type 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. | |
| 320 | func (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 | 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: | |
| 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. | |
| 48 | 48 | func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { |
| 49 | 49 | var stdout, stderr bytes.Buffer |
| 50 | 50 | ctx := &control.Ctx{ |
internal/httpd/diff.go +1
| @@ -22,6 +22,7 @@ type diffLine struct { | ||
| 22 | 22 | NewLine int64 // line number in the new file (0 when absent) |
| 23 | 23 | OldLine int64 // line number in the old file (0 when absent) |
| 24 | 24 | Threads []diffThread |
| 25 | Compose bool // render the new-thread form under this line | |
| 25 | 26 | } |
| 26 | 27 | |
| 27 | 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 | 30 | http.Redirect(w, r, dest, http.StatusSeeOther) |
| 31 | 31 | } |
| 32 | 32 | |
| 33 | // mrDiffRedirect returns to the diff view, where the thread controls are. | |
| 34 | func (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 | 46 | // mrArgs builds "<verb> owner/name <n>" for the mr command family. |
| 34 | 47 | func mrArgs(r *http.Request, verb string, extra ...string) []string { |
| 35 | 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 | 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. | |
| 92 | func (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 | 124 | // mrThreadSubmit resolves or reopens one review thread. |
| 77 | 125 | func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 78 | 126 | verb := "resolve" |
internal/httpd/routes.go +2
| @@ -151,6 +151,8 @@ func (s *Server) Routes() []Route { | ||
| 151 | 151 | Handler: s.checkOrigin(s.requireUser(s.mrCloseSubmit))}, |
| 152 | 152 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/thread", Mutating: true, |
| 153 | 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 | 156 | Route{Method: "GET", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", |
| 155 | 157 | Handler: s.requireUser(s.editForm)}, |
| 156 | 158 | Route{Method: "POST", Pattern: "/{owner}/{repo}/edit/{ref}/{path...}", Mutating: true, |
internal/httpd/web.go +55 −8
| @@ -13,6 +13,7 @@ import ( | ||
| 13 | 13 | "gitbay.org/gitbay/internal/policy" |
| 14 | 14 | "html/template" |
| 15 | 15 | "net/http" |
| 16 | "net/url" | |
| 16 | 17 | "path" |
| 17 | 18 | "regexp" |
| 18 | 19 | "sort" |
| @@ -1191,16 +1192,30 @@ func renderReadme(name string, raw []byte) template.HTML { | ||
| 1191 | 1192 | } |
| 1192 | 1193 | |
| 1193 | 1194 | 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. | |
| 1205 | type reviewRights struct { | |
| 1206 | Viewer string | |
| 1207 | MRAuthor string | |
| 1208 | Write bool | |
| 1209 | } | |
| 1210 | ||
| 1211 | func (r reviewRights) canResolve(threadAuthor string) bool { | |
| 1212 | return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor) | |
| 1198 | 1213 | } |
| 1199 | 1214 | |
| 1200 | 1215 | // attachThreads injects review threads under their anchored diff lines; |
| 1201 | 1216 | // threads whose anchor no longer appears (stale after force-push, or on a |
| 1202 | 1217 | // context line outside the current diff) are returned separately. |
| 1203 | func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer) ([]diffFile, []diffThread) { | |
| 1218 | func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) { | |
| 1204 | 1219 | type anchor struct { |
| 1205 | 1220 | path string |
| 1206 | 1221 | side string |
| @@ -1214,7 +1229,8 @@ func attachThreads(files []diffFile, comments []store.DiffComment, headSHA strin | ||
| 1214 | 1229 | for _, cm := range comments { |
| 1215 | 1230 | if cm.ReplyTo == 0 { |
| 1216 | 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 | 1234 | anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} |
| 1219 | 1235 | order = append(order, cm.ID) |
| 1220 | 1236 | } else if th, ok := threads[cm.ReplyTo]; ok { |
| @@ -1252,6 +1268,32 @@ func attachThreads(files []diffFile, comments []store.DiffComment, headSHA strin | ||
| 1252 | 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. | |
| 1274 | func 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 | ||
| 1255 | 1297 | type sigView struct { |
| 1256 | 1298 | State string |
| 1257 | 1299 | Signer string |
| @@ -1577,8 +1619,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1577 | 1619 | } |
| 1578 | 1620 | } |
| 1579 | 1621 | md := s.ugcFor(r, p.Repo) |
| 1622 | canWrite := s.canWriteRepo(r, p.Repo) | |
| 1580 | 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 | 1629 | stat := statOf(files) |
| 1583 | 1630 | // The commits this MR carries: base..head, the same range as the diff. |
| 1584 | 1631 | type commitRow struct { |
| @@ -1632,7 +1679,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 1632 | 1679 | DetachedThreads []diffThread |
| 1633 | 1680 | }{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md), |
| 1634 | 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 | |
| 1638 | 1685 | 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 { | ||
| 972 | 972 | } |
| 973 | 973 | table.difftable tr.hunk td.src { color: var(--accent); } |
| 974 | 974 | 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 */ | |
| 978 | table.difftable td.ln a.cmt { color: inherit; text-decoration: none; } | |
| 979 | table.difftable td.ln a.cmt:hover, | |
| 980 | table.difftable td.ln a.cmt:focus { color: var(--accent); text-decoration: underline; } | |
| 975 | 981 | |
| 976 | 982 | /* badges: signature and check states. Semantic colors stay distinct: |
| 977 | 983 | green verified/success, amber pending/stale, red bad/failure, muted |
| @@ -1156,6 +1162,11 @@ details.editbox input[type="text"] { width: 100%; } | ||
| 1156 | 1162 | .thread .when { color: var(--muted); } |
| 1157 | 1163 | .thread .rendered p { margin: var(--sp-1) 0; } |
| 1158 | 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 | 1171 | /* forms: every control shares one shell, so a select reads as a sibling |
| 1161 | 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 | 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> |
| 10 | 10 | </div> |
| 11 | 11 | <pre class="message">{{.Message}}</pre> |
| 12 | {{template "difffiles" dict "Files" .DiffFiles "Base" "" "Can" false}} | |
| 12 | {{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}} | |
| 13 | 13 | {{end}} |
internal/web/templates/layout.html +30 −7
| @@ -114,10 +114,10 @@ | ||
| 114 | 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 | 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 | |
| 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}} | |
| 121 | 121 | {{range .Files}}<details class="difffold"{{if .Open}} open{{end}}> |
| 122 | 122 | <summary> |
| 123 | 123 | <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span> |
| @@ -128,11 +128,34 @@ | ||
| 128 | 128 | {{else}}<div class="tablewrap"><table class="difftable"> |
| 129 | 129 | {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr> |
| 130 | 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> | |
| 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> | |
| 133 | 140 | {{end}}{{end}} |
| 134 | 141 | </table></div>{{end}} |
| 135 | 142 | </details> |
| 136 | 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&cpath={{.Path}}&cline={{.N}}&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 | 35 | </article>{{end}} |
| 36 | 36 | {{end}} |
| 37 | 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 | 39 | {{if .Viewer}} |
| 40 | 40 | <form method="post" action="{{$base}}/comment" class="commentform"> |
| 41 | 41 | <p><textarea name="body" aria-label="Comment" rows="4" placeholder="Comment as {{.Viewer}}"></textarea></p> |
| @@ -61,7 +61,7 @@ | ||
| 61 | 61 | |
| 62 | 62 | {{else}} |
| 63 | 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 | 65 | {{end}} |
| 66 | 66 | |
| 67 | 67 | </div> |