Commit 9cf7a688b5

9cf7a688b50f2eff0fec058372a65ce3c830371b

parent: 5522555448

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 05:18 UTC

web: side-by-side diff layout

web diff show|set unified|split stores the layout per account
(migration 0068); ?layout= overrides it per request. The split table
falls back to stacked rows below 64rem.

Ref #290

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 +123 −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,121 @@ 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 Compose *diffLine // the line whose new-thread form opens under this row
320}
321
322// diffLayout is the layout a diff page renders in and the links that
323// switch it.
324type diffLayout struct {
325 Split bool
326 UnifiedURL string
327 SplitURL string
328 Carry string // "split" or "unified" when the request chose it, so links keep it
329}
330
331// splitFiles fills each file's Rows. It runs after threads and compose
332// forms are attached to the lines.
333func splitFiles(files []diffFile) {
334 for f := range files {
335 lines := files[f].Lines
336 var rows []splitRow
337 for i := 0; i < len(lines); {
338 ln := &lines[i]
339 switch ln.Class {
340 case "hunk", "meta":
341 rows = append(rows, splitRow{Kind: ln.Class, Text: ln.Text})
342 i++
343 case "ctx":
344 r := splitRow{Kind: "pair", Old: ln, New: ln, Threads: ln.Threads}
345 if ln.Compose {
346 r.Compose = ln
347 }
348 rows = append(rows, r)
349 i++
350 default:
351 var dels, adds []*diffLine
352 for i < len(lines) && lines[i].Class == "del" {
353 dels = append(dels, &lines[i])
354 i++
355 }
356 for i < len(lines) && lines[i].Class == "add" {
357 adds = append(adds, &lines[i])
358 i++
359 }
360 if len(dels)+len(adds) == 0 {
361 i++ // an unknown class: skip rather than loop
362 continue
363 }
364 for k := 0; k < len(dels) || k < len(adds); k++ {
365 r := splitRow{Kind: "pair"}
366 for _, l := range []*diffLine{pick(dels, k), pick(adds, k)} {
367 if l == nil {
368 continue
369 }
370 if l.Class == "del" {
371 r.Old = l
372 } else {
373 r.New = l
374 }
375 r.Threads = append(r.Threads, l.Threads...)
376 if l.Compose {
377 r.Compose = l
378 }
379 }
380 rows = append(rows, r)
381 }
382 }
383 }
384 files[f].Rows = rows
385 }
386}
387
388func pick(s []*diffLine, i int) *diffLine {
389 if i < len(s) {
390 return s[i]
391 }
392 return nil
393}
394
395// diffLayoutFor resolves the layout for a request: ?layout= wins, then the
396// signed-in account's setting, then unified. The two switch links keep
397// every other query parameter.
398func (s *Server) diffLayoutFor(r *http.Request) diffLayout {
399 q := r.URL.Query()
400 l := diffLayout{}
401 switch q.Get("layout") {
402 case "split":
403 l.Split, l.Carry = true, "split"
404 case "unified":
405 l.Carry = "unified"
406 default:
407 if s.cfg.Web.Mode == "accounts" {
408 if u := s.viewer(r); u.ID != 0 {
409 if v, err := s.st.DiffLayout(u.ID); err == nil {
410 l.Split = v == "split"
411 }
412 }
413 }
414 }
415 link := func(v string) string {
416 c := url.Values{}
417 for k, vs := range q {
418 c[k] = vs
419 }
420 c.Set("layout", v)
421 return r.URL.Path + "?" + c.Encode()
422 }
423 l.UnifiedURL, l.SplitURL = link("unified"), link("split")
424 return l
425}
internal/httpd/difflayout_test.go added +162
@@ -0,0 +1,162 @@
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}
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 +33
@@ -1393,6 +1393,39 @@ 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); }
1414p.layoutpick { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); }
1415p.layoutpick strong { color: var(--fg); font-weight: 600; }
1416@media (max-width: 64rem) {
1417 table.difftable.split, table.difftable.split tbody { display: block; }
1418 table.difftable.split colgroup { display: none; }
1419 table.difftable.split tr.pair { display: grid; grid-template-columns: auto 1fr; }
1420 table.difftable.split tr.pair td.none, table.difftable.split tr.pair td.ctx.old { display: none; }
1421 table.difftable.split td.ln + td.src + td.ln { border-left: 0; }
1422 table.difftable.split tr.pair td.ln { min-width: 3rem; }
1423 table.difftable.split tr.hunk, table.difftable.split tr.dmeta,
1424 table.difftable.split tr.threadrow { display: block; }
1425 table.difftable.split tr.hunk td.ln, table.difftable.split tr.dmeta td.ln { display: none; }
1426 table.difftable.split tr.hunk td, table.difftable.split tr.dmeta td,
1427 table.difftable.split tr.threadrow td { display: block; }
1428}
1396/* A linked line: f2-n17 on a diff, L17 on a file (see issue 118). */ 1429/* 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); } 1430.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); } 1431.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}}<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}}</td>{{else}}<td class="ln ctx old">{{.OldLine}}</td><td class="src ctx old chroma">{{if .Code}}{{.Code}}{{else}}{{.Content}}{{end}}</td>{{end}}{{else}}<td class="ln none"></td><td class="src none"></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}}</td>{{else}}<td class="ln none"></td><td class="src none"></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}}