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 | |
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 | + | } | |
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 | } | |
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 { | |
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"> | |
M internal/adapters/web/actions_templ.go +120 -43 generated file: collapsed
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>): | |
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 { | |
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) { | |
M internal/adapters/web/diff_templ.go +463 -215
| @@ -769,147 +769,231 @@ func FileList(files []DiffFile) templ.Component { | |||
| 769 | 769 | templ_7745c5c3_Var35 = templ.NopComponent | |
| 770 | 770 | } | |
| 771 | 771 | ctx = templ.ClearChildren(ctx) | |
| 772 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<ol class=\"files\" aria-label=\"Changed files\">") | |
| 772 | + | if len(files) > fileFilterMin { | |
| 773 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"ffilter\"><label for=\"files-filter\" class=\"sr\">filter the changed files</label> <input id=\"files-filter\" type=\"search\" placeholder=\"Filter files…\" autocomplete=\"off\" data-pick=\".files > li\" hx-on:input=\"ermite.filter(this)\"></div>") | |
| 774 | + | if templ_7745c5c3_Err != nil { | |
| 775 | + | return templ_7745c5c3_Err | |
| 776 | + | } | |
| 777 | + | } | |
| 778 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<ol class=\"files\" aria-label=\"Changed files\">") | |
| 773 | 779 | if templ_7745c5c3_Err != nil { | |
| 774 | 780 | return templ_7745c5c3_Err | |
| 775 | 781 | } | |
| 776 | 782 | for _, f := range files { | |
| 777 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<li><a href=\"") | |
| 783 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<li><a href=\"") | |
| 778 | 784 | if templ_7745c5c3_Err != nil { | |
| 779 | 785 | return templ_7745c5c3_Err | |
| 780 | 786 | } | |
| 781 | 787 | var templ_7745c5c3_Var36 templ.SafeURL | |
| 782 | 788 | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + f.ID)) | |
| 783 | 789 | if templ_7745c5c3_Err != nil { | |
| 784 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 148, Col: 37} | |
| 790 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 161, Col: 37} | |
| 785 | 791 | } | |
| 786 | 792 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 787 | 793 | if templ_7745c5c3_Err != nil { | |
| 788 | 794 | return templ_7745c5c3_Err | |
| 789 | 795 | } | |
| 790 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\" hx-on:click=\"document.getElementById(this.hash.slice(1)).open = true\"><span class=\"st\" title=\"") | |
| 796 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\" title=\"") | |
| 791 | 797 | if templ_7745c5c3_Err != nil { | |
| 792 | 798 | return templ_7745c5c3_Err | |
| 793 | 799 | } | |
| 794 | 800 | var templ_7745c5c3_Var37 string | |
| 795 | - | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status) | |
| 801 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path) | |
| 796 | 802 | if templ_7745c5c3_Err != nil { | |
| 797 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 151, Col: 38} | |
| 803 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 162, Col: 19} | |
| 798 | 804 | } | |
| 799 | 805 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37) | |
| 800 | 806 | if templ_7745c5c3_Err != nil { | |
| 801 | 807 | return templ_7745c5c3_Err | |
| 802 | 808 | } | |
| 803 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">") | |
| 809 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\" hx-on:click=\"(window.ermiteMR?.openFile || ermite.openFile)(this.hash.slice(1))\"><span class=\"st\" data-st=\"") | |
| 804 | 810 | if templ_7745c5c3_Err != nil { | |
| 805 | 811 | return templ_7745c5c3_Err | |
| 806 | 812 | } | |
| 807 | 813 | var templ_7745c5c3_Var38 string | |
| 808 | - | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 814 | + | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Status) | |
| 809 | 815 | if templ_7745c5c3_Err != nil { | |
| 810 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 151, Col: 51} | |
| 816 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 40} | |
| 811 | 817 | } | |
| 812 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 818 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38) | |
| 813 | 819 | if templ_7745c5c3_Err != nil { | |
| 814 | 820 | return templ_7745c5c3_Err | |
| 815 | 821 | } | |
| 816 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span> <span class=\"p\" title=\"") | |
| 822 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" title=\"") | |
| 817 | 823 | if templ_7745c5c3_Err != nil { | |
| 818 | 824 | return templ_7745c5c3_Err | |
| 819 | 825 | } | |
| 820 | 826 | var templ_7745c5c3_Var39 string | |
| 821 | - | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path) | |
| 827 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(statusName(f.Status)) | |
| 822 | 828 | if templ_7745c5c3_Err != nil { | |
| 823 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 152, Col: 35} | |
| 829 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 71} | |
| 824 | 830 | } | |
| 825 | 831 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39) | |
| 826 | 832 | if templ_7745c5c3_Err != nil { | |
| 827 | 833 | return templ_7745c5c3_Err | |
| 828 | 834 | } | |
| 829 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">") | |
| 835 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">") | |
| 830 | 836 | if templ_7745c5c3_Err != nil { | |
| 831 | 837 | return templ_7745c5c3_Err | |
| 832 | 838 | } | |
| 833 | 839 | var templ_7745c5c3_Var40 string | |
| 834 | - | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path) | |
| 840 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 835 | 841 | if templ_7745c5c3_Err != nil { | |
| 836 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 152, Col: 46} | |
| 842 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 165, Col: 84} | |
| 837 | 843 | } | |
| 838 | 844 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 839 | 845 | if templ_7745c5c3_Err != nil { | |
| 840 | 846 | return templ_7745c5c3_Err | |
| 841 | 847 | } | |
| 842 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span> <span class=\"n\"><span class=\"plus\">+") | |
| 848 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"nm\"><span class=\"p\">") | |
| 843 | 849 | if templ_7745c5c3_Err != nil { | |
| 844 | 850 | return templ_7745c5c3_Err | |
| 845 | 851 | } | |
| 846 | 852 | var templ_7745c5c3_Var41 string | |
| 847 | - | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 853 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(f.Name) | |
| 848 | 854 | if templ_7745c5c3_Err != nil { | |
| 849 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 54} | |
| 855 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 46} | |
| 850 | 856 | } | |
| 851 | 857 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 852 | 858 | if templ_7745c5c3_Err != nil { | |
| 853 | 859 | return templ_7745c5c3_Err | |
| 854 | 860 | } | |
| 855 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <span class=\"minus\">-") | |
| 861 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span>") | |
| 856 | 862 | if templ_7745c5c3_Err != nil { | |
| 857 | 863 | return templ_7745c5c3_Err | |
| 858 | 864 | } | |
| 859 | 865 | var templ_7745c5c3_Var42 string | |
| 860 | - | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 866 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 861 | 867 | if templ_7745c5c3_Err != nil { | |
| 862 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 153, Col: 98} | |
| 868 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 60} | |
| 863 | 869 | } | |
| 864 | 870 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 865 | 871 | if templ_7745c5c3_Err != nil { | |
| 866 | 872 | return templ_7745c5c3_Err | |
| 867 | 873 | } | |
| 868 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</span></span> <span class=\"b\" aria-hidden=\"true\"><span class=\"plus\">") | |
| 874 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<span class=\"d\"><bdi>") | |
| 869 | 875 | if templ_7745c5c3_Err != nil { | |
| 870 | 876 | return templ_7745c5c3_Err | |
| 871 | 877 | } | |
| 872 | 878 | var templ_7745c5c3_Var43 string | |
| 873 | - | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Add) | |
| 879 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(f.Dir) | |
| 874 | 880 | if templ_7745c5c3_Err != nil { | |
| 875 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 70} | |
| 881 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 166, Col: 90} | |
| 876 | 882 | } | |
| 877 | 883 | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 878 | 884 | if templ_7745c5c3_Err != nil { | |
| 879 | 885 | return templ_7745c5c3_Err | |
| 880 | 886 | } | |
| 881 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</span><span class=\"minus\">") | |
| 887 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</bdi></span></span> ") | |
| 882 | 888 | if templ_7745c5c3_Err != nil { | |
| 883 | 889 | return templ_7745c5c3_Err | |
| 884 | 890 | } | |
| 885 | - | var templ_7745c5c3_Var44 string | |
| 886 | - | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Del) | |
| 887 | - | if templ_7745c5c3_Err != nil { | |
| 888 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 110} | |
| 889 | - | } | |
| 890 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) | |
| 891 | - | if templ_7745c5c3_Err != nil { | |
| 892 | - | return templ_7745c5c3_Err | |
| 893 | - | } | |
| 894 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span>") | |
| 895 | - | if templ_7745c5c3_Err != nil { | |
| 896 | - | return templ_7745c5c3_Err | |
| 897 | - | } | |
| 898 | - | var templ_7745c5c3_Var45 string | |
| 899 | - | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(f.Bar.Rest) | |
| 900 | - | if templ_7745c5c3_Err != nil { | |
| 901 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 154, Col: 131} | |
| 891 | + | if f.OpenNotes > 0 { | |
| 892 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"cn\" title=\"") | |
| 893 | + | if templ_7745c5c3_Err != nil { | |
| 894 | + | return templ_7745c5c3_Err | |
| 895 | + | } | |
| 896 | + | var templ_7745c5c3_Var44 string | |
| 897 | + | templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.OpenNotes, "open comment", "open comments")) | |
| 898 | + | if templ_7745c5c3_Err != nil { | |
| 899 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 83} | |
| 900 | + | } | |
| 901 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44) | |
| 902 | + | if templ_7745c5c3_Err != nil { | |
| 903 | + | return templ_7745c5c3_Err | |
| 904 | + | } | |
| 905 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">") | |
| 906 | + | if templ_7745c5c3_Err != nil { | |
| 907 | + | return templ_7745c5c3_Err | |
| 908 | + | } | |
| 909 | + | var templ_7745c5c3_Var45 string | |
| 910 | + | templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.OpenNotes)) | |
| 911 | + | if templ_7745c5c3_Err != nil { | |
| 912 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 105} | |
| 913 | + | } | |
| 914 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 915 | + | if templ_7745c5c3_Err != nil { | |
| 916 | + | return templ_7745c5c3_Err | |
| 917 | + | } | |
| 918 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span> ") | |
| 919 | + | if templ_7745c5c3_Err != nil { | |
| 920 | + | return templ_7745c5c3_Err | |
| 921 | + | } | |
| 922 | + | } else if f.Notes > 0 { | |
| 923 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<span class=\"cn done\" title=\"") | |
| 924 | + | if templ_7745c5c3_Err != nil { | |
| 925 | + | return templ_7745c5c3_Err | |
| 926 | + | } | |
| 927 | + | var templ_7745c5c3_Var46 string | |
| 928 | + | templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(plural(f.Notes, "resolved comment", "resolved comments")) | |
| 929 | + | if templ_7745c5c3_Err != nil { | |
| 930 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 170, Col: 92} | |
| 931 | + | } | |
| 932 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46) | |
| 933 | + | if templ_7745c5c3_Err != nil { | |
| 934 | + | return templ_7745c5c3_Err | |
| 935 | + | } | |
| 936 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\">") | |
| 937 | + | if templ_7745c5c3_Err != nil { | |
| 938 | + | return templ_7745c5c3_Err | |
| 939 | + | } | |
| 940 | + | var templ_7745c5c3_Var47 string | |
| 941 | + | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Notes)) | |
| 942 | + | if templ_7745c5c3_Err != nil { | |
| 943 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 170, Col: 110} | |
| 944 | + | } | |
| 945 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) | |
| 946 | + | if templ_7745c5c3_Err != nil { | |
| 947 | + | return templ_7745c5c3_Err | |
| 948 | + | } | |
| 949 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> ") | |
| 950 | + | if templ_7745c5c3_Err != nil { | |
| 951 | + | return templ_7745c5c3_Err | |
| 952 | + | } | |
| 902 | 953 | } | |
| 903 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) | |
| 904 | - | if templ_7745c5c3_Err != nil { | |
| 905 | - | return templ_7745c5c3_Err | |
| 954 | + | if f.Bin { | |
| 955 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"n mute\">bin</span>") | |
| 956 | + | if templ_7745c5c3_Err != nil { | |
| 957 | + | return templ_7745c5c3_Err | |
| 958 | + | } | |
| 959 | + | } else { | |
| 960 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"n\"><span class=\"plus\">+") | |
| 961 | + | if templ_7745c5c3_Err != nil { | |
| 962 | + | return templ_7745c5c3_Err | |
| 963 | + | } | |
| 964 | + | var templ_7745c5c3_Var48 string | |
| 965 | + | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 966 | + | if templ_7745c5c3_Err != nil { | |
| 967 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 55} | |
| 968 | + | } | |
| 969 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 970 | + | if templ_7745c5c3_Err != nil { | |
| 971 | + | return templ_7745c5c3_Err | |
| 972 | + | } | |
| 973 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span> <span class=\"minus\">-") | |
| 974 | + | if templ_7745c5c3_Err != nil { | |
| 975 | + | return templ_7745c5c3_Err | |
| 976 | + | } | |
| 977 | + | var templ_7745c5c3_Var49 string | |
| 978 | + | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 979 | + | if templ_7745c5c3_Err != nil { | |
| 980 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 99} | |
| 981 | + | } | |
| 982 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 983 | + | if templ_7745c5c3_Err != nil { | |
| 984 | + | return templ_7745c5c3_Err | |
| 985 | + | } | |
| 986 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</span></span>") | |
| 987 | + | if templ_7745c5c3_Err != nil { | |
| 988 | + | return templ_7745c5c3_Err | |
| 989 | + | } | |
| 906 | 990 | } | |
| 907 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span></a></li>") | |
| 991 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</a></li>") | |
| 908 | 992 | if templ_7745c5c3_Err != nil { | |
| 909 | 993 | return templ_7745c5c3_Err | |
| 910 | 994 | } | |
| 911 | 995 | } | |
| 912 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</ol>") | |
| 996 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</ol>") | |
| 913 | 997 | if templ_7745c5c3_Err != nil { | |
| 914 | 998 | return templ_7745c5c3_Err | |
| 915 | 999 | } | |
| @@ -937,222 +1021,306 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 937 | 1021 | }() | |
| 938 | 1022 | } | |
| 939 | 1023 | ctx = templ.InitializeContext(ctx) | |
| 940 | - | templ_7745c5c3_Var46 := templ.GetChildren(ctx) | |
| 941 | - | if templ_7745c5c3_Var46 == nil { | |
| 942 | - | templ_7745c5c3_Var46 = templ.NopComponent | |
| 1024 | + | templ_7745c5c3_Var50 := templ.GetChildren(ctx) | |
| 1025 | + | if templ_7745c5c3_Var50 == nil { | |
| 1026 | + | templ_7745c5c3_Var50 = templ.NopComponent | |
| 943 | 1027 | } | |
| 944 | 1028 | ctx = templ.ClearChildren(ctx) | |
| 945 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<details class=\"dfile\" id=\"") | |
| 1029 | + | var templ_7745c5c3_Var51 = []any{"dfile", "lnw-" + itoa(f.Digits)} | |
| 1030 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...) | |
| 1031 | + | if templ_7745c5c3_Err != nil { | |
| 1032 | + | return templ_7745c5c3_Err | |
| 1033 | + | } | |
| 1034 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "<details class=\"") | |
| 1035 | + | if templ_7745c5c3_Err != nil { | |
| 1036 | + | return templ_7745c5c3_Err | |
| 1037 | + | } | |
| 1038 | + | var templ_7745c5c3_Var52 string | |
| 1039 | + | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var51).String()) | |
| 1040 | + | if templ_7745c5c3_Err != nil { | |
| 1041 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1042 | + | } | |
| 1043 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52) | |
| 1044 | + | if templ_7745c5c3_Err != nil { | |
| 1045 | + | return templ_7745c5c3_Err | |
| 1046 | + | } | |
| 1047 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" id=\"") | |
| 946 | 1048 | if templ_7745c5c3_Err != nil { | |
| 947 | 1049 | return templ_7745c5c3_Err | |
| 948 | 1050 | } | |
| 949 | - | var templ_7745c5c3_Var47 string | |
| 950 | - | templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID) | |
| 1051 | + | var templ_7745c5c3_Var53 string | |
| 1052 | + | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID) | |
| 951 | 1053 | if templ_7745c5c3_Err != nil { | |
| 952 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 168, Col: 11} | |
| 1054 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 190, Col: 11} | |
| 953 | 1055 | } | |
| 954 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47) | |
| 1056 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53) | |
| 955 | 1057 | if templ_7745c5c3_Err != nil { | |
| 956 | 1058 | return templ_7745c5c3_Err | |
| 957 | 1059 | } | |
| 958 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" data-file") | |
| 1060 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" data-file") | |
| 959 | 1061 | if templ_7745c5c3_Err != nil { | |
| 960 | 1062 | return templ_7745c5c3_Err | |
| 961 | 1063 | } | |
| 962 | 1064 | if f.Open { | |
| 963 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, " open") | |
| 1065 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, " open") | |
| 964 | 1066 | if templ_7745c5c3_Err != nil { | |
| 965 | 1067 | return templ_7745c5c3_Err | |
| 966 | 1068 | } | |
| 967 | 1069 | } | |
| 968 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "><summary><span class=\"st\">") | |
| 1070 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "><summary><span class=\"st\">") | |
| 969 | 1071 | if templ_7745c5c3_Err != nil { | |
| 970 | 1072 | return templ_7745c5c3_Err | |
| 971 | 1073 | } | |
| 972 | - | var templ_7745c5c3_Var48 string | |
| 973 | - | templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 1074 | + | var templ_7745c5c3_Var54 string | |
| 1075 | + | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(f.Status) | |
| 974 | 1076 | if templ_7745c5c3_Err != nil { | |
| 975 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 30} | |
| 1077 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 30} | |
| 976 | 1078 | } | |
| 977 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) | |
| 1079 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) | |
| 978 | 1080 | if templ_7745c5c3_Err != nil { | |
| 979 | 1081 | return templ_7745c5c3_Err | |
| 980 | 1082 | } | |
| 981 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span>") | |
| 1083 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span>") | |
| 982 | 1084 | if templ_7745c5c3_Err != nil { | |
| 983 | 1085 | return templ_7745c5c3_Err | |
| 984 | 1086 | } | |
| 985 | - | var templ_7745c5c3_Var49 string | |
| 986 | - | templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1087 | + | var templ_7745c5c3_Var55 string | |
| 1088 | + | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 987 | 1089 | if templ_7745c5c3_Err != nil { | |
| 988 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 44} | |
| 1090 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 44} | |
| 989 | 1091 | } | |
| 990 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) | |
| 1092 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 991 | 1093 | if templ_7745c5c3_Err != nil { | |
| 992 | 1094 | return templ_7745c5c3_Err | |
| 993 | 1095 | } | |
| 994 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"p\">") | |
| 1096 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<span class=\"p\">") | |
| 995 | 1097 | if templ_7745c5c3_Err != nil { | |
| 996 | 1098 | return templ_7745c5c3_Err | |
| 997 | 1099 | } | |
| 998 | - | var templ_7745c5c3_Var50 string | |
| 999 | - | templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path) | |
| 1100 | + | var templ_7745c5c3_Var56 string | |
| 1101 | + | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(f.Path) | |
| 1000 | 1102 | if templ_7745c5c3_Err != nil { | |
| 1001 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 70} | |
| 1103 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 70} | |
| 1002 | 1104 | } | |
| 1003 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) | |
| 1105 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1004 | 1106 | if templ_7745c5c3_Err != nil { | |
| 1005 | 1107 | return templ_7745c5c3_Err | |
| 1006 | 1108 | } | |
| 1007 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span>") | |
| 1109 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span>") | |
| 1008 | 1110 | if templ_7745c5c3_Err != nil { | |
| 1009 | 1111 | return templ_7745c5c3_Err | |
| 1010 | 1112 | } | |
| 1011 | - | var templ_7745c5c3_Var51 string | |
| 1012 | - | templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1113 | + | var templ_7745c5c3_Var57 string | |
| 1114 | + | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1013 | 1115 | if templ_7745c5c3_Err != nil { | |
| 1014 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 84} | |
| 1116 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 84} | |
| 1015 | 1117 | } | |
| 1016 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) | |
| 1118 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) | |
| 1017 | 1119 | if templ_7745c5c3_Err != nil { | |
| 1018 | 1120 | return templ_7745c5c3_Err | |
| 1019 | 1121 | } | |
| 1020 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<span class=\"plus\">+") | |
| 1122 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "<span class=\"plus\">+") | |
| 1021 | 1123 | if templ_7745c5c3_Err != nil { | |
| 1022 | 1124 | return templ_7745c5c3_Err | |
| 1023 | 1125 | } | |
| 1024 | - | var templ_7745c5c3_Var52 string | |
| 1025 | - | templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 1126 | + | var templ_7745c5c3_Var58 string | |
| 1127 | + | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Add)) | |
| 1026 | 1128 | if templ_7745c5c3_Err != nil { | |
| 1027 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 119} | |
| 1129 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 119} | |
| 1028 | 1130 | } | |
| 1029 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) | |
| 1131 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1030 | 1132 | if templ_7745c5c3_Err != nil { | |
| 1031 | 1133 | return templ_7745c5c3_Err | |
| 1032 | 1134 | } | |
| 1033 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "</span>") | |
| 1135 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "</span>") | |
| 1034 | 1136 | if templ_7745c5c3_Err != nil { | |
| 1035 | 1137 | return templ_7745c5c3_Err | |
| 1036 | 1138 | } | |
| 1037 | - | var templ_7745c5c3_Var53 string | |
| 1038 | - | templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1139 | + | var templ_7745c5c3_Var59 string | |
| 1140 | + | templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1039 | 1141 | if templ_7745c5c3_Err != nil { | |
| 1040 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 133} | |
| 1142 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 133} | |
| 1041 | 1143 | } | |
| 1042 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) | |
| 1144 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) | |
| 1043 | 1145 | if templ_7745c5c3_Err != nil { | |
| 1044 | 1146 | return templ_7745c5c3_Err | |
| 1045 | 1147 | } | |
| 1046 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<span class=\"minus\">-") | |
| 1148 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<span class=\"minus\">-") | |
| 1047 | 1149 | if templ_7745c5c3_Err != nil { | |
| 1048 | 1150 | return templ_7745c5c3_Err | |
| 1049 | 1151 | } | |
| 1050 | - | var templ_7745c5c3_Var54 string | |
| 1051 | - | templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 1152 | + | var templ_7745c5c3_Var60 string | |
| 1153 | + | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(f.Del)) | |
| 1052 | 1154 | if templ_7745c5c3_Err != nil { | |
| 1053 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 175, Col: 169} | |
| 1155 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 197, Col: 169} | |
| 1054 | 1156 | } | |
| 1055 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) | |
| 1157 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) | |
| 1056 | 1158 | if templ_7745c5c3_Err != nil { | |
| 1057 | 1159 | return templ_7745c5c3_Err | |
| 1058 | 1160 | } | |
| 1059 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span> ") | |
| 1161 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</span> ") | |
| 1060 | 1162 | if templ_7745c5c3_Err != nil { | |
| 1061 | 1163 | return templ_7745c5c3_Err | |
| 1062 | 1164 | } | |
| 1063 | 1165 | if f.Note != "" { | |
| 1064 | - | var templ_7745c5c3_Var55 string | |
| 1065 | - | templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1166 | + | var templ_7745c5c3_Var61 string | |
| 1167 | + | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(" ") | |
| 1066 | 1168 | if templ_7745c5c3_Err != nil { | |
| 1067 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 177, Col: 9} | |
| 1169 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 9} | |
| 1068 | 1170 | } | |
| 1069 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) | |
| 1171 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) | |
| 1070 | 1172 | if templ_7745c5c3_Err != nil { | |
| 1071 | 1173 | return templ_7745c5c3_Err | |
| 1072 | 1174 | } | |
| 1073 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<span class=\"note\">") | |
| 1175 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<span class=\"note\">") | |
| 1074 | 1176 | if templ_7745c5c3_Err != nil { | |
| 1075 | 1177 | return templ_7745c5c3_Err | |
| 1076 | 1178 | } | |
| 1077 | - | var templ_7745c5c3_Var56 string | |
| 1078 | - | templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1179 | + | var templ_7745c5c3_Var62 string | |
| 1180 | + | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1079 | 1181 | if templ_7745c5c3_Err != nil { | |
| 1080 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 177, Col: 38} | |
| 1182 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 199, Col: 38} | |
| 1081 | 1183 | } | |
| 1082 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) | |
| 1184 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) | |
| 1083 | 1185 | if templ_7745c5c3_Err != nil { | |
| 1084 | 1186 | return templ_7745c5c3_Err | |
| 1085 | 1187 | } | |
| 1086 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span>") | |
| 1188 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span>") | |
| 1087 | 1189 | if templ_7745c5c3_Err != nil { | |
| 1088 | 1190 | return templ_7745c5c3_Err | |
| 1089 | 1191 | } | |
| 1090 | 1192 | } | |
| 1091 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</summary> ") | |
| 1193 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</summary> ") | |
| 1092 | 1194 | if templ_7745c5c3_Err != nil { | |
| 1093 | 1195 | return templ_7745c5c3_Err | |
| 1094 | 1196 | } | |
| 1095 | - | if len(f.Hunks) > 0 { | |
| 1096 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<div class=\"dscroll\">") | |
| 1197 | + | if f.Lazy { | |
| 1198 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, " ") | |
| 1199 | + | if templ_7745c5c3_Err != nil { | |
| 1200 | + | return templ_7745c5c3_Err | |
| 1201 | + | } | |
| 1202 | + | var templ_7745c5c3_Var63 = []any{"dlazy", "dlazy-" + itoa(f.Height)} | |
| 1203 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var63...) | |
| 1204 | + | if templ_7745c5c3_Err != nil { | |
| 1205 | + | return templ_7745c5c3_Err | |
| 1206 | + | } | |
| 1207 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "<div class=\"") | |
| 1208 | + | if templ_7745c5c3_Err != nil { | |
| 1209 | + | return templ_7745c5c3_Err | |
| 1210 | + | } | |
| 1211 | + | var templ_7745c5c3_Var64 string | |
| 1212 | + | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var63).String()) | |
| 1213 | + | if templ_7745c5c3_Err != nil { | |
| 1214 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1215 | + | } | |
| 1216 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64) | |
| 1217 | + | if templ_7745c5c3_Err != nil { | |
| 1218 | + | return templ_7745c5c3_Err | |
| 1219 | + | } | |
| 1220 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\" data-src=\"") | |
| 1221 | + | if templ_7745c5c3_Err != nil { | |
| 1222 | + | return templ_7745c5c3_Err | |
| 1223 | + | } | |
| 1224 | + | var templ_7745c5c3_Var65 string | |
| 1225 | + | templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Src) | |
| 1226 | + | if templ_7745c5c3_Err != nil { | |
| 1227 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 69} | |
| 1228 | + | } | |
| 1229 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65) | |
| 1230 | + | if templ_7745c5c3_Err != nil { | |
| 1231 | + | return templ_7745c5c3_Err | |
| 1232 | + | } | |
| 1233 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\" data-notes=\"") | |
| 1234 | + | if templ_7745c5c3_Err != nil { | |
| 1235 | + | return templ_7745c5c3_Err | |
| 1236 | + | } | |
| 1237 | + | var templ_7745c5c3_Var66 string | |
| 1238 | + | templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Comments) | |
| 1239 | + | if templ_7745c5c3_Err != nil { | |
| 1240 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 95} | |
| 1241 | + | } | |
| 1242 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66) | |
| 1243 | + | if templ_7745c5c3_Err != nil { | |
| 1244 | + | return templ_7745c5c3_Err | |
| 1245 | + | } | |
| 1246 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\"><a href=\"") | |
| 1247 | + | if templ_7745c5c3_Err != nil { | |
| 1248 | + | return templ_7745c5c3_Err | |
| 1249 | + | } | |
| 1250 | + | var templ_7745c5c3_Var67 templ.SafeURL | |
| 1251 | + | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("?from=" + itoa(f.From) + "#" + f.ID)) | |
| 1252 | + | if templ_7745c5c3_Err != nil { | |
| 1253 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 207, Col: 65} | |
| 1254 | + | } | |
| 1255 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) | |
| 1256 | + | if templ_7745c5c3_Err != nil { | |
| 1257 | + | return templ_7745c5c3_Err | |
| 1258 | + | } | |
| 1259 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">show this file</a></div>") | |
| 1260 | + | if templ_7745c5c3_Err != nil { | |
| 1261 | + | return templ_7745c5c3_Err | |
| 1262 | + | } | |
| 1263 | + | } else if len(f.Hunks) > 0 { | |
| 1264 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<div class=\"dscroll\">") | |
| 1097 | 1265 | if templ_7745c5c3_Err != nil { | |
| 1098 | 1266 | return templ_7745c5c3_Err | |
| 1099 | 1267 | } | |
| 1100 | 1268 | if p.ShowUnified { | |
| 1101 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<table class=\"dt uni\" aria-label=\"") | |
| 1269 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "<table class=\"dt uni\" aria-label=\"") | |
| 1102 | 1270 | if templ_7745c5c3_Err != nil { | |
| 1103 | 1271 | return templ_7745c5c3_Err | |
| 1104 | 1272 | } | |
| 1105 | - | var templ_7745c5c3_Var57 string | |
| 1106 | - | templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified") | |
| 1273 | + | var templ_7745c5c3_Var68 string | |
| 1274 | + | templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", unified") | |
| 1107 | 1275 | if templ_7745c5c3_Err != nil { | |
| 1108 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 183, Col: 60} | |
| 1276 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 212, Col: 60} | |
| 1109 | 1277 | } | |
| 1110 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57) | |
| 1278 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var68) | |
| 1111 | 1279 | if templ_7745c5c3_Err != nil { | |
| 1112 | 1280 | return templ_7745c5c3_Err | |
| 1113 | 1281 | } | |
| 1114 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\">") | |
| 1282 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">") | |
| 1115 | 1283 | if templ_7745c5c3_Err != nil { | |
| 1116 | 1284 | return templ_7745c5c3_Err | |
| 1117 | 1285 | } | |
| 1118 | 1286 | for _, h := range f.Hunks { | |
| 1119 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<tbody><tr class=\"hunk\"><td colspan=\"4\">") | |
| 1287 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "<tbody><tr class=\"hunk\"><td colspan=\"4\">") | |
| 1120 | 1288 | if templ_7745c5c3_Err != nil { | |
| 1121 | 1289 | return templ_7745c5c3_Err | |
| 1122 | 1290 | } | |
| 1123 | - | var templ_7745c5c3_Var58 string | |
| 1124 | - | templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1291 | + | var templ_7745c5c3_Var69 string | |
| 1292 | + | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1125 | 1293 | if templ_7745c5c3_Err != nil { | |
| 1126 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 186, Col: 51} | |
| 1294 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 215, Col: 51} | |
| 1127 | 1295 | } | |
| 1128 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) | |
| 1296 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var69)) | |
| 1129 | 1297 | if templ_7745c5c3_Err != nil { | |
| 1130 | 1298 | return templ_7745c5c3_Err | |
| 1131 | 1299 | } | |
| 1132 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</td></tr>") | |
| 1300 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "</td></tr>") | |
| 1133 | 1301 | if templ_7745c5c3_Err != nil { | |
| 1134 | 1302 | return templ_7745c5c3_Err | |
| 1135 | 1303 | } | |
| 1136 | 1304 | for _, ln := range h.Lines { | |
| 1137 | - | var templ_7745c5c3_Var59 = []any{ln.Kind} | |
| 1138 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var59...) | |
| 1305 | + | var templ_7745c5c3_Var70 = []any{ln.Kind} | |
| 1306 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var70...) | |
| 1139 | 1307 | if templ_7745c5c3_Err != nil { | |
| 1140 | 1308 | return templ_7745c5c3_Err | |
| 1141 | 1309 | } | |
| 1142 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<tr class=\"") | |
| 1310 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "<tr class=\"") | |
| 1143 | 1311 | if templ_7745c5c3_Err != nil { | |
| 1144 | 1312 | return templ_7745c5c3_Err | |
| 1145 | 1313 | } | |
| 1146 | - | var templ_7745c5c3_Var60 string | |
| 1147 | - | templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var59).String()) | |
| 1314 | + | var templ_7745c5c3_Var71 string | |
| 1315 | + | templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var70).String()) | |
| 1148 | 1316 | if templ_7745c5c3_Err != nil { | |
| 1149 | 1317 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1150 | 1318 | } | |
| 1151 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60) | |
| 1319 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71) | |
| 1152 | 1320 | if templ_7745c5c3_Err != nil { | |
| 1153 | 1321 | return templ_7745c5c3_Err | |
| 1154 | 1322 | } | |
| 1155 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\"><td class=\"ln\">") | |
| 1323 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\"><td class=\"ln\">") | |
| 1156 | 1324 | if templ_7745c5c3_Err != nil { | |
| 1157 | 1325 | return templ_7745c5c3_Err | |
| 1158 | 1326 | } | |
| @@ -1160,7 +1328,7 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1160 | 1328 | if templ_7745c5c3_Err != nil { | |
| 1161 | 1329 | return templ_7745c5c3_Err | |
| 1162 | 1330 | } | |
| 1163 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</td><td class=\"ln\">") | |
| 1331 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</td><td class=\"ln\">") | |
| 1164 | 1332 | if templ_7745c5c3_Err != nil { | |
| 1165 | 1333 | return templ_7745c5c3_Err | |
| 1166 | 1334 | } | |
| @@ -1168,20 +1336,20 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1168 | 1336 | if templ_7745c5c3_Err != nil { | |
| 1169 | 1337 | return templ_7745c5c3_Err | |
| 1170 | 1338 | } | |
| 1171 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</td><td class=\"m\">") | |
| 1339 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</td><td class=\"m\">") | |
| 1172 | 1340 | if templ_7745c5c3_Err != nil { | |
| 1173 | 1341 | return templ_7745c5c3_Err | |
| 1174 | 1342 | } | |
| 1175 | - | var templ_7745c5c3_Var61 string | |
| 1176 | - | templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1343 | + | var templ_7745c5c3_Var72 string | |
| 1344 | + | templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1177 | 1345 | if templ_7745c5c3_Err != nil { | |
| 1178 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 191, Col: 33} | |
| 1346 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 220, Col: 33} | |
| 1179 | 1347 | } | |
| 1180 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) | |
| 1348 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) | |
| 1181 | 1349 | if templ_7745c5c3_Err != nil { | |
| 1182 | 1350 | return templ_7745c5c3_Err | |
| 1183 | 1351 | } | |
| 1184 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</td><td class=\"tx\">") | |
| 1352 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</td><td class=\"tx\">") | |
| 1185 | 1353 | if templ_7745c5c3_Err != nil { | |
| 1186 | 1354 | return templ_7745c5c3_Err | |
| 1187 | 1355 | } | |
| @@ -1189,12 +1357,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1189 | 1357 | if templ_7745c5c3_Err != nil { | |
| 1190 | 1358 | return templ_7745c5c3_Err | |
| 1191 | 1359 | } | |
| 1192 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</td></tr>") | |
| 1360 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</td></tr>") | |
| 1193 | 1361 | if templ_7745c5c3_Err != nil { | |
| 1194 | 1362 | return templ_7745c5c3_Err | |
| 1195 | 1363 | } | |
| 1196 | 1364 | if len(ln.Notes) > 0 || ln.Form != nil { | |
| 1197 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>") | |
| 1365 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "<tr class=\"fnote\"><td colspan=\"3\"></td><td>") | |
| 1198 | 1366 | if templ_7745c5c3_Err != nil { | |
| 1199 | 1367 | return templ_7745c5c3_Err | |
| 1200 | 1368 | } | |
| @@ -1202,60 +1370,60 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1202 | 1370 | if templ_7745c5c3_Err != nil { | |
| 1203 | 1371 | return templ_7745c5c3_Err | |
| 1204 | 1372 | } | |
| 1205 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</td></tr>") | |
| 1373 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</td></tr>") | |
| 1206 | 1374 | if templ_7745c5c3_Err != nil { | |
| 1207 | 1375 | return templ_7745c5c3_Err | |
| 1208 | 1376 | } | |
| 1209 | 1377 | } | |
| 1210 | 1378 | } | |
| 1211 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</tbody>") | |
| 1379 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "</tbody>") | |
| 1212 | 1380 | if templ_7745c5c3_Err != nil { | |
| 1213 | 1381 | return templ_7745c5c3_Err | |
| 1214 | 1382 | } | |
| 1215 | 1383 | } | |
| 1216 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "</table>") | |
| 1384 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "</table>") | |
| 1217 | 1385 | if templ_7745c5c3_Err != nil { | |
| 1218 | 1386 | return templ_7745c5c3_Err | |
| 1219 | 1387 | } | |
| 1220 | 1388 | } | |
| 1221 | 1389 | if p.ShowSplit { | |
| 1222 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<table class=\"dt spl\" aria-label=\"") | |
| 1390 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "<table class=\"dt spl\" aria-label=\"") | |
| 1223 | 1391 | if templ_7745c5c3_Err != nil { | |
| 1224 | 1392 | return templ_7745c5c3_Err | |
| 1225 | 1393 | } | |
| 1226 | - | var templ_7745c5c3_Var62 string | |
| 1227 | - | templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split") | |
| 1394 | + | var templ_7745c5c3_Var73 string | |
| 1395 | + | templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Path + ", split") | |
| 1228 | 1396 | if templ_7745c5c3_Err != nil { | |
| 1229 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 206, Col: 58} | |
| 1397 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 235, Col: 58} | |
| 1230 | 1398 | } | |
| 1231 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62) | |
| 1399 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73) | |
| 1232 | 1400 | if templ_7745c5c3_Err != nil { | |
| 1233 | 1401 | return templ_7745c5c3_Err | |
| 1234 | 1402 | } | |
| 1235 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ") | |
| 1403 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\"><colgroup><col class=\"c-ln\"><col class=\"c-m\"><col><col class=\"c-ln\"><col class=\"c-m\"><col></colgroup> ") | |
| 1236 | 1404 | if templ_7745c5c3_Err != nil { | |
| 1237 | 1405 | return templ_7745c5c3_Err | |
| 1238 | 1406 | } | |
| 1239 | 1407 | for _, h := range f.Hunks { | |
| 1240 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "<tbody><tr class=\"hunk\"><td colspan=\"6\">") | |
| 1408 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<tbody><tr class=\"hunk\"><td colspan=\"6\">") | |
| 1241 | 1409 | if templ_7745c5c3_Err != nil { | |
| 1242 | 1410 | return templ_7745c5c3_Err | |
| 1243 | 1411 | } | |
| 1244 | - | var templ_7745c5c3_Var63 string | |
| 1245 | - | templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1412 | + | var templ_7745c5c3_Var74 string | |
| 1413 | + | templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinStringErrs(h.Header) | |
| 1246 | 1414 | if templ_7745c5c3_Err != nil { | |
| 1247 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 210, Col: 51} | |
| 1415 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 239, Col: 51} | |
| 1248 | 1416 | } | |
| 1249 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) | |
| 1417 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) | |
| 1250 | 1418 | if templ_7745c5c3_Err != nil { | |
| 1251 | 1419 | return templ_7745c5c3_Err | |
| 1252 | 1420 | } | |
| 1253 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</td></tr>") | |
| 1421 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "</td></tr>") | |
| 1254 | 1422 | if templ_7745c5c3_Err != nil { | |
| 1255 | 1423 | return templ_7745c5c3_Err | |
| 1256 | 1424 | } | |
| 1257 | 1425 | for _, row := range h.Split { | |
| 1258 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "<tr>") | |
| 1426 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "<tr>") | |
| 1259 | 1427 | if templ_7745c5c3_Err != nil { | |
| 1260 | 1428 | return templ_7745c5c3_Err | |
| 1261 | 1429 | } | |
| @@ -1267,12 +1435,12 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1267 | 1435 | if templ_7745c5c3_Err != nil { | |
| 1268 | 1436 | return templ_7745c5c3_Err | |
| 1269 | 1437 | } | |
| 1270 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "</tr>") | |
| 1438 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "</tr>") | |
| 1271 | 1439 | if templ_7745c5c3_Err != nil { | |
| 1272 | 1440 | return templ_7745c5c3_Err | |
| 1273 | 1441 | } | |
| 1274 | 1442 | if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil { | |
| 1275 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "<tr class=\"fnote\"><td colspan=\"6\">") | |
| 1443 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "<tr class=\"fnote\"><td colspan=\"6\">") | |
| 1276 | 1444 | if templ_7745c5c3_Err != nil { | |
| 1277 | 1445 | return templ_7745c5c3_Err | |
| 1278 | 1446 | } | |
| @@ -1280,53 +1448,133 @@ func DiffFileView(f DiffFile, p DiffView) templ.Component { | |||
| 1280 | 1448 | if templ_7745c5c3_Err != nil { | |
| 1281 | 1449 | return templ_7745c5c3_Err | |
| 1282 | 1450 | } | |
| 1283 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "</td></tr>") | |
| 1451 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td></tr>") | |
| 1284 | 1452 | if templ_7745c5c3_Err != nil { | |
| 1285 | 1453 | return templ_7745c5c3_Err | |
| 1286 | 1454 | } | |
| 1287 | 1455 | } | |
| 1288 | 1456 | } | |
| 1289 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</tbody>") | |
| 1457 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "</tbody>") | |
| 1290 | 1458 | if templ_7745c5c3_Err != nil { | |
| 1291 | 1459 | return templ_7745c5c3_Err | |
| 1292 | 1460 | } | |
| 1293 | 1461 | } | |
| 1294 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "</table>") | |
| 1462 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "</table>") | |
| 1295 | 1463 | if templ_7745c5c3_Err != nil { | |
| 1296 | 1464 | return templ_7745c5c3_Err | |
| 1297 | 1465 | } | |
| 1298 | 1466 | } | |
| 1299 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</div>") | |
| 1467 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</div>") | |
| 1300 | 1468 | if templ_7745c5c3_Err != nil { | |
| 1301 | 1469 | return templ_7745c5c3_Err | |
| 1302 | 1470 | } | |
| 1303 | 1471 | } else { | |
| 1304 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "<p class=\"note pad\">") | |
| 1472 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 136, "<p class=\"note pad\">") | |
| 1305 | 1473 | if templ_7745c5c3_Err != nil { | |
| 1306 | 1474 | return templ_7745c5c3_Err | |
| 1307 | 1475 | } | |
| 1308 | 1476 | if f.Note != "" { | |
| 1309 | - | var templ_7745c5c3_Var64 string | |
| 1310 | - | templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1477 | + | var templ_7745c5c3_Var75 string | |
| 1478 | + | templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(f.Note) | |
| 1311 | 1479 | if templ_7745c5c3_Err != nil { | |
| 1312 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 230, Col: 13} | |
| 1480 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 259, Col: 13} | |
| 1313 | 1481 | } | |
| 1314 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) | |
| 1482 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) | |
| 1315 | 1483 | if templ_7745c5c3_Err != nil { | |
| 1316 | 1484 | return templ_7745c5c3_Err | |
| 1317 | 1485 | } | |
| 1318 | 1486 | } else { | |
| 1319 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "no textual change.") | |
| 1487 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "no textual change.") | |
| 1488 | + | if templ_7745c5c3_Err != nil { | |
| 1489 | + | return templ_7745c5c3_Err | |
| 1490 | + | } | |
| 1491 | + | } | |
| 1492 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 138, "</p>") | |
| 1493 | + | if templ_7745c5c3_Err != nil { | |
| 1494 | + | return templ_7745c5c3_Err | |
| 1495 | + | } | |
| 1496 | + | } | |
| 1497 | + | if len(f.Loose) > 0 { | |
| 1498 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "<div class=\"dloose\">") | |
| 1499 | + | if templ_7745c5c3_Err != nil { | |
| 1500 | + | return templ_7745c5c3_Err | |
| 1501 | + | } | |
| 1502 | + | for _, c := range f.Loose { | |
| 1503 | + | templ_7745c5c3_Err = commentView(c, "").Render(ctx, templ_7745c5c3_Buffer) | |
| 1320 | 1504 | if templ_7745c5c3_Err != nil { | |
| 1321 | 1505 | return templ_7745c5c3_Err | |
| 1322 | 1506 | } | |
| 1323 | 1507 | } | |
| 1324 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</p>") | |
| 1508 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "</div>") | |
| 1325 | 1509 | if templ_7745c5c3_Err != nil { | |
| 1326 | 1510 | return templ_7745c5c3_Err | |
| 1327 | 1511 | } | |
| 1328 | 1512 | } | |
| 1329 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</details>") | |
| 1513 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 141, "</details>") | |
| 1514 | + | if templ_7745c5c3_Err != nil { | |
| 1515 | + | return templ_7745c5c3_Err | |
| 1516 | + | } | |
| 1517 | + | return nil | |
| 1518 | + | }) | |
| 1519 | + | } | |
| 1520 | + | ||
| 1521 | + | // MRFiles is a window of files of a merge request, for the placeholders of | |
| 1522 | + | // the page: the files only, which replace the placeholders of the same ids. | |
| 1523 | + | func MRFiles(p MRFilesPage) templ.Component { | |
| 1524 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1525 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1526 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1527 | + | return templ_7745c5c3_CtxErr | |
| 1528 | + | } | |
| 1529 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1530 | + | if !templ_7745c5c3_IsBuffer { | |
| 1531 | + | defer func() { | |
| 1532 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1533 | + | if templ_7745c5c3_Err == nil { | |
| 1534 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1535 | + | } | |
| 1536 | + | }() | |
| 1537 | + | } | |
| 1538 | + | ctx = templ.InitializeContext(ctx) | |
| 1539 | + | templ_7745c5c3_Var76 := templ.GetChildren(ctx) | |
| 1540 | + | if templ_7745c5c3_Var76 == nil { | |
| 1541 | + | templ_7745c5c3_Var76 = templ.NopComponent | |
| 1542 | + | } | |
| 1543 | + | ctx = templ.ClearChildren(ctx) | |
| 1544 | + | for _, f := range p.Files { | |
| 1545 | + | templ_7745c5c3_Err = DiffFileView(f, p.DiffView).Render(ctx, templ_7745c5c3_Buffer) | |
| 1546 | + | if templ_7745c5c3_Err != nil { | |
| 1547 | + | return templ_7745c5c3_Err | |
| 1548 | + | } | |
| 1549 | + | } | |
| 1550 | + | return nil | |
| 1551 | + | }) | |
| 1552 | + | } | |
| 1553 | + | ||
| 1554 | + | // MRFilesMoved answers a window of a diff that is not the one of the page any | |
| 1555 | + | // more: the placeholder shows it instead of a file of another diff. | |
| 1556 | + | func MRFilesMoved() templ.Component { | |
| 1557 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 1558 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 1559 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 1560 | + | return templ_7745c5c3_CtxErr | |
| 1561 | + | } | |
| 1562 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 1563 | + | if !templ_7745c5c3_IsBuffer { | |
| 1564 | + | defer func() { | |
| 1565 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 1566 | + | if templ_7745c5c3_Err == nil { | |
| 1567 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 1568 | + | } | |
| 1569 | + | }() | |
| 1570 | + | } | |
| 1571 | + | ctx = templ.InitializeContext(ctx) | |
| 1572 | + | templ_7745c5c3_Var77 := templ.GetChildren(ctx) | |
| 1573 | + | if templ_7745c5c3_Var77 == nil { | |
| 1574 | + | templ_7745c5c3_Var77 = templ.NopComponent | |
| 1575 | + | } | |
| 1576 | + | ctx = templ.ClearChildren(ctx) | |
| 1577 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "<p class=\"notice\" data-moved>this merge request changed since the page was loaded — <a href=\"\">reload</a></p>") | |
| 1330 | 1578 | if templ_7745c5c3_Err != nil { | |
| 1331 | 1579 | return templ_7745c5c3_Err | |
| 1332 | 1580 | } | |
| @@ -1352,36 +1600,36 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||
| 1352 | 1600 | }() | |
| 1353 | 1601 | } | |
| 1354 | 1602 | ctx = templ.InitializeContext(ctx) | |
| 1355 | - | templ_7745c5c3_Var65 := templ.GetChildren(ctx) | |
| 1356 | - | if templ_7745c5c3_Var65 == nil { | |
| 1357 | - | templ_7745c5c3_Var65 = templ.NopComponent | |
| 1603 | + | templ_7745c5c3_Var78 := templ.GetChildren(ctx) | |
| 1604 | + | if templ_7745c5c3_Var78 == nil { | |
| 1605 | + | templ_7745c5c3_Var78 = templ.NopComponent | |
| 1358 | 1606 | } | |
| 1359 | 1607 | ctx = templ.ClearChildren(ctx) | |
| 1360 | 1608 | if ln == nil { | |
| 1361 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>") | |
| 1609 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "<td class=\"ln none\"></td><td class=\"m none\"></td><td class=\"tx none\"></td>") | |
| 1362 | 1610 | if templ_7745c5c3_Err != nil { | |
| 1363 | 1611 | return templ_7745c5c3_Err | |
| 1364 | 1612 | } | |
| 1365 | 1613 | } else { | |
| 1366 | - | var templ_7745c5c3_Var66 = []any{"ln", ln.Kind} | |
| 1367 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var66...) | |
| 1614 | + | var templ_7745c5c3_Var79 = []any{"ln", ln.Kind} | |
| 1615 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...) | |
| 1368 | 1616 | if templ_7745c5c3_Err != nil { | |
| 1369 | 1617 | return templ_7745c5c3_Err | |
| 1370 | 1618 | } | |
| 1371 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "<td class=\"") | |
| 1619 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 144, "<td class=\"") | |
| 1372 | 1620 | if templ_7745c5c3_Err != nil { | |
| 1373 | 1621 | return templ_7745c5c3_Err | |
| 1374 | 1622 | } | |
| 1375 | - | var templ_7745c5c3_Var67 string | |
| 1376 | - | templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var66).String()) | |
| 1623 | + | var templ_7745c5c3_Var80 string | |
| 1624 | + | templ_7745c5c3_Var80, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var79).String()) | |
| 1377 | 1625 | if templ_7745c5c3_Err != nil { | |
| 1378 | 1626 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1379 | 1627 | } | |
| 1380 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67) | |
| 1628 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var80) | |
| 1381 | 1629 | if templ_7745c5c3_Err != nil { | |
| 1382 | 1630 | return templ_7745c5c3_Err | |
| 1383 | 1631 | } | |
| 1384 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\">") | |
| 1632 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\">") | |
| 1385 | 1633 | if templ_7745c5c3_Err != nil { | |
| 1386 | 1634 | return templ_7745c5c3_Err | |
| 1387 | 1635 | } | |
| @@ -1396,64 +1644,64 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||
| 1396 | 1644 | return templ_7745c5c3_Err | |
| 1397 | 1645 | } | |
| 1398 | 1646 | } | |
| 1399 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "</td>") | |
| 1647 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "</td>") | |
| 1400 | 1648 | if templ_7745c5c3_Err != nil { | |
| 1401 | 1649 | return templ_7745c5c3_Err | |
| 1402 | 1650 | } | |
| 1403 | - | var templ_7745c5c3_Var68 = []any{"m", ln.Kind} | |
| 1404 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var68...) | |
| 1651 | + | var templ_7745c5c3_Var81 = []any{"m", ln.Kind} | |
| 1652 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var81...) | |
| 1405 | 1653 | if templ_7745c5c3_Err != nil { | |
| 1406 | 1654 | return templ_7745c5c3_Err | |
| 1407 | 1655 | } | |
| 1408 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<td class=\"") | |
| 1656 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "<td class=\"") | |
| 1409 | 1657 | if templ_7745c5c3_Err != nil { | |
| 1410 | 1658 | return templ_7745c5c3_Err | |
| 1411 | 1659 | } | |
| 1412 | - | var templ_7745c5c3_Var69 string | |
| 1413 | - | templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var68).String()) | |
| 1660 | + | var templ_7745c5c3_Var82 string | |
| 1661 | + | templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var81).String()) | |
| 1414 | 1662 | if templ_7745c5c3_Err != nil { | |
| 1415 | 1663 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1416 | 1664 | } | |
| 1417 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69) | |
| 1665 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var82) | |
| 1418 | 1666 | if templ_7745c5c3_Err != nil { | |
| 1419 | 1667 | return templ_7745c5c3_Err | |
| 1420 | 1668 | } | |
| 1421 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\">") | |
| 1669 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 148, "\">") | |
| 1422 | 1670 | if templ_7745c5c3_Err != nil { | |
| 1423 | 1671 | return templ_7745c5c3_Err | |
| 1424 | 1672 | } | |
| 1425 | - | var templ_7745c5c3_Var70 string | |
| 1426 | - | templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1673 | + | var templ_7745c5c3_Var83 string | |
| 1674 | + | templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(ln.Mark) | |
| 1427 | 1675 | if templ_7745c5c3_Err != nil { | |
| 1428 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 254, Col: 38} | |
| 1676 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 304, Col: 38} | |
| 1429 | 1677 | } | |
| 1430 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) | |
| 1678 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) | |
| 1431 | 1679 | if templ_7745c5c3_Err != nil { | |
| 1432 | 1680 | return templ_7745c5c3_Err | |
| 1433 | 1681 | } | |
| 1434 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "</td>") | |
| 1682 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 149, "</td>") | |
| 1435 | 1683 | if templ_7745c5c3_Err != nil { | |
| 1436 | 1684 | return templ_7745c5c3_Err | |
| 1437 | 1685 | } | |
| 1438 | - | var templ_7745c5c3_Var71 = []any{"tx", ln.Kind} | |
| 1439 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var71...) | |
| 1686 | + | var templ_7745c5c3_Var84 = []any{"tx", ln.Kind} | |
| 1687 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var84...) | |
| 1440 | 1688 | if templ_7745c5c3_Err != nil { | |
| 1441 | 1689 | return templ_7745c5c3_Err | |
| 1442 | 1690 | } | |
| 1443 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, "<td class=\"") | |
| 1691 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 150, "<td class=\"") | |
| 1444 | 1692 | if templ_7745c5c3_Err != nil { | |
| 1445 | 1693 | return templ_7745c5c3_Err | |
| 1446 | 1694 | } | |
| 1447 | - | var templ_7745c5c3_Var72 string | |
| 1448 | - | templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var71).String()) | |
| 1695 | + | var templ_7745c5c3_Var85 string | |
| 1696 | + | templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var84).String()) | |
| 1449 | 1697 | if templ_7745c5c3_Err != nil { | |
| 1450 | 1698 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 1, Col: 0} | |
| 1451 | 1699 | } | |
| 1452 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72) | |
| 1700 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var85) | |
| 1453 | 1701 | if templ_7745c5c3_Err != nil { | |
| 1454 | 1702 | return templ_7745c5c3_Err | |
| 1455 | 1703 | } | |
| 1456 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "\">") | |
| 1704 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 151, "\">") | |
| 1457 | 1705 | if templ_7745c5c3_Err != nil { | |
| 1458 | 1706 | return templ_7745c5c3_Err | |
| 1459 | 1707 | } | |
| @@ -1461,7 +1709,7 @@ func splitSide(ln *DiffLine, old bool) templ.Component { | |||
| 1461 | 1709 | if templ_7745c5c3_Err != nil { | |
| 1462 | 1710 | return templ_7745c5c3_Err | |
| 1463 | 1711 | } | |
| 1464 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "</td>") | |
| 1712 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 152, "</td>") | |
| 1465 | 1713 | if templ_7745c5c3_Err != nil { | |
| 1466 | 1714 | return templ_7745c5c3_Err | |
| 1467 | 1715 | } | |
| @@ -1488,49 +1736,49 @@ func gutter(n, href string) templ.Component { | |||
| 1488 | 1736 | }() | |
| 1489 | 1737 | } | |
| 1490 | 1738 | ctx = templ.InitializeContext(ctx) | |
| 1491 | - | templ_7745c5c3_Var73 := templ.GetChildren(ctx) | |
| 1492 | - | if templ_7745c5c3_Var73 == nil { | |
| 1493 | - | templ_7745c5c3_Var73 = templ.NopComponent | |
| 1739 | + | templ_7745c5c3_Var86 := templ.GetChildren(ctx) | |
| 1740 | + | if templ_7745c5c3_Var86 == nil { | |
| 1741 | + | templ_7745c5c3_Var86 = templ.NopComponent | |
| 1494 | 1742 | } | |
| 1495 | 1743 | ctx = templ.ClearChildren(ctx) | |
| 1496 | 1744 | if href != "" { | |
| 1497 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, "<a href=\"") | |
| 1745 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 153, "<a href=\"") | |
| 1498 | 1746 | if templ_7745c5c3_Err != nil { | |
| 1499 | 1747 | return templ_7745c5c3_Err | |
| 1500 | 1748 | } | |
| 1501 | - | var templ_7745c5c3_Var74 templ.SafeURL | |
| 1502 | - | templ_7745c5c3_Var74, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) | |
| 1749 | + | var templ_7745c5c3_Var87 templ.SafeURL | |
| 1750 | + | templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(href)) | |
| 1503 | 1751 | if templ_7745c5c3_Err != nil { | |
| 1504 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 31} | |
| 1752 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 31} | |
| 1505 | 1753 | } | |
| 1506 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var74)) | |
| 1754 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) | |
| 1507 | 1755 | if templ_7745c5c3_Err != nil { | |
| 1508 | 1756 | return templ_7745c5c3_Err | |
| 1509 | 1757 | } | |
| 1510 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 134, "\" title=\"comment on this line\">") | |
| 1758 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 154, "\" title=\"comment on this line\">") | |
| 1511 | 1759 | if templ_7745c5c3_Err != nil { | |
| 1512 | 1760 | return templ_7745c5c3_Err | |
| 1513 | 1761 | } | |
| 1514 | - | var templ_7745c5c3_Var75 string | |
| 1515 | - | templ_7745c5c3_Var75, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 1762 | + | var templ_7745c5c3_Var88 string | |
| 1763 | + | templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 1516 | 1764 | if templ_7745c5c3_Err != nil { | |
| 1517 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 263, Col: 66} | |
| 1765 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 313, Col: 66} | |
| 1518 | 1766 | } | |
| 1519 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var75)) | |
| 1767 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) | |
| 1520 | 1768 | if templ_7745c5c3_Err != nil { | |
| 1521 | 1769 | return templ_7745c5c3_Err | |
| 1522 | 1770 | } | |
| 1523 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "</a>") | |
| 1771 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 155, "</a>") | |
| 1524 | 1772 | if templ_7745c5c3_Err != nil { | |
| 1525 | 1773 | return templ_7745c5c3_Err | |
| 1526 | 1774 | } | |
| 1527 | 1775 | } else { | |
| 1528 | - | var templ_7745c5c3_Var76 string | |
| 1529 | - | templ_7745c5c3_Var76, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 1776 | + | var templ_7745c5c3_Var89 string | |
| 1777 | + | templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 1530 | 1778 | if templ_7745c5c3_Err != nil { | |
| 1531 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 265, Col: 5} | |
| 1779 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/diff.templ`, Line: 315, Col: 5} | |
| 1532 | 1780 | } | |
| 1533 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var76)) | |
| 1781 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) | |
| 1534 | 1782 | if templ_7745c5c3_Err != nil { | |
| 1535 | 1783 | return templ_7745c5c3_Err | |
| 1536 | 1784 | } | |
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 { | |
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 | |
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 | + | }); | |
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 | } | |
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 | } | |
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"> | |
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 | + | } | |
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 | |
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> | |
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() | |
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) { | |
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); } | |
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 | |
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(); | |
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. | |
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)) | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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> | |
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[^"]*)"`) | |
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 | |
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 | |
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 | } | |
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) | |
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 | ||
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 | |
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") | |
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) | |
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 | + | } | |
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 | ||