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).
367367| push device remove | yes | yes | yes |
368368| activity push on, off | yes | yes | yes |
369369| web colour scheme | yes | yes | n/a |
370| web diff layout | yes | yes | n/a |
370371| API token mint | yes | yes | no |
371372| API token list, revoke | yes | yes | no |
372373| 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
743743preference. =web theme show= prints it. The account page has the same
744744control under Appearance.
745745
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
746754* Notifications
747755
748756When 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
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
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
716* v1.38.0 — 2026-09-29
817
918Merge when ready, build egress by trust class, and restore-drill
cmd/gitbay/main.go +4
@@ -745,6 +745,10 @@ func webCmd() *cobra.Command {
745745 pass("show", passOpts{server: []string{"web", "theme", "show"}}),
746746 pass("set", passOpts{server: []string{"web", "theme", "set"}}),
747747 ),
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 ),
748752 )
749753}
750754
cmd/gitbay/summaries_gen.go +2
@@ -224,6 +224,8 @@ var summaries = map[string]string{
224224 "token create": "mint an API token (shown once)",
225225 "token list": "list API tokens",
226226 "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",
227229 "web login": "mint a one-time browser login URL",
228230 "web sessions list": "list your browser sessions",
229231 "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) {
101101 "token list": {},
102102 "web sessions list": {},
103103 "web theme show": {},
104 "web diff show": {},
104105 "account export": {},
105106 "org list": {},
106107 "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
9696 watchOn, _ := s.st.WatchEnabled(u.ID)
9797 pushOn, _ := s.st.PushEnabled(u.ID)
9898 theme, _ := s.st.Theme(u.ID)
99 diffPref, _ := s.st.DiffLayout(u.ID)
99100
100101 var devices []accountDevice
101102 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
149150 PushOn bool
150151 Devices []accountDevice
151152 ThemeSetting string // system, light or dark: the form's selected option
153 DiffSetting string // unified or split: the form's selected option
152154 Tokens []accountToken
153155 TokenShown string // a token minted by this request, shown once
154156 Reauth bool // Notice is the stale-session refusal: link to sign in
155157 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
156158 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,
158160 tokens, tokenShown, reauth})
159161}
160162
@@ -342,6 +344,12 @@ func (s *Server) accountSubmit(w http.ResponseWriter, r *http.Request, u store.U
342344 return
343345 }
344346 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")
345353 case "notify-mail", "notify-watch", "notify-push":
346354 pref := strings.TrimPrefix(r.FormValue("field"), "notify-")
347355 state := "off"
internal/httpd/compare.go +6 −1
@@ -73,6 +73,10 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
7373 }
7474 commits = append(commits, cr)
7575 }
76 layout := s.diffLayoutFor(r)
77 if layout.Split {
78 splitFiles(files)
79 }
7680 canWrite := s.canWriteRepo(r, p.Repo)
7781 canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
7882 s.render(w, "compare.html", struct {
@@ -84,5 +88,6 @@ func (s *Server) compare(w http.ResponseWriter, r *http.Request) {
8488 DiffTruncated bool
8589 Stat diffStat
8690 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})
8893}
internal/httpd/diff.go +123 −2
@@ -3,6 +3,8 @@ package httpd
33import (
44 "bytes"
55 "html/template"
6 "net/http"
7 "net/url"
68 "regexp"
79 "strconv"
810 "strings"
@@ -36,8 +38,9 @@ type diffFile struct {
3638 Dels int
3739 Binary bool
3840 Lines []diffLine
39 Threads int // threads anchored in this file, so it can stay unfolded
40 Open bool // rendered unfolded: small files, and anything under review
41 Rows []splitRow // the split layout's rows; empty in the unified layout
42 Threads int // threads anchored in this file, so it can stay unfolded
43 Open bool // rendered unfolded: small files, and anything under review
4144}
4245
4346type diffStat struct{ Files, Adds, Dels int }
@@ -302,3 +305,121 @@ func statOf(files []diffFile) diffStat {
302305 }
303306 return st
304307}
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 {
2323 Comments []renderedComment
2424 Reviews []reviewRow
2525 DiffFiles []diffFile
26 DiffTruncated bool
2627 Stat diffStat
2728 Commits []struct{}
2829 Branches []gitutil.Ref
@@ -38,6 +39,7 @@ type mrPageData struct {
3839 HeadPruned bool
3940 Base string
4041 Draft *draft
42 Layout diffLayout
4143}
4244
4345// 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) {
16971697 }
16981698 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
16991699 files := parseDiff(patch)
1700 layout := s.diffLayoutFor(r)
1701 if layout.Split {
1702 splitFiles(files)
1703 }
17001704 committerEmail := ""
17011705 if parsed.CommitterEmail != parsed.AuthorEmail {
17021706 committerEmail = parsed.CommitterEmail
@@ -1716,9 +1720,10 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
17161720 Checks []store.CommitStatus
17171721 DiffFiles []diffFile
17181722 DiffTruncated bool
1723 Layout diffLayout
17191724 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
17201725 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})
17221727}
17231728
17241729// 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
21872192 if p.Viewer != "" {
21882193 markCompose(files, r.URL.Query())
21892194 }
2195 layout := s.diffLayoutFor(r)
2196 if layout.Split {
2197 splitFiles(files)
2198 }
21902199 stat := statOf(files)
21912200 // The commits this MR carries: base..head, the same range as the diff.
21922201 type commitRow struct {
@@ -2293,10 +2302,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri
22932302 Base string
22942303 LabelColors map[string]template.CSS
22952304 Draft *draft
2305 Layout diffLayout
22962306 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
22972307 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
22982308 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})
23002310}
23012311
23022312// 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 {
267267 return err
268268}
269269
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
270286func (s *Store) UserByID(id int64) (User, error) {
271287 var u User
272288 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:
13931393table.difftable td.ln a.cmt { color: inherit; text-decoration: none; }
13941394table.difftable td.ln a.cmt:hover,
13951395table.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}
13961429/* A linked line: f2-n17 on a diff, L17 on a file (see issue 118). */
13971430.difftable tr:target td, .code .line:has(a:target) { background: color-mix(in srgb, var(--mark) 18%, transparent); }
13981431.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>
180180 <button type="submit" class="btn">Save</button>
181181</form>
182182<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>
183193</section>
184194
185195<section id="export"><h2>Export</h2>
internal/web/templates/commit.html +1 −1
@@ -11,5 +11,5 @@
1111</div>
1212<pre class="message" tabindex="0">{{.Message}}</pre>
1313{{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}}
1515{{end}}
internal/web/templates/compare.html +1 −1
@@ -14,5 +14,5 @@
1414</ul>{{else}}<p class="empty-note"><code>{{.Head}}</code> has nothing that <code>{{.Base}}</code> does not.</p>{{end}}
1515{{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>
1616{{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}}
1818{{end}}
internal/web/templates/layout.html +24 −4
@@ -199,7 +199,8 @@
199199 line-number gutters and review threads inline. Base is the MR's
200200 endpoint and Viewer the signed-in account; the commit page passes
201201 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}}
203204{{range $fi, $f := .Files}}<details class="difffold" id="f{{$fi}}"{{if .Open}} open{{end}}>
204205 <summary>
205206 <span class="fpath">{{if eq .Status "renamed"}}<span class="was">{{.OldPath}} →</span> {{end}}{{.Path}}</span>
@@ -207,10 +208,29 @@
207208 <span class="fstat">{{if .Adds}}<span class="add">+{{.Adds}}</span>{{end}}{{if .Dels}} <span class="del">−{{.Dels}}</span>{{end}}</span>
208209 </summary>
209210 {{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>
210230 {{else}}<div class="tablewrap"><table class="difftable">
211231 {{range .Lines}}{{if eq .Class "hunk"}}<tr class="hunk"><td class="ln" colspan="2"></td><td class="src">{{.Text}}</td></tr>
212232 {{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>
214234 {{if .Compose}}<tr class="threadrow"><td colspan="3"><form id="compose" method="post" action="{{$base}}/diff-comment" class="thread composing">
215235 <input type="hidden" name="path" value="{{.Path}}">
216236 <input type="hidden" name="line" value="{{if eq .Class "del"}}{{.OldLine}}{{else}}{{.NewLine}}{{end}}">
@@ -218,7 +238,7 @@
218238 <p><textarea name="body" aria-label="Comment on {{.Path}}" rows="3" placeholder="Comment on this line" autofocus></textarea></p>
219239 <p><button type="submit" class="btn">Comment</button>
220240 <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>
222242 </form></td></tr>
223243 {{end}}{{end}}{{range .Threads}}<tr class="threadrow"><td colspan="3">{{template "thread" dict "T" . "Base" $base "Viewer" $viewer "Class" ""}}</td></tr>
224244 {{end}}{{end}}
@@ -228,7 +248,7 @@
228248
229249{{/* cmtln turns a line number into the link that opens the comment form
230250 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}}
232252
233253{{/* thread renders one review thread with its reply and resolve controls.
234254 Class carries "stale" for threads whose anchor is gone. */}}
internal/web/templates/mr.html +1 −1
@@ -76,7 +76,7 @@
7676{{else}}
7777{{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>
7878{{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}}
8080{{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>
8181{{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}}
8282{{end}}