feat(web): merge request layout, merge bar and lazy-loaded files (#51)
- commit
- 3c3845e5d8be66bcbd3b3c37e910ff00f2dda13c
- author
- brice · · 2026-10-17 22:27 UTC
- committer
- bnema · · 2026-10-17 22:27 UTC
- parent
- e08f000
- signature
- SSH Unverified
SHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivAThe key is unknown
72 files · +3495 -1450
M internal/adapters/gitcli/diff.go +10 -3
| @@ -107,16 +107,22 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 107 | 107 | stats.Deletions += entries[i].del | |
| 108 | 108 | } | |
| 109 | 109 | var diff domain.Diff | |
| 110 | - | if len(entries) > limits.MaxFiles { | |
| 110 | + | if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles { | |
| 111 | 111 | entries = entries[:limits.MaxFiles] | |
| 112 | 112 | diff.Truncated = true | |
| 113 | 113 | } | |
| 114 | + | // Only the files of the window [from, to) get their hunks; the others are | |
| 115 | + | // listed as they are, neither truncated nor counted in the budgets. | |
| 116 | + | from, to := max(limits.From, 0), len(entries) | |
| 117 | + | if limits.To > 0 { | |
| 118 | + | to = min(limits.To, len(entries)) | |
| 119 | + | } | |
| 114 | 120 | ||
| 115 | 121 | // The patch holds one section per file, two for a type change, in the | |
| 116 | 122 | // order of the entries. Entries without lines to show (binary, pure | |
| 117 | 123 | // rename, mode change) have a section without hunks. | |
| 118 | 124 | lastRead := -1 | |
| 119 | - | for i := range entries { | |
| 125 | + | for i := from; i < to; i++ { | |
| 120 | 126 | if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) { | |
| 121 | 127 | lastRead = i | |
| 122 | 128 | } | |
| @@ -136,8 +142,9 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||
| 136 | 142 | if e.typeChange { | |
| 137 | 143 | sections = 2 | |
| 138 | 144 | } | |
| 145 | + | inWindow := i >= from && i < to | |
| 139 | 146 | switch { | |
| 140 | - | case !changedLines(e): | |
| 147 | + | case !inWindow, !changedLines(e): | |
| 141 | 148 | // Nothing to show. | |
| 142 | 149 | case !wantsHunks(e, limits, budget, pr.bytesLeft): | |
| 143 | 150 | e.file.Truncated = true | |
| @@ -107,16 +107,22 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 107 | stats.Deletions += entries[i].del | 107 | stats.Deletions += entries[i].del | ||
| 108 | } | 108 | } | ||
| 109 | var diff domain.Diff | 109 | var diff domain.Diff | ||
| 110 | - | if len(entries) > limits.MaxFiles { | 110 | + | if limits.MaxFiles > 0 && len(entries) > limits.MaxFiles { |
| 111 | entries = entries[:limits.MaxFiles] | 111 | entries = entries[:limits.MaxFiles] | ||
| 112 | diff.Truncated = true | 112 | diff.Truncated = true | ||
| 113 | } | 113 | } | ||
| 114 | + | // Only the files of the window [from, to) get their hunks; the others are | |||
| 115 | + | // listed as they are, neither truncated nor counted in the budgets. | |||
| 116 | + | from, to := max(limits.From, 0), len(entries) | |||
| 117 | + | if limits.To > 0 { | |||
| 118 | + | to = min(limits.To, len(entries)) | |||
| 119 | + | } | |||
| 114 | 120 | ||||
| 115 | // The patch holds one section per file, two for a type change, in the | 121 | // The patch holds one section per file, two for a type change, in the | ||
| 116 | // order of the entries. Entries without lines to show (binary, pure | 122 | // order of the entries. Entries without lines to show (binary, pure | ||
| 117 | // rename, mode change) have a section without hunks. | 123 | // rename, mode change) have a section without hunks. | ||
| 118 | lastRead := -1 | 124 | lastRead := -1 | ||
| 119 | - | for i := range entries { | 125 | + | for i := from; i < to; i++ { |
| 120 | if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) { | 126 | if wantsHunks(&entries[i], limits, limits.MaxTotalLines, limits.MaxTotalBytes) { | ||
| 121 | lastRead = i | 127 | lastRead = i | ||
| 122 | } | 128 | } | ||
| @@ -136,8 +142,9 @@ func readDiff(r *bufio.Reader, limits domain.DiffLimits) (domain.Diff, domain.Di | |||||
| 136 | if e.typeChange { | 142 | if e.typeChange { | ||
| 137 | sections = 2 | 143 | sections = 2 | ||
| 138 | } | 144 | } | ||
| 145 | + | inWindow := i >= from && i < to | |||
| 139 | switch { | 146 | switch { | ||
| 140 | - | case !changedLines(e): | 147 | + | case !inWindow, !changedLines(e): |
| 141 | // Nothing to show. | 148 | // Nothing to show. | ||
| 142 | case !wantsHunks(e, limits, budget, pr.bytesLeft): | 149 | case !wantsHunks(e, limits, budget, pr.bytesLeft): | ||
| 143 | e.file.Truncated = true | 150 | e.file.Truncated = true | ||
M internal/adapters/gitcli/diff_test.go +176 -0
| @@ -484,3 +484,179 @@ func TestGetCommitStopsGitEarly(t *testing.T) { | |||
| 484 | 484 | assert.True(t, d.Diff.Files[1].Truncated) | |
| 485 | 485 | assert.Equal(t, 100005, d.Stats.Additions) | |
| 486 | 486 | } | |
| 487 | + | ||
| 488 | + | // windowFixture commits a diff that mixes what the patch reader must step over | |
| 489 | + | // in a window: text, binary, a rename, a type change (two sections), a mode | |
| 490 | + | // change (no hunks) and more text. It returns the id of the second commit. | |
| 491 | + | func windowFixture(t *testing.T, data, name string) (*readertest.Fixture, string) { | |
| 492 | + | t.Helper() | |
| 493 | + | f := readertest.NewFixture(t, data, name) | |
| 494 | + | f.Lines("a-text", "a", 20).WriteBytes("b-bin", []byte{0, 1, 2}).Lines("c-old", "c", 30). | |
| 495 | + | Lines("d-type", "d", 5).Lines("e-mode", "e", 3).Lines("f-text", "f", 20).Lines("g-text", "g", 20) | |
| 496 | + | f.Commit("v1") | |
| 497 | + | f.Lines("a-text", "A", 21).WriteBytes("b-bin", []byte{0, 3}).Mv("c-old", "c-new"). | |
| 498 | + | Remove("d-type").Symlink("a-text", "d-type").Chmod("e-mode", 0o755).Lines("f-text", "F", 20).Lines("g-text", "G", 2) | |
| 499 | + | f.Write("c-new", readLines("c", 30)+"extra\n") // a rename with one line added | |
| 500 | + | id := f.Commit("v2") | |
| 501 | + | return f, id | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func readLines(prefix string, n int) string { | |
| 505 | + | var b strings.Builder | |
| 506 | + | for i := range n { | |
| 507 | + | fmt.Fprintf(&b, "%s %d\n", prefix, i) | |
| 508 | + | } | |
| 509 | + | return b.String() | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func TestDiffLimitsWindow(t *testing.T) { | |
| 513 | + | ctx := context.Background() | |
| 514 | + | g, data := newReader(t, nil) | |
| 515 | + | ||
| 516 | + | t.Run("a window reads the hunks of its files only, whatever the files around it", func(t *testing.T) { | |
| 517 | + | f, id := windowFixture(t, data, "win-mixed") | |
| 518 | + | full, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), domain.DefaultDiffLimits()) | |
| 519 | + | require.NoError(t, err) | |
| 520 | + | assertMatchesGitShow(t, f, id, full) | |
| 521 | + | n := len(full.Diff.Files) | |
| 522 | + | require.GreaterOrEqual(t, n, 7) | |
| 523 | + | status := map[domain.FileStatus]bool{} | |
| 524 | + | for _, file := range full.Diff.Files { | |
| 525 | + | status[file.Status] = true | |
| 526 | + | } | |
| 527 | + | assert.True(t, status[domain.FileTypeChanged], "the fixture has a type change") | |
| 528 | + | assert.True(t, status[domain.FileRenamed], "the fixture has a rename") | |
| 529 | + | assert.False(t, full.Diff.Truncated) | |
| 530 | + | ||
| 531 | + | tests := []struct { | |
| 532 | + | name string | |
| 533 | + | from, to int | |
| 534 | + | }{ | |
| 535 | + | {"first file", 0, 1}, | |
| 536 | + | {"middle", 2, 5}, | |
| 537 | + | {"type change alone", 3, 4}, | |
| 538 | + | {"binary alone", 1, 2}, | |
| 539 | + | {"mode change alone", 4, 5}, | |
| 540 | + | {"rename alone", 2, 3}, | |
| 541 | + | {"through the end, to 0", 3, 0}, | |
| 542 | + | {"to past the end", 5, 100}, | |
| 543 | + | {"last file", n - 1, n}, | |
| 544 | + | {"from past the end", n, n + 5}, | |
| 545 | + | {"far past the end", 1000, 0}, | |
| 546 | + | {"empty window", 3, 3}, | |
| 547 | + | {"whole diff", 0, 0}, | |
| 548 | + | } | |
| 549 | + | for _, tc := range tests { | |
| 550 | + | t.Run(tc.name, func(t *testing.T) { | |
| 551 | + | limits := domain.DefaultDiffLimits() | |
| 552 | + | limits.From, limits.To = tc.from, tc.to | |
| 553 | + | d, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), limits) | |
| 554 | + | require.NoError(t, err) | |
| 555 | + | require.Len(t, d.Diff.Files, n) | |
| 556 | + | assert.Equal(t, full.Stats, d.Stats) | |
| 557 | + | assert.False(t, d.Diff.Truncated, "files outside the window do not truncate the diff") | |
| 558 | + | to := tc.to | |
| 559 | + | if to == 0 { | |
| 560 | + | to = n | |
| 561 | + | } | |
| 562 | + | for i, got := range d.Diff.Files { | |
| 563 | + | want := full.Diff.Files[i] | |
| 564 | + | if i < tc.from || i >= to { | |
| 565 | + | want.Hunks = nil | |
| 566 | + | } | |
| 567 | + | assert.Equal(t, want, got, "file %d (%s)", i, want.NewPath) | |
| 568 | + | } | |
| 569 | + | }) | |
| 570 | + | } | |
| 571 | + | }) | |
| 572 | + | ||
| 573 | + | t.Run("files outside the window use none of the total budget", func(t *testing.T) { | |
| 574 | + | f := readertest.NewFixture(t, data, "win-budget") | |
| 575 | + | commitFiles(f, "five", map[string]int{"a": 10, "b": 10, "c": 10, "d": 10, "e": 10}) | |
| 576 | + | limits := domain.DiffLimits{MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20, From: 3, To: 5} | |
| 577 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 578 | + | require.NoError(t, err) | |
| 579 | + | require.Len(t, d.Diff.Files, 5) | |
| 580 | + | for i, file := range d.Diff.Files { | |
| 581 | + | switch i { | |
| 582 | + | case 3: | |
| 583 | + | assert.False(t, file.Truncated) | |
| 584 | + | assert.Len(t, file.Hunks, 1, "10 lines fit in 15") | |
| 585 | + | case 4: | |
| 586 | + | assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left") | |
| 587 | + | default: | |
| 588 | + | assert.False(t, file.Truncated) | |
| 589 | + | assert.Empty(t, file.Hunks) | |
| 590 | + | assert.Equal(t, 10, file.Additions, "counts stay accurate") | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | assert.True(t, d.Diff.Truncated, "a file of the window is truncated") | |
| 594 | + | ||
| 595 | + | limits.From, limits.To = 0, 0 | |
| 596 | + | limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file | |
| 597 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 598 | + | require.NoError(t, err) | |
| 599 | + | assert.True(t, d.Diff.Files[0].Truncated) | |
| 600 | + | }) | |
| 601 | + | ||
| 602 | + | t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) { | |
| 603 | + | f := readertest.NewFixture(t, data, "win-huge") | |
| 604 | + | f.Lines("a", "x", 5).Lines("z-huge", "y", 5000).Commit("c") | |
| 605 | + | limits := domain.DefaultDiffLimits() | |
| 606 | + | limits.From, limits.To = 0, 1 | |
| 607 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 608 | + | require.NoError(t, err) | |
| 609 | + | require.Len(t, d.Diff.Files, 2) | |
| 610 | + | assert.Len(t, d.Diff.Files[0].Hunks, 1) | |
| 611 | + | assert.False(t, d.Diff.Files[1].Truncated) | |
| 612 | + | assert.Empty(t, d.Diff.Files[1].Hunks) | |
| 613 | + | assert.False(t, d.Diff.Truncated) | |
| 614 | + | limits.From, limits.To = 1, 2 | |
| 615 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 616 | + | require.NoError(t, err) | |
| 617 | + | assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit") | |
| 618 | + | assert.True(t, d.Diff.Truncated) | |
| 619 | + | }) | |
| 620 | + | ||
| 621 | + | t.Run("MaxFiles 0 lists every file", func(t *testing.T) { | |
| 622 | + | f := readertest.NewFixture(t, data, "win-many") | |
| 623 | + | const total = 350 | |
| 624 | + | for i := range total { | |
| 625 | + | f.Write(fmt.Sprintf("many/f%03d", i), fmt.Sprintf("x%d\n", i)) | |
| 626 | + | } | |
| 627 | + | f.Commit("many") | |
| 628 | + | limits := domain.DefaultDiffLimits() | |
| 629 | + | limits.MaxFiles = 0 | |
| 630 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 631 | + | require.NoError(t, err) | |
| 632 | + | require.Len(t, d.Diff.Files, total) | |
| 633 | + | assert.False(t, d.Diff.Truncated) | |
| 634 | + | assert.Equal(t, domain.DiffStats{FilesChanged: total, Additions: total}, d.Stats) | |
| 635 | + | for i, file := range d.Diff.Files { | |
| 636 | + | assert.Equal(t, fmt.Sprintf("many/f%03d", i), file.NewPath) | |
| 637 | + | require.Len(t, file.Hunks, 1, file.NewPath) | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | // A window past the 300th file reads its own hunks. | |
| 641 | + | limits.From, limits.To = 320, 330 | |
| 642 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |
| 643 | + | require.NoError(t, err) | |
| 644 | + | require.Len(t, d.Diff.Files, total) | |
| 645 | + | assert.False(t, d.Diff.Truncated) | |
| 646 | + | for i, file := range d.Diff.Files { | |
| 647 | + | if i >= 320 && i < 330 { | |
| 648 | + | require.Len(t, file.Hunks, 1) | |
| 649 | + | assert.Equal(t, fmt.Sprintf("x%d", i), file.Hunks[0].Lines[0].Text) | |
| 650 | + | } else { | |
| 651 | + | assert.Empty(t, file.Hunks) | |
| 652 | + | } | |
| 653 | + | assert.False(t, file.Truncated) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | // The default limits still cut at 300. | |
| 657 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) | |
| 658 | + | require.NoError(t, err) | |
| 659 | + | assert.Len(t, d.Diff.Files, 300) | |
| 660 | + | assert.True(t, d.Diff.Truncated) | |
| 661 | + | }) | |
| 662 | + | } | |
| @@ -484,3 +484,179 @@ func TestGetCommitStopsGitEarly(t *testing.T) { | |||||
| 484 | assert.True(t, d.Diff.Files[1].Truncated) | 484 | assert.True(t, d.Diff.Files[1].Truncated) | ||
| 485 | assert.Equal(t, 100005, d.Stats.Additions) | 485 | assert.Equal(t, 100005, d.Stats.Additions) | ||
| 486 | } | 486 | } | ||
| 487 | + | ||||
| 488 | + | // windowFixture commits a diff that mixes what the patch reader must step over | |||
| 489 | + | // in a window: text, binary, a rename, a type change (two sections), a mode | |||
| 490 | + | // change (no hunks) and more text. It returns the id of the second commit. | |||
| 491 | + | func windowFixture(t *testing.T, data, name string) (*readertest.Fixture, string) { | |||
| 492 | + | t.Helper() | |||
| 493 | + | f := readertest.NewFixture(t, data, name) | |||
| 494 | + | f.Lines("a-text", "a", 20).WriteBytes("b-bin", []byte{0, 1, 2}).Lines("c-old", "c", 30). | |||
| 495 | + | Lines("d-type", "d", 5).Lines("e-mode", "e", 3).Lines("f-text", "f", 20).Lines("g-text", "g", 20) | |||
| 496 | + | f.Commit("v1") | |||
| 497 | + | f.Lines("a-text", "A", 21).WriteBytes("b-bin", []byte{0, 3}).Mv("c-old", "c-new"). | |||
| 498 | + | Remove("d-type").Symlink("a-text", "d-type").Chmod("e-mode", 0o755).Lines("f-text", "F", 20).Lines("g-text", "G", 2) | |||
| 499 | + | f.Write("c-new", readLines("c", 30)+"extra\n") // a rename with one line added | |||
| 500 | + | id := f.Commit("v2") | |||
| 501 | + | return f, id | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func readLines(prefix string, n int) string { | |||
| 505 | + | var b strings.Builder | |||
| 506 | + | for i := range n { | |||
| 507 | + | fmt.Fprintf(&b, "%s %d\n", prefix, i) | |||
| 508 | + | } | |||
| 509 | + | return b.String() | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | func TestDiffLimitsWindow(t *testing.T) { | |||
| 513 | + | ctx := context.Background() | |||
| 514 | + | g, data := newReader(t, nil) | |||
| 515 | + | ||||
| 516 | + | t.Run("a window reads the hunks of its files only, whatever the files around it", func(t *testing.T) { | |||
| 517 | + | f, id := windowFixture(t, data, "win-mixed") | |||
| 518 | + | full, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), domain.DefaultDiffLimits()) | |||
| 519 | + | require.NoError(t, err) | |||
| 520 | + | assertMatchesGitShow(t, f, id, full) | |||
| 521 | + | n := len(full.Diff.Files) | |||
| 522 | + | require.GreaterOrEqual(t, n, 7) | |||
| 523 | + | status := map[domain.FileStatus]bool{} | |||
| 524 | + | for _, file := range full.Diff.Files { | |||
| 525 | + | status[file.Status] = true | |||
| 526 | + | } | |||
| 527 | + | assert.True(t, status[domain.FileTypeChanged], "the fixture has a type change") | |||
| 528 | + | assert.True(t, status[domain.FileRenamed], "the fixture has a rename") | |||
| 529 | + | assert.False(t, full.Diff.Truncated) | |||
| 530 | + | ||||
| 531 | + | tests := []struct { | |||
| 532 | + | name string | |||
| 533 | + | from, to int | |||
| 534 | + | }{ | |||
| 535 | + | {"first file", 0, 1}, | |||
| 536 | + | {"middle", 2, 5}, | |||
| 537 | + | {"type change alone", 3, 4}, | |||
| 538 | + | {"binary alone", 1, 2}, | |||
| 539 | + | {"mode change alone", 4, 5}, | |||
| 540 | + | {"rename alone", 2, 3}, | |||
| 541 | + | {"through the end, to 0", 3, 0}, | |||
| 542 | + | {"to past the end", 5, 100}, | |||
| 543 | + | {"last file", n - 1, n}, | |||
| 544 | + | {"from past the end", n, n + 5}, | |||
| 545 | + | {"far past the end", 1000, 0}, | |||
| 546 | + | {"empty window", 3, 3}, | |||
| 547 | + | {"whole diff", 0, 0}, | |||
| 548 | + | } | |||
| 549 | + | for _, tc := range tests { | |||
| 550 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 551 | + | limits := domain.DefaultDiffLimits() | |||
| 552 | + | limits.From, limits.To = tc.from, tc.to | |||
| 553 | + | d, err := g.GetCommit(ctx, f.Name, readertest.Rev(t, id), limits) | |||
| 554 | + | require.NoError(t, err) | |||
| 555 | + | require.Len(t, d.Diff.Files, n) | |||
| 556 | + | assert.Equal(t, full.Stats, d.Stats) | |||
| 557 | + | assert.False(t, d.Diff.Truncated, "files outside the window do not truncate the diff") | |||
| 558 | + | to := tc.to | |||
| 559 | + | if to == 0 { | |||
| 560 | + | to = n | |||
| 561 | + | } | |||
| 562 | + | for i, got := range d.Diff.Files { | |||
| 563 | + | want := full.Diff.Files[i] | |||
| 564 | + | if i < tc.from || i >= to { | |||
| 565 | + | want.Hunks = nil | |||
| 566 | + | } | |||
| 567 | + | assert.Equal(t, want, got, "file %d (%s)", i, want.NewPath) | |||
| 568 | + | } | |||
| 569 | + | }) | |||
| 570 | + | } | |||
| 571 | + | }) | |||
| 572 | + | ||||
| 573 | + | t.Run("files outside the window use none of the total budget", func(t *testing.T) { | |||
| 574 | + | f := readertest.NewFixture(t, data, "win-budget") | |||
| 575 | + | commitFiles(f, "five", map[string]int{"a": 10, "b": 10, "c": 10, "d": 10, "e": 10}) | |||
| 576 | + | limits := domain.DiffLimits{MaxFileLines: 100, MaxTotalLines: 15, MaxTotalBytes: 1 << 20, From: 3, To: 5} | |||
| 577 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 578 | + | require.NoError(t, err) | |||
| 579 | + | require.Len(t, d.Diff.Files, 5) | |||
| 580 | + | for i, file := range d.Diff.Files { | |||
| 581 | + | switch i { | |||
| 582 | + | case 3: | |||
| 583 | + | assert.False(t, file.Truncated) | |||
| 584 | + | assert.Len(t, file.Hunks, 1, "10 lines fit in 15") | |||
| 585 | + | case 4: | |||
| 586 | + | assert.True(t, file.Truncated, "the second file of the window does not fit in the 5 lines left") | |||
| 587 | + | default: | |||
| 588 | + | assert.False(t, file.Truncated) | |||
| 589 | + | assert.Empty(t, file.Hunks) | |||
| 590 | + | assert.Equal(t, 10, file.Additions, "counts stay accurate") | |||
| 591 | + | } | |||
| 592 | + | } | |||
| 593 | + | assert.True(t, d.Diff.Truncated, "a file of the window is truncated") | |||
| 594 | + | ||||
| 595 | + | limits.From, limits.To = 0, 0 | |||
| 596 | + | limits.MaxTotalBytes = 25 // "a 0\n".."a 9\n" is 30 bytes of text per file | |||
| 597 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 598 | + | require.NoError(t, err) | |||
| 599 | + | assert.True(t, d.Diff.Files[0].Truncated) | |||
| 600 | + | }) | |||
| 601 | + | ||||
| 602 | + | t.Run("a file beyond the window is not truncated by its own size", func(t *testing.T) { | |||
| 603 | + | f := readertest.NewFixture(t, data, "win-huge") | |||
| 604 | + | f.Lines("a", "x", 5).Lines("z-huge", "y", 5000).Commit("c") | |||
| 605 | + | limits := domain.DefaultDiffLimits() | |||
| 606 | + | limits.From, limits.To = 0, 1 | |||
| 607 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 608 | + | require.NoError(t, err) | |||
| 609 | + | require.Len(t, d.Diff.Files, 2) | |||
| 610 | + | assert.Len(t, d.Diff.Files[0].Hunks, 1) | |||
| 611 | + | assert.False(t, d.Diff.Files[1].Truncated) | |||
| 612 | + | assert.Empty(t, d.Diff.Files[1].Hunks) | |||
| 613 | + | assert.False(t, d.Diff.Truncated) | |||
| 614 | + | limits.From, limits.To = 1, 2 | |||
| 615 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 616 | + | require.NoError(t, err) | |||
| 617 | + | assert.True(t, d.Diff.Files[1].Truncated, "in the window, over the per-file limit") | |||
| 618 | + | assert.True(t, d.Diff.Truncated) | |||
| 619 | + | }) | |||
| 620 | + | ||||
| 621 | + | t.Run("MaxFiles 0 lists every file", func(t *testing.T) { | |||
| 622 | + | f := readertest.NewFixture(t, data, "win-many") | |||
| 623 | + | const total = 350 | |||
| 624 | + | for i := range total { | |||
| 625 | + | f.Write(fmt.Sprintf("many/f%03d", i), fmt.Sprintf("x%d\n", i)) | |||
| 626 | + | } | |||
| 627 | + | f.Commit("many") | |||
| 628 | + | limits := domain.DefaultDiffLimits() | |||
| 629 | + | limits.MaxFiles = 0 | |||
| 630 | + | d, err := g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 631 | + | require.NoError(t, err) | |||
| 632 | + | require.Len(t, d.Diff.Files, total) | |||
| 633 | + | assert.False(t, d.Diff.Truncated) | |||
| 634 | + | assert.Equal(t, domain.DiffStats{FilesChanged: total, Additions: total}, d.Stats) | |||
| 635 | + | for i, file := range d.Diff.Files { | |||
| 636 | + | assert.Equal(t, fmt.Sprintf("many/f%03d", i), file.NewPath) | |||
| 637 | + | require.Len(t, file.Hunks, 1, file.NewPath) | |||
| 638 | + | } | |||
| 639 | + | ||||
| 640 | + | // A window past the 300th file reads its own hunks. | |||
| 641 | + | limits.From, limits.To = 320, 330 | |||
| 642 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, limits) | |||
| 643 | + | require.NoError(t, err) | |||
| 644 | + | require.Len(t, d.Diff.Files, total) | |||
| 645 | + | assert.False(t, d.Diff.Truncated) | |||
| 646 | + | for i, file := range d.Diff.Files { | |||
| 647 | + | if i >= 320 && i < 330 { | |||
| 648 | + | require.Len(t, file.Hunks, 1) | |||
| 649 | + | assert.Equal(t, fmt.Sprintf("x%d", i), file.Hunks[0].Lines[0].Text) | |||
| 650 | + | } else { | |||
| 651 | + | assert.Empty(t, file.Hunks) | |||
| 652 | + | } | |||
| 653 | + | assert.False(t, file.Truncated) | |||
| 654 | + | } | |||
| 655 | + | ||||
| 656 | + | // The default limits still cut at 300. | |||
| 657 | + | d, err = g.GetCommit(ctx, f.Name, domain.Revision{}, domain.DefaultDiffLimits()) | |||
| 658 | + | require.NoError(t, err) | |||
| 659 | + | assert.Len(t, d.Diff.Files, 300) | |||
| 660 | + | assert.True(t, d.Diff.Truncated) | |||
| 661 | + | }) | |||
| 662 | + | } | |||
M internal/adapters/rpc/mergerequest.go +1 -1
| @@ -166,7 +166,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect. | |||
| 166 | 166 | if err != nil { | |
| 167 | 167 | return nil, connectError(ctx, err) | |
| 168 | 168 | } | |
| 169 | - | d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetSinceLastRead()) | |
| 169 | + | d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.GetMergeRequestOptions{SinceLastRead: req.Msg.GetSinceLastRead()}) | |
| 170 | 170 | if err != nil { | |
| 171 | 171 | return nil, connectError(ctx, err) | |
| 172 | 172 | } | |
| @@ -166,7 +166,7 @@ func (s *mergeRequestService) GetMergeRequest(ctx context.Context, req *connect. | |||||
| 166 | if err != nil { | 166 | if err != nil { | ||
| 167 | return nil, connectError(ctx, err) | 167 | return nil, connectError(ctx, err) | ||
| 168 | } | 168 | } | ||
| 169 | - | d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), req.Msg.GetSinceLastRead()) | 169 | + | d, err := s.mrs.Get(ctx, caller(ctx), name, req.Msg.GetNumber(), ports.GetMergeRequestOptions{SinceLastRead: req.Msg.GetSinceLastRead()}) |
| 170 | if err != nil { | 170 | if err != nil { | ||
| 171 | return nil, connectError(ctx, err) | 171 | return nil, connectError(ctx, err) | ||
| 172 | } | 172 | } | ||
M internal/adapters/rpc/mergerequest_test.go +1 -1
| @@ -212,7 +212,7 @@ func TestMergeRequestServiceGet(t *testing.T) { | |||
| 212 | 212 | t.Run(tt.name, func(t *testing.T) { | |
| 213 | 213 | t.Parallel() | |
| 214 | 214 | mrs := mocks.NewMergeRequestService(t) | |
| 215 | - | mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), true). | |
| 215 | + | mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}). | |
| 216 | 216 | Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once() | |
| 217 | 217 | resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true})) | |
| 218 | 218 | if tt.wantCode != 0 { | |
| @@ -212,7 +212,7 @@ func TestMergeRequestServiceGet(t *testing.T) { | |||||
| 212 | t.Run(tt.name, func(t *testing.T) { | 212 | t.Run(tt.name, func(t *testing.T) { | ||
| 213 | t.Parallel() | 213 | t.Parallel() | ||
| 214 | mrs := mocks.NewMergeRequestService(t) | 214 | mrs := mocks.NewMergeRequestService(t) | ||
| 215 | - | mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), true). | 215 | + | mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}). |
| 216 | Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once() | 216 | Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once() | ||
| 217 | resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true})) | 217 | resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true})) | ||
| 218 | if tt.wantCode != 0 { | 218 | if tt.wantCode != 0 { | ||
M internal/adapters/web/actions.templ +14 -0
| @@ -423,6 +423,20 @@ templ CommitChecks(c ChecksSummary) { | |||
| 423 | 423 | } | |
| 424 | 424 | } | |
| 425 | 425 | ||
| 426 | + | // MRChecksSum is the state of the checks of a merge request in the bar above | |
| 427 | + | // the diff, a link to the checks of the rail. The event stream replaces it | |
| 428 | + | // with the checks section (same event, fragments.go). | |
| 429 | + | templ MRChecksSum(c ChecksSummary) { | |
| 430 | + | <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }> | |
| 431 | + | if c.Total > 0 { | |
| 432 | + | @CheckIcon(c.Kind, c.State) | |
| 433 | + | <span>{ c.Label }</span> | |
| 434 | + | } else { | |
| 435 | + | <span class="mute">no checks</span> | |
| 436 | + | } | |
| 437 | + | </a> | |
| 438 | + | } | |
| 439 | + | ||
| 426 | 440 | // MRChecks is the checks section of the rail of a merge request. | |
| 427 | 441 | templ MRChecks(c ChecksSection) { | |
| 428 | 442 | <section aria-labelledby="r-checks" id="mr-checks"> | |
| @@ -423,6 +423,20 @@ templ CommitChecks(c ChecksSummary) { | |||||
| 423 | } | 423 | } | ||
| 424 | } | 424 | } | ||
| 425 | 425 | ||||
| 426 | + | // MRChecksSum is the state of the checks of a merge request in the bar above | |||
| 427 | + | // the diff, a link to the checks of the rail. The event stream replaces it | |||
| 428 | + | // with the checks section (same event, fragments.go). | |||
| 429 | + | templ MRChecksSum(c ChecksSummary) { | |||
| 430 | + | <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }> | |||
| 431 | + | if c.Total > 0 { | |||
| 432 | + | @CheckIcon(c.Kind, c.State) | |||
| 433 | + | <span>{ c.Label }</span> | |||
| 434 | + | } else { | |||
| 435 | + | <span class="mute">no checks</span> | |||
| 436 | + | } | |||
| 437 | + | </a> | |||
| 438 | + | } | |||
| 439 | + | ||||
| 426 | // MRChecks is the checks section of the rail of a merge request. | 440 | // MRChecks is the checks section of the rail of a merge request. | ||
| 427 | templ MRChecks(c ChecksSection) { | 441 | templ MRChecks(c ChecksSection) { | ||
| 428 | <section aria-labelledby="r-checks" id="mr-checks"> | 442 | <section aria-labelledby="r-checks" id="mr-checks"> | ||
M internal/adapters/web/actions_templ.go +120 -43
| @@ -2967,8 +2967,10 @@ func CommitChecks(c ChecksSummary) templ.Component { | |||
| 2967 | 2967 | }) | |
| 2968 | 2968 | } | |
| 2969 | 2969 | ||
| 2970 | - | // MRChecks is the checks section of the rail of a merge request. | |
| 2971 | - | func MRChecks(c ChecksSection) templ.Component { | |
| 2970 | + | // MRChecksSum is the state of the checks of a merge request in the bar above | |
| 2971 | + | // the diff, a link to the checks of the rail. The event stream replaces it | |
| 2972 | + | // with the checks section (same event, fragments.go). | |
| 2973 | + | func MRChecksSum(c ChecksSummary) templ.Component { | |
| 2972 | 2974 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 2973 | 2975 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 2974 | 2976 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| @@ -2989,35 +2991,110 @@ func MRChecks(c ChecksSection) templ.Component { | |||
| 2989 | 2991 | templ_7745c5c3_Var158 = templ.NopComponent | |
| 2990 | 2992 | } | |
| 2991 | 2993 | ctx = templ.ClearChildren(ctx) | |
| 2992 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">") | |
| 2994 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "<a class=\"ck-sum mr-ck\" id=\"mr-ck\" href=\"#mr-checks\" title=\"") | |
| 2993 | 2995 | if templ_7745c5c3_Err != nil { | |
| 2994 | 2996 | return templ_7745c5c3_Err | |
| 2995 | 2997 | } | |
| 2996 | 2998 | var templ_7745c5c3_Var159 string | |
| 2997 | - | templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows))) | |
| 2999 | + | templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title) | |
| 3000 | + | if templ_7745c5c3_Err != nil { | |
| 3001 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 430, Col: 69} | |
| 3002 | + | } | |
| 3003 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var159) | |
| 3004 | + | if templ_7745c5c3_Err != nil { | |
| 3005 | + | return templ_7745c5c3_Err | |
| 3006 | + | } | |
| 3007 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "\">") | |
| 3008 | + | if templ_7745c5c3_Err != nil { | |
| 3009 | + | return templ_7745c5c3_Err | |
| 3010 | + | } | |
| 3011 | + | if c.Total > 0 { | |
| 3012 | + | templ_7745c5c3_Err = CheckIcon(c.Kind, c.State).Render(ctx, templ_7745c5c3_Buffer) | |
| 3013 | + | if templ_7745c5c3_Err != nil { | |
| 3014 | + | return templ_7745c5c3_Err | |
| 3015 | + | } | |
| 3016 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, " <span>") | |
| 3017 | + | if templ_7745c5c3_Err != nil { | |
| 3018 | + | return templ_7745c5c3_Err | |
| 3019 | + | } | |
| 3020 | + | var templ_7745c5c3_Var160 string | |
| 3021 | + | templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 3022 | + | if templ_7745c5c3_Err != nil { | |
| 3023 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 433, Col: 18} | |
| 3024 | + | } | |
| 3025 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160)) | |
| 3026 | + | if templ_7745c5c3_Err != nil { | |
| 3027 | + | return templ_7745c5c3_Err | |
| 3028 | + | } | |
| 3029 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</span>") | |
| 3030 | + | if templ_7745c5c3_Err != nil { | |
| 3031 | + | return templ_7745c5c3_Err | |
| 3032 | + | } | |
| 3033 | + | } else { | |
| 3034 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "<span class=\"mute\">no checks</span>") | |
| 3035 | + | if templ_7745c5c3_Err != nil { | |
| 3036 | + | return templ_7745c5c3_Err | |
| 3037 | + | } | |
| 3038 | + | } | |
| 3039 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "</a>") | |
| 3040 | + | if templ_7745c5c3_Err != nil { | |
| 3041 | + | return templ_7745c5c3_Err | |
| 3042 | + | } | |
| 3043 | + | return nil | |
| 3044 | + | }) | |
| 3045 | + | } | |
| 3046 | + | ||
| 3047 | + | // MRChecks is the checks section of the rail of a merge request. | |
| 3048 | + | func MRChecks(c ChecksSection) templ.Component { | |
| 3049 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 3050 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 3051 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 3052 | + | return templ_7745c5c3_CtxErr | |
| 3053 | + | } | |
| 3054 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 3055 | + | if !templ_7745c5c3_IsBuffer { | |
| 3056 | + | defer func() { | |
| 3057 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 3058 | + | if templ_7745c5c3_Err == nil { | |
| 3059 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 3060 | + | } | |
| 3061 | + | }() | |
| 3062 | + | } | |
| 3063 | + | ctx = templ.InitializeContext(ctx) | |
| 3064 | + | templ_7745c5c3_Var161 := templ.GetChildren(ctx) | |
| 3065 | + | if templ_7745c5c3_Var161 == nil { | |
| 3066 | + | templ_7745c5c3_Var161 = templ.NopComponent | |
| 3067 | + | } | |
| 3068 | + | ctx = templ.ClearChildren(ctx) | |
| 3069 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">") | |
| 3070 | + | if templ_7745c5c3_Err != nil { | |
| 3071 | + | return templ_7745c5c3_Err | |
| 3072 | + | } | |
| 3073 | + | var templ_7745c5c3_Var162 string | |
| 3074 | + | templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows))) | |
| 2998 | 3075 | if templ_7745c5c3_Err != nil { | |
| 2999 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 429, Col: 66} | |
| 3076 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 443, Col: 66} | |
| 3000 | 3077 | } | |
| 3001 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159)) | |
| 3078 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162)) | |
| 3002 | 3079 | if templ_7745c5c3_Err != nil { | |
| 3003 | 3080 | return templ_7745c5c3_Err | |
| 3004 | 3081 | } | |
| 3005 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "</span></h2>") | |
| 3082 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</span></h2>") | |
| 3006 | 3083 | if templ_7745c5c3_Err != nil { | |
| 3007 | 3084 | return templ_7745c5c3_Err | |
| 3008 | 3085 | } | |
| 3009 | 3086 | if len(c.Rows) == 0 { | |
| 3010 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "<p class=\"dim\">no checks</p>") | |
| 3087 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<p class=\"dim\">no checks</p>") | |
| 3011 | 3088 | if templ_7745c5c3_Err != nil { | |
| 3012 | 3089 | return templ_7745c5c3_Err | |
| 3013 | 3090 | } | |
| 3014 | 3091 | } else { | |
| 3015 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "<ul class=\"checks\">") | |
| 3092 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "<ul class=\"checks\">") | |
| 3016 | 3093 | if templ_7745c5c3_Err != nil { | |
| 3017 | 3094 | return templ_7745c5c3_Err | |
| 3018 | 3095 | } | |
| 3019 | 3096 | for _, r := range c.Rows { | |
| 3020 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "<li>") | |
| 3097 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "<li>") | |
| 3021 | 3098 | if templ_7745c5c3_Err != nil { | |
| 3022 | 3099 | return templ_7745c5c3_Err | |
| 3023 | 3100 | } | |
| @@ -3026,108 +3103,108 @@ func MRChecks(c ChecksSection) templ.Component { | |||
| 3026 | 3103 | return templ_7745c5c3_Err | |
| 3027 | 3104 | } | |
| 3028 | 3105 | if r.Href != "" { | |
| 3029 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<a href=\"") | |
| 3106 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "<a href=\"") | |
| 3030 | 3107 | if templ_7745c5c3_Err != nil { | |
| 3031 | 3108 | return templ_7745c5c3_Err | |
| 3032 | 3109 | } | |
| 3033 | - | var templ_7745c5c3_Var160 templ.SafeURL | |
| 3034 | - | templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 3110 | + | var templ_7745c5c3_Var163 templ.SafeURL | |
| 3111 | + | templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 3035 | 3112 | if templ_7745c5c3_Err != nil { | |
| 3036 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 438, Col: 38} | |
| 3113 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 452, Col: 38} | |
| 3037 | 3114 | } | |
| 3038 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160)) | |
| 3115 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163)) | |
| 3039 | 3116 | if templ_7745c5c3_Err != nil { | |
| 3040 | 3117 | return templ_7745c5c3_Err | |
| 3041 | 3118 | } | |
| 3042 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\">") | |
| 3119 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "\">") | |
| 3043 | 3120 | if templ_7745c5c3_Err != nil { | |
| 3044 | 3121 | return templ_7745c5c3_Err | |
| 3045 | 3122 | } | |
| 3046 | - | var templ_7745c5c3_Var161 string | |
| 3047 | - | templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 3123 | + | var templ_7745c5c3_Var164 string | |
| 3124 | + | templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 3048 | 3125 | if templ_7745c5c3_Err != nil { | |
| 3049 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 438, Col: 49} | |
| 3126 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 452, Col: 49} | |
| 3050 | 3127 | } | |
| 3051 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var161)) | |
| 3128 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164)) | |
| 3052 | 3129 | if templ_7745c5c3_Err != nil { | |
| 3053 | 3130 | return templ_7745c5c3_Err | |
| 3054 | 3131 | } | |
| 3055 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</a> ") | |
| 3132 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "</a> ") | |
| 3056 | 3133 | if templ_7745c5c3_Err != nil { | |
| 3057 | 3134 | return templ_7745c5c3_Err | |
| 3058 | 3135 | } | |
| 3059 | 3136 | } else { | |
| 3060 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<span>") | |
| 3137 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "<span>") | |
| 3061 | 3138 | if templ_7745c5c3_Err != nil { | |
| 3062 | 3139 | return templ_7745c5c3_Err | |
| 3063 | 3140 | } | |
| 3064 | - | var templ_7745c5c3_Var162 string | |
| 3065 | - | templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 3141 | + | var templ_7745c5c3_Var165 string | |
| 3142 | + | templ_7745c5c3_Var165, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 3066 | 3143 | if templ_7745c5c3_Err != nil { | |
| 3067 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 440, Col: 21} | |
| 3144 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 454, Col: 21} | |
| 3068 | 3145 | } | |
| 3069 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162)) | |
| 3146 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var165)) | |
| 3070 | 3147 | if templ_7745c5c3_Err != nil { | |
| 3071 | 3148 | return templ_7745c5c3_Err | |
| 3072 | 3149 | } | |
| 3073 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "</span> ") | |
| 3150 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "</span> ") | |
| 3074 | 3151 | if templ_7745c5c3_Err != nil { | |
| 3075 | 3152 | return templ_7745c5c3_Err | |
| 3076 | 3153 | } | |
| 3077 | 3154 | } | |
| 3078 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "<span class=\"dim\">") | |
| 3155 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<span class=\"dim\">") | |
| 3079 | 3156 | if templ_7745c5c3_Err != nil { | |
| 3080 | 3157 | return templ_7745c5c3_Err | |
| 3081 | 3158 | } | |
| 3082 | - | var templ_7745c5c3_Var163 string | |
| 3083 | - | templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |
| 3159 | + | var templ_7745c5c3_Var166 string | |
| 3160 | + | templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | |
| 3084 | 3161 | if templ_7745c5c3_Err != nil { | |
| 3085 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 442, Col: 33} | |
| 3162 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 456, Col: 33} | |
| 3086 | 3163 | } | |
| 3087 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163)) | |
| 3164 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166)) | |
| 3088 | 3165 | if templ_7745c5c3_Err != nil { | |
| 3089 | 3166 | return templ_7745c5c3_Err | |
| 3090 | 3167 | } | |
| 3091 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "</span> ") | |
| 3168 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "</span> ") | |
| 3092 | 3169 | if templ_7745c5c3_Err != nil { | |
| 3093 | 3170 | return templ_7745c5c3_Err | |
| 3094 | 3171 | } | |
| 3095 | 3172 | if r.Waiting { | |
| 3096 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<span class=\"mute\">waiting for a runner</span>") | |
| 3173 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "<span class=\"mute\">waiting for a runner</span>") | |
| 3097 | 3174 | if templ_7745c5c3_Err != nil { | |
| 3098 | 3175 | return templ_7745c5c3_Err | |
| 3099 | 3176 | } | |
| 3100 | 3177 | } | |
| 3101 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "</li>") | |
| 3178 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "</li>") | |
| 3102 | 3179 | if templ_7745c5c3_Err != nil { | |
| 3103 | 3180 | return templ_7745c5c3_Err | |
| 3104 | 3181 | } | |
| 3105 | 3182 | } | |
| 3106 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "</ul>") | |
| 3183 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "</ul>") | |
| 3107 | 3184 | if templ_7745c5c3_Err != nil { | |
| 3108 | 3185 | return templ_7745c5c3_Err | |
| 3109 | 3186 | } | |
| 3110 | 3187 | } | |
| 3111 | 3188 | if c.Note != "" { | |
| 3112 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "<p class=\"mute\">") | |
| 3189 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "<p class=\"mute\">") | |
| 3113 | 3190 | if templ_7745c5c3_Err != nil { | |
| 3114 | 3191 | return templ_7745c5c3_Err | |
| 3115 | 3192 | } | |
| 3116 | - | var templ_7745c5c3_Var164 string | |
| 3117 | - | templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note) | |
| 3193 | + | var templ_7745c5c3_Var167 string | |
| 3194 | + | templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note) | |
| 3118 | 3195 | if templ_7745c5c3_Err != nil { | |
| 3119 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 451, Col: 27} | |
| 3196 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 465, Col: 27} | |
| 3120 | 3197 | } | |
| 3121 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164)) | |
| 3198 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var167)) | |
| 3122 | 3199 | if templ_7745c5c3_Err != nil { | |
| 3123 | 3200 | return templ_7745c5c3_Err | |
| 3124 | 3201 | } | |
| 3125 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "</p>") | |
| 3202 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "</p>") | |
| 3126 | 3203 | if templ_7745c5c3_Err != nil { | |
| 3127 | 3204 | return templ_7745c5c3_Err | |
| 3128 | 3205 | } | |
| 3129 | 3206 | } | |
| 3130 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "</section>") | |
| 3207 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "</section>") | |
| 3131 | 3208 | if templ_7745c5c3_Err != nil { | |
| 3132 | 3209 | return templ_7745c5c3_Err | |
| 3133 | 3210 | } | |
| @@ -2967,8 +2967,10 @@ func CommitChecks(c ChecksSummary) templ.Component { | |||||
| 2967 | }) | 2967 | }) | ||
| 2968 | } | 2968 | } | ||
| 2969 | 2969 | ||||
| 2970 | - | // MRChecks is the checks section of the rail of a merge request. | 2970 | + | // MRChecksSum is the state of the checks of a merge request in the bar above |
| 2971 | - | func MRChecks(c ChecksSection) templ.Component { | 2971 | + | // the diff, a link to the checks of the rail. The event stream replaces it |
| 2972 | + | // with the checks section (same event, fragments.go). | |||
| 2973 | + | func MRChecksSum(c ChecksSummary) templ.Component { | |||
| 2972 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | 2974 | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | ||
| 2973 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | 2975 | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | ||
| 2974 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | 2976 | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | ||
| @@ -2989,35 +2991,110 @@ func MRChecks(c ChecksSection) templ.Component { | |||||
| 2989 | templ_7745c5c3_Var158 = templ.NopComponent | 2991 | templ_7745c5c3_Var158 = templ.NopComponent | ||
| 2990 | } | 2992 | } | ||
| 2991 | ctx = templ.ClearChildren(ctx) | 2993 | ctx = templ.ClearChildren(ctx) | ||
| 2992 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">") | 2994 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 252, "<a class=\"ck-sum mr-ck\" id=\"mr-ck\" href=\"#mr-checks\" title=\"") |
| 2993 | if templ_7745c5c3_Err != nil { | 2995 | if templ_7745c5c3_Err != nil { | ||
| 2994 | return templ_7745c5c3_Err | 2996 | return templ_7745c5c3_Err | ||
| 2995 | } | 2997 | } | ||
| 2996 | var templ_7745c5c3_Var159 string | 2998 | var templ_7745c5c3_Var159 string | ||
| 2997 | - | templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows))) | 2999 | + | templ_7745c5c3_Var159, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.Title) |
| 3000 | + | if templ_7745c5c3_Err != nil { | |||
| 3001 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 430, Col: 69} | |||
| 3002 | + | } | |||
| 3003 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var159) | |||
| 3004 | + | if templ_7745c5c3_Err != nil { | |||
| 3005 | + | return templ_7745c5c3_Err | |||
| 3006 | + | } | |||
| 3007 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "\">") | |||
| 3008 | + | if templ_7745c5c3_Err != nil { | |||
| 3009 | + | return templ_7745c5c3_Err | |||
| 3010 | + | } | |||
| 3011 | + | if c.Total > 0 { | |||
| 3012 | + | templ_7745c5c3_Err = CheckIcon(c.Kind, c.State).Render(ctx, templ_7745c5c3_Buffer) | |||
| 3013 | + | if templ_7745c5c3_Err != nil { | |||
| 3014 | + | return templ_7745c5c3_Err | |||
| 3015 | + | } | |||
| 3016 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, " <span>") | |||
| 3017 | + | if templ_7745c5c3_Err != nil { | |||
| 3018 | + | return templ_7745c5c3_Err | |||
| 3019 | + | } | |||
| 3020 | + | var templ_7745c5c3_Var160 string | |||
| 3021 | + | templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |||
| 3022 | + | if templ_7745c5c3_Err != nil { | |||
| 3023 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 433, Col: 18} | |||
| 3024 | + | } | |||
| 3025 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160)) | |||
| 3026 | + | if templ_7745c5c3_Err != nil { | |||
| 3027 | + | return templ_7745c5c3_Err | |||
| 3028 | + | } | |||
| 3029 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "</span>") | |||
| 3030 | + | if templ_7745c5c3_Err != nil { | |||
| 3031 | + | return templ_7745c5c3_Err | |||
| 3032 | + | } | |||
| 3033 | + | } else { | |||
| 3034 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "<span class=\"mute\">no checks</span>") | |||
| 3035 | + | if templ_7745c5c3_Err != nil { | |||
| 3036 | + | return templ_7745c5c3_Err | |||
| 3037 | + | } | |||
| 3038 | + | } | |||
| 3039 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "</a>") | |||
| 3040 | + | if templ_7745c5c3_Err != nil { | |||
| 3041 | + | return templ_7745c5c3_Err | |||
| 3042 | + | } | |||
| 3043 | + | return nil | |||
| 3044 | + | }) | |||
| 3045 | + | } | |||
| 3046 | + | ||||
| 3047 | + | // MRChecks is the checks section of the rail of a merge request. | |||
| 3048 | + | func MRChecks(c ChecksSection) templ.Component { | |||
| 3049 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 3050 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 3051 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 3052 | + | return templ_7745c5c3_CtxErr | |||
| 3053 | + | } | |||
| 3054 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 3055 | + | if !templ_7745c5c3_IsBuffer { | |||
| 3056 | + | defer func() { | |||
| 3057 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 3058 | + | if templ_7745c5c3_Err == nil { | |||
| 3059 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 3060 | + | } | |||
| 3061 | + | }() | |||
| 3062 | + | } | |||
| 3063 | + | ctx = templ.InitializeContext(ctx) | |||
| 3064 | + | templ_7745c5c3_Var161 := templ.GetChildren(ctx) | |||
| 3065 | + | if templ_7745c5c3_Var161 == nil { | |||
| 3066 | + | templ_7745c5c3_Var161 = templ.NopComponent | |||
| 3067 | + | } | |||
| 3068 | + | ctx = templ.ClearChildren(ctx) | |||
| 3069 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "<section aria-labelledby=\"r-checks\" id=\"mr-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">") | |||
| 3070 | + | if templ_7745c5c3_Err != nil { | |||
| 3071 | + | return templ_7745c5c3_Err | |||
| 3072 | + | } | |||
| 3073 | + | var templ_7745c5c3_Var162 string | |||
| 3074 | + | templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(c.Rows))) | |||
| 2998 | if templ_7745c5c3_Err != nil { | 3075 | if templ_7745c5c3_Err != nil { | ||
| 2999 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 429, Col: 66} | 3076 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 443, Col: 66} |
| 3000 | } | 3077 | } | ||
| 3001 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var159)) | 3078 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162)) |
| 3002 | if templ_7745c5c3_Err != nil { | 3079 | if templ_7745c5c3_Err != nil { | ||
| 3003 | return templ_7745c5c3_Err | 3080 | return templ_7745c5c3_Err | ||
| 3004 | } | 3081 | } | ||
| 3005 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 253, "</span></h2>") | 3082 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</span></h2>") |
| 3006 | if templ_7745c5c3_Err != nil { | 3083 | if templ_7745c5c3_Err != nil { | ||
| 3007 | return templ_7745c5c3_Err | 3084 | return templ_7745c5c3_Err | ||
| 3008 | } | 3085 | } | ||
| 3009 | if len(c.Rows) == 0 { | 3086 | if len(c.Rows) == 0 { | ||
| 3010 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 254, "<p class=\"dim\">no checks</p>") | 3087 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<p class=\"dim\">no checks</p>") |
| 3011 | if templ_7745c5c3_Err != nil { | 3088 | if templ_7745c5c3_Err != nil { | ||
| 3012 | return templ_7745c5c3_Err | 3089 | return templ_7745c5c3_Err | ||
| 3013 | } | 3090 | } | ||
| 3014 | } else { | 3091 | } else { | ||
| 3015 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 255, "<ul class=\"checks\">") | 3092 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "<ul class=\"checks\">") |
| 3016 | if templ_7745c5c3_Err != nil { | 3093 | if templ_7745c5c3_Err != nil { | ||
| 3017 | return templ_7745c5c3_Err | 3094 | return templ_7745c5c3_Err | ||
| 3018 | } | 3095 | } | ||
| 3019 | for _, r := range c.Rows { | 3096 | for _, r := range c.Rows { | ||
| 3020 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 256, "<li>") | 3097 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "<li>") |
| 3021 | if templ_7745c5c3_Err != nil { | 3098 | if templ_7745c5c3_Err != nil { | ||
| 3022 | return templ_7745c5c3_Err | 3099 | return templ_7745c5c3_Err | ||
| 3023 | } | 3100 | } | ||
| @@ -3026,108 +3103,108 @@ func MRChecks(c ChecksSection) templ.Component { | |||||
| 3026 | return templ_7745c5c3_Err | 3103 | return templ_7745c5c3_Err | ||
| 3027 | } | 3104 | } | ||
| 3028 | if r.Href != "" { | 3105 | if r.Href != "" { | ||
| 3029 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 257, "<a href=\"") | 3106 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "<a href=\"") |
| 3030 | if templ_7745c5c3_Err != nil { | 3107 | if templ_7745c5c3_Err != nil { | ||
| 3031 | return templ_7745c5c3_Err | 3108 | return templ_7745c5c3_Err | ||
| 3032 | } | 3109 | } | ||
| 3033 | - | var templ_7745c5c3_Var160 templ.SafeURL | 3110 | + | var templ_7745c5c3_Var163 templ.SafeURL |
| 3034 | - | templ_7745c5c3_Var160, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | 3111 | + | templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) |
| 3035 | if templ_7745c5c3_Err != nil { | 3112 | if templ_7745c5c3_Err != nil { | ||
| 3036 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 438, Col: 38} | 3113 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 452, Col: 38} |
| 3037 | } | 3114 | } | ||
| 3038 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var160)) | 3115 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163)) |
| 3039 | if templ_7745c5c3_Err != nil { | 3116 | if templ_7745c5c3_Err != nil { | ||
| 3040 | return templ_7745c5c3_Err | 3117 | return templ_7745c5c3_Err | ||
| 3041 | } | 3118 | } | ||
| 3042 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 258, "\">") | 3119 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "\">") |
| 3043 | if templ_7745c5c3_Err != nil { | 3120 | if templ_7745c5c3_Err != nil { | ||
| 3044 | return templ_7745c5c3_Err | 3121 | return templ_7745c5c3_Err | ||
| 3045 | } | 3122 | } | ||
| 3046 | - | var templ_7745c5c3_Var161 string | 3123 | + | var templ_7745c5c3_Var164 string |
| 3047 | - | templ_7745c5c3_Var161, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | 3124 | + | templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) |
| 3048 | if templ_7745c5c3_Err != nil { | 3125 | if templ_7745c5c3_Err != nil { | ||
| 3049 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 438, Col: 49} | 3126 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 452, Col: 49} |
| 3050 | } | 3127 | } | ||
| 3051 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var161)) | 3128 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164)) |
| 3052 | if templ_7745c5c3_Err != nil { | 3129 | if templ_7745c5c3_Err != nil { | ||
| 3053 | return templ_7745c5c3_Err | 3130 | return templ_7745c5c3_Err | ||
| 3054 | } | 3131 | } | ||
| 3055 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 259, "</a> ") | 3132 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "</a> ") |
| 3056 | if templ_7745c5c3_Err != nil { | 3133 | if templ_7745c5c3_Err != nil { | ||
| 3057 | return templ_7745c5c3_Err | 3134 | return templ_7745c5c3_Err | ||
| 3058 | } | 3135 | } | ||
| 3059 | } else { | 3136 | } else { | ||
| 3060 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 260, "<span>") | 3137 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "<span>") |
| 3061 | if templ_7745c5c3_Err != nil { | 3138 | if templ_7745c5c3_Err != nil { | ||
| 3062 | return templ_7745c5c3_Err | 3139 | return templ_7745c5c3_Err | ||
| 3063 | } | 3140 | } | ||
| 3064 | - | var templ_7745c5c3_Var162 string | 3141 | + | var templ_7745c5c3_Var165 string |
| 3065 | - | templ_7745c5c3_Var162, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | 3142 | + | templ_7745c5c3_Var165, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) |
| 3066 | if templ_7745c5c3_Err != nil { | 3143 | if templ_7745c5c3_Err != nil { | ||
| 3067 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 440, Col: 21} | 3144 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 454, Col: 21} |
| 3068 | } | 3145 | } | ||
| 3069 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var162)) | 3146 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var165)) |
| 3070 | if templ_7745c5c3_Err != nil { | 3147 | if templ_7745c5c3_Err != nil { | ||
| 3071 | return templ_7745c5c3_Err | 3148 | return templ_7745c5c3_Err | ||
| 3072 | } | 3149 | } | ||
| 3073 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 261, "</span> ") | 3150 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "</span> ") |
| 3074 | if templ_7745c5c3_Err != nil { | 3151 | if templ_7745c5c3_Err != nil { | ||
| 3075 | return templ_7745c5c3_Err | 3152 | return templ_7745c5c3_Err | ||
| 3076 | } | 3153 | } | ||
| 3077 | } | 3154 | } | ||
| 3078 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 262, "<span class=\"dim\">") | 3155 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "<span class=\"dim\">") |
| 3079 | if templ_7745c5c3_Err != nil { | 3156 | if templ_7745c5c3_Err != nil { | ||
| 3080 | return templ_7745c5c3_Err | 3157 | return templ_7745c5c3_Err | ||
| 3081 | } | 3158 | } | ||
| 3082 | - | var templ_7745c5c3_Var163 string | 3159 | + | var templ_7745c5c3_Var166 string |
| 3083 | - | templ_7745c5c3_Var163, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) | 3160 | + | templ_7745c5c3_Var166, templ_7745c5c3_Err = templ.JoinStringErrs(r.State) |
| 3084 | if templ_7745c5c3_Err != nil { | 3161 | if templ_7745c5c3_Err != nil { | ||
| 3085 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 442, Col: 33} | 3162 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 456, Col: 33} |
| 3086 | } | 3163 | } | ||
| 3087 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var163)) | 3164 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var166)) |
| 3088 | if templ_7745c5c3_Err != nil { | 3165 | if templ_7745c5c3_Err != nil { | ||
| 3089 | return templ_7745c5c3_Err | 3166 | return templ_7745c5c3_Err | ||
| 3090 | } | 3167 | } | ||
| 3091 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 263, "</span> ") | 3168 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "</span> ") |
| 3092 | if templ_7745c5c3_Err != nil { | 3169 | if templ_7745c5c3_Err != nil { | ||
| 3093 | return templ_7745c5c3_Err | 3170 | return templ_7745c5c3_Err | ||
| 3094 | } | 3171 | } | ||
| 3095 | if r.Waiting { | 3172 | if r.Waiting { | ||
| 3096 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 264, "<span class=\"mute\">waiting for a runner</span>") | 3173 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 270, "<span class=\"mute\">waiting for a runner</span>") |
| 3097 | if templ_7745c5c3_Err != nil { | 3174 | if templ_7745c5c3_Err != nil { | ||
| 3098 | return templ_7745c5c3_Err | 3175 | return templ_7745c5c3_Err | ||
| 3099 | } | 3176 | } | ||
| 3100 | } | 3177 | } | ||
| 3101 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 265, "</li>") | 3178 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 271, "</li>") |
| 3102 | if templ_7745c5c3_Err != nil { | 3179 | if templ_7745c5c3_Err != nil { | ||
| 3103 | return templ_7745c5c3_Err | 3180 | return templ_7745c5c3_Err | ||
| 3104 | } | 3181 | } | ||
| 3105 | } | 3182 | } | ||
| 3106 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 266, "</ul>") | 3183 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 272, "</ul>") |
| 3107 | if templ_7745c5c3_Err != nil { | 3184 | if templ_7745c5c3_Err != nil { | ||
| 3108 | return templ_7745c5c3_Err | 3185 | return templ_7745c5c3_Err | ||
| 3109 | } | 3186 | } | ||
| 3110 | } | 3187 | } | ||
| 3111 | if c.Note != "" { | 3188 | if c.Note != "" { | ||
| 3112 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 267, "<p class=\"mute\">") | 3189 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 273, "<p class=\"mute\">") |
| 3113 | if templ_7745c5c3_Err != nil { | 3190 | if templ_7745c5c3_Err != nil { | ||
| 3114 | return templ_7745c5c3_Err | 3191 | return templ_7745c5c3_Err | ||
| 3115 | } | 3192 | } | ||
| 3116 | - | var templ_7745c5c3_Var164 string | 3193 | + | var templ_7745c5c3_Var167 string |
| 3117 | - | templ_7745c5c3_Var164, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note) | 3194 | + | templ_7745c5c3_Var167, templ_7745c5c3_Err = templ.JoinStringErrs(c.Note) |
| 3118 | if templ_7745c5c3_Err != nil { | 3195 | if templ_7745c5c3_Err != nil { | ||
| 3119 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 451, Col: 27} | 3196 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/actions.templ`, Line: 465, Col: 27} |
| 3120 | } | 3197 | } | ||
| 3121 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var164)) | 3198 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var167)) |
| 3122 | if templ_7745c5c3_Err != nil { | 3199 | if templ_7745c5c3_Err != nil { | ||
| 3123 | return templ_7745c5c3_Err | 3200 | return templ_7745c5c3_Err | ||
| 3124 | } | 3201 | } | ||
| 3125 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 268, "</p>") | 3202 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 274, "</p>") |
| 3126 | if templ_7745c5c3_Err != nil { | 3203 | if templ_7745c5c3_Err != nil { | ||
| 3127 | return templ_7745c5c3_Err | 3204 | return templ_7745c5c3_Err | ||
| 3128 | } | 3205 | } | ||
| 3129 | } | 3206 | } | ||
| 3130 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 269, "</section>") | 3207 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 275, "</section>") |
| 3131 | if templ_7745c5c3_Err != nil { | 3208 | if templ_7745c5c3_Err != nil { | ||
| 3132 | return templ_7745c5c3_Err | 3209 | return templ_7745c5c3_Err | ||
| 3133 | } | 3210 | } | ||
M internal/adapters/web/assets.go +2 -1
| @@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/" | |||
| 24 | 24 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | |
| 25 | 25 | // the content, so they can be cached for ever. | |
| 26 | 26 | type Assets struct { | |
| 27 | - | CSS, HTMX, Live, SSE, App, Files, Passkey, Log, Icon string | |
| 27 | + | CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string | |
| 28 | 28 | // Manifest is the web app manifest (install on a phone's home screen), | |
| 29 | 29 | // AppleIcon the home screen icon iOS reads from a link instead. | |
| 30 | 30 | Manifest, AppleIcon string | |
| @@ -62,6 +62,7 @@ var scripts = []struct { | |||
| 62 | 62 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | |
| 63 | 63 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | |
| 64 | 64 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | |
| 65 | + | {"mr.js", func(a *Assets, u string) { a.MR = u }}, | |
| 65 | 66 | } | |
| 66 | 67 | ||
| 67 | 68 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | |
| @@ -24,7 +24,7 @@ const StaticPrefix = "/-/static/" | |||||
| 24 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | 24 | // Assets are the URLs of the assets a page loads. Their names carry a hash of | ||
| 25 | // the content, so they can be cached for ever. | 25 | // the content, so they can be cached for ever. | ||
| 26 | type Assets struct { | 26 | type Assets struct { | ||
| 27 | - | CSS, HTMX, Live, SSE, App, Files, Passkey, Log, Icon string | 27 | + | CSS, HTMX, Live, SSE, App, Files, Passkey, Log, MR, Icon string |
| 28 | // Manifest is the web app manifest (install on a phone's home screen), | 28 | // Manifest is the web app manifest (install on a phone's home screen), | ||
| 29 | // AppleIcon the home screen icon iOS reads from a link instead. | 29 | // AppleIcon the home screen icon iOS reads from a link instead. | ||
| 30 | Manifest, AppleIcon string | 30 | Manifest, AppleIcon string | ||
| @@ -62,6 +62,7 @@ var scripts = []struct { | |||||
| 62 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | 62 | {"files.js", func(a *Assets, u string) { a.Files = u }}, | ||
| 63 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | 63 | {"passkey.js", func(a *Assets, u string) { a.Passkey = u }}, | ||
| 64 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | 64 | {"log.js", func(a *Assets, u string) { a.Log = u }}, | ||
| 65 | + | {"mr.js", func(a *Assets, u string) { a.MR = u }}, | |||
| 65 | } | 66 | } | ||
| 66 | 67 | ||||
| 67 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | 68 | // sheetAssets are the files the stylesheet references as url(<dir>/<file>): | ||
M internal/adapters/web/diff.go +61 -34
| @@ -80,10 +80,6 @@ type DiffHunk struct { | |||
| 80 | 80 | Split []SplitRow | |
| 81 | 81 | } | |
| 82 | 82 | ||
| 83 | - | // Bar is the change bar of the file list, "+++--·····", in three runs so each | |
| 84 | - | // can carry its colour. | |
| 85 | - | type Bar struct{ Add, Del, Rest string } | |
| 86 | - | ||
| 87 | 83 | // DiffFile is one changed file. | |
| 88 | 84 | type DiffFile struct { | |
| 89 | 85 | ID string | |
| @@ -93,12 +89,48 @@ type DiffFile struct { | |||
| 93 | 89 | Key string | |
| 94 | 90 | Path string // display path, "old → new" for a rename | |
| 95 | 91 | Add, Del int | |
| 96 | - | Bar Bar | |
| 92 | + | // Bin is true for a binary file: no lines to count. | |
| 93 | + | Bin bool | |
| 97 | 94 | // Note is why there is no text to show, or what else changed: a binary | |
| 98 | 95 | // file, a mode change, a truncation, a similarity. | |
| 99 | 96 | Note string | |
| 100 | 97 | Open bool | |
| 101 | 98 | Hunks []DiffHunk | |
| 99 | + | // Digits is the width of the largest line number of the file, at least 2 | |
| 100 | + | // and at most 7: the gutter is as wide as the file needs (class lnw-N). | |
| 101 | + | Digits int | |
| 102 | + | // Name and Dir are the last element of the path and the directory before | |
| 103 | + | // it ("" at the root), for the compact file list. | |
| 104 | + | Name, Dir string | |
| 105 | + | // Notes is the number of comments on lines of the file, Open those not | |
| 106 | + | // resolved; the file list shows them. | |
| 107 | + | Notes, OpenNotes int | |
| 108 | + | // Lazy is true when the hunks of the file were not read: the page shows a | |
| 109 | + | // placeholder that loads them (Src, the window of files it belongs to). | |
| 110 | + | Lazy bool | |
| 111 | + | Src string | |
| 112 | + | // From is the index of the first file of the window of a lazy file: the | |
| 113 | + | // page without scripts links to ?from=From. | |
| 114 | + | From int | |
| 115 | + | // Height is the size class of a lazy file (1 to 4, by its changed lines), | |
| 116 | + | // so that the placeholder takes about the room of the diff. | |
| 117 | + | Height int | |
| 118 | + | // Comments are the ids of the comments on the file ("c12"): a link to a | |
| 119 | + | // comment of a lazy file loads that file first. | |
| 120 | + | Comments string | |
| 121 | + | // Loose are the comments of a file loaded with its window that are on a | |
| 122 | + | // line its diff does not show: listed at the end of the file. | |
| 123 | + | Loose []CommentView | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // gutterDigits is the number of digits of the largest line number of the | |
| 127 | + | // hunks, clamped to the widths the style sheet has a class for. | |
| 128 | + | func gutterDigits(hunks []domain.Hunk) int { | |
| 129 | + | most := 0 | |
| 130 | + | for _, h := range hunks { | |
| 131 | + | most = max(most, h.OldStart+h.OldLines, h.NewStart+h.NewLines) | |
| 132 | + | } | |
| 133 | + | return min(max(len(itoa(most)), 2), 7) | |
| 102 | 134 | } | |
| 103 | 135 | ||
| 104 | 136 | func lineNumber(n int) string { | |
| @@ -176,26 +208,6 @@ func pairLines(ls []DiffLine) []SplitRow { | |||
| 176 | 208 | return out | |
| 177 | 209 | } | |
| 178 | 210 | ||
| 179 | - | // changeBar draws the change bar of a file, scaled to the largest file. | |
| 180 | - | func changeBar(add, del, most int) Bar { | |
| 181 | - | const cells = 10 | |
| 182 | - | if most == 0 { | |
| 183 | - | return Bar{Rest: strings.Repeat("·", cells)} | |
| 184 | - | } | |
| 185 | - | scale := func(n int) int { | |
| 186 | - | c := (n*cells + most - 1) / most | |
| 187 | - | if n > 0 && c == 0 { | |
| 188 | - | c = 1 | |
| 189 | - | } | |
| 190 | - | return c | |
| 191 | - | } | |
| 192 | - | a, d := scale(add), scale(del) | |
| 193 | - | if a+d > cells { | |
| 194 | - | d = cells - a | |
| 195 | - | } | |
| 196 | - | return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)} | |
| 197 | - | } | |
| 198 | - | ||
| 199 | 211 | // statusLetter is the one-letter status of a file. | |
| 200 | 212 | func statusLetter(s domain.FileStatus) string { | |
| 201 | 213 | switch s { | |
| @@ -213,6 +225,27 @@ func statusLetter(s domain.FileStatus) string { | |||
| 213 | 225 | return "M" | |
| 214 | 226 | } | |
| 215 | 227 | ||
| 228 | + | // fileFilterMin is the number of files above which the file list has a | |
| 229 | + | // filter. | |
| 230 | + | const fileFilterMin = 10 | |
| 231 | + | ||
| 232 | + | // statusName says what a status letter means, for its tooltip. | |
| 233 | + | func statusName(letter string) string { | |
| 234 | + | switch letter { | |
| 235 | + | case "A": | |
| 236 | + | return "added" | |
| 237 | + | case "D": | |
| 238 | + | return "deleted" | |
| 239 | + | case "R": | |
| 240 | + | return "renamed" | |
| 241 | + | case "C": | |
| 242 | + | return "copied" | |
| 243 | + | case "T": | |
| 244 | + | return "type changed" | |
| 245 | + | } | |
| 246 | + | return "modified" | |
| 247 | + | } | |
| 248 | + | ||
| 216 | 249 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | |
| 217 | 250 | func hunkHeader(h domain.Hunk) string { | |
| 218 | 251 | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines) | |
| @@ -239,16 +272,11 @@ func diffLine(l domain.Line) DiffLine { | |||
| 239 | 272 | ||
| 240 | 273 | // diffFiles converts the files of a diff for the page. | |
| 241 | 274 | func diffFiles(files []domain.FileDiff) []DiffFile { | |
| 242 | - | most := 0 | |
| 243 | - | for _, f := range files { | |
| 244 | - | most = max(most, f.Additions+f.Deletions) | |
| 245 | - | } | |
| 246 | 275 | out := make([]DiffFile, 0, len(files)) | |
| 247 | 276 | for i, f := range files { | |
| 248 | 277 | df := DiffFile{ | |
| 249 | 278 | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), | |
| 250 | - | Add: f.Additions, Del: f.Deletions, | |
| 251 | - | Bar: changeBar(f.Additions, f.Deletions, most), | |
| 279 | + | Add: f.Additions, Del: f.Deletions, Bin: f.Binary, | |
| 252 | 280 | // A file that is shown in full opens; a file with nothing to read | |
| 253 | 281 | // (binary, truncated) stays closed under its note. | |
| 254 | 282 | Open: len(f.Hunks) > 0 && !f.Truncated, | |
| @@ -264,9 +292,8 @@ func diffFiles(files []domain.FileDiff) []DiffFile { | |||
| 264 | 292 | df.Path = visibleText(f.NewPath) | |
| 265 | 293 | } | |
| 266 | 294 | df.Note = fileNote(f) | |
| 267 | - | if f.Binary { | |
| 268 | - | df.Bar = Bar{Rest: "bin"} | |
| 269 | - | } | |
| 295 | + | df.Digits = gutterDigits(f.Hunks) | |
| 296 | + | df.Name, df.Dir = splitPath(df.Key) | |
| 270 | 297 | for _, h := range f.Hunks { | |
| 271 | 298 | dh := DiffHunk{Header: hunkHeader(h)} | |
| 272 | 299 | for _, l := range h.Lines { | |
| @@ -80,10 +80,6 @@ type DiffHunk struct { | |||||
| 80 | Split []SplitRow | 80 | Split []SplitRow | ||
| 81 | } | 81 | } | ||
| 82 | 82 | ||||
| 83 | - | // Bar is the change bar of the file list, "+++--·····", in three runs so each | |||
| 84 | - | // can carry its colour. | |||
| 85 | - | type Bar struct{ Add, Del, Rest string } | |||
| 86 | - | ||||
| 87 | // DiffFile is one changed file. | 83 | // DiffFile is one changed file. | ||
| 88 | type DiffFile struct { | 84 | type DiffFile struct { | ||
| 89 | ID string | 85 | ID string | ||
| @@ -93,12 +89,48 @@ type DiffFile struct { | |||||
| 93 | Key string | 89 | Key string | ||
| 94 | Path string // display path, "old → new" for a rename | 90 | Path string // display path, "old → new" for a rename | ||
| 95 | Add, Del int | 91 | Add, Del int | ||
| 96 | - | Bar Bar | 92 | + | // Bin is true for a binary file: no lines to count. |
| 93 | + | Bin bool | |||
| 97 | // Note is why there is no text to show, or what else changed: a binary | 94 | // Note is why there is no text to show, or what else changed: a binary | ||
| 98 | // file, a mode change, a truncation, a similarity. | 95 | // file, a mode change, a truncation, a similarity. | ||
| 99 | Note string | 96 | Note string | ||
| 100 | Open bool | 97 | Open bool | ||
| 101 | Hunks []DiffHunk | 98 | Hunks []DiffHunk | ||
| 99 | + | // Digits is the width of the largest line number of the file, at least 2 | |||
| 100 | + | // and at most 7: the gutter is as wide as the file needs (class lnw-N). | |||
| 101 | + | Digits int | |||
| 102 | + | // Name and Dir are the last element of the path and the directory before | |||
| 103 | + | // it ("" at the root), for the compact file list. | |||
| 104 | + | Name, Dir string | |||
| 105 | + | // Notes is the number of comments on lines of the file, Open those not | |||
| 106 | + | // resolved; the file list shows them. | |||
| 107 | + | Notes, OpenNotes int | |||
| 108 | + | // Lazy is true when the hunks of the file were not read: the page shows a | |||
| 109 | + | // placeholder that loads them (Src, the window of files it belongs to). | |||
| 110 | + | Lazy bool | |||
| 111 | + | Src string | |||
| 112 | + | // From is the index of the first file of the window of a lazy file: the | |||
| 113 | + | // page without scripts links to ?from=From. | |||
| 114 | + | From int | |||
| 115 | + | // Height is the size class of a lazy file (1 to 4, by its changed lines), | |||
| 116 | + | // so that the placeholder takes about the room of the diff. | |||
| 117 | + | Height int | |||
| 118 | + | // Comments are the ids of the comments on the file ("c12"): a link to a | |||
| 119 | + | // comment of a lazy file loads that file first. | |||
| 120 | + | Comments string | |||
| 121 | + | // Loose are the comments of a file loaded with its window that are on a | |||
| 122 | + | // line its diff does not show: listed at the end of the file. | |||
| 123 | + | Loose []CommentView | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | // gutterDigits is the number of digits of the largest line number of the | |||
| 127 | + | // hunks, clamped to the widths the style sheet has a class for. | |||
| 128 | + | func gutterDigits(hunks []domain.Hunk) int { | |||
| 129 | + | most := 0 | |||
| 130 | + | for _, h := range hunks { | |||
| 131 | + | most = max(most, h.OldStart+h.OldLines, h.NewStart+h.NewLines) | |||
| 132 | + | } | |||
| 133 | + | return min(max(len(itoa(most)), 2), 7) | |||
| 102 | } | 134 | } | ||
| 103 | 135 | ||||
| 104 | func lineNumber(n int) string { | 136 | func lineNumber(n int) string { | ||
| @@ -176,26 +208,6 @@ func pairLines(ls []DiffLine) []SplitRow { | |||||
| 176 | return out | 208 | return out | ||
| 177 | } | 209 | } | ||
| 178 | 210 | ||||
| 179 | - | // changeBar draws the change bar of a file, scaled to the largest file. | |||
| 180 | - | func changeBar(add, del, most int) Bar { | |||
| 181 | - | const cells = 10 | |||
| 182 | - | if most == 0 { | |||
| 183 | - | return Bar{Rest: strings.Repeat("·", cells)} | |||
| 184 | - | } | |||
| 185 | - | scale := func(n int) int { | |||
| 186 | - | c := (n*cells + most - 1) / most | |||
| 187 | - | if n > 0 && c == 0 { | |||
| 188 | - | c = 1 | |||
| 189 | - | } | |||
| 190 | - | return c | |||
| 191 | - | } | |||
| 192 | - | a, d := scale(add), scale(del) | |||
| 193 | - | if a+d > cells { | |||
| 194 | - | d = cells - a | |||
| 195 | - | } | |||
| 196 | - | return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)} | |||
| 197 | - | } | |||
| 198 | - | ||||
| 199 | // statusLetter is the one-letter status of a file. | 211 | // statusLetter is the one-letter status of a file. | ||
| 200 | func statusLetter(s domain.FileStatus) string { | 212 | func statusLetter(s domain.FileStatus) string { | ||
| 201 | switch s { | 213 | switch s { | ||
| @@ -213,6 +225,27 @@ func statusLetter(s domain.FileStatus) string { | |||||
| 213 | return "M" | 225 | return "M" | ||
| 214 | } | 226 | } | ||
| 215 | 227 | ||||
| 228 | + | // fileFilterMin is the number of files above which the file list has a | |||
| 229 | + | // filter. | |||
| 230 | + | const fileFilterMin = 10 | |||
| 231 | + | ||||
| 232 | + | // statusName says what a status letter means, for its tooltip. | |||
| 233 | + | func statusName(letter string) string { | |||
| 234 | + | switch letter { | |||
| 235 | + | case "A": | |||
| 236 | + | return "added" | |||
| 237 | + | case "D": | |||
| 238 | + | return "deleted" | |||
| 239 | + | case "R": | |||
| 240 | + | return "renamed" | |||
| 241 | + | case "C": | |||
| 242 | + | return "copied" | |||
| 243 | + | case "T": | |||
| 244 | + | return "type changed" | |||
| 245 | + | } | |||
| 246 | + | return "modified" | |||
| 247 | + | } | |||
| 248 | + | ||||
| 216 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | 249 | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | ||
| 217 | func hunkHeader(h domain.Hunk) string { | 250 | func hunkHeader(h domain.Hunk) string { | ||
| 218 | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines) | 251 | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.OldStart, h.OldLines, h.NewStart, h.NewLines) | ||
| @@ -239,16 +272,11 @@ func diffLine(l domain.Line) DiffLine { | |||||
| 239 | 272 | ||||
| 240 | // diffFiles converts the files of a diff for the page. | 273 | // diffFiles converts the files of a diff for the page. | ||
| 241 | func diffFiles(files []domain.FileDiff) []DiffFile { | 274 | func diffFiles(files []domain.FileDiff) []DiffFile { | ||
| 242 | - | most := 0 | |||
| 243 | - | for _, f := range files { | |||
| 244 | - | most = max(most, f.Additions+f.Deletions) | |||
| 245 | - | } | |||
| 246 | out := make([]DiffFile, 0, len(files)) | 275 | out := make([]DiffFile, 0, len(files)) | ||
| 247 | for i, f := range files { | 276 | for i, f := range files { | ||
| 248 | df := DiffFile{ | 277 | df := DiffFile{ | ||
| 249 | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), | 278 | ID: "f" + itoa(i+1), Status: statusLetter(f.Status), | ||
| 250 | - | Add: f.Additions, Del: f.Deletions, | 279 | + | Add: f.Additions, Del: f.Deletions, Bin: f.Binary, |
| 251 | - | Bar: changeBar(f.Additions, f.Deletions, most), | |||
| 252 | // A file that is shown in full opens; a file with nothing to read | 280 | // A file that is shown in full opens; a file with nothing to read | ||
| 253 | // (binary, truncated) stays closed under its note. | 281 | // (binary, truncated) stays closed under its note. | ||
| 254 | Open: len(f.Hunks) > 0 && !f.Truncated, | 282 | Open: len(f.Hunks) > 0 && !f.Truncated, | ||
| @@ -264,9 +292,8 @@ func diffFiles(files []domain.FileDiff) []DiffFile { | |||||
| 264 | df.Path = visibleText(f.NewPath) | 292 | df.Path = visibleText(f.NewPath) | ||
| 265 | } | 293 | } | ||
| 266 | df.Note = fileNote(f) | 294 | df.Note = fileNote(f) | ||
| 267 | - | if f.Binary { | 295 | + | df.Digits = gutterDigits(f.Hunks) |
| 268 | - | df.Bar = Bar{Rest: "bin"} | 296 | + | df.Name, df.Dir = splitPath(df.Key) |
| 269 | - | } | |||
| 270 | for _, h := range f.Hunks { | 297 | for _, h := range f.Hunks { | ||
| 271 | dh := DiffHunk{Header: hunkHeader(h)} | 298 | dh := DiffHunk{Header: hunkHeader(h)} | ||
| 272 | for _, l := range h.Lines { | 299 | for _, l := range h.Lines { | ||
M internal/adapters/web/diff.templ +57 -7
| @@ -141,17 +141,39 @@ templ commitRail(p CommitPage) { | |||
| 141 | 141 | // FileList is the table of contents of a diff: status letter, path, +n -m | |
| 142 | 142 | // and an ASCII bar. The row of the file in view is marked. | |
| 143 | 143 | templ FileList(files []DiffFile) { | |
| 144 | + | if len(files) > fileFilterMin { | |
| 145 | + | <div class="ffilter"> | |
| 146 | + | <label for="files-filter" class="sr">filter the changed files</label> | |
| 147 | + | <input | |
| 148 | + | id="files-filter" | |
| 149 | + | type="search" | |
| 150 | + | placeholder="Filter files…" | |
| 151 | + | autocomplete="off" | |
| 152 | + | data-pick=".files > li" | |
| 153 | + | hx-on:input="ermite.filter(this)" | |
| 154 | + | /> | |
| 155 | + | </div> | |
| 156 | + | } | |
| 144 | 157 | <ol class="files" aria-label="Changed files"> | |
| 145 | 158 | for _, f := range files { | |
| 146 | 159 | <li> | |
| 147 | 160 | <a | |
| 148 | 161 | href={ templ.SafeURL("#" + f.ID) } | |
| 149 | - | hx-on:click="document.getElementById(this.hash.slice(1)).open = true" | |
| 162 | + | title={ f.Path } | |
| 163 | + | hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))" | |
| 150 | 164 | > | |
| 151 | - | <span class="st" title={ f.Status }>{ f.Status }</span> | |
| 152 | - | <span class="p" title={ f.Path }>{ f.Path }</span> | |
| 153 | - | <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span> | |
| 154 | - | <span class="b" aria-hidden="true"><span class="plus">{ f.Bar.Add }</span><span class="minus">{ f.Bar.Del }</span>{ f.Bar.Rest }</span> | |
| 165 | + | <span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span> | |
| 166 | + | <span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span> | |
| 167 | + | if f.OpenNotes > 0 { | |
| 168 | + | <span class="cn" title={ plural(f.OpenNotes, "open comment", "open comments") }>{ itoa(f.OpenNotes) }</span> | |
| 169 | + | } else if f.Notes > 0 { | |
| 170 | + | <span class="cn done" title={ plural(f.Notes, "resolved comment", "resolved comments") }>{ itoa(f.Notes) }</span> | |
| 171 | + | } | |
| 172 | + | if f.Bin { | |
| 173 | + | <span class="n mute">bin</span> | |
| 174 | + | } else { | |
| 175 | + | <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span> | |
| 176 | + | } | |
| 155 | 177 | </a> | |
| 156 | 178 | </li> | |
| 157 | 179 | } | |
| @@ -164,7 +186,7 @@ templ FileList(files []DiffFile) { | |||
| 164 | 186 | // colour alone. | |
| 165 | 187 | templ DiffFileView(f DiffFile, p DiffView) { | |
| 166 | 188 | <details | |
| 167 | - | class="dfile" | |
| 189 | + | class={ "dfile", "lnw-" + itoa(f.Digits) } | |
| 168 | 190 | id={ f.ID } | |
| 169 | 191 | data-file | |
| 170 | 192 | if f.Open { | |
| @@ -177,7 +199,14 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||
| 177 | 199 | { " " }<span class="note">{ f.Note }</span> | |
| 178 | 200 | } | |
| 179 | 201 | </summary> | |
| 180 | - | if len(f.Hunks) > 0 { | |
| 202 | + | if f.Lazy { | |
| 203 | + | // A placeholder: mr.js fetches the window of the file when it comes | |
| 204 | + | // near the screen and swaps the files of the window in. Without | |
| 205 | + | // scripts the link opens the page of that window. | |
| 206 | + | <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }> | |
| 207 | + | <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a> | |
| 208 | + | </div> | |
| 209 | + | } else if len(f.Hunks) > 0 { | |
| 181 | 210 | <div class="dscroll"> | |
| 182 | 211 | if p.ShowUnified { | |
| 183 | 212 | <table class="dt uni" aria-label={ f.Path + ", unified" }> | |
| @@ -233,9 +262,30 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||
| 233 | 262 | } | |
| 234 | 263 | </p> | |
| 235 | 264 | } | |
| 265 | + | if len(f.Loose) > 0 { | |
| 266 | + | <div class="dloose"> | |
| 267 | + | for _, c := range f.Loose { | |
| 268 | + | @commentView(c, "") | |
| 269 | + | } | |
| 270 | + | </div> | |
| 271 | + | } | |
| 236 | 272 | </details> | |
| 237 | 273 | } | |
| 238 | 274 | ||
| 275 | + | // MRFiles is a window of files of a merge request, for the placeholders of | |
| 276 | + | // the page: the files only, which replace the placeholders of the same ids. | |
| 277 | + | templ MRFiles(p MRFilesPage) { | |
| 278 | + | for _, f := range p.Files { | |
| 279 | + | @DiffFileView(f, p.DiffView) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // MRFilesMoved answers a window of a diff that is not the one of the page any | |
| 284 | + | // more: the placeholder shows it instead of a file of another diff. | |
| 285 | + | templ MRFilesMoved() { | |
| 286 | + | <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p> | |
| 287 | + | } | |
| 288 | + | ||
| 239 | 289 | // splitSide is one half of a row of the split view; a side without a line is | |
| 240 | 290 | // an empty cell. | |
| 241 | 291 | templ splitSide(ln *DiffLine, old bool) { | |
| @@ -141,17 +141,39 @@ templ commitRail(p CommitPage) { | |||||
| 141 | // FileList is the table of contents of a diff: status letter, path, +n -m | 141 | // FileList is the table of contents of a diff: status letter, path, +n -m | ||
| 142 | // and an ASCII bar. The row of the file in view is marked. | 142 | // and an ASCII bar. The row of the file in view is marked. | ||
| 143 | templ FileList(files []DiffFile) { | 143 | templ FileList(files []DiffFile) { | ||
| 144 | + | if len(files) > fileFilterMin { | |||
| 145 | + | <div class="ffilter"> | |||
| 146 | + | <label for="files-filter" class="sr">filter the changed files</label> | |||
| 147 | + | <input | |||
| 148 | + | id="files-filter" | |||
| 149 | + | type="search" | |||
| 150 | + | placeholder="Filter files…" | |||
| 151 | + | autocomplete="off" | |||
| 152 | + | data-pick=".files > li" | |||
| 153 | + | hx-on:input="ermite.filter(this)" | |||
| 154 | + | /> | |||
| 155 | + | </div> | |||
| 156 | + | } | |||
| 144 | <ol class="files" aria-label="Changed files"> | 157 | <ol class="files" aria-label="Changed files"> | ||
| 145 | for _, f := range files { | 158 | for _, f := range files { | ||
| 146 | <li> | 159 | <li> | ||
| 147 | <a | 160 | <a | ||
| 148 | href={ templ.SafeURL("#" + f.ID) } | 161 | href={ templ.SafeURL("#" + f.ID) } | ||
| 149 | - | hx-on:click="document.getElementById(this.hash.slice(1)).open = true" | 162 | + | title={ f.Path } |
| 163 | + | hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))" | |||
| 150 | > | 164 | > | ||
| 151 | - | <span class="st" title={ f.Status }>{ f.Status }</span> | 165 | + | <span class="st" data-st={ f.Status } title={ statusName(f.Status) }>{ f.Status }</span> |
| 152 | - | <span class="p" title={ f.Path }>{ f.Path }</span> | 166 | + | <span class="nm"><span class="p">{ f.Name }</span>{ " " }<span class="d"><bdi>{ f.Dir }</bdi></span></span> |
| 153 | - | <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span> | 167 | + | if f.OpenNotes > 0 { |
| 154 | - | <span class="b" aria-hidden="true"><span class="plus">{ f.Bar.Add }</span><span class="minus">{ f.Bar.Del }</span>{ f.Bar.Rest }</span> | 168 | + | <span class="cn" title={ plural(f.OpenNotes, "open comment", "open comments") }>{ itoa(f.OpenNotes) }</span> |
| 169 | + | } else if f.Notes > 0 { | |||
| 170 | + | <span class="cn done" title={ plural(f.Notes, "resolved comment", "resolved comments") }>{ itoa(f.Notes) }</span> | |||
| 171 | + | } | |||
| 172 | + | if f.Bin { | |||
| 173 | + | <span class="n mute">bin</span> | |||
| 174 | + | } else { | |||
| 175 | + | <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span> | |||
| 176 | + | } | |||
| 155 | </a> | 177 | </a> | ||
| 156 | </li> | 178 | </li> | ||
| 157 | } | 179 | } | ||
| @@ -164,7 +186,7 @@ templ FileList(files []DiffFile) { | |||||
| 164 | // colour alone. | 186 | // colour alone. | ||
| 165 | templ DiffFileView(f DiffFile, p DiffView) { | 187 | templ DiffFileView(f DiffFile, p DiffView) { | ||
| 166 | <details | 188 | <details | ||
| 167 | - | class="dfile" | 189 | + | class={ "dfile", "lnw-" + itoa(f.Digits) } |
| 168 | id={ f.ID } | 190 | id={ f.ID } | ||
| 169 | data-file | 191 | data-file | ||
| 170 | if f.Open { | 192 | if f.Open { | ||
| @@ -177,7 +199,14 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||||
| 177 | { " " }<span class="note">{ f.Note }</span> | 199 | { " " }<span class="note">{ f.Note }</span> | ||
| 178 | } | 200 | } | ||
| 179 | </summary> | 201 | </summary> | ||
| 180 | - | if len(f.Hunks) > 0 { | 202 | + | if f.Lazy { |
| 203 | + | // A placeholder: mr.js fetches the window of the file when it comes | |||
| 204 | + | // near the screen and swaps the files of the window in. Without | |||
| 205 | + | // scripts the link opens the page of that window. | |||
| 206 | + | <div class={ "dlazy", "dlazy-" + itoa(f.Height) } data-src={ f.Src } data-notes={ f.Comments }> | |||
| 207 | + | <a href={ templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID) }>show this file</a> | |||
| 208 | + | </div> | |||
| 209 | + | } else if len(f.Hunks) > 0 { | |||
| 181 | <div class="dscroll"> | 210 | <div class="dscroll"> | ||
| 182 | if p.ShowUnified { | 211 | if p.ShowUnified { | ||
| 183 | <table class="dt uni" aria-label={ f.Path + ", unified" }> | 212 | <table class="dt uni" aria-label={ f.Path + ", unified" }> | ||
| @@ -233,9 +262,30 @@ templ DiffFileView(f DiffFile, p DiffView) { | |||||
| 233 | } | 262 | } | ||
| 234 | </p> | 263 | </p> | ||
| 235 | } | 264 | } | ||
| 265 | + | if len(f.Loose) > 0 { | |||
| 266 | + | <div class="dloose"> | |||
| 267 | + | for _, c := range f.Loose { | |||
| 268 | + | @commentView(c, "") | |||
| 269 | + | } | |||
| 270 | + | </div> | |||
| 271 | + | } | |||
| 236 | </details> | 272 | </details> | ||
| 237 | } | 273 | } | ||
| 238 | 274 | ||||
| 275 | + | // MRFiles is a window of files of a merge request, for the placeholders of | |||
| 276 | + | // the page: the files only, which replace the placeholders of the same ids. | |||
| 277 | + | templ MRFiles(p MRFilesPage) { | |||
| 278 | + | for _, f := range p.Files { | |||
| 279 | + | @DiffFileView(f, p.DiffView) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // MRFilesMoved answers a window of a diff that is not the one of the page any | |||
| 284 | + | // more: the placeholder shows it instead of a file of another diff. | |||
| 285 | + | templ MRFilesMoved() { | |||
| 286 | + | <p class="notice" data-moved>this merge request changed since the page was loaded — <a href="">reload</a></p> | |||
| 287 | + | } | |||
| 288 | + | ||||
| 239 | // splitSide is one half of a row of the split view; a side without a line is | 289 | // splitSide is one half of a row of the split view; a side without a line is | ||
| 240 | // an empty cell. | 290 | // an empty cell. | ||
| 241 | templ splitSide(ln *DiffLine, old bool) { | 291 | templ splitSide(ln *DiffLine, old bool) { | ||
M internal/adapters/web/diff_templ.go +463 -215 generated file: collapsed
M internal/adapters/web/diff_test.go +0 -9
| @@ -85,15 +85,6 @@ func TestPairLines(t *testing.T) { | |||
| 85 | 85 | assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4]) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | func TestChangeBar(t *testing.T) { | |
| 89 | - | t.Parallel() | |
| 90 | - | assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0)) | |
| 91 | - | assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100)) | |
| 92 | - | assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100)) | |
| 93 | - | assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away") | |
| 94 | - | assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar") | |
| 95 | - | } | |
| 96 | - | ||
| 97 | 88 | func TestFileNotes(t *testing.T) { | |
| 98 | 89 | t.Parallel() | |
| 99 | 90 | tests := []struct { | |
| @@ -85,15 +85,6 @@ func TestPairLines(t *testing.T) { | |||||
| 85 | assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4]) | 85 | assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4]) | ||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | - | func TestChangeBar(t *testing.T) { | |||
| 89 | - | t.Parallel() | |||
| 90 | - | assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0)) | |||
| 91 | - | assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100)) | |||
| 92 | - | assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100)) | |||
| 93 | - | assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away") | |||
| 94 | - | assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar") | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | func TestFileNotes(t *testing.T) { | 88 | func TestFileNotes(t *testing.T) { | ||
| 98 | t.Parallel() | 89 | t.Parallel() | ||
| 99 | tests := []struct { | 90 | tests := []struct { | ||
M internal/adapters/web/fragments.go +7 -2
| @@ -496,8 +496,13 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) { | |||
| 496 | 496 | case err != nil: | |
| 497 | 497 | return nil, err | |
| 498 | 498 | } | |
| 499 | - | f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, l.s.checksFor(ctx, name, l.head)))) | |
| 500 | - | return []fragment{f}, err | |
| 499 | + | checks := l.s.checksFor(ctx, name, l.head) | |
| 500 | + | f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, checks))) | |
| 501 | + | if err != nil { | |
| 502 | + | return nil, err | |
| 503 | + | } | |
| 504 | + | sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks))) | |
| 505 | + | return []fragment{f, sum}, err | |
| 501 | 506 | } | |
| 502 | 507 | ||
| 503 | 508 | // recentActions is the "Recent actions" block of the summary rail, read again | |
| @@ -496,8 +496,13 @@ func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) { | |||||
| 496 | case err != nil: | 496 | case err != nil: | ||
| 497 | return nil, err | 497 | return nil, err | ||
| 498 | } | 498 | } | ||
| 499 | - | f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, l.s.checksFor(ctx, name, l.head)))) | 499 | + | checks := l.s.checksFor(ctx, name, l.head) |
| 500 | - | return []fragment{f}, err | 500 | + | f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, checks))) |
| 501 | + | if err != nil { | |||
| 502 | + | return nil, err | |||
| 503 | + | } | |||
| 504 | + | sum, err := renderFragment(ctx, "ci-run", MRChecksSum(checksSummary(name, l.head, checks))) | |||
| 505 | + | return []fragment{f, sum}, err | |||
| 501 | } | 506 | } | ||
| 502 | 507 | ||||
| 503 | // recentActions is the "Recent actions" block of the summary rail, read again | 508 | // recentActions is the "Recent actions" block of the summary rail, read again | ||
A internal/adapters/web/jstest/mr.test.mjs +38 -0
| @@ -0,0 +1,38 @@ | |||
| 1 | + | // Tests of the pure core of the merge request page (static/js/mr.js). Run with | |
| 2 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |
| 3 | + | import assert from 'node:assert/strict'; | |
| 4 | + | import { test } from 'node:test'; | |
| 5 | + | ||
| 6 | + | globalThis.window = globalThis; | |
| 7 | + | globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] }; | |
| 8 | + | await import('../static/js/mr.js'); | |
| 9 | + | const C = window.ermiteMR.core; | |
| 10 | + | ||
| 11 | + | test('windowOf: the start of the window of a file', () => { | |
| 12 | + | assert.equal(C.windowOf('f1', 25), 0); | |
| 13 | + | assert.equal(C.windowOf('f25', 25), 0); | |
| 14 | + | assert.equal(C.windowOf('f26', 25), 25); | |
| 15 | + | assert.equal(C.windowOf('f84', 25), 75); | |
| 16 | + | assert.equal(C.windowOf('c3', 25), -1, 'not a file'); | |
| 17 | + | assert.equal(C.windowOf('f0', 25), -1); | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test('menuKey: modes by number, D toggles the deletion, nothing else', () => { | |
| 21 | + | assert.deepEqual(C.menuKey('1', 2), { mode: 0 }); | |
| 22 | + | assert.deepEqual(C.menuKey('2', 2), { mode: 1 }); | |
| 23 | + | assert.equal(C.menuKey('3', 2), null, 'no third mode'); | |
| 24 | + | assert.equal(C.menuKey('0', 2), null); | |
| 25 | + | assert.deepEqual(C.menuKey('D', 2), { toggle: true }); | |
| 26 | + | assert.equal(C.menuKey('d', 2), null, 'd is the split layout'); | |
| 27 | + | assert.equal(C.menuKey('Enter', 2), null, 'the browser submits: the focus is on the button'); | |
| 28 | + | assert.equal(C.menuKey('M', 2), null); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test('step: stays in the list', () => { | |
| 32 | + | assert.equal(C.step(0, 1, 3), 1); | |
| 33 | + | assert.equal(C.step(2, 1, 3), 2); | |
| 34 | + | assert.equal(C.step(0, -1, 3), 0); | |
| 35 | + | assert.equal(C.step(-1, 1, 3), 0); | |
| 36 | + | assert.equal(C.step(-1, -1, 3), 2); | |
| 37 | + | assert.equal(C.step(0, 1, 0), -1); | |
| 38 | + | }); | |
| @@ -0,0 +1,38 @@ | |||||
| 1 | + | // Tests of the pure core of the merge request page (static/js/mr.js). Run with | |||
| 2 | + | // node --test 'internal/adapters/web/jstest/*.test.mjs' | |||
| 3 | + | import assert from 'node:assert/strict'; | |||
| 4 | + | import { test } from 'node:test'; | |||
| 5 | + | ||||
| 6 | + | globalThis.window = globalThis; | |||
| 7 | + | globalThis.document = { readyState: 'complete', querySelector: () => null, querySelectorAll: () => [] }; | |||
| 8 | + | await import('../static/js/mr.js'); | |||
| 9 | + | const C = window.ermiteMR.core; | |||
| 10 | + | ||||
| 11 | + | test('windowOf: the start of the window of a file', () => { | |||
| 12 | + | assert.equal(C.windowOf('f1', 25), 0); | |||
| 13 | + | assert.equal(C.windowOf('f25', 25), 0); | |||
| 14 | + | assert.equal(C.windowOf('f26', 25), 25); | |||
| 15 | + | assert.equal(C.windowOf('f84', 25), 75); | |||
| 16 | + | assert.equal(C.windowOf('c3', 25), -1, 'not a file'); | |||
| 17 | + | assert.equal(C.windowOf('f0', 25), -1); | |||
| 18 | + | }); | |||
| 19 | + | ||||
| 20 | + | test('menuKey: modes by number, D toggles the deletion, nothing else', () => { | |||
| 21 | + | assert.deepEqual(C.menuKey('1', 2), { mode: 0 }); | |||
| 22 | + | assert.deepEqual(C.menuKey('2', 2), { mode: 1 }); | |||
| 23 | + | assert.equal(C.menuKey('3', 2), null, 'no third mode'); | |||
| 24 | + | assert.equal(C.menuKey('0', 2), null); | |||
| 25 | + | assert.deepEqual(C.menuKey('D', 2), { toggle: true }); | |||
| 26 | + | assert.equal(C.menuKey('d', 2), null, 'd is the split layout'); | |||
| 27 | + | assert.equal(C.menuKey('Enter', 2), null, 'the browser submits: the focus is on the button'); | |||
| 28 | + | assert.equal(C.menuKey('M', 2), null); | |||
| 29 | + | }); | |||
| 30 | + | ||||
| 31 | + | test('step: stays in the list', () => { | |||
| 32 | + | assert.equal(C.step(0, 1, 3), 1); | |||
| 33 | + | assert.equal(C.step(2, 1, 3), 2); | |||
| 34 | + | assert.equal(C.step(0, -1, 3), 0); | |||
| 35 | + | assert.equal(C.step(-1, 1, 3), 0); | |||
| 36 | + | assert.equal(C.step(-1, -1, 3), 2); | |||
| 37 | + | assert.equal(C.step(0, 1, 0), -1); | |||
| 38 | + | }); | |||
M internal/adapters/web/keys.templ +4 -1
| @@ -29,6 +29,9 @@ templ Keys() { | |||
| 29 | 29 | k-split from:body -> ermite.layout('split'); | |
| 30 | 30 | k-unified from:body -> ermite.layout('unified'); | |
| 31 | 31 | k-collapse from:body -> ermite.collapse(); | |
| 32 | - | k-permalink from:body -> ermite.permalink()" | |
| 32 | + | k-permalink from:body -> ermite.permalink(); | |
| 33 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 34 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 35 | + | k-gf from:body -> window.ermiteMR?.focusFiles()" | |
| 33 | 36 | ></div> | |
| 34 | 37 | } | |
| @@ -29,6 +29,9 @@ templ Keys() { | |||||
| 29 | k-split from:body -> ermite.layout('split'); | 29 | k-split from:body -> ermite.layout('split'); | ||
| 30 | k-unified from:body -> ermite.layout('unified'); | 30 | k-unified from:body -> ermite.layout('unified'); | ||
| 31 | k-collapse from:body -> ermite.collapse(); | 31 | k-collapse from:body -> ermite.collapse(); | ||
| 32 | - | k-permalink from:body -> ermite.permalink()" | 32 | + | k-permalink from:body -> ermite.permalink(); |
| 33 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 34 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 35 | + | k-gf from:body -> window.ermiteMR?.focusFiles()" | |||
| 33 | ></div> | 36 | ></div> | ||
| 34 | } | 37 | } | ||
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/mr.go +70 -13
| @@ -28,6 +28,13 @@ import ( | |||
| 28 | 28 | // GET /<repo>/merge-requests/<n> the request with its diff | |
| 29 | 29 | // GET /<repo>/merge-requests/<n>/since the diff since the last read | |
| 30 | 30 | // GET /<repo>/compare/<branch> a branch against the default branch | |
| 31 | + | // GET /<repo>/merge-requests/<n>/files a window of files of the diff | |
| 32 | + | // | |
| 33 | + | // The page lists every changed file and reads the hunks of the first window | |
| 34 | + | // of files only (mrWindow); the files after it are placeholders that the | |
| 35 | + | // browser fills in, a window per request, as they come near the screen | |
| 36 | + | // (/files?from=&base=&head=). Without scripts each placeholder links to the | |
| 37 | + | // page of its window (?from=), which shows the hunks of that window. | |
| 31 | 38 | // | |
| 32 | 39 | // Reading marks nothing as read. MarkRead stores state, and a GET must stay | |
| 33 | 40 | // safe: a prefetcher, a link preview or a reload of the owner's tab would move | |
| @@ -43,6 +50,9 @@ import ( | |||
| 43 | 50 | // forms and the CLI lines exist only in the owner's copy. | |
| 44 | 51 | ||
| 45 | 52 | const ( | |
| 53 | + | // mrWindow is how many files of a merge request one read gets the hunks | |
| 54 | + | // of: the page shows the first window and loads the others. | |
| 55 | + | mrWindow = 25 | |
| 46 | 56 | // mrPerPage is how many decided merge requests a page of the list shows, | |
| 47 | 57 | // at least; pages of the use case are read whole. | |
| 48 | 58 | mrPerPage = 50 | |
| @@ -114,6 +124,8 @@ type ResolveForm struct { | |||
| 114 | 124 | // Value is the state the form sets. | |
| 115 | 125 | Value bool | |
| 116 | 126 | Label string | |
| 127 | + | // From is the window of the page the comment is on (CommentForm.From). | |
| 128 | + | From int | |
| 117 | 129 | } | |
| 118 | 130 | ||
| 119 | 131 | // CommentForm is the POST that adds a comment. | |
| @@ -125,6 +137,9 @@ type CommentForm struct { | |||
| 125 | 137 | Path, Line, Side string | |
| 126 | 138 | Cancel string | |
| 127 | 139 | Focus bool | |
| 140 | + | // From is the window of the page the form is on (see mrWindow), sent | |
| 141 | + | // back so that the answer returns to it; 0 for the first. | |
| 142 | + | From int | |
| 128 | 143 | } | |
| 129 | 144 | ||
| 130 | 145 | // MRCommit is a commit of the request. | |
| @@ -198,8 +213,11 @@ type MRPage struct { | |||
| 198 | 213 | Invisible bool | |
| 199 | 214 | ||
| 200 | 215 | // General are the comments that are not on a line, Loose those on a line | |
| 201 | - | // that the diff shown does not have. | |
| 216 | + | // that the diff shown does not have. With LooseInFiles (a window of files | |
| 217 | + | // loaded later) the loose comments of a listed file go under the file | |
| 218 | + | // instead (DiffFile.Loose). | |
| 202 | 219 | General, Loose []CommentView | |
| 220 | + | LooseInFiles bool | |
| 203 | 221 | NComments int | |
| 204 | 222 | NUnresolved int | |
| 205 | 223 | GeneralForm *CommentForm | |
| @@ -208,12 +226,16 @@ type MRPage struct { | |||
| 208 | 226 | CommitsNote string | |
| 209 | 227 | Modes []string | |
| 210 | 228 | ChecksBlock bool | |
| 211 | - | // Checks are the CI checks of the head of the request. | |
| 212 | - | Checks ChecksSection | |
| 229 | + | // Checks are the CI checks of the head of the request, ChecksSum their | |
| 230 | + | // state in the bar above the diff. | |
| 231 | + | Checks ChecksSection | |
| 232 | + | ChecksSum ChecksSummary | |
| 213 | 233 | ||
| 214 | 234 | Box *MergeBox | |
| 215 | 235 | // Live is true when the page announces changes. | |
| 216 | 236 | Live bool | |
| 237 | + | // From is the first file of the window the page read the hunks of (?from=). | |
| 238 | + | From int | |
| 217 | 239 | } | |
| 218 | 240 | ||
| 219 | 241 | // CompareCommit is a commit of a branch that the target lacks. | |
| @@ -419,10 +441,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 419 | 441 | if err != nil { | |
| 420 | 442 | return nil, 0, err | |
| 421 | 443 | } | |
| 422 | - | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since) | |
| 444 | + | from, err := parseFrom(r) | |
| 423 | 445 | if err != nil { | |
| 424 | 446 | return nil, 0, err | |
| 425 | 447 | } | |
| 448 | + | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | |
| 449 | + | SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow}, | |
| 450 | + | }) | |
| 451 | + | if err != nil { | |
| 452 | + | return nil, 0, err | |
| 453 | + | } | |
| 454 | + | if from > 0 && from >= len(res.Diff.Files) { | |
| 455 | + | return nil, 0, errNotFound | |
| 456 | + | } | |
| 426 | 457 | m := res.MergeRequest | |
| 427 | 458 | now := s.cfg.Now() | |
| 428 | 459 | signed := SignedIn(ctx) | |
| @@ -470,13 +501,20 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 470 | 501 | } | |
| 471 | 502 | } | |
| 472 | 503 | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | |
| 473 | - | if open && head != "" { | |
| 474 | - | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head)) | |
| 475 | - | } else if head != "" { | |
| 476 | - | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head)) | |
| 477 | - | p.Checks.Note = "" | |
| 504 | + | if head != "" { | |
| 505 | + | checks := s.checksFor(ctx, d.repo.Name, head) | |
| 506 | + | p.Checks = checksSection(d.repo.Repo, checks) | |
| 507 | + | p.ChecksSum = checksSummary(d.repo.Name, head, checks) | |
| 508 | + | if !open { | |
| 509 | + | p.Checks.Note = "" | |
| 510 | + | } | |
| 478 | 511 | } | |
| 479 | 512 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | |
| 513 | + | countNotes(p.Files, res.Comments) | |
| 514 | + | if !res.DiffBaseID.IsZero() { | |
| 515 | + | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | |
| 516 | + | } | |
| 517 | + | p.From = from | |
| 480 | 518 | ||
| 481 | 519 | // The commits of the request, newest first. | |
| 482 | 520 | for _, c := range res.Commits { | |
| @@ -538,7 +576,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 538 | 576 | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | |
| 539 | 577 | lines, invisible := diffLines(p.Files) | |
| 540 | 578 | p.Invisible = invisible | |
| 541 | - | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | |
| 579 | + | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| 542 | 580 | if signed && open { | |
| 543 | 581 | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) | |
| 544 | 582 | if m.IsDraft() { | |
| @@ -570,6 +608,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||
| 570 | 608 | p.Live = true | |
| 571 | 609 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)) | |
| 572 | 610 | l.HasRail = true | |
| 611 | + | l.MR = true | |
| 573 | 612 | return MergeRequest(l, p), http.StatusOK, nil | |
| 574 | 613 | } | |
| 575 | 614 | ||
| @@ -666,23 +705,30 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 666 | 705 | } | |
| 667 | 706 | index := map[lineKey]*DiffLine{} | |
| 668 | 707 | fileIDs := map[string]string{} | |
| 708 | + | fileAt := map[string]int{} | |
| 669 | 709 | for fi := range p.Files { | |
| 670 | 710 | f := &p.Files[fi] | |
| 671 | 711 | fileIDs[f.Key] = f.ID | |
| 712 | + | fileAt[f.Key] = fi | |
| 672 | 713 | linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible) | |
| 714 | + | // The form of a line shows on the page of the window of its file. | |
| 715 | + | on := self + "?on=" | |
| 716 | + | if w := windowStart(fi); w > 0 { | |
| 717 | + | on = self + "?from=" + itoa(w) + "&on=" | |
| 718 | + | } | |
| 673 | 719 | for hi := range f.Hunks { | |
| 674 | 720 | for li := range f.Hunks[hi].Lines { | |
| 675 | 721 | ln := &f.Hunks[hi].Lines[li] | |
| 676 | 722 | if ln.oldN > 0 && ln.Kind != "add" { | |
| 677 | 723 | index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln | |
| 678 | 724 | if linkable { | |
| 679 | - | ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | |
| 725 | + | ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | |
| 680 | 726 | } | |
| 681 | 727 | } | |
| 682 | 728 | if ln.newN > 0 && ln.Kind != "del" { | |
| 683 | 729 | index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln | |
| 684 | 730 | if linkable { | |
| 685 | - | ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | |
| 731 | + | ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | |
| 686 | 732 | } | |
| 687 | 733 | } | |
| 688 | 734 | } | |
| @@ -711,6 +757,11 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 711 | 757 | p.NUnresolved++ | |
| 712 | 758 | } | |
| 713 | 759 | general := c.Path == "" | |
| 760 | + | fi, listed := fileAt[c.Path] | |
| 761 | + | if !general && listed && p.Files[fi].Lazy { | |
| 762 | + | // Its file comes with its window, and the comment with it. | |
| 763 | + | continue | |
| 764 | + | } | |
| 714 | 765 | var ln *DiffLine | |
| 715 | 766 | if !general { | |
| 716 | 767 | ln = find(c.Path, c.Side, c.Line) | |
| @@ -724,6 +775,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 724 | 775 | } | |
| 725 | 776 | if canWrite { | |
| 726 | 777 | v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"} | |
| 778 | + | if !general && listed { | |
| 779 | + | v.Resolve.From = windowStart(fi) | |
| 780 | + | } | |
| 727 | 781 | if c.Resolved { | |
| 728 | 782 | v.Resolve.Label = "Unresolve" | |
| 729 | 783 | } | |
| @@ -733,6 +787,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 733 | 787 | ln.Notes = append(ln.Notes, v) | |
| 734 | 788 | case general: | |
| 735 | 789 | p.General = append(p.General, v) | |
| 790 | + | case p.LooseInFiles && listed: | |
| 791 | + | p.Files[fi].Loose = append(p.Files[fi].Loose, v) | |
| 736 | 792 | default: | |
| 737 | 793 | p.Loose = append(p.Loose, v) | |
| 738 | 794 | } | |
| @@ -747,7 +803,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||
| 747 | 803 | ln.Form = &CommentForm{ | |
| 748 | 804 | ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side, | |
| 749 | 805 | Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")", | |
| 750 | - | Cancel: self + "#" + fileIDs[on.path], Focus: true, | |
| 806 | + | Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true, | |
| 807 | + | From: windowStart(fileAt[on.path]), | |
| 751 | 808 | } | |
| 752 | 809 | } | |
| 753 | 810 | } | |
| @@ -28,6 +28,13 @@ import ( | |||||
| 28 | // GET /<repo>/merge-requests/<n> the request with its diff | 28 | // GET /<repo>/merge-requests/<n> the request with its diff | ||
| 29 | // GET /<repo>/merge-requests/<n>/since the diff since the last read | 29 | // GET /<repo>/merge-requests/<n>/since the diff since the last read | ||
| 30 | // GET /<repo>/compare/<branch> a branch against the default branch | 30 | // GET /<repo>/compare/<branch> a branch against the default branch | ||
| 31 | + | // GET /<repo>/merge-requests/<n>/files a window of files of the diff | |||
| 32 | + | // | |||
| 33 | + | // The page lists every changed file and reads the hunks of the first window | |||
| 34 | + | // of files only (mrWindow); the files after it are placeholders that the | |||
| 35 | + | // browser fills in, a window per request, as they come near the screen | |||
| 36 | + | // (/files?from=&base=&head=). Without scripts each placeholder links to the | |||
| 37 | + | // page of its window (?from=), which shows the hunks of that window. | |||
| 31 | // | 38 | // | ||
| 32 | // Reading marks nothing as read. MarkRead stores state, and a GET must stay | 39 | // Reading marks nothing as read. MarkRead stores state, and a GET must stay | ||
| 33 | // safe: a prefetcher, a link preview or a reload of the owner's tab would move | 40 | // safe: a prefetcher, a link preview or a reload of the owner's tab would move | ||
| @@ -43,6 +50,9 @@ import ( | |||||
| 43 | // forms and the CLI lines exist only in the owner's copy. | 50 | // forms and the CLI lines exist only in the owner's copy. | ||
| 44 | 51 | ||||
| 45 | const ( | 52 | const ( | ||
| 53 | + | // mrWindow is how many files of a merge request one read gets the hunks | |||
| 54 | + | // of: the page shows the first window and loads the others. | |||
| 55 | + | mrWindow = 25 | |||
| 46 | // mrPerPage is how many decided merge requests a page of the list shows, | 56 | // mrPerPage is how many decided merge requests a page of the list shows, | ||
| 47 | // at least; pages of the use case are read whole. | 57 | // at least; pages of the use case are read whole. | ||
| 48 | mrPerPage = 50 | 58 | mrPerPage = 50 | ||
| @@ -114,6 +124,8 @@ type ResolveForm struct { | |||||
| 114 | // Value is the state the form sets. | 124 | // Value is the state the form sets. | ||
| 115 | Value bool | 125 | Value bool | ||
| 116 | Label string | 126 | Label string | ||
| 127 | + | // From is the window of the page the comment is on (CommentForm.From). | |||
| 128 | + | From int | |||
| 117 | } | 129 | } | ||
| 118 | 130 | ||||
| 119 | // CommentForm is the POST that adds a comment. | 131 | // CommentForm is the POST that adds a comment. | ||
| @@ -125,6 +137,9 @@ type CommentForm struct { | |||||
| 125 | Path, Line, Side string | 137 | Path, Line, Side string | ||
| 126 | Cancel string | 138 | Cancel string | ||
| 127 | Focus bool | 139 | Focus bool | ||
| 140 | + | // From is the window of the page the form is on (see mrWindow), sent | |||
| 141 | + | // back so that the answer returns to it; 0 for the first. | |||
| 142 | + | From int | |||
| 128 | } | 143 | } | ||
| 129 | 144 | ||||
| 130 | // MRCommit is a commit of the request. | 145 | // MRCommit is a commit of the request. | ||
| @@ -198,8 +213,11 @@ type MRPage struct { | |||||
| 198 | Invisible bool | 213 | Invisible bool | ||
| 199 | 214 | ||||
| 200 | // General are the comments that are not on a line, Loose those on a line | 215 | // General are the comments that are not on a line, Loose those on a line | ||
| 201 | - | // that the diff shown does not have. | 216 | + | // that the diff shown does not have. With LooseInFiles (a window of files |
| 217 | + | // loaded later) the loose comments of a listed file go under the file | |||
| 218 | + | // instead (DiffFile.Loose). | |||
| 202 | General, Loose []CommentView | 219 | General, Loose []CommentView | ||
| 220 | + | LooseInFiles bool | |||
| 203 | NComments int | 221 | NComments int | ||
| 204 | NUnresolved int | 222 | NUnresolved int | ||
| 205 | GeneralForm *CommentForm | 223 | GeneralForm *CommentForm | ||
| @@ -208,12 +226,16 @@ type MRPage struct { | |||||
| 208 | CommitsNote string | 226 | CommitsNote string | ||
| 209 | Modes []string | 227 | Modes []string | ||
| 210 | ChecksBlock bool | 228 | ChecksBlock bool | ||
| 211 | - | // Checks are the CI checks of the head of the request. | 229 | + | // Checks are the CI checks of the head of the request, ChecksSum their |
| 212 | - | Checks ChecksSection | 230 | + | // state in the bar above the diff. |
| 231 | + | Checks ChecksSection | |||
| 232 | + | ChecksSum ChecksSummary | |||
| 213 | 233 | ||||
| 214 | Box *MergeBox | 234 | Box *MergeBox | ||
| 215 | // Live is true when the page announces changes. | 235 | // Live is true when the page announces changes. | ||
| 216 | Live bool | 236 | Live bool | ||
| 237 | + | // From is the first file of the window the page read the hunks of (?from=). | |||
| 238 | + | From int | |||
| 217 | } | 239 | } | ||
| 218 | 240 | ||||
| 219 | // CompareCommit is a commit of a branch that the target lacks. | 241 | // CompareCommit is a commit of a branch that the target lacks. | ||
| @@ -419,10 +441,19 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 419 | if err != nil { | 441 | if err != nil { | ||
| 420 | return nil, 0, err | 442 | return nil, 0, err | ||
| 421 | } | 443 | } | ||
| 422 | - | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, since) | 444 | + | from, err := parseFrom(r) |
| 423 | if err != nil { | 445 | if err != nil { | ||
| 424 | return nil, 0, err | 446 | return nil, 0, err | ||
| 425 | } | 447 | } | ||
| 448 | + | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | |||
| 449 | + | SinceLastRead: since, Files: &ports.FileWindow{From: from, To: from + mrWindow}, | |||
| 450 | + | }) | |||
| 451 | + | if err != nil { | |||
| 452 | + | return nil, 0, err | |||
| 453 | + | } | |||
| 454 | + | if from > 0 && from >= len(res.Diff.Files) { | |||
| 455 | + | return nil, 0, errNotFound | |||
| 456 | + | } | |||
| 426 | m := res.MergeRequest | 457 | m := res.MergeRequest | ||
| 427 | now := s.cfg.Now() | 458 | now := s.cfg.Now() | ||
| 428 | signed := SignedIn(ctx) | 459 | signed := SignedIn(ctx) | ||
| @@ -470,13 +501,20 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 470 | } | 501 | } | ||
| 471 | } | 502 | } | ||
| 472 | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | 503 | p.ChecksBlock = d.repo.Settings.FailingChecksBlock | ||
| 473 | - | if open && head != "" { | 504 | + | if head != "" { |
| 474 | - | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head)) | 505 | + | checks := s.checksFor(ctx, d.repo.Name, head) |
| 475 | - | } else if head != "" { | 506 | + | p.Checks = checksSection(d.repo.Repo, checks) |
| 476 | - | p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head)) | 507 | + | p.ChecksSum = checksSummary(d.repo.Name, head, checks) |
| 477 | - | p.Checks.Note = "" | 508 | + | if !open { |
| 509 | + | p.Checks.Note = "" | |||
| 510 | + | } | |||
| 478 | } | 511 | } | ||
| 479 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | 512 | p.Note = diffNote(res.Diff, len(p.Files), p.NFiles) | ||
| 513 | + | countNotes(p.Files, res.Comments) | |||
| 514 | + | if !res.DiffBaseID.IsZero() { | |||
| 515 | + | lazyFiles(p.Files, from, filesSrc(d.name(), n, since, res.DiffBaseID.String(), head)) | |||
| 516 | + | } | |||
| 517 | + | p.From = from | |||
| 480 | 518 | ||||
| 481 | // The commits of the request, newest first. | 519 | // The commits of the request, newest first. | ||
| 482 | for _, c := range res.Commits { | 520 | for _, c := range res.Commits { | ||
| @@ -538,7 +576,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 538 | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | 576 | s.placeComments(&p, d.name(), n, res.Comments, head, !since, canWrite, on) | ||
| 539 | lines, invisible := diffLines(p.Files) | 577 | lines, invisible := diffLines(p.Files) | ||
| 540 | p.Invisible = invisible | 578 | p.Invisible = invisible | ||
| 541 | - | p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split") | 579 | + | p.DiffView = newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) |
| 542 | if signed && open { | 580 | if signed && open { | ||
| 543 | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) | 581 | p.Box = s.mergeBox(d, n, m.Source, head, lastRead) | ||
| 544 | if m.IsDraft() { | 582 | if m.IsDraft() { | ||
| @@ -570,6 +608,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ | |||||
| 570 | p.Live = true | 608 | p.Live = true | ||
| 571 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)) | 609 | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head)) | ||
| 572 | l.HasRail = true | 610 | l.HasRail = true | ||
| 611 | + | l.MR = true | |||
| 573 | return MergeRequest(l, p), http.StatusOK, nil | 612 | return MergeRequest(l, p), http.StatusOK, nil | ||
| 574 | } | 613 | } | ||
| 575 | 614 | ||||
| @@ -666,23 +705,30 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||||
| 666 | } | 705 | } | ||
| 667 | index := map[lineKey]*DiffLine{} | 706 | index := map[lineKey]*DiffLine{} | ||
| 668 | fileIDs := map[string]string{} | 707 | fileIDs := map[string]string{} | ||
| 708 | + | fileAt := map[string]int{} | |||
| 669 | for fi := range p.Files { | 709 | for fi := range p.Files { | ||
| 670 | f := &p.Files[fi] | 710 | f := &p.Files[fi] | ||
| 671 | fileIDs[f.Key] = f.ID | 711 | fileIDs[f.Key] = f.ID | ||
| 712 | + | fileAt[f.Key] = fi | |||
| 672 | linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible) | 713 | linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible) | ||
| 714 | + | // The form of a line shows on the page of the window of its file. | |||
| 715 | + | on := self + "?on=" | |||
| 716 | + | if w := windowStart(fi); w > 0 { | |||
| 717 | + | on = self + "?from=" + itoa(w) + "&on=" | |||
| 718 | + | } | |||
| 673 | for hi := range f.Hunks { | 719 | for hi := range f.Hunks { | ||
| 674 | for li := range f.Hunks[hi].Lines { | 720 | for li := range f.Hunks[hi].Lines { | ||
| 675 | ln := &f.Hunks[hi].Lines[li] | 721 | ln := &f.Hunks[hi].Lines[li] | ||
| 676 | if ln.oldN > 0 && ln.Kind != "add" { | 722 | if ln.oldN > 0 && ln.Kind != "add" { | ||
| 677 | index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln | 723 | index[lineKey{f.Key, domain.SideOld, ln.oldN}] = ln | ||
| 678 | if linkable { | 724 | if linkable { | ||
| 679 | - | ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | 725 | + | ln.OldHref = on + "old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID |
| 680 | } | 726 | } | ||
| 681 | } | 727 | } | ||
| 682 | if ln.newN > 0 && ln.Kind != "del" { | 728 | if ln.newN > 0 && ln.Kind != "del" { | ||
| 683 | index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln | 729 | index[lineKey{f.Key, domain.SideNew, ln.newN}] = ln | ||
| 684 | if linkable { | 730 | if linkable { | ||
| 685 | - | ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID | 731 | + | ln.NewHref = on + "new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID |
| 686 | } | 732 | } | ||
| 687 | } | 733 | } | ||
| 688 | } | 734 | } | ||
| @@ -711,6 +757,11 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||||
| 711 | p.NUnresolved++ | 757 | p.NUnresolved++ | ||
| 712 | } | 758 | } | ||
| 713 | general := c.Path == "" | 759 | general := c.Path == "" | ||
| 760 | + | fi, listed := fileAt[c.Path] | |||
| 761 | + | if !general && listed && p.Files[fi].Lazy { | |||
| 762 | + | // Its file comes with its window, and the comment with it. | |||
| 763 | + | continue | |||
| 764 | + | } | |||
| 714 | var ln *DiffLine | 765 | var ln *DiffLine | ||
| 715 | if !general { | 766 | if !general { | ||
| 716 | ln = find(c.Path, c.Side, c.Line) | 767 | ln = find(c.Path, c.Side, c.Line) | ||
| @@ -724,6 +775,9 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||||
| 724 | } | 775 | } | ||
| 725 | if canWrite { | 776 | if canWrite { | ||
| 726 | v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"} | 777 | v.Resolve = &ResolveForm{Action: resolveAction(cid), Value: !c.Resolved, Label: "Resolve"} | ||
| 778 | + | if !general && listed { | |||
| 779 | + | v.Resolve.From = windowStart(fi) | |||
| 780 | + | } | |||
| 727 | if c.Resolved { | 781 | if c.Resolved { | ||
| 728 | v.Resolve.Label = "Unresolve" | 782 | v.Resolve.Label = "Unresolve" | ||
| 729 | } | 783 | } | ||
| @@ -733,6 +787,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||||
| 733 | ln.Notes = append(ln.Notes, v) | 787 | ln.Notes = append(ln.Notes, v) | ||
| 734 | case general: | 788 | case general: | ||
| 735 | p.General = append(p.General, v) | 789 | p.General = append(p.General, v) | ||
| 790 | + | case p.LooseInFiles && listed: | |||
| 791 | + | p.Files[fi].Loose = append(p.Files[fi].Loose, v) | |||
| 736 | default: | 792 | default: | ||
| 737 | p.Loose = append(p.Loose, v) | 793 | p.Loose = append(p.Loose, v) | ||
| 738 | } | 794 | } | ||
| @@ -747,7 +803,8 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai | |||||
| 747 | ln.Form = &CommentForm{ | 803 | ln.Form = &CommentForm{ | ||
| 748 | ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side, | 804 | ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side, | ||
| 749 | Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")", | 805 | Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")", | ||
| 750 | - | Cancel: self + "#" + fileIDs[on.path], Focus: true, | 806 | + | Cancel: withFrom(self, windowStart(fileAt[on.path])) + "#" + fileIDs[on.path], Focus: true, |
| 807 | + | From: windowStart(fileAt[on.path]), | |||
| 751 | } | 808 | } | ||
| 752 | } | 809 | } | ||
| 753 | } | 810 | } | ||
M internal/adapters/web/mr.templ +113 -75
| @@ -162,13 +162,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 162 | 162 | if p.SinceNote != "" { | |
| 163 | 163 | <p class="notice">{ p.SinceNote }</p> | |
| 164 | 164 | } | |
| 165 | - | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 166 | - | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> | |
| 167 | - | <span class="grow"></span> | |
| 168 | - | @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref) | |
| 169 | - | @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref) | |
| 170 | - | @collapseButton() | |
| 171 | - | </div> | |
| 165 | + | @mrBar(p) | |
| 172 | 166 | if p.Invisible { | |
| 173 | 167 | <p class="notice"> | |
| 174 | 168 | this diff holds invisible characters (direction controls, zero-width characters). They are shown as | |
| @@ -180,6 +174,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 180 | 174 | } | |
| 181 | 175 | <div | |
| 182 | 176 | id="diffs" | |
| 177 | + | data-mr | |
| 183 | 178 | if p.Both { | |
| 184 | 179 | data-diff={ p.View } | |
| 185 | 180 | } else { | |
| @@ -256,6 +251,9 @@ templ commentView(c CommentView, suffix string) { | |||
| 256 | 251 | if c.Resolve != nil { | |
| 257 | 252 | <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }> | |
| 258 | 253 | <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/> | |
| 254 | + | if c.Resolve.From > 0 { | |
| 255 | + | <input type="hidden" name="from" value={ itoa(c.Resolve.From) }/> | |
| 256 | + | } | |
| 259 | 257 | <button type="submit" class="btn">{ c.Resolve.Label }</button> | |
| 260 | 258 | </form> | |
| 261 | 259 | } | |
| @@ -289,6 +287,9 @@ templ commentForm(f CommentForm, suffix string) { | |||
| 289 | 287 | <input type="hidden" name="line" value={ f.Line }/> | |
| 290 | 288 | <input type="hidden" name="side" value={ f.Side }/> | |
| 291 | 289 | } | |
| 290 | + | if f.From > 0 { | |
| 291 | + | <input type="hidden" name="from" value={ itoa(f.From) }/> | |
| 292 | + | } | |
| 292 | 293 | <div class="f-btns"> | |
| 293 | 294 | <button type="submit" class="btn primary">Comment</button> | |
| 294 | 295 | if f.Cancel != "" { | |
| @@ -299,25 +300,11 @@ templ commentForm(f CommentForm, suffix string) { | |||
| 299 | 300 | } | |
| 300 | 301 | ||
| 301 | 302 | templ mrRail(p MRPage) { | |
| 302 | - | if p.Box != nil { | |
| 303 | - | @mergeBox(p) | |
| 304 | - | } | |
| 305 | - | @MRChecks(p.Checks) | |
| 306 | - | <section aria-labelledby="r-settings"> | |
| 307 | - | <h2 id="r-settings">Settings</h2> | |
| 308 | - | <dl class="kv"> | |
| 309 | - | <dt>modes</dt> | |
| 310 | - | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |
| 311 | - | <dt>checks</dt> | |
| 312 | - | <dd> | |
| 313 | - | if p.ChecksBlock { | |
| 314 | - | failing ones block the merge | |
| 315 | - | } else { | |
| 316 | - | do not block the merge | |
| 317 | - | } | |
| 318 | - | </dd> | |
| 319 | - | </dl> | |
| 303 | + | <section aria-labelledby="r-files" class="r-files"> | |
| 304 | + | <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2> | |
| 305 | + | @FileList(p.Files) | |
| 320 | 306 | </section> | |
| 307 | + | @MRChecks(p.Checks) | |
| 321 | 308 | <section aria-labelledby="r-commits"> | |
| 322 | 309 | <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2> | |
| 323 | 310 | <ul class="pcommits"> | |
| @@ -344,74 +331,125 @@ templ mrRail(p MRPage) { | |||
| 344 | 331 | <p class="note">{ p.CommitsNote }</p> | |
| 345 | 332 | } | |
| 346 | 333 | </section> | |
| 347 | - | <section aria-labelledby="r-files"> | |
| 348 | - | <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2> | |
| 349 | - | @FileList(p.Files) | |
| 350 | - | </section> | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | // mergeBox is the first block of the rail of an open request, for the owner: | |
| 354 | - | // the merge form (it carries the head that was read), the terminal lines, and | |
| 355 | - | // the other things one does with the request. | |
| 356 | - | templ mergeBox(p MRPage) { | |
| 357 | - | <section class="decide" aria-labelledby="r-merge" data-state="open"> | |
| 358 | - | <h2 id="r-merge">Merge</h2> | |
| 334 | + | <section aria-labelledby="r-settings"> | |
| 335 | + | <h2 id="r-settings">Settings</h2> | |
| 359 | 336 | <dl class="kv"> | |
| 360 | - | <dt>head</dt> | |
| 337 | + | <dt>modes</dt> | |
| 338 | + | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |
| 339 | + | <dt>checks</dt> | |
| 361 | 340 | <dd> | |
| 362 | - | if p.Box.HeadGone { | |
| 363 | - | gone: the source branch was deleted | |
| 341 | + | if p.ChecksBlock { | |
| 342 | + | failing ones block the merge | |
| 364 | 343 | } else { | |
| 365 | - | { p.HeadShort } | |
| 344 | + | do not block the merge | |
| 366 | 345 | } | |
| 367 | 346 | </dd> | |
| 368 | - | <dt>into</dt> | |
| 369 | - | <dd>{ p.Target }</dd> | |
| 370 | 347 | </dl> | |
| 371 | - | if p.Box.ReadyAction != "" { | |
| 372 | - | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | |
| 373 | - | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | |
| 374 | - | <button type="submit" class="btn primary" title="remove the draft prefix from the title">Ready for review</button> | |
| 375 | - | </form> | |
| 376 | - | } else if !p.Box.HeadGone { | |
| 377 | - | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | |
| 378 | - | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 379 | - | <label class="sr" for="merge-mode">merge mode</label> | |
| 380 | - | <select id="merge-mode" name="mode"> | |
| 381 | - | for _, m := range p.Box.Modes { | |
| 382 | - | <option value={ m.Value }>{ m.Label }</option> | |
| 383 | - | } | |
| 384 | - | </select> | |
| 385 | - | if p.Box.CanDeleteBranch { | |
| 386 | - | <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label> | |
| 387 | - | } | |
| 388 | - | <div class="d-btns"> | |
| 389 | - | <button type="submit" class="btn primary">Merge</button> | |
| 390 | - | </div> | |
| 391 | - | </form> | |
| 348 | + | </section> | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // mrBar is the bar above the diff, which follows the review: the totals, the | |
| 352 | + | // layout of the diff, the state of the checks and, for the owner of an open | |
| 353 | + | // request, the merge menu and the other actions. Each menu is a <details>: it | |
| 354 | + | // works without scripts; with them, M and the keys of the menu drive it. | |
| 355 | + | templ mrBar(p MRPage) { | |
| 356 | + | <div class="diff-tools mr-bar" role="toolbar" aria-label="Diff view and merge"> | |
| 357 | + | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> | |
| 358 | + | @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref) | |
| 359 | + | @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref) | |
| 360 | + | @collapseButton() | |
| 361 | + | <span class="grow"></span> | |
| 362 | + | @MRChecksSum(p.ChecksSum) | |
| 363 | + | if p.Box != nil { | |
| 364 | + | @mergeMenu(p) | |
| 365 | + | @moreMenu(p) | |
| 392 | 366 | } | |
| 393 | - | <div class="d-btns"> | |
| 367 | + | </div> | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // mergeMenu is the merge of an open request, for the owner: the form (it | |
| 371 | + | // carries the head that was read), or "Ready for review" for a draft. The | |
| 372 | + | // mode and the branch deletion have keys (1-3, D) while it is open. | |
| 373 | + | templ mergeMenu(p MRPage) { | |
| 374 | + | <details class="menu mmenu" id="merge-menu" data-state="open"> | |
| 375 | + | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | |
| 376 | + | if p.Box.ReadyAction != "" { | |
| 377 | + | Draft | |
| 378 | + | } else { | |
| 379 | + | Merge | |
| 380 | + | } | |
| 381 | + | <kbd aria-hidden="true">M</kbd> | |
| 382 | + | </summary> | |
| 383 | + | <div class="menu-pop" role="group" aria-label="Merge"> | |
| 384 | + | <dl class="kv"> | |
| 385 | + | <dt>head</dt> | |
| 386 | + | <dd> | |
| 387 | + | if p.Box.HeadGone { | |
| 388 | + | gone: the source branch was deleted | |
| 389 | + | } else { | |
| 390 | + | { p.HeadShort } | |
| 391 | + | } | |
| 392 | + | </dd> | |
| 393 | + | <dt>into</dt> | |
| 394 | + | <dd>{ p.Target }</dd> | |
| 395 | + | </dl> | |
| 396 | + | if p.Box.ReadyAction != "" { | |
| 397 | + | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | |
| 398 | + | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | |
| 399 | + | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | |
| 400 | + | </form> | |
| 401 | + | } else if !p.Box.HeadGone { | |
| 402 | + | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | |
| 403 | + | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 404 | + | <fieldset class="d-modes"> | |
| 405 | + | <legend class="sr">merge mode</legend> | |
| 406 | + | for i, m := range p.Box.Modes { | |
| 407 | + | <label> | |
| 408 | + | <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> | |
| 409 | + | { m.Label } | |
| 410 | + | if i < 9 { | |
| 411 | + | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | |
| 412 | + | } | |
| 413 | + | </label> | |
| 414 | + | } | |
| 415 | + | </fieldset> | |
| 416 | + | if p.Box.CanDeleteBranch { | |
| 417 | + | <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label> | |
| 418 | + | } | |
| 419 | + | <div class="d-btns"> | |
| 420 | + | <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button> | |
| 421 | + | </div> | |
| 422 | + | </form> | |
| 423 | + | } | |
| 424 | + | </div> | |
| 425 | + | </details> | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | // moreMenu is what else the owner does with an open request: mark the head | |
| 429 | + | // read, close it, merge it from a terminal. | |
| 430 | + | templ moreMenu(p MRPage) { | |
| 431 | + | <details class="menu omenu"> | |
| 432 | + | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | |
| 433 | + | <div class="menu-pop" role="group" aria-label="More actions"> | |
| 394 | 434 | if p.Box.CanMarkRead { | |
| 395 | 435 | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | |
| 396 | 436 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 397 | - | <button type="submit" title="the next visit can show only what changed after this head" class="btn">Mark read</button> | |
| 437 | + | <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button> | |
| 398 | 438 | </form> | |
| 399 | 439 | } | |
| 400 | 440 | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | |
| 401 | 441 | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | |
| 402 | 442 | </form> | |
| 443 | + | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | |
| 444 | + | <details class="d-or"> | |
| 445 | + | <summary class="mute">or from a terminal</summary> | |
| 446 | + | @CloneBlock(p.Box.CLI) | |
| 447 | + | </details> | |
| 448 | + | } | |
| 403 | 449 | </div> | |
| 404 | - | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | |
| 405 | - | <details class="d-or"> | |
| 406 | - | <summary class="mute">or from a terminal</summary> | |
| 407 | - | @CloneBlock(p.Box.CLI) | |
| 408 | - | </details> | |
| 409 | - | } | |
| 410 | - | </section> | |
| 450 | + | </details> | |
| 411 | 451 | } | |
| 412 | 452 | ||
| 413 | - | // Compare is a branch against the default branch: the commits it is ahead by, | |
| 414 | - | // its diff when the API can give one, and the form that opens a merge request. | |
| 415 | 453 | templ Compare(l Layout, p ComparePage) { | |
| 416 | 454 | @Shell(l, compareRail(p)) { | |
| 417 | 455 | <header class="show mr-h"> | |
| @@ -162,13 +162,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 162 | if p.SinceNote != "" { | 162 | if p.SinceNote != "" { | ||
| 163 | <p class="notice">{ p.SinceNote }</p> | 163 | <p class="notice">{ p.SinceNote }</p> | ||
| 164 | } | 164 | } | ||
| 165 | - | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | 165 | + | @mrBar(p) |
| 166 | - | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> | |||
| 167 | - | <span class="grow"></span> | |||
| 168 | - | @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref) | |||
| 169 | - | @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref) | |||
| 170 | - | @collapseButton() | |||
| 171 | - | </div> | |||
| 172 | if p.Invisible { | 166 | if p.Invisible { | ||
| 173 | <p class="notice"> | 167 | <p class="notice"> | ||
| 174 | this diff holds invisible characters (direction controls, zero-width characters). They are shown as | 168 | this diff holds invisible characters (direction controls, zero-width characters). They are shown as | ||
| @@ -180,6 +174,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 180 | } | 174 | } | ||
| 181 | <div | 175 | <div | ||
| 182 | id="diffs" | 176 | id="diffs" | ||
| 177 | + | data-mr | |||
| 183 | if p.Both { | 178 | if p.Both { | ||
| 184 | data-diff={ p.View } | 179 | data-diff={ p.View } | ||
| 185 | } else { | 180 | } else { | ||
| @@ -256,6 +251,9 @@ templ commentView(c CommentView, suffix string) { | |||||
| 256 | if c.Resolve != nil { | 251 | if c.Resolve != nil { | ||
| 257 | <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }> | 252 | <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }> | ||
| 258 | <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/> | 253 | <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/> | ||
| 254 | + | if c.Resolve.From > 0 { | |||
| 255 | + | <input type="hidden" name="from" value={ itoa(c.Resolve.From) }/> | |||
| 256 | + | } | |||
| 259 | <button type="submit" class="btn">{ c.Resolve.Label }</button> | 257 | <button type="submit" class="btn">{ c.Resolve.Label }</button> | ||
| 260 | </form> | 258 | </form> | ||
| 261 | } | 259 | } | ||
| @@ -289,6 +287,9 @@ templ commentForm(f CommentForm, suffix string) { | |||||
| 289 | <input type="hidden" name="line" value={ f.Line }/> | 287 | <input type="hidden" name="line" value={ f.Line }/> | ||
| 290 | <input type="hidden" name="side" value={ f.Side }/> | 288 | <input type="hidden" name="side" value={ f.Side }/> | ||
| 291 | } | 289 | } | ||
| 290 | + | if f.From > 0 { | |||
| 291 | + | <input type="hidden" name="from" value={ itoa(f.From) }/> | |||
| 292 | + | } | |||
| 292 | <div class="f-btns"> | 293 | <div class="f-btns"> | ||
| 293 | <button type="submit" class="btn primary">Comment</button> | 294 | <button type="submit" class="btn primary">Comment</button> | ||
| 294 | if f.Cancel != "" { | 295 | if f.Cancel != "" { | ||
| @@ -299,25 +300,11 @@ templ commentForm(f CommentForm, suffix string) { | |||||
| 299 | } | 300 | } | ||
| 300 | 301 | ||||
| 301 | templ mrRail(p MRPage) { | 302 | templ mrRail(p MRPage) { | ||
| 302 | - | if p.Box != nil { | 303 | + | <section aria-labelledby="r-files" class="r-files"> |
| 303 | - | @mergeBox(p) | 304 | + | <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span><kbd aria-hidden="true">g f</kbd></h2> |
| 304 | - | } | 305 | + | @FileList(p.Files) |
| 305 | - | @MRChecks(p.Checks) | |||
| 306 | - | <section aria-labelledby="r-settings"> | |||
| 307 | - | <h2 id="r-settings">Settings</h2> | |||
| 308 | - | <dl class="kv"> | |||
| 309 | - | <dt>modes</dt> | |||
| 310 | - | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |||
| 311 | - | <dt>checks</dt> | |||
| 312 | - | <dd> | |||
| 313 | - | if p.ChecksBlock { | |||
| 314 | - | failing ones block the merge | |||
| 315 | - | } else { | |||
| 316 | - | do not block the merge | |||
| 317 | - | } | |||
| 318 | - | </dd> | |||
| 319 | - | </dl> | |||
| 320 | </section> | 306 | </section> | ||
| 307 | + | @MRChecks(p.Checks) | |||
| 321 | <section aria-labelledby="r-commits"> | 308 | <section aria-labelledby="r-commits"> | ||
| 322 | <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2> | 309 | <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2> | ||
| 323 | <ul class="pcommits"> | 310 | <ul class="pcommits"> | ||
| @@ -344,74 +331,125 @@ templ mrRail(p MRPage) { | |||||
| 344 | <p class="note">{ p.CommitsNote }</p> | 331 | <p class="note">{ p.CommitsNote }</p> | ||
| 345 | } | 332 | } | ||
| 346 | </section> | 333 | </section> | ||
| 347 | - | <section aria-labelledby="r-files"> | 334 | + | <section aria-labelledby="r-settings"> |
| 348 | - | <h2 id="r-files">Files <span class="count">{ itoa(len(p.Files)) }</span></h2> | 335 | + | <h2 id="r-settings">Settings</h2> |
| 349 | - | @FileList(p.Files) | |||
| 350 | - | </section> | |||
| 351 | - | } | |||
| 352 | - | ||||
| 353 | - | // mergeBox is the first block of the rail of an open request, for the owner: | |||
| 354 | - | // the merge form (it carries the head that was read), the terminal lines, and | |||
| 355 | - | // the other things one does with the request. | |||
| 356 | - | templ mergeBox(p MRPage) { | |||
| 357 | - | <section class="decide" aria-labelledby="r-merge" data-state="open"> | |||
| 358 | - | <h2 id="r-merge">Merge</h2> | |||
| 359 | <dl class="kv"> | 336 | <dl class="kv"> | ||
| 360 | - | <dt>head</dt> | 337 | + | <dt>modes</dt> |
| 338 | + | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |||
| 339 | + | <dt>checks</dt> | |||
| 361 | <dd> | 340 | <dd> | ||
| 362 | - | if p.Box.HeadGone { | 341 | + | if p.ChecksBlock { |
| 363 | - | gone: the source branch was deleted | 342 | + | failing ones block the merge |
| 364 | } else { | 343 | } else { | ||
| 365 | - | { p.HeadShort } | 344 | + | do not block the merge |
| 366 | } | 345 | } | ||
| 367 | </dd> | 346 | </dd> | ||
| 368 | - | <dt>into</dt> | |||
| 369 | - | <dd>{ p.Target }</dd> | |||
| 370 | </dl> | 347 | </dl> | ||
| 371 | - | if p.Box.ReadyAction != "" { | 348 | + | </section> |
| 372 | - | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | 349 | + | } |
| 373 | - | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | 350 | + | |
| 374 | - | <button type="submit" class="btn primary" title="remove the draft prefix from the title">Ready for review</button> | 351 | + | // mrBar is the bar above the diff, which follows the review: the totals, the |
| 375 | - | </form> | 352 | + | // layout of the diff, the state of the checks and, for the owner of an open |
| 376 | - | } else if !p.Box.HeadGone { | 353 | + | // request, the merge menu and the other actions. Each menu is a <details>: it |
| 377 | - | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | 354 | + | // works without scripts; with them, M and the keys of the menu drive it. |
| 378 | - | <input type="hidden" name="head_id" value={ p.Box.Head }/> | 355 | + | templ mrBar(p MRPage) { |
| 379 | - | <label class="sr" for="merge-mode">merge mode</label> | 356 | + | <div class="diff-tools mr-bar" role="toolbar" aria-label="Diff view and merge"> |
| 380 | - | <select id="merge-mode" name="mode"> | 357 | + | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> |
| 381 | - | for _, m := range p.Box.Modes { | 358 | + | @viewLink(p.DiffView, "unified", "Unified", "u", p.UnifiedHref) |
| 382 | - | <option value={ m.Value }>{ m.Label }</option> | 359 | + | @viewLink(p.DiffView, "split", "Split", "d", p.SplitHref) |
| 383 | - | } | 360 | + | @collapseButton() |
| 384 | - | </select> | 361 | + | <span class="grow"></span> |
| 385 | - | if p.Box.CanDeleteBranch { | 362 | + | @MRChecksSum(p.ChecksSum) |
| 386 | - | <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge</label> | 363 | + | if p.Box != nil { |
| 387 | - | } | 364 | + | @mergeMenu(p) |
| 388 | - | <div class="d-btns"> | 365 | + | @moreMenu(p) |
| 389 | - | <button type="submit" class="btn primary">Merge</button> | |||
| 390 | - | </div> | |||
| 391 | - | </form> | |||
| 392 | } | 366 | } | ||
| 393 | - | <div class="d-btns"> | 367 | + | </div> |
| 368 | + | } | |||
| 369 | + | ||||
| 370 | + | // mergeMenu is the merge of an open request, for the owner: the form (it | |||
| 371 | + | // carries the head that was read), or "Ready for review" for a draft. The | |||
| 372 | + | // mode and the branch deletion have keys (1-3, D) while it is open. | |||
| 373 | + | templ mergeMenu(p MRPage) { | |||
| 374 | + | <details class="menu mmenu" id="merge-menu" data-state="open"> | |||
| 375 | + | <summary class="btn primary" aria-keyshortcuts="Shift+M"> | |||
| 376 | + | if p.Box.ReadyAction != "" { | |||
| 377 | + | Draft | |||
| 378 | + | } else { | |||
| 379 | + | Merge | |||
| 380 | + | } | |||
| 381 | + | <kbd aria-hidden="true">M</kbd> | |||
| 382 | + | </summary> | |||
| 383 | + | <div class="menu-pop" role="group" aria-label="Merge"> | |||
| 384 | + | <dl class="kv"> | |||
| 385 | + | <dt>head</dt> | |||
| 386 | + | <dd> | |||
| 387 | + | if p.Box.HeadGone { | |||
| 388 | + | gone: the source branch was deleted | |||
| 389 | + | } else { | |||
| 390 | + | { p.HeadShort } | |||
| 391 | + | } | |||
| 392 | + | </dd> | |||
| 393 | + | <dt>into</dt> | |||
| 394 | + | <dd>{ p.Target }</dd> | |||
| 395 | + | </dl> | |||
| 396 | + | if p.Box.ReadyAction != "" { | |||
| 397 | + | <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p> | |||
| 398 | + | <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }> | |||
| 399 | + | <button type="submit" class="btn primary" data-default title="remove the draft prefix from the title">Ready for review</button> | |||
| 400 | + | </form> | |||
| 401 | + | } else if !p.Box.HeadGone { | |||
| 402 | + | <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }> | |||
| 403 | + | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |||
| 404 | + | <fieldset class="d-modes"> | |||
| 405 | + | <legend class="sr">merge mode</legend> | |||
| 406 | + | for i, m := range p.Box.Modes { | |||
| 407 | + | <label> | |||
| 408 | + | <input type="radio" name="mode" value={ m.Value } checked?={ i == 0 }/> | |||
| 409 | + | { m.Label } | |||
| 410 | + | if i < 9 { | |||
| 411 | + | <kbd aria-hidden="true">{ itoa(i + 1) }</kbd> | |||
| 412 | + | } | |||
| 413 | + | </label> | |||
| 414 | + | } | |||
| 415 | + | </fieldset> | |||
| 416 | + | if p.Box.CanDeleteBranch { | |||
| 417 | + | <label class="d-check"><input type="checkbox" name="delete_branch" value="1"/> delete { p.Source } after the merge <kbd aria-hidden="true">D</kbd></label> | |||
| 418 | + | } | |||
| 419 | + | <div class="d-btns"> | |||
| 420 | + | <button type="submit" class="btn primary" data-default>Merge into { p.Target }</button> | |||
| 421 | + | </div> | |||
| 422 | + | </form> | |||
| 423 | + | } | |||
| 424 | + | </div> | |||
| 425 | + | </details> | |||
| 426 | + | } | |||
| 427 | + | ||||
| 428 | + | // moreMenu is what else the owner does with an open request: mark the head | |||
| 429 | + | // read, close it, merge it from a terminal. | |||
| 430 | + | templ moreMenu(p MRPage) { | |||
| 431 | + | <details class="menu omenu"> | |||
| 432 | + | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | |||
| 433 | + | <div class="menu-pop" role="group" aria-label="More actions"> | |||
| 394 | if p.Box.CanMarkRead { | 434 | if p.Box.CanMarkRead { | ||
| 395 | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | 435 | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | ||
| 396 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | 436 | <input type="hidden" name="head_id" value={ p.Box.Head }/> | ||
| 397 | - | <button type="submit" title="the next visit can show only what changed after this head" class="btn">Mark read</button> | 437 | + | <button type="submit" title="the next visit can show only what changed after this head" class="btn" aria-keyshortcuts="Shift+R" data-read>Mark read <kbd aria-hidden="true">R</kbd></button> |
| 398 | </form> | 438 | </form> | ||
| 399 | } | 439 | } | ||
| 400 | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | 440 | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | ||
| 401 | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | 441 | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | ||
| 402 | </form> | 442 | </form> | ||
| 443 | + | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | |||
| 444 | + | <details class="d-or"> | |||
| 445 | + | <summary class="mute">or from a terminal</summary> | |||
| 446 | + | @CloneBlock(p.Box.CLI) | |||
| 447 | + | </details> | |||
| 448 | + | } | |||
| 403 | </div> | 449 | </div> | ||
| 404 | - | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | 450 | + | </details> |
| 405 | - | <details class="d-or"> | |||
| 406 | - | <summary class="mute">or from a terminal</summary> | |||
| 407 | - | @CloneBlock(p.Box.CLI) | |||
| 408 | - | </details> | |||
| 409 | - | } | |||
| 410 | - | </section> | |||
| 411 | } | 451 | } | ||
| 412 | 452 | ||||
| 413 | - | // Compare is a branch against the default branch: the commits it is ahead by, | |||
| 414 | - | // its diff when the API can give one, and the form that opens a merge request. | |||
| 415 | templ Compare(l Layout, p ComparePage) { | 453 | templ Compare(l Layout, p ComparePage) { | ||
| 416 | @Shell(l, compareRail(p)) { | 454 | @Shell(l, compareRail(p)) { | ||
| 417 | <header class="show mr-h"> | 455 | <header class="show mr-h"> | ||
A internal/adapters/web/mr_files.go +219 -0
| @@ -0,0 +1,219 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "net/http" | |
| 9 | + | "net/url" | |
| 10 | + | "strconv" | |
| 11 | + | "strings" | |
| 12 | + | ||
| 13 | + | "github.com/a-h/templ" | |
| 14 | + | ||
| 15 | + | "bnema.dev/ermite/internal/domain" | |
| 16 | + | "bnema.dev/ermite/internal/ports" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The diff of a merge request is read a window of files at a time. The page | |
| 20 | + | // reads the files of one window (the first, or the one ?from= names) with | |
| 21 | + | // their hunks and lists the others as placeholders; a placeholder holds the | |
| 22 | + | // address of its window, which the browser fetches when it comes near the | |
| 23 | + | // screen and swaps in (mr.js). The window carries the base and the head the | |
| 24 | + | // page shows: when they moved, the use case refuses (ErrConflict) and the | |
| 25 | + | // placeholder offers to reload instead of mixing two diffs in one page. | |
| 26 | + | ||
| 27 | + | // MRFilesPage is a window of files of a merge request, for /files: the files | |
| 28 | + | // of the window, rendered like in the page, and nothing else. | |
| 29 | + | type MRFilesPage struct { | |
| 30 | + | Files []DiffFile | |
| 31 | + | DiffView | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // windowStart is the first file of the window that holds file i. | |
| 35 | + | func windowStart(i int) int { return i - i%mrWindow } | |
| 36 | + | ||
| 37 | + | // withFrom adds the window of a page to its address: the page of the files | |
| 38 | + | // [from, from+mrWindow) when from is not the first window. | |
| 39 | + | func withFrom(href string, from int) string { | |
| 40 | + | if from <= 0 { | |
| 41 | + | return href | |
| 42 | + | } | |
| 43 | + | sep := "?" | |
| 44 | + | if strings.Contains(href, "?") { | |
| 45 | + | sep = "&" | |
| 46 | + | } | |
| 47 | + | return href + sep + "from=" + itoa(from) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // formFrom reads the window a form was posted from (a hidden field "from"): | |
| 51 | + | // 0 when absent or not a window start, so that a redirect never carries | |
| 52 | + | // anything else. | |
| 53 | + | func formFrom(r *http.Request) int { | |
| 54 | + | s := r.PostFormValue("from") | |
| 55 | + | n, err := strconv.Atoi(s) | |
| 56 | + | if err != nil || n <= 0 || n%mrWindow != 0 || strconv.Itoa(n) != s { | |
| 57 | + | return 0 | |
| 58 | + | } | |
| 59 | + | return n | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // parseFrom reads ?from=: the start of a window, 0 when absent. Anything else | |
| 63 | + | // (not a number, negative, not a window start) is not a page. | |
| 64 | + | func parseFrom(r *http.Request) (int, error) { | |
| 65 | + | s := r.URL.Query().Get("from") | |
| 66 | + | if s == "" { | |
| 67 | + | return 0, nil | |
| 68 | + | } | |
| 69 | + | n, err := strconv.Atoi(s) | |
| 70 | + | if err != nil || n < 0 || n%mrWindow != 0 || strconv.Itoa(n) != s { | |
| 71 | + | return 0, errNotFound | |
| 72 | + | } | |
| 73 | + | return n, nil | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows | |
| 77 | + | // a placeholder that loads their window. src is the URL of /files with the | |
| 78 | + | // base and the head of the diff; self the page, for the links of a page | |
| 79 | + | // without scripts. | |
| 80 | + | func lazyFiles(files []DiffFile, from int, src string) { | |
| 81 | + | for i := range files { | |
| 82 | + | f := &files[i] | |
| 83 | + | if i >= from && i < from+mrWindow { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | f.Lazy = true | |
| 87 | + | f.From = windowStart(i) | |
| 88 | + | f.Src = src + "&from=" + itoa(f.From) | |
| 89 | + | f.Height = lazyHeight(f.Add + f.Del) | |
| 90 | + | f.Open = !f.Bin && f.Add+f.Del > 0 | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // lazyHeight is the size class of the placeholder of a file with n changed | |
| 95 | + | // lines: about the room its diff takes, so that the page does not jump much | |
| 96 | + | // when it comes in. | |
| 97 | + | func lazyHeight(n int) int { | |
| 98 | + | switch { | |
| 99 | + | case n <= 10: | |
| 100 | + | return 1 | |
| 101 | + | case n <= 50: | |
| 102 | + | return 2 | |
| 103 | + | case n <= 200: | |
| 104 | + | return 3 | |
| 105 | + | default: | |
| 106 | + | return 4 | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // filesSrc is the address of the windows of a diff. | |
| 111 | + | func filesSrc(repo string, n int64, since bool, base, head string) string { | |
| 112 | + | q := url.Values{} | |
| 113 | + | if since { | |
| 114 | + | q.Set("since", "1") | |
| 115 | + | } | |
| 116 | + | q.Set("base", base) | |
| 117 | + | q.Set("head", head) | |
| 118 | + | return mrHref(repo, n, "files") + "?" + q.Encode() | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // countNotes sets, on each file, the number of comments on its lines and the | |
| 122 | + | // ids of those comments. | |
| 123 | + | func countNotes(files []DiffFile, comments []domain.Comment) { | |
| 124 | + | at := make(map[string]int, len(files)) | |
| 125 | + | for i, f := range files { | |
| 126 | + | at[f.Key] = i | |
| 127 | + | } | |
| 128 | + | ids := make([][]string, len(files)) | |
| 129 | + | for _, c := range comments { | |
| 130 | + | i, ok := at[c.Path] | |
| 131 | + | if c.Path == "" || !ok { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | files[i].Notes++ | |
| 135 | + | if !c.Resolved { | |
| 136 | + | files[i].OpenNotes++ | |
| 137 | + | } | |
| 138 | + | ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10)) | |
| 139 | + | } | |
| 140 | + | for i := range files { | |
| 141 | + | files[i].Comments = strings.Join(ids[i], " ") | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // splitPath is the last element of a path and the directory before it. | |
| 146 | + | func splitPath(p string) (name, dir string) { | |
| 147 | + | i := strings.LastIndexByte(p, '/') | |
| 148 | + | if i < 0 { | |
| 149 | + | return visibleText(p), "" | |
| 150 | + | } | |
| 151 | + | return visibleText(p[i+1:]), visibleText(p[:i]) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // mrFiles is GET /<repo>/merge-requests/<n>/files?from=&base=&head=[&since=1]: | |
| 155 | + | // the files of a window with their hunks and their comments, for a | |
| 156 | + | // placeholder of the page. A base or a head that is not the one of the diff | |
| 157 | + | // any more answers 409: the page says to reload. | |
| 158 | + | // | |
| 159 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here | |
| 160 | + | func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 161 | + | n, err := mrNumber(r) | |
| 162 | + | if err != nil { | |
| 163 | + | return nil, 0, err | |
| 164 | + | } | |
| 165 | + | q := r.URL.Query() | |
| 166 | + | since := q.Get("since") == "1" | |
| 167 | + | if since && !SignedIn(ctx) { | |
| 168 | + | return nil, 0, errNotFound | |
| 169 | + | } | |
| 170 | + | from, err := parseFrom(r) | |
| 171 | + | if err != nil { | |
| 172 | + | return nil, 0, err | |
| 173 | + | } | |
| 174 | + | base, errB := domain.ParseObjectID(q.Get("base")) | |
| 175 | + | head, errH := domain.ParseObjectID(q.Get("head")) | |
| 176 | + | if errB != nil || errH != nil { | |
| 177 | + | return nil, 0, errNotFound | |
| 178 | + | } | |
| 179 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 180 | + | if err != nil { | |
| 181 | + | return nil, 0, err | |
| 182 | + | } | |
| 183 | + | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | |
| 184 | + | SinceLastRead: since, DiffOnly: true, Base: base, Head: head, | |
| 185 | + | Files: &ports.FileWindow{From: from, To: from + mrWindow}, | |
| 186 | + | }) | |
| 187 | + | if errors.Is(err, domain.ErrConflict) { | |
| 188 | + | return MRFilesMoved(), http.StatusConflict, nil | |
| 189 | + | } | |
| 190 | + | if err != nil { | |
| 191 | + | return nil, 0, err | |
| 192 | + | } | |
| 193 | + | if from >= len(res.Diff.Files) { | |
| 194 | + | return nil, 0, errNotFound | |
| 195 | + | } | |
| 196 | + | m := res.MergeRequest | |
| 197 | + | signed := SignedIn(ctx) | |
| 198 | + | canWrite := signed && m.State == domain.MRStateOpen && !since | |
| 199 | + | p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true} | |
| 200 | + | s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil) | |
| 201 | + | window := p.Files[from:min(from+mrWindow, len(p.Files))] | |
| 202 | + | // The layouts follow the page: it sends ?view= (the layout shown) and | |
| 203 | + | // ?layouts=both when it holds both layouts and switches them itself, so | |
| 204 | + | // that every window of the page renders the same way. | |
| 205 | + | self := mrHref(d.name(), n) | |
| 206 | + | if since { | |
| 207 | + | self = mrHref(d.name(), n, "since") | |
| 208 | + | } | |
| 209 | + | lines, _ := diffLines(window) | |
| 210 | + | v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |
| 211 | + | switch r.URL.Query().Get("layouts") { | |
| 212 | + | case "both": | |
| 213 | + | v.Both = true | |
| 214 | + | case "one": | |
| 215 | + | v.Both = false | |
| 216 | + | } | |
| 217 | + | v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split" | |
| 218 | + | return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil | |
| 219 | + | } | |
| @@ -0,0 +1,219 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/url" | |||
| 10 | + | "strconv" | |||
| 11 | + | "strings" | |||
| 12 | + | ||||
| 13 | + | "github.com/a-h/templ" | |||
| 14 | + | ||||
| 15 | + | "bnema.dev/ermite/internal/domain" | |||
| 16 | + | "bnema.dev/ermite/internal/ports" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // The diff of a merge request is read a window of files at a time. The page | |||
| 20 | + | // reads the files of one window (the first, or the one ?from= names) with | |||
| 21 | + | // their hunks and lists the others as placeholders; a placeholder holds the | |||
| 22 | + | // address of its window, which the browser fetches when it comes near the | |||
| 23 | + | // screen and swaps in (mr.js). The window carries the base and the head the | |||
| 24 | + | // page shows: when they moved, the use case refuses (ErrConflict) and the | |||
| 25 | + | // placeholder offers to reload instead of mixing two diffs in one page. | |||
| 26 | + | ||||
| 27 | + | // MRFilesPage is a window of files of a merge request, for /files: the files | |||
| 28 | + | // of the window, rendered like in the page, and nothing else. | |||
| 29 | + | type MRFilesPage struct { | |||
| 30 | + | Files []DiffFile | |||
| 31 | + | DiffView | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // windowStart is the first file of the window that holds file i. | |||
| 35 | + | func windowStart(i int) int { return i - i%mrWindow } | |||
| 36 | + | ||||
| 37 | + | // withFrom adds the window of a page to its address: the page of the files | |||
| 38 | + | // [from, from+mrWindow) when from is not the first window. | |||
| 39 | + | func withFrom(href string, from int) string { | |||
| 40 | + | if from <= 0 { | |||
| 41 | + | return href | |||
| 42 | + | } | |||
| 43 | + | sep := "?" | |||
| 44 | + | if strings.Contains(href, "?") { | |||
| 45 | + | sep = "&" | |||
| 46 | + | } | |||
| 47 | + | return href + sep + "from=" + itoa(from) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // formFrom reads the window a form was posted from (a hidden field "from"): | |||
| 51 | + | // 0 when absent or not a window start, so that a redirect never carries | |||
| 52 | + | // anything else. | |||
| 53 | + | func formFrom(r *http.Request) int { | |||
| 54 | + | s := r.PostFormValue("from") | |||
| 55 | + | n, err := strconv.Atoi(s) | |||
| 56 | + | if err != nil || n <= 0 || n%mrWindow != 0 || strconv.Itoa(n) != s { | |||
| 57 | + | return 0 | |||
| 58 | + | } | |||
| 59 | + | return n | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // parseFrom reads ?from=: the start of a window, 0 when absent. Anything else | |||
| 63 | + | // (not a number, negative, not a window start) is not a page. | |||
| 64 | + | func parseFrom(r *http.Request) (int, error) { | |||
| 65 | + | s := r.URL.Query().Get("from") | |||
| 66 | + | if s == "" { | |||
| 67 | + | return 0, nil | |||
| 68 | + | } | |||
| 69 | + | n, err := strconv.Atoi(s) | |||
| 70 | + | if err != nil || n < 0 || n%mrWindow != 0 || strconv.Itoa(n) != s { | |||
| 71 | + | return 0, errNotFound | |||
| 72 | + | } | |||
| 73 | + | return n, nil | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // lazyFiles marks the files outside [from, from+mrWindow) lazy: the page shows | |||
| 77 | + | // a placeholder that loads their window. src is the URL of /files with the | |||
| 78 | + | // base and the head of the diff; self the page, for the links of a page | |||
| 79 | + | // without scripts. | |||
| 80 | + | func lazyFiles(files []DiffFile, from int, src string) { | |||
| 81 | + | for i := range files { | |||
| 82 | + | f := &files[i] | |||
| 83 | + | if i >= from && i < from+mrWindow { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | f.Lazy = true | |||
| 87 | + | f.From = windowStart(i) | |||
| 88 | + | f.Src = src + "&from=" + itoa(f.From) | |||
| 89 | + | f.Height = lazyHeight(f.Add + f.Del) | |||
| 90 | + | f.Open = !f.Bin && f.Add+f.Del > 0 | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // lazyHeight is the size class of the placeholder of a file with n changed | |||
| 95 | + | // lines: about the room its diff takes, so that the page does not jump much | |||
| 96 | + | // when it comes in. | |||
| 97 | + | func lazyHeight(n int) int { | |||
| 98 | + | switch { | |||
| 99 | + | case n <= 10: | |||
| 100 | + | return 1 | |||
| 101 | + | case n <= 50: | |||
| 102 | + | return 2 | |||
| 103 | + | case n <= 200: | |||
| 104 | + | return 3 | |||
| 105 | + | default: | |||
| 106 | + | return 4 | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // filesSrc is the address of the windows of a diff. | |||
| 111 | + | func filesSrc(repo string, n int64, since bool, base, head string) string { | |||
| 112 | + | q := url.Values{} | |||
| 113 | + | if since { | |||
| 114 | + | q.Set("since", "1") | |||
| 115 | + | } | |||
| 116 | + | q.Set("base", base) | |||
| 117 | + | q.Set("head", head) | |||
| 118 | + | return mrHref(repo, n, "files") + "?" + q.Encode() | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // countNotes sets, on each file, the number of comments on its lines and the | |||
| 122 | + | // ids of those comments. | |||
| 123 | + | func countNotes(files []DiffFile, comments []domain.Comment) { | |||
| 124 | + | at := make(map[string]int, len(files)) | |||
| 125 | + | for i, f := range files { | |||
| 126 | + | at[f.Key] = i | |||
| 127 | + | } | |||
| 128 | + | ids := make([][]string, len(files)) | |||
| 129 | + | for _, c := range comments { | |||
| 130 | + | i, ok := at[c.Path] | |||
| 131 | + | if c.Path == "" || !ok { | |||
| 132 | + | continue | |||
| 133 | + | } | |||
| 134 | + | files[i].Notes++ | |||
| 135 | + | if !c.Resolved { | |||
| 136 | + | files[i].OpenNotes++ | |||
| 137 | + | } | |||
| 138 | + | ids[i] = append(ids[i], "c"+strconv.FormatInt(c.ID, 10)) | |||
| 139 | + | } | |||
| 140 | + | for i := range files { | |||
| 141 | + | files[i].Comments = strings.Join(ids[i], " ") | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | // splitPath is the last element of a path and the directory before it. | |||
| 146 | + | func splitPath(p string) (name, dir string) { | |||
| 147 | + | i := strings.LastIndexByte(p, '/') | |||
| 148 | + | if i < 0 { | |||
| 149 | + | return visibleText(p), "" | |||
| 150 | + | } | |||
| 151 | + | return visibleText(p[i+1:]), visibleText(p[:i]) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | // mrFiles is GET /<repo>/merge-requests/<n>/files?from=&base=&head=[&since=1]: | |||
| 155 | + | // the files of a window with their hunks and their comments, for a | |||
| 156 | + | // placeholder of the page. A base or a head that is not the one of the diff | |||
| 157 | + | // any more answers 409: the page says to reload. | |||
| 158 | + | // | |||
| 159 | + | //hexcheck:ignore suspicious-business-logic-in-adapter builds a window of the merge request page from the use case results; no rule is decided here | |||
| 160 | + | func (s *Server) mrFiles(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 161 | + | n, err := mrNumber(r) | |||
| 162 | + | if err != nil { | |||
| 163 | + | return nil, 0, err | |||
| 164 | + | } | |||
| 165 | + | q := r.URL.Query() | |||
| 166 | + | since := q.Get("since") == "1" | |||
| 167 | + | if since && !SignedIn(ctx) { | |||
| 168 | + | return nil, 0, errNotFound | |||
| 169 | + | } | |||
| 170 | + | from, err := parseFrom(r) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return nil, 0, err | |||
| 173 | + | } | |||
| 174 | + | base, errB := domain.ParseObjectID(q.Get("base")) | |||
| 175 | + | head, errH := domain.ParseObjectID(q.Get("head")) | |||
| 176 | + | if errB != nil || errH != nil { | |||
| 177 | + | return nil, 0, errNotFound | |||
| 178 | + | } | |||
| 179 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 180 | + | if err != nil { | |||
| 181 | + | return nil, 0, err | |||
| 182 | + | } | |||
| 183 | + | res, err := s.cfg.MergeRequests.Get(ctx, principal(ctx), d.repo.Name, n, ports.GetMergeRequestOptions{ | |||
| 184 | + | SinceLastRead: since, DiffOnly: true, Base: base, Head: head, | |||
| 185 | + | Files: &ports.FileWindow{From: from, To: from + mrWindow}, | |||
| 186 | + | }) | |||
| 187 | + | if errors.Is(err, domain.ErrConflict) { | |||
| 188 | + | return MRFilesMoved(), http.StatusConflict, nil | |||
| 189 | + | } | |||
| 190 | + | if err != nil { | |||
| 191 | + | return nil, 0, err | |||
| 192 | + | } | |||
| 193 | + | if from >= len(res.Diff.Files) { | |||
| 194 | + | return nil, 0, errNotFound | |||
| 195 | + | } | |||
| 196 | + | m := res.MergeRequest | |||
| 197 | + | signed := SignedIn(ctx) | |||
| 198 | + | canWrite := signed && m.State == domain.MRStateOpen && !since | |||
| 199 | + | p := MRPage{Files: diffFiles(res.Diff.Files), LooseInFiles: true} | |||
| 200 | + | s.placeComments(&p, d.name(), n, res.Comments, m.HeadID.String(), !since, canWrite, nil) | |||
| 201 | + | window := p.Files[from:min(from+mrWindow, len(p.Files))] | |||
| 202 | + | // The layouts follow the page: it sends ?view= (the layout shown) and | |||
| 203 | + | // ?layouts=both when it holds both layouts and switches them itself, so | |||
| 204 | + | // that every window of the page renders the same way. | |||
| 205 | + | self := mrHref(d.name(), n) | |||
| 206 | + | if since { | |||
| 207 | + | self = mrHref(d.name(), n, "since") | |||
| 208 | + | } | |||
| 209 | + | lines, _ := diffLines(window) | |||
| 210 | + | v := newDiffView(r, lines, withFrom(self+"?view=unified", from), withFrom(self+"?view=split", from)) | |||
| 211 | + | switch r.URL.Query().Get("layouts") { | |||
| 212 | + | case "both": | |||
| 213 | + | v.Both = true | |||
| 214 | + | case "one": | |||
| 215 | + | v.Both = false | |||
| 216 | + | } | |||
| 217 | + | v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split" | |||
| 218 | + | return MRFiles(MRFilesPage{Files: window, DiffView: v}), http.StatusOK, nil | |||
| 219 | + | } | |||
M internal/adapters/web/mr_post.go +2 -0
| @@ -181,6 +181,7 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string | |||
| 181 | 181 | if err != nil { | |
| 182 | 182 | return "", back, err | |
| 183 | 183 | } | |
| 184 | + | back = withFrom(back, formFrom(r)) | |
| 184 | 185 | c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params) | |
| 185 | 186 | if err != nil { | |
| 186 | 187 | return "", back, err | |
| @@ -209,6 +210,7 @@ func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string | |||
| 209 | 210 | // An id this server never issued is 0: the use case answers not found, | |
| 210 | 211 | // after the repository and the scope were checked, as for any unknown | |
| 211 | 212 | // comment. | |
| 213 | + | back = withFrom(back, formFrom(r)) | |
| 212 | 214 | c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved) | |
| 213 | 215 | if err != nil { | |
| 214 | 216 | return "", back, err | |
| @@ -181,6 +181,7 @@ func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string | |||||
| 181 | if err != nil { | 181 | if err != nil { | ||
| 182 | return "", back, err | 182 | return "", back, err | ||
| 183 | } | 183 | } | ||
| 184 | + | back = withFrom(back, formFrom(r)) | |||
| 184 | c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params) | 185 | c, err := s.cfg.MergeRequests.AddComment(ctx, principal(ctx), name, n, params) | ||
| 185 | if err != nil { | 186 | if err != nil { | ||
| 186 | return "", back, err | 187 | return "", back, err | ||
| @@ -209,6 +210,7 @@ func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string | |||||
| 209 | // An id this server never issued is 0: the use case answers not found, | 210 | // An id this server never issued is 0: the use case answers not found, | ||
| 210 | // after the repository and the scope were checked, as for any unknown | 211 | // after the repository and the scope were checked, as for any unknown | ||
| 211 | // comment. | 212 | // comment. | ||
| 213 | + | back = withFrom(back, formFrom(r)) | |||
| 212 | c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved) | 214 | c, err := s.cfg.MergeRequests.ResolveComment(ctx, principal(ctx), name, n, commentID(r.PathValue("id")), resolved) | ||
| 213 | if err != nil { | 215 | if err != nil { | ||
| 214 | return "", back, err | 216 | return "", back, err | ||
M internal/adapters/web/mr_templ.go +767 -574 generated file: collapsed
M internal/adapters/web/overlays.templ +8 -2
| @@ -4,8 +4,9 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import "strings" | |
| 6 | 6 | ||
| 7 | - | // OverlayHelp is the keyboard map panel. | |
| 8 | - | templ OverlayHelp() { | |
| 7 | + | // OverlayHelp is the keyboard map panel; the keys of the merge request page | |
| 8 | + | // show on that page only. | |
| 9 | + | templ OverlayHelp(mr bool) { | |
| 9 | 10 | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | |
| 10 | 11 | <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div> | |
| 11 | 12 | <dl class="keys"> | |
| @@ -21,6 +22,11 @@ templ OverlayHelp() { | |||
| 21 | 22 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | |
| 22 | 23 | <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div> | |
| 23 | 24 | <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div> | |
| 25 | + | if mr { | |
| 26 | + | <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div> | |
| 27 | + | <div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div> | |
| 28 | + | <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div> | |
| 29 | + | } | |
| 24 | 30 | <div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div> | |
| 25 | 31 | </dl> | |
| 26 | 32 | </dialog> | |
| @@ -4,8 +4,9 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import "strings" | 5 | import "strings" | ||
| 6 | 6 | ||||
| 7 | - | // OverlayHelp is the keyboard map panel. | 7 | + | // OverlayHelp is the keyboard map panel; the keys of the merge request page |
| 8 | - | templ OverlayHelp() { | 8 | + | // show on that page only. |
| 9 | + | templ OverlayHelp(mr bool) { | |||
| 9 | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | 10 | <dialog id="panel-help" class="panel modal" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())" hx-on:click="event.target === this && this.close()"> | ||
| 10 | <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div> | 11 | <div class="panel-head"><h2>Keyboard shortcuts</h2><button type="button" class="panel-x" aria-label="Close" hx-on:click="closest('dialog').close()"><kbd>esc</kbd> close</button></div> | ||
| 11 | <dl class="keys"> | 12 | <dl class="keys"> | ||
| @@ -21,6 +22,11 @@ templ OverlayHelp() { | |||||
| 21 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | 22 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | ||
| 22 | <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div> | 23 | <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div> | ||
| 23 | <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div> | 24 | <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div> | ||
| 25 | + | if mr { | |||
| 26 | + | <div><dt><kbd>g f</kbd></dt><dd>merge request: the list of files (j k move, enter opens, esc back)</dd></div> | |||
| 27 | + | <div><dt><kbd>M</kbd></dt><dd>merge request: the merge menu; there 1–3 pick the mode, D deletes the branch, enter merges</dd></div> | |||
| 28 | + | <div><dt><kbd>R</kbd></dt><dd>merge request: mark the head read</dd></div> | |||
| 29 | + | } | |||
| 24 | <div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div> | 30 | <div><dt><kbd>esc</kbd></dt><dd>close a dialog, leave a field</dd></div> | ||
| 25 | </dl> | 31 | </dl> | ||
| 26 | </dialog> | 32 | </dialog> | ||
M internal/adapters/web/overlays_templ.go +45 -34 generated file: collapsed
M internal/adapters/web/shell.templ +4 -1
| @@ -29,6 +29,9 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 29 | 29 | <script src={ l.Assets.Files } defer></script> | |
| 30 | 30 | <script src={ l.Assets.Passkey } defer></script> | |
| 31 | 31 | <script src={ l.Assets.Log } defer></script> | |
| 32 | + | if l.MR { | |
| 33 | + | <script src={ l.Assets.MR } defer></script> | |
| 34 | + | } | |
| 32 | 35 | </head> | |
| 33 | 36 | <body | |
| 34 | 37 | data-ref={ l.RefPath } | |
| @@ -57,7 +60,7 @@ templ Shell(l Layout, rail templ.Component) { | |||
| 57 | 60 | </div> | |
| 58 | 61 | @Footer(l) | |
| 59 | 62 | @Hint(l) | |
| 60 | - | @OverlayHelp() | |
| 63 | + | @OverlayHelp(l.MR) | |
| 61 | 64 | @OverlayRefs(l) | |
| 62 | 65 | @OverlayCmd(l.Cmd) | |
| 63 | 66 | @Keys() | |
| @@ -29,6 +29,9 @@ templ Shell(l Layout, rail templ.Component) { | |||||
| 29 | <script src={ l.Assets.Files } defer></script> | 29 | <script src={ l.Assets.Files } defer></script> | ||
| 30 | <script src={ l.Assets.Passkey } defer></script> | 30 | <script src={ l.Assets.Passkey } defer></script> | ||
| 31 | <script src={ l.Assets.Log } defer></script> | 31 | <script src={ l.Assets.Log } defer></script> | ||
| 32 | + | if l.MR { | |||
| 33 | + | <script src={ l.Assets.MR } defer></script> | |||
| 34 | + | } | |||
| 32 | </head> | 35 | </head> | ||
| 33 | <body | 36 | <body | ||
| 34 | data-ref={ l.RefPath } | 37 | data-ref={ l.RefPath } | ||
| @@ -57,7 +60,7 @@ templ Shell(l Layout, rail templ.Component) { | |||||
| 57 | </div> | 60 | </div> | ||
| 58 | @Footer(l) | 61 | @Footer(l) | ||
| 59 | @Hint(l) | 62 | @Hint(l) | ||
| 60 | - | @OverlayHelp() | 63 | + | @OverlayHelp(l.MR) |
| 61 | @OverlayRefs(l) | 64 | @OverlayRefs(l) | ||
| 62 | @OverlayCmd(l.Cmd) | 65 | @OverlayCmd(l.Cmd) | ||
| 63 | @Keys() | 66 | @Keys() | ||
M internal/adapters/web/shell_templ.go +185 -162 generated file: collapsed
M internal/adapters/web/static/css/50-code.css +37 -11
| @@ -46,10 +46,18 @@ | |||
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | /* one file of a diff */ | |
| 49 | + | .dfile { --lnw: 4ch; } | |
| 50 | + | .lnw-2 { --lnw: 2ch; } .lnw-3 { --lnw: 3ch; } .lnw-4 { --lnw: 4ch; } .lnw-5 { --lnw: 5ch; } .lnw-6 { --lnw: 6ch; } .lnw-7 { --lnw: 7ch; } | |
| 49 | 51 | .dfile { | |
| 50 | - | margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + 56px); | |
| 52 | + | margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px) + 8px); | |
| 53 | + | /* a file far from the screen is neither laid out nor painted: a diff of | |
| 54 | + | hundreds of files scrolls and switches layout like a short one. The | |
| 55 | + | browser remembers the last size it rendered (auto). */ | |
| 56 | + | content-visibility: auto; contain-intrinsic-size: auto 480px; | |
| 51 | 57 | & > summary { | |
| 52 | - | position: sticky; top: calc(var(--hdr-h, 0px) + 48px); z-index: 4; | |
| 58 | + | /* under the diff tools, whose height --bar-h is (two lines when they | |
| 59 | + | wrap: the merge request bar on a narrow screen) */ | |
| 60 | + | position: sticky; top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px)); z-index: 4; | |
| 53 | 61 | background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3); | |
| 54 | 62 | font-family: var(--mono); font-size: var(--fs-s); | |
| 55 | 63 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none; | |
| @@ -66,7 +74,9 @@ | |||
| 66 | 74 | width: max-content; min-width: 100%; font-family: var(--mono); | |
| 67 | 75 | font-size: var(--fs-code); line-height: var(--lh-code); | |
| 68 | 76 | & td { padding: 0; vertical-align: top; } | |
| 69 | - | & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | |
| 77 | + | /* the gutter is as wide as the largest line number of the file (lnw-N on | |
| 78 | + | .dfile), plus 1ch on each side */ | |
| 79 | + | & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | |
| 70 | 80 | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | |
| 71 | 81 | & .tx { white-space: pre; } | |
| 72 | 82 | & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; } | |
| @@ -83,22 +93,38 @@ | |||
| 83 | 93 | the hunk header, which spans all columns); long lines wrap in their half */ | |
| 84 | 94 | .spl { | |
| 85 | 95 | width: 100%; table-layout: fixed; | |
| 86 | - | & .c-ln { width: 7ch; } & .c-m { width: 2.5ch; } | |
| 96 | + | & .c-ln { width: calc(var(--lnw) + 2ch); } & .c-m { width: 2.5ch; } | |
| 87 | 97 | & .tx, & .hunk td { white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 88 | 98 | & td.ln:nth-child(4) { border-left: 1px solid var(--border); } | |
| 89 | 99 | } | |
| 90 | 100 | #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; } | |
| 91 | 101 | ||
| 92 | - | /* diff table of contents in the rail */ | |
| 102 | + | /* diff table of contents in the rail: one line a file, the name first and | |
| 103 | + | its directory dimmed after it (cut from its start when long), the | |
| 104 | + | comments, the counts */ | |
| 93 | 105 | .files { | |
| 94 | - | font-size: var(--fs-s); | |
| 95 | - | & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: var(--s2); padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; text-decoration: none; font-family: var(--mono); } | |
| 106 | + | font-size: var(--fs-s); position: relative; /* the offsets of its links count from it (ermite.js) */ | |
| 107 | + | & li[hidden] { display: none; } | |
| 108 | + | & a { | |
| 109 | + | display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: baseline; | |
| 110 | + | height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2)); | |
| 111 | + | border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink); | |
| 112 | + | } | |
| 96 | 113 | & a:hover { background: var(--bg-raised); } | |
| 97 | 114 | & a:hover .p { text-decoration: underline; } | |
| 98 | - | & a[aria-current="true"] { background: var(--row); font-weight: bold; } | |
| 99 | - | & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } | |
| 100 | - | & .n { color: var(--ink-dim); font-size: var(--fs-s); } | |
| 101 | - | & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; } | |
| 115 | + | & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); } | |
| 116 | + | & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); } | |
| 117 | + | & .st { color: var(--ink-mute); font-weight: bold; } | |
| 118 | + | & .st[data-st="A"] { color: var(--teal); } | |
| 119 | + | & .st[data-st="D"] { color: var(--red); } | |
| 120 | + | & .nm { overflow: hidden; white-space: nowrap; display: flex; min-width: 0; } | |
| 121 | + | & .nm .p { flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } | |
| 122 | + | /* cut from the start (rtl), the path itself kept in its order (bdi) */ | |
| 123 | + | & .nm .d { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; color: var(--ink-mute); padding-left: 1ch; } | |
| 124 | + | & .nm .d bdi { direction: ltr; } | |
| 125 | + | & .cn { font: 600 var(--fs-xs) / 16px var(--font); padding: 0 6px; border-radius: 999px; background: var(--ink-hi); color: var(--on-ink); align-self: center; } | |
| 126 | + | & .cn.done { background: var(--bg-strong); color: var(--ink-dim); } | |
| 127 | + | & .n { color: var(--ink-dim); font-size: var(--fs-xs); white-space: nowrap; } | |
| 102 | 128 | } | |
| 103 | 129 | ||
| 104 | 130 | @media (max-width: 700px) { | |
| @@ -46,10 +46,18 @@ | |||||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | /* one file of a diff */ | 48 | /* one file of a diff */ | ||
| 49 | + | .dfile { --lnw: 4ch; } | |||
| 50 | + | .lnw-2 { --lnw: 2ch; } .lnw-3 { --lnw: 3ch; } .lnw-4 { --lnw: 4ch; } .lnw-5 { --lnw: 5ch; } .lnw-6 { --lnw: 6ch; } .lnw-7 { --lnw: 7ch; } | |||
| 49 | .dfile { | 51 | .dfile { | ||
| 50 | - | margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + 56px); | 52 | + | margin-top: var(--s3); scroll-margin-top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px) + 8px); |
| 53 | + | /* a file far from the screen is neither laid out nor painted: a diff of | |||
| 54 | + | hundreds of files scrolls and switches layout like a short one. The | |||
| 55 | + | browser remembers the last size it rendered (auto). */ | |||
| 56 | + | content-visibility: auto; contain-intrinsic-size: auto 480px; | |||
| 51 | & > summary { | 57 | & > summary { | ||
| 52 | - | position: sticky; top: calc(var(--hdr-h, 0px) + 48px); z-index: 4; | 58 | + | /* under the diff tools, whose height --bar-h is (two lines when they |
| 59 | + | wrap: the merge request bar on a narrow screen) */ | |||
| 60 | + | position: sticky; top: calc(var(--hdr-h, 0px) + var(--bar-h, 48px)); z-index: 4; | |||
| 53 | background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3); | 61 | background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--r) var(--r) 0 0; padding: var(--s2) var(--s3); | ||
| 54 | font-family: var(--mono); font-size: var(--fs-s); | 62 | font-family: var(--mono); font-size: var(--fs-s); | ||
| 55 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none; | 63 | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; list-style: none; | ||
| @@ -66,7 +74,9 @@ | |||||
| 66 | width: max-content; min-width: 100%; font-family: var(--mono); | 74 | width: max-content; min-width: 100%; font-family: var(--mono); | ||
| 67 | font-size: var(--fs-code); line-height: var(--lh-code); | 75 | font-size: var(--fs-code); line-height: var(--lh-code); | ||
| 68 | & td { padding: 0; vertical-align: top; } | 76 | & td { padding: 0; vertical-align: top; } | ||
| 69 | - | & .ln { width: 6ch; min-width: 6ch; padding-right: 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | 77 | + | /* the gutter is as wide as the largest line number of the file (lnw-N on |
| 78 | + | .dfile), plus 1ch on each side */ | |||
| 79 | + | & .ln { width: calc(var(--lnw) + 2ch); min-width: calc(var(--lnw) + 2ch); padding: 0 1ch; text-align: right; color: var(--ink-mute); user-select: none; } | |||
| 70 | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | 80 | & .m { width: 2ch; text-align: center; user-select: none; color: var(--ink-dim); } | ||
| 71 | & .tx { white-space: pre; } | 81 | & .tx { white-space: pre; } | ||
| 72 | & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; } | 82 | & .hunk td { color: var(--ink-mute); background: var(--paper-alt); padding: 0 1ch; } | ||
| @@ -83,22 +93,38 @@ | |||||
| 83 | the hunk header, which spans all columns); long lines wrap in their half */ | 93 | the hunk header, which spans all columns); long lines wrap in their half */ | ||
| 84 | .spl { | 94 | .spl { | ||
| 85 | width: 100%; table-layout: fixed; | 95 | width: 100%; table-layout: fixed; | ||
| 86 | - | & .c-ln { width: 7ch; } & .c-m { width: 2.5ch; } | 96 | + | & .c-ln { width: calc(var(--lnw) + 2ch); } & .c-m { width: 2.5ch; } |
| 87 | & .tx, & .hunk td { white-space: pre-wrap; overflow-wrap: anywhere; } | 97 | & .tx, & .hunk td { white-space: pre-wrap; overflow-wrap: anywhere; } | ||
| 88 | & td.ln:nth-child(4) { border-left: 1px solid var(--border); } | 98 | & td.ln:nth-child(4) { border-left: 1px solid var(--border); } | ||
| 89 | } | 99 | } | ||
| 90 | #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; } | 100 | #diffs[data-diff="unified"] .spl, #diffs[data-diff="split"] .uni { display: none; } | ||
| 91 | 101 | ||||
| 92 | - | /* diff table of contents in the rail */ | 102 | + | /* diff table of contents in the rail: one line a file, the name first and |
| 103 | + | its directory dimmed after it (cut from its start when long), the | |||
| 104 | + | comments, the counts */ | |||
| 93 | .files { | 105 | .files { | ||
| 94 | - | font-size: var(--fs-s); | 106 | + | font-size: var(--fs-s); position: relative; /* the offsets of its links count from it (ermite.js) */ |
| 95 | - | & a { display: grid; grid-template-columns: 2ch minmax(0, 1fr) auto; column-gap: var(--s2); padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; text-decoration: none; font-family: var(--mono); } | 107 | + | & li[hidden] { display: none; } |
| 108 | + | & a { | |||
| 109 | + | display: grid; grid-template-columns: 1.5ch minmax(0, 1fr) auto auto; column-gap: var(--s2); align-items: baseline; | |||
| 110 | + | height: 26px; line-height: 26px; padding: 0 var(--s2); margin: 0 calc(-1 * var(--s2)); | |||
| 111 | + | border-radius: 4px; border-left: 2px solid transparent; text-decoration: none; font-family: var(--mono); color: var(--ink); | |||
| 112 | + | } | |||
| 96 | & a:hover { background: var(--bg-raised); } | 113 | & a:hover { background: var(--bg-raised); } | ||
| 97 | & a:hover .p { text-decoration: underline; } | 114 | & a:hover .p { text-decoration: underline; } | ||
| 98 | - | & a[aria-current="true"] { background: var(--row); font-weight: bold; } | 115 | + | & a[aria-current="true"] { background: var(--row); border-left-color: var(--ink-hi); } |
| 99 | - | & .p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } | 116 | + | & a[aria-current="true"] .p { font-weight: bold; color: var(--ink-hi); } |
| 100 | - | & .n { color: var(--ink-dim); font-size: var(--fs-s); } | 117 | + | & .st { color: var(--ink-mute); font-weight: bold; } |
| 101 | - | & .b { grid-column: 2 / -1; color: var(--ink-mute); font-size: var(--fs-s); letter-spacing: .1ch; line-height: 1; padding-bottom: 4px; } | 118 | + | & .st[data-st="A"] { color: var(--teal); } |
| 119 | + | & .st[data-st="D"] { color: var(--red); } | |||
| 120 | + | & .nm { overflow: hidden; white-space: nowrap; display: flex; min-width: 0; } | |||
| 121 | + | & .nm .p { flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } | |||
| 122 | + | /* cut from the start (rtl), the path itself kept in its order (bdi) */ | |||
| 123 | + | & .nm .d { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; direction: rtl; text-align: left; color: var(--ink-mute); padding-left: 1ch; } | |||
| 124 | + | & .nm .d bdi { direction: ltr; } | |||
| 125 | + | & .cn { font: 600 var(--fs-xs) / 16px var(--font); padding: 0 6px; border-radius: 999px; background: var(--ink-hi); color: var(--on-ink); align-self: center; } | |||
| 126 | + | & .cn.done { background: var(--bg-strong); color: var(--ink-dim); } | |||
| 127 | + | & .n { color: var(--ink-dim); font-size: var(--fs-xs); white-space: nowrap; } | |||
| 102 | } | 128 | } | ||
| 103 | 129 | ||||
| 104 | @media (max-width: 700px) { | 130 | @media (max-width: 700px) { | ||
M internal/adapters/web/static/css/60-mr.css +91 -23
| @@ -18,41 +18,109 @@ | |||
| 18 | 18 | & .mute { font-weight: 400; font-family: var(--mono); } | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | /* merge block: first thing in the rail */ | |
| 22 | - | .decide { | |
| 23 | - | border-color: var(--border-strong) !important; | |
| 24 | - | & select { | |
| 25 | - | display: block; width: 100%; height: 36px; padding: 0 var(--s3); | |
| 26 | - | font: inherit; color: inherit; background: var(--bg-inset); | |
| 27 | - | border: 1px solid var(--border-strong); border-radius: var(--r-s); | |
| 21 | + | /* the bar above the diff of a merge request: the diff tools, the checks and | |
| 22 | + | the menus of the owner, on one line that follows the review */ | |
| 23 | + | /* on a narrow screen the bar takes two lines: the diff tools, then the | |
| 24 | + | checks and the menus; the file headers stick under both */ | |
| 25 | + | @media (max-width: 860px) { | |
| 26 | + | body:has(.mr-bar) { --bar-h: 96px; } | |
| 27 | + | .mr-bar .grow { flex-basis: 100%; height: 0; } | |
| 28 | + | } | |
| 29 | + | .mr-bar { | |
| 30 | + | gap: var(--s2); | |
| 31 | + | & .mr-ck { margin-left: 0; height: 32px; padding: 0 var(--s2); border-radius: var(--r-s); text-decoration: none; color: var(--ink-dim); font-size: var(--fs-s); } | |
| 32 | + | & .mr-ck:hover { background: var(--bg-raised); color: var(--ink-hi); } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /* a menu of the bar: a <details> whose panel opens under its button */ | |
| 36 | + | .menu { | |
| 37 | + | position: relative; | |
| 38 | + | & > summary { list-style: none; } | |
| 39 | + | & > summary::-webkit-details-marker { display: none; } | |
| 40 | + | & > summary.primary::after { content: ""; width: 0; height: 0; margin-left: 2px; border: 4px solid transparent; border-top-color: currentColor; transform: translateY(2px); } | |
| 41 | + | & .menu-pop { | |
| 42 | + | position: absolute; right: 0; top: calc(100% + var(--s1)); z-index: 6; width: 320px; max-width: calc(100vw - 2 * var(--pad)); | |
| 43 | + | display: grid; gap: var(--s3); padding: var(--s3) var(--s4); | |
| 44 | + | background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: 0 8px 24px rgb(0 0 0 / .18); | |
| 45 | + | font-size: var(--fs-s); | |
| 28 | 46 | } | |
| 29 | - | & .d-check { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s2); color: var(--ink-dim); font-size: var(--fs-s); cursor: pointer; } | |
| 30 | - | & .d-check input { accent-color: var(--ink-hi); margin: 0; } | |
| 31 | - | & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; } | |
| 32 | - | & .d-btns .primary { flex: 1; justify-content: center; } | |
| 33 | - | /* the terminal lines fold away: a reviewer who merges in the page does | |
| 34 | - | not need them in sight */ | |
| 35 | - | & .d-draft { font-size: var(--fs-s); color: var(--ink-dim); } | |
| 36 | - | & .d-or { font-size: var(--fs-s); } | |
| 37 | - | & .d-or > summary { cursor: pointer; list-style: none; } | |
| 47 | + | & .kv { margin: 0; } | |
| 48 | + | } | |
| 49 | + | .mmenu { | |
| 50 | + | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } | |
| 51 | + | & .d-modes label, & .d-check { | |
| 52 | + | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); | |
| 53 | + | border-radius: var(--r-s); color: var(--ink); cursor: pointer; | |
| 54 | + | } | |
| 55 | + | & .d-modes label:hover, & .d-check:hover { background: var(--bg-raised); } | |
| 56 | + | & .d-modes label:has(:checked) { background: var(--bg-raised); color: var(--ink-hi); font-weight: 600; } | |
| 57 | + | & .d-modes kbd, & .d-check kbd { margin-left: auto; } | |
| 58 | + | & input { accent-color: var(--ink-hi); margin: 0; } | |
| 59 | + | & .d-check { color: var(--ink-dim); } | |
| 60 | + | & .d-btns { display: flex; margin-top: var(--s1); } | |
| 61 | + | & .d-btns .btn { flex: 1; justify-content: center; } | |
| 62 | + | & .d-draft { color: var(--ink-dim); } | |
| 63 | + | } | |
| 64 | + | .omenu { | |
| 65 | + | & > summary { font-size: 18px; line-height: 1; } | |
| 66 | + | & .menu-pop { width: 280px; } | |
| 67 | + | & form .btn { width: 100%; justify-content: flex-start; } | |
| 68 | + | & .btn kbd { margin-left: auto; } | |
| 69 | + | & .d-or > summary { cursor: pointer; list-style: none; color: var(--ink-mute); } | |
| 38 | 70 | & .d-or > summary::-webkit-details-marker { display: none; } | |
| 39 | 71 | & .d-or > summary::before { content: "▸ "; } | |
| 40 | 72 | & .d-or[open] > summary::before { content: "▾ "; } | |
| 41 | 73 | & .d-or > summary:hover { color: var(--ink-hi); } | |
| 42 | 74 | & .d-or[open] > .clone { margin-top: var(--s2); } | |
| 43 | - | & .clone { margin-top: 0; } | |
| 44 | 75 | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 45 | 76 | } | |
| 46 | 77 | ||
| 47 | - | /* the rail of a merge request follows the review: the merge block and the | |
| 48 | - | checks stay in reach at the end of a long diff. It scrolls on its own | |
| 49 | - | when it is taller than the window. Only on a two-column layout. */ | |
| 78 | + | /* the rail of a merge request: the list of files on top, which follows the | |
| 79 | + | review and scrolls on its own; checks, commits and settings scroll with | |
| 80 | + | the page. Only on a two-column layout. */ | |
| 50 | 81 | @media (min-width: 1081px) { | |
| 51 | - | .work.has-rail:has(#mr-checks) .rail { | |
| 52 | - | position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4)); | |
| 82 | + | /* the rail runs the height of the content, so that its first block can | |
| 83 | + | stick all along the diff */ | |
| 84 | + | .work.has-rail:has(.r-files) { align-items: stretch; } | |
| 85 | + | .rail:has(.r-files) { align-self: stretch; } | |
| 86 | + | .rail .r-files { | |
| 87 | + | position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4)); z-index: 3; | |
| 88 | + | display: flex; flex-direction: column; | |
| 53 | 89 | max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2); | |
| 54 | - | overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; | |
| 55 | 90 | } | |
| 91 | + | .rail .r-files > .files { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; min-height: 0; } | |
| 92 | + | } | |
| 93 | + | /* on one column the rail goes under the diff: the list of files keeps its | |
| 94 | + | own scroll, under a height */ | |
| 95 | + | @media (max-width: 1080px) { | |
| 96 | + | .rail .r-files > .files { max-height: 50dvh; overflow-y: auto; overscroll-behavior: contain; } | |
| 97 | + | } | |
| 98 | + | .r-files { | |
| 99 | + | & > h2 kbd { margin-left: auto; } | |
| 100 | + | & > .ffilter { margin: var(--s2) var(--s3) 0; } | |
| 101 | + | & > .ffilter input { | |
| 102 | + | width: 100%; height: 30px; padding: 0 var(--s2); font: inherit; font-size: var(--fs-s); color: inherit; | |
| 103 | + | background: var(--bg-inset); border: 1px solid var(--border-strong); border-radius: var(--r-s); | |
| 104 | + | } | |
| 105 | + | & > .files { margin: var(--s2) 0 !important; padding: 0 var(--s2); } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | /* a file of the diff whose window is not loaded yet: about its room */ | |
| 109 | + | .dlazy { | |
| 110 | + | display: flex; align-items: center; justify-content: center; | |
| 111 | + | background: repeating-linear-gradient(var(--bg-surface) 0 var(--lh-code), var(--paper-alt) var(--lh-code) calc(var(--lh-code) * 2)); | |
| 112 | + | border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r); | |
| 113 | + | font-size: var(--fs-s); | |
| 114 | + | & a { color: var(--ink-mute); } | |
| 115 | + | &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; } | |
| 116 | + | /* with scripts the window comes by itself: the link of the page without | |
| 117 | + | scripts would only flash */ | |
| 118 | + | html.js & > a { visibility: hidden; } | |
| 119 | + | & .notice { margin: 0; } | |
| 120 | + | } | |
| 121 | + | .dloose { | |
| 122 | + | padding: var(--s2) var(--s3) var(--s3); background: var(--bg-surface); | |
| 123 | + | border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r); | |
| 56 | 124 | } | |
| 57 | 125 | ||
| 58 | 126 | .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); } | |
| @@ -18,41 +18,109 @@ | |||||
| 18 | & .mute { font-weight: 400; font-family: var(--mono); } | 18 | & .mute { font-weight: 400; font-family: var(--mono); } | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | /* merge block: first thing in the rail */ | 21 | + | /* the bar above the diff of a merge request: the diff tools, the checks and |
| 22 | - | .decide { | 22 | + | the menus of the owner, on one line that follows the review */ |
| 23 | - | border-color: var(--border-strong) !important; | 23 | + | /* on a narrow screen the bar takes two lines: the diff tools, then the |
| 24 | - | & select { | 24 | + | checks and the menus; the file headers stick under both */ |
| 25 | - | display: block; width: 100%; height: 36px; padding: 0 var(--s3); | 25 | + | @media (max-width: 860px) { |
| 26 | - | font: inherit; color: inherit; background: var(--bg-inset); | 26 | + | body:has(.mr-bar) { --bar-h: 96px; } |
| 27 | - | border: 1px solid var(--border-strong); border-radius: var(--r-s); | 27 | + | .mr-bar .grow { flex-basis: 100%; height: 0; } |
| 28 | + | } | |||
| 29 | + | .mr-bar { | |||
| 30 | + | gap: var(--s2); | |||
| 31 | + | & .mr-ck { margin-left: 0; height: 32px; padding: 0 var(--s2); border-radius: var(--r-s); text-decoration: none; color: var(--ink-dim); font-size: var(--fs-s); } | |||
| 32 | + | & .mr-ck:hover { background: var(--bg-raised); color: var(--ink-hi); } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | /* a menu of the bar: a <details> whose panel opens under its button */ | |||
| 36 | + | .menu { | |||
| 37 | + | position: relative; | |||
| 38 | + | & > summary { list-style: none; } | |||
| 39 | + | & > summary::-webkit-details-marker { display: none; } | |||
| 40 | + | & > summary.primary::after { content: ""; width: 0; height: 0; margin-left: 2px; border: 4px solid transparent; border-top-color: currentColor; transform: translateY(2px); } | |||
| 41 | + | & .menu-pop { | |||
| 42 | + | position: absolute; right: 0; top: calc(100% + var(--s1)); z-index: 6; width: 320px; max-width: calc(100vw - 2 * var(--pad)); | |||
| 43 | + | display: grid; gap: var(--s3); padding: var(--s3) var(--s4); | |||
| 44 | + | background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--r); box-shadow: 0 8px 24px rgb(0 0 0 / .18); | |||
| 45 | + | font-size: var(--fs-s); | |||
| 28 | } | 46 | } | ||
| 29 | - | & .d-check { display: flex; gap: var(--s2); align-items: center; margin-top: var(--s2); color: var(--ink-dim); font-size: var(--fs-s); cursor: pointer; } | 47 | + | & .kv { margin: 0; } |
| 30 | - | & .d-check input { accent-color: var(--ink-hi); margin: 0; } | 48 | + | } |
| 31 | - | & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; } | 49 | + | .mmenu { |
| 32 | - | & .d-btns .primary { flex: 1; justify-content: center; } | 50 | + | & .d-modes { display: grid; gap: var(--s1); border: 0; padding: 0; margin: 0; } |
| 33 | - | /* the terminal lines fold away: a reviewer who merges in the page does | 51 | + | & .d-modes label, & .d-check { |
| 34 | - | not need them in sight */ | 52 | + | display: flex; gap: var(--s2); align-items: center; padding: 4px var(--s2); margin: 0 calc(-1 * var(--s2)); |
| 35 | - | & .d-draft { font-size: var(--fs-s); color: var(--ink-dim); } | 53 | + | border-radius: var(--r-s); color: var(--ink); cursor: pointer; |
| 36 | - | & .d-or { font-size: var(--fs-s); } | 54 | + | } |
| 37 | - | & .d-or > summary { cursor: pointer; list-style: none; } | 55 | + | & .d-modes label:hover, & .d-check:hover { background: var(--bg-raised); } |
| 56 | + | & .d-modes label:has(:checked) { background: var(--bg-raised); color: var(--ink-hi); font-weight: 600; } | |||
| 57 | + | & .d-modes kbd, & .d-check kbd { margin-left: auto; } | |||
| 58 | + | & input { accent-color: var(--ink-hi); margin: 0; } | |||
| 59 | + | & .d-check { color: var(--ink-dim); } | |||
| 60 | + | & .d-btns { display: flex; margin-top: var(--s1); } | |||
| 61 | + | & .d-btns .btn { flex: 1; justify-content: center; } | |||
| 62 | + | & .d-draft { color: var(--ink-dim); } | |||
| 63 | + | } | |||
| 64 | + | .omenu { | |||
| 65 | + | & > summary { font-size: 18px; line-height: 1; } | |||
| 66 | + | & .menu-pop { width: 280px; } | |||
| 67 | + | & form .btn { width: 100%; justify-content: flex-start; } | |||
| 68 | + | & .btn kbd { margin-left: auto; } | |||
| 69 | + | & .d-or > summary { cursor: pointer; list-style: none; color: var(--ink-mute); } | |||
| 38 | & .d-or > summary::-webkit-details-marker { display: none; } | 70 | & .d-or > summary::-webkit-details-marker { display: none; } | ||
| 39 | & .d-or > summary::before { content: "▸ "; } | 71 | & .d-or > summary::before { content: "▸ "; } | ||
| 40 | & .d-or[open] > summary::before { content: "▾ "; } | 72 | & .d-or[open] > summary::before { content: "▾ "; } | ||
| 41 | & .d-or > summary:hover { color: var(--ink-hi); } | 73 | & .d-or > summary:hover { color: var(--ink-hi); } | ||
| 42 | & .d-or[open] > .clone { margin-top: var(--s2); } | 74 | & .d-or[open] > .clone { margin-top: var(--s2); } | ||
| 43 | - | & .clone { margin-top: 0; } | |||
| 44 | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | 75 | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | ||
| 45 | } | 76 | } | ||
| 46 | 77 | ||||
| 47 | - | /* the rail of a merge request follows the review: the merge block and the | 78 | + | /* the rail of a merge request: the list of files on top, which follows the |
| 48 | - | checks stay in reach at the end of a long diff. It scrolls on its own | 79 | + | review and scrolls on its own; checks, commits and settings scroll with |
| 49 | - | when it is taller than the window. Only on a two-column layout. */ | 80 | + | the page. Only on a two-column layout. */ |
| 50 | @media (min-width: 1081px) { | 81 | @media (min-width: 1081px) { | ||
| 51 | - | .work.has-rail:has(#mr-checks) .rail { | 82 | + | /* the rail runs the height of the content, so that its first block can |
| 52 | - | position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4)); | 83 | + | stick all along the diff */ |
| 84 | + | .work.has-rail:has(.r-files) { align-items: stretch; } | |||
| 85 | + | .rail:has(.r-files) { align-self: stretch; } | |||
| 86 | + | .rail .r-files { | |||
| 87 | + | position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4)); z-index: 3; | |||
| 88 | + | display: flex; flex-direction: column; | |||
| 53 | max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2); | 89 | max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2); | ||
| 54 | - | overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; | |||
| 55 | } | 90 | } | ||
| 91 | + | .rail .r-files > .files { overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; min-height: 0; } | |||
| 92 | + | } | |||
| 93 | + | /* on one column the rail goes under the diff: the list of files keeps its | |||
| 94 | + | own scroll, under a height */ | |||
| 95 | + | @media (max-width: 1080px) { | |||
| 96 | + | .rail .r-files > .files { max-height: 50dvh; overflow-y: auto; overscroll-behavior: contain; } | |||
| 97 | + | } | |||
| 98 | + | .r-files { | |||
| 99 | + | & > h2 kbd { margin-left: auto; } | |||
| 100 | + | & > .ffilter { margin: var(--s2) var(--s3) 0; } | |||
| 101 | + | & > .ffilter input { | |||
| 102 | + | width: 100%; height: 30px; padding: 0 var(--s2); font: inherit; font-size: var(--fs-s); color: inherit; | |||
| 103 | + | background: var(--bg-inset); border: 1px solid var(--border-strong); border-radius: var(--r-s); | |||
| 104 | + | } | |||
| 105 | + | & > .files { margin: var(--s2) 0 !important; padding: 0 var(--s2); } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | /* a file of the diff whose window is not loaded yet: about its room */ | |||
| 109 | + | .dlazy { | |||
| 110 | + | display: flex; align-items: center; justify-content: center; | |||
| 111 | + | background: repeating-linear-gradient(var(--bg-surface) 0 var(--lh-code), var(--paper-alt) var(--lh-code) calc(var(--lh-code) * 2)); | |||
| 112 | + | border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r); | |||
| 113 | + | font-size: var(--fs-s); | |||
| 114 | + | & a { color: var(--ink-mute); } | |||
| 115 | + | &.dlazy-1 { height: 120px; } &.dlazy-2 { height: 360px; } &.dlazy-3 { height: 900px; } &.dlazy-4 { height: 1600px; } | |||
| 116 | + | /* with scripts the window comes by itself: the link of the page without | |||
| 117 | + | scripts would only flash */ | |||
| 118 | + | html.js & > a { visibility: hidden; } | |||
| 119 | + | & .notice { margin: 0; } | |||
| 120 | + | } | |||
| 121 | + | .dloose { | |||
| 122 | + | padding: var(--s2) var(--s3) var(--s3); background: var(--bg-surface); | |||
| 123 | + | border: 1px solid var(--border); border-top: 0; border-radius: 0 0 var(--r) var(--r); | |||
| 56 | } | 124 | } | ||
| 57 | 125 | ||||
| 58 | .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); } | 126 | .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); } | ||
M internal/adapters/web/static/js/ermite.js +42 -5
| @@ -41,6 +41,7 @@ | |||
| 41 | 41 | }; | |
| 42 | 42 | ||
| 43 | 43 | const ermite = { | |
| 44 | + | flash, | |
| 44 | 45 | fire(name) { | |
| 45 | 46 | body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | |
| 46 | 47 | }, | |
| @@ -48,6 +49,7 @@ | |||
| 48 | 49 | // Row cursor: j / k. | |
| 49 | 50 | move(d) { | |
| 50 | 51 | if ($('dialog[open]')) return; | |
| 52 | + | if (window.ermiteMR?.stepFiles(d)) return; | |
| 51 | 53 | const rows = visibleRows(); | |
| 52 | 54 | if (!rows.length) { | |
| 53 | 55 | scrollBy(0, d * 180); | |
| @@ -73,6 +75,11 @@ | |||
| 73 | 75 | open() { | |
| 74 | 76 | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | |
| 75 | 77 | }, | |
| 78 | + | // openFile opens a file of the diff the table of contents links to. | |
| 79 | + | openFile(id) { | |
| 80 | + | const f = document.getElementById(id); | |
| 81 | + | if (f) f.open = true; | |
| 82 | + | }, | |
| 76 | 83 | focusFilter() { | |
| 77 | 84 | $('[data-filter]')?.focus(); | |
| 78 | 85 | }, | |
| @@ -210,9 +217,11 @@ | |||
| 210 | 217 | ||
| 211 | 218 | // 1. Keys. | |
| 212 | 219 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | |
| 213 | - | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | |
| 220 | + | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc', | |
| 221 | + | // the merge request page (mr.js): the merge menu, mark read | |
| 222 | + | M: 'merge', R: 'read' }; | |
| 214 | 223 | // "g t" is the key "t": the tabs have one event each. | |
| 215 | - | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | |
| 224 | + | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' }; | |
| 216 | 225 | let pending = 0; | |
| 217 | 226 | let pendingTimer = 0; | |
| 218 | 227 | addEventListener('keydown', (e) => { | |
| @@ -221,7 +230,16 @@ | |||
| 221 | 230 | // field keeps ctrl k (kill to end of line on macOS). | |
| 222 | 231 | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); } | |
| 223 | 232 | if (e.ctrlKey || e.metaKey || e.altKey) return; | |
| 224 | - | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); } | |
| 233 | + | const mr = window.ermiteMR; | |
| 234 | + | if (e.key === 'Escape') { | |
| 235 | + | pending = 0; | |
| 236 | + | if (mr?.closeMenus() || mr?.leaveFiles()) return; | |
| 237 | + | if (typing) e.target.blur(); | |
| 238 | + | return ermite.fire('esc'); | |
| 239 | + | } | |
| 240 | + | // An open merge menu takes its keys (1-9, D) before the field check: its | |
| 241 | + | // radios and its checkbox hold the focus. | |
| 242 | + | if (mr?.menuKey(e)) return; | |
| 225 | 243 | if (typing) return; | |
| 226 | 244 | // An open dialog takes only the keys that move between dialogs: the page | |
| 227 | 245 | // behind it does not react (scripted clicks would go through inert). | |
| @@ -273,7 +291,7 @@ | |||
| 273 | 291 | // line (the first file when none is). One observer tracks the files in the | |
| 274 | 292 | // band; it reports only when a file enters or leaves it, so a scroll costs | |
| 275 | 293 | // nothing in between, and a jump that skips the band is caught by the scan. | |
| 276 | - | const files = $$('.dfile'); | |
| 294 | + | let files = $$('.dfile'); | |
| 277 | 295 | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | |
| 278 | 296 | if (files.length && links.size && 'IntersectionObserver' in window) { | |
| 279 | 297 | const inBand = new Set(); | |
| @@ -284,7 +302,17 @@ | |||
| 284 | 302 | ?? files[0]; | |
| 285 | 303 | if (f === current) return; | |
| 286 | 304 | links.get(current?.id)?.removeAttribute('aria-current'); | |
| 287 | - | links.get(f.id)?.setAttribute('aria-current', 'true'); | |
| 305 | + | const a = links.get(f.id); | |
| 306 | + | a?.setAttribute('aria-current', 'true'); | |
| 307 | + | // The list scrolls on its own: the current file stays in it. Only the | |
| 308 | + | // list moves, never the page (scrollIntoView would scroll the page to | |
| 309 | + | // the list when the rail is under the diff). | |
| 310 | + | const box = a?.closest('.files'); | |
| 311 | + | if (a && box && box.scrollHeight > box.clientHeight && !box.contains(document.activeElement)) { | |
| 312 | + | const top = a.offsetTop; | |
| 313 | + | if (top < box.scrollTop) box.scrollTop = top; | |
| 314 | + | else if (top + a.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + a.offsetHeight - box.clientHeight; | |
| 315 | + | } | |
| 288 | 316 | current = f; | |
| 289 | 317 | }; | |
| 290 | 318 | const io = new IntersectionObserver((entries) => { | |
| @@ -295,6 +323,15 @@ | |||
| 295 | 323 | mark(); | |
| 296 | 324 | }, { rootMargin: '0px 0px -67% 0px' }); | |
| 297 | 325 | for (const f of files) io.observe(f); | |
| 326 | + | // A window of files of a merge request came in (mr.js): its files replace | |
| 327 | + | // placeholders, which the observer and the list knew by element. | |
| 328 | + | ermite.watchFile = (f, old) => { | |
| 329 | + | if (old) { io.unobserve(old); inBand.delete(old); } | |
| 330 | + | files = $$('.dfile'); | |
| 331 | + | for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x); | |
| 332 | + | if (current && !current.isConnected) current = document.getElementById(current.id); | |
| 333 | + | io.observe(f); | |
| 334 | + | }; | |
| 298 | 335 | } | |
| 299 | 336 | ||
| 300 | 337 | // 5. A failure of a handler or of a request (htmx logs it to the console | |
| @@ -41,6 +41,7 @@ | |||||
| 41 | }; | 41 | }; | ||
| 42 | 42 | ||||
| 43 | const ermite = { | 43 | const ermite = { | ||
| 44 | + | flash, | |||
| 44 | fire(name) { | 45 | fire(name) { | ||
| 45 | body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | 46 | body().dispatchEvent(new CustomEvent('k-' + name, { bubbles: false })); | ||
| 46 | }, | 47 | }, | ||
| @@ -48,6 +49,7 @@ | |||||
| 48 | // Row cursor: j / k. | 49 | // Row cursor: j / k. | ||
| 49 | move(d) { | 50 | move(d) { | ||
| 50 | if ($('dialog[open]')) return; | 51 | if ($('dialog[open]')) return; | ||
| 52 | + | if (window.ermiteMR?.stepFiles(d)) return; | |||
| 51 | const rows = visibleRows(); | 53 | const rows = visibleRows(); | ||
| 52 | if (!rows.length) { | 54 | if (!rows.length) { | ||
| 53 | scrollBy(0, d * 180); | 55 | scrollBy(0, d * 180); | ||
| @@ -73,6 +75,11 @@ | |||||
| 73 | open() { | 75 | open() { | ||
| 74 | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | 76 | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | ||
| 75 | }, | 77 | }, | ||
| 78 | + | // openFile opens a file of the diff the table of contents links to. | |||
| 79 | + | openFile(id) { | |||
| 80 | + | const f = document.getElementById(id); | |||
| 81 | + | if (f) f.open = true; | |||
| 82 | + | }, | |||
| 76 | focusFilter() { | 83 | focusFilter() { | ||
| 77 | $('[data-filter]')?.focus(); | 84 | $('[data-filter]')?.focus(); | ||
| 78 | }, | 85 | }, | ||
| @@ -210,9 +217,11 @@ | |||||
| 210 | 217 | ||||
| 211 | // 1. Keys. | 218 | // 1. Keys. | ||
| 212 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | 219 | const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink', | ||
| 213 | - | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' }; | 220 | + | s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc', |
| 221 | + | // the merge request page (mr.js): the merge menu, mark read | |||
| 222 | + | M: 'merge', R: 'read' }; | |||
| 214 | // "g t" is the key "t": the tabs have one event each. | 223 | // "g t" is the key "t": the tabs have one event each. | ||
| 215 | - | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | 224 | + | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' }; |
| 216 | let pending = 0; | 225 | let pending = 0; | ||
| 217 | let pendingTimer = 0; | 226 | let pendingTimer = 0; | ||
| 218 | addEventListener('keydown', (e) => { | 227 | addEventListener('keydown', (e) => { | ||
| @@ -221,7 +230,16 @@ | |||||
| 221 | // field keeps ctrl k (kill to end of line on macOS). | 230 | // field keeps ctrl k (kill to end of line on macOS). | ||
| 222 | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); } | 231 | if ((e.metaKey || (e.ctrlKey && !typing)) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') { e.preventDefault(); pending = 0; return ermite.fire('cmd'); } | ||
| 223 | if (e.ctrlKey || e.metaKey || e.altKey) return; | 232 | if (e.ctrlKey || e.metaKey || e.altKey) return; | ||
| 224 | - | if (e.key === 'Escape') { pending = 0; if (typing) e.target.blur(); return ermite.fire('esc'); } | 233 | + | const mr = window.ermiteMR; |
| 234 | + | if (e.key === 'Escape') { | |||
| 235 | + | pending = 0; | |||
| 236 | + | if (mr?.closeMenus() || mr?.leaveFiles()) return; | |||
| 237 | + | if (typing) e.target.blur(); | |||
| 238 | + | return ermite.fire('esc'); | |||
| 239 | + | } | |||
| 240 | + | // An open merge menu takes its keys (1-9, D) before the field check: its | |||
| 241 | + | // radios and its checkbox hold the focus. | |||
| 242 | + | if (mr?.menuKey(e)) return; | |||
| 225 | if (typing) return; | 243 | if (typing) return; | ||
| 226 | // An open dialog takes only the keys that move between dialogs: the page | 244 | // An open dialog takes only the keys that move between dialogs: the page | ||
| 227 | // behind it does not react (scripted clicks would go through inert). | 245 | // behind it does not react (scripted clicks would go through inert). | ||
| @@ -273,7 +291,7 @@ | |||||
| 273 | // line (the first file when none is). One observer tracks the files in the | 291 | // line (the first file when none is). One observer tracks the files in the | ||
| 274 | // band; it reports only when a file enters or leaves it, so a scroll costs | 292 | // band; it reports only when a file enters or leaves it, so a scroll costs | ||
| 275 | // nothing in between, and a jump that skips the band is caught by the scan. | 293 | // nothing in between, and a jump that skips the band is caught by the scan. | ||
| 276 | - | const files = $$('.dfile'); | 294 | + | let files = $$('.dfile'); |
| 277 | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | 295 | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | ||
| 278 | if (files.length && links.size && 'IntersectionObserver' in window) { | 296 | if (files.length && links.size && 'IntersectionObserver' in window) { | ||
| 279 | const inBand = new Set(); | 297 | const inBand = new Set(); | ||
| @@ -284,7 +302,17 @@ | |||||
| 284 | ?? files[0]; | 302 | ?? files[0]; | ||
| 285 | if (f === current) return; | 303 | if (f === current) return; | ||
| 286 | links.get(current?.id)?.removeAttribute('aria-current'); | 304 | links.get(current?.id)?.removeAttribute('aria-current'); | ||
| 287 | - | links.get(f.id)?.setAttribute('aria-current', 'true'); | 305 | + | const a = links.get(f.id); |
| 306 | + | a?.setAttribute('aria-current', 'true'); | |||
| 307 | + | // The list scrolls on its own: the current file stays in it. Only the | |||
| 308 | + | // list moves, never the page (scrollIntoView would scroll the page to | |||
| 309 | + | // the list when the rail is under the diff). | |||
| 310 | + | const box = a?.closest('.files'); | |||
| 311 | + | if (a && box && box.scrollHeight > box.clientHeight && !box.contains(document.activeElement)) { | |||
| 312 | + | const top = a.offsetTop; | |||
| 313 | + | if (top < box.scrollTop) box.scrollTop = top; | |||
| 314 | + | else if (top + a.offsetHeight > box.scrollTop + box.clientHeight) box.scrollTop = top + a.offsetHeight - box.clientHeight; | |||
| 315 | + | } | |||
| 288 | current = f; | 316 | current = f; | ||
| 289 | }; | 317 | }; | ||
| 290 | const io = new IntersectionObserver((entries) => { | 318 | const io = new IntersectionObserver((entries) => { | ||
| @@ -295,6 +323,15 @@ | |||||
| 295 | mark(); | 323 | mark(); | ||
| 296 | }, { rootMargin: '0px 0px -67% 0px' }); | 324 | }, { rootMargin: '0px 0px -67% 0px' }); | ||
| 297 | for (const f of files) io.observe(f); | 325 | for (const f of files) io.observe(f); | ||
| 326 | + | // A window of files of a merge request came in (mr.js): its files replace | |||
| 327 | + | // placeholders, which the observer and the list knew by element. | |||
| 328 | + | ermite.watchFile = (f, old) => { | |||
| 329 | + | if (old) { io.unobserve(old); inBand.delete(old); } | |||
| 330 | + | files = $$('.dfile'); | |||
| 331 | + | for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x); | |||
| 332 | + | if (current && !current.isConnected) current = document.getElementById(current.id); | |||
| 333 | + | io.observe(f); | |||
| 334 | + | }; | |||
| 298 | } | 335 | } | ||
| 299 | 336 | ||||
| 300 | // 5. A failure of a handler or of a request (htmx logs it to the console | 337 | // 5. A failure of a handler or of a request (htmx logs it to the console | ||
A internal/adapters/web/static/js/mr.js +249 -0
| @@ -0,0 +1,249 @@ | |||
| 1 | + | // mr.js: the merge request page beyond what htmx and hx-live give. | |
| 2 | + | // | |
| 3 | + | // 1. Windows of files. The page holds the hunks of one window of files; the | |
| 4 | + | // other files are placeholders (.dlazy[data-src]). A placeholder that comes | |
| 5 | + | // near the screen fetches its window (/files?from=&base=&head=), whose | |
| 6 | + | // files replace the placeholders of the same ids. A window is fetched once; | |
| 7 | + | // a link to a file or to a comment of a placeholder fetches it first. | |
| 8 | + | // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu | |
| 9 | + | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | |
| 10 | + | // submits (the browser does it: the focus is on the button), Esc closes. | |
| 11 | + | // R marks the head read. Nothing merges on one key. | |
| 12 | + | // 3. g f puts the focus in the list of files; j / k move in it, Enter opens | |
| 13 | + | // the file, Esc goes back to the diff. | |
| 14 | + | // | |
| 15 | + | // Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure | |
| 16 | + | // (jstest/mr.test.mjs). | |
| 17 | + | window.ermiteMR = (() => { | |
| 18 | + | // core: the decisions, without the DOM. | |
| 19 | + | const core = { | |
| 20 | + | // windowOf is the start of the window of file i (files are f1, f2, ...). | |
| 21 | + | windowOf(id, size) { | |
| 22 | + | const n = Number(String(id).replace(/^f/, '')) - 1; | |
| 23 | + | return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1; | |
| 24 | + | }, | |
| 25 | + | // menuKey says what a key does while the merge menu is open: a mode | |
| 26 | + | // (index), the branch deletion, or nothing. | |
| 27 | + | menuKey(key, modes) { | |
| 28 | + | if (key === 'D') return { toggle: true }; | |
| 29 | + | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | |
| 30 | + | return null; | |
| 31 | + | }, | |
| 32 | + | // step moves an index within [0, n). | |
| 33 | + | step(i, d, n) { | |
| 34 | + | if (n === 0) return -1; | |
| 35 | + | if (i < 0) return d > 0 ? 0 : n - 1; | |
| 36 | + | return Math.max(0, Math.min(n - 1, i + d)); | |
| 37 | + | }, | |
| 38 | + | }; | |
| 39 | + | ||
| 40 | + | const $ = (sel, root = document) => root.querySelector(sel); | |
| 41 | + | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |
| 42 | + | const loading = new Map(); // src -> promise of the swap | |
| 43 | + | let moved = false; | |
| 44 | + | let lazyIO = null; // the observer of the placeholders (watch) | |
| 45 | + | let revealGen = 0; // the last reveal wins the scroll | |
| 46 | + | ||
| 47 | + | // load fetches the window of a placeholder and swaps its files in. The | |
| 48 | + | // promise resolves once they are in the page (or the page said to reload). | |
| 49 | + | function load(ph) { | |
| 50 | + | const src = ph?.dataset.src; | |
| 51 | + | if (!src || moved) return Promise.resolve(); | |
| 52 | + | if (loading.has(src)) return loading.get(src); | |
| 53 | + | for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', ''); | |
| 54 | + | // The window renders like the page: the layout shown, and both layouts | |
| 55 | + | // when the page switches them itself (data-diff) or one (data-view). | |
| 56 | + | const d = $('#diffs')?.dataset || {}; | |
| 57 | + | const view = d.diff || d.view; | |
| 58 | + | const layouts = d.diff ? 'both' : d.view ? 'one' : ''; | |
| 59 | + | const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : ''); | |
| 60 | + | const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }) | |
| 61 | + | .then(async (res) => { | |
| 62 | + | const html = await res.text(); | |
| 63 | + | if (res.status === 409) return stale(html); | |
| 64 | + | if (!res.ok) throw new Error('loading files: ' + res.status); | |
| 65 | + | // A window of the old diff after the page went stale stays out. | |
| 66 | + | if (!moved) swap(html); | |
| 67 | + | }) | |
| 68 | + | .catch((err) => { | |
| 69 | + | loading.delete(src); | |
| 70 | + | for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading'); | |
| 71 | + | window.ermite?.flash?.(err.message, 4000); | |
| 72 | + | }); | |
| 73 | + | loading.set(src, p); | |
| 74 | + | return p; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // swap replaces each placeholder file with the file of the same id. A | |
| 78 | + | // file the reader folded stays folded. | |
| 79 | + | function swap(html) { | |
| 80 | + | const t = document.createElement('template'); | |
| 81 | + | t.innerHTML = html; | |
| 82 | + | for (const f of [...t.content.querySelectorAll('details[data-file]')]) { | |
| 83 | + | const old = document.getElementById(f.id); | |
| 84 | + | if (!old || !old.querySelector('.dlazy')) continue; | |
| 85 | + | f.open = old.open; | |
| 86 | + | for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p); | |
| 87 | + | old.replaceWith(f); | |
| 88 | + | window.htmx?.process(f); | |
| 89 | + | window.ermite?.watchFile?.(f, old); | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // stale: the diff changed under the page. The placeholders say so; no file | |
| 94 | + | // of another diff comes in. | |
| 95 | + | function stale(html) { | |
| 96 | + | moved = true; | |
| 97 | + | for (const p of $$('.dlazy')) { | |
| 98 | + | p.removeAttribute('data-loading'); | |
| 99 | + | p.innerHTML = html; | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // reveal loads the file of an id (a file "f12" or a comment "c3") when it is | |
| 104 | + | // a placeholder, then scrolls to it. It returns false when there is nothing | |
| 105 | + | // to load. | |
| 106 | + | function reveal(id, scroll = true) { | |
| 107 | + | // Any navigation, loaded or not, makes the reveals still loading stale. | |
| 108 | + | const gen = ++revealGen; | |
| 109 | + | let ph = document.getElementById(id)?.querySelector(':scope > .dlazy'); | |
| 110 | + | if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) { | |
| 111 | + | const base = id.replace(/-s$/, ''); | |
| 112 | + | ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base)); | |
| 113 | + | } | |
| 114 | + | if (!ph) return false; | |
| 115 | + | load(ph).then(() => { | |
| 116 | + | // A later reveal (another file asked meanwhile) owns the view. | |
| 117 | + | if (gen !== revealGen) return; | |
| 118 | + | const el = document.getElementById(id); | |
| 119 | + | if (!el) return; | |
| 120 | + | const d = el.closest('details'); | |
| 121 | + | if (d) d.open = true; | |
| 122 | + | if (!scroll) return; | |
| 123 | + | // The files above may have changed size while it loaded (placeholders, | |
| 124 | + | // content-visibility): place it, then again once laid out. | |
| 125 | + | el.scrollIntoView({ block: 'start' }); | |
| 126 | + | requestAnimationFrame(() => requestAnimationFrame(() => { | |
| 127 | + | if (gen === revealGen) el.scrollIntoView({ block: 'start' }); | |
| 128 | + | })); | |
| 129 | + | }); | |
| 130 | + | return true; | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | // Placeholders load as they come within two screens of the view; files | |
| 134 | + | // that come in are marked in the list of files (ermite.js marks the one in | |
| 135 | + | // view). | |
| 136 | + | function watch() { | |
| 137 | + | if (!('IntersectionObserver' in window)) { | |
| 138 | + | for (const p of $$('.dlazy[data-src]')) load(p); | |
| 139 | + | return; | |
| 140 | + | } | |
| 141 | + | lazyIO = new IntersectionObserver((entries) => { | |
| 142 | + | for (const e of entries) { | |
| 143 | + | if (!e.isIntersecting) continue; | |
| 144 | + | lazyIO.unobserve(e.target); | |
| 145 | + | load(e.target); | |
| 146 | + | } | |
| 147 | + | }, { rootMargin: '200% 0px 200% 0px' }); | |
| 148 | + | for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // The merge menu. | |
| 152 | + | const menu = () => $('#merge-menu'); | |
| 153 | + | function openMerge() { | |
| 154 | + | const m = menu(); | |
| 155 | + | if (!m) return false; | |
| 156 | + | for (const d of $$('details.menu[open]')) if (d !== m) d.open = false; | |
| 157 | + | m.open = true; | |
| 158 | + | $('[data-default]', m)?.focus(); | |
| 159 | + | return true; | |
| 160 | + | } | |
| 161 | + | function closeMenus() { | |
| 162 | + | const open = $$('details.menu[open]'); | |
| 163 | + | for (const d of open) d.open = false; | |
| 164 | + | if (open.length) $('summary', open[0])?.focus(); | |
| 165 | + | return open.length > 0; | |
| 166 | + | } | |
| 167 | + | // menuKey handles a key while the merge menu is open; true when it used it. | |
| 168 | + | function menuKey(e) { | |
| 169 | + | const m = menu(); | |
| 170 | + | if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false; | |
| 171 | + | // An open dialog (help, commands) is above the menu: it keeps the keys. | |
| 172 | + | if ($('dialog[open]')) return false; | |
| 173 | + | // A field outside the menu (a comment being written) keeps its keys. | |
| 174 | + | const field = e.target.closest?.('input, textarea, select, [contenteditable]'); | |
| 175 | + | if (field && !m.contains(field)) return false; | |
| 176 | + | const radios = $$('input[name="mode"]', m); | |
| 177 | + | const act = core.menuKey(e.key, radios.length); | |
| 178 | + | if (!act) return false; | |
| 179 | + | e.preventDefault(); | |
| 180 | + | if (act.toggle) { | |
| 181 | + | const box = $('input[name="delete_branch"]', m); | |
| 182 | + | if (box) box.checked = !box.checked; | |
| 183 | + | } else { | |
| 184 | + | radios[act.mode].checked = true; | |
| 185 | + | } | |
| 186 | + | $('[data-default]', m)?.focus(); | |
| 187 | + | return true; | |
| 188 | + | } | |
| 189 | + | function markRead() { | |
| 190 | + | const b = $('button[data-read]'); | |
| 191 | + | if (!b) return false; | |
| 192 | + | b.closest('form').requestSubmit(b); | |
| 193 | + | return true; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // The list of files by keyboard: g f focuses the current file. | |
| 197 | + | const links = () => $$('.r-files .files > li:not([hidden]) > a'); | |
| 198 | + | function focusFiles() { | |
| 199 | + | const all = links(); | |
| 200 | + | if (!all.length) return false; | |
| 201 | + | const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0]; | |
| 202 | + | cur.focus(); | |
| 203 | + | cur.scrollIntoView({ block: 'nearest' }); | |
| 204 | + | return true; | |
| 205 | + | } | |
| 206 | + | // stepFiles moves the focus in the list; false when the focus is not in it. | |
| 207 | + | function stepFiles(d) { | |
| 208 | + | const all = links(); | |
| 209 | + | const i = all.indexOf(document.activeElement); | |
| 210 | + | if (i < 0) return false; | |
| 211 | + | const j = core.step(i, d, all.length); | |
| 212 | + | all[j].focus(); | |
| 213 | + | all[j].scrollIntoView({ block: 'nearest' }); | |
| 214 | + | return true; | |
| 215 | + | } | |
| 216 | + | function leaveFiles() { | |
| 217 | + | if (!links().includes(document.activeElement)) return false; | |
| 218 | + | document.getElementById('main')?.focus({ preventScroll: true }); | |
| 219 | + | return true; | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // openFile opens the file of a link of the list (and loads it first). | |
| 223 | + | function openFile(id) { | |
| 224 | + | const f = document.getElementById(id); | |
| 225 | + | if (f) f.open = true; | |
| 226 | + | reveal(id); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | function start() { | |
| 230 | + | if (!$('#diffs[data-mr]')) return; | |
| 231 | + | watch(); | |
| 232 | + | // A link to a file or a comment that is not loaded yet. | |
| 233 | + | const hash = () => { | |
| 234 | + | const id = decodeURIComponent(location.hash.slice(1)); | |
| 235 | + | if (id) reveal(id); | |
| 236 | + | }; | |
| 237 | + | addEventListener('hashchange', hash); | |
| 238 | + | hash(); | |
| 239 | + | // A click outside an open menu closes it. | |
| 240 | + | addEventListener('click', (e) => { | |
| 241 | + | for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false; | |
| 242 | + | }); | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, openFile }; | |
| 246 | + | })(); | |
| 247 | + | ||
| 248 | + | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | |
| 249 | + | else window.ermiteMR.start(); | |
| @@ -0,0 +1,249 @@ | |||||
| 1 | + | // mr.js: the merge request page beyond what htmx and hx-live give. | |||
| 2 | + | // | |||
| 3 | + | // 1. Windows of files. The page holds the hunks of one window of files; the | |||
| 4 | + | // other files are placeholders (.dlazy[data-src]). A placeholder that comes | |||
| 5 | + | // near the screen fetches its window (/files?from=&base=&head=), whose | |||
| 6 | + | // files replace the placeholders of the same ids. A window is fetched once; | |||
| 7 | + | // a link to a file or to a comment of a placeholder fetches it first. | |||
| 8 | + | // 2. The menus of the bar (merge, more) and their keys: M opens the merge menu | |||
| 9 | + | // on its button, 1-9 pick a mode, D toggles the branch deletion, Enter | |||
| 10 | + | // submits (the browser does it: the focus is on the button), Esc closes. | |||
| 11 | + | // R marks the head read. Nothing merges on one key. | |||
| 12 | + | // 3. g f puts the focus in the list of files; j / k move in it, Enter opens | |||
| 13 | + | // the file, Esc goes back to the diff. | |||
| 14 | + | // | |||
| 15 | + | // Nothing runs on a page without #diffs[data-mr]. window.ermiteMR.core is pure | |||
| 16 | + | // (jstest/mr.test.mjs). | |||
| 17 | + | window.ermiteMR = (() => { | |||
| 18 | + | // core: the decisions, without the DOM. | |||
| 19 | + | const core = { | |||
| 20 | + | // windowOf is the start of the window of file i (files are f1, f2, ...). | |||
| 21 | + | windowOf(id, size) { | |||
| 22 | + | const n = Number(String(id).replace(/^f/, '')) - 1; | |||
| 23 | + | return Number.isInteger(n) && n >= 0 ? n - (n % size) : -1; | |||
| 24 | + | }, | |||
| 25 | + | // menuKey says what a key does while the merge menu is open: a mode | |||
| 26 | + | // (index), the branch deletion, or nothing. | |||
| 27 | + | menuKey(key, modes) { | |||
| 28 | + | if (key === 'D') return { toggle: true }; | |||
| 29 | + | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | |||
| 30 | + | return null; | |||
| 31 | + | }, | |||
| 32 | + | // step moves an index within [0, n). | |||
| 33 | + | step(i, d, n) { | |||
| 34 | + | if (n === 0) return -1; | |||
| 35 | + | if (i < 0) return d > 0 ? 0 : n - 1; | |||
| 36 | + | return Math.max(0, Math.min(n - 1, i + d)); | |||
| 37 | + | }, | |||
| 38 | + | }; | |||
| 39 | + | ||||
| 40 | + | const $ = (sel, root = document) => root.querySelector(sel); | |||
| 41 | + | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |||
| 42 | + | const loading = new Map(); // src -> promise of the swap | |||
| 43 | + | let moved = false; | |||
| 44 | + | let lazyIO = null; // the observer of the placeholders (watch) | |||
| 45 | + | let revealGen = 0; // the last reveal wins the scroll | |||
| 46 | + | ||||
| 47 | + | // load fetches the window of a placeholder and swaps its files in. The | |||
| 48 | + | // promise resolves once they are in the page (or the page said to reload). | |||
| 49 | + | function load(ph) { | |||
| 50 | + | const src = ph?.dataset.src; | |||
| 51 | + | if (!src || moved) return Promise.resolve(); | |||
| 52 | + | if (loading.has(src)) return loading.get(src); | |||
| 53 | + | for (const p of $$('.dlazy[data-src]')) if (p.dataset.src === src) p.setAttribute('data-loading', ''); | |||
| 54 | + | // The window renders like the page: the layout shown, and both layouts | |||
| 55 | + | // when the page switches them itself (data-diff) or one (data-view). | |||
| 56 | + | const d = $('#diffs')?.dataset || {}; | |||
| 57 | + | const view = d.diff || d.view; | |||
| 58 | + | const layouts = d.diff ? 'both' : d.view ? 'one' : ''; | |||
| 59 | + | const url = src + (view ? '&view=' + encodeURIComponent(view) : '') + (layouts ? '&layouts=' + layouts : ''); | |||
| 60 | + | const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' }) | |||
| 61 | + | .then(async (res) => { | |||
| 62 | + | const html = await res.text(); | |||
| 63 | + | if (res.status === 409) return stale(html); | |||
| 64 | + | if (!res.ok) throw new Error('loading files: ' + res.status); | |||
| 65 | + | // A window of the old diff after the page went stale stays out. | |||
| 66 | + | if (!moved) swap(html); | |||
| 67 | + | }) | |||
| 68 | + | .catch((err) => { | |||
| 69 | + | loading.delete(src); | |||
| 70 | + | for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading'); | |||
| 71 | + | window.ermite?.flash?.(err.message, 4000); | |||
| 72 | + | }); | |||
| 73 | + | loading.set(src, p); | |||
| 74 | + | return p; | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // swap replaces each placeholder file with the file of the same id. A | |||
| 78 | + | // file the reader folded stays folded. | |||
| 79 | + | function swap(html) { | |||
| 80 | + | const t = document.createElement('template'); | |||
| 81 | + | t.innerHTML = html; | |||
| 82 | + | for (const f of [...t.content.querySelectorAll('details[data-file]')]) { | |||
| 83 | + | const old = document.getElementById(f.id); | |||
| 84 | + | if (!old || !old.querySelector('.dlazy')) continue; | |||
| 85 | + | f.open = old.open; | |||
| 86 | + | for (const p of $$('.dlazy', old)) lazyIO?.unobserve(p); | |||
| 87 | + | old.replaceWith(f); | |||
| 88 | + | window.htmx?.process(f); | |||
| 89 | + | window.ermite?.watchFile?.(f, old); | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // stale: the diff changed under the page. The placeholders say so; no file | |||
| 94 | + | // of another diff comes in. | |||
| 95 | + | function stale(html) { | |||
| 96 | + | moved = true; | |||
| 97 | + | for (const p of $$('.dlazy')) { | |||
| 98 | + | p.removeAttribute('data-loading'); | |||
| 99 | + | p.innerHTML = html; | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // reveal loads the file of an id (a file "f12" or a comment "c3") when it is | |||
| 104 | + | // a placeholder, then scrolls to it. It returns false when there is nothing | |||
| 105 | + | // to load. | |||
| 106 | + | function reveal(id, scroll = true) { | |||
| 107 | + | // Any navigation, loaded or not, makes the reveals still loading stale. | |||
| 108 | + | const gen = ++revealGen; | |||
| 109 | + | let ph = document.getElementById(id)?.querySelector(':scope > .dlazy'); | |||
| 110 | + | if (!ph && /^c\d+/.test(id) && !document.getElementById(id)) { | |||
| 111 | + | const base = id.replace(/-s$/, ''); | |||
| 112 | + | ph = $$('.dlazy[data-notes]').find((p) => p.dataset.notes.split(' ').includes(base)); | |||
| 113 | + | } | |||
| 114 | + | if (!ph) return false; | |||
| 115 | + | load(ph).then(() => { | |||
| 116 | + | // A later reveal (another file asked meanwhile) owns the view. | |||
| 117 | + | if (gen !== revealGen) return; | |||
| 118 | + | const el = document.getElementById(id); | |||
| 119 | + | if (!el) return; | |||
| 120 | + | const d = el.closest('details'); | |||
| 121 | + | if (d) d.open = true; | |||
| 122 | + | if (!scroll) return; | |||
| 123 | + | // The files above may have changed size while it loaded (placeholders, | |||
| 124 | + | // content-visibility): place it, then again once laid out. | |||
| 125 | + | el.scrollIntoView({ block: 'start' }); | |||
| 126 | + | requestAnimationFrame(() => requestAnimationFrame(() => { | |||
| 127 | + | if (gen === revealGen) el.scrollIntoView({ block: 'start' }); | |||
| 128 | + | })); | |||
| 129 | + | }); | |||
| 130 | + | return true; | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | // Placeholders load as they come within two screens of the view; files | |||
| 134 | + | // that come in are marked in the list of files (ermite.js marks the one in | |||
| 135 | + | // view). | |||
| 136 | + | function watch() { | |||
| 137 | + | if (!('IntersectionObserver' in window)) { | |||
| 138 | + | for (const p of $$('.dlazy[data-src]')) load(p); | |||
| 139 | + | return; | |||
| 140 | + | } | |||
| 141 | + | lazyIO = new IntersectionObserver((entries) => { | |||
| 142 | + | for (const e of entries) { | |||
| 143 | + | if (!e.isIntersecting) continue; | |||
| 144 | + | lazyIO.unobserve(e.target); | |||
| 145 | + | load(e.target); | |||
| 146 | + | } | |||
| 147 | + | }, { rootMargin: '200% 0px 200% 0px' }); | |||
| 148 | + | for (const p of $$('.dlazy[data-src]')) lazyIO.observe(p); | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // The merge menu. | |||
| 152 | + | const menu = () => $('#merge-menu'); | |||
| 153 | + | function openMerge() { | |||
| 154 | + | const m = menu(); | |||
| 155 | + | if (!m) return false; | |||
| 156 | + | for (const d of $$('details.menu[open]')) if (d !== m) d.open = false; | |||
| 157 | + | m.open = true; | |||
| 158 | + | $('[data-default]', m)?.focus(); | |||
| 159 | + | return true; | |||
| 160 | + | } | |||
| 161 | + | function closeMenus() { | |||
| 162 | + | const open = $$('details.menu[open]'); | |||
| 163 | + | for (const d of open) d.open = false; | |||
| 164 | + | if (open.length) $('summary', open[0])?.focus(); | |||
| 165 | + | return open.length > 0; | |||
| 166 | + | } | |||
| 167 | + | // menuKey handles a key while the merge menu is open; true when it used it. | |||
| 168 | + | function menuKey(e) { | |||
| 169 | + | const m = menu(); | |||
| 170 | + | if (!m?.open || e.ctrlKey || e.metaKey || e.altKey) return false; | |||
| 171 | + | // An open dialog (help, commands) is above the menu: it keeps the keys. | |||
| 172 | + | if ($('dialog[open]')) return false; | |||
| 173 | + | // A field outside the menu (a comment being written) keeps its keys. | |||
| 174 | + | const field = e.target.closest?.('input, textarea, select, [contenteditable]'); | |||
| 175 | + | if (field && !m.contains(field)) return false; | |||
| 176 | + | const radios = $$('input[name="mode"]', m); | |||
| 177 | + | const act = core.menuKey(e.key, radios.length); | |||
| 178 | + | if (!act) return false; | |||
| 179 | + | e.preventDefault(); | |||
| 180 | + | if (act.toggle) { | |||
| 181 | + | const box = $('input[name="delete_branch"]', m); | |||
| 182 | + | if (box) box.checked = !box.checked; | |||
| 183 | + | } else { | |||
| 184 | + | radios[act.mode].checked = true; | |||
| 185 | + | } | |||
| 186 | + | $('[data-default]', m)?.focus(); | |||
| 187 | + | return true; | |||
| 188 | + | } | |||
| 189 | + | function markRead() { | |||
| 190 | + | const b = $('button[data-read]'); | |||
| 191 | + | if (!b) return false; | |||
| 192 | + | b.closest('form').requestSubmit(b); | |||
| 193 | + | return true; | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // The list of files by keyboard: g f focuses the current file. | |||
| 197 | + | const links = () => $$('.r-files .files > li:not([hidden]) > a'); | |||
| 198 | + | function focusFiles() { | |||
| 199 | + | const all = links(); | |||
| 200 | + | if (!all.length) return false; | |||
| 201 | + | const cur = all.find((a) => a.getAttribute('aria-current') === 'true') || all[0]; | |||
| 202 | + | cur.focus(); | |||
| 203 | + | cur.scrollIntoView({ block: 'nearest' }); | |||
| 204 | + | return true; | |||
| 205 | + | } | |||
| 206 | + | // stepFiles moves the focus in the list; false when the focus is not in it. | |||
| 207 | + | function stepFiles(d) { | |||
| 208 | + | const all = links(); | |||
| 209 | + | const i = all.indexOf(document.activeElement); | |||
| 210 | + | if (i < 0) return false; | |||
| 211 | + | const j = core.step(i, d, all.length); | |||
| 212 | + | all[j].focus(); | |||
| 213 | + | all[j].scrollIntoView({ block: 'nearest' }); | |||
| 214 | + | return true; | |||
| 215 | + | } | |||
| 216 | + | function leaveFiles() { | |||
| 217 | + | if (!links().includes(document.activeElement)) return false; | |||
| 218 | + | document.getElementById('main')?.focus({ preventScroll: true }); | |||
| 219 | + | return true; | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | // openFile opens the file of a link of the list (and loads it first). | |||
| 223 | + | function openFile(id) { | |||
| 224 | + | const f = document.getElementById(id); | |||
| 225 | + | if (f) f.open = true; | |||
| 226 | + | reveal(id); | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | function start() { | |||
| 230 | + | if (!$('#diffs[data-mr]')) return; | |||
| 231 | + | watch(); | |||
| 232 | + | // A link to a file or a comment that is not loaded yet. | |||
| 233 | + | const hash = () => { | |||
| 234 | + | const id = decodeURIComponent(location.hash.slice(1)); | |||
| 235 | + | if (id) reveal(id); | |||
| 236 | + | }; | |||
| 237 | + | addEventListener('hashchange', hash); | |||
| 238 | + | hash(); | |||
| 239 | + | // A click outside an open menu closes it. | |||
| 240 | + | addEventListener('click', (e) => { | |||
| 241 | + | for (const d of $$('details.menu[open]')) if (!d.contains(e.target)) d.open = false; | |||
| 242 | + | }); | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | return { core, start, load, reveal, openMerge, closeMenus, menuKey, markRead, focusFiles, stepFiles, leaveFiles, openFile }; | |||
| 246 | + | })(); | |||
| 247 | + | ||||
| 248 | + | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | |||
| 249 | + | else window.ermiteMR.start(); | |||
M internal/adapters/web/view.go +2 -0
| @@ -67,6 +67,8 @@ type Layout struct { | |||
| 67 | 67 | // Passkeys tells that the passkey routes exist: the header then links to | |
| 68 | 68 | // them. | |
| 69 | 69 | Passkeys bool | |
| 70 | + | // MR loads the script of the merge request page (mr.js). | |
| 71 | + | MR bool | |
| 70 | 72 | } | |
| 71 | 73 | ||
| 72 | 74 | // Link is a plain label + href pair. | |
| @@ -67,6 +67,8 @@ type Layout struct { | |||||
| 67 | // Passkeys tells that the passkey routes exist: the header then links to | 67 | // Passkeys tells that the passkey routes exist: the header then links to | ||
| 68 | // them. | 68 | // them. | ||
| 69 | Passkeys bool | 69 | Passkeys bool | ||
| 70 | + | // MR loads the script of the merge request page (mr.js). | |||
| 71 | + | MR bool | |||
| 70 | } | 72 | } | ||
| 71 | 73 | ||||
| 72 | // Link is a plain label + href pair. | 74 | // Link is a plain label + href pair. | ||
M internal/adapters/web/web.go +1 -0
| @@ -177,6 +177,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 177 | 177 | mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList)) | |
| 178 | 178 | mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow)) | |
| 179 | 179 | mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince)) | |
| 180 | + | mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles)) | |
| 180 | 181 | mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare)) | |
| 181 | 182 | // The writes: POST only, behind the gate (ServeHTTP). | |
| 182 | 183 | mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate)) | |
| @@ -177,6 +177,7 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 177 | mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList)) | 177 | mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList)) | ||
| 178 | mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow)) | 178 | mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow)) | ||
| 179 | mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince)) | 179 | mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince)) | ||
| 180 | + | mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles)) | |||
| 180 | mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare)) | 181 | mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare)) | ||
| 181 | // The writes: POST only, behind the gate (ServeHTTP). | 182 | // The writes: POST only, behind the gate (ServeHTTP). | ||
| 182 | mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate)) | 183 | mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate)) | ||
M internal/app/testdata/web/blob-binary.html +4 -1
| @@ -374,7 +374,10 @@ | |||
| 374 | 374 | k-split from:body -> ermite.layout('split'); | |
| 375 | 375 | k-unified from:body -> ermite.layout('unified'); | |
| 376 | 376 | k-collapse from:body -> ermite.collapse(); | |
| 377 | - | k-permalink from:body -> ermite.permalink()"> | |
| 377 | + | k-permalink from:body -> ermite.permalink(); | |
| 378 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 379 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 380 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 378 | 381 | </div> | |
| 379 | 382 | </body> | |
| 380 | 383 | </html> | |
| @@ -374,7 +374,10 @@ | |||||
| 374 | k-split from:body -> ermite.layout('split'); | 374 | k-split from:body -> ermite.layout('split'); | ||
| 375 | k-unified from:body -> ermite.layout('unified'); | 375 | k-unified from:body -> ermite.layout('unified'); | ||
| 376 | k-collapse from:body -> ermite.collapse(); | 376 | k-collapse from:body -> ermite.collapse(); | ||
| 377 | - | k-permalink from:body -> ermite.permalink()"> | 377 | + | k-permalink from:body -> ermite.permalink(); |
| 378 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 379 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 380 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 378 | </div> | 381 | </div> | ||
| 379 | </body> | 382 | </body> | ||
| 380 | </html> | 383 | </html> | ||
M internal/app/testdata/web/blob-code.html +4 -1
| @@ -444,7 +444,10 @@ | |||
| 444 | 444 | k-split from:body -> ermite.layout('split'); | |
| 445 | 445 | k-unified from:body -> ermite.layout('unified'); | |
| 446 | 446 | k-collapse from:body -> ermite.collapse(); | |
| 447 | - | k-permalink from:body -> ermite.permalink()"> | |
| 447 | + | k-permalink from:body -> ermite.permalink(); | |
| 448 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 449 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 450 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 448 | 451 | </div> | |
| 449 | 452 | </body> | |
| 450 | 453 | </html> | |
| @@ -444,7 +444,10 @@ | |||||
| 444 | k-split from:body -> ermite.layout('split'); | 444 | k-split from:body -> ermite.layout('split'); | ||
| 445 | k-unified from:body -> ermite.layout('unified'); | 445 | k-unified from:body -> ermite.layout('unified'); | ||
| 446 | k-collapse from:body -> ermite.collapse(); | 446 | k-collapse from:body -> ermite.collapse(); | ||
| 447 | - | k-permalink from:body -> ermite.permalink()"> | 447 | + | k-permalink from:body -> ermite.permalink(); |
| 448 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 449 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 450 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 448 | </div> | 451 | </div> | ||
| 449 | </body> | 452 | </body> | ||
| 450 | </html> | 453 | </html> | ||
M internal/app/testdata/web/blob-empty.html +4 -1
| @@ -374,7 +374,10 @@ | |||
| 374 | 374 | k-split from:body -> ermite.layout('split'); | |
| 375 | 375 | k-unified from:body -> ermite.layout('unified'); | |
| 376 | 376 | k-collapse from:body -> ermite.collapse(); | |
| 377 | - | k-permalink from:body -> ermite.permalink()"> | |
| 377 | + | k-permalink from:body -> ermite.permalink(); | |
| 378 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 379 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 380 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 378 | 381 | </div> | |
| 379 | 382 | </body> | |
| 380 | 383 | </html> | |
| @@ -374,7 +374,10 @@ | |||||
| 374 | k-split from:body -> ermite.layout('split'); | 374 | k-split from:body -> ermite.layout('split'); | ||
| 375 | k-unified from:body -> ermite.layout('unified'); | 375 | k-unified from:body -> ermite.layout('unified'); | ||
| 376 | k-collapse from:body -> ermite.collapse(); | 376 | k-collapse from:body -> ermite.collapse(); | ||
| 377 | - | k-permalink from:body -> ermite.permalink()"> | 377 | + | k-permalink from:body -> ermite.permalink(); |
| 378 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 379 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 380 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 378 | </div> | 381 | </div> | ||
| 379 | </body> | 382 | </body> | ||
| 380 | </html> | 383 | </html> | ||
M internal/app/testdata/web/blob-image.html +4 -1
| @@ -376,7 +376,10 @@ | |||
| 376 | 376 | k-split from:body -> ermite.layout('split'); | |
| 377 | 377 | k-unified from:body -> ermite.layout('unified'); | |
| 378 | 378 | k-collapse from:body -> ermite.collapse(); | |
| 379 | - | k-permalink from:body -> ermite.permalink()"> | |
| 379 | + | k-permalink from:body -> ermite.permalink(); | |
| 380 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 381 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 382 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 380 | 383 | </div> | |
| 381 | 384 | </body> | |
| 382 | 385 | </html> | |
| @@ -376,7 +376,10 @@ | |||||
| 376 | k-split from:body -> ermite.layout('split'); | 376 | k-split from:body -> ermite.layout('split'); | ||
| 377 | k-unified from:body -> ermite.layout('unified'); | 377 | k-unified from:body -> ermite.layout('unified'); | ||
| 378 | k-collapse from:body -> ermite.collapse(); | 378 | k-collapse from:body -> ermite.collapse(); | ||
| 379 | - | k-permalink from:body -> ermite.permalink()"> | 379 | + | k-permalink from:body -> ermite.permalink(); |
| 380 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 381 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 382 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 380 | </div> | 383 | </div> | ||
| 381 | </body> | 384 | </body> | ||
| 382 | </html> | 385 | </html> | ||
M internal/app/testdata/web/blob-markdown.html +4 -1
| @@ -441,7 +441,10 @@ | |||
| 441 | 441 | k-split from:body -> ermite.layout('split'); | |
| 442 | 442 | k-unified from:body -> ermite.layout('unified'); | |
| 443 | 443 | k-collapse from:body -> ermite.collapse(); | |
| 444 | - | k-permalink from:body -> ermite.permalink()"> | |
| 444 | + | k-permalink from:body -> ermite.permalink(); | |
| 445 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 446 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 447 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 445 | 448 | </div> | |
| 446 | 449 | </body> | |
| 447 | 450 | </html> | |
| @@ -441,7 +441,10 @@ | |||||
| 441 | k-split from:body -> ermite.layout('split'); | 441 | k-split from:body -> ermite.layout('split'); | ||
| 442 | k-unified from:body -> ermite.layout('unified'); | 442 | k-unified from:body -> ermite.layout('unified'); | ||
| 443 | k-collapse from:body -> ermite.collapse(); | 443 | k-collapse from:body -> ermite.collapse(); | ||
| 444 | - | k-permalink from:body -> ermite.permalink()"> | 444 | + | k-permalink from:body -> ermite.permalink(); |
| 445 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 446 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 447 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 445 | </div> | 448 | </div> | ||
| 446 | </body> | 449 | </body> | ||
| 447 | </html> | 450 | </html> | ||
M internal/app/testdata/web/blob-svg.html +4 -1
| @@ -399,7 +399,10 @@ | |||
| 399 | 399 | k-split from:body -> ermite.layout('split'); | |
| 400 | 400 | k-unified from:body -> ermite.layout('unified'); | |
| 401 | 401 | k-collapse from:body -> ermite.collapse(); | |
| 402 | - | k-permalink from:body -> ermite.permalink()"> | |
| 402 | + | k-permalink from:body -> ermite.permalink(); | |
| 403 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 404 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 405 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 403 | 406 | </div> | |
| 404 | 407 | </body> | |
| 405 | 408 | </html> | |
| @@ -399,7 +399,10 @@ | |||||
| 399 | k-split from:body -> ermite.layout('split'); | 399 | k-split from:body -> ermite.layout('split'); | ||
| 400 | k-unified from:body -> ermite.layout('unified'); | 400 | k-unified from:body -> ermite.layout('unified'); | ||
| 401 | k-collapse from:body -> ermite.collapse(); | 401 | k-collapse from:body -> ermite.collapse(); | ||
| 402 | - | k-permalink from:body -> ermite.permalink()"> | 402 | + | k-permalink from:body -> ermite.permalink(); |
| 403 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 404 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 405 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 403 | </div> | 406 | </div> | ||
| 404 | </body> | 407 | </body> | ||
| 405 | </html> | 408 | </html> | ||
M internal/app/testdata/web/blob-text.html +4 -1
| @@ -394,7 +394,10 @@ | |||
| 394 | 394 | k-split from:body -> ermite.layout('split'); | |
| 395 | 395 | k-unified from:body -> ermite.layout('unified'); | |
| 396 | 396 | k-collapse from:body -> ermite.collapse(); | |
| 397 | - | k-permalink from:body -> ermite.permalink()"> | |
| 397 | + | k-permalink from:body -> ermite.permalink(); | |
| 398 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 399 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 400 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 398 | 401 | </div> | |
| 399 | 402 | </body> | |
| 400 | 403 | </html> | |
| @@ -394,7 +394,10 @@ | |||||
| 394 | k-split from:body -> ermite.layout('split'); | 394 | k-split from:body -> ermite.layout('split'); | ||
| 395 | k-unified from:body -> ermite.layout('unified'); | 395 | k-unified from:body -> ermite.layout('unified'); | ||
| 396 | k-collapse from:body -> ermite.collapse(); | 396 | k-collapse from:body -> ermite.collapse(); | ||
| 397 | - | k-permalink from:body -> ermite.permalink()"> | 397 | + | k-permalink from:body -> ermite.permalink(); |
| 398 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 399 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 400 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 398 | </div> | 401 | </div> | ||
| 399 | </body> | 402 | </body> | ||
| 400 | </html> | 403 | </html> | ||
M internal/app/testdata/web/blob-too-large.html +4 -1
| @@ -374,7 +374,10 @@ | |||
| 374 | 374 | k-split from:body -> ermite.layout('split'); | |
| 375 | 375 | k-unified from:body -> ermite.layout('unified'); | |
| 376 | 376 | k-collapse from:body -> ermite.collapse(); | |
| 377 | - | k-permalink from:body -> ermite.permalink()"> | |
| 377 | + | k-permalink from:body -> ermite.permalink(); | |
| 378 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 379 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 380 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 378 | 381 | </div> | |
| 379 | 382 | </body> | |
| 380 | 383 | </html> | |
| @@ -374,7 +374,10 @@ | |||||
| 374 | k-split from:body -> ermite.layout('split'); | 374 | k-split from:body -> ermite.layout('split'); | ||
| 375 | k-unified from:body -> ermite.layout('unified'); | 375 | k-unified from:body -> ermite.layout('unified'); | ||
| 376 | k-collapse from:body -> ermite.collapse(); | 376 | k-collapse from:body -> ermite.collapse(); | ||
| 377 | - | k-permalink from:body -> ermite.permalink()"> | 377 | + | k-permalink from:body -> ermite.permalink(); |
| 378 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 379 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 380 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 378 | </div> | 381 | </div> | ||
| 379 | </body> | 382 | </body> | ||
| 380 | </html> | 383 | </html> | ||
M internal/app/testdata/web/blob-trojan.html +4 -1
| @@ -441,7 +441,10 @@ | |||
| 441 | 441 | k-split from:body -> ermite.layout('split'); | |
| 442 | 442 | k-unified from:body -> ermite.layout('unified'); | |
| 443 | 443 | k-collapse from:body -> ermite.collapse(); | |
| 444 | - | k-permalink from:body -> ermite.permalink()"> | |
| 444 | + | k-permalink from:body -> ermite.permalink(); | |
| 445 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 446 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 447 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 445 | 448 | </div> | |
| 446 | 449 | </body> | |
| 447 | 450 | </html> | |
| @@ -441,7 +441,10 @@ | |||||
| 441 | k-split from:body -> ermite.layout('split'); | 441 | k-split from:body -> ermite.layout('split'); | ||
| 442 | k-unified from:body -> ermite.layout('unified'); | 442 | k-unified from:body -> ermite.layout('unified'); | ||
| 443 | k-collapse from:body -> ermite.collapse(); | 443 | k-collapse from:body -> ermite.collapse(); | ||
| 444 | - | k-permalink from:body -> ermite.permalink()"> | 444 | + | k-permalink from:body -> ermite.permalink(); |
| 445 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 446 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 447 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 445 | </div> | 448 | </div> | ||
| 446 | </body> | 449 | </body> | ||
| 447 | </html> | 450 | </html> | ||
M internal/app/testdata/web/commit-big.html +14 -10
| @@ -130,7 +130,7 @@ | |||
| 130 | 130 | </div> | |
| 131 | 131 | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | |
| 132 | 132 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 133 | - | <details class="dfile" id="f1" data-file> | |
| 133 | + | <details class="dfile lnw-2" id="f1" data-file> | |
| 134 | 134 | <summary> | |
| 135 | 135 | <span class="st">A</span> | |
| 136 | 136 | <span class="p">big.txt</span> | |
| @@ -148,18 +148,19 @@ | |||
| 148 | 148 | </h2> | |
| 149 | 149 | <ol class="files" aria-label="Changed files"> | |
| 150 | 150 | <li> | |
| 151 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 152 | - | <span class="st" title="A">A</span> | |
| 153 | - | <span class="p" title="big.txt">big.txt</span> | |
| 151 | + | <a href="#f1" title="big.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 152 | + | <span class="st" data-st="A" title="added">A</span> | |
| 153 | + | <span class="nm"> | |
| 154 | + | <span class="p">big.txt</span> | |
| 155 | + | <span class="d"> | |
| 156 | + | <bdi> | |
| 157 | + | </bdi> | |
| 158 | + | </span> | |
| 159 | + | </span> | |
| 154 | 160 | <span class="n"> | |
| 155 | 161 | <span class="plus">+2600</span> | |
| 156 | 162 | <span class="minus">-0</span> | |
| 157 | 163 | </span> | |
| 158 | - | <span class="b" aria-hidden="true"> | |
| 159 | - | <span class="plus">++++++++++</span> | |
| 160 | - | <span class="minus"> | |
| 161 | - | </span> | |
| 162 | - | </span> | |
| 163 | 164 | </a> | |
| 164 | 165 | </li> | |
| 165 | 166 | </ol> | |
| @@ -411,7 +412,10 @@ | |||
| 411 | 412 | k-split from:body -> ermite.layout('split'); | |
| 412 | 413 | k-unified from:body -> ermite.layout('unified'); | |
| 413 | 414 | k-collapse from:body -> ermite.collapse(); | |
| 414 | - | k-permalink from:body -> ermite.permalink()"> | |
| 415 | + | k-permalink from:body -> ermite.permalink(); | |
| 416 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 417 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 418 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 415 | 419 | </div> | |
| 416 | 420 | </body> | |
| 417 | 421 | </html> | |
| @@ -130,7 +130,7 @@ | |||||
| 130 | </div> | 130 | </div> | ||
| 131 | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | 131 | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | ||
| 132 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 132 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 133 | - | <details class="dfile" id="f1" data-file> | 133 | + | <details class="dfile lnw-2" id="f1" data-file> |
| 134 | <summary> | 134 | <summary> | ||
| 135 | <span class="st">A</span> | 135 | <span class="st">A</span> | ||
| 136 | <span class="p">big.txt</span> | 136 | <span class="p">big.txt</span> | ||
| @@ -148,18 +148,19 @@ | |||||
| 148 | </h2> | 148 | </h2> | ||
| 149 | <ol class="files" aria-label="Changed files"> | 149 | <ol class="files" aria-label="Changed files"> | ||
| 150 | <li> | 150 | <li> | ||
| 151 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 151 | + | <a href="#f1" title="big.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 152 | - | <span class="st" title="A">A</span> | 152 | + | <span class="st" data-st="A" title="added">A</span> |
| 153 | - | <span class="p" title="big.txt">big.txt</span> | 153 | + | <span class="nm"> |
| 154 | + | <span class="p">big.txt</span> | |||
| 155 | + | <span class="d"> | |||
| 156 | + | <bdi> | |||
| 157 | + | </bdi> | |||
| 158 | + | </span> | |||
| 159 | + | </span> | |||
| 154 | <span class="n"> | 160 | <span class="n"> | ||
| 155 | <span class="plus">+2600</span> | 161 | <span class="plus">+2600</span> | ||
| 156 | <span class="minus">-0</span> | 162 | <span class="minus">-0</span> | ||
| 157 | </span> | 163 | </span> | ||
| 158 | - | <span class="b" aria-hidden="true"> | |||
| 159 | - | <span class="plus">++++++++++</span> | |||
| 160 | - | <span class="minus"> | |||
| 161 | - | </span> | |||
| 162 | - | </span> | |||
| 163 | </a> | 164 | </a> | ||
| 164 | </li> | 165 | </li> | ||
| 165 | </ol> | 166 | </ol> | ||
| @@ -411,7 +412,10 @@ | |||||
| 411 | k-split from:body -> ermite.layout('split'); | 412 | k-split from:body -> ermite.layout('split'); | ||
| 412 | k-unified from:body -> ermite.layout('unified'); | 413 | k-unified from:body -> ermite.layout('unified'); | ||
| 413 | k-collapse from:body -> ermite.collapse(); | 414 | k-collapse from:body -> ermite.collapse(); | ||
| 414 | - | k-permalink from:body -> ermite.permalink()"> | 415 | + | k-permalink from:body -> ermite.permalink(); |
| 416 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 417 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 418 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 415 | </div> | 419 | </div> | ||
| 416 | </body> | 420 | </body> | ||
| 417 | </html> | 421 | </html> | ||
M internal/app/testdata/web/commit-binary.html +13 -12
| @@ -125,7 +125,7 @@ | |||
| 125 | 125 | </button> | |
| 126 | 126 | </div> | |
| 127 | 127 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 128 | - | <details class="dfile" id="f1" data-file> | |
| 128 | + | <details class="dfile lnw-2" id="f1" data-file> | |
| 129 | 129 | <summary> | |
| 130 | 130 | <span class="st">M</span> | |
| 131 | 131 | <span class="p">logo.bin</span> | |
| @@ -143,18 +143,16 @@ | |||
| 143 | 143 | </h2> | |
| 144 | 144 | <ol class="files" aria-label="Changed files"> | |
| 145 | 145 | <li> | |
| 146 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 147 | - | <span class="st" title="M">M</span> | |
| 148 | - | <span class="p" title="logo.bin">logo.bin</span> | |
| 149 | - | <span class="n"> | |
| 150 | - | <span class="plus">+0</span> | |
| 151 | - | <span class="minus">-0</span> | |
| 146 | + | <a href="#f1" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 147 | + | <span class="st" data-st="M" title="modified">M</span> | |
| 148 | + | <span class="nm"> | |
| 149 | + | <span class="p">logo.bin</span> | |
| 150 | + | <span class="d"> | |
| 151 | + | <bdi> | |
| 152 | + | </bdi> | |
| 152 | 153 | </span> | |
| 153 | - | <span class="b" aria-hidden="true"> | |
| 154 | - | <span class="plus"> | |
| 155 | 154 | </span> | |
| 156 | - | <span class="minus"> | |
| 157 | - | </span>bin</span> | |
| 155 | + | <span class="n mute">bin</span> | |
| 158 | 156 | </a> | |
| 159 | 157 | </li> | |
| 160 | 158 | </ol> | |
| @@ -406,7 +404,10 @@ | |||
| 406 | 404 | k-split from:body -> ermite.layout('split'); | |
| 407 | 405 | k-unified from:body -> ermite.layout('unified'); | |
| 408 | 406 | k-collapse from:body -> ermite.collapse(); | |
| 409 | - | k-permalink from:body -> ermite.permalink()"> | |
| 407 | + | k-permalink from:body -> ermite.permalink(); | |
| 408 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 409 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 410 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 410 | 411 | </div> | |
| 411 | 412 | </body> | |
| 412 | 413 | </html> | |
| @@ -125,7 +125,7 @@ | |||||
| 125 | </button> | 125 | </button> | ||
| 126 | </div> | 126 | </div> | ||
| 127 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 127 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 128 | - | <details class="dfile" id="f1" data-file> | 128 | + | <details class="dfile lnw-2" id="f1" data-file> |
| 129 | <summary> | 129 | <summary> | ||
| 130 | <span class="st">M</span> | 130 | <span class="st">M</span> | ||
| 131 | <span class="p">logo.bin</span> | 131 | <span class="p">logo.bin</span> | ||
| @@ -143,18 +143,16 @@ | |||||
| 143 | </h2> | 143 | </h2> | ||
| 144 | <ol class="files" aria-label="Changed files"> | 144 | <ol class="files" aria-label="Changed files"> | ||
| 145 | <li> | 145 | <li> | ||
| 146 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 146 | + | <a href="#f1" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 147 | - | <span class="st" title="M">M</span> | 147 | + | <span class="st" data-st="M" title="modified">M</span> |
| 148 | - | <span class="p" title="logo.bin">logo.bin</span> | 148 | + | <span class="nm"> |
| 149 | - | <span class="n"> | 149 | + | <span class="p">logo.bin</span> |
| 150 | - | <span class="plus">+0</span> | 150 | + | <span class="d"> |
| 151 | - | <span class="minus">-0</span> | 151 | + | <bdi> |
| 152 | + | </bdi> | |||
| 152 | </span> | 153 | </span> | ||
| 153 | - | <span class="b" aria-hidden="true"> | |||
| 154 | - | <span class="plus"> | |||
| 155 | </span> | 154 | </span> | ||
| 156 | - | <span class="minus"> | 155 | + | <span class="n mute">bin</span> |
| 157 | - | </span>bin</span> | |||
| 158 | </a> | 156 | </a> | ||
| 159 | </li> | 157 | </li> | ||
| 160 | </ol> | 158 | </ol> | ||
| @@ -406,7 +404,10 @@ | |||||
| 406 | k-split from:body -> ermite.layout('split'); | 404 | k-split from:body -> ermite.layout('split'); | ||
| 407 | k-unified from:body -> ermite.layout('unified'); | 405 | k-unified from:body -> ermite.layout('unified'); | ||
| 408 | k-collapse from:body -> ermite.collapse(); | 406 | k-collapse from:body -> ermite.collapse(); | ||
| 409 | - | k-permalink from:body -> ermite.permalink()"> | 407 | + | k-permalink from:body -> ermite.permalink(); |
| 408 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 409 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 410 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 410 | </div> | 411 | </div> | ||
| 411 | </body> | 412 | </body> | ||
| 412 | </html> | 413 | </html> | ||
M internal/app/testdata/web/commit-merge.html +14 -10
| @@ -124,7 +124,7 @@ | |||
| 124 | 124 | </button> | |
| 125 | 125 | </div> | |
| 126 | 126 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 127 | - | <details class="dfile" id="f1" data-file open> | |
| 127 | + | <details class="dfile lnw-2" id="f1" data-file open> | |
| 128 | 128 | <summary> | |
| 129 | 129 | <span class="st">A</span> | |
| 130 | 130 | <span class="p">side.txt</span> | |
| @@ -182,18 +182,19 @@ | |||
| 182 | 182 | </h2> | |
| 183 | 183 | <ol class="files" aria-label="Changed files"> | |
| 184 | 184 | <li> | |
| 185 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 186 | - | <span class="st" title="A">A</span> | |
| 187 | - | <span class="p" title="side.txt">side.txt</span> | |
| 185 | + | <a href="#f1" title="side.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 186 | + | <span class="st" data-st="A" title="added">A</span> | |
| 187 | + | <span class="nm"> | |
| 188 | + | <span class="p">side.txt</span> | |
| 189 | + | <span class="d"> | |
| 190 | + | <bdi> | |
| 191 | + | </bdi> | |
| 192 | + | </span> | |
| 193 | + | </span> | |
| 188 | 194 | <span class="n"> | |
| 189 | 195 | <span class="plus">+1</span> | |
| 190 | 196 | <span class="minus">-0</span> | |
| 191 | 197 | </span> | |
| 192 | - | <span class="b" aria-hidden="true"> | |
| 193 | - | <span class="plus">++++++++++</span> | |
| 194 | - | <span class="minus"> | |
| 195 | - | </span> | |
| 196 | - | </span> | |
| 197 | 198 | </a> | |
| 198 | 199 | </li> | |
| 199 | 200 | </ol> | |
| @@ -445,7 +446,10 @@ | |||
| 445 | 446 | k-split from:body -> ermite.layout('split'); | |
| 446 | 447 | k-unified from:body -> ermite.layout('unified'); | |
| 447 | 448 | k-collapse from:body -> ermite.collapse(); | |
| 448 | - | k-permalink from:body -> ermite.permalink()"> | |
| 449 | + | k-permalink from:body -> ermite.permalink(); | |
| 450 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 451 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 452 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 449 | 453 | </div> | |
| 450 | 454 | </body> | |
| 451 | 455 | </html> | |
| @@ -124,7 +124,7 @@ | |||||
| 124 | </button> | 124 | </button> | ||
| 125 | </div> | 125 | </div> | ||
| 126 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 126 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 127 | - | <details class="dfile" id="f1" data-file open> | 127 | + | <details class="dfile lnw-2" id="f1" data-file open> |
| 128 | <summary> | 128 | <summary> | ||
| 129 | <span class="st">A</span> | 129 | <span class="st">A</span> | ||
| 130 | <span class="p">side.txt</span> | 130 | <span class="p">side.txt</span> | ||
| @@ -182,18 +182,19 @@ | |||||
| 182 | </h2> | 182 | </h2> | ||
| 183 | <ol class="files" aria-label="Changed files"> | 183 | <ol class="files" aria-label="Changed files"> | ||
| 184 | <li> | 184 | <li> | ||
| 185 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 185 | + | <a href="#f1" title="side.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 186 | - | <span class="st" title="A">A</span> | 186 | + | <span class="st" data-st="A" title="added">A</span> |
| 187 | - | <span class="p" title="side.txt">side.txt</span> | 187 | + | <span class="nm"> |
| 188 | + | <span class="p">side.txt</span> | |||
| 189 | + | <span class="d"> | |||
| 190 | + | <bdi> | |||
| 191 | + | </bdi> | |||
| 192 | + | </span> | |||
| 193 | + | </span> | |||
| 188 | <span class="n"> | 194 | <span class="n"> | ||
| 189 | <span class="plus">+1</span> | 195 | <span class="plus">+1</span> | ||
| 190 | <span class="minus">-0</span> | 196 | <span class="minus">-0</span> | ||
| 191 | </span> | 197 | </span> | ||
| 192 | - | <span class="b" aria-hidden="true"> | |||
| 193 | - | <span class="plus">++++++++++</span> | |||
| 194 | - | <span class="minus"> | |||
| 195 | - | </span> | |||
| 196 | - | </span> | |||
| 197 | </a> | 198 | </a> | ||
| 198 | </li> | 199 | </li> | ||
| 199 | </ol> | 200 | </ol> | ||
| @@ -445,7 +446,10 @@ | |||||
| 445 | k-split from:body -> ermite.layout('split'); | 446 | k-split from:body -> ermite.layout('split'); | ||
| 446 | k-unified from:body -> ermite.layout('unified'); | 447 | k-unified from:body -> ermite.layout('unified'); | ||
| 447 | k-collapse from:body -> ermite.collapse(); | 448 | k-collapse from:body -> ermite.collapse(); | ||
| 448 | - | k-permalink from:body -> ermite.permalink()"> | 449 | + | k-permalink from:body -> ermite.permalink(); |
| 450 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 451 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 452 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 449 | </div> | 453 | </div> | ||
| 450 | </body> | 454 | </body> | ||
| 451 | </html> | 455 | </html> | ||
M internal/app/testdata/web/commit-modify-split.html +24 -18
| @@ -131,7 +131,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 131 | 131 | </button> | |
| 132 | 132 | </div> | |
| 133 | 133 | <div id="diffs" data-diff="split"> | |
| 134 | - | <details class="dfile" id="f1" data-file open> | |
| 134 | + | <details class="dfile lnw-2" id="f1" data-file open> | |
| 135 | 135 | <summary> | |
| 136 | 136 | <span class="st">M</span> | |
| 137 | 137 | <span class="p">a.txt</span> | |
| @@ -271,7 +271,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 271 | 271 | </table> | |
| 272 | 272 | </div> | |
| 273 | 273 | </details> | |
| 274 | - | <details class="dfile" id="f2" data-file> | |
| 274 | + | <details class="dfile lnw-2" id="f2" data-file> | |
| 275 | 275 | <summary> | |
| 276 | 276 | <span class="st">M</span> | |
| 277 | 277 | <span class="p">run.sh</span> | |
| @@ -289,32 +289,35 @@ And <b>html</b> & "quotes".</pre> | |||
| 289 | 289 | </h2> | |
| 290 | 290 | <ol class="files" aria-label="Changed files"> | |
| 291 | 291 | <li> | |
| 292 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 293 | - | <span class="st" title="M">M</span> | |
| 294 | - | <span class="p" title="a.txt">a.txt</span> | |
| 292 | + | <a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 293 | + | <span class="st" data-st="M" title="modified">M</span> | |
| 294 | + | <span class="nm"> | |
| 295 | + | <span class="p">a.txt</span> | |
| 296 | + | <span class="d"> | |
| 297 | + | <bdi> | |
| 298 | + | </bdi> | |
| 299 | + | </span> | |
| 300 | + | </span> | |
| 295 | 301 | <span class="n"> | |
| 296 | 302 | <span class="plus">+2</span> | |
| 297 | 303 | <span class="minus">-1</span> | |
| 298 | 304 | </span> | |
| 299 | - | <span class="b" aria-hidden="true"> | |
| 300 | - | <span class="plus">+++++++</span> | |
| 301 | - | <span class="minus">---</span> | |
| 302 | - | </span> | |
| 303 | 305 | </a> | |
| 304 | 306 | </li> | |
| 305 | 307 | <li> | |
| 306 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 307 | - | <span class="st" title="M">M</span> | |
| 308 | - | <span class="p" title="run.sh">run.sh</span> | |
| 308 | + | <a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 309 | + | <span class="st" data-st="M" title="modified">M</span> | |
| 310 | + | <span class="nm"> | |
| 311 | + | <span class="p">run.sh</span> | |
| 312 | + | <span class="d"> | |
| 313 | + | <bdi> | |
| 314 | + | </bdi> | |
| 315 | + | </span> | |
| 316 | + | </span> | |
| 309 | 317 | <span class="n"> | |
| 310 | 318 | <span class="plus">+0</span> | |
| 311 | 319 | <span class="minus">-0</span> | |
| 312 | 320 | </span> | |
| 313 | - | <span class="b" aria-hidden="true"> | |
| 314 | - | <span class="plus"> | |
| 315 | - | </span> | |
| 316 | - | <span class="minus"> | |
| 317 | - | </span>··········</span> | |
| 318 | 321 | </a> | |
| 319 | 322 | </li> | |
| 320 | 323 | </ol> | |
| @@ -566,7 +569,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 566 | 569 | k-split from:body -> ermite.layout('split'); | |
| 567 | 570 | k-unified from:body -> ermite.layout('unified'); | |
| 568 | 571 | k-collapse from:body -> ermite.collapse(); | |
| 569 | - | k-permalink from:body -> ermite.permalink()"> | |
| 572 | + | k-permalink from:body -> ermite.permalink(); | |
| 573 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 574 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 575 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 570 | 576 | </div> | |
| 571 | 577 | </body> | |
| 572 | 578 | </html> | |
| @@ -131,7 +131,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 131 | </button> | 131 | </button> | ||
| 132 | </div> | 132 | </div> | ||
| 133 | <div id="diffs" data-diff="split"> | 133 | <div id="diffs" data-diff="split"> | ||
| 134 | - | <details class="dfile" id="f1" data-file open> | 134 | + | <details class="dfile lnw-2" id="f1" data-file open> |
| 135 | <summary> | 135 | <summary> | ||
| 136 | <span class="st">M</span> | 136 | <span class="st">M</span> | ||
| 137 | <span class="p">a.txt</span> | 137 | <span class="p">a.txt</span> | ||
| @@ -271,7 +271,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 271 | </table> | 271 | </table> | ||
| 272 | </div> | 272 | </div> | ||
| 273 | </details> | 273 | </details> | ||
| 274 | - | <details class="dfile" id="f2" data-file> | 274 | + | <details class="dfile lnw-2" id="f2" data-file> |
| 275 | <summary> | 275 | <summary> | ||
| 276 | <span class="st">M</span> | 276 | <span class="st">M</span> | ||
| 277 | <span class="p">run.sh</span> | 277 | <span class="p">run.sh</span> | ||
| @@ -289,32 +289,35 @@ And <b>html</b> & "quotes".</pre> | |||||
| 289 | </h2> | 289 | </h2> | ||
| 290 | <ol class="files" aria-label="Changed files"> | 290 | <ol class="files" aria-label="Changed files"> | ||
| 291 | <li> | 291 | <li> | ||
| 292 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 292 | + | <a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 293 | - | <span class="st" title="M">M</span> | 293 | + | <span class="st" data-st="M" title="modified">M</span> |
| 294 | - | <span class="p" title="a.txt">a.txt</span> | 294 | + | <span class="nm"> |
| 295 | + | <span class="p">a.txt</span> | |||
| 296 | + | <span class="d"> | |||
| 297 | + | <bdi> | |||
| 298 | + | </bdi> | |||
| 299 | + | </span> | |||
| 300 | + | </span> | |||
| 295 | <span class="n"> | 301 | <span class="n"> | ||
| 296 | <span class="plus">+2</span> | 302 | <span class="plus">+2</span> | ||
| 297 | <span class="minus">-1</span> | 303 | <span class="minus">-1</span> | ||
| 298 | </span> | 304 | </span> | ||
| 299 | - | <span class="b" aria-hidden="true"> | |||
| 300 | - | <span class="plus">+++++++</span> | |||
| 301 | - | <span class="minus">---</span> | |||
| 302 | - | </span> | |||
| 303 | </a> | 305 | </a> | ||
| 304 | </li> | 306 | </li> | ||
| 305 | <li> | 307 | <li> | ||
| 306 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 308 | + | <a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 307 | - | <span class="st" title="M">M</span> | 309 | + | <span class="st" data-st="M" title="modified">M</span> |
| 308 | - | <span class="p" title="run.sh">run.sh</span> | 310 | + | <span class="nm"> |
| 311 | + | <span class="p">run.sh</span> | |||
| 312 | + | <span class="d"> | |||
| 313 | + | <bdi> | |||
| 314 | + | </bdi> | |||
| 315 | + | </span> | |||
| 316 | + | </span> | |||
| 309 | <span class="n"> | 317 | <span class="n"> | ||
| 310 | <span class="plus">+0</span> | 318 | <span class="plus">+0</span> | ||
| 311 | <span class="minus">-0</span> | 319 | <span class="minus">-0</span> | ||
| 312 | </span> | 320 | </span> | ||
| 313 | - | <span class="b" aria-hidden="true"> | |||
| 314 | - | <span class="plus"> | |||
| 315 | - | </span> | |||
| 316 | - | <span class="minus"> | |||
| 317 | - | </span>··········</span> | |||
| 318 | </a> | 321 | </a> | ||
| 319 | </li> | 322 | </li> | ||
| 320 | </ol> | 323 | </ol> | ||
| @@ -566,7 +569,10 @@ And <b>html</b> & "quotes".</pre> | |||||
| 566 | k-split from:body -> ermite.layout('split'); | 569 | k-split from:body -> ermite.layout('split'); | ||
| 567 | k-unified from:body -> ermite.layout('unified'); | 570 | k-unified from:body -> ermite.layout('unified'); | ||
| 568 | k-collapse from:body -> ermite.collapse(); | 571 | k-collapse from:body -> ermite.collapse(); | ||
| 569 | - | k-permalink from:body -> ermite.permalink()"> | 572 | + | k-permalink from:body -> ermite.permalink(); |
| 573 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 574 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 575 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 570 | </div> | 576 | </div> | ||
| 571 | </body> | 577 | </body> | ||
| 572 | </html> | 578 | </html> | ||
M internal/app/testdata/web/commit-modify.html +24 -18
| @@ -131,7 +131,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 131 | 131 | </button> | |
| 132 | 132 | </div> | |
| 133 | 133 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 134 | - | <details class="dfile" id="f1" data-file open> | |
| 134 | + | <details class="dfile lnw-2" id="f1" data-file open> | |
| 135 | 135 | <summary> | |
| 136 | 136 | <span class="st">M</span> | |
| 137 | 137 | <span class="p">a.txt</span> | |
| @@ -271,7 +271,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 271 | 271 | </table> | |
| 272 | 272 | </div> | |
| 273 | 273 | </details> | |
| 274 | - | <details class="dfile" id="f2" data-file> | |
| 274 | + | <details class="dfile lnw-2" id="f2" data-file> | |
| 275 | 275 | <summary> | |
| 276 | 276 | <span class="st">M</span> | |
| 277 | 277 | <span class="p">run.sh</span> | |
| @@ -289,32 +289,35 @@ And <b>html</b> & "quotes".</pre> | |||
| 289 | 289 | </h2> | |
| 290 | 290 | <ol class="files" aria-label="Changed files"> | |
| 291 | 291 | <li> | |
| 292 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 293 | - | <span class="st" title="M">M</span> | |
| 294 | - | <span class="p" title="a.txt">a.txt</span> | |
| 292 | + | <a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 293 | + | <span class="st" data-st="M" title="modified">M</span> | |
| 294 | + | <span class="nm"> | |
| 295 | + | <span class="p">a.txt</span> | |
| 296 | + | <span class="d"> | |
| 297 | + | <bdi> | |
| 298 | + | </bdi> | |
| 299 | + | </span> | |
| 300 | + | </span> | |
| 295 | 301 | <span class="n"> | |
| 296 | 302 | <span class="plus">+2</span> | |
| 297 | 303 | <span class="minus">-1</span> | |
| 298 | 304 | </span> | |
| 299 | - | <span class="b" aria-hidden="true"> | |
| 300 | - | <span class="plus">+++++++</span> | |
| 301 | - | <span class="minus">---</span> | |
| 302 | - | </span> | |
| 303 | 305 | </a> | |
| 304 | 306 | </li> | |
| 305 | 307 | <li> | |
| 306 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 307 | - | <span class="st" title="M">M</span> | |
| 308 | - | <span class="p" title="run.sh">run.sh</span> | |
| 308 | + | <a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 309 | + | <span class="st" data-st="M" title="modified">M</span> | |
| 310 | + | <span class="nm"> | |
| 311 | + | <span class="p">run.sh</span> | |
| 312 | + | <span class="d"> | |
| 313 | + | <bdi> | |
| 314 | + | </bdi> | |
| 315 | + | </span> | |
| 316 | + | </span> | |
| 309 | 317 | <span class="n"> | |
| 310 | 318 | <span class="plus">+0</span> | |
| 311 | 319 | <span class="minus">-0</span> | |
| 312 | 320 | </span> | |
| 313 | - | <span class="b" aria-hidden="true"> | |
| 314 | - | <span class="plus"> | |
| 315 | - | </span> | |
| 316 | - | <span class="minus"> | |
| 317 | - | </span>··········</span> | |
| 318 | 321 | </a> | |
| 319 | 322 | </li> | |
| 320 | 323 | </ol> | |
| @@ -566,7 +569,10 @@ And <b>html</b> & "quotes".</pre> | |||
| 566 | 569 | k-split from:body -> ermite.layout('split'); | |
| 567 | 570 | k-unified from:body -> ermite.layout('unified'); | |
| 568 | 571 | k-collapse from:body -> ermite.collapse(); | |
| 569 | - | k-permalink from:body -> ermite.permalink()"> | |
| 572 | + | k-permalink from:body -> ermite.permalink(); | |
| 573 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 574 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 575 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 570 | 576 | </div> | |
| 571 | 577 | </body> | |
| 572 | 578 | </html> | |
| @@ -131,7 +131,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 131 | </button> | 131 | </button> | ||
| 132 | </div> | 132 | </div> | ||
| 133 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 133 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 134 | - | <details class="dfile" id="f1" data-file open> | 134 | + | <details class="dfile lnw-2" id="f1" data-file open> |
| 135 | <summary> | 135 | <summary> | ||
| 136 | <span class="st">M</span> | 136 | <span class="st">M</span> | ||
| 137 | <span class="p">a.txt</span> | 137 | <span class="p">a.txt</span> | ||
| @@ -271,7 +271,7 @@ And <b>html</b> & "quotes".</pre> | |||||
| 271 | </table> | 271 | </table> | ||
| 272 | </div> | 272 | </div> | ||
| 273 | </details> | 273 | </details> | ||
| 274 | - | <details class="dfile" id="f2" data-file> | 274 | + | <details class="dfile lnw-2" id="f2" data-file> |
| 275 | <summary> | 275 | <summary> | ||
| 276 | <span class="st">M</span> | 276 | <span class="st">M</span> | ||
| 277 | <span class="p">run.sh</span> | 277 | <span class="p">run.sh</span> | ||
| @@ -289,32 +289,35 @@ And <b>html</b> & "quotes".</pre> | |||||
| 289 | </h2> | 289 | </h2> | ||
| 290 | <ol class="files" aria-label="Changed files"> | 290 | <ol class="files" aria-label="Changed files"> | ||
| 291 | <li> | 291 | <li> | ||
| 292 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 292 | + | <a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 293 | - | <span class="st" title="M">M</span> | 293 | + | <span class="st" data-st="M" title="modified">M</span> |
| 294 | - | <span class="p" title="a.txt">a.txt</span> | 294 | + | <span class="nm"> |
| 295 | + | <span class="p">a.txt</span> | |||
| 296 | + | <span class="d"> | |||
| 297 | + | <bdi> | |||
| 298 | + | </bdi> | |||
| 299 | + | </span> | |||
| 300 | + | </span> | |||
| 295 | <span class="n"> | 301 | <span class="n"> | ||
| 296 | <span class="plus">+2</span> | 302 | <span class="plus">+2</span> | ||
| 297 | <span class="minus">-1</span> | 303 | <span class="minus">-1</span> | ||
| 298 | </span> | 304 | </span> | ||
| 299 | - | <span class="b" aria-hidden="true"> | |||
| 300 | - | <span class="plus">+++++++</span> | |||
| 301 | - | <span class="minus">---</span> | |||
| 302 | - | </span> | |||
| 303 | </a> | 305 | </a> | ||
| 304 | </li> | 306 | </li> | ||
| 305 | <li> | 307 | <li> | ||
| 306 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 308 | + | <a href="#f2" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 307 | - | <span class="st" title="M">M</span> | 309 | + | <span class="st" data-st="M" title="modified">M</span> |
| 308 | - | <span class="p" title="run.sh">run.sh</span> | 310 | + | <span class="nm"> |
| 311 | + | <span class="p">run.sh</span> | |||
| 312 | + | <span class="d"> | |||
| 313 | + | <bdi> | |||
| 314 | + | </bdi> | |||
| 315 | + | </span> | |||
| 316 | + | </span> | |||
| 309 | <span class="n"> | 317 | <span class="n"> | ||
| 310 | <span class="plus">+0</span> | 318 | <span class="plus">+0</span> | ||
| 311 | <span class="minus">-0</span> | 319 | <span class="minus">-0</span> | ||
| 312 | </span> | 320 | </span> | ||
| 313 | - | <span class="b" aria-hidden="true"> | |||
| 314 | - | <span class="plus"> | |||
| 315 | - | </span> | |||
| 316 | - | <span class="minus"> | |||
| 317 | - | </span>··········</span> | |||
| 318 | </a> | 321 | </a> | ||
| 319 | </li> | 322 | </li> | ||
| 320 | </ol> | 323 | </ol> | ||
| @@ -566,7 +569,10 @@ And <b>html</b> & "quotes".</pre> | |||||
| 566 | k-split from:body -> ermite.layout('split'); | 569 | k-split from:body -> ermite.layout('split'); | ||
| 567 | k-unified from:body -> ermite.layout('unified'); | 570 | k-unified from:body -> ermite.layout('unified'); | ||
| 568 | k-collapse from:body -> ermite.collapse(); | 571 | k-collapse from:body -> ermite.collapse(); | ||
| 569 | - | k-permalink from:body -> ermite.permalink()"> | 572 | + | k-permalink from:body -> ermite.permalink(); |
| 573 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 574 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 575 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 570 | </div> | 576 | </div> | ||
| 571 | </body> | 577 | </body> | ||
| 572 | </html> | 578 | </html> | ||
M internal/app/testdata/web/commit-rename.html +24 -19
| @@ -125,7 +125,7 @@ | |||
| 125 | 125 | </button> | |
| 126 | 126 | </div> | |
| 127 | 127 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 128 | - | <details class="dfile" id="f1" data-file> | |
| 128 | + | <details class="dfile lnw-2" id="f1" data-file> | |
| 129 | 129 | <summary> | |
| 130 | 130 | <span class="st">R</span> | |
| 131 | 131 | <span class="p">old.txt → docs-old.txt</span> | |
| @@ -135,7 +135,7 @@ | |||
| 135 | 135 | </summary> | |
| 136 | 136 | <p class="note pad">renamed, 100% similar</p> | |
| 137 | 137 | </details> | |
| 138 | - | <details class="dfile" id="f2" data-file open> | |
| 138 | + | <details class="dfile lnw-2" id="f2" data-file open> | |
| 139 | 139 | <summary> | |
| 140 | 140 | <span class="st">D</span> | |
| 141 | 141 | <span class="p">gone.txt</span> | |
| @@ -193,33 +193,35 @@ | |||
| 193 | 193 | </h2> | |
| 194 | 194 | <ol class="files" aria-label="Changed files"> | |
| 195 | 195 | <li> | |
| 196 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 197 | - | <span class="st" title="R">R</span> | |
| 198 | - | <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span> | |
| 196 | + | <a href="#f1" title="old.txt → docs-old.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 197 | + | <span class="st" data-st="R" title="renamed">R</span> | |
| 198 | + | <span class="nm"> | |
| 199 | + | <span class="p">docs-old.txt</span> | |
| 200 | + | <span class="d"> | |
| 201 | + | <bdi> | |
| 202 | + | </bdi> | |
| 203 | + | </span> | |
| 204 | + | </span> | |
| 199 | 205 | <span class="n"> | |
| 200 | 206 | <span class="plus">+0</span> | |
| 201 | 207 | <span class="minus">-0</span> | |
| 202 | 208 | </span> | |
| 203 | - | <span class="b" aria-hidden="true"> | |
| 204 | - | <span class="plus"> | |
| 205 | - | </span> | |
| 206 | - | <span class="minus"> | |
| 207 | - | </span>··········</span> | |
| 208 | 209 | </a> | |
| 209 | 210 | </li> | |
| 210 | 211 | <li> | |
| 211 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 212 | - | <span class="st" title="D">D</span> | |
| 213 | - | <span class="p" title="gone.txt">gone.txt</span> | |
| 212 | + | <a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 213 | + | <span class="st" data-st="D" title="deleted">D</span> | |
| 214 | + | <span class="nm"> | |
| 215 | + | <span class="p">gone.txt</span> | |
| 216 | + | <span class="d"> | |
| 217 | + | <bdi> | |
| 218 | + | </bdi> | |
| 219 | + | </span> | |
| 220 | + | </span> | |
| 214 | 221 | <span class="n"> | |
| 215 | 222 | <span class="plus">+0</span> | |
| 216 | 223 | <span class="minus">-1</span> | |
| 217 | 224 | </span> | |
| 218 | - | <span class="b" aria-hidden="true"> | |
| 219 | - | <span class="plus"> | |
| 220 | - | </span> | |
| 221 | - | <span class="minus">----------</span> | |
| 222 | - | </span> | |
| 223 | 225 | </a> | |
| 224 | 226 | </li> | |
| 225 | 227 | </ol> | |
| @@ -471,7 +473,10 @@ | |||
| 471 | 473 | k-split from:body -> ermite.layout('split'); | |
| 472 | 474 | k-unified from:body -> ermite.layout('unified'); | |
| 473 | 475 | k-collapse from:body -> ermite.collapse(); | |
| 474 | - | k-permalink from:body -> ermite.permalink()"> | |
| 476 | + | k-permalink from:body -> ermite.permalink(); | |
| 477 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 478 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 479 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 475 | 480 | </div> | |
| 476 | 481 | </body> | |
| 477 | 482 | </html> | |
| @@ -125,7 +125,7 @@ | |||||
| 125 | </button> | 125 | </button> | ||
| 126 | </div> | 126 | </div> | ||
| 127 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 127 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 128 | - | <details class="dfile" id="f1" data-file> | 128 | + | <details class="dfile lnw-2" id="f1" data-file> |
| 129 | <summary> | 129 | <summary> | ||
| 130 | <span class="st">R</span> | 130 | <span class="st">R</span> | ||
| 131 | <span class="p">old.txt → docs-old.txt</span> | 131 | <span class="p">old.txt → docs-old.txt</span> | ||
| @@ -135,7 +135,7 @@ | |||||
| 135 | </summary> | 135 | </summary> | ||
| 136 | <p class="note pad">renamed, 100% similar</p> | 136 | <p class="note pad">renamed, 100% similar</p> | ||
| 137 | </details> | 137 | </details> | ||
| 138 | - | <details class="dfile" id="f2" data-file open> | 138 | + | <details class="dfile lnw-2" id="f2" data-file open> |
| 139 | <summary> | 139 | <summary> | ||
| 140 | <span class="st">D</span> | 140 | <span class="st">D</span> | ||
| 141 | <span class="p">gone.txt</span> | 141 | <span class="p">gone.txt</span> | ||
| @@ -193,33 +193,35 @@ | |||||
| 193 | </h2> | 193 | </h2> | ||
| 194 | <ol class="files" aria-label="Changed files"> | 194 | <ol class="files" aria-label="Changed files"> | ||
| 195 | <li> | 195 | <li> | ||
| 196 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 196 | + | <a href="#f1" title="old.txt → docs-old.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 197 | - | <span class="st" title="R">R</span> | 197 | + | <span class="st" data-st="R" title="renamed">R</span> |
| 198 | - | <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span> | 198 | + | <span class="nm"> |
| 199 | + | <span class="p">docs-old.txt</span> | |||
| 200 | + | <span class="d"> | |||
| 201 | + | <bdi> | |||
| 202 | + | </bdi> | |||
| 203 | + | </span> | |||
| 204 | + | </span> | |||
| 199 | <span class="n"> | 205 | <span class="n"> | ||
| 200 | <span class="plus">+0</span> | 206 | <span class="plus">+0</span> | ||
| 201 | <span class="minus">-0</span> | 207 | <span class="minus">-0</span> | ||
| 202 | </span> | 208 | </span> | ||
| 203 | - | <span class="b" aria-hidden="true"> | |||
| 204 | - | <span class="plus"> | |||
| 205 | - | </span> | |||
| 206 | - | <span class="minus"> | |||
| 207 | - | </span>··········</span> | |||
| 208 | </a> | 209 | </a> | ||
| 209 | </li> | 210 | </li> | ||
| 210 | <li> | 211 | <li> | ||
| 211 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 212 | + | <a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 212 | - | <span class="st" title="D">D</span> | 213 | + | <span class="st" data-st="D" title="deleted">D</span> |
| 213 | - | <span class="p" title="gone.txt">gone.txt</span> | 214 | + | <span class="nm"> |
| 215 | + | <span class="p">gone.txt</span> | |||
| 216 | + | <span class="d"> | |||
| 217 | + | <bdi> | |||
| 218 | + | </bdi> | |||
| 219 | + | </span> | |||
| 220 | + | </span> | |||
| 214 | <span class="n"> | 221 | <span class="n"> | ||
| 215 | <span class="plus">+0</span> | 222 | <span class="plus">+0</span> | ||
| 216 | <span class="minus">-1</span> | 223 | <span class="minus">-1</span> | ||
| 217 | </span> | 224 | </span> | ||
| 218 | - | <span class="b" aria-hidden="true"> | |||
| 219 | - | <span class="plus"> | |||
| 220 | - | </span> | |||
| 221 | - | <span class="minus">----------</span> | |||
| 222 | - | </span> | |||
| 223 | </a> | 225 | </a> | ||
| 224 | </li> | 226 | </li> | ||
| 225 | </ol> | 227 | </ol> | ||
| @@ -471,7 +473,10 @@ | |||||
| 471 | k-split from:body -> ermite.layout('split'); | 473 | k-split from:body -> ermite.layout('split'); | ||
| 472 | k-unified from:body -> ermite.layout('unified'); | 474 | k-unified from:body -> ermite.layout('unified'); | ||
| 473 | k-collapse from:body -> ermite.collapse(); | 475 | k-collapse from:body -> ermite.collapse(); | ||
| 474 | - | k-permalink from:body -> ermite.permalink()"> | 476 | + | k-permalink from:body -> ermite.permalink(); |
| 477 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 478 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 479 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 475 | </div> | 480 | </div> | ||
| 476 | </body> | 481 | </body> | ||
| 477 | </html> | 482 | </html> | ||
M internal/app/testdata/web/commit-root.html +53 -45
| @@ -123,7 +123,7 @@ | |||
| 123 | 123 | </button> | |
| 124 | 124 | </div> | |
| 125 | 125 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 126 | - | <details class="dfile" id="f1" data-file open> | |
| 126 | + | <details class="dfile lnw-2" id="f1" data-file open> | |
| 127 | 127 | <summary> | |
| 128 | 128 | <span class="st">A</span> | |
| 129 | 129 | <span class="p">a.txt</span> | |
| @@ -245,7 +245,7 @@ | |||
| 245 | 245 | </table> | |
| 246 | 246 | </div> | |
| 247 | 247 | </details> | |
| 248 | - | <details class="dfile" id="f2" data-file open> | |
| 248 | + | <details class="dfile lnw-2" id="f2" data-file open> | |
| 249 | 249 | <summary> | |
| 250 | 250 | <span class="st">A</span> | |
| 251 | 251 | <span class="p">gone.txt</span> | |
| @@ -295,7 +295,7 @@ | |||
| 295 | 295 | </table> | |
| 296 | 296 | </div> | |
| 297 | 297 | </details> | |
| 298 | - | <details class="dfile" id="f3" data-file> | |
| 298 | + | <details class="dfile lnw-2" id="f3" data-file> | |
| 299 | 299 | <summary> | |
| 300 | 300 | <span class="st">A</span> | |
| 301 | 301 | <span class="p">logo.bin</span> | |
| @@ -305,7 +305,7 @@ | |||
| 305 | 305 | </summary> | |
| 306 | 306 | <p class="note pad">binary file: no text diff</p> | |
| 307 | 307 | </details> | |
| 308 | - | <details class="dfile" id="f4" data-file open> | |
| 308 | + | <details class="dfile lnw-2" id="f4" data-file open> | |
| 309 | 309 | <summary> | |
| 310 | 310 | <span class="st">A</span> | |
| 311 | 311 | <span class="p">old.txt</span> | |
| @@ -391,7 +391,7 @@ | |||
| 391 | 391 | </table> | |
| 392 | 392 | </div> | |
| 393 | 393 | </details> | |
| 394 | - | <details class="dfile" id="f5" data-file open> | |
| 394 | + | <details class="dfile lnw-2" id="f5" data-file open> | |
| 395 | 395 | <summary> | |
| 396 | 396 | <span class="st">A</span> | |
| 397 | 397 | <span class="p">run.sh</span> | |
| @@ -467,75 +467,80 @@ | |||
| 467 | 467 | </h2> | |
| 468 | 468 | <ol class="files" aria-label="Changed files"> | |
| 469 | 469 | <li> | |
| 470 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 471 | - | <span class="st" title="A">A</span> | |
| 472 | - | <span class="p" title="a.txt">a.txt</span> | |
| 470 | + | <a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 471 | + | <span class="st" data-st="A" title="added">A</span> | |
| 472 | + | <span class="nm"> | |
| 473 | + | <span class="p">a.txt</span> | |
| 474 | + | <span class="d"> | |
| 475 | + | <bdi> | |
| 476 | + | </bdi> | |
| 477 | + | </span> | |
| 478 | + | </span> | |
| 473 | 479 | <span class="n"> | |
| 474 | 480 | <span class="plus">+5</span> | |
| 475 | 481 | <span class="minus">-0</span> | |
| 476 | 482 | </span> | |
| 477 | - | <span class="b" aria-hidden="true"> | |
| 478 | - | <span class="plus">++++++++++</span> | |
| 479 | - | <span class="minus"> | |
| 480 | - | </span> | |
| 481 | - | </span> | |
| 482 | 483 | </a> | |
| 483 | 484 | </li> | |
| 484 | 485 | <li> | |
| 485 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 486 | - | <span class="st" title="A">A</span> | |
| 487 | - | <span class="p" title="gone.txt">gone.txt</span> | |
| 486 | + | <a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 487 | + | <span class="st" data-st="A" title="added">A</span> | |
| 488 | + | <span class="nm"> | |
| 489 | + | <span class="p">gone.txt</span> | |
| 490 | + | <span class="d"> | |
| 491 | + | <bdi> | |
| 492 | + | </bdi> | |
| 493 | + | </span> | |
| 494 | + | </span> | |
| 488 | 495 | <span class="n"> | |
| 489 | 496 | <span class="plus">+1</span> | |
| 490 | 497 | <span class="minus">-0</span> | |
| 491 | 498 | </span> | |
| 492 | - | <span class="b" aria-hidden="true"> | |
| 493 | - | <span class="plus">++</span> | |
| 494 | - | <span class="minus"> | |
| 495 | - | </span>········</span> | |
| 496 | 499 | </a> | |
| 497 | 500 | </li> | |
| 498 | 501 | <li> | |
| 499 | - | <a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 500 | - | <span class="st" title="A">A</span> | |
| 501 | - | <span class="p" title="logo.bin">logo.bin</span> | |
| 502 | - | <span class="n"> | |
| 503 | - | <span class="plus">+0</span> | |
| 504 | - | <span class="minus">-0</span> | |
| 502 | + | <a href="#f3" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 503 | + | <span class="st" data-st="A" title="added">A</span> | |
| 504 | + | <span class="nm"> | |
| 505 | + | <span class="p">logo.bin</span> | |
| 506 | + | <span class="d"> | |
| 507 | + | <bdi> | |
| 508 | + | </bdi> | |
| 505 | 509 | </span> | |
| 506 | - | <span class="b" aria-hidden="true"> | |
| 507 | - | <span class="plus"> | |
| 508 | 510 | </span> | |
| 509 | - | <span class="minus"> | |
| 510 | - | </span>bin</span> | |
| 511 | + | <span class="n mute">bin</span> | |
| 511 | 512 | </a> | |
| 512 | 513 | </li> | |
| 513 | 514 | <li> | |
| 514 | - | <a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 515 | - | <span class="st" title="A">A</span> | |
| 516 | - | <span class="p" title="old.txt">old.txt</span> | |
| 515 | + | <a href="#f4" title="old.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 516 | + | <span class="st" data-st="A" title="added">A</span> | |
| 517 | + | <span class="nm"> | |
| 518 | + | <span class="p">old.txt</span> | |
| 519 | + | <span class="d"> | |
| 520 | + | <bdi> | |
| 521 | + | </bdi> | |
| 522 | + | </span> | |
| 523 | + | </span> | |
| 517 | 524 | <span class="n"> | |
| 518 | 525 | <span class="plus">+3</span> | |
| 519 | 526 | <span class="minus">-0</span> | |
| 520 | 527 | </span> | |
| 521 | - | <span class="b" aria-hidden="true"> | |
| 522 | - | <span class="plus">++++++</span> | |
| 523 | - | <span class="minus"> | |
| 524 | - | </span>····</span> | |
| 525 | 528 | </a> | |
| 526 | 529 | </li> | |
| 527 | 530 | <li> | |
| 528 | - | <a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 529 | - | <span class="st" title="A">A</span> | |
| 530 | - | <span class="p" title="run.sh">run.sh</span> | |
| 531 | + | <a href="#f5" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 532 | + | <span class="st" data-st="A" title="added">A</span> | |
| 533 | + | <span class="nm"> | |
| 534 | + | <span class="p">run.sh</span> | |
| 535 | + | <span class="d"> | |
| 536 | + | <bdi> | |
| 537 | + | </bdi> | |
| 538 | + | </span> | |
| 539 | + | </span> | |
| 531 | 540 | <span class="n"> | |
| 532 | 541 | <span class="plus">+2</span> | |
| 533 | 542 | <span class="minus">-0</span> | |
| 534 | 543 | </span> | |
| 535 | - | <span class="b" aria-hidden="true"> | |
| 536 | - | <span class="plus">++++</span> | |
| 537 | - | <span class="minus"> | |
| 538 | - | </span>······</span> | |
| 539 | 544 | </a> | |
| 540 | 545 | </li> | |
| 541 | 546 | </ol> | |
| @@ -787,7 +792,10 @@ | |||
| 787 | 792 | k-split from:body -> ermite.layout('split'); | |
| 788 | 793 | k-unified from:body -> ermite.layout('unified'); | |
| 789 | 794 | k-collapse from:body -> ermite.collapse(); | |
| 790 | - | k-permalink from:body -> ermite.permalink()"> | |
| 795 | + | k-permalink from:body -> ermite.permalink(); | |
| 796 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 797 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 798 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 791 | 799 | </div> | |
| 792 | 800 | </body> | |
| 793 | 801 | </html> | |
| @@ -123,7 +123,7 @@ | |||||
| 123 | </button> | 123 | </button> | ||
| 124 | </div> | 124 | </div> | ||
| 125 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 125 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 126 | - | <details class="dfile" id="f1" data-file open> | 126 | + | <details class="dfile lnw-2" id="f1" data-file open> |
| 127 | <summary> | 127 | <summary> | ||
| 128 | <span class="st">A</span> | 128 | <span class="st">A</span> | ||
| 129 | <span class="p">a.txt</span> | 129 | <span class="p">a.txt</span> | ||
| @@ -245,7 +245,7 @@ | |||||
| 245 | </table> | 245 | </table> | ||
| 246 | </div> | 246 | </div> | ||
| 247 | </details> | 247 | </details> | ||
| 248 | - | <details class="dfile" id="f2" data-file open> | 248 | + | <details class="dfile lnw-2" id="f2" data-file open> |
| 249 | <summary> | 249 | <summary> | ||
| 250 | <span class="st">A</span> | 250 | <span class="st">A</span> | ||
| 251 | <span class="p">gone.txt</span> | 251 | <span class="p">gone.txt</span> | ||
| @@ -295,7 +295,7 @@ | |||||
| 295 | </table> | 295 | </table> | ||
| 296 | </div> | 296 | </div> | ||
| 297 | </details> | 297 | </details> | ||
| 298 | - | <details class="dfile" id="f3" data-file> | 298 | + | <details class="dfile lnw-2" id="f3" data-file> |
| 299 | <summary> | 299 | <summary> | ||
| 300 | <span class="st">A</span> | 300 | <span class="st">A</span> | ||
| 301 | <span class="p">logo.bin</span> | 301 | <span class="p">logo.bin</span> | ||
| @@ -305,7 +305,7 @@ | |||||
| 305 | </summary> | 305 | </summary> | ||
| 306 | <p class="note pad">binary file: no text diff</p> | 306 | <p class="note pad">binary file: no text diff</p> | ||
| 307 | </details> | 307 | </details> | ||
| 308 | - | <details class="dfile" id="f4" data-file open> | 308 | + | <details class="dfile lnw-2" id="f4" data-file open> |
| 309 | <summary> | 309 | <summary> | ||
| 310 | <span class="st">A</span> | 310 | <span class="st">A</span> | ||
| 311 | <span class="p">old.txt</span> | 311 | <span class="p">old.txt</span> | ||
| @@ -391,7 +391,7 @@ | |||||
| 391 | </table> | 391 | </table> | ||
| 392 | </div> | 392 | </div> | ||
| 393 | </details> | 393 | </details> | ||
| 394 | - | <details class="dfile" id="f5" data-file open> | 394 | + | <details class="dfile lnw-2" id="f5" data-file open> |
| 395 | <summary> | 395 | <summary> | ||
| 396 | <span class="st">A</span> | 396 | <span class="st">A</span> | ||
| 397 | <span class="p">run.sh</span> | 397 | <span class="p">run.sh</span> | ||
| @@ -467,75 +467,80 @@ | |||||
| 467 | </h2> | 467 | </h2> | ||
| 468 | <ol class="files" aria-label="Changed files"> | 468 | <ol class="files" aria-label="Changed files"> | ||
| 469 | <li> | 469 | <li> | ||
| 470 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 470 | + | <a href="#f1" title="a.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 471 | - | <span class="st" title="A">A</span> | 471 | + | <span class="st" data-st="A" title="added">A</span> |
| 472 | - | <span class="p" title="a.txt">a.txt</span> | 472 | + | <span class="nm"> |
| 473 | + | <span class="p">a.txt</span> | |||
| 474 | + | <span class="d"> | |||
| 475 | + | <bdi> | |||
| 476 | + | </bdi> | |||
| 477 | + | </span> | |||
| 478 | + | </span> | |||
| 473 | <span class="n"> | 479 | <span class="n"> | ||
| 474 | <span class="plus">+5</span> | 480 | <span class="plus">+5</span> | ||
| 475 | <span class="minus">-0</span> | 481 | <span class="minus">-0</span> | ||
| 476 | </span> | 482 | </span> | ||
| 477 | - | <span class="b" aria-hidden="true"> | |||
| 478 | - | <span class="plus">++++++++++</span> | |||
| 479 | - | <span class="minus"> | |||
| 480 | - | </span> | |||
| 481 | - | </span> | |||
| 482 | </a> | 483 | </a> | ||
| 483 | </li> | 484 | </li> | ||
| 484 | <li> | 485 | <li> | ||
| 485 | - | <a href="#f2" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 486 | + | <a href="#f2" title="gone.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 486 | - | <span class="st" title="A">A</span> | 487 | + | <span class="st" data-st="A" title="added">A</span> |
| 487 | - | <span class="p" title="gone.txt">gone.txt</span> | 488 | + | <span class="nm"> |
| 489 | + | <span class="p">gone.txt</span> | |||
| 490 | + | <span class="d"> | |||
| 491 | + | <bdi> | |||
| 492 | + | </bdi> | |||
| 493 | + | </span> | |||
| 494 | + | </span> | |||
| 488 | <span class="n"> | 495 | <span class="n"> | ||
| 489 | <span class="plus">+1</span> | 496 | <span class="plus">+1</span> | ||
| 490 | <span class="minus">-0</span> | 497 | <span class="minus">-0</span> | ||
| 491 | </span> | 498 | </span> | ||
| 492 | - | <span class="b" aria-hidden="true"> | |||
| 493 | - | <span class="plus">++</span> | |||
| 494 | - | <span class="minus"> | |||
| 495 | - | </span>········</span> | |||
| 496 | </a> | 499 | </a> | ||
| 497 | </li> | 500 | </li> | ||
| 498 | <li> | 501 | <li> | ||
| 499 | - | <a href="#f3" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 502 | + | <a href="#f3" title="logo.bin" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 500 | - | <span class="st" title="A">A</span> | 503 | + | <span class="st" data-st="A" title="added">A</span> |
| 501 | - | <span class="p" title="logo.bin">logo.bin</span> | 504 | + | <span class="nm"> |
| 502 | - | <span class="n"> | 505 | + | <span class="p">logo.bin</span> |
| 503 | - | <span class="plus">+0</span> | 506 | + | <span class="d"> |
| 504 | - | <span class="minus">-0</span> | 507 | + | <bdi> |
| 508 | + | </bdi> | |||
| 505 | </span> | 509 | </span> | ||
| 506 | - | <span class="b" aria-hidden="true"> | |||
| 507 | - | <span class="plus"> | |||
| 508 | </span> | 510 | </span> | ||
| 509 | - | <span class="minus"> | 511 | + | <span class="n mute">bin</span> |
| 510 | - | </span>bin</span> | |||
| 511 | </a> | 512 | </a> | ||
| 512 | </li> | 513 | </li> | ||
| 513 | <li> | 514 | <li> | ||
| 514 | - | <a href="#f4" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 515 | + | <a href="#f4" title="old.txt" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 515 | - | <span class="st" title="A">A</span> | 516 | + | <span class="st" data-st="A" title="added">A</span> |
| 516 | - | <span class="p" title="old.txt">old.txt</span> | 517 | + | <span class="nm"> |
| 518 | + | <span class="p">old.txt</span> | |||
| 519 | + | <span class="d"> | |||
| 520 | + | <bdi> | |||
| 521 | + | </bdi> | |||
| 522 | + | </span> | |||
| 523 | + | </span> | |||
| 517 | <span class="n"> | 524 | <span class="n"> | ||
| 518 | <span class="plus">+3</span> | 525 | <span class="plus">+3</span> | ||
| 519 | <span class="minus">-0</span> | 526 | <span class="minus">-0</span> | ||
| 520 | </span> | 527 | </span> | ||
| 521 | - | <span class="b" aria-hidden="true"> | |||
| 522 | - | <span class="plus">++++++</span> | |||
| 523 | - | <span class="minus"> | |||
| 524 | - | </span>····</span> | |||
| 525 | </a> | 528 | </a> | ||
| 526 | </li> | 529 | </li> | ||
| 527 | <li> | 530 | <li> | ||
| 528 | - | <a href="#f5" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 531 | + | <a href="#f5" title="run.sh" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 529 | - | <span class="st" title="A">A</span> | 532 | + | <span class="st" data-st="A" title="added">A</span> |
| 530 | - | <span class="p" title="run.sh">run.sh</span> | 533 | + | <span class="nm"> |
| 534 | + | <span class="p">run.sh</span> | |||
| 535 | + | <span class="d"> | |||
| 536 | + | <bdi> | |||
| 537 | + | </bdi> | |||
| 538 | + | </span> | |||
| 539 | + | </span> | |||
| 531 | <span class="n"> | 540 | <span class="n"> | ||
| 532 | <span class="plus">+2</span> | 541 | <span class="plus">+2</span> | ||
| 533 | <span class="minus">-0</span> | 542 | <span class="minus">-0</span> | ||
| 534 | </span> | 543 | </span> | ||
| 535 | - | <span class="b" aria-hidden="true"> | |||
| 536 | - | <span class="plus">++++</span> | |||
| 537 | - | <span class="minus"> | |||
| 538 | - | </span>······</span> | |||
| 539 | </a> | 544 | </a> | ||
| 540 | </li> | 545 | </li> | ||
| 541 | </ol> | 546 | </ol> | ||
| @@ -787,7 +792,10 @@ | |||||
| 787 | k-split from:body -> ermite.layout('split'); | 792 | k-split from:body -> ermite.layout('split'); | ||
| 788 | k-unified from:body -> ermite.layout('unified'); | 793 | k-unified from:body -> ermite.layout('unified'); | ||
| 789 | k-collapse from:body -> ermite.collapse(); | 794 | k-collapse from:body -> ermite.collapse(); | ||
| 790 | - | k-permalink from:body -> ermite.permalink()"> | 795 | + | k-permalink from:body -> ermite.permalink(); |
| 796 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 797 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 798 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 791 | </div> | 799 | </div> | ||
| 792 | </body> | 800 | </body> | ||
| 793 | </html> | 801 | </html> | ||
M internal/app/testdata/web/commit-trojan.html +14 -10
| @@ -127,7 +127,7 @@ | |||
| 127 | 127 | </div> | |
| 128 | 128 | <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv"><U+XXXX></span> markers, never applied.</p> | |
| 129 | 129 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 130 | - | <details class="dfile" id="f1" data-file open> | |
| 130 | + | <details class="dfile lnw-2" id="f1" data-file open> | |
| 131 | 131 | <summary> | |
| 132 | 132 | <span class="st">A</span> | |
| 133 | 133 | <span class="p">evil.go</span> | |
| @@ -317,18 +317,19 @@ | |||
| 317 | 317 | </h2> | |
| 318 | 318 | <ol class="files" aria-label="Changed files"> | |
| 319 | 319 | <li> | |
| 320 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 321 | - | <span class="st" title="A">A</span> | |
| 322 | - | <span class="p" title="evil.go">evil.go</span> | |
| 320 | + | <a href="#f1" title="evil.go" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> | |
| 321 | + | <span class="st" data-st="A" title="added">A</span> | |
| 322 | + | <span class="nm"> | |
| 323 | + | <span class="p">evil.go</span> | |
| 324 | + | <span class="d"> | |
| 325 | + | <bdi> | |
| 326 | + | </bdi> | |
| 327 | + | </span> | |
| 328 | + | </span> | |
| 323 | 329 | <span class="n"> | |
| 324 | 330 | <span class="plus">+8</span> | |
| 325 | 331 | <span class="minus">-0</span> | |
| 326 | 332 | </span> | |
| 327 | - | <span class="b" aria-hidden="true"> | |
| 328 | - | <span class="plus">++++++++++</span> | |
| 329 | - | <span class="minus"> | |
| 330 | - | </span> | |
| 331 | - | </span> | |
| 332 | 333 | </a> | |
| 333 | 334 | </li> | |
| 334 | 335 | </ol> | |
| @@ -580,7 +581,10 @@ | |||
| 580 | 581 | k-split from:body -> ermite.layout('split'); | |
| 581 | 582 | k-unified from:body -> ermite.layout('unified'); | |
| 582 | 583 | k-collapse from:body -> ermite.collapse(); | |
| 583 | - | k-permalink from:body -> ermite.permalink()"> | |
| 584 | + | k-permalink from:body -> ermite.permalink(); | |
| 585 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 586 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 587 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 584 | 588 | </div> | |
| 585 | 589 | </body> | |
| 586 | 590 | </html> | |
| @@ -127,7 +127,7 @@ | |||||
| 127 | </div> | 127 | </div> | ||
| 128 | <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv"><U+XXXX></span> markers, never applied.</p> | 128 | <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv"><U+XXXX></span> markers, never applied.</p> | ||
| 129 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | 129 | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | ||
| 130 | - | <details class="dfile" id="f1" data-file open> | 130 | + | <details class="dfile lnw-2" id="f1" data-file open> |
| 131 | <summary> | 131 | <summary> | ||
| 132 | <span class="st">A</span> | 132 | <span class="st">A</span> | ||
| 133 | <span class="p">evil.go</span> | 133 | <span class="p">evil.go</span> | ||
| @@ -317,18 +317,19 @@ | |||||
| 317 | </h2> | 317 | </h2> | ||
| 318 | <ol class="files" aria-label="Changed files"> | 318 | <ol class="files" aria-label="Changed files"> | ||
| 319 | <li> | 319 | <li> | ||
| 320 | - | <a href="#f1" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | 320 | + | <a href="#f1" title="evil.go" hx-on:click="(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))"> |
| 321 | - | <span class="st" title="A">A</span> | 321 | + | <span class="st" data-st="A" title="added">A</span> |
| 322 | - | <span class="p" title="evil.go">evil.go</span> | 322 | + | <span class="nm"> |
| 323 | + | <span class="p">evil.go</span> | |||
| 324 | + | <span class="d"> | |||
| 325 | + | <bdi> | |||
| 326 | + | </bdi> | |||
| 327 | + | </span> | |||
| 328 | + | </span> | |||
| 323 | <span class="n"> | 329 | <span class="n"> | ||
| 324 | <span class="plus">+8</span> | 330 | <span class="plus">+8</span> | ||
| 325 | <span class="minus">-0</span> | 331 | <span class="minus">-0</span> | ||
| 326 | </span> | 332 | </span> | ||
| 327 | - | <span class="b" aria-hidden="true"> | |||
| 328 | - | <span class="plus">++++++++++</span> | |||
| 329 | - | <span class="minus"> | |||
| 330 | - | </span> | |||
| 331 | - | </span> | |||
| 332 | </a> | 333 | </a> | ||
| 333 | </li> | 334 | </li> | ||
| 334 | </ol> | 335 | </ol> | ||
| @@ -580,7 +581,10 @@ | |||||
| 580 | k-split from:body -> ermite.layout('split'); | 581 | k-split from:body -> ermite.layout('split'); | ||
| 581 | k-unified from:body -> ermite.layout('unified'); | 582 | k-unified from:body -> ermite.layout('unified'); | ||
| 582 | k-collapse from:body -> ermite.collapse(); | 583 | k-collapse from:body -> ermite.collapse(); | ||
| 583 | - | k-permalink from:body -> ermite.permalink()"> | 584 | + | k-permalink from:body -> ermite.permalink(); |
| 585 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 586 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 587 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 584 | </div> | 588 | </div> | ||
| 585 | </body> | 589 | </body> | ||
| 586 | </html> | 590 | </html> | ||
M internal/app/testdata/web/commits-page2.html +4 -1
| @@ -391,7 +391,10 @@ | |||
| 391 | 391 | k-split from:body -> ermite.layout('split'); | |
| 392 | 392 | k-unified from:body -> ermite.layout('unified'); | |
| 393 | 393 | k-collapse from:body -> ermite.collapse(); | |
| 394 | - | k-permalink from:body -> ermite.permalink()"> | |
| 394 | + | k-permalink from:body -> ermite.permalink(); | |
| 395 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 396 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 397 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 395 | 398 | </div> | |
| 396 | 399 | </body> | |
| 397 | 400 | </html> | |
| @@ -391,7 +391,10 @@ | |||||
| 391 | k-split from:body -> ermite.layout('split'); | 391 | k-split from:body -> ermite.layout('split'); | ||
| 392 | k-unified from:body -> ermite.layout('unified'); | 392 | k-unified from:body -> ermite.layout('unified'); | ||
| 393 | k-collapse from:body -> ermite.collapse(); | 393 | k-collapse from:body -> ermite.collapse(); | ||
| 394 | - | k-permalink from:body -> ermite.permalink()"> | 394 | + | k-permalink from:body -> ermite.permalink(); |
| 395 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 396 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 397 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 395 | </div> | 398 | </div> | ||
| 396 | </body> | 399 | </body> | ||
| 397 | </html> | 400 | </html> | ||
M internal/app/testdata/web/commits-path.html +4 -1
| @@ -406,7 +406,10 @@ | |||
| 406 | 406 | k-split from:body -> ermite.layout('split'); | |
| 407 | 407 | k-unified from:body -> ermite.layout('unified'); | |
| 408 | 408 | k-collapse from:body -> ermite.collapse(); | |
| 409 | - | k-permalink from:body -> ermite.permalink()"> | |
| 409 | + | k-permalink from:body -> ermite.permalink(); | |
| 410 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 411 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 412 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 410 | 413 | </div> | |
| 411 | 414 | </body> | |
| 412 | 415 | </html> | |
| @@ -406,7 +406,10 @@ | |||||
| 406 | k-split from:body -> ermite.layout('split'); | 406 | k-split from:body -> ermite.layout('split'); | ||
| 407 | k-unified from:body -> ermite.layout('unified'); | 407 | k-unified from:body -> ermite.layout('unified'); | ||
| 408 | k-collapse from:body -> ermite.collapse(); | 408 | k-collapse from:body -> ermite.collapse(); | ||
| 409 | - | k-permalink from:body -> ermite.permalink()"> | 409 | + | k-permalink from:body -> ermite.permalink(); |
| 410 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 411 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 412 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 410 | </div> | 413 | </div> | ||
| 411 | </body> | 414 | </body> | ||
| 412 | </html> | 415 | </html> | ||
M internal/app/testdata/web/commits-root.html +4 -1
| @@ -1291,7 +1291,10 @@ | |||
| 1291 | 1291 | k-split from:body -> ermite.layout('split'); | |
| 1292 | 1292 | k-unified from:body -> ermite.layout('unified'); | |
| 1293 | 1293 | k-collapse from:body -> ermite.collapse(); | |
| 1294 | - | k-permalink from:body -> ermite.permalink()"> | |
| 1294 | + | k-permalink from:body -> ermite.permalink(); | |
| 1295 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 1296 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 1297 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 1295 | 1298 | </div> | |
| 1296 | 1299 | </body> | |
| 1297 | 1300 | </html> | |
| @@ -1291,7 +1291,10 @@ | |||||
| 1291 | k-split from:body -> ermite.layout('split'); | 1291 | k-split from:body -> ermite.layout('split'); | ||
| 1292 | k-unified from:body -> ermite.layout('unified'); | 1292 | k-unified from:body -> ermite.layout('unified'); | ||
| 1293 | k-collapse from:body -> ermite.collapse(); | 1293 | k-collapse from:body -> ermite.collapse(); | ||
| 1294 | - | k-permalink from:body -> ermite.permalink()"> | 1294 | + | k-permalink from:body -> ermite.permalink(); |
| 1295 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 1296 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 1297 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 1295 | </div> | 1298 | </div> | ||
| 1296 | </body> | 1299 | </body> | ||
| 1297 | </html> | 1300 | </html> | ||
M internal/app/testdata/web/commits-small.html +4 -1
| @@ -572,7 +572,10 @@ | |||
| 572 | 572 | k-split from:body -> ermite.layout('split'); | |
| 573 | 573 | k-unified from:body -> ermite.layout('unified'); | |
| 574 | 574 | k-collapse from:body -> ermite.collapse(); | |
| 575 | - | k-permalink from:body -> ermite.permalink()"> | |
| 575 | + | k-permalink from:body -> ermite.permalink(); | |
| 576 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 577 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 578 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 576 | 579 | </div> | |
| 577 | 580 | </body> | |
| 578 | 581 | </html> | |
| @@ -572,7 +572,10 @@ | |||||
| 572 | k-split from:body -> ermite.layout('split'); | 572 | k-split from:body -> ermite.layout('split'); | ||
| 573 | k-unified from:body -> ermite.layout('unified'); | 573 | k-unified from:body -> ermite.layout('unified'); | ||
| 574 | k-collapse from:body -> ermite.collapse(); | 574 | k-collapse from:body -> ermite.collapse(); | ||
| 575 | - | k-permalink from:body -> ermite.permalink()"> | 575 | + | k-permalink from:body -> ermite.permalink(); |
| 576 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 577 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 578 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 576 | </div> | 579 | </div> | ||
| 577 | </body> | 580 | </body> | ||
| 578 | </html> | 581 | </html> | ||
M internal/app/testdata/web/empty.html +4 -1
| @@ -240,7 +240,10 @@ | |||
| 240 | 240 | k-split from:body -> ermite.layout('split'); | |
| 241 | 241 | k-unified from:body -> ermite.layout('unified'); | |
| 242 | 242 | k-collapse from:body -> ermite.collapse(); | |
| 243 | - | k-permalink from:body -> ermite.permalink()"> | |
| 243 | + | k-permalink from:body -> ermite.permalink(); | |
| 244 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 245 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 246 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 244 | 247 | </div> | |
| 245 | 248 | </body> | |
| 246 | 249 | </html> | |
| @@ -240,7 +240,10 @@ | |||||
| 240 | k-split from:body -> ermite.layout('split'); | 240 | k-split from:body -> ermite.layout('split'); | ||
| 241 | k-unified from:body -> ermite.layout('unified'); | 241 | k-unified from:body -> ermite.layout('unified'); | ||
| 242 | k-collapse from:body -> ermite.collapse(); | 242 | k-collapse from:body -> ermite.collapse(); | ||
| 243 | - | k-permalink from:body -> ermite.permalink()"> | 243 | + | k-permalink from:body -> ermite.permalink(); |
| 244 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 245 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 246 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 244 | </div> | 247 | </div> | ||
| 245 | </body> | 248 | </body> | ||
| 246 | </html> | 249 | </html> | ||
M internal/app/testdata/web/home.html +4 -1
| @@ -278,7 +278,10 @@ | |||
| 278 | 278 | k-split from:body -> ermite.layout('split'); | |
| 279 | 279 | k-unified from:body -> ermite.layout('unified'); | |
| 280 | 280 | k-collapse from:body -> ermite.collapse(); | |
| 281 | - | k-permalink from:body -> ermite.permalink()"> | |
| 281 | + | k-permalink from:body -> ermite.permalink(); | |
| 282 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 283 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 284 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 282 | 285 | </div> | |
| 283 | 286 | </body> | |
| 284 | 287 | </html> | |
| @@ -278,7 +278,10 @@ | |||||
| 278 | k-split from:body -> ermite.layout('split'); | 278 | k-split from:body -> ermite.layout('split'); | ||
| 279 | k-unified from:body -> ermite.layout('unified'); | 279 | k-unified from:body -> ermite.layout('unified'); | ||
| 280 | k-collapse from:body -> ermite.collapse(); | 280 | k-collapse from:body -> ermite.collapse(); | ||
| 281 | - | k-permalink from:body -> ermite.permalink()"> | 281 | + | k-permalink from:body -> ermite.permalink(); |
| 282 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 283 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 284 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 282 | </div> | 285 | </div> | ||
| 283 | </body> | 286 | </body> | ||
| 284 | </html> | 287 | </html> | ||
M internal/app/testdata/web/not-found-page.html +4 -1
| @@ -223,7 +223,10 @@ | |||
| 223 | 223 | k-split from:body -> ermite.layout('split'); | |
| 224 | 224 | k-unified from:body -> ermite.layout('unified'); | |
| 225 | 225 | k-collapse from:body -> ermite.collapse(); | |
| 226 | - | k-permalink from:body -> ermite.permalink()"> | |
| 226 | + | k-permalink from:body -> ermite.permalink(); | |
| 227 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 228 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 229 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 227 | 230 | </div> | |
| 228 | 231 | </body> | |
| 229 | 232 | </html> | |
| @@ -223,7 +223,10 @@ | |||||
| 223 | k-split from:body -> ermite.layout('split'); | 223 | k-split from:body -> ermite.layout('split'); | ||
| 224 | k-unified from:body -> ermite.layout('unified'); | 224 | k-unified from:body -> ermite.layout('unified'); | ||
| 225 | k-collapse from:body -> ermite.collapse(); | 225 | k-collapse from:body -> ermite.collapse(); | ||
| 226 | - | k-permalink from:body -> ermite.permalink()"> | 226 | + | k-permalink from:body -> ermite.permalink(); |
| 227 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 228 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 229 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 227 | </div> | 230 | </div> | ||
| 228 | </body> | 231 | </body> | ||
| 229 | </html> | 232 | </html> | ||
M internal/app/testdata/web/not-found.html +4 -1
| @@ -223,7 +223,10 @@ | |||
| 223 | 223 | k-split from:body -> ermite.layout('split'); | |
| 224 | 224 | k-unified from:body -> ermite.layout('unified'); | |
| 225 | 225 | k-collapse from:body -> ermite.collapse(); | |
| 226 | - | k-permalink from:body -> ermite.permalink()"> | |
| 226 | + | k-permalink from:body -> ermite.permalink(); | |
| 227 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 228 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 229 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 227 | 230 | </div> | |
| 228 | 231 | </body> | |
| 229 | 232 | </html> | |
| @@ -223,7 +223,10 @@ | |||||
| 223 | k-split from:body -> ermite.layout('split'); | 223 | k-split from:body -> ermite.layout('split'); | ||
| 224 | k-unified from:body -> ermite.layout('unified'); | 224 | k-unified from:body -> ermite.layout('unified'); | ||
| 225 | k-collapse from:body -> ermite.collapse(); | 225 | k-collapse from:body -> ermite.collapse(); | ||
| 226 | - | k-permalink from:body -> ermite.permalink()"> | 226 | + | k-permalink from:body -> ermite.permalink(); |
| 227 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 228 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 229 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 227 | </div> | 230 | </div> | ||
| 228 | </body> | 231 | </body> | ||
| 229 | </html> | 232 | </html> | ||
M internal/app/testdata/web/refs.html +4 -1
| @@ -458,7 +458,10 @@ | |||
| 458 | 458 | k-split from:body -> ermite.layout('split'); | |
| 459 | 459 | k-unified from:body -> ermite.layout('unified'); | |
| 460 | 460 | k-collapse from:body -> ermite.collapse(); | |
| 461 | - | k-permalink from:body -> ermite.permalink()"> | |
| 461 | + | k-permalink from:body -> ermite.permalink(); | |
| 462 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 463 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 464 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 462 | 465 | </div> | |
| 463 | 466 | </body> | |
| 464 | 467 | </html> | |
| @@ -458,7 +458,10 @@ | |||||
| 458 | k-split from:body -> ermite.layout('split'); | 458 | k-split from:body -> ermite.layout('split'); | ||
| 459 | k-unified from:body -> ermite.layout('unified'); | 459 | k-unified from:body -> ermite.layout('unified'); | ||
| 460 | k-collapse from:body -> ermite.collapse(); | 460 | k-collapse from:body -> ermite.collapse(); | ||
| 461 | - | k-permalink from:body -> ermite.permalink()"> | 461 | + | k-permalink from:body -> ermite.permalink(); |
| 462 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 463 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 464 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 462 | </div> | 465 | </div> | ||
| 463 | </body> | 466 | </body> | ||
| 464 | </html> | 467 | </html> | ||
M internal/app/testdata/web/summary.html +4 -1
| @@ -487,7 +487,10 @@ | |||
| 487 | 487 | k-split from:body -> ermite.layout('split'); | |
| 488 | 488 | k-unified from:body -> ermite.layout('unified'); | |
| 489 | 489 | k-collapse from:body -> ermite.collapse(); | |
| 490 | - | k-permalink from:body -> ermite.permalink()"> | |
| 490 | + | k-permalink from:body -> ermite.permalink(); | |
| 491 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 492 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 493 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 491 | 494 | </div> | |
| 492 | 495 | </body> | |
| 493 | 496 | </html> | |
| @@ -487,7 +487,10 @@ | |||||
| 487 | k-split from:body -> ermite.layout('split'); | 487 | k-split from:body -> ermite.layout('split'); | ||
| 488 | k-unified from:body -> ermite.layout('unified'); | 488 | k-unified from:body -> ermite.layout('unified'); | ||
| 489 | k-collapse from:body -> ermite.collapse(); | 489 | k-collapse from:body -> ermite.collapse(); | ||
| 490 | - | k-permalink from:body -> ermite.permalink()"> | 490 | + | k-permalink from:body -> ermite.permalink(); |
| 491 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 492 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 493 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 491 | </div> | 494 | </div> | ||
| 492 | </body> | 495 | </body> | ||
| 493 | </html> | 496 | </html> | ||
M internal/app/testdata/web/tree-nested.html +4 -1
| @@ -394,7 +394,10 @@ | |||
| 394 | 394 | k-split from:body -> ermite.layout('split'); | |
| 395 | 395 | k-unified from:body -> ermite.layout('unified'); | |
| 396 | 396 | k-collapse from:body -> ermite.collapse(); | |
| 397 | - | k-permalink from:body -> ermite.permalink()"> | |
| 397 | + | k-permalink from:body -> ermite.permalink(); | |
| 398 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 399 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 400 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 398 | 401 | </div> | |
| 399 | 402 | </body> | |
| 400 | 403 | </html> | |
| @@ -394,7 +394,10 @@ | |||||
| 394 | k-split from:body -> ermite.layout('split'); | 394 | k-split from:body -> ermite.layout('split'); | ||
| 395 | k-unified from:body -> ermite.layout('unified'); | 395 | k-unified from:body -> ermite.layout('unified'); | ||
| 396 | k-collapse from:body -> ermite.collapse(); | 396 | k-collapse from:body -> ermite.collapse(); | ||
| 397 | - | k-permalink from:body -> ermite.permalink()"> | 397 | + | k-permalink from:body -> ermite.permalink(); |
| 398 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 399 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 400 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 398 | </div> | 401 | </div> | ||
| 399 | </body> | 402 | </body> | ||
| 400 | </html> | 403 | </html> | ||
M internal/app/testdata/web/tree-root.html +4 -1
| @@ -621,7 +621,10 @@ | |||
| 621 | 621 | k-split from:body -> ermite.layout('split'); | |
| 622 | 622 | k-unified from:body -> ermite.layout('unified'); | |
| 623 | 623 | k-collapse from:body -> ermite.collapse(); | |
| 624 | - | k-permalink from:body -> ermite.permalink()"> | |
| 624 | + | k-permalink from:body -> ermite.permalink(); | |
| 625 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 626 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 627 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 625 | 628 | </div> | |
| 626 | 629 | </body> | |
| 627 | 630 | </html> | |
| @@ -621,7 +621,10 @@ | |||||
| 621 | k-split from:body -> ermite.layout('split'); | 621 | k-split from:body -> ermite.layout('split'); | ||
| 622 | k-unified from:body -> ermite.layout('unified'); | 622 | k-unified from:body -> ermite.layout('unified'); | ||
| 623 | k-collapse from:body -> ermite.collapse(); | 623 | k-collapse from:body -> ermite.collapse(); | ||
| 624 | - | k-permalink from:body -> ermite.permalink()"> | 624 | + | k-permalink from:body -> ermite.permalink(); |
| 625 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 626 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 627 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 625 | </div> | 628 | </div> | ||
| 626 | </body> | 629 | </body> | ||
| 627 | </html> | 630 | </html> | ||
M internal/app/testdata/web/tree-slash-ref.html +4 -1
| @@ -391,7 +391,10 @@ | |||
| 391 | 391 | k-split from:body -> ermite.layout('split'); | |
| 392 | 392 | k-unified from:body -> ermite.layout('unified'); | |
| 393 | 393 | k-collapse from:body -> ermite.collapse(); | |
| 394 | - | k-permalink from:body -> ermite.permalink()"> | |
| 394 | + | k-permalink from:body -> ermite.permalink(); | |
| 395 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 396 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 397 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 395 | 398 | </div> | |
| 396 | 399 | </body> | |
| 397 | 400 | </html> | |
| @@ -391,7 +391,10 @@ | |||||
| 391 | k-split from:body -> ermite.layout('split'); | 391 | k-split from:body -> ermite.layout('split'); | ||
| 392 | k-unified from:body -> ermite.layout('unified'); | 392 | k-unified from:body -> ermite.layout('unified'); | ||
| 393 | k-collapse from:body -> ermite.collapse(); | 393 | k-collapse from:body -> ermite.collapse(); | ||
| 394 | - | k-permalink from:body -> ermite.permalink()"> | 394 | + | k-permalink from:body -> ermite.permalink(); |
| 395 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 396 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 397 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 395 | </div> | 398 | </div> | ||
| 396 | </body> | 399 | </body> | ||
| 397 | </html> | 400 | </html> | ||
M internal/app/testdata/web/tree-subdir.html +4 -1
| @@ -416,7 +416,10 @@ | |||
| 416 | 416 | k-split from:body -> ermite.layout('split'); | |
| 417 | 417 | k-unified from:body -> ermite.layout('unified'); | |
| 418 | 418 | k-collapse from:body -> ermite.collapse(); | |
| 419 | - | k-permalink from:body -> ermite.permalink()"> | |
| 419 | + | k-permalink from:body -> ermite.permalink(); | |
| 420 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |
| 421 | + | k-read from:body -> window.ermiteMR?.markRead(); | |
| 422 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |
| 420 | 423 | </div> | |
| 421 | 424 | </body> | |
| 422 | 425 | </html> | |
| @@ -416,7 +416,10 @@ | |||||
| 416 | k-split from:body -> ermite.layout('split'); | 416 | k-split from:body -> ermite.layout('split'); | ||
| 417 | k-unified from:body -> ermite.layout('unified'); | 417 | k-unified from:body -> ermite.layout('unified'); | ||
| 418 | k-collapse from:body -> ermite.collapse(); | 418 | k-collapse from:body -> ermite.collapse(); | ||
| 419 | - | k-permalink from:body -> ermite.permalink()"> | 419 | + | k-permalink from:body -> ermite.permalink(); |
| 420 | + | k-merge from:body -> window.ermiteMR?.openMerge(); | |||
| 421 | + | k-read from:body -> window.ermiteMR?.markRead(); | |||
| 422 | + | k-gf from:body -> window.ermiteMR?.focusFiles()"> | |||
| 420 | </div> | 423 | </div> | ||
| 421 | </body> | 424 | </body> | ||
| 422 | </html> | 425 | </html> | ||
M internal/app/web_mr_test.go +92 -7
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "fmt" | |
| 6 | 7 | "html" | |
| 7 | 8 | "net/http" | |
| 8 | 9 | "net/url" | |
| @@ -141,7 +142,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 141 | 142 | assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept") | |
| 142 | 143 | assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form") | |
| 143 | 144 | assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line") | |
| 144 | - | assert.NotContains(t, mr.body, ">Merge</button>") | |
| 145 | + | assert.NotContains(t, mr.body, `id="merge-menu"`) | |
| 145 | 146 | assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail") | |
| 146 | 147 | assert.NotContains(t, strings.ToLower(mr.body), "proposal") | |
| 147 | 148 | assertNoInline(t, mr.body) | |
| @@ -157,10 +158,11 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||
| 157 | 158 | assert.Contains(t, own.hdr.Values("Vary"), "Cookie") | |
| 158 | 159 | assert.Equal(t, head, formHead(t, own.body)) | |
| 159 | 160 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | |
| 160 | - | assert.Contains(t, own.body, `<option value="fast-forward">`) | |
| 161 | - | assert.Contains(t, own.body, `<option value="squash">`) | |
| 161 | + | assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff") | |
| 162 | + | assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default") | |
| 163 | + | assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`) | |
| 162 | 164 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | |
| 163 | - | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge`) | |
| 165 | + | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) | |
| 164 | 166 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | |
| 165 | 167 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash") | |
| 166 | 168 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | |
| @@ -313,7 +315,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||
| 313 | 315 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`) | |
| 314 | 316 | res = b.form(base+"/read", token, url.Values{"head_id": {head1}}) | |
| 315 | 317 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 316 | - | assert.NotContains(t, b.get(base, as(token)).body, ">Mark read</button>", "the head is the one that was read") | |
| 318 | + | assert.NotContains(t, b.get(base, as(token)).body, "data-read>Mark read", "the head is the one that was read") | |
| 317 | 319 | r.writeFile(r.wt, "extra.txt", []byte("extra\n")) | |
| 318 | 320 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 319 | 321 | head2 := r.headOf("feat/search") | |
| @@ -322,7 +324,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||
| 322 | 324 | assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`) | |
| 323 | 325 | assert.Contains(t, all.body, "since your last read") | |
| 324 | 326 | assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked") | |
| 325 | - | assert.Contains(t, all.body, ">Mark read</button>") | |
| 327 | + | assert.Contains(t, all.body, "data-read>Mark read") | |
| 326 | 328 | assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head") | |
| 327 | 329 | since := b.get(base+"/since", as(token)) | |
| 328 | 330 | require.Equal(t, 200, since.status, since.body) | |
| @@ -357,7 +359,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||
| 357 | 359 | assert.Contains(t, done.body, "fast-forward") | |
| 358 | 360 | assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`) | |
| 359 | 361 | assert.NotContains(t, done.body, "<form") | |
| 360 | - | assert.NotContains(t, b.get(base, as(token)).body, ">Merge</button>", "nothing to merge any more") | |
| 362 | + | assert.NotContains(t, b.get(base, as(token)).body, `id="merge-menu"`, "nothing to merge any more") | |
| 361 | 363 | // Writes on a merged request are refused with a page. | |
| 362 | 364 | late := b.form(base+"/comments", token, url.Values{"text": {"too late"}}) | |
| 363 | 365 | assert.Equal(t, http.StatusConflict, late.status) | |
| @@ -628,3 +630,86 @@ func TestWebMergeRequestSinceAfterForcePush(t *testing.T) { | |||
| 628 | 630 | assert.Contains(t, lost.body, "is gone, the branch was rewritten") | |
| 629 | 631 | assert.Contains(t, lost.body, "other.txt", "the whole change is shown") | |
| 630 | 632 | } | |
| 633 | + | ||
| 634 | + | // A request of many files: the page lists every file and holds the hunks of | |
| 635 | + | // the first window only; the other files are placeholders that load their | |
| 636 | + | // window from /files, which carries the base and the head of the diff. A | |
| 637 | + | // comment on a far file comes with its window; a moved head answers 409; the | |
| 638 | + | // page of a window (?from=, the links of a page without scripts) shows its | |
| 639 | + | // hunks and its line forms. | |
| 640 | + | func TestWebMergeRequestFileWindows(t *testing.T) { | |
| 641 | + | r := newMRRepo(t, domain.VisibilityPublic) | |
| 642 | + | b := browser{r.harness} | |
| 643 | + | token := r.signIn(t) | |
| 644 | + | const n = 60 | |
| 645 | + | for i := range n { | |
| 646 | + | r.writeFile(r.wt, fmt.Sprintf("many/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i))) | |
| 647 | + | } | |
| 648 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 649 | + | head := r.headOf("feat/search") | |
| 650 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Many").ok(t) | |
| 651 | + | r.cli(t, adminToken, "mr", "comment", "proj", "1", "--path", "many/f55.txt", "--line", "1", "-m", "far note").ok(t) | |
| 652 | + | ||
| 653 | + | page := b.get("/proj/merge-requests/1", nil) | |
| 654 | + | require.Equal(t, 200, page.status) | |
| 655 | + | assert.Equal(t, n+1, strings.Count(page.body, `<li><a href="#f`), "every file is listed") | |
| 656 | + | assert.Equal(t, n+1-25, strings.Count(page.body, `class="dlazy`), "the files past the first window are placeholders") | |
| 657 | + | assert.NotContains(t, page.body, "far note", "the comment of a far file comes with its window") | |
| 658 | + | assert.Contains(t, page.body, `data-notes="c1"`, "its placeholder knows it") | |
| 659 | + | assert.Contains(t, page.body, `<span class="cn" title="1 open comment">1</span>`, "the list counts it") | |
| 660 | + | m := reWindowSrc.FindStringSubmatch(page.body) | |
| 661 | + | require.Len(t, m, 2, "a placeholder holds the address of its window") | |
| 662 | + | src := html.UnescapeString(m[1]) | |
| 663 | + | assert.Contains(t, src, "head="+head) | |
| 664 | + | ||
| 665 | + | win := b.get(strings.Replace(src, "from=25", "from=50", 1), nil) | |
| 666 | + | require.Equal(t, 200, win.status, win.body) | |
| 667 | + | assert.Contains(t, win.body, "far note") | |
| 668 | + | assert.Contains(t, win.body, `line 55</td>`) | |
| 669 | + | assert.NotContains(t, win.body, "<html", "a window is a fragment") | |
| 670 | + | assert.NotContains(t, win.body, `class="dlazy`) | |
| 671 | + | // The window renders the layouts of the page: both when it switches them, | |
| 672 | + | // the one it shows otherwise. | |
| 673 | + | both := b.get(src+"&view=split&layouts=both", nil) | |
| 674 | + | assert.Contains(t, both.body, `class="dt uni"`) | |
| 675 | + | assert.Contains(t, both.body, `class="dt spl"`) | |
| 676 | + | one := b.get(src+"&view=split&layouts=one", nil) | |
| 677 | + | assert.NotContains(t, one.body, `class="dt uni"`) | |
| 678 | + | assert.Contains(t, one.body, `class="dt spl"`) | |
| 679 | + | assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=7", 1), nil).status, "not a window start") | |
| 680 | + | assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=100", 1), nil).status, "past the files") | |
| 681 | + | ||
| 682 | + | // The page of a window, without scripts: its hunks, the line forms of the | |
| 683 | + | // owner point back to it. | |
| 684 | + | own := b.get("/proj/merge-requests/1?from=50", as(token)) | |
| 685 | + | require.Equal(t, 200, own.status) | |
| 686 | + | assert.Contains(t, own.body, "line 55</td>") | |
| 687 | + | assert.Contains(t, own.body, `href="/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt#f`) | |
| 688 | + | assert.NotContains(t, own.body, "line 0</td>", "the first window is a placeholder there") | |
| 689 | + | assert.Contains(t, own.body, `href="/proj/merge-requests/1?view=split&from=50"`, "the layout links keep the window") | |
| 690 | + | assert.Equal(t, 404, b.get("/proj/merge-requests/1?from=3", nil).status) | |
| 691 | + | ||
| 692 | + | // A comment on a line of a far window: its form, its cancel link and the | |
| 693 | + | // answer return to that window. | |
| 694 | + | form := b.get("/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt", as(token)) | |
| 695 | + | require.Equal(t, 200, form.status) | |
| 696 | + | assert.Contains(t, form.body, `name="from" value="50"`) | |
| 697 | + | assert.Contains(t, form.body, `class="btn" href="/proj/merge-requests/1?from=50#f`) | |
| 698 | + | res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"near it"}, "path": {"many/f55.txt"}, "line": {"1"}, "side": {"new"}, "from": {"50"}}) | |
| 699 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |
| 700 | + | assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location")) | |
| 701 | + | assert.Contains(t, b.get("/proj/merge-requests/1?from=50", as(token)).body, `<input type="hidden" name="from" value="50">`, "its resolve form too") | |
| 702 | + | res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"true"}, "from": {"50"}}) | |
| 703 | + | assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location")) | |
| 704 | + | res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"false"}, "from": {"7"}}) | |
| 705 | + | assert.Equal(t, "/proj/merge-requests/1#c2", res.hdr.Get("Location"), "a window that is none is dropped") | |
| 706 | + | ||
| 707 | + | // The head moved: the window says to reload, never a file of another diff. | |
| 708 | + | r.writeFile(r.wt, "later.txt", []byte("later\n")) | |
| 709 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |
| 710 | + | stale := b.get(src, nil) | |
| 711 | + | assert.Equal(t, http.StatusConflict, stale.status) | |
| 712 | + | assert.Contains(t, stale.body, "data-moved") | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`) | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "fmt" | |||
| 6 | "html" | 7 | "html" | ||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "net/url" | 9 | "net/url" | ||
| @@ -141,7 +142,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 141 | assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept") | 142 | assert.Contains(t, mr.body, "Finds things.\nSee ", "line breaks are kept") | ||
| 142 | assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form") | 143 | assert.NotContains(t, mr.body, "<form", "an anonymous visitor gets no form") | ||
| 143 | assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line") | 144 | assert.NotContains(t, mr.body, "ermite mr merge", "nor the merge line") | ||
| 144 | - | assert.NotContains(t, mr.body, ">Merge</button>") | 145 | + | assert.NotContains(t, mr.body, `id="merge-menu"`) |
| 145 | assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail") | 146 | assert.Contains(t, mr.body, `href="/proj/commit/`+head+`"`, "the commits are in the rail") | ||
| 146 | assert.NotContains(t, strings.ToLower(mr.body), "proposal") | 147 | assert.NotContains(t, strings.ToLower(mr.body), "proposal") | ||
| 147 | assertNoInline(t, mr.body) | 148 | assertNoInline(t, mr.body) | ||
| @@ -157,10 +158,11 @@ func TestWebMergeRequestPagesRender(t *testing.T) { | |||||
| 157 | assert.Contains(t, own.hdr.Values("Vary"), "Cookie") | 158 | assert.Contains(t, own.hdr.Values("Vary"), "Cookie") | ||
| 158 | assert.Equal(t, head, formHead(t, own.body)) | 159 | assert.Equal(t, head, formHead(t, own.body)) | ||
| 159 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | 160 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/merge"`) | ||
| 160 | - | assert.Contains(t, own.body, `<option value="fast-forward">`) | 161 | + | assert.Contains(t, own.body, `id="merge-menu"`, "the merge is a menu of the bar above the diff") |
| 161 | - | assert.Contains(t, own.body, `<option value="squash">`) | 162 | + | assert.Contains(t, own.body, `<input type="radio" name="mode" value="fast-forward" checked>`, "the first mode is the default") |
| 163 | + | assert.Contains(t, own.body, `<input type="radio" name="mode" value="squash">`) | |||
| 162 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | 164 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head) | ||
| 163 | - | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge`) | 165 | + | assert.Contains(t, own.body, `name="delete_branch" value="1"> delete feat/search after the merge <kbd aria-hidden="true">D</kbd>`) |
| 164 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | 166 | assert.NotContains(t, own.body, `name="delete_branch" value="1" checked>`, "unchecked by default") | ||
| 165 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash") | 167 | assert.Contains(t, own.body, "ermite mr merge proj 1 --head "+head+" --squash") | ||
| 166 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | 168 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/comments"`) | ||
| @@ -313,7 +315,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||||
| 313 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`) | 315 | assert.Contains(t, own.body, `action="/proj/merge-requests/1/read"`) | ||
| 314 | res = b.form(base+"/read", token, url.Values{"head_id": {head1}}) | 316 | res = b.form(base+"/read", token, url.Values{"head_id": {head1}}) | ||
| 315 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | 317 | require.Equal(t, http.StatusSeeOther, res.status, res.body) | ||
| 316 | - | assert.NotContains(t, b.get(base, as(token)).body, ">Mark read</button>", "the head is the one that was read") | 318 | + | assert.NotContains(t, b.get(base, as(token)).body, "data-read>Mark read", "the head is the one that was read") |
| 317 | r.writeFile(r.wt, "extra.txt", []byte("extra\n")) | 319 | r.writeFile(r.wt, "extra.txt", []byte("extra\n")) | ||
| 318 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | 320 | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | ||
| 319 | head2 := r.headOf("feat/search") | 321 | head2 := r.headOf("feat/search") | ||
| @@ -322,7 +324,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||||
| 322 | assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`) | 324 | assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`) | ||
| 323 | assert.Contains(t, all.body, "since your last read") | 325 | assert.Contains(t, all.body, "since your last read") | ||
| 324 | assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked") | 326 | assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked") | ||
| 325 | - | assert.Contains(t, all.body, ">Mark read</button>") | 327 | + | assert.Contains(t, all.body, "data-read>Mark read") |
| 326 | assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head") | 328 | assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head") | ||
| 327 | since := b.get(base+"/since", as(token)) | 329 | since := b.get(base+"/since", as(token)) | ||
| 328 | require.Equal(t, 200, since.status, since.body) | 330 | require.Equal(t, 200, since.status, since.body) | ||
| @@ -357,7 +359,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||||
| 357 | assert.Contains(t, done.body, "fast-forward") | 359 | assert.Contains(t, done.body, "fast-forward") | ||
| 358 | assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`) | 360 | assert.Contains(t, done.body, `href="/proj/commit/`+head2+`"`) | ||
| 359 | assert.NotContains(t, done.body, "<form") | 361 | assert.NotContains(t, done.body, "<form") | ||
| 360 | - | assert.NotContains(t, b.get(base, as(token)).body, ">Merge</button>", "nothing to merge any more") | 362 | + | assert.NotContains(t, b.get(base, as(token)).body, `id="merge-menu"`, "nothing to merge any more") |
| 361 | // Writes on a merged request are refused with a page. | 363 | // Writes on a merged request are refused with a page. | ||
| 362 | late := b.form(base+"/comments", token, url.Values{"text": {"too late"}}) | 364 | late := b.form(base+"/comments", token, url.Values{"text": {"too late"}}) | ||
| 363 | assert.Equal(t, http.StatusConflict, late.status) | 365 | assert.Equal(t, http.StatusConflict, late.status) | ||
| @@ -628,3 +630,86 @@ func TestWebMergeRequestSinceAfterForcePush(t *testing.T) { | |||||
| 628 | assert.Contains(t, lost.body, "is gone, the branch was rewritten") | 630 | assert.Contains(t, lost.body, "is gone, the branch was rewritten") | ||
| 629 | assert.Contains(t, lost.body, "other.txt", "the whole change is shown") | 631 | assert.Contains(t, lost.body, "other.txt", "the whole change is shown") | ||
| 630 | } | 632 | } | ||
| 633 | + | ||||
| 634 | + | // A request of many files: the page lists every file and holds the hunks of | |||
| 635 | + | // the first window only; the other files are placeholders that load their | |||
| 636 | + | // window from /files, which carries the base and the head of the diff. A | |||
| 637 | + | // comment on a far file comes with its window; a moved head answers 409; the | |||
| 638 | + | // page of a window (?from=, the links of a page without scripts) shows its | |||
| 639 | + | // hunks and its line forms. | |||
| 640 | + | func TestWebMergeRequestFileWindows(t *testing.T) { | |||
| 641 | + | r := newMRRepo(t, domain.VisibilityPublic) | |||
| 642 | + | b := browser{r.harness} | |||
| 643 | + | token := r.signIn(t) | |||
| 644 | + | const n = 60 | |||
| 645 | + | for i := range n { | |||
| 646 | + | r.writeFile(r.wt, fmt.Sprintf("many/f%02d.txt", i), []byte(fmt.Sprintf("line %d\n", i))) | |||
| 647 | + | } | |||
| 648 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |||
| 649 | + | head := r.headOf("feat/search") | |||
| 650 | + | r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "Many").ok(t) | |||
| 651 | + | r.cli(t, adminToken, "mr", "comment", "proj", "1", "--path", "many/f55.txt", "--line", "1", "-m", "far note").ok(t) | |||
| 652 | + | ||||
| 653 | + | page := b.get("/proj/merge-requests/1", nil) | |||
| 654 | + | require.Equal(t, 200, page.status) | |||
| 655 | + | assert.Equal(t, n+1, strings.Count(page.body, `<li><a href="#f`), "every file is listed") | |||
| 656 | + | assert.Equal(t, n+1-25, strings.Count(page.body, `class="dlazy`), "the files past the first window are placeholders") | |||
| 657 | + | assert.NotContains(t, page.body, "far note", "the comment of a far file comes with its window") | |||
| 658 | + | assert.Contains(t, page.body, `data-notes="c1"`, "its placeholder knows it") | |||
| 659 | + | assert.Contains(t, page.body, `<span class="cn" title="1 open comment">1</span>`, "the list counts it") | |||
| 660 | + | m := reWindowSrc.FindStringSubmatch(page.body) | |||
| 661 | + | require.Len(t, m, 2, "a placeholder holds the address of its window") | |||
| 662 | + | src := html.UnescapeString(m[1]) | |||
| 663 | + | assert.Contains(t, src, "head="+head) | |||
| 664 | + | ||||
| 665 | + | win := b.get(strings.Replace(src, "from=25", "from=50", 1), nil) | |||
| 666 | + | require.Equal(t, 200, win.status, win.body) | |||
| 667 | + | assert.Contains(t, win.body, "far note") | |||
| 668 | + | assert.Contains(t, win.body, `line 55</td>`) | |||
| 669 | + | assert.NotContains(t, win.body, "<html", "a window is a fragment") | |||
| 670 | + | assert.NotContains(t, win.body, `class="dlazy`) | |||
| 671 | + | // The window renders the layouts of the page: both when it switches them, | |||
| 672 | + | // the one it shows otherwise. | |||
| 673 | + | both := b.get(src+"&view=split&layouts=both", nil) | |||
| 674 | + | assert.Contains(t, both.body, `class="dt uni"`) | |||
| 675 | + | assert.Contains(t, both.body, `class="dt spl"`) | |||
| 676 | + | one := b.get(src+"&view=split&layouts=one", nil) | |||
| 677 | + | assert.NotContains(t, one.body, `class="dt uni"`) | |||
| 678 | + | assert.Contains(t, one.body, `class="dt spl"`) | |||
| 679 | + | assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=7", 1), nil).status, "not a window start") | |||
| 680 | + | assert.Equal(t, 404, b.get(strings.Replace(src, "from=25", "from=100", 1), nil).status, "past the files") | |||
| 681 | + | ||||
| 682 | + | // The page of a window, without scripts: its hunks, the line forms of the | |||
| 683 | + | // owner point back to it. | |||
| 684 | + | own := b.get("/proj/merge-requests/1?from=50", as(token)) | |||
| 685 | + | require.Equal(t, 200, own.status) | |||
| 686 | + | assert.Contains(t, own.body, "line 55</td>") | |||
| 687 | + | assert.Contains(t, own.body, `href="/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt#f`) | |||
| 688 | + | assert.NotContains(t, own.body, "line 0</td>", "the first window is a placeholder there") | |||
| 689 | + | assert.Contains(t, own.body, `href="/proj/merge-requests/1?view=split&from=50"`, "the layout links keep the window") | |||
| 690 | + | assert.Equal(t, 404, b.get("/proj/merge-requests/1?from=3", nil).status) | |||
| 691 | + | ||||
| 692 | + | // A comment on a line of a far window: its form, its cancel link and the | |||
| 693 | + | // answer return to that window. | |||
| 694 | + | form := b.get("/proj/merge-requests/1?from=50&on=new:1:many%2Ff55.txt", as(token)) | |||
| 695 | + | require.Equal(t, 200, form.status) | |||
| 696 | + | assert.Contains(t, form.body, `name="from" value="50"`) | |||
| 697 | + | assert.Contains(t, form.body, `class="btn" href="/proj/merge-requests/1?from=50#f`) | |||
| 698 | + | res := b.form("/proj/merge-requests/1/comments", token, url.Values{"text": {"near it"}, "path": {"many/f55.txt"}, "line": {"1"}, "side": {"new"}, "from": {"50"}}) | |||
| 699 | + | require.Equal(t, http.StatusSeeOther, res.status, res.body) | |||
| 700 | + | assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location")) | |||
| 701 | + | assert.Contains(t, b.get("/proj/merge-requests/1?from=50", as(token)).body, `<input type="hidden" name="from" value="50">`, "its resolve form too") | |||
| 702 | + | res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"true"}, "from": {"50"}}) | |||
| 703 | + | assert.Equal(t, "/proj/merge-requests/1?from=50#c2", res.hdr.Get("Location")) | |||
| 704 | + | res = b.form("/proj/merge-requests/1/comments/2/resolve", token, url.Values{"resolved": {"false"}, "from": {"7"}}) | |||
| 705 | + | assert.Equal(t, "/proj/merge-requests/1#c2", res.hdr.Get("Location"), "a window that is none is dropped") | |||
| 706 | + | ||||
| 707 | + | // The head moved: the window says to reload, never a file of another diff. | |||
| 708 | + | r.writeFile(r.wt, "later.txt", []byte("later\n")) | |||
| 709 | + | r.mustGit(r.wt, "push", "-q", r.url, "feat/search") | |||
| 710 | + | stale := b.get(src, nil) | |||
| 711 | + | assert.Equal(t, http.StatusConflict, stale.status) | |||
| 712 | + | assert.Contains(t, stale.body, "data-moved") | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | var reWindowSrc = regexp.MustCompile(`data-src="([^"]*from=25[^"]*)"`) | |||
M internal/domain/git.go +14 -3
| @@ -493,7 +493,8 @@ type FileDiff struct { | |||
| 493 | 493 | // Similarity is a percentage for a renamed or copied file, 0 otherwise. | |
| 494 | 494 | Similarity int | |
| 495 | 495 | // Truncated means the file exceeded a diff limit: Hunks is empty although | |
| 496 | - | // the file changed. Additions and Deletions stay accurate. | |
| 496 | + | // the file changed. Additions and Deletions stay accurate. A file outside | |
| 497 | + | // the DiffLimits window has no Hunks and is not Truncated. | |
| 497 | 498 | Truncated bool | |
| 498 | 499 | Hunks []Hunk | |
| 499 | 500 | } | |
| @@ -502,9 +503,11 @@ type FileDiff struct { | |||
| 502 | 503 | // parent, or against the empty tree for a root commit. | |
| 503 | 504 | type Diff struct { | |
| 504 | 505 | // Files are ordered as Git reports them (by path). Files past | |
| 505 | - | // DiffLimits.MaxFiles are left out. | |
| 506 | + | // DiffLimits.MaxFiles (when it is not 0) are left out. | |
| 506 | 507 | Files []FileDiff | |
| 507 | 508 | // Truncated is true when files were left out or a listed file is truncated. | |
| 509 | + | // Files listed without hunks because they are outside the DiffLimits window | |
| 510 | + | // do not make it true. | |
| 508 | 511 | Truncated bool | |
| 509 | 512 | } | |
| 510 | 513 | ||
| @@ -523,7 +526,8 @@ type CommitDetail struct { | |||
| 523 | 526 | ||
| 524 | 527 | // DiffLimits bound the size of a Diff. Lines are hunk lines, context included. | |
| 525 | 528 | type DiffLimits struct { | |
| 526 | - | // MaxFiles is the number of files kept. | |
| 529 | + | // MaxFiles is the number of files kept; 0 means no limit on the number of | |
| 530 | + | // files (every changed file is listed). | |
| 527 | 531 | MaxFiles int | |
| 528 | 532 | // MaxFileLines is the most lines one file may have; a file over it is | |
| 529 | 533 | // truncated. | |
| @@ -536,6 +540,13 @@ type DiffLimits struct { | |||
| 536 | 540 | // the memory of a diff whose lines are very long; files are truncated | |
| 537 | 541 | // like for MaxTotalLines. | |
| 538 | 542 | MaxTotalBytes int | |
| 543 | + | // From and To select the files whose hunks are read: those with an index in | |
| 544 | + | // [From, To), counted from 0 in the order of Diff.Files. The other files | |
| 545 | + | // are listed (paths, status, modes, ids, Binary, Additions, Deletions, | |
| 546 | + | // Similarity) without hunks; they are not Truncated and use none of | |
| 547 | + | // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values | |
| 548 | + | // select every file. MaxFileLines applies to each file of the window. | |
| 549 | + | From, To int | |
| 539 | 550 | } | |
| 540 | 551 | ||
| 541 | 552 | // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000 | |
| @@ -493,7 +493,8 @@ type FileDiff struct { | |||||
| 493 | // Similarity is a percentage for a renamed or copied file, 0 otherwise. | 493 | // Similarity is a percentage for a renamed or copied file, 0 otherwise. | ||
| 494 | Similarity int | 494 | Similarity int | ||
| 495 | // Truncated means the file exceeded a diff limit: Hunks is empty although | 495 | // Truncated means the file exceeded a diff limit: Hunks is empty although | ||
| 496 | - | // the file changed. Additions and Deletions stay accurate. | 496 | + | // the file changed. Additions and Deletions stay accurate. A file outside |
| 497 | + | // the DiffLimits window has no Hunks and is not Truncated. | |||
| 497 | Truncated bool | 498 | Truncated bool | ||
| 498 | Hunks []Hunk | 499 | Hunks []Hunk | ||
| 499 | } | 500 | } | ||
| @@ -502,9 +503,11 @@ type FileDiff struct { | |||||
| 502 | // parent, or against the empty tree for a root commit. | 503 | // parent, or against the empty tree for a root commit. | ||
| 503 | type Diff struct { | 504 | type Diff struct { | ||
| 504 | // Files are ordered as Git reports them (by path). Files past | 505 | // Files are ordered as Git reports them (by path). Files past | ||
| 505 | - | // DiffLimits.MaxFiles are left out. | 506 | + | // DiffLimits.MaxFiles (when it is not 0) are left out. |
| 506 | Files []FileDiff | 507 | Files []FileDiff | ||
| 507 | // Truncated is true when files were left out or a listed file is truncated. | 508 | // Truncated is true when files were left out or a listed file is truncated. | ||
| 509 | + | // Files listed without hunks because they are outside the DiffLimits window | |||
| 510 | + | // do not make it true. | |||
| 508 | Truncated bool | 511 | Truncated bool | ||
| 509 | } | 512 | } | ||
| 510 | 513 | ||||
| @@ -523,7 +526,8 @@ type CommitDetail struct { | |||||
| 523 | 526 | ||||
| 524 | // DiffLimits bound the size of a Diff. Lines are hunk lines, context included. | 527 | // DiffLimits bound the size of a Diff. Lines are hunk lines, context included. | ||
| 525 | type DiffLimits struct { | 528 | type DiffLimits struct { | ||
| 526 | - | // MaxFiles is the number of files kept. | 529 | + | // MaxFiles is the number of files kept; 0 means no limit on the number of |
| 530 | + | // files (every changed file is listed). | |||
| 527 | MaxFiles int | 531 | MaxFiles int | ||
| 528 | // MaxFileLines is the most lines one file may have; a file over it is | 532 | // MaxFileLines is the most lines one file may have; a file over it is | ||
| 529 | // truncated. | 533 | // truncated. | ||
| @@ -536,6 +540,13 @@ type DiffLimits struct { | |||||
| 536 | // the memory of a diff whose lines are very long; files are truncated | 540 | // the memory of a diff whose lines are very long; files are truncated | ||
| 537 | // like for MaxTotalLines. | 541 | // like for MaxTotalLines. | ||
| 538 | MaxTotalBytes int | 542 | MaxTotalBytes int | ||
| 543 | + | // From and To select the files whose hunks are read: those with an index in | |||
| 544 | + | // [From, To), counted from 0 in the order of Diff.Files. The other files | |||
| 545 | + | // are listed (paths, status, modes, ids, Binary, Additions, Deletions, | |||
| 546 | + | // Similarity) without hunks; they are not Truncated and use none of | |||
| 547 | + | // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values | |||
| 548 | + | // select every file. MaxFileLines applies to each file of the window. | |||
| 549 | + | From, To int | |||
| 539 | } | 550 | } | ||
| 540 | 551 | ||||
| 541 | // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000 | 552 | // DefaultDiffLimits are the limits of the API contract: 300 files, 2,000 | ||
M internal/ports/dto.go +20 -0
| @@ -194,6 +194,26 @@ type MergeRequestDetail struct { | |||
| 194 | 194 | LastReadLost bool | |
| 195 | 195 | } | |
| 196 | 196 | ||
| 197 | + | // GetMergeRequestOptions say which diff MergeRequestService.Get reads. | |
| 198 | + | type GetMergeRequestOptions struct { | |
| 199 | + | // SinceLastRead compares the head recorded by MarkRead with the head (see Get). | |
| 200 | + | SinceLastRead bool | |
| 201 | + | // Files, when set, lists every changed file, without the file-count | |
| 202 | + | // limit, and reads the hunks of the files in the window only, each within | |
| 203 | + | // the per-file and the total limits of domain.DefaultDiffLimits. Unset, | |
| 204 | + | // the diff is bounded by domain.DefaultDiffLimits (the API contract). | |
| 205 | + | Files *FileWindow | |
| 206 | + | // Base and Head, when not zero, are the diff the caller shows: Get | |
| 207 | + | // returns domain.ErrConflict when the request's diff base or head differ | |
| 208 | + | // (the branch moved, a read was marked). | |
| 209 | + | Base, Head domain.ObjectID | |
| 210 | + | // DiffOnly skips the commits of the request (Commits stays empty). | |
| 211 | + | DiffOnly bool | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // FileWindow is the range [From, To) of files whose hunks are read. | |
| 215 | + | type FileWindow struct{ From, To int } | |
| 216 | + | ||
| 197 | 217 | // AddCommentParams describes the comment to add. | |
| 198 | 218 | type AddCommentParams struct { | |
| 199 | 219 | // Path, Line and Side designate a line of the diff; all empty for a | |
| @@ -194,6 +194,26 @@ type MergeRequestDetail struct { | |||||
| 194 | LastReadLost bool | 194 | LastReadLost bool | ||
| 195 | } | 195 | } | ||
| 196 | 196 | ||||
| 197 | + | // GetMergeRequestOptions say which diff MergeRequestService.Get reads. | |||
| 198 | + | type GetMergeRequestOptions struct { | |||
| 199 | + | // SinceLastRead compares the head recorded by MarkRead with the head (see Get). | |||
| 200 | + | SinceLastRead bool | |||
| 201 | + | // Files, when set, lists every changed file, without the file-count | |||
| 202 | + | // limit, and reads the hunks of the files in the window only, each within | |||
| 203 | + | // the per-file and the total limits of domain.DefaultDiffLimits. Unset, | |||
| 204 | + | // the diff is bounded by domain.DefaultDiffLimits (the API contract). | |||
| 205 | + | Files *FileWindow | |||
| 206 | + | // Base and Head, when not zero, are the diff the caller shows: Get | |||
| 207 | + | // returns domain.ErrConflict when the request's diff base or head differ | |||
| 208 | + | // (the branch moved, a read was marked). | |||
| 209 | + | Base, Head domain.ObjectID | |||
| 210 | + | // DiffOnly skips the commits of the request (Commits stays empty). | |||
| 211 | + | DiffOnly bool | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // FileWindow is the range [From, To) of files whose hunks are read. | |||
| 215 | + | type FileWindow struct{ From, To int } | |||
| 216 | + | ||||
| 197 | // AddCommentParams describes the comment to add. | 217 | // AddCommentParams describes the comment to add. | ||
| 198 | type AddCommentParams struct { | 218 | type AddCommentParams struct { | ||
| 199 | // Path, Line and Side designate a line of the diff; all empty for a | 219 | // Path, Line and Side designate a line of the diff; all empty for a | ||
M internal/ports/mocks/MergeRequestService.go +15 -15
| @@ -361,8 +361,8 @@ func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Con | |||
| 361 | 361 | } | |
| 362 | 362 | ||
| 363 | 363 | // Get provides a mock function for the type MergeRequestService | |
| 364 | - | func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) { | |
| 365 | - | ret := _mock.Called(ctx, p, name, number, sinceLastRead) | |
| 364 | + | func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { | |
| 365 | + | ret := _mock.Called(ctx, p, name, number, opts) | |
| 366 | 366 | ||
| 367 | 367 | if len(ret) == 0 { | |
| 368 | 368 | panic("no return value specified for Get") | |
| @@ -370,16 +370,16 @@ func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, n | |||
| 370 | 370 | ||
| 371 | 371 | var r0 ports.MergeRequestDetail | |
| 372 | 372 | var r1 error | |
| 373 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (ports.MergeRequestDetail, error)); ok { | |
| 374 | - | return returnFunc(ctx, p, name, number, sinceLastRead) | |
| 373 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error)); ok { | |
| 374 | + | return returnFunc(ctx, p, name, number, opts) | |
| 375 | 375 | } | |
| 376 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) ports.MergeRequestDetail); ok { | |
| 377 | - | r0 = returnFunc(ctx, p, name, number, sinceLastRead) | |
| 376 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) ports.MergeRequestDetail); ok { | |
| 377 | + | r0 = returnFunc(ctx, p, name, number, opts) | |
| 378 | 378 | } else { | |
| 379 | 379 | r0 = ret.Get(0).(ports.MergeRequestDetail) | |
| 380 | 380 | } | |
| 381 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok { | |
| 382 | - | r1 = returnFunc(ctx, p, name, number, sinceLastRead) | |
| 381 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) error); ok { | |
| 382 | + | r1 = returnFunc(ctx, p, name, number, opts) | |
| 383 | 383 | } else { | |
| 384 | 384 | r1 = ret.Error(1) | |
| 385 | 385 | } | |
| @@ -396,12 +396,12 @@ type MergeRequestService_Get_Call struct { | |||
| 396 | 396 | // - p domain.Principal | |
| 397 | 397 | // - name domain.RepoName | |
| 398 | 398 | // - number int64 | |
| 399 | - | // - sinceLastRead bool | |
| 400 | - | func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, sinceLastRead any) *MergeRequestService_Get_Call { | |
| 401 | - | return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, sinceLastRead)} | |
| 399 | + | // - opts ports.GetMergeRequestOptions | |
| 400 | + | func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, opts any) *MergeRequestService_Get_Call { | |
| 401 | + | return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, opts)} | |
| 402 | 402 | } | |
| 403 | 403 | ||
| 404 | - | func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool)) *MergeRequestService_Get_Call { | |
| 404 | + | func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions)) *MergeRequestService_Get_Call { | |
| 405 | 405 | _c.Call.Run(func(args mock.Arguments) { | |
| 406 | 406 | var arg0 context.Context | |
| 407 | 407 | if args[0] != nil { | |
| @@ -419,9 +419,9 @@ func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p doma | |||
| 419 | 419 | if args[3] != nil { | |
| 420 | 420 | arg3 = args[3].(int64) | |
| 421 | 421 | } | |
| 422 | - | var arg4 bool | |
| 422 | + | var arg4 ports.GetMergeRequestOptions | |
| 423 | 423 | if args[4] != nil { | |
| 424 | - | arg4 = args[4].(bool) | |
| 424 | + | arg4 = args[4].(ports.GetMergeRequestOptions) | |
| 425 | 425 | } | |
| 426 | 426 | run( | |
| 427 | 427 | arg0, | |
| @@ -439,7 +439,7 @@ func (_c *MergeRequestService_Get_Call) Return(mergeRequestDetail ports.MergeReq | |||
| 439 | 439 | return _c | |
| 440 | 440 | } | |
| 441 | 441 | ||
| 442 | - | func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call { | |
| 442 | + | func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call { | |
| 443 | 443 | _c.Call.Return(run) | |
| 444 | 444 | return _c | |
| 445 | 445 | } | |
| @@ -361,8 +361,8 @@ func (_c *MergeRequestService_Create_Call) RunAndReturn(run func(ctx context.Con | |||||
| 361 | } | 361 | } | ||
| 362 | 362 | ||||
| 363 | // Get provides a mock function for the type MergeRequestService | 363 | // Get provides a mock function for the type MergeRequestService | ||
| 364 | - | func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) { | 364 | + | func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { |
| 365 | - | ret := _mock.Called(ctx, p, name, number, sinceLastRead) | 365 | + | ret := _mock.Called(ctx, p, name, number, opts) |
| 366 | 366 | ||||
| 367 | if len(ret) == 0 { | 367 | if len(ret) == 0 { | ||
| 368 | panic("no return value specified for Get") | 368 | panic("no return value specified for Get") | ||
| @@ -370,16 +370,16 @@ func (_mock *MergeRequestService) Get(ctx context.Context, p domain.Principal, n | |||||
| 370 | 370 | ||||
| 371 | var r0 ports.MergeRequestDetail | 371 | var r0 ports.MergeRequestDetail | ||
| 372 | var r1 error | 372 | var r1 error | ||
| 373 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (ports.MergeRequestDetail, error)); ok { | 373 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error)); ok { |
| 374 | - | return returnFunc(ctx, p, name, number, sinceLastRead) | 374 | + | return returnFunc(ctx, p, name, number, opts) |
| 375 | } | 375 | } | ||
| 376 | - | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) ports.MergeRequestDetail); ok { | 376 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) ports.MergeRequestDetail); ok { |
| 377 | - | r0 = returnFunc(ctx, p, name, number, sinceLastRead) | 377 | + | r0 = returnFunc(ctx, p, name, number, opts) |
| 378 | } else { | 378 | } else { | ||
| 379 | r0 = ret.Get(0).(ports.MergeRequestDetail) | 379 | r0 = ret.Get(0).(ports.MergeRequestDetail) | ||
| 380 | } | 380 | } | ||
| 381 | - | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok { | 381 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, ports.GetMergeRequestOptions) error); ok { |
| 382 | - | r1 = returnFunc(ctx, p, name, number, sinceLastRead) | 382 | + | r1 = returnFunc(ctx, p, name, number, opts) |
| 383 | } else { | 383 | } else { | ||
| 384 | r1 = ret.Error(1) | 384 | r1 = ret.Error(1) | ||
| 385 | } | 385 | } | ||
| @@ -396,12 +396,12 @@ type MergeRequestService_Get_Call struct { | |||||
| 396 | // - p domain.Principal | 396 | // - p domain.Principal | ||
| 397 | // - name domain.RepoName | 397 | // - name domain.RepoName | ||
| 398 | // - number int64 | 398 | // - number int64 | ||
| 399 | - | // - sinceLastRead bool | 399 | + | // - opts ports.GetMergeRequestOptions |
| 400 | - | func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, sinceLastRead any) *MergeRequestService_Get_Call { | 400 | + | func (_e *MergeRequestService_Expecter) Get(ctx any, p any, name any, number any, opts any) *MergeRequestService_Get_Call { |
| 401 | - | return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, sinceLastRead)} | 401 | + | return &MergeRequestService_Get_Call{Call: _e.mock.On("Get", ctx, p, name, number, opts)} |
| 402 | } | 402 | } | ||
| 403 | 403 | ||||
| 404 | - | func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool)) *MergeRequestService_Get_Call { | 404 | + | func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions)) *MergeRequestService_Get_Call { |
| 405 | _c.Call.Run(func(args mock.Arguments) { | 405 | _c.Call.Run(func(args mock.Arguments) { | ||
| 406 | var arg0 context.Context | 406 | var arg0 context.Context | ||
| 407 | if args[0] != nil { | 407 | if args[0] != nil { | ||
| @@ -419,9 +419,9 @@ func (_c *MergeRequestService_Get_Call) Run(run func(ctx context.Context, p doma | |||||
| 419 | if args[3] != nil { | 419 | if args[3] != nil { | ||
| 420 | arg3 = args[3].(int64) | 420 | arg3 = args[3].(int64) | ||
| 421 | } | 421 | } | ||
| 422 | - | var arg4 bool | 422 | + | var arg4 ports.GetMergeRequestOptions |
| 423 | if args[4] != nil { | 423 | if args[4] != nil { | ||
| 424 | - | arg4 = args[4].(bool) | 424 | + | arg4 = args[4].(ports.GetMergeRequestOptions) |
| 425 | } | 425 | } | ||
| 426 | run( | 426 | run( | ||
| 427 | arg0, | 427 | arg0, | ||
| @@ -439,7 +439,7 @@ func (_c *MergeRequestService_Get_Call) Return(mergeRequestDetail ports.MergeReq | |||||
| 439 | return _c | 439 | return _c | ||
| 440 | } | 440 | } | ||
| 441 | 441 | ||||
| 442 | - | func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call { | 442 | + | func (_c *MergeRequestService_Get_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error)) *MergeRequestService_Get_Call { |
| 443 | _c.Call.Return(run) | 443 | _c.Call.Return(run) | ||
| 444 | return _c | 444 | return _c | ||
| 445 | } | 445 | } | ||
M internal/ports/services.go +1 -1
| @@ -108,7 +108,7 @@ type EventService interface { | |||
| 108 | 108 | type MergeRequestService interface { | |
| 109 | 109 | Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) | |
| 110 | 110 | List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) | |
| 111 | - | Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (MergeRequestDetail, error) | |
| 111 | + | Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error) | |
| 112 | 112 | AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error) | |
| 113 | 113 | ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) | |
| 114 | 114 | Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) | |
| @@ -108,7 +108,7 @@ type EventService interface { | |||||
| 108 | type MergeRequestService interface { | 108 | type MergeRequestService interface { | ||
| 109 | Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) | 109 | Create(ctx context.Context, p domain.Principal, name domain.RepoName, params CreateMergeRequestParams) (domain.MergeRequest, error) | ||
| 110 | List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) | 110 | List(ctx context.Context, p domain.Principal, name domain.RepoName, params ListMergeRequestsParams) (MergeRequestPage, error) | ||
| 111 | - | Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (MergeRequestDetail, error) | 111 | + | Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts GetMergeRequestOptions) (MergeRequestDetail, error) |
| 112 | AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error) | 112 | AddComment(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params AddCommentParams) (domain.Comment, error) | ||
| 113 | ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) | 113 | ResolveComment(ctx context.Context, p domain.Principal, name domain.RepoName, number, commentID int64, resolved bool) (domain.Comment, error) | ||
| 114 | Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) | 114 | Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) | ||
M internal/usecase/ci_actions_test.go +1 -1
| @@ -75,7 +75,7 @@ func TestPickRemoteRef(t *testing.T) { | |||
| 75 | 75 | ||
| 76 | 76 | // countingRemote answers ListRefs from a function and counts the calls. | |
| 77 | 77 | type countingRemote struct { | |
| 78 | - | calls int | |
| 78 | + | calls int | |
| 79 | 79 | answer func() ([]ports.RemoteRef, error) | |
| 80 | 80 | } | |
| 81 | 81 | ||
| @@ -75,7 +75,7 @@ func TestPickRemoteRef(t *testing.T) { | |||||
| 75 | 75 | ||||
| 76 | // countingRemote answers ListRefs from a function and counts the calls. | 76 | // countingRemote answers ListRefs from a function and counts the calls. | ||
| 77 | type countingRemote struct { | 77 | type countingRemote struct { | ||
| 78 | - | calls int | 78 | + | calls int |
| 79 | answer func() ([]ports.RemoteRef, error) | 79 | answer func() ([]ports.RemoteRef, error) | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
M internal/usecase/mergerequests.go +72 -14
| @@ -7,6 +7,8 @@ import ( | |||
| 7 | 7 | "encoding/base64" | |
| 8 | 8 | "errors" | |
| 9 | 9 | "fmt" | |
| 10 | + | "math" | |
| 11 | + | "slices" | |
| 10 | 12 | "strconv" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "sync" | |
| @@ -419,12 +421,24 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain | |||
| 419 | 421 | // Get returns a merge request with its commits, its diff, its comments and the | |
| 420 | 422 | // read position. The diff compares the merge base of the target and the head | |
| 421 | 423 | // with the head ("target...head"), within domain.DefaultDiffLimits. With | |
| 422 | - | // sinceLastRead it compares the head recorded by MarkRead with the current | |
| 423 | - | // head instead, which works after a force-push as long as the recorded commit | |
| 424 | - | // still exists; when none was recorded the diff is the default one, and when | |
| 425 | - | // the recorded commit is gone too, with LastReadLost set. A merged or closed | |
| 426 | - | // request keeps the head and the base it had. | |
| 427 | - | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) { | |
| 424 | + | // opts.SinceLastRead it compares the head recorded by MarkRead with the | |
| 425 | + | // current head instead, which works after a force-push as long as the recorded | |
| 426 | + | // commit still exists; when none was recorded the diff is the default one, and | |
| 427 | + | // when the recorded commit is gone too, with LastReadLost set. A merged or | |
| 428 | + | // closed request keeps the head and the base it had. | |
| 429 | + | // | |
| 430 | + | // opts.Files lists every changed file and reads the hunks of a window of them | |
| 431 | + | // only; opts.Base and opts.Head make Get fail with domain.ErrConflict when the | |
| 432 | + | // diff is no longer the one the caller shows (before any diff is read); | |
| 433 | + | // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid. | |
| 434 | + | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { | |
| 435 | + | limits := domain.DefaultDiffLimits() | |
| 436 | + | if w := opts.Files; w != nil { | |
| 437 | + | if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) { | |
| 438 | + | return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To) | |
| 439 | + | } | |
| 440 | + | limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To | |
| 441 | + | } | |
| 428 | 442 | _, mr, err := m.get(ctx, p, name, number) | |
| 429 | 443 | if err != nil { | |
| 430 | 444 | return ports.MergeRequestDetail{}, err | |
| @@ -432,6 +446,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 432 | 446 | if mr, err = m.withHead(ctx, mr); err != nil { | |
| 433 | 447 | return ports.MergeRequestDetail{}, err | |
| 434 | 448 | } | |
| 449 | + | if !opts.Head.IsZero() && opts.Head != mr.HeadID { | |
| 450 | + | return ports.MergeRequestDetail{}, fmt.Errorf("%w: the head of !%d moved to %s", domain.ErrConflict, number, mr.HeadID) | |
| 451 | + | } | |
| 452 | + | // checkBase fails when the caller shows another diff base than base. | |
| 453 | + | checkBase := func(base domain.ObjectID) error { | |
| 454 | + | if !opts.Base.IsZero() && opts.Base != base { | |
| 455 | + | return fmt.Errorf("%w: the diff base of !%d is now %s", domain.ErrConflict, number, base) | |
| 456 | + | } | |
| 457 | + | return nil | |
| 458 | + | } | |
| 435 | 459 | d := ports.MergeRequestDetail{MergeRequest: mr} | |
| 436 | 460 | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | |
| 437 | 461 | return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err) | |
| @@ -446,7 +470,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 446 | 470 | } | |
| 447 | 471 | head := mr.HeadID | |
| 448 | 472 | if head.IsZero() { | |
| 449 | - | return d, nil // the source branch is gone: no commits, no diff | |
| 473 | + | return d, checkBase(domain.ObjectID{}) // the source branch is gone: no commits, no diff | |
| 450 | 474 | } | |
| 451 | 475 | if !mr.IsOpen() { | |
| 452 | 476 | // After a squash the reviewed commits are only reachable from the | |
| @@ -455,13 +479,19 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 455 | 479 | case err != nil: | |
| 456 | 480 | return ports.MergeRequestDetail{}, err | |
| 457 | 481 | case !ok: | |
| 458 | - | return d, nil // the reviewed commits are gone: no commits, no diff | |
| 482 | + | // The reviewed commits are gone: no commits, no diff. | |
| 483 | + | if err := checkBase(domain.ObjectID{}); err != nil { | |
| 484 | + | return ports.MergeRequestDetail{}, err | |
| 485 | + | } | |
| 486 | + | return d, nil | |
| 459 | 487 | } | |
| 460 | 488 | } | |
| 461 | - | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | |
| 462 | - | return ports.MergeRequestDetail{}, err | |
| 489 | + | if !opts.DiffOnly { | |
| 490 | + | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | |
| 491 | + | return ports.MergeRequestDetail{}, err | |
| 492 | + | } | |
| 463 | 493 | } | |
| 464 | - | if sinceLastRead && !d.LastReadID.IsZero() { | |
| 494 | + | if opts.SinceLastRead && !d.LastReadID.IsZero() { | |
| 465 | 495 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | |
| 466 | 496 | case err != nil: | |
| 467 | 497 | return ports.MergeRequestDetail{}, err | |
| @@ -471,11 +501,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||
| 471 | 501 | d.LastReadLost = true | |
| 472 | 502 | } | |
| 473 | 503 | } | |
| 504 | + | if err := checkBase(base); err != nil { | |
| 505 | + | return ports.MergeRequestDetail{}, err | |
| 506 | + | } | |
| 474 | 507 | if base.IsZero() { | |
| 475 | 508 | return d, nil | |
| 476 | 509 | } | |
| 477 | 510 | d.DiffBaseID = base | |
| 478 | - | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, domain.DefaultDiffLimits()); err != nil { | |
| 511 | + | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil { | |
| 479 | 512 | return ports.MergeRequestDetail{}, err | |
| 480 | 513 | } | |
| 481 | 514 | return d, nil | |
| @@ -601,16 +634,41 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p | |||
| 601 | 634 | v.Add("path", "the merge request has no diff to comment on") | |
| 602 | 635 | return v.Err() | |
| 603 | 636 | } | |
| 604 | - | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, domain.DefaultDiffLimits()) | |
| 637 | + | // A comment may be on a file past the 300th that the web view lists, so | |
| 638 | + | // the file is looked up among all the files (MaxFiles 0). Reading every | |
| 639 | + | // file's hunks within the default total limits would reject a comment on a | |
| 640 | + | // file the earlier ones left no budget for. So a first read lists the files | |
| 641 | + | // with no hunks (From past the end: cheap, git is stopped at once), and a | |
| 642 | + | // second reads the hunks of the one file within the default per-file limit | |
| 643 | + | // and total limits, as the web view does for a window holding it. | |
| 644 | + | list := domain.DefaultDiffLimits() | |
| 645 | + | list.MaxFiles, list.From = 0, math.MaxInt | |
| 646 | + | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list) | |
| 605 | 647 | if err != nil { | |
| 606 | 648 | return err | |
| 607 | 649 | } | |
| 608 | - | if !diff.HasLine(params.Path, params.Side, params.Line) { | |
| 650 | + | idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path }) | |
| 651 | + | if idx >= 0 { | |
| 652 | + | one := domain.DefaultDiffLimits() | |
| 653 | + | one.MaxFiles, one.From, one.To = 0, idx, idx+1 | |
| 654 | + | if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil { | |
| 655 | + | return err | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) { | |
| 609 | 659 | v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side)) | |
| 610 | 660 | } | |
| 611 | 661 | return v.Err() | |
| 612 | 662 | } | |
| 613 | 663 | ||
| 664 | + | // diffFileName is the path a comment designates for f, as Diff.HasLine reads it. | |
| 665 | + | func diffFileName(f domain.FileDiff) string { | |
| 666 | + | if f.Status == domain.FileDeleted { | |
| 667 | + | return f.OldPath | |
| 668 | + | } | |
| 669 | + | return f.NewPath | |
| 670 | + | } | |
| 671 | + | ||
| 614 | 672 | // ResolveComment marks a comment resolved or unresolved. The principal needs | |
| 615 | 673 | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | |
| 616 | 674 | // not open domain.ErrPreconditionFailed. Setting the state the comment already | |
| @@ -7,6 +7,8 @@ import ( | |||||
| 7 | "encoding/base64" | 7 | "encoding/base64" | ||
| 8 | "errors" | 8 | "errors" | ||
| 9 | "fmt" | 9 | "fmt" | ||
| 10 | + | "math" | |||
| 11 | + | "slices" | |||
| 10 | "strconv" | 12 | "strconv" | ||
| 11 | "strings" | 13 | "strings" | ||
| 12 | "sync" | 14 | "sync" | ||
| @@ -419,12 +421,24 @@ func (m *MergeRequests) get(ctx context.Context, p domain.Principal, name domain | |||||
| 419 | // Get returns a merge request with its commits, its diff, its comments and the | 421 | // Get returns a merge request with its commits, its diff, its comments and the | ||
| 420 | // read position. The diff compares the merge base of the target and the head | 422 | // read position. The diff compares the merge base of the target and the head | ||
| 421 | // with the head ("target...head"), within domain.DefaultDiffLimits. With | 423 | // with the head ("target...head"), within domain.DefaultDiffLimits. With | ||
| 422 | - | // sinceLastRead it compares the head recorded by MarkRead with the current | 424 | + | // opts.SinceLastRead it compares the head recorded by MarkRead with the |
| 423 | - | // head instead, which works after a force-push as long as the recorded commit | 425 | + | // current head instead, which works after a force-push as long as the recorded |
| 424 | - | // still exists; when none was recorded the diff is the default one, and when | 426 | + | // commit still exists; when none was recorded the diff is the default one, and |
| 425 | - | // the recorded commit is gone too, with LastReadLost set. A merged or closed | 427 | + | // when the recorded commit is gone too, with LastReadLost set. A merged or |
| 426 | - | // request keeps the head and the base it had. | 428 | + | // closed request keeps the head and the base it had. |
| 427 | - | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, sinceLastRead bool) (ports.MergeRequestDetail, error) { | 429 | + | // |
| 430 | + | // opts.Files lists every changed file and reads the hunks of a window of them | |||
| 431 | + | // only; opts.Base and opts.Head make Get fail with domain.ErrConflict when the | |||
| 432 | + | // diff is no longer the one the caller shows (before any diff is read); | |||
| 433 | + | // opts.DiffOnly skips the commits. An invalid window gives domain.ErrInvalid. | |||
| 434 | + | func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, opts ports.GetMergeRequestOptions) (ports.MergeRequestDetail, error) { | |||
| 435 | + | limits := domain.DefaultDiffLimits() | |||
| 436 | + | if w := opts.Files; w != nil { | |||
| 437 | + | if w.From < 0 || w.To < 0 || (w.To != 0 && w.To < w.From) { | |||
| 438 | + | return ports.MergeRequestDetail{}, fmt.Errorf("%w: file window [%d, %d)", domain.ErrInvalid, w.From, w.To) | |||
| 439 | + | } | |||
| 440 | + | limits.MaxFiles, limits.From, limits.To = 0, w.From, w.To | |||
| 441 | + | } | |||
| 428 | _, mr, err := m.get(ctx, p, name, number) | 442 | _, mr, err := m.get(ctx, p, name, number) | ||
| 429 | if err != nil { | 443 | if err != nil { | ||
| 430 | return ports.MergeRequestDetail{}, err | 444 | return ports.MergeRequestDetail{}, err | ||
| @@ -432,6 +446,16 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||||
| 432 | if mr, err = m.withHead(ctx, mr); err != nil { | 446 | if mr, err = m.withHead(ctx, mr); err != nil { | ||
| 433 | return ports.MergeRequestDetail{}, err | 447 | return ports.MergeRequestDetail{}, err | ||
| 434 | } | 448 | } | ||
| 449 | + | if !opts.Head.IsZero() && opts.Head != mr.HeadID { | |||
| 450 | + | return ports.MergeRequestDetail{}, fmt.Errorf("%w: the head of !%d moved to %s", domain.ErrConflict, number, mr.HeadID) | |||
| 451 | + | } | |||
| 452 | + | // checkBase fails when the caller shows another diff base than base. | |||
| 453 | + | checkBase := func(base domain.ObjectID) error { | |||
| 454 | + | if !opts.Base.IsZero() && opts.Base != base { | |||
| 455 | + | return fmt.Errorf("%w: the diff base of !%d is now %s", domain.ErrConflict, number, base) | |||
| 456 | + | } | |||
| 457 | + | return nil | |||
| 458 | + | } | |||
| 435 | d := ports.MergeRequestDetail{MergeRequest: mr} | 459 | d := ports.MergeRequestDetail{MergeRequest: mr} | ||
| 436 | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | 460 | if d.Comments, err = m.store.ListComments(ctx, name, number); err != nil { | ||
| 437 | return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err) | 461 | return ports.MergeRequestDetail{}, fmt.Errorf("list comments of !%d in %q: %w", number, name, err) | ||
| @@ -446,7 +470,7 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||||
| 446 | } | 470 | } | ||
| 447 | head := mr.HeadID | 471 | head := mr.HeadID | ||
| 448 | if head.IsZero() { | 472 | if head.IsZero() { | ||
| 449 | - | return d, nil // the source branch is gone: no commits, no diff | 473 | + | return d, checkBase(domain.ObjectID{}) // the source branch is gone: no commits, no diff |
| 450 | } | 474 | } | ||
| 451 | if !mr.IsOpen() { | 475 | if !mr.IsOpen() { | ||
| 452 | // After a squash the reviewed commits are only reachable from the | 476 | // After a squash the reviewed commits are only reachable from the | ||
| @@ -455,13 +479,19 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||||
| 455 | case err != nil: | 479 | case err != nil: | ||
| 456 | return ports.MergeRequestDetail{}, err | 480 | return ports.MergeRequestDetail{}, err | ||
| 457 | case !ok: | 481 | case !ok: | ||
| 458 | - | return d, nil // the reviewed commits are gone: no commits, no diff | 482 | + | // The reviewed commits are gone: no commits, no diff. |
| 483 | + | if err := checkBase(domain.ObjectID{}); err != nil { | |||
| 484 | + | return ports.MergeRequestDetail{}, err | |||
| 485 | + | } | |||
| 486 | + | return d, nil | |||
| 459 | } | 487 | } | ||
| 460 | } | 488 | } | ||
| 461 | - | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { | 489 | + | if !opts.DiffOnly { |
| 462 | - | return ports.MergeRequestDetail{}, err | 490 | + | if d.Commits, d.CommitsTruncated, err = m.git.ListRange(ctx, name, exclude, head, MaxMergeRequestCommits); err != nil { |
| 491 | + | return ports.MergeRequestDetail{}, err | |||
| 492 | + | } | |||
| 463 | } | 493 | } | ||
| 464 | - | if sinceLastRead && !d.LastReadID.IsZero() { | 494 | + | if opts.SinceLastRead && !d.LastReadID.IsZero() { |
| 465 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | 495 | switch ok, err := m.git.HasCommit(ctx, name, d.LastReadID); { | ||
| 466 | case err != nil: | 496 | case err != nil: | ||
| 467 | return ports.MergeRequestDetail{}, err | 497 | return ports.MergeRequestDetail{}, err | ||
| @@ -471,11 +501,14 @@ func (m *MergeRequests) Get(ctx context.Context, p domain.Principal, name domain | |||||
| 471 | d.LastReadLost = true | 501 | d.LastReadLost = true | ||
| 472 | } | 502 | } | ||
| 473 | } | 503 | } | ||
| 504 | + | if err := checkBase(base); err != nil { | |||
| 505 | + | return ports.MergeRequestDetail{}, err | |||
| 506 | + | } | |||
| 474 | if base.IsZero() { | 507 | if base.IsZero() { | ||
| 475 | return d, nil | 508 | return d, nil | ||
| 476 | } | 509 | } | ||
| 477 | d.DiffBaseID = base | 510 | d.DiffBaseID = base | ||
| 478 | - | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, domain.DefaultDiffLimits()); err != nil { | 511 | + | if d.Stats, d.Diff, err = m.git.DiffCommits(ctx, name, base, head, limits); err != nil { |
| 479 | return ports.MergeRequestDetail{}, err | 512 | return ports.MergeRequestDetail{}, err | ||
| 480 | } | 513 | } | ||
| 481 | return d, nil | 514 | return d, nil | ||
| @@ -601,16 +634,41 @@ func (m *MergeRequests) checkLine(ctx context.Context, mr domain.MergeRequest, p | |||||
| 601 | v.Add("path", "the merge request has no diff to comment on") | 634 | v.Add("path", "the merge request has no diff to comment on") | ||
| 602 | return v.Err() | 635 | return v.Err() | ||
| 603 | } | 636 | } | ||
| 604 | - | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, domain.DefaultDiffLimits()) | 637 | + | // A comment may be on a file past the 300th that the web view lists, so |
| 638 | + | // the file is looked up among all the files (MaxFiles 0). Reading every | |||
| 639 | + | // file's hunks within the default total limits would reject a comment on a | |||
| 640 | + | // file the earlier ones left no budget for. So a first read lists the files | |||
| 641 | + | // with no hunks (From past the end: cheap, git is stopped at once), and a | |||
| 642 | + | // second reads the hunks of the one file within the default per-file limit | |||
| 643 | + | // and total limits, as the web view does for a window holding it. | |||
| 644 | + | list := domain.DefaultDiffLimits() | |||
| 645 | + | list.MaxFiles, list.From = 0, math.MaxInt | |||
| 646 | + | _, diff, err := m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, list) | |||
| 605 | if err != nil { | 647 | if err != nil { | ||
| 606 | return err | 648 | return err | ||
| 607 | } | 649 | } | ||
| 608 | - | if !diff.HasLine(params.Path, params.Side, params.Line) { | 650 | + | idx := slices.IndexFunc(diff.Files, func(f domain.FileDiff) bool { return diffFileName(f) == params.Path }) |
| 651 | + | if idx >= 0 { | |||
| 652 | + | one := domain.DefaultDiffLimits() | |||
| 653 | + | one.MaxFiles, one.From, one.To = 0, idx, idx+1 | |||
| 654 | + | if _, diff, err = m.git.DiffCommits(ctx, mr.Repo, base, mr.HeadID, one); err != nil { | |||
| 655 | + | return err | |||
| 656 | + | } | |||
| 657 | + | } | |||
| 658 | + | if idx < 0 || !diff.HasLine(params.Path, params.Side, params.Line) { | |||
| 609 | v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side)) | 659 | v.Add("path", fmt.Sprintf("%s:%d on the %s side is not a line of the diff", params.Path, params.Line, params.Side)) | ||
| 610 | } | 660 | } | ||
| 611 | return v.Err() | 661 | return v.Err() | ||
| 612 | } | 662 | } | ||
| 613 | 663 | ||||
| 664 | + | // diffFileName is the path a comment designates for f, as Diff.HasLine reads it. | |||
| 665 | + | func diffFileName(f domain.FileDiff) string { | |||
| 666 | + | if f.Status == domain.FileDeleted { | |||
| 667 | + | return f.OldPath | |||
| 668 | + | } | |||
| 669 | + | return f.NewPath | |||
| 670 | + | } | |||
| 671 | + | ||||
| 614 | // ResolveComment marks a comment resolved or unresolved. The principal needs | 672 | // ResolveComment marks a comment resolved or unresolved. The principal needs | ||
| 615 | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | 673 | // mr:write. An unknown comment gives domain.ErrNotFound and a request that is | ||
| 616 | // not open domain.ErrPreconditionFailed. Setting the state the comment already | 674 | // not open domain.ErrPreconditionFailed. Setting the state the comment already | ||
M internal/usecase/mergerequests_branches_test.go +1 -1
| @@ -39,7 +39,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) { | |||
| 39 | 39 | } | |
| 40 | 40 | assert.Equal(t, main, r.branchHead("main"), "main did not move") | |
| 41 | 41 | assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays") | |
| 42 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 42 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 43 | 43 | require.NoError(t, err) | |
| 44 | 44 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 45 | 45 | assert.Empty(t, r.drain(), "no event") | |
| @@ -39,7 +39,7 @@ func TestMergeWithBranchLimitedToken(t *testing.T) { | |||||
| 39 | } | 39 | } | ||
| 40 | assert.Equal(t, main, r.branchHead("main"), "main did not move") | 40 | assert.Equal(t, main, r.branchHead("main"), "main did not move") | ||
| 41 | assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays") | 41 | assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays") | ||
| 42 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 42 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 43 | require.NoError(t, err) | 43 | require.NoError(t, err) | ||
| 44 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | 44 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | ||
| 45 | assert.Empty(t, r.drain(), "no event") | 45 | assert.Empty(t, r.drain(), "no event") | ||
M internal/usecase/mergerequests_delete_test.go +3 -3
| @@ -42,7 +42,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) { | |||
| 42 | 42 | }, evs[1].(domain.PushEvent).Updates) | |
| 43 | 43 | ||
| 44 | 44 | // The merged request still reads, with its commits and diff. | |
| 45 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 45 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 46 | 46 | require.NoError(t, err) | |
| 47 | 47 | assert.Len(t, d.Commits, 2) | |
| 48 | 48 | assert.Len(t, d.Diff.Files, 2) | |
| @@ -82,7 +82,7 @@ func TestMergeRefusesToDeleteANeededBranch(t *testing.T) { | |||
| 82 | 82 | assert.Contains(t, err.Error(), "!2 still uses") | |
| 83 | 83 | assert.Equal(t, main, r.branchHead("main"), "not merged") | |
| 84 | 84 | assert.Equal(t, head, r.branchHead("feat")) | |
| 85 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 85 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 86 | 86 | require.NoError(t, err) | |
| 87 | 87 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 88 | 88 | }) | |
| @@ -138,7 +138,7 @@ func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) { | |||
| 138 | 138 | require.NoError(t, err) | |
| 139 | 139 | require.False(t, ok, "the reviewed commits are pruned") | |
| 140 | 140 | ||
| 141 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 141 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 142 | 142 | require.NoError(t, err) | |
| 143 | 143 | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | |
| 144 | 144 | assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID) | |
| @@ -42,7 +42,7 @@ func TestMergeDeletesTheSourceBranch(t *testing.T) { | |||||
| 42 | }, evs[1].(domain.PushEvent).Updates) | 42 | }, evs[1].(domain.PushEvent).Updates) | ||
| 43 | 43 | ||||
| 44 | // The merged request still reads, with its commits and diff. | 44 | // The merged request still reads, with its commits and diff. | ||
| 45 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 45 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 46 | require.NoError(t, err) | 46 | require.NoError(t, err) | ||
| 47 | assert.Len(t, d.Commits, 2) | 47 | assert.Len(t, d.Commits, 2) | ||
| 48 | assert.Len(t, d.Diff.Files, 2) | 48 | assert.Len(t, d.Diff.Files, 2) | ||
| @@ -82,7 +82,7 @@ func TestMergeRefusesToDeleteANeededBranch(t *testing.T) { | |||||
| 82 | assert.Contains(t, err.Error(), "!2 still uses") | 82 | assert.Contains(t, err.Error(), "!2 still uses") | ||
| 83 | assert.Equal(t, main, r.branchHead("main"), "not merged") | 83 | assert.Equal(t, main, r.branchHead("main"), "not merged") | ||
| 84 | assert.Equal(t, head, r.branchHead("feat")) | 84 | assert.Equal(t, head, r.branchHead("feat")) | ||
| 85 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 85 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 86 | require.NoError(t, err) | 86 | require.NoError(t, err) | ||
| 87 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | 87 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | ||
| 88 | }) | 88 | }) | ||
| @@ -138,7 +138,7 @@ func TestSquashMergedRequestReadsAfterItsCommitsArePruned(t *testing.T) { | |||||
| 138 | require.NoError(t, err) | 138 | require.NoError(t, err) | ||
| 139 | require.False(t, ok, "the reviewed commits are pruned") | 139 | require.False(t, ok, "the reviewed commits are pruned") | ||
| 140 | 140 | ||||
| 141 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 141 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 142 | require.NoError(t, err) | 142 | require.NoError(t, err) | ||
| 143 | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | 143 | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | ||
| 144 | assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID) | 144 | assert.Equal(t, res.MergedCommitID, d.MergeRequest.MergedCommitID) | ||
A internal/usecase/mergerequests_files_test.go +192 -0
| @@ -0,0 +1,192 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "bnema.dev/ermite/internal/domain" | |
| 15 | + | "bnema.dev/ermite/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // manyFiles commits n one-line files m/f000... in one commit and | |
| 19 | + | // returns the commit id. | |
| 20 | + | func (r *mrRig) manyFiles(n int) domain.ObjectID { | |
| 21 | + | r.t.Helper() | |
| 22 | + | for i := range n { | |
| 23 | + | full := filepath.Join(r.work, "m", fmt.Sprintf("f%03d", i)) | |
| 24 | + | require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755)) | |
| 25 | + | require.NoError(r.t, os.WriteFile(full, []byte(fmt.Sprintf("line %d\n", i)), 0o644)) | |
| 26 | + | } | |
| 27 | + | r.sh("add", "m") | |
| 28 | + | r.sh("commit", "-q", "-m", "many files") | |
| 29 | + | return r.head() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | const manyFilesCount = 320 | |
| 33 | + | ||
| 34 | + | // bigMR opens a request that changes manyFilesCount files. | |
| 35 | + | func bigMR(t *testing.T) (*mrRig, domain.MergeRequest) { | |
| 36 | + | t.Helper() | |
| 37 | + | r := newMRRig(t) | |
| 38 | + | r.seed() | |
| 39 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |
| 40 | + | r.manyFiles(manyFilesCount) | |
| 41 | + | r.push("feat") | |
| 42 | + | return r, r.open("feat") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestGetMergeRequestFilesWindow(t *testing.T) { | |
| 46 | + | r, mr := bigMR(t) | |
| 47 | + | n := mr.Number | |
| 48 | + | ||
| 49 | + | t.Run("the default diff keeps the contract limit", func(t *testing.T) { | |
| 50 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{}) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | assert.Len(t, d.Diff.Files, 300) | |
| 53 | + | assert.True(t, d.Diff.Truncated) | |
| 54 | + | assert.Equal(t, manyFilesCount, d.Stats.FilesChanged) | |
| 55 | + | }) | |
| 56 | + | ||
| 57 | + | t.Run("every file is listed, the hunks are those of the window", func(t *testing.T) { | |
| 58 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 305, To: 310}}) | |
| 59 | + | require.NoError(t, err) | |
| 60 | + | require.Len(t, d.Diff.Files, manyFilesCount) | |
| 61 | + | assert.Equal(t, manyFilesCount, d.Stats.FilesChanged) | |
| 62 | + | assert.False(t, d.Diff.Truncated) | |
| 63 | + | for i, f := range d.Diff.Files { | |
| 64 | + | assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath) | |
| 65 | + | assert.Equal(t, 1, f.Additions) | |
| 66 | + | assert.False(t, f.Truncated) | |
| 67 | + | if i >= 305 && i < 310 { | |
| 68 | + | require.Len(t, f.Hunks, 1, f.NewPath) | |
| 69 | + | assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text) | |
| 70 | + | } else { | |
| 71 | + | assert.Empty(t, f.Hunks, f.NewPath) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | }) | |
| 75 | + | ||
| 76 | + | t.Run("an empty window past the end lists the files only", func(t *testing.T) { | |
| 77 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 1000}}) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | require.Len(t, d.Diff.Files, manyFilesCount) | |
| 80 | + | for _, f := range d.Diff.Files { | |
| 81 | + | assert.Empty(t, f.Hunks) | |
| 82 | + | } | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | t.Run("an open window reads through the end", func(t *testing.T) { | |
| 86 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 315}}) | |
| 87 | + | require.NoError(t, err) | |
| 88 | + | require.Len(t, d.Diff.Files, manyFilesCount) | |
| 89 | + | assert.Empty(t, d.Diff.Files[314].Hunks) | |
| 90 | + | for _, f := range d.Diff.Files[315:] { | |
| 91 | + | assert.Len(t, f.Hunks, 1) | |
| 92 | + | } | |
| 93 | + | }) | |
| 94 | + | ||
| 95 | + | t.Run("an invalid window is refused", func(t *testing.T) { | |
| 96 | + | for _, w := range []ports.FileWindow{{From: -1}, {To: -1}, {From: 5, To: 3}} { | |
| 97 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &w}) | |
| 98 | + | require.ErrorIs(t, err, domain.ErrInvalid, "%+v", w) | |
| 99 | + | } | |
| 100 | + | }) | |
| 101 | + | ||
| 102 | + | t.Run("a comment on a line of a file past the 300th is accepted", func(t *testing.T) { | |
| 103 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 1, Side: domain.SideNew, Text: "late file"}) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | assert.Equal(t, "m/f315", c.Path) | |
| 106 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f319", Line: 1, Side: domain.SideNew, Text: "last file"}) | |
| 107 | + | require.NoError(t, err) | |
| 108 | + | ||
| 109 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 2, Side: domain.SideNew, Text: "no such line"}) | |
| 110 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 111 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f999", Line: 1, Side: domain.SideNew, Text: "no such file"}) | |
| 112 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |
| 113 | + | }) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestGetMergeRequestBaseAndHeadChecks(t *testing.T) { | |
| 117 | + | r := newMRRig(t) | |
| 118 | + | base := r.seed() | |
| 119 | + | head1 := r.feature("feat") | |
| 120 | + | mr := r.open("feat") | |
| 121 | + | n := mr.Number | |
| 122 | + | other := oidFrom(t, "1111111111111111111111111111111111111111") | |
| 123 | + | ||
| 124 | + | t.Run("matching or unset base and head", func(t *testing.T) { | |
| 125 | + | for _, o := range []ports.GetMergeRequestOptions{ | |
| 126 | + | {}, {Base: base}, {Head: head1}, {Base: base, Head: head1}, {Base: base, Head: head1, Files: &ports.FileWindow{To: 1}}, | |
| 127 | + | } { | |
| 128 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, o) | |
| 129 | + | require.NoError(t, err, "%+v", o) | |
| 130 | + | assert.Equal(t, base, d.DiffBaseID) | |
| 131 | + | } | |
| 132 | + | }) | |
| 133 | + | ||
| 134 | + | t.Run("another base or head is a conflict", func(t *testing.T) { | |
| 135 | + | for _, o := range []ports.GetMergeRequestOptions{ | |
| 136 | + | {Base: other}, {Head: other}, {Base: base, Head: other}, {Base: other, Head: head1}, | |
| 137 | + | {Base: head1}, // the head is not the base | |
| 138 | + | {Base: other, Files: &ports.FileWindow{From: 1, To: 2}}, | |
| 139 | + | } { | |
| 140 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, o) | |
| 141 | + | require.ErrorIs(t, err, domain.ErrConflict, "%+v", o) | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | t.Run("the head moved", func(t *testing.T) { | |
| 146 | + | r.commit(author, "d.txt", "dee\n", "add d") | |
| 147 | + | head2 := r.head() | |
| 148 | + | r.push("feat") | |
| 149 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head1}) | |
| 150 | + | require.ErrorIs(t, err, domain.ErrConflict) | |
| 151 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head2}) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | assert.Equal(t, head2, d.MergeRequest.HeadID) | |
| 154 | + | }) | |
| 155 | + | ||
| 156 | + | t.Run("the base is the one after since-last-read", func(t *testing.T) { | |
| 157 | + | head2 := r.branchHead("feat") | |
| 158 | + | _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, head1) | |
| 159 | + | require.NoError(t, err) | |
| 160 | + | // The whole diff (base) is no longer what since-last-read shows (head1). | |
| 161 | + | _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: base, Head: head2}) | |
| 162 | + | require.ErrorIs(t, err, domain.ErrConflict) | |
| 163 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: head1, Head: head2}) | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | assert.Equal(t, head1, d.DiffBaseID) | |
| 166 | + | require.Len(t, d.Diff.Files, 1) | |
| 167 | + | assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath) | |
| 168 | + | // Without since-last-read the base is still the merge base. | |
| 169 | + | _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: head1, Head: head2}) | |
| 170 | + | require.ErrorIs(t, err, domain.ErrConflict) | |
| 171 | + | }) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestGetMergeRequestDiffOnly(t *testing.T) { | |
| 175 | + | r := newMRRig(t) | |
| 176 | + | base := r.seed() | |
| 177 | + | r.feature("feat") | |
| 178 | + | mr := r.open("feat") | |
| 179 | + | ||
| 180 | + | full, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 181 | + | require.NoError(t, err) | |
| 182 | + | require.Len(t, full.Commits, 2) | |
| 183 | + | ||
| 184 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true}) | |
| 185 | + | require.NoError(t, err) | |
| 186 | + | assert.Empty(t, d.Commits) | |
| 187 | + | assert.False(t, d.CommitsTruncated) | |
| 188 | + | assert.Equal(t, base, d.DiffBaseID) | |
| 189 | + | assert.Equal(t, full.Diff, d.Diff) | |
| 190 | + | assert.Equal(t, full.Stats, d.Stats) | |
| 191 | + | assert.Equal(t, full.Comments, d.Comments) | |
| 192 | + | } | |
| @@ -0,0 +1,192 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "bnema.dev/ermite/internal/domain" | |||
| 15 | + | "bnema.dev/ermite/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // manyFiles commits n one-line files m/f000... in one commit and | |||
| 19 | + | // returns the commit id. | |||
| 20 | + | func (r *mrRig) manyFiles(n int) domain.ObjectID { | |||
| 21 | + | r.t.Helper() | |||
| 22 | + | for i := range n { | |||
| 23 | + | full := filepath.Join(r.work, "m", fmt.Sprintf("f%03d", i)) | |||
| 24 | + | require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755)) | |||
| 25 | + | require.NoError(r.t, os.WriteFile(full, []byte(fmt.Sprintf("line %d\n", i)), 0o644)) | |||
| 26 | + | } | |||
| 27 | + | r.sh("add", "m") | |||
| 28 | + | r.sh("commit", "-q", "-m", "many files") | |||
| 29 | + | return r.head() | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | const manyFilesCount = 320 | |||
| 33 | + | ||||
| 34 | + | // bigMR opens a request that changes manyFilesCount files. | |||
| 35 | + | func bigMR(t *testing.T) (*mrRig, domain.MergeRequest) { | |||
| 36 | + | t.Helper() | |||
| 37 | + | r := newMRRig(t) | |||
| 38 | + | r.seed() | |||
| 39 | + | r.sh("checkout", "-q", "-B", "feat", "main") | |||
| 40 | + | r.manyFiles(manyFilesCount) | |||
| 41 | + | r.push("feat") | |||
| 42 | + | return r, r.open("feat") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestGetMergeRequestFilesWindow(t *testing.T) { | |||
| 46 | + | r, mr := bigMR(t) | |||
| 47 | + | n := mr.Number | |||
| 48 | + | ||||
| 49 | + | t.Run("the default diff keeps the contract limit", func(t *testing.T) { | |||
| 50 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{}) | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | assert.Len(t, d.Diff.Files, 300) | |||
| 53 | + | assert.True(t, d.Diff.Truncated) | |||
| 54 | + | assert.Equal(t, manyFilesCount, d.Stats.FilesChanged) | |||
| 55 | + | }) | |||
| 56 | + | ||||
| 57 | + | t.Run("every file is listed, the hunks are those of the window", func(t *testing.T) { | |||
| 58 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 305, To: 310}}) | |||
| 59 | + | require.NoError(t, err) | |||
| 60 | + | require.Len(t, d.Diff.Files, manyFilesCount) | |||
| 61 | + | assert.Equal(t, manyFilesCount, d.Stats.FilesChanged) | |||
| 62 | + | assert.False(t, d.Diff.Truncated) | |||
| 63 | + | for i, f := range d.Diff.Files { | |||
| 64 | + | assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath) | |||
| 65 | + | assert.Equal(t, 1, f.Additions) | |||
| 66 | + | assert.False(t, f.Truncated) | |||
| 67 | + | if i >= 305 && i < 310 { | |||
| 68 | + | require.Len(t, f.Hunks, 1, f.NewPath) | |||
| 69 | + | assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text) | |||
| 70 | + | } else { | |||
| 71 | + | assert.Empty(t, f.Hunks, f.NewPath) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | ||||
| 76 | + | t.Run("an empty window past the end lists the files only", func(t *testing.T) { | |||
| 77 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 1000}}) | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | require.Len(t, d.Diff.Files, manyFilesCount) | |||
| 80 | + | for _, f := range d.Diff.Files { | |||
| 81 | + | assert.Empty(t, f.Hunks) | |||
| 82 | + | } | |||
| 83 | + | }) | |||
| 84 | + | ||||
| 85 | + | t.Run("an open window reads through the end", func(t *testing.T) { | |||
| 86 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 315}}) | |||
| 87 | + | require.NoError(t, err) | |||
| 88 | + | require.Len(t, d.Diff.Files, manyFilesCount) | |||
| 89 | + | assert.Empty(t, d.Diff.Files[314].Hunks) | |||
| 90 | + | for _, f := range d.Diff.Files[315:] { | |||
| 91 | + | assert.Len(t, f.Hunks, 1) | |||
| 92 | + | } | |||
| 93 | + | }) | |||
| 94 | + | ||||
| 95 | + | t.Run("an invalid window is refused", func(t *testing.T) { | |||
| 96 | + | for _, w := range []ports.FileWindow{{From: -1}, {To: -1}, {From: 5, To: 3}} { | |||
| 97 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &w}) | |||
| 98 | + | require.ErrorIs(t, err, domain.ErrInvalid, "%+v", w) | |||
| 99 | + | } | |||
| 100 | + | }) | |||
| 101 | + | ||||
| 102 | + | t.Run("a comment on a line of a file past the 300th is accepted", func(t *testing.T) { | |||
| 103 | + | c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 1, Side: domain.SideNew, Text: "late file"}) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | assert.Equal(t, "m/f315", c.Path) | |||
| 106 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f319", Line: 1, Side: domain.SideNew, Text: "last file"}) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | ||||
| 109 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 2, Side: domain.SideNew, Text: "no such line"}) | |||
| 110 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 111 | + | _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f999", Line: 1, Side: domain.SideNew, Text: "no such file"}) | |||
| 112 | + | require.ErrorIs(t, err, domain.ErrInvalid) | |||
| 113 | + | }) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestGetMergeRequestBaseAndHeadChecks(t *testing.T) { | |||
| 117 | + | r := newMRRig(t) | |||
| 118 | + | base := r.seed() | |||
| 119 | + | head1 := r.feature("feat") | |||
| 120 | + | mr := r.open("feat") | |||
| 121 | + | n := mr.Number | |||
| 122 | + | other := oidFrom(t, "1111111111111111111111111111111111111111") | |||
| 123 | + | ||||
| 124 | + | t.Run("matching or unset base and head", func(t *testing.T) { | |||
| 125 | + | for _, o := range []ports.GetMergeRequestOptions{ | |||
| 126 | + | {}, {Base: base}, {Head: head1}, {Base: base, Head: head1}, {Base: base, Head: head1, Files: &ports.FileWindow{To: 1}}, | |||
| 127 | + | } { | |||
| 128 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, o) | |||
| 129 | + | require.NoError(t, err, "%+v", o) | |||
| 130 | + | assert.Equal(t, base, d.DiffBaseID) | |||
| 131 | + | } | |||
| 132 | + | }) | |||
| 133 | + | ||||
| 134 | + | t.Run("another base or head is a conflict", func(t *testing.T) { | |||
| 135 | + | for _, o := range []ports.GetMergeRequestOptions{ | |||
| 136 | + | {Base: other}, {Head: other}, {Base: base, Head: other}, {Base: other, Head: head1}, | |||
| 137 | + | {Base: head1}, // the head is not the base | |||
| 138 | + | {Base: other, Files: &ports.FileWindow{From: 1, To: 2}}, | |||
| 139 | + | } { | |||
| 140 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, o) | |||
| 141 | + | require.ErrorIs(t, err, domain.ErrConflict, "%+v", o) | |||
| 142 | + | } | |||
| 143 | + | }) | |||
| 144 | + | ||||
| 145 | + | t.Run("the head moved", func(t *testing.T) { | |||
| 146 | + | r.commit(author, "d.txt", "dee\n", "add d") | |||
| 147 | + | head2 := r.head() | |||
| 148 | + | r.push("feat") | |||
| 149 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head1}) | |||
| 150 | + | require.ErrorIs(t, err, domain.ErrConflict) | |||
| 151 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head2}) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | assert.Equal(t, head2, d.MergeRequest.HeadID) | |||
| 154 | + | }) | |||
| 155 | + | ||||
| 156 | + | t.Run("the base is the one after since-last-read", func(t *testing.T) { | |||
| 157 | + | head2 := r.branchHead("feat") | |||
| 158 | + | _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, head1) | |||
| 159 | + | require.NoError(t, err) | |||
| 160 | + | // The whole diff (base) is no longer what since-last-read shows (head1). | |||
| 161 | + | _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: base, Head: head2}) | |||
| 162 | + | require.ErrorIs(t, err, domain.ErrConflict) | |||
| 163 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: head1, Head: head2}) | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | assert.Equal(t, head1, d.DiffBaseID) | |||
| 166 | + | require.Len(t, d.Diff.Files, 1) | |||
| 167 | + | assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath) | |||
| 168 | + | // Without since-last-read the base is still the merge base. | |||
| 169 | + | _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: head1, Head: head2}) | |||
| 170 | + | require.ErrorIs(t, err, domain.ErrConflict) | |||
| 171 | + | }) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestGetMergeRequestDiffOnly(t *testing.T) { | |||
| 175 | + | r := newMRRig(t) | |||
| 176 | + | base := r.seed() | |||
| 177 | + | r.feature("feat") | |||
| 178 | + | mr := r.open("feat") | |||
| 179 | + | ||||
| 180 | + | full, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |||
| 181 | + | require.NoError(t, err) | |||
| 182 | + | require.Len(t, full.Commits, 2) | |||
| 183 | + | ||||
| 184 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true}) | |||
| 185 | + | require.NoError(t, err) | |||
| 186 | + | assert.Empty(t, d.Commits) | |||
| 187 | + | assert.False(t, d.CommitsTruncated) | |||
| 188 | + | assert.Equal(t, base, d.DiffBaseID) | |||
| 189 | + | assert.Equal(t, full.Diff, d.Diff) | |||
| 190 | + | assert.Equal(t, full.Stats, d.Stats) | |||
| 191 | + | assert.Equal(t, full.Comments, d.Comments) | |||
| 192 | + | } | |||
M internal/usecase/mergerequests_integration_test.go +22 -22
| @@ -345,7 +345,7 @@ func TestMergeRequestAccess(t *testing.T) { | |||
| 345 | 345 | return err | |
| 346 | 346 | }, | |
| 347 | 347 | "Get": func(p domain.Principal) error { | |
| 348 | - | _, err := r.mrs.Get(r.ctx, p, r.repo, n, false) | |
| 348 | + | _, err := r.mrs.Get(r.ctx, p, r.repo, n, ports.GetMergeRequestOptions{}) | |
| 349 | 349 | return err | |
| 350 | 350 | }, | |
| 351 | 351 | } | |
| @@ -356,7 +356,7 @@ func TestMergeRequestAccess(t *testing.T) { | |||
| 356 | 356 | assert.NoError(t, call(reader), what+" with repo:read") | |
| 357 | 357 | assert.NoError(t, call(root), what+" by root") | |
| 358 | 358 | } | |
| 359 | - | _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false) | |
| 359 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, ports.GetMergeRequestOptions{}) | |
| 360 | 360 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number") | |
| 361 | 361 | _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{}) | |
| 362 | 362 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository") | |
| @@ -505,7 +505,7 @@ func TestGetMergeRequest(t *testing.T) { | |||
| 505 | 505 | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"}) | |
| 506 | 506 | require.NoError(t, err) | |
| 507 | 507 | ||
| 508 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 508 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 509 | 509 | require.NoError(t, err) | |
| 510 | 510 | assert.Equal(t, head, d.MergeRequest.HeadID) | |
| 511 | 511 | assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head") | |
| @@ -523,14 +523,14 @@ func TestGetMergeRequest(t *testing.T) { | |||
| 523 | 523 | r.sh("checkout", "-q", "main") | |
| 524 | 524 | r.commit(author, "later.txt", "x\n", "later on main") | |
| 525 | 525 | r.push("main") | |
| 526 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 526 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 527 | 527 | require.NoError(t, err) | |
| 528 | 528 | assert.Equal(t, main, d.DiffBaseID) | |
| 529 | 529 | assert.Len(t, d.Commits, 2, "the commits that main lacks") | |
| 530 | 530 | ||
| 531 | 531 | // A deleted source branch: no commits, no diff, an empty head. | |
| 532 | 532 | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | |
| 533 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 533 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 534 | 534 | require.NoError(t, err) | |
| 535 | 535 | assert.True(t, d.MergeRequest.HeadID.IsZero()) | |
| 536 | 536 | assert.Empty(t, d.Commits) | |
| @@ -548,7 +548,7 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) { | |||
| 548 | 548 | } | |
| 549 | 549 | r.push("long") | |
| 550 | 550 | mr := r.open("long") | |
| 551 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 551 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 552 | 552 | require.NoError(t, err) | |
| 553 | 553 | assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits) | |
| 554 | 554 | assert.True(t, d.CommitsTruncated) | |
| @@ -610,7 +610,7 @@ func TestAddComment(t *testing.T) { | |||
| 610 | 610 | } | |
| 611 | 611 | ||
| 612 | 612 | // Ids count 1, 2, ... in order of creation and only the valid ones are kept. | |
| 613 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, n, false) | |
| 613 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, n, ports.GetMergeRequestOptions{}) | |
| 614 | 614 | require.NoError(t, err) | |
| 615 | 615 | var kept int | |
| 616 | 616 | for _, tc := range tests { | |
| @@ -657,7 +657,7 @@ func TestConcurrentComments(t *testing.T) { | |||
| 657 | 657 | seen[id] = true | |
| 658 | 658 | } | |
| 659 | 659 | assert.Len(t, seen, workers) | |
| 660 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 660 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 661 | 661 | require.NoError(t, err) | |
| 662 | 662 | assert.Len(t, d.Comments, workers) | |
| 663 | 663 | } | |
| @@ -752,7 +752,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||
| 752 | 752 | n := mr.Number | |
| 753 | 753 | ||
| 754 | 754 | // Nothing recorded: the flag changes nothing. | |
| 755 | - | d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, true) | |
| 755 | + | d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true}) | |
| 756 | 756 | require.NoError(t, err) | |
| 757 | 757 | assert.True(t, d.LastReadID.IsZero()) | |
| 758 | 758 | assert.False(t, d.LastReadLost) | |
| @@ -762,7 +762,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||
| 762 | 762 | got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{}) | |
| 763 | 763 | require.NoError(t, err) | |
| 764 | 764 | assert.Equal(t, head1, got) | |
| 765 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, false) | |
| 765 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{}) | |
| 766 | 766 | require.NoError(t, err) | |
| 767 | 767 | assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set") | |
| 768 | 768 | assert.Equal(t, base, d.DiffBaseID) | |
| @@ -771,7 +771,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||
| 771 | 771 | r.commit(author, "d.txt", "dee\n", "add d") | |
| 772 | 772 | head2 := r.head() | |
| 773 | 773 | r.push("feat") | |
| 774 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | |
| 774 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true}) | |
| 775 | 775 | require.NoError(t, err) | |
| 776 | 776 | assert.Equal(t, head1, d.DiffBaseID) | |
| 777 | 777 | assert.Equal(t, head2, d.MergeRequest.HeadID) | |
| @@ -791,7 +791,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||
| 791 | 791 | // An explicit head is recorded as given. | |
| 792 | 792 | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base) | |
| 793 | 793 | require.NoError(t, err) | |
| 794 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | |
| 794 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true}) | |
| 795 | 795 | require.NoError(t, err) | |
| 796 | 796 | assert.Equal(t, base, d.DiffBaseID) | |
| 797 | 797 | } | |
| @@ -819,7 +819,7 @@ func TestSinceLastReadAfterForcePush(t *testing.T) { | |||
| 819 | 819 | // head1 is not an ancestor of head2 any more. | |
| 820 | 820 | r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String()) | |
| 821 | 821 | ||
| 822 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | |
| 822 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true}) | |
| 823 | 823 | require.NoError(t, err) | |
| 824 | 824 | assert.False(t, d.LastReadLost) | |
| 825 | 825 | assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read") | |
| @@ -838,14 +838,14 @@ func TestSinceLastReadAfterForcePush(t *testing.T) { | |||
| 838 | 838 | assert.Equal(t, []string{"see"}, removed) | |
| 839 | 839 | ||
| 840 | 840 | // The whole change is still available without the flag. | |
| 841 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 841 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 842 | 842 | require.NoError(t, err) | |
| 843 | 843 | assert.Equal(t, base, d.DiffBaseID) | |
| 844 | 844 | assert.Len(t, d.Diff.Files, 2) | |
| 845 | 845 | ||
| 846 | 846 | // Once the recorded commit is pruned, the response says so. | |
| 847 | 847 | r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now") | |
| 848 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | |
| 848 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true}) | |
| 849 | 849 | require.NoError(t, err) | |
| 850 | 850 | assert.True(t, d.LastReadLost, "the recorded head is gone") | |
| 851 | 851 | assert.Equal(t, head1, d.LastReadID, "still reported") | |
| @@ -871,7 +871,7 @@ func TestMergeFastForward(t *testing.T) { | |||
| 871 | 871 | _ = main | |
| 872 | 872 | ||
| 873 | 873 | // The stored request is the merged one, and reads keep a diff. | |
| 874 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | |
| 874 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 875 | 875 | require.NoError(t, err) | |
| 876 | 876 | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | |
| 877 | 877 | assert.Equal(t, head, d.MergeRequest.HeadID) | |
| @@ -940,7 +940,7 @@ func TestMergeRefusals(t *testing.T) { | |||
| 940 | 940 | assert.Contains(t, err.Error(), "branch moved, read again") | |
| 941 | 941 | assert.NotEqual(t, head, r.branchHead("main"), "main did not move") | |
| 942 | 942 | // Reading again gives the new head, which merges. | |
| 943 | - | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | |
| 943 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 944 | 944 | require.NoError(t, err) | |
| 945 | 945 | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID}) | |
| 946 | 946 | require.NoError(t, err) | |
| @@ -962,7 +962,7 @@ func TestMergeRefusals(t *testing.T) { | |||
| 962 | 962 | assert.Contains(t, err.Error(), "rebase", mode) | |
| 963 | 963 | } | |
| 964 | 964 | assert.Equal(t, tip, r.branchHead("main")) | |
| 965 | - | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | |
| 965 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 966 | 966 | require.NoError(t, err) | |
| 967 | 967 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | |
| 968 | 968 | ||
| @@ -1081,14 +1081,14 @@ func TestConcurrentMergesSerialize(t *testing.T) { | |||
| 1081 | 1081 | require.Len(t, won, 1, "exactly one merge request was merged: %v", won) | |
| 1082 | 1082 | for number, times := range won { | |
| 1083 | 1083 | assert.Equal(t, 1, times, "!%d merged twice", number) | |
| 1084 | - | mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | |
| 1084 | + | mr, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{}) | |
| 1085 | 1085 | require.NoError(t, err) | |
| 1086 | 1086 | assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State) | |
| 1087 | 1087 | assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main")) | |
| 1088 | 1088 | } | |
| 1089 | 1089 | for number := range heads { | |
| 1090 | 1090 | if won[number] == 0 { | |
| 1091 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | |
| 1091 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{}) | |
| 1092 | 1092 | require.NoError(t, err) | |
| 1093 | 1093 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number) | |
| 1094 | 1094 | } | |
| @@ -1275,7 +1275,7 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) { | |||
| 1275 | 1275 | // repo:write alone does not read merge requests. | |
| 1276 | 1276 | _, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{}) | |
| 1277 | 1277 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 1278 | - | _, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, false) | |
| 1278 | + | _, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 1279 | 1279 | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 1280 | 1280 | ||
| 1281 | 1281 | closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number) | |
| @@ -1328,7 +1328,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) { | |||
| 1328 | 1328 | } | |
| 1329 | 1329 | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"}) | |
| 1330 | 1330 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | |
| 1331 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | |
| 1331 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) | |
| 1332 | 1332 | require.NoError(t, err) | |
| 1333 | 1333 | assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest) | |
| 1334 | 1334 | ||
| @@ -345,7 +345,7 @@ func TestMergeRequestAccess(t *testing.T) { | |||||
| 345 | return err | 345 | return err | ||
| 346 | }, | 346 | }, | ||
| 347 | "Get": func(p domain.Principal) error { | 347 | "Get": func(p domain.Principal) error { | ||
| 348 | - | _, err := r.mrs.Get(r.ctx, p, r.repo, n, false) | 348 | + | _, err := r.mrs.Get(r.ctx, p, r.repo, n, ports.GetMergeRequestOptions{}) |
| 349 | return err | 349 | return err | ||
| 350 | }, | 350 | }, | ||
| 351 | } | 351 | } | ||
| @@ -356,7 +356,7 @@ func TestMergeRequestAccess(t *testing.T) { | |||||
| 356 | assert.NoError(t, call(reader), what+" with repo:read") | 356 | assert.NoError(t, call(reader), what+" with repo:read") | ||
| 357 | assert.NoError(t, call(root), what+" by root") | 357 | assert.NoError(t, call(root), what+" by root") | ||
| 358 | } | 358 | } | ||
| 359 | - | _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, false) | 359 | + | _, err := r.mrs.Get(r.ctx, reader, r.repo, n+1, ports.GetMergeRequestOptions{}) |
| 360 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number") | 360 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown number") | ||
| 361 | _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{}) | 361 | _, err = r.mrs.List(r.ctx, root, name(t, "ghost"), ports.ListMergeRequestsParams{}) | ||
| 362 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository") | 362 | assert.ErrorIs(t, err, domain.ErrNotFound, "unknown repository") | ||
| @@ -505,7 +505,7 @@ func TestGetMergeRequest(t *testing.T) { | |||||
| 505 | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"}) | 505 | _, err = r.mrs.AddComment(r.ctx, root, r.repo, mr.Number, ports.AddCommentParams{Text: "second"}) | ||
| 506 | require.NoError(t, err) | 506 | require.NoError(t, err) | ||
| 507 | 507 | ||||
| 508 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 508 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 509 | require.NoError(t, err) | 509 | require.NoError(t, err) | ||
| 510 | assert.Equal(t, head, d.MergeRequest.HeadID) | 510 | assert.Equal(t, head, d.MergeRequest.HeadID) | ||
| 511 | assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head") | 511 | assert.Equal(t, main, d.DiffBaseID, "the base is the merge base of the target and the head") | ||
| @@ -523,14 +523,14 @@ func TestGetMergeRequest(t *testing.T) { | |||||
| 523 | r.sh("checkout", "-q", "main") | 523 | r.sh("checkout", "-q", "main") | ||
| 524 | r.commit(author, "later.txt", "x\n", "later on main") | 524 | r.commit(author, "later.txt", "x\n", "later on main") | ||
| 525 | r.push("main") | 525 | r.push("main") | ||
| 526 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 526 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 527 | require.NoError(t, err) | 527 | require.NoError(t, err) | ||
| 528 | assert.Equal(t, main, d.DiffBaseID) | 528 | assert.Equal(t, main, d.DiffBaseID) | ||
| 529 | assert.Len(t, d.Commits, 2, "the commits that main lacks") | 529 | assert.Len(t, d.Commits, 2, "the commits that main lacks") | ||
| 530 | 530 | ||||
| 531 | // A deleted source branch: no commits, no diff, an empty head. | 531 | // A deleted source branch: no commits, no diff, an empty head. | ||
| 532 | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | 532 | r.run(r.bare(), r.env(author), "branch", "-D", "feat") | ||
| 533 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 533 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 534 | require.NoError(t, err) | 534 | require.NoError(t, err) | ||
| 535 | assert.True(t, d.MergeRequest.HeadID.IsZero()) | 535 | assert.True(t, d.MergeRequest.HeadID.IsZero()) | ||
| 536 | assert.Empty(t, d.Commits) | 536 | assert.Empty(t, d.Commits) | ||
| @@ -548,7 +548,7 @@ func TestGetMergeRequestTruncatesCommits(t *testing.T) { | |||||
| 548 | } | 548 | } | ||
| 549 | r.push("long") | 549 | r.push("long") | ||
| 550 | mr := r.open("long") | 550 | mr := r.open("long") | ||
| 551 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | 551 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 552 | require.NoError(t, err) | 552 | require.NoError(t, err) | ||
| 553 | assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits) | 553 | assert.Len(t, d.Commits, usecase.MaxMergeRequestCommits) | ||
| 554 | assert.True(t, d.CommitsTruncated) | 554 | assert.True(t, d.CommitsTruncated) | ||
| @@ -610,7 +610,7 @@ func TestAddComment(t *testing.T) { | |||||
| 610 | } | 610 | } | ||
| 611 | 611 | ||||
| 612 | // Ids count 1, 2, ... in order of creation and only the valid ones are kept. | 612 | // Ids count 1, 2, ... in order of creation and only the valid ones are kept. | ||
| 613 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, n, false) | 613 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, n, ports.GetMergeRequestOptions{}) |
| 614 | require.NoError(t, err) | 614 | require.NoError(t, err) | ||
| 615 | var kept int | 615 | var kept int | ||
| 616 | for _, tc := range tests { | 616 | for _, tc := range tests { | ||
| @@ -657,7 +657,7 @@ func TestConcurrentComments(t *testing.T) { | |||||
| 657 | seen[id] = true | 657 | seen[id] = true | ||
| 658 | } | 658 | } | ||
| 659 | assert.Len(t, seen, workers) | 659 | assert.Len(t, seen, workers) | ||
| 660 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | 660 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 661 | require.NoError(t, err) | 661 | require.NoError(t, err) | ||
| 662 | assert.Len(t, d.Comments, workers) | 662 | assert.Len(t, d.Comments, workers) | ||
| 663 | } | 663 | } | ||
| @@ -752,7 +752,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||||
| 752 | n := mr.Number | 752 | n := mr.Number | ||
| 753 | 753 | ||||
| 754 | // Nothing recorded: the flag changes nothing. | 754 | // Nothing recorded: the flag changes nothing. | ||
| 755 | - | d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, true) | 755 | + | d, err := r.mrs.Get(r.ctx, mrW, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true}) |
| 756 | require.NoError(t, err) | 756 | require.NoError(t, err) | ||
| 757 | assert.True(t, d.LastReadID.IsZero()) | 757 | assert.True(t, d.LastReadID.IsZero()) | ||
| 758 | assert.False(t, d.LastReadLost) | 758 | assert.False(t, d.LastReadLost) | ||
| @@ -762,7 +762,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||||
| 762 | got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{}) | 762 | got, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, domain.ObjectID{}) | ||
| 763 | require.NoError(t, err) | 763 | require.NoError(t, err) | ||
| 764 | assert.Equal(t, head1, got) | 764 | assert.Equal(t, head1, got) | ||
| 765 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, false) | 765 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{}) |
| 766 | require.NoError(t, err) | 766 | require.NoError(t, err) | ||
| 767 | assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set") | 767 | assert.Equal(t, head1, d.LastReadID, "reported whether or not since_last_read is set") | ||
| 768 | assert.Equal(t, base, d.DiffBaseID) | 768 | assert.Equal(t, base, d.DiffBaseID) | ||
| @@ -771,7 +771,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||||
| 771 | r.commit(author, "d.txt", "dee\n", "add d") | 771 | r.commit(author, "d.txt", "dee\n", "add d") | ||
| 772 | head2 := r.head() | 772 | head2 := r.head() | ||
| 773 | r.push("feat") | 773 | r.push("feat") | ||
| 774 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | 774 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true}) |
| 775 | require.NoError(t, err) | 775 | require.NoError(t, err) | ||
| 776 | assert.Equal(t, head1, d.DiffBaseID) | 776 | assert.Equal(t, head1, d.DiffBaseID) | ||
| 777 | assert.Equal(t, head2, d.MergeRequest.HeadID) | 777 | assert.Equal(t, head2, d.MergeRequest.HeadID) | ||
| @@ -791,7 +791,7 @@ func TestMarkReadAndSinceLastRead(t *testing.T) { | |||||
| 791 | // An explicit head is recorded as given. | 791 | // An explicit head is recorded as given. | ||
| 792 | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base) | 792 | _, err = r.mrs.MarkRead(r.ctx, mrW, r.repo, n, base) | ||
| 793 | require.NoError(t, err) | 793 | require.NoError(t, err) | ||
| 794 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, true) | 794 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true}) |
| 795 | require.NoError(t, err) | 795 | require.NoError(t, err) | ||
| 796 | assert.Equal(t, base, d.DiffBaseID) | 796 | assert.Equal(t, base, d.DiffBaseID) | ||
| 797 | } | 797 | } | ||
| @@ -819,7 +819,7 @@ func TestSinceLastReadAfterForcePush(t *testing.T) { | |||||
| 819 | // head1 is not an ancestor of head2 any more. | 819 | // head1 is not an ancestor of head2 any more. | ||
| 820 | r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String()) | 820 | r.run(r.bare(), r.env(author), "cat-file", "-e", head1.String()) | ||
| 821 | 821 | ||||
| 822 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | 822 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true}) |
| 823 | require.NoError(t, err) | 823 | require.NoError(t, err) | ||
| 824 | assert.False(t, d.LastReadLost) | 824 | assert.False(t, d.LastReadLost) | ||
| 825 | assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read") | 825 | assert.Equal(t, head1, d.DiffBaseID, "the diff starts at the head that was read") | ||
| @@ -838,14 +838,14 @@ func TestSinceLastReadAfterForcePush(t *testing.T) { | |||||
| 838 | assert.Equal(t, []string{"see"}, removed) | 838 | assert.Equal(t, []string{"see"}, removed) | ||
| 839 | 839 | ||||
| 840 | // The whole change is still available without the flag. | 840 | // The whole change is still available without the flag. | ||
| 841 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 841 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 842 | require.NoError(t, err) | 842 | require.NoError(t, err) | ||
| 843 | assert.Equal(t, base, d.DiffBaseID) | 843 | assert.Equal(t, base, d.DiffBaseID) | ||
| 844 | assert.Len(t, d.Diff.Files, 2) | 844 | assert.Len(t, d.Diff.Files, 2) | ||
| 845 | 845 | ||||
| 846 | // Once the recorded commit is pruned, the response says so. | 846 | // Once the recorded commit is pruned, the response says so. | ||
| 847 | r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now") | 847 | r.run(r.bare(), r.env(author), "gc", "-q", "--prune=now") | ||
| 848 | - | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, true) | 848 | + | d, err = r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{SinceLastRead: true}) |
| 849 | require.NoError(t, err) | 849 | require.NoError(t, err) | ||
| 850 | assert.True(t, d.LastReadLost, "the recorded head is gone") | 850 | assert.True(t, d.LastReadLost, "the recorded head is gone") | ||
| 851 | assert.Equal(t, head1, d.LastReadID, "still reported") | 851 | assert.Equal(t, head1, d.LastReadID, "still reported") | ||
| @@ -871,7 +871,7 @@ func TestMergeFastForward(t *testing.T) { | |||||
| 871 | _ = main | 871 | _ = main | ||
| 872 | 872 | ||||
| 873 | // The stored request is the merged one, and reads keep a diff. | 873 | // The stored request is the merged one, and reads keep a diff. | ||
| 874 | - | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false) | 874 | + | d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 875 | require.NoError(t, err) | 875 | require.NoError(t, err) | ||
| 876 | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | 876 | assert.Equal(t, domain.MRStateMerged, d.MergeRequest.State) | ||
| 877 | assert.Equal(t, head, d.MergeRequest.HeadID) | 877 | assert.Equal(t, head, d.MergeRequest.HeadID) | ||
| @@ -940,7 +940,7 @@ func TestMergeRefusals(t *testing.T) { | |||||
| 940 | assert.Contains(t, err.Error(), "branch moved, read again") | 940 | assert.Contains(t, err.Error(), "branch moved, read again") | ||
| 941 | assert.NotEqual(t, head, r.branchHead("main"), "main did not move") | 941 | assert.NotEqual(t, head, r.branchHead("main"), "main did not move") | ||
| 942 | // Reading again gives the new head, which merges. | 942 | // Reading again gives the new head, which merges. | ||
| 943 | - | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | 943 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 944 | require.NoError(t, err) | 944 | require.NoError(t, err) | ||
| 945 | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID}) | 945 | _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: d.MergeRequest.HeadID}) | ||
| 946 | require.NoError(t, err) | 946 | require.NoError(t, err) | ||
| @@ -962,7 +962,7 @@ func TestMergeRefusals(t *testing.T) { | |||||
| 962 | assert.Contains(t, err.Error(), "rebase", mode) | 962 | assert.Contains(t, err.Error(), "rebase", mode) | ||
| 963 | } | 963 | } | ||
| 964 | assert.Equal(t, tip, r.branchHead("main")) | 964 | assert.Equal(t, tip, r.branchHead("main")) | ||
| 965 | - | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, false) | 965 | + | d, err := r.mrs.Get(r.ctx, mrM, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 966 | require.NoError(t, err) | 966 | require.NoError(t, err) | ||
| 967 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | 967 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State) | ||
| 968 | 968 | ||||
| @@ -1081,14 +1081,14 @@ func TestConcurrentMergesSerialize(t *testing.T) { | |||||
| 1081 | require.Len(t, won, 1, "exactly one merge request was merged: %v", won) | 1081 | require.Len(t, won, 1, "exactly one merge request was merged: %v", won) | ||
| 1082 | for number, times := range won { | 1082 | for number, times := range won { | ||
| 1083 | assert.Equal(t, 1, times, "!%d merged twice", number) | 1083 | assert.Equal(t, 1, times, "!%d merged twice", number) | ||
| 1084 | - | mr, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | 1084 | + | mr, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{}) |
| 1085 | require.NoError(t, err) | 1085 | require.NoError(t, err) | ||
| 1086 | assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State) | 1086 | assert.Equal(t, domain.MRStateMerged, mr.MergeRequest.State) | ||
| 1087 | assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main")) | 1087 | assert.Equal(t, mr.MergeRequest.MergedCommitID, r.branchHead("main")) | ||
| 1088 | } | 1088 | } | ||
| 1089 | for number := range heads { | 1089 | for number := range heads { | ||
| 1090 | if won[number] == 0 { | 1090 | if won[number] == 0 { | ||
| 1091 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, number, false) | 1091 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, number, ports.GetMergeRequestOptions{}) |
| 1092 | require.NoError(t, err) | 1092 | require.NoError(t, err) | ||
| 1093 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number) | 1093 | assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State, "!%d lost and stays open", number) | ||
| 1094 | } | 1094 | } | ||
| @@ -1275,7 +1275,7 @@ func TestAgentPresetWorksOnItsPrivateRepository(t *testing.T) { | |||||
| 1275 | // repo:write alone does not read merge requests. | 1275 | // repo:write alone does not read merge requests. | ||
| 1276 | _, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{}) | 1276 | _, err = r.mrs.List(r.ctx, agent, r.repo, ports.ListMergeRequestsParams{}) | ||
| 1277 | assert.ErrorIs(t, err, domain.ErrNotFound) | 1277 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 1278 | - | _, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, false) | 1278 | + | _, err = r.mrs.Get(r.ctx, agent, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 1279 | assert.ErrorIs(t, err, domain.ErrNotFound) | 1279 | assert.ErrorIs(t, err, domain.ErrNotFound) | ||
| 1280 | 1280 | ||||
| 1281 | closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number) | 1281 | closed, err := r.mrs.Close(r.ctx, agent, r.repo, mr.Number) | ||
| @@ -1328,7 +1328,7 @@ func TestCommentsPerMergeRequestAreBounded(t *testing.T) { | |||||
| 1328 | } | 1328 | } | ||
| 1329 | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"}) | 1329 | _, err := r.mrs.AddComment(r.ctx, mrW, r.repo, mr.Number, ports.AddCommentParams{Text: "one too many"}) | ||
| 1330 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | 1330 | assert.ErrorIs(t, err, domain.ErrResourceExhausted) | ||
| 1331 | - | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, false) | 1331 | + | d, err := r.mrs.Get(r.ctx, root, r.repo, mr.Number, ports.GetMergeRequestOptions{}) |
| 1332 | require.NoError(t, err) | 1332 | require.NoError(t, err) | ||
| 1333 | assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest) | 1333 | assert.Len(t, d.Comments, usecase.MaxCommentsPerMergeRequest) | ||
| 1334 | 1334 | ||||