web: comment on a diff line and reply to threads !120
10 files changed, +271 −22
Layout: unified · split
e2e/mrweb_test.go +117
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "os" | 7 | "os" |
| 8 | "path/filepath" | 8 | "path/filepath" |
| 9 | "regexp" | 9 | "regexp" |
| 10 | "strconv" | ||
| 10 | "strings" | 11 | "strings" |
| 11 | "testing" | 12 | "testing" |
| 12 | ) | 13 | ) |
| @@ -214,3 +215,119 @@ func TestMRWebCreate(t *testing.T) { | |||
| 214 | t.Fatalf("created MR wrong: %+v", show) | 215 | t.Fatalf("created MR wrong: %+v", show) |
| 215 | } | 216 | } |
| 216 | } | 217 | } |
| 218 | |||
| 219 | // TestMRWebDiffThreads opens a review thread on a diff line and replies to | ||
| 220 | // it from the browser. The CLI's view of the threads afterwards is what | ||
| 221 | // proves the page dispatched mr diff-comment rather than writing its own | ||
| 222 | // rows. | ||
| 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 | // 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. |
| 48 | func (s *Server) runControlStdin(u store.User, argv []string, stdin string) (msg string, ok bool) { | 48 | func (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. | ||
| 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 | // mrArgs builds "<verb> owner/name <n>" for the mr command family. | 46 | // mrArgs builds "<verb> owner/name <n>" for the mr command family. |
| 34 | func mrArgs(r *http.Request, verb string, extra ...string) []string { | 47 | func 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. | ||
| 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 | // mrThreadSubmit resolves or reopens one review thread. | 124 | // mrThreadSubmit resolves or reopens one review thread. |
| 77 | func (s *Server) mrThreadSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 125 | func (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 | ||
| 1193 | type diffThread struct { | 1194 | type 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. | ||
| 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 | // 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. |
| 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 | 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. | ||
| 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 | type sigView struct { | 1297 | type 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 | ||
| 1638 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | 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 | table.difftable tr.hunk td.src { color: var(--accent); } | 973 | table.difftable tr.hunk td.src { color: var(--accent); } |
| 974 | table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background: var(--bg); } | 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 | /* 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: <{{.CommitterEmail}}>{{end}}</p> | 9 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{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&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 | </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> |