web: side-by-side diff layout !526

merged merged by cmc on 2026-09-29 05:24 UTC · krz/gitbay:diff-split-290 into main

23 files changed, +587 −13

Layout: unified · split

.gitbay/wiki/Parity.org +1
@@ -367,6 +367,7 @@ client has no use for one (krz/gitbay#57).
367| push device remove | yes | yes | yes | 367| push device remove | yes | yes | yes |
368| activity push on, off | yes | yes | yes | 368| activity push on, off | yes | yes | yes |
369| web colour scheme | yes | yes | n/a | 369| web colour scheme | yes | yes | n/a |
370| web diff layout | yes | yes | n/a |
370| API token mint | yes | yes | no | 371| API token mint | yes | yes | no |
371| API token list, revoke | yes | yes | no | 372| API token list, revoke | yes | yes | no |
372| API token revoke with what it created | yes | no | no | 373| API token revoke with what it created | yes | no | no |
.gitbay/wiki/Users.org +8
@@ -743,6 +743,14 @@ your account; =system=, the default, follows the browser's own
743preference. =web theme show= prints it. The account page has the same 743preference. =web theme show= prints it. The account page has the same
744control under Appearance. 744control under Appearance.
745 745
746=web diff set split= draws diffs side by side on the merge request,
747commit and compare pages; =unified=, the default, keeps one column.
748=web diff show= prints it, and the account page has the same control
749under Appearance. =?layout=split= or =?layout=unified= on a diff page
750overrides the setting for that request. In a narrow window the split
751layout stacks the old line over the new one, as a unified diff does.
752Each column's line numbers open a comment on their own side.
753
746* Notifications 754* Notifications
747 755
748When the instance has SMTP configured, activity mails you as well as 756When the instance has SMTP configured, activity mails you as well as
CHANGELOG.org +9
@@ -4,6 +4,15 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased
8
9- =web diff set unified|split= and a Diff layout control on the account
10 page choose how the merge request, commit and compare pages draw a
11 diff; =?layout=split|unified= overrides it per request. The split
12 layout puts old and new side by side, each column's line numbers
13 comment on their own side, and a narrow window stacks the rows as a
14 unified diff. Migration 0068 adds =users.diff_layout=. (#290)
15
7* v1.38.0 — 2026-09-29 16* v1.38.0 — 2026-09-29
8 17
9Merge when ready, build egress by trust class, and restore-drill 18Merge when ready, build egress by trust class, and restore-drill
cmd/gitbay/main.go +4
@@ -745,6 +745,10 @@ func webCmd() *cobra.Command {
745 pass("show", passOpts{server: []string{"web", "theme", "show"}}), 745 pass("show", passOpts{server: []string{"web", "theme", "show"}}),
746 pass("set", passOpts{server: []string{"web", "theme", "set"}}), 746 pass("set", passOpts{server: []string{"web", "theme", "set"}}),
747 ), 747 ),
748 group("diff", "the diff layout the web UI uses for you",
749 pass("show", passOpts{server: []string{"web", "diff", "show"}}),
750 pass("set", passOpts{server: []string{"web", "diff", "set"}}),
751 ),
748 ) 752 )
749} 753}
750 754
cmd/gitbay/summaries_gen.go +2
@@ -224,6 +224,8 @@ var summaries = map[string]string{
224 "token create": "mint an API token (shown once)", 224 "token create": "mint an API token (shown once)",
225 "token list": "list API tokens", 225 "token list": "list API tokens",
226 "token revoke": "revoke an API token by name", 226 "token revoke": "revoke an API token by name",
227 "web diff set": "draw web diffs unified or side by side",
228 "web diff show": "the diff layout the web UI uses for you",
227 "web login": "mint a one-time browser login URL", 229 "web login": "mint a one-time browser login URL",
228 "web sessions list": "list your browser sessions", 230 "web sessions list": "list your browser sessions",
229 "web sessions revoke": "end a browser session, or all of them", 231 "web sessions revoke": "end a browser session, or all of them",
e2e/readonly_test.go +1
@@ -101,6 +101,7 @@ func TestReadOnlyCommandsWriteNothing(t *testing.T) {
101 "token list": {}, 101 "token list": {},
102 "web sessions list": {}, 102 "web sessions list": {},
103 "web theme show": {}, 103 "web theme show": {},
104 "web diff show": {},
104 "account export": {}, 105 "account export": {},
105 "org list": {}, 106 "org list": {},
106 "repo list": {}, 107 "repo list": {},
internal/control/difflayout.go added +49
@@ -0,0 +1,49 @@
1package control
2
3import (
4 "fmt"
5 "io"
6
7 "gitbay.org/gitbay/internal/protocol"
8)
9
10func init() {
11 register(Command{Path: []string{"web", "diff", "show"},
12 Summary: "the diff layout the web UI uses for you",
13 Usage: "web diff show",
14 Examples: []string{"web diff show"},
15 ReadOnly: true, Run: runWebDiffShow})
16 register(Command{Path: []string{"web", "diff", "set"},
17 Summary: "draw web diffs unified or side by side",
18 Usage: "web diff set unified|split",
19 Examples: []string{"web diff set split"}, Run: runWebDiffSet})
20}
21
22var diffLayouts = map[string]bool{"unified": true, "split": true}
23
24func runWebDiffShow(c *Ctx, args []string) int {
25 if len(args) != 0 {
26 return c.usage()
27 }
28 return emitDiffLayout(c)
29}
30
31func runWebDiffSet(c *Ctx, args []string) int {
32 if len(args) != 1 || !diffLayouts[args[0]] {
33 return c.usage()
34 }
35 if err := c.Store.SetDiffLayout(c.User.ID, args[0]); err != nil {
36 return c.fail(protocol.ExitFailure, "%v", err)
37 }
38 return emitDiffLayout(c)
39}
40
41func emitDiffLayout(c *Ctx) int {
42 layout, err := c.Store.DiffLayout(c.User.ID)
43 if err != nil {
44 return c.fail(protocol.ExitFailure, "%v", err)
45 }
46 return c.emit(map[string]string{"layout": layout}, func(w io.Writer) {
47 fmt.Fprintf(w, "layout: %s\n", layout)
48 })
49}
internal/control/difflayout_test.go added +36
@@ -0,0 +1,36 @@
1package control
2
3import (
4 "bytes"
5 "io"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13// web diff show reports unified for a new account, set refuses anything
14// but the two layouts, and a set value is what show reports next.
15func TestWebDiffLayout(t *testing.T) {
16 st, _, uid := newQueueTestRepo(t)
17 var buf bytes.Buffer
18 c := &Ctx{User: store.User{ID: uid, Username: "alice"}, Store: st, Stdout: &buf, Stderr: io.Discard, JSON: true}
19
20 if code := runWebDiffShow(c, nil); code != protocol.ExitOK || !strings.Contains(buf.String(), `"layout":"unified"`) {
21 t.Fatalf("show: %d %s", code, buf.String())
22 }
23 if code := runWebDiffSet(c, []string{"triple"}); code != protocol.ExitUsage {
24 t.Fatalf("bad value exited %d", code)
25 }
26 if code := runWebDiffSet(c, nil); code != protocol.ExitUsage {
27 t.Fatalf("no value exited %d", code)
28 }
29 buf.Reset()
30 if code := runWebDiffSet(c, []string{"split"}); code != protocol.ExitOK || !strings.Contains(buf.String(), `"layout":"split"`) {
31 t.Fatalf("set: %d %s", code, buf.String())
32 }
33 if got, _ := st.DiffLayout(uid); got != "split" {
34 t.Fatalf("stored layout: %q", got)
35 }
36}
internal/httpd/account.go +9 −1
@@ -96,6 +96,7 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
96 watchOn, _ := s.st.WatchEnabled(u.ID) 96 watchOn, _ := s.st.WatchEnabled(u.ID)
97 pushOn, _ := s.st.PushEnabled(u.ID) 97 pushOn, _ := s.st.PushEnabled(u.ID)
98 theme, _ := s.st.Theme(u.ID) 98 theme, _ := s.st.Theme(u.ID)
99 diffPref, _ := s.st.DiffLayout(u.ID)
99 100
100 var devices []accountDevice 101 var devices []accountDevice
101 if list, err := s.st.PushDevices(u.ID); err == nil { 102 if list, err := s.st.PushDevices(u.ID); err == nil {
@@ -149,12 +150,13 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U
149 PushOn bool 150 PushOn bool
150 Devices []accountDevice 151 Devices []accountDevice
151 ThemeSetting string // system, light or dark: the form's selected option 152 ThemeSetting string // system, light or dark: the form's selected option
153 DiffSetting string // unified or split: the form's selected option
152 Tokens []accountToken 154 Tokens []accountToken
153 TokenShown string // a token minted by this request, shown once 155 TokenShown string // a token minted by this request, shown once
154 Reauth bool // Notice is the stale-session refusal: link to sign in 156 Reauth bool // Notice is the stale-session refusal: link to sign in
155 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), 157 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
156 aboutRepo, aboutEdit, s.cfg.SiteHost(), 158 aboutRepo, aboutEdit, s.cfg.SiteHost(),
157 notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, 159 notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, diffPref,
158 tokens, tokenShown, reauth}) 160 tokens, tokenShown, reauth})
159} 161}
160 162
@@ -342,6 +344,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
342 return 344 return
343 } 345 }
344 back("", "colour scheme saved") 346 back("", "colour scheme saved")
347 case "diff-layout":
348 if _, msg, ok := s.runControl(u, []string{"web", "diff", "set", r.FormValue("layout")}); !ok {
349 back(msg, "")
350 return
351 }
352 back("", "diff layout saved")
345 case "notify-mail", "notify-watch", "notify-push": 353 case "notify-mail", "notify-watch", "notify-push":
346 pref := strings.TrimPrefix(r.FormValue("field"), "notify-") 354 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
347 state := "off" 355 state := "off"
internal/httpd/compare.go +6 −1
@@ -73,6 +73,10 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
73 } 73 }
74 commits = append(commits, cr) 74 commits = append(commits, cr)
75 } 75 }
76 layout := s.diffLayoutFor(r)
77 if layout.Split {
78 splitFiles(files)
79 }
76 canWrite := s.canWriteRepo(r, p.Repo) 80 canWrite := s.canWriteRepo(r, p.Repo)
77 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0 81 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
78 s.render(w, "compare.html", struct { 82 s.render(w, "compare.html", struct {
@@ -84,5 +88,6 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
84 DiffTruncated bool 88 DiffTruncated bool
85 Stat diffStat 89 Stat diffStat
86 CanOpenMR bool 90 CanOpenMR bool
87 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), canOpenMR}) 91 Layout diffLayout
92 }{p, base, head, baseSHA, headSHA, mergeBase, commits, total, files, truncated, statOf(files), canOpenMR, layout})
88} 93}
internal/httpd/diff.go +154 −2
@@ -3,6 +3,8 @@ package httpd
3import ( 3import (
4 "bytes" 4 "bytes"
5 "html/template" 5 "html/template"
6 "net/http"
7 "net/url"
6 "regexp" 8 "regexp"
7 "strconv" 9 "strconv"
8 "strings" 10 "strings"
@@ -36,8 +38,9 @@ type diffFile struct {
36 Dels int 38 Dels int
37 Binary bool 39 Binary bool
38 Lines []diffLine 40 Lines []diffLine
39 Threads int // threads anchored in this file, so it can stay unfolded 41 Rows []splitRow // the split layout's rows; empty in the unified layout
40 Open bool // rendered unfolded: small files, and anything under review 42 Threads int // threads anchored in this file, so it can stay unfolded
43 Open bool // rendered unfolded: small files, and anything under review
41} 44}
42 45
43type diffStat struct{ Files, Adds, Dels int } 46type diffStat struct{ Files, Adds, Dels int }
@@ -302,3 +305,152 @@ func statOf(files []diffFile) diffStat {
302 } 305 }
303 return st 306 return st
304} 307}
308
309// splitRow is one row of the side-by-side layout: a hunk or meta line
310// spanning both columns, or a pair of lines. In a run of deletions
311// followed by additions the two are zipped, and the shorter side is left
312// empty. Old and New point into the file's Lines.
313type splitRow struct {
314 Kind string // hunk | meta | pair
315 Text string
316 Old *diffLine
317 New *diffLine
318 Threads []diffThread
319 OldNote string // "\ No newline" marker belonging to the old side
320 NewNote string // and to the new side
321 Compose *diffLine // the line whose new-thread form opens under this row
322}
323
324// diffLayout is the layout a diff page renders in and the links that
325// switch it.
326type diffLayout struct {
327 Split bool
328 UnifiedURL string
329 SplitURL string
330 Carry string // "split" or "unified" when the request chose it, so links keep it
331}
332
333// splitFiles fills each file's Rows. It runs after threads and compose
334// forms are attached to the lines.
335func splitFiles(files []diffFile) {
336 for f := range files {
337 lines := files[f].Lines
338 var rows []splitRow
339 for i := 0; i < len(lines); {
340 ln := &lines[i]
341 switch ln.Class {
342 case "hunk", "meta":
343 if ln.Class == "meta" && ln.Path == "" && len(rows) > 0 && strings.HasPrefix(ln.Text, `\`) {
344 // a marker after a context line: neither side ends in a newline
345 if last := &rows[len(rows)-1]; last.Old != nil && last.Old == last.New {
346 last.OldNote, last.NewNote = ln.Text, ln.Text
347 i++
348 continue
349 }
350 }
351 rows = append(rows, splitRow{Kind: ln.Class, Text: ln.Text})
352 i++
353 case "ctx":
354 r := splitRow{Kind: "pair", Old: ln, New: ln, Threads: ln.Threads}
355 if ln.Compose {
356 r.Compose = ln
357 }
358 rows = append(rows, r)
359 i++
360 default:
361 var dels, adds []*diffLine
362 marker := func() string {
363 if i < len(lines) && lines[i].Class == "meta" && strings.HasPrefix(lines[i].Text, `\`) {
364 i++
365 return lines[i-1].Text
366 }
367 return ""
368 }
369 var oldNote, newNote string
370 for i < len(lines) && lines[i].Class == "del" {
371 dels = append(dels, &lines[i])
372 i++
373 }
374 if len(dels) > 0 {
375 oldNote = marker()
376 }
377 for i < len(lines) && lines[i].Class == "add" {
378 adds = append(adds, &lines[i])
379 i++
380 }
381 if len(adds) > 0 {
382 newNote = marker()
383 }
384 if len(dels)+len(adds) == 0 {
385 i++ // an unknown class: skip rather than loop
386 continue
387 }
388 first := len(rows)
389 for k := 0; k < len(dels) || k < len(adds); k++ {
390 r := splitRow{Kind: "pair"}
391 for _, l := range []*diffLine{pick(dels, k), pick(adds, k)} {
392 if l == nil {
393 continue
394 }
395 if l.Class == "del" {
396 r.Old = l
397 } else {
398 r.New = l
399 }
400 r.Threads = append(r.Threads, l.Threads...)
401 if l.Compose {
402 r.Compose = l
403 }
404 }
405 rows = append(rows, r)
406 }
407 if len(dels) > 0 {
408 rows[first+len(dels)-1].OldNote = oldNote
409 }
410 if len(adds) > 0 {
411 rows[first+len(adds)-1].NewNote = newNote
412 }
413 }
414 }
415 files[f].Rows = rows
416 }
417}
418
419func pick(s []*diffLine, i int) *diffLine {
420 if i < len(s) {
421 return s[i]
422 }
423 return nil
424}
425
426// diffLayoutFor resolves the layout for a request: ?layout= wins, then the
427// signed-in account's setting, then unified. The two switch links keep
428// every other query parameter.
429func (s *Server) diffLayoutFor(r *http.Request) diffLayout {
430 q := r.URL.Query()
431 l := diffLayout{}
432 switch q.Get("layout") {
433 case "split":
434 l.Split, l.Carry = true, "split"
435 case "unified":
436 l.Carry = "unified"
437 default:
438 if s.cfg.Web.Mode == "accounts" {
439 if u := s.viewer(r); u.ID != 0 {
440 if v, err := s.st.DiffLayout(u.ID); err == nil {
441 l.Split = v == "split"
442 }
443 }
444 }
445 }
446 link := func(v string) string {
447 c := url.Values{}
448 for k, vs := range q {
449 c[k] = vs
450 }
451 c.Set("layout", v)
452 return r.URL.Path + "?" + c.Encode()
453 }
454 l.UnifiedURL, l.SplitURL = link("unified"), link("split")
455 return l
456}
internal/httpd/difflayout_test.go added +202
@@ -0,0 +1,202 @@
1package httpd
2
3import (
4 "net/http/httptest"
5 "strings"
6 "testing"
7
8 "gitbay.org/gitbay/internal/config"
9 "gitbay.org/gitbay/internal/store"
10 "gitbay.org/gitbay/internal/web"
11)
12
13const layoutPatch = `diff --git a/a.txt b/a.txt
14--- a/a.txt
15+++ b/a.txt
16@@ -1,4 +1,4 @@
17 keep
18-old one
19-old two
20+new one
21 tail
22`
23
24func renderCommitDiff(t *testing.T, layout diffLayout, mutate func([]diffFile)) string {
25 t.Helper()
26 files := parseDiff(layoutPatch)
27 if mutate != nil {
28 mutate(files)
29 }
30 if layout.Split {
31 splitFiles(files)
32 }
33 var sb strings.Builder
34 if err := web.Render(&sb, "commit.html", struct {
35 repoPage
36 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
37 Parents []string
38 Sig sigView
39 Checks []store.CommitStatus
40 DiffFiles []diffFile
41 DiffTruncated bool
42 Layout diffLayout
43 }{repoPage: testRepoPage(), SHA: strings.Repeat("a", 40), ShortSHA: "aaaaaaaaaa", DiffFiles: files, Layout: layout}); err != nil {
44 t.Fatalf("render: %v", err)
45 }
46 return sb.String()
47}
48
49func TestSplitRowsPairDeletionsWithAdditions(t *testing.T) {
50 files := parseDiff(layoutPatch)
51 splitFiles(files)
52 var pairs []splitRow
53 for _, r := range files[0].Rows {
54 if r.Kind == "pair" {
55 pairs = append(pairs, r)
56 }
57 }
58 if len(pairs) != 4 {
59 t.Fatalf("want 4 pair rows, got %d", len(pairs))
60 }
61 if pairs[1].Old == nil || pairs[1].New == nil || pairs[1].Old.OldLine != 2 || pairs[1].New.NewLine != 2 {
62 t.Errorf("first change row does not pair old 2 with new 2: %+v", pairs[1])
63 }
64 if pairs[2].Old == nil || pairs[2].Old.OldLine != 3 || pairs[2].New != nil {
65 t.Errorf("surplus deletion is not left-only: %+v", pairs[2])
66 }
67}
68
69func TestDiffRendersUnified(t *testing.T) {
70 out := renderCommitDiff(t, diffLayout{UnifiedURL: "/x?layout=unified", SplitURL: "/x?layout=split"}, nil)
71 if strings.Contains(out, `difftable split`) {
72 t.Error("unified layout rendered the split table")
73 }
74 if !strings.Contains(out, `<a href="/x?layout=split">split</a>`) {
75 t.Errorf("no link to the split layout:\n%s", out)
76 }
77}
78
79func TestDiffRendersSplit(t *testing.T) {
80 out := renderCommitDiff(t, diffLayout{Split: true, UnifiedURL: "/x?layout=unified", SplitURL: "/x?layout=split"}, nil)
81 if !strings.Contains(out, `class="difftable split"`) {
82 t.Fatalf("no split table:\n%s", out)
83 }
84 for _, want := range []string{`id="f0-o2"`, `id="f0-o3"`, `id="f0-n2"`, `id="f0-n1"`, `<a href="/x?layout=unified">unified</a>`} {
85 if !strings.Contains(out, want) {
86 t.Errorf("split output lacks %s", want)
87 }
88 }
89 if strings.Count(out, `id="f0-n1"`) != 1 {
90 t.Error("a context line's id appears more than once")
91 }
92 if !strings.Contains(out, `class="ln none"`) {
93 t.Error("the surplus deletion has no empty right side")
94 }
95}
96
97// Each column's line number links its own side, and the layout override
98// travels with the link.
99func TestSplitCommentLinksKeepSides(t *testing.T) {
100 files := parseDiff(layoutPatch)
101 splitFiles(files)
102 var sb strings.Builder
103 rp := testRepoPage()
104 rp.Viewer = "alice"
105 err := web.Render(&sb, "mr.html", mrPageData{repoPage: rp, MR: testMR("open"), View: "diff",
106 DiffFiles: files, Layout: diffLayout{Split: true, Carry: "split"}})
107 if err != nil {
108 t.Fatalf("render: %v", err)
109 }
110 out := sb.String()
111 for _, want := range []string{
112 `cpath=a.txt&amp;cline=2&amp;cside=old&amp;layout=split#compose`,
113 `cpath=a.txt&amp;cline=2&amp;cside=new&amp;layout=split#compose`,
114 } {
115 if !strings.Contains(out, want) {
116 i := strings.Index(out, "cpath")
117 t.Errorf("lacks %s; sample %q", want, out[max(i-50, 0):min(i+150, len(out))])
118 }
119 }
120}
121
122func TestDiffLayoutForQueryOverridesAccount(t *testing.T) {
123 st, err := store.Open(":memory:")
124 if err != nil {
125 t.Fatal(err)
126 }
127 defer st.Close()
128 if err := st.MigrateUp(); err != nil {
129 t.Fatal(err)
130 }
131 uid, _ := st.CreateUser("alice", false)
132 cfg := config.Default()
133 cfg.Web.Mode = "accounts"
134 s := New(cfg, st, nil)
135 ck := sessionCookieFor(t, s, st, uid)
136
137 get := func(target string) diffLayout {
138 req := httptest.NewRequest("GET", target, nil)
139 req.AddCookie(ck)
140 return s.diffLayoutFor(req)
141 }
142 if get("/o/r/commit/abc").Split {
143 t.Error("a new account defaults to split")
144 }
145 if err := st.SetDiffLayout(uid, "split"); err != nil {
146 t.Fatal(err)
147 }
148 if l := get("/o/r/commit/abc"); !l.Split || l.Carry != "" {
149 t.Errorf("account setting ignored: %+v", l)
150 }
151 if l := get("/o/r/commit/abc?layout=unified"); l.Split || l.Carry != "unified" {
152 t.Errorf("?layout=unified did not override: %+v", l)
153 }
154 l := get("/o/r/compare?base=main&head=x")
155 if !strings.Contains(l.UnifiedURL, "base=main") || !strings.Contains(l.UnifiedURL, "layout=unified") {
156 t.Errorf("switch link dropped the query: %s", l.UnifiedURL)
157 }
158 anon := httptest.NewRequest("GET", "/o/r/commit/abc?layout=split", nil)
159 if !s.diffLayoutFor(anon).Split {
160 t.Error("?layout=split ignored for a signed-out reader")
161 }
162}
163
164func splitPairs(patch string) []splitRow {
165 files := parseDiff(patch)
166 splitFiles(files)
167 var out []splitRow
168 for _, r := range files[0].Rows {
169 out = append(out, r)
170 }
171 return out
172}
173
174const noNL = `\ No newline at end of file`
175
176func TestSplitNoNewlineMarkerPairs(t *testing.T) {
177 head := "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n"
178 cases := map[string]struct {
179 patch, oldNote, newNote string
180 }{
181 "newline added": {head + "-foo\n" + noNL + "\n+foo\n", noNL, ""},
182 "newline removed": {head + "-foo\n+foo\n" + noNL + "\n", "", noNL},
183 "both lack": {head + "-foo\n" + noNL + "\n+bar\n" + noNL + "\n", noNL, noNL},
184 }
185 for name, c := range cases {
186 rows := splitPairs(c.patch)
187 if len(rows) != 2 || rows[1].Kind != "pair" || rows[1].Old == nil || rows[1].New == nil {
188 t.Errorf("%s: want a hunk and one paired row, got %+v", name, rows)
189 continue
190 }
191 if rows[1].OldNote != c.oldNote || rows[1].NewNote != c.newNote {
192 t.Errorf("%s: notes %q %q", name, rows[1].OldNote, rows[1].NewNote)
193 }
194 }
195}
196
197func TestSplitEmptyCellsAreHiddenFromAssistiveTech(t *testing.T) {
198 out := renderCommitDiff(t, diffLayout{Split: true, UnifiedURL: "/x", SplitURL: "/y"}, nil)
199 if !strings.Contains(out, `class="ln none" aria-hidden="true"`) || !strings.Contains(out, `class="src none" aria-hidden="true"`) {
200 t.Error("empty cells are not aria-hidden")
201 }
202}
internal/httpd/mrpage_test.go +2
@@ -23,6 +23,7 @@ type mrPageData struct {
23 Comments []renderedComment 23 Comments []renderedComment
24 Reviews []reviewRow 24 Reviews []reviewRow
25 DiffFiles []diffFile 25 DiffFiles []diffFile
26 DiffTruncated bool
26 Stat diffStat 27 Stat diffStat
27 Commits []struct{} 28 Commits []struct{}
28 Branches []gitutil.Ref 29 Branches []gitutil.Ref
@@ -38,6 +39,7 @@ type mrPageData struct {
38 HeadPruned bool 39 HeadPruned bool
39 Base string 40 Base string
40 Draft *draft 41 Draft *draft
42 Layout diffLayout
41} 43}
42 44
43// A pruned head has no diff to show; the page must say the head is gone 45// A pruned head has no diff to show; the page must say the head is gone
internal/httpd/web.go +12 −2
@@ -1697,6 +1697,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1697 } 1697 }
1698 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20) 1698 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1699 files := parseDiff(patch) 1699 files := parseDiff(patch)
1700 layout := s.diffLayoutFor(r)
1701 if layout.Split {
1702 splitFiles(files)
1703 }
1700 committerEmail := "" 1704 committerEmail := ""
1701 if parsed.CommitterEmail != parsed.AuthorEmail { 1705 if parsed.CommitterEmail != parsed.AuthorEmail {
1702 committerEmail = parsed.CommitterEmail 1706 committerEmail = parsed.CommitterEmail
@@ -1716,9 +1720,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1716 Checks []store.CommitStatus 1720 Checks []store.CommitStatus
1717 DiffFiles []diffFile 1721 DiffFiles []diffFile
1718 DiffTruncated bool 1722 DiffTruncated bool
1723 Layout diffLayout
1719 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail, 1724 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1720 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, 1725 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1721 gitutil.Parents(p.Dir, full), v, checks, files, truncated}) 1726 gitutil.Parents(p.Dir, full), v, checks, files, truncated, layout})
1722} 1727}
1723 1728
1724// labelPalette provides default label chip colors: mid-tone hues that stay 1729// labelPalette provides default label chip colors: mid-tone hues that stay
@@ -2187,6 +2192,10 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
2187 if p.Viewer != "" { 2192 if p.Viewer != "" {
2188 markCompose(files, r.URL.Query()) 2193 markCompose(files, r.URL.Query())
2189 } 2194 }
2195 layout := s.diffLayoutFor(r)
2196 if layout.Split {
2197 splitFiles(files)
2198 }
2190 stat := statOf(files) 2199 stat := statOf(files)
2191 // The commits this MR carries: base..head, the same range as the diff. 2200 // The commits this MR carries: base..head, the same range as the diff.
2192 type commitRow struct { 2201 type commitRow struct {
@@ -2293,10 +2302,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
2293 Base string 2302 Base string
2294 LabelColors map[string]template.CSS 2303 LabelColors map[string]template.CSS
2295 Draft *draft 2304 Draft *draft
2305 Layout diffLayout
2296 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), 2306 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2297 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), 2307 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2298 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, 2308 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2299 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d}) 2309 sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout})
2300} 2310}
2301 2311
2302// sourceGone reports whether an MR's source branch no longer exists: the 2312// sourceGone reports whether an MR's source branch no longer exists: the
internal/store/migrations/0068_users_diff_layout.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE users DROP COLUMN diff_layout;
internal/store/migrations/0068_users_diff_layout.up.sql added +3
@@ -0,0 +1,3 @@
1-- The diff layout the web UI renders for the account: unified or split
2-- (#290).
3ALTER TABLE users ADD COLUMN diff_layout TEXT NOT NULL DEFAULT 'unified';
internal/store/users.go +16
@@ -267,6 +267,22 @@ func (s *Store) SetTheme(userID int64, theme string) error {
267 return err 267 return err
268} 268}
269 269
270// DiffLayout is how the account wants diffs drawn on the web: unified or
271// split (#290).
272func (s *Store) DiffLayout(userID int64) (string, error) {
273 var l string
274 err := s.DB.QueryRow("SELECT diff_layout FROM users WHERE id = ?", userID).Scan(&l)
275 if errors.Is(err, sql.ErrNoRows) {
276 return "", ErrNotFound
277 }
278 return l, err
279}
280
281func (s *Store) SetDiffLayout(userID int64, layout string) error {
282 _, err := s.DB.Exec("UPDATE users SET diff_layout = ? WHERE id = ?", layout, userID)
283 return err
284}
285
270func (s *Store) UserByID(id int64) (User, error) { 286func (s *Store) UserByID(id int64) (User, error) {
271 var u User 287 var u User
272 var admin, pending, disabled int 288 var admin, pending, disabled int
internal/web/static/style.css +35
@@ -1393,6 +1393,41 @@ table.difftable tr.threadrow td { padding: var(--sp-2) var(--sp-3); background:
1393table.difftable td.ln a.cmt { color: inherit; text-decoration: none; } 1393table.difftable td.ln a.cmt { color: inherit; text-decoration: none; }
1394table.difftable td.ln a.cmt:hover, 1394table.difftable td.ln a.cmt:hover,
1395table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underline; } 1395table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underline; }
1396/* split layout: old and new side by side in one table. Where two
1397 columns of code do not fit, each row becomes a two-column grid and the
1398 old cells stack over the new, which reads as a unified diff. */
1399table.difftable.split { table-layout: fixed; }
1400table.difftable.split col.cln { width: 3.5rem; }
1401table.difftable.split td.ln.ctx, table.difftable.split td.ln.none { border-right: 1px solid var(--line); }
1402table.difftable.split td.ln + td.src + td.ln { border-left: 1px solid var(--line); }
1403table.difftable.split td.ln { width: auto; }
1404table.difftable.split td.del { background: var(--diff-del); }
1405table.difftable.split td.add { background: var(--diff-add); }
1406table.difftable.split td.none { background: var(--surface); }
1407table.difftable.split td.src.add::before { content: "+"; color: var(--ok); }
1408table.difftable.split td.src.del::before { content: "\2212"; color: var(--bad); }
1409table.difftable.split td.src.ctx::before { content: " "; }
1410table.difftable.split tr.threadrow td { overflow-wrap: anywhere; }
1411table.difftable.split td.ln:target,
1412table.difftable.split td.ln:target + td { background: color-mix(in srgb, var(--mark) 18%, transparent); }
1413table.difftable.split td.ln:target { border-left: 3px solid var(--mark); }
1414table.difftable.split .nonl { display: block; color: var(--muted); font-style: italic; }
1415p.layoutpick { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); }
1416p.layoutpick strong { color: var(--fg); font-weight: 600; }
1417@media (max-width: 64rem) {
1418 p.layoutpick { display: none; }
1419 table.difftable.split, table.difftable.split tbody { display: block; }
1420 table.difftable.split colgroup { display: none; }
1421 table.difftable.split tr.pair { display: grid; grid-template-columns: auto 1fr; }
1422 table.difftable.split tr.pair td.none, table.difftable.split tr.pair td.ctx.old { display: none; }
1423 table.difftable.split td.ln + td.src + td.ln { border-left: 0; }
1424 table.difftable.split tr.pair td.ln { min-width: 3rem; }
1425 table.difftable.split tr.hunk, table.difftable.split tr.dmeta,
1426 table.difftable.split tr.threadrow { display: block; }
1427 table.difftable.split tr.hunk td.ln, table.difftable.split tr.dmeta td.ln { display: none; }
1428 table.difftable.split tr.hunk td, table.difftable.split tr.dmeta td,
1429 table.difftable.split tr.threadrow td { display: block; }
1430}
1396/* A linked line: f2-n17 on a diff, L17 on a file (see issue 118). */ 1431/* A linked line: f2-n17 on a diff, L17 on a file (see issue 118). */
1397.difftable tr:target td, .code .line:has(a:target) { background: color-mix(in srgb, var(--mark) 18%, transparent); } 1432.difftable tr:target td, .code .line:has(a:target) { background: color-mix(in srgb, var(--mark) 18%, transparent); }
1398.difftable tr:target td.ln, .code .line:has(a:target) .ln { border-left: 3px solid var(--mark); } 1433.difftable tr:target td.ln, .code .line:has(a:target) .ln { border-left: 3px solid var(--mark); }
internal/web/templates/account.html +10
@@ -180,6 +180,16 @@ form here to add one, since a browser cannot produce an APNs token.</p>
180 <button type="submit" class="btn">Save</button> 180 <button type="submit" class="btn">Save</button>
181</form> 181</form>
182<p class="meta">The same setting as <code>gitbay web theme set</code>. Following the browser takes its light or dark preference.</p> 182<p class="meta">The same setting as <code>gitbay web theme set</code>. Following the browser takes its light or dark preference.</p>
183<form method="post" action="/settings" class="setform">
184 <input type="hidden" name="field" value="diff-layout">
185 <label for="layout">Diff layout</label>
186 <select id="layout" name="layout">
187 <option value="unified"{{if ne .DiffSetting "split"}} selected{{end}}>unified</option>
188 <option value="split"{{if eq .DiffSetting "split"}} selected{{end}}>side by side</option>
189 </select>
190 <button type="submit" class="btn">Save</button>
191</form>
192<p class="meta">The same setting as <code>gitbay web diff set</code>. A <code>?layout=split</code> or <code>?layout=unified</code> on a diff page overrides it for that request. Narrow windows always show a unified diff.</p>
183</section> 193</section>
184 194
185<section id="export"><h2>Export</h2> 195<section id="export"><h2>Export</h2>
internal/web/templates/commit.html +1 −1
@@ -11,5 +11,5 @@
11</div> 11</div>
12<pre class="message" tabindex="0">{{.Message}}</pre> 12<pre class="message" tabindex="0">{{.Message}}</pre>
13{{if .DiffTruncated}}<p class="error" role="alert">This patch is larger than 4 MiB and is cut off below. Clone the repository to see all of it.</p>{{end}} 13{{if .DiffTruncated}}<p class="error" role="alert">This patch is larger than 4 MiB and is cut off below. Clone the repository to see all of it.</p>{{end}}
14{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}} 14{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" "" "L" .Layout}}
15{{end}} 15{{end}}
internal/web/templates/compare.html +1 −1
@@ -14,5 +14,5 @@
14</ul>{{else}}<p class="empty-note"><code>{{.Head}}</code> has nothing that <code>{{.Base}}</code> does not.</p>{{end}} 14</ul>{{else}}<p class="empty-note"><code>{{.Head}}</code> has nothing that <code>{{.Base}}</code> does not.</p>{{end}}
15{{if .DiffFiles}}<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>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p> 15{{if .DiffFiles}}<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>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
16{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below.</p>{{end}} 16{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below.</p>{{end}}
17{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}}{{end}} 17{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" "" "L" .Layout}}{{end}}
18{{end}} 18{{end}}
internal/web/templates/layout.html +24 −4
@@ -199,7 +199,8 @@
199 line-number gutters and review threads inline. Base is the MR's 199 line-number gutters and review threads inline. Base is the MR's
200 endpoint and Viewer the signed-in account; the commit page passes 200 endpoint and Viewer the signed-in account; the commit page passes
201 neither and gets the same diff without review controls. */}} 201 neither and gets the same diff without review controls. */}}
202{{define "difffiles"}}{{$base := .Base}}{{$viewer := .Viewer}} 202{{define "difffiles"}}{{$base := .Base}}{{$viewer := .Viewer}}{{$L := .L}}{{$split := false}}{{with $L}}{{$split = .Split}}{{end}}
203{{with $L}}{{if .UnifiedURL}}<p class="layoutpick">Layout: {{if .Split}}<a href="{{.UnifiedURL}}">unified</a> · <strong aria-current="true">split</strong>{{else}}<strong aria-current="true">unified</strong> · <a href="{{.SplitURL}}">split</a>{{end}}</p>{{end}}{{end}}
203{{range $fi, $f := .Files}}<details class="difffold" id="f{{$fi}}"{{if .Open}} open{{end}}> 204{{range $fi, $f := .Files}}<details class="difffold" id="f{{$fi}}"{{if .Open}} open{{end}}>
204 <summary> 205 <summary>
205 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span> 206 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span>
@@ -207,10 +208,29 @@
207 <span class="fstat">{{if .Adds}}<span class="add">+{{.Adds}}</span>{{end}}{{if .Dels}} <span class="del">−{{.Dels}}</span>{{end}}</span> 208 <span class="fstat">{{if .Adds}}<span class="add">+{{.Adds}}</span>{{end}}{{if .Dels}} <span class="del">−{{.Dels}}</span>{{end}}</span>
208 </summary> 209 </summary>
209 {{if .Binary}}<p class="none">Binary file not shown.</p> 210 {{if .Binary}}<p class="none">Binary file not shown.</p>
211 {{else if $split}}<div class="tablewrap"><table class="difftable split"><colgroup><col class="cln"><col><col class="cln"><col></colgroup>
212 {{range .Rows}}{{if eq .Kind "hunk"}}<tr class="hunk"><td class="ln"></td><td class="src" colspan="3">{{.Text}}</td></tr>
213 {{else if eq .Kind "meta"}}<tr class="dmeta"><td class="ln"></td><td class="src" colspan="3">{{.Text}}</td></tr>
214 {{else}}{{$r := .}}<tr class="pair">
215 {{with .Old}}{{if eq .Class "del"}}<td class="ln del" id="f{{$fi}}-o{{.OldLine}}">{{template "cmtln" dict "N" .OldLine "Path" .Path "Side" "old" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}</td><td class="src del chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}{{with $r.OldNote}}<span class="nonl">{{.}}</span>{{end}}</td>{{else}}<td class="ln ctx old">{{.OldLine}}</td><td class="src ctx old chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}{{with $r.OldNote}}<span class="nonl">{{.}}</span>{{end}}</td>{{end}}{{else}}<td class="ln none" aria-hidden="true"></td><td class="src none" aria-hidden="true"></td>{{end}}
216 {{with .New}}<td class="ln {{.Class}}" id="f{{$fi}}-n{{.NewLine}}">{{template "cmtln" dict "N" .NewLine "Path" .Path "Side" "new" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}</td><td class="src {{.Class}} chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}{{with $r.NewNote}}<span class="nonl">{{.}}</span>{{end}}</td>{{else}}<td class="ln none" aria-hidden="true"></td><td class="src none" aria-hidden="true"></td>{{end}}
217 </tr>
218 {{with .Compose}}<tr class="threadrow"><td colspan="4"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing">
219 <input type="hidden" name="path" value="{{.Path}}">
220 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
221 <input type="hidden" name="side" value="{{if eq .Class "del"}}old{{else}}new{{end}}">
222 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p>
223 <p><button type="submit" class="btn">Comment</button>
224 <button type="submit" name="pending" value="on" class="btn">Add to review</button>
225 <a href="{{$base}}?view=diff{{if $L.Carry}}&amp;layout={{$L.Carry}}{{end}}">Cancel</a></p>
226 </form></td></tr>
227 {{end}}{{range .Threads}}<tr class="threadrow"><td colspan="4">{{template "thread" dict "T" . "Base" $base "Viewer" $viewer "Class" ""}}</td></tr>
228 {{end}}{{end}}{{end}}
229 </table></div>
210 {{else}}<div class="tablewrap"><table class="difftable"> 230 {{else}}<div class="tablewrap"><table class="difftable">
211 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr> 231 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
212 {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr> 232 {{else if eq .Class "meta"}}<tr class="dmeta"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
213 {{else}}<tr class="{{.Class}}" id="f{{$fi}}-{{if eq .Class "del"}}o{{.OldLine}}{{else}}n{{.NewLine}}{{end}}"><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> 233 {{else}}<tr class="{{.Class}}" id="f{{$fi}}-{{if eq .Class "del"}}o{{.OldLine}}{{else}}n{{.NewLine}}{{end}}"><td class="ln">{{if .OldLine}}{{if eq .Class "del"}}{{template "cmtln" dict "N" .OldLine "Path" .Path "Side" "old" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}{{else}}{{.OldLine}}{{end}}{{end}}</td><td class="ln">{{if .NewLine}}{{template "cmtln" dict "N" .NewLine "Path" .Path "Side" "new" "Base" $base "Viewer" $viewer "Carry" $L.Carry}}{{end}}</td><td class="src chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td></tr>
214 {{if .Compose}}<tr class="threadrow"><td colspan="3"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing"> 234 {{if .Compose}}<tr class="threadrow"><td colspan="3"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing">
215 <input type="hidden" name="path" value="{{.Path}}"> 235 <input type="hidden" name="path" value="{{.Path}}">
216 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}"> 236 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
@@ -218,7 +238,7 @@
218 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p> 238 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p>
219 <p><button type="submit" class="btn">Comment</button> 239 <p><button type="submit" class="btn">Comment</button>
220 <button type="submit" name="pending" value="on" class="btn">Add to review</button> 240 <button type="submit" name="pending" value="on" class="btn">Add to review</button>
221 <a href="{{$base}}?view=diff">Cancel</a></p> 241 <a href="{{$base}}?view=diff{{if $L.Carry}}&amp;layout={{$L.Carry}}{{end}}">Cancel</a></p>
222 </form></td></tr> 242 </form></td></tr>
223 {{end}}{{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3">{{template "thread" dict "T" . "Base" $base "Viewer" $viewer "Class" ""}}</td></tr> 243 {{end}}{{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3">{{template "thread" dict "T" . "Base" $base "Viewer" $viewer "Class" ""}}</td></tr>
224 {{end}}{{end}} 244 {{end}}{{end}}
@@ -228,7 +248,7 @@
228 248
229{{/* cmtln turns a line number into the link that opens the comment form 249{{/* cmtln turns a line number into the link that opens the comment form
230 on that line. No JavaScript: the anchor travels in the query. */}} 250 on that line. No JavaScript: the anchor travels in the query. */}}
231{{define "cmtln"}}{{if and .Viewer .Base}}<a class="cmt" aria-label="Comment on line {{.N}}" 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}} 251{{define "cmtln"}}{{if and .Viewer .Base}}<a class="cmt" aria-label="Comment on line {{.N}}" title="Comment on this line" href="{{.Base}}?view=diff&amp;cpath={{.Path}}&amp;cline={{.N}}&amp;cside={{.Side}}{{if .Carry}}&amp;layout={{.Carry}}{{end}}#compose">{{.N}}</a>{{else}}{{.N}}{{end}}{{end}}
232 252
233{{/* thread renders one review thread with its reply and resolve controls. 253{{/* thread renders one review thread with its reply and resolve controls.
234 Class carries "stale" for threads whose anchor is gone. */}} 254 Class carries "stale" for threads whose anchor is gone. */}}
internal/web/templates/mr.html +1 −1
@@ -76,7 +76,7 @@
76{{else}} 76{{else}}
77{{if .DiffFiles}}<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>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p> 77{{if .DiffFiles}}<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>{{if .DiffTruncated}} · shown up to 4 MiB; the counts and the last file are partial{{end}}</p>
78{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}} 78{{if .DiffTruncated}}<p class="error" role="alert">This diff is larger than 4 MiB and is cut off below. Fetch the branch to see all of it.</p>{{end}}
79{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer}} 79{{template "difffiles" dict "Files" .DiffFiles "Base" $base "Viewer" .Viewer "L" .Layout}}
80{{else if .HeadPruned}}<p class="empty-note">The head of this merge request is no longer in the repository; its diff is not available.</p> 80{{else if .HeadPruned}}<p class="empty-note">The head of this merge request is no longer in the repository; its diff is not available.</p>
81{{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}} 81{{else}}<p class="empty-note">No changes between the source and target.{{if .HeadMerged}} The source branch was already merged or fast-forwarded into <code>{{.MR.TargetRef}}</code>.{{end}}</p>{{end}}
82{{end}} 82{{end}}