Commit 9cf7a688b5
Verified · cmc
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 | |||
| 743 | preference. =web theme show= prints it. The account page has the same | 743 | preference. =web theme show= prints it. The account page has the same |
| 744 | control under Appearance. | 744 | control under Appearance. |
| 745 | 745 | ||
| 746 | =web diff set split= draws diffs side by side on the merge request, | ||
| 747 | commit and compare pages; =unified=, the default, keeps one column. | ||
| 748 | =web diff show= prints it, and the account page has the same control | ||
| 749 | under Appearance. =?layout=split= or =?layout=unified= on a diff page | ||
| 750 | overrides the setting for that request. In a narrow window the split | ||
| 751 | layout stacks the old line over the new one, as a unified diff does. | ||
| 752 | Each column's line numbers open a comment on their own side. | ||
| 753 | |||
| 746 | * Notifications | 754 | * Notifications |
| 747 | 755 | ||
| 748 | When the instance has SMTP configured, activity mails you as well as | 756 | When 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 | |||
| 4 | automatically on daemon start; upgrade notes appear per release when | 4 | automatically on daemon start; upgrade notes appear per release when |
| 5 | anything beyond "replace the binary and restart" is needed. | 5 | anything 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 | ||
| 9 | Merge when ready, build egress by trust class, and restore-drill | 18 | Merge 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "io" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/protocol" | ||
| 8 | ) | ||
| 9 | |||
| 10 | func 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 | |||
| 22 | var diffLayouts = map[string]bool{"unified": true, "split": true} | ||
| 23 | |||
| 24 | func runWebDiffShow(c *Ctx, args []string) int { | ||
| 25 | if len(args) != 0 { | ||
| 26 | return c.usage() | ||
| 27 | } | ||
| 28 | return emitDiffLayout(c) | ||
| 29 | } | ||
| 30 | |||
| 31 | func 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 | |||
| 41 | func 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 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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. | ||
| 15 | func 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 | |||
| 3 | import ( | 3 | import ( |
| 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 | ||
| 43 | type diffStat struct{ Files, Adds, Dels int } | 46 | type 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. | ||
| 313 | type 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. | ||
| 324 | type 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. | ||
| 333 | func 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 | |||
| 388 | func 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. | ||
| 398 | func (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 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 13 | const 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 | |||
| 24 | func 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 | |||
| 49 | func 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 | |||
| 69 | func 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 | |||
| 79 | func 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. | ||
| 99 | func 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&cline=2&cside=old&layout=split#compose`, | ||
| 113 | `cpath=a.txt&cline=2&cside=new&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 | |||
| 122 | func 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 @@ | |||
| 1 | ALTER 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). | ||
| 3 | ALTER 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). | ||
| 272 | func (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 | |||
| 281 | func (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 | |||
| 270 | func (s *Store) UserByID(id int64) (User, error) { | 286 | func (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: | |||
| 1393 | table.difftable td.ln a.cmt { color: inherit; text-decoration: none; } | 1393 | table.difftable td.ln a.cmt { color: inherit; text-decoration: none; } |
| 1394 | table.difftable td.ln a.cmt:hover, | 1394 | table.difftable td.ln a.cmt:hover, |
| 1395 | table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underline; } | 1395 | table.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. */ | ||
| 1399 | table.difftable.split { table-layout: fixed; } | ||
| 1400 | table.difftable.split col.cln { width: 3.5rem; } | ||
| 1401 | table.difftable.split td.ln.ctx, table.difftable.split td.ln.none { border-right: 1px solid var(--line); } | ||
| 1402 | table.difftable.split td.ln + td.src + td.ln { border-left: 1px solid var(--line); } | ||
| 1403 | table.difftable.split td.ln { width: auto; } | ||
| 1404 | table.difftable.split td.del { background: var(--diff-del); } | ||
| 1405 | table.difftable.split td.add { background: var(--diff-add); } | ||
| 1406 | table.difftable.split td.none { background: var(--surface); } | ||
| 1407 | table.difftable.split td.src.add::before { content: "+"; color: var(--ok); } | ||
| 1408 | table.difftable.split td.src.del::before { content: "\2212"; color: var(--bad); } | ||
| 1409 | table.difftable.split td.src.ctx::before { content: " "; } | ||
| 1410 | table.difftable.split tr.threadrow td { overflow-wrap: anywhere; } | ||
| 1411 | table.difftable.split td.ln:target, | ||
| 1412 | table.difftable.split td.ln:target + td { background: color-mix(in srgb, var(--mark) 18%, transparent); } | ||
| 1413 | table.difftable.split td.ln:target { border-left: 3px solid var(--mark); } | ||
| 1414 | p.layoutpick { color: var(--muted); font-size: var(--fs-2); margin: 0 0 var(--sp-3); } | ||
| 1415 | p.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}}&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}}&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&cpath={{.Path}}&cline={{.N}}&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&cpath={{.Path}}&cline={{.N}}&cside={{.Side}}{{if .Carry}}&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}} |