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 generated file: collapsed
M internal/adapters/web/diff_test.go +0 -9
| @@ -85,15 +85,6 @@ func TestPairLines(t *testing.T) { | |||
| 85 | 85 | assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4]) | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | - | func TestChangeBar(t *testing.T) { | |
| 89 | - | t.Parallel() | |
| 90 | - | assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0)) | |
| 91 | - | assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100)) | |
| 92 | - | assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100)) | |
| 93 | - | assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away") | |
| 94 | - | assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar") | |
| 95 | - | } | |
| 96 | - | ||
| 97 | 88 | func TestFileNotes(t *testing.T) { | |
| 98 | 89 | t.Parallel() | |
| 99 | 90 | tests := []struct { | |
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
| @@ -203,69 +203,92 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 203 | 203 | if templ_7745c5c3_Err != nil { | |
| 204 | 204 | return templ_7745c5c3_Err | |
| 205 | 205 | } | |
| 206 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script></head><body data-ref=\"") | |
| 206 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" defer></script>") | |
| 207 | 207 | if templ_7745c5c3_Err != nil { | |
| 208 | 208 | return templ_7745c5c3_Err | |
| 209 | 209 | } | |
| 210 | - | var templ_7745c5c3_Var15 string | |
| 211 | - | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath) | |
| 210 | + | if l.MR { | |
| 211 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<script src=\"") | |
| 212 | + | if templ_7745c5c3_Err != nil { | |
| 213 | + | return templ_7745c5c3_Err | |
| 214 | + | } | |
| 215 | + | var templ_7745c5c3_Var15 string | |
| 216 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.Assets.MR) | |
| 217 | + | if templ_7745c5c3_Err != nil { | |
| 218 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 33, Col: 29} | |
| 219 | + | } | |
| 220 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |
| 221 | + | if templ_7745c5c3_Err != nil { | |
| 222 | + | return templ_7745c5c3_Err | |
| 223 | + | } | |
| 224 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" defer></script>") | |
| 225 | + | if templ_7745c5c3_Err != nil { | |
| 226 | + | return templ_7745c5c3_Err | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</head><body data-ref=\"") | |
| 230 | + | if templ_7745c5c3_Err != nil { | |
| 231 | + | return templ_7745c5c3_Err | |
| 232 | + | } | |
| 233 | + | var templ_7745c5c3_Var16 string | |
| 234 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.RefPath) | |
| 212 | 235 | if templ_7745c5c3_Err != nil { | |
| 213 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 34, Col: 23} | |
| 236 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 37, Col: 23} | |
| 214 | 237 | } | |
| 215 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |
| 238 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |
| 216 | 239 | if templ_7745c5c3_Err != nil { | |
| 217 | 240 | return templ_7745c5c3_Err | |
| 218 | 241 | } | |
| 219 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" data-sha=\"") | |
| 242 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-sha=\"") | |
| 220 | 243 | if templ_7745c5c3_Err != nil { | |
| 221 | 244 | return templ_7745c5c3_Err | |
| 222 | 245 | } | |
| 223 | - | var templ_7745c5c3_Var16 string | |
| 224 | - | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA) | |
| 246 | + | var templ_7745c5c3_Var17 string | |
| 247 | + | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.SHA) | |
| 225 | 248 | if templ_7745c5c3_Err != nil { | |
| 226 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 35, Col: 19} | |
| 249 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 38, Col: 19} | |
| 227 | 250 | } | |
| 228 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) | |
| 251 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) | |
| 229 | 252 | if templ_7745c5c3_Err != nil { | |
| 230 | 253 | return templ_7745c5c3_Err | |
| 231 | 254 | } | |
| 232 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" data-sel=\"\" data-nav=\"\"") | |
| 255 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" data-sel=\"\" data-nav=\"\"") | |
| 233 | 256 | if templ_7745c5c3_Err != nil { | |
| 234 | 257 | return templ_7745c5c3_Err | |
| 235 | 258 | } | |
| 236 | 259 | if l.PathsURL != "" { | |
| 237 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " data-paths=\"") | |
| 260 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " data-paths=\"") | |
| 238 | 261 | if templ_7745c5c3_Err != nil { | |
| 239 | 262 | return templ_7745c5c3_Err | |
| 240 | 263 | } | |
| 241 | - | var templ_7745c5c3_Var17 string | |
| 242 | - | templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL) | |
| 264 | + | var templ_7745c5c3_Var18 string | |
| 265 | + | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.PathsURL) | |
| 243 | 266 | if templ_7745c5c3_Err != nil { | |
| 244 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 39, Col: 27} | |
| 267 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 42, Col: 27} | |
| 245 | 268 | } | |
| 246 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) | |
| 269 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) | |
| 247 | 270 | if templ_7745c5c3_Err != nil { | |
| 248 | 271 | return templ_7745c5c3_Err | |
| 249 | 272 | } | |
| 250 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" data-blob=\"") | |
| 273 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" data-blob=\"") | |
| 251 | 274 | if templ_7745c5c3_Err != nil { | |
| 252 | 275 | return templ_7745c5c3_Err | |
| 253 | 276 | } | |
| 254 | - | var templ_7745c5c3_Var18 string | |
| 255 | - | templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase) | |
| 277 | + | var templ_7745c5c3_Var19 string | |
| 278 | + | templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(l.BlobBase) | |
| 256 | 279 | if templ_7745c5c3_Err != nil { | |
| 257 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 40, Col: 26} | |
| 280 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 43, Col: 26} | |
| 258 | 281 | } | |
| 259 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) | |
| 282 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) | |
| 260 | 283 | if templ_7745c5c3_Err != nil { | |
| 261 | 284 | return templ_7745c5c3_Err | |
| 262 | 285 | } | |
| 263 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"") | |
| 286 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\"") | |
| 264 | 287 | if templ_7745c5c3_Err != nil { | |
| 265 | 288 | return templ_7745c5c3_Err | |
| 266 | 289 | } | |
| 267 | 290 | } | |
| 268 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "><a class=\"skip\" href=\"#main\">skip to content</a>") | |
| 291 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "><a class=\"skip\" href=\"#main\">skip to content</a>") | |
| 269 | 292 | if templ_7745c5c3_Err != nil { | |
| 270 | 293 | return templ_7745c5c3_Err | |
| 271 | 294 | } | |
| @@ -273,48 +296,48 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 273 | 296 | if templ_7745c5c3_Err != nil { | |
| 274 | 297 | return templ_7745c5c3_Err | |
| 275 | 298 | } | |
| 276 | - | var templ_7745c5c3_Var19 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)} | |
| 277 | - | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var19...) | |
| 299 | + | var templ_7745c5c3_Var20 = []any{"work", templ.KV("has-rail", l.HasRail && rail != nil)} | |
| 300 | + | templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) | |
| 278 | 301 | if templ_7745c5c3_Err != nil { | |
| 279 | 302 | return templ_7745c5c3_Err | |
| 280 | 303 | } | |
| 281 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"") | |
| 304 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"") | |
| 282 | 305 | if templ_7745c5c3_Err != nil { | |
| 283 | 306 | return templ_7745c5c3_Err | |
| 284 | 307 | } | |
| 285 | - | var templ_7745c5c3_Var20 string | |
| 286 | - | templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String()) | |
| 308 | + | var templ_7745c5c3_Var21 string | |
| 309 | + | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String()) | |
| 287 | 310 | if templ_7745c5c3_Err != nil { | |
| 288 | 311 | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 1, Col: 0} | |
| 289 | 312 | } | |
| 290 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) | |
| 313 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) | |
| 291 | 314 | if templ_7745c5c3_Err != nil { | |
| 292 | 315 | return templ_7745c5c3_Err | |
| 293 | 316 | } | |
| 294 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">") | |
| 317 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">") | |
| 295 | 318 | if templ_7745c5c3_Err != nil { | |
| 296 | 319 | return templ_7745c5c3_Err | |
| 297 | 320 | } | |
| 298 | 321 | if l.Lede != "" { | |
| 299 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">") | |
| 322 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<section class=\"work-lede\" aria-label=\"Description\"><p class=\"lede\">") | |
| 300 | 323 | if templ_7745c5c3_Err != nil { | |
| 301 | 324 | return templ_7745c5c3_Err | |
| 302 | 325 | } | |
| 303 | - | var templ_7745c5c3_Var21 string | |
| 304 | - | templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede) | |
| 326 | + | var templ_7745c5c3_Var22 string | |
| 327 | + | templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(l.Lede) | |
| 305 | 328 | if templ_7745c5c3_Err != nil { | |
| 306 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 47, Col: 81} | |
| 329 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 50, Col: 81} | |
| 307 | 330 | } | |
| 308 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) | |
| 331 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | |
| 309 | 332 | if templ_7745c5c3_Err != nil { | |
| 310 | 333 | return templ_7745c5c3_Err | |
| 311 | 334 | } | |
| 312 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</p></section>") | |
| 335 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</p></section>") | |
| 313 | 336 | if templ_7745c5c3_Err != nil { | |
| 314 | 337 | return templ_7745c5c3_Err | |
| 315 | 338 | } | |
| 316 | 339 | } | |
| 317 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<main id=\"main\" tabindex=\"-1\">") | |
| 340 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<main id=\"main\" tabindex=\"-1\">") | |
| 318 | 341 | if templ_7745c5c3_Err != nil { | |
| 319 | 342 | return templ_7745c5c3_Err | |
| 320 | 343 | } | |
| @@ -322,12 +345,12 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 322 | 345 | if templ_7745c5c3_Err != nil { | |
| 323 | 346 | return templ_7745c5c3_Err | |
| 324 | 347 | } | |
| 325 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</main>") | |
| 348 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</main>") | |
| 326 | 349 | if templ_7745c5c3_Err != nil { | |
| 327 | 350 | return templ_7745c5c3_Err | |
| 328 | 351 | } | |
| 329 | 352 | if l.HasRail && rail != nil { | |
| 330 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<aside class=\"rail\" aria-label=\"Context\">") | |
| 353 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<aside class=\"rail\" aria-label=\"Context\">") | |
| 331 | 354 | if templ_7745c5c3_Err != nil { | |
| 332 | 355 | return templ_7745c5c3_Err | |
| 333 | 356 | } | |
| @@ -335,12 +358,12 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 335 | 358 | if templ_7745c5c3_Err != nil { | |
| 336 | 359 | return templ_7745c5c3_Err | |
| 337 | 360 | } | |
| 338 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</aside>") | |
| 361 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</aside>") | |
| 339 | 362 | if templ_7745c5c3_Err != nil { | |
| 340 | 363 | return templ_7745c5c3_Err | |
| 341 | 364 | } | |
| 342 | 365 | } | |
| 343 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>") | |
| 366 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>") | |
| 344 | 367 | if templ_7745c5c3_Err != nil { | |
| 345 | 368 | return templ_7745c5c3_Err | |
| 346 | 369 | } | |
| @@ -352,7 +375,7 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 352 | 375 | if templ_7745c5c3_Err != nil { | |
| 353 | 376 | return templ_7745c5c3_Err | |
| 354 | 377 | } | |
| 355 | - | templ_7745c5c3_Err = OverlayHelp().Render(ctx, templ_7745c5c3_Buffer) | |
| 378 | + | templ_7745c5c3_Err = OverlayHelp(l.MR).Render(ctx, templ_7745c5c3_Buffer) | |
| 356 | 379 | if templ_7745c5c3_Err != nil { | |
| 357 | 380 | return templ_7745c5c3_Err | |
| 358 | 381 | } | |
| @@ -368,7 +391,7 @@ func Shell(l Layout, rail templ.Component) templ.Component { | |||
| 368 | 391 | if templ_7745c5c3_Err != nil { | |
| 369 | 392 | return templ_7745c5c3_Err | |
| 370 | 393 | } | |
| 371 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</body></html>") | |
| 394 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</body></html>") | |
| 372 | 395 | if templ_7745c5c3_Err != nil { | |
| 373 | 396 | return templ_7745c5c3_Err | |
| 374 | 397 | } | |
| @@ -394,324 +417,324 @@ func Header(l Layout) templ.Component { | |||
| 394 | 417 | }() | |
| 395 | 418 | } | |
| 396 | 419 | ctx = templ.InitializeContext(ctx) | |
| 397 | - | templ_7745c5c3_Var22 := templ.GetChildren(ctx) | |
| 398 | - | if templ_7745c5c3_Var22 == nil { | |
| 399 | - | templ_7745c5c3_Var22 = templ.NopComponent | |
| 420 | + | templ_7745c5c3_Var23 := templ.GetChildren(ctx) | |
| 421 | + | if templ_7745c5c3_Var23 == nil { | |
| 422 | + | templ_7745c5c3_Var23 = templ.NopComponent | |
| 400 | 423 | } | |
| 401 | 424 | ctx = templ.ClearChildren(ctx) | |
| 402 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ") | |
| 425 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<header class=\"hdr\"><div class=\"hdr-top\"><a class=\"brand\" href=\"/\" data-g=\"h\" aria-label=\"home\" title=\"home (g h)\"><span class=\"brand-mark\" aria-hidden=\"true\"></span></a> ") | |
| 403 | 426 | if templ_7745c5c3_Err != nil { | |
| 404 | 427 | return templ_7745c5c3_Err | |
| 405 | 428 | } | |
| 406 | 429 | if len(l.Crumbs) > 0 { | |
| 407 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>") | |
| 430 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<nav class=\"crumbs\" aria-label=\"Path\"><ol>") | |
| 408 | 431 | if templ_7745c5c3_Err != nil { | |
| 409 | 432 | return templ_7745c5c3_Err | |
| 410 | 433 | } | |
| 411 | 434 | for _, c := range l.Crumbs { | |
| 412 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<li>") | |
| 435 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<li>") | |
| 413 | 436 | if templ_7745c5c3_Err != nil { | |
| 414 | 437 | return templ_7745c5c3_Err | |
| 415 | 438 | } | |
| 416 | 439 | if c.Href != "" { | |
| 417 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<a href=\"") | |
| 440 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"") | |
| 418 | 441 | if templ_7745c5c3_Err != nil { | |
| 419 | 442 | return templ_7745c5c3_Err | |
| 420 | 443 | } | |
| 421 | - | var templ_7745c5c3_Var23 templ.SafeURL | |
| 422 | - | templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 444 | + | var templ_7745c5c3_Var24 templ.SafeURL | |
| 445 | + | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href)) | |
| 423 | 446 | if templ_7745c5c3_Err != nil { | |
| 424 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 83, Col: 38} | |
| 447 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 86, Col: 38} | |
| 425 | 448 | } | |
| 426 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | |
| 449 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 427 | 450 | if templ_7745c5c3_Err != nil { | |
| 428 | 451 | return templ_7745c5c3_Err | |
| 429 | 452 | } | |
| 430 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"") | |
| 453 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\"") | |
| 431 | 454 | if templ_7745c5c3_Err != nil { | |
| 432 | 455 | return templ_7745c5c3_Err | |
| 433 | 456 | } | |
| 434 | 457 | if c.Current { | |
| 435 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " aria-current=\"page\"") | |
| 458 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, " aria-current=\"page\"") | |
| 436 | 459 | if templ_7745c5c3_Err != nil { | |
| 437 | 460 | return templ_7745c5c3_Err | |
| 438 | 461 | } | |
| 439 | 462 | } | |
| 440 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, ">") | |
| 463 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, ">") | |
| 441 | 464 | if templ_7745c5c3_Err != nil { | |
| 442 | 465 | return templ_7745c5c3_Err | |
| 443 | 466 | } | |
| 444 | - | var templ_7745c5c3_Var24 string | |
| 445 | - | templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 467 | + | var templ_7745c5c3_Var25 string | |
| 468 | + | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 446 | 469 | if templ_7745c5c3_Err != nil { | |
| 447 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 87, Col: 19} | |
| 470 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 90, Col: 19} | |
| 448 | 471 | } | |
| 449 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | |
| 472 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | |
| 450 | 473 | if templ_7745c5c3_Err != nil { | |
| 451 | 474 | return templ_7745c5c3_Err | |
| 452 | 475 | } | |
| 453 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</a>") | |
| 476 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</a>") | |
| 454 | 477 | if templ_7745c5c3_Err != nil { | |
| 455 | 478 | return templ_7745c5c3_Err | |
| 456 | 479 | } | |
| 457 | 480 | } else { | |
| 458 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<span") | |
| 481 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span") | |
| 459 | 482 | if templ_7745c5c3_Err != nil { | |
| 460 | 483 | return templ_7745c5c3_Err | |
| 461 | 484 | } | |
| 462 | 485 | if c.Current { | |
| 463 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " aria-current=\"page\"") | |
| 486 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " aria-current=\"page\"") | |
| 464 | 487 | if templ_7745c5c3_Err != nil { | |
| 465 | 488 | return templ_7745c5c3_Err | |
| 466 | 489 | } | |
| 467 | 490 | } | |
| 468 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ">") | |
| 491 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">") | |
| 469 | 492 | if templ_7745c5c3_Err != nil { | |
| 470 | 493 | return templ_7745c5c3_Err | |
| 471 | 494 | } | |
| 472 | - | var templ_7745c5c3_Var25 string | |
| 473 | - | templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 495 | + | var templ_7745c5c3_Var26 string | |
| 496 | + | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(c.Label) | |
| 474 | 497 | if templ_7745c5c3_Err != nil { | |
| 475 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 93, Col: 19} | |
| 498 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 96, Col: 19} | |
| 476 | 499 | } | |
| 477 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | |
| 500 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 478 | 501 | if templ_7745c5c3_Err != nil { | |
| 479 | 502 | return templ_7745c5c3_Err | |
| 480 | 503 | } | |
| 481 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span>") | |
| 504 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span>") | |
| 482 | 505 | if templ_7745c5c3_Err != nil { | |
| 483 | 506 | return templ_7745c5c3_Err | |
| 484 | 507 | } | |
| 485 | 508 | } | |
| 486 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</li>") | |
| 509 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</li>") | |
| 487 | 510 | if templ_7745c5c3_Err != nil { | |
| 488 | 511 | return templ_7745c5c3_Err | |
| 489 | 512 | } | |
| 490 | 513 | } | |
| 491 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</ol></nav>") | |
| 514 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</ol></nav>") | |
| 492 | 515 | if templ_7745c5c3_Err != nil { | |
| 493 | 516 | return templ_7745c5c3_Err | |
| 494 | 517 | } | |
| 495 | 518 | } | |
| 496 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"hdr-acts\">") | |
| 519 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"hdr-acts\">") | |
| 497 | 520 | if templ_7745c5c3_Err != nil { | |
| 498 | 521 | return templ_7745c5c3_Err | |
| 499 | 522 | } | |
| 500 | 523 | if l.Ref != "" { | |
| 501 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<a class=\"btn ref\" href=\"") | |
| 524 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<a class=\"btn ref\" href=\"") | |
| 502 | 525 | if templ_7745c5c3_Err != nil { | |
| 503 | 526 | return templ_7745c5c3_Err | |
| 504 | 527 | } | |
| 505 | - | var templ_7745c5c3_Var26 templ.SafeURL | |
| 506 | - | templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs"))) | |
| 528 | + | var templ_7745c5c3_Var27 templ.SafeURL | |
| 529 | + | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(repoHref(l.Repo, "refs"))) | |
| 507 | 530 | if templ_7745c5c3_Err != nil { | |
| 508 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 104, Col: 52} | |
| 531 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 107, Col: 52} | |
| 509 | 532 | } | |
| 510 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | |
| 533 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 511 | 534 | if templ_7745c5c3_Err != nil { | |
| 512 | 535 | return templ_7745c5c3_Err | |
| 513 | 536 | } | |
| 514 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") | |
| 537 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" data-fire=\"refs\" aria-keyshortcuts=\"b\" title=\"switch branch or tag (b)\"><span class=\"ico i-branch\" aria-hidden=\"true\"></span> <span class=\"ref-name\">") | |
| 515 | 538 | if templ_7745c5c3_Err != nil { | |
| 516 | 539 | return templ_7745c5c3_Err | |
| 517 | 540 | } | |
| 518 | - | var templ_7745c5c3_Var27 string | |
| 519 | - | templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) | |
| 541 | + | var templ_7745c5c3_Var28 string | |
| 542 | + | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(l.Ref) | |
| 520 | 543 | if templ_7745c5c3_Err != nil { | |
| 521 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 110, Col: 36} | |
| 544 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 113, Col: 36} | |
| 522 | 545 | } | |
| 523 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) | |
| 546 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) | |
| 524 | 547 | if templ_7745c5c3_Err != nil { | |
| 525 | 548 | return templ_7745c5c3_Err | |
| 526 | 549 | } | |
| 527 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span> <span class=\"sha\">") | |
| 550 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</span> <span class=\"sha\">") | |
| 528 | 551 | if templ_7745c5c3_Err != nil { | |
| 529 | 552 | return templ_7745c5c3_Err | |
| 530 | 553 | } | |
| 531 | - | var templ_7745c5c3_Var28 string | |
| 532 | - | templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) | |
| 554 | + | var templ_7745c5c3_Var29 string | |
| 555 | + | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Short) | |
| 533 | 556 | if templ_7745c5c3_Err != nil { | |
| 534 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 111, Col: 33} | |
| 557 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 114, Col: 33} | |
| 535 | 558 | } | |
| 536 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) | |
| 559 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 537 | 560 | if templ_7745c5c3_Err != nil { | |
| 538 | 561 | return templ_7745c5c3_Err | |
| 539 | 562 | } | |
| 540 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span></a> ") | |
| 563 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span></a> ") | |
| 541 | 564 | if templ_7745c5c3_Err != nil { | |
| 542 | 565 | return templ_7745c5c3_Err | |
| 543 | 566 | } | |
| 544 | 567 | } | |
| 545 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ") | |
| 568 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"btn search\" data-js aria-haspopup=\"dialog\" aria-controls=\"panel-cmd\" aria-keyshortcuts=\"Control+K :\" title=\"go to a section, ref, file or repo (ctrl k or :)\" data-fire=\"cmd\"><span class=\"ico i-search\" aria-hidden=\"true\"></span> <span class=\"search-label\">Go to…</span> <kbd aria-hidden=\"true\">ctrl k</kbd></button> ") | |
| 546 | 569 | if templ_7745c5c3_Err != nil { | |
| 547 | 570 | return templ_7745c5c3_Err | |
| 548 | 571 | } | |
| 549 | 572 | if SignedIn(ctx) { | |
| 550 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>") | |
| 573 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<form class=\"owner\" method=\"post\" action=\"/-/logout\"><span class=\"owner-badge\" title=\"signed in as the owner\"><span class=\"ico i-user\" aria-hidden=\"true\"></span>") | |
| 551 | 574 | if templ_7745c5c3_Err != nil { | |
| 552 | 575 | return templ_7745c5c3_Err | |
| 553 | 576 | } | |
| 554 | - | var templ_7745c5c3_Var29 string | |
| 555 | - | templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner) | |
| 577 | + | var templ_7745c5c3_Var30 string | |
| 578 | + | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(l.Owner) | |
| 556 | 579 | if templ_7745c5c3_Err != nil { | |
| 557 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 130, Col: 123} | |
| 580 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 133, Col: 123} | |
| 558 | 581 | } | |
| 559 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) | |
| 582 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 560 | 583 | if templ_7745c5c3_Err != nil { | |
| 561 | 584 | return templ_7745c5c3_Err | |
| 562 | 585 | } | |
| 563 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span> ") | |
| 586 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</span> ") | |
| 564 | 587 | if templ_7745c5c3_Err != nil { | |
| 565 | 588 | return templ_7745c5c3_Err | |
| 566 | 589 | } | |
| 567 | 590 | if l.Passkeys { | |
| 568 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ") | |
| 591 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<a class=\"btn icon\" href=\"/-/passkeys\" aria-label=\"Passkeys\" title=\"passkeys: add or remove the passkeys that sign you in\"><span class=\"ico i-key\" aria-hidden=\"true\"></span></a> ") | |
| 569 | 592 | if templ_7745c5c3_Err != nil { | |
| 570 | 593 | return templ_7745c5c3_Err | |
| 571 | 594 | } | |
| 572 | 595 | } | |
| 573 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>") | |
| 596 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<button type=\"submit\" class=\"btn icon\" aria-label=\"Sign out\" title=\"sign out of this browser\"><span class=\"ico i-log-out\" aria-hidden=\"true\"></span></button></form>") | |
| 574 | 597 | if templ_7745c5c3_Err != nil { | |
| 575 | 598 | return templ_7745c5c3_Err | |
| 576 | 599 | } | |
| 577 | 600 | } else if l.Passkeys { | |
| 578 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>") | |
| 601 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<a class=\"btn icon\" href=\"/-/login\" aria-label=\"Sign in\" title=\"sign in with a passkey\"><span class=\"ico i-log-in\" aria-hidden=\"true\"></span></a>") | |
| 579 | 602 | if templ_7745c5c3_Err != nil { | |
| 580 | 603 | return templ_7745c5c3_Err | |
| 581 | 604 | } | |
| 582 | 605 | } | |
| 583 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div></div>") | |
| 606 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div></div>") | |
| 584 | 607 | if templ_7745c5c3_Err != nil { | |
| 585 | 608 | return templ_7745c5c3_Err | |
| 586 | 609 | } | |
| 587 | 610 | if len(l.Sections) > 0 { | |
| 588 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") | |
| 611 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<nav class=\"tabs\" aria-label=\"Sections\"><ul>") | |
| 589 | 612 | if templ_7745c5c3_Err != nil { | |
| 590 | 613 | return templ_7745c5c3_Err | |
| 591 | 614 | } | |
| 592 | 615 | for _, s := range l.Sections { | |
| 593 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<li><a href=\"") | |
| 616 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<li><a href=\"") | |
| 594 | 617 | if templ_7745c5c3_Err != nil { | |
| 595 | 618 | return templ_7745c5c3_Err | |
| 596 | 619 | } | |
| 597 | - | var templ_7745c5c3_Var30 templ.SafeURL | |
| 598 | - | templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 620 | + | var templ_7745c5c3_Var31 templ.SafeURL | |
| 621 | + | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(s.Href)) | |
| 599 | 622 | if templ_7745c5c3_Err != nil { | |
| 600 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 147, Col: 36} | |
| 623 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 150, Col: 36} | |
| 601 | 624 | } | |
| 602 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) | |
| 625 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) | |
| 603 | 626 | if templ_7745c5c3_Err != nil { | |
| 604 | 627 | return templ_7745c5c3_Err | |
| 605 | 628 | } | |
| 606 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" data-key=\"") | |
| 629 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" data-key=\"") | |
| 607 | 630 | if templ_7745c5c3_Err != nil { | |
| 608 | 631 | return templ_7745c5c3_Err | |
| 609 | 632 | } | |
| 610 | - | var templ_7745c5c3_Var31 string | |
| 611 | - | templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 633 | + | var templ_7745c5c3_Var32 string | |
| 634 | + | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 612 | 635 | if templ_7745c5c3_Err != nil { | |
| 613 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 148, Col: 24} | |
| 636 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 151, Col: 24} | |
| 614 | 637 | } | |
| 615 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) | |
| 638 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) | |
| 616 | 639 | if templ_7745c5c3_Err != nil { | |
| 617 | 640 | return templ_7745c5c3_Err | |
| 618 | 641 | } | |
| 619 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\" aria-keyshortcuts=\"") | |
| 642 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" aria-keyshortcuts=\"") | |
| 620 | 643 | if templ_7745c5c3_Err != nil { | |
| 621 | 644 | return templ_7745c5c3_Err | |
| 622 | 645 | } | |
| 623 | - | var templ_7745c5c3_Var32 string | |
| 624 | - | templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 646 | + | var templ_7745c5c3_Var33 string | |
| 647 | + | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(s.Key) | |
| 625 | 648 | if templ_7745c5c3_Err != nil { | |
| 626 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 149, Col: 33} | |
| 649 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 152, Col: 33} | |
| 627 | 650 | } | |
| 628 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) | |
| 651 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) | |
| 629 | 652 | if templ_7745c5c3_Err != nil { | |
| 630 | 653 | return templ_7745c5c3_Err | |
| 631 | 654 | } | |
| 632 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"") | |
| 655 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"") | |
| 633 | 656 | if templ_7745c5c3_Err != nil { | |
| 634 | 657 | return templ_7745c5c3_Err | |
| 635 | 658 | } | |
| 636 | 659 | if s.Current { | |
| 637 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " aria-current=\"page\"") | |
| 660 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " aria-current=\"page\"") | |
| 638 | 661 | if templ_7745c5c3_Err != nil { | |
| 639 | 662 | return templ_7745c5c3_Err | |
| 640 | 663 | } | |
| 641 | 664 | } | |
| 642 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">") | |
| 665 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">") | |
| 643 | 666 | if templ_7745c5c3_Err != nil { | |
| 644 | 667 | return templ_7745c5c3_Err | |
| 645 | 668 | } | |
| 646 | - | var templ_7745c5c3_Var33 string | |
| 647 | - | templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 669 | + | var templ_7745c5c3_Var34 string | |
| 670 | + | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Label) | |
| 648 | 671 | if templ_7745c5c3_Err != nil { | |
| 649 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 154, Col: 17} | |
| 672 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 157, Col: 17} | |
| 650 | 673 | } | |
| 651 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) | |
| 674 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 652 | 675 | if templ_7745c5c3_Err != nil { | |
| 653 | 676 | return templ_7745c5c3_Err | |
| 654 | 677 | } | |
| 655 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " ") | |
| 678 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ") | |
| 656 | 679 | if templ_7745c5c3_Err != nil { | |
| 657 | 680 | return templ_7745c5c3_Err | |
| 658 | 681 | } | |
| 659 | 682 | if s.Count != "" { | |
| 660 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<span class=\"count\">") | |
| 683 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<span class=\"count\">") | |
| 661 | 684 | if templ_7745c5c3_Err != nil { | |
| 662 | 685 | return templ_7745c5c3_Err | |
| 663 | 686 | } | |
| 664 | - | var templ_7745c5c3_Var34 string | |
| 665 | - | templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count) | |
| 687 | + | var templ_7745c5c3_Var35 string | |
| 688 | + | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(s.Count) | |
| 666 | 689 | if templ_7745c5c3_Err != nil { | |
| 667 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 38} | |
| 690 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 38} | |
| 668 | 691 | } | |
| 669 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) | |
| 692 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 670 | 693 | if templ_7745c5c3_Err != nil { | |
| 671 | 694 | return templ_7745c5c3_Err | |
| 672 | 695 | } | |
| 673 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<span class=\"sr\">") | |
| 696 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"sr\">") | |
| 674 | 697 | if templ_7745c5c3_Err != nil { | |
| 675 | 698 | return templ_7745c5c3_Err | |
| 676 | 699 | } | |
| 677 | - | var templ_7745c5c3_Var35 string | |
| 678 | - | templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(" open") | |
| 700 | + | var templ_7745c5c3_Var36 string | |
| 701 | + | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(" open") | |
| 679 | 702 | if templ_7745c5c3_Err != nil { | |
| 680 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 156, Col: 66} | |
| 703 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 159, Col: 66} | |
| 681 | 704 | } | |
| 682 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) | |
| 705 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 683 | 706 | if templ_7745c5c3_Err != nil { | |
| 684 | 707 | return templ_7745c5c3_Err | |
| 685 | 708 | } | |
| 686 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</span></span> ") | |
| 709 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></span> ") | |
| 687 | 710 | if templ_7745c5c3_Err != nil { | |
| 688 | 711 | return templ_7745c5c3_Err | |
| 689 | 712 | } | |
| 690 | 713 | } | |
| 691 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<kbd aria-hidden=\"true\">") | |
| 714 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<kbd aria-hidden=\"true\">") | |
| 692 | 715 | if templ_7745c5c3_Err != nil { | |
| 693 | 716 | return templ_7745c5c3_Err | |
| 694 | 717 | } | |
| 695 | - | var templ_7745c5c3_Var36 string | |
| 696 | - | templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) | |
| 718 | + | var templ_7745c5c3_Var37 string | |
| 719 | + | templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(s.Key) | |
| 697 | 720 | if templ_7745c5c3_Err != nil { | |
| 698 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 158, Col: 39} | |
| 721 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 161, Col: 39} | |
| 699 | 722 | } | |
| 700 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) | |
| 723 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) | |
| 701 | 724 | if templ_7745c5c3_Err != nil { | |
| 702 | 725 | return templ_7745c5c3_Err | |
| 703 | 726 | } | |
| 704 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</kbd></a></li>") | |
| 727 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</kbd></a></li>") | |
| 705 | 728 | if templ_7745c5c3_Err != nil { | |
| 706 | 729 | return templ_7745c5c3_Err | |
| 707 | 730 | } | |
| 708 | 731 | } | |
| 709 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</ul></nav>") | |
| 732 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</ul></nav>") | |
| 710 | 733 | if templ_7745c5c3_Err != nil { | |
| 711 | 734 | return templ_7745c5c3_Err | |
| 712 | 735 | } | |
| 713 | 736 | } | |
| 714 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</header>") | |
| 737 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</header>") | |
| 715 | 738 | if templ_7745c5c3_Err != nil { | |
| 716 | 739 | return templ_7745c5c3_Err | |
| 717 | 740 | } | |
| @@ -737,38 +760,38 @@ func Footer(l Layout) templ.Component { | |||
| 737 | 760 | }() | |
| 738 | 761 | } | |
| 739 | 762 | ctx = templ.InitializeContext(ctx) | |
| 740 | - | templ_7745c5c3_Var37 := templ.GetChildren(ctx) | |
| 741 | - | if templ_7745c5c3_Var37 == nil { | |
| 742 | - | templ_7745c5c3_Var37 = templ.NopComponent | |
| 763 | + | templ_7745c5c3_Var38 := templ.GetChildren(ctx) | |
| 764 | + | if templ_7745c5c3_Var38 == nil { | |
| 765 | + | templ_7745c5c3_Var38 = templ.NopComponent | |
| 743 | 766 | } | |
| 744 | 767 | ctx = templ.ClearChildren(ctx) | |
| 745 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "<footer class=\"foot\"><span>ermite ") | |
| 768 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<footer class=\"foot\"><span>ermite ") | |
| 746 | 769 | if templ_7745c5c3_Err != nil { | |
| 747 | 770 | return templ_7745c5c3_Err | |
| 748 | 771 | } | |
| 749 | - | var templ_7745c5c3_Var38 string | |
| 750 | - | templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) | |
| 772 | + | var templ_7745c5c3_Var39 string | |
| 773 | + | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(l.Version) | |
| 751 | 774 | if templ_7745c5c3_Err != nil { | |
| 752 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 172, Col: 26} | |
| 775 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 175, Col: 26} | |
| 753 | 776 | } | |
| 754 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) | |
| 777 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 755 | 778 | if templ_7745c5c3_Err != nil { | |
| 756 | 779 | return templ_7745c5c3_Err | |
| 757 | 780 | } | |
| 758 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</span> <a href=\"") | |
| 781 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</span> <a href=\"") | |
| 759 | 782 | if templ_7745c5c3_Err != nil { | |
| 760 | 783 | return templ_7745c5c3_Err | |
| 761 | 784 | } | |
| 762 | - | var templ_7745c5c3_Var39 templ.SafeURL | |
| 763 | - | templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) | |
| 785 | + | var templ_7745c5c3_Var40 templ.SafeURL | |
| 786 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(l.SourceURL)) | |
| 764 | 787 | if templ_7745c5c3_Err != nil { | |
| 765 | - | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 173, Col: 38} | |
| 788 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/shell.templ`, Line: 176, Col: 38} | |
| 766 | 789 | } | |
| 767 | - | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) | |
| 790 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 768 | 791 | if templ_7745c5c3_Err != nil { | |
| 769 | 792 | return templ_7745c5c3_Err | |
| 770 | 793 | } | |
| 771 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>") | |
| 794 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" rel=\"noopener\" title=\"source of this version (AGPL-3.0-or-later)\">source</a> <button type=\"button\" class=\"foot-keys\" data-js data-fire=\"help\" aria-haspopup=\"dialog\" aria-controls=\"panel-help\" aria-keyshortcuts=\"?\"><kbd aria-hidden=\"true\">?</kbd> keyboard shortcuts</button></footer>") | |
| 772 | 795 | if templ_7745c5c3_Err != nil { | |
| 773 | 796 | return templ_7745c5c3_Err | |
| 774 | 797 | } | |
| @@ -796,12 +819,12 @@ func Hint(l Layout) templ.Component { | |||
| 796 | 819 | }() | |
| 797 | 820 | } | |
| 798 | 821 | ctx = templ.InitializeContext(ctx) | |
| 799 | - | templ_7745c5c3_Var40 := templ.GetChildren(ctx) | |
| 800 | - | if templ_7745c5c3_Var40 == nil { | |
| 801 | - | templ_7745c5c3_Var40 = templ.NopComponent | |
| 822 | + | templ_7745c5c3_Var41 := templ.GetChildren(ctx) | |
| 823 | + | if templ_7745c5c3_Var41 == nil { | |
| 824 | + | templ_7745c5c3_Var41 = templ.NopComponent | |
| 802 | 825 | } | |
| 803 | 826 | ctx = templ.ClearChildren(ctx) | |
| 804 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") | |
| 827 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div class=\"hint\" role=\"status\" aria-live=\"polite\"><span class=\"hint-nav\" :text=\"document.body.dataset.nav || ''\"></span> <span class=\"hint-sel\" :text=\"document.body.dataset.sel ? document.body.dataset.sel + ' · y permalink' : ''\"></span> <span class=\"hint-flash\" :text=\"document.body.dataset.flash || ''\"></span>") | |
| 805 | 828 | if templ_7745c5c3_Err != nil { | |
| 806 | 829 | return templ_7745c5c3_Err | |
| 807 | 830 | } | |
| @@ -809,7 +832,7 @@ func Hint(l Layout) templ.Component { | |||
| 809 | 832 | if templ_7745c5c3_Err != nil { | |
| 810 | 833 | return templ_7745c5c3_Err | |
| 811 | 834 | } | |
| 812 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</div>") | |
| 835 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</div>") | |
| 813 | 836 | if templ_7745c5c3_Err != nil { | |
| 814 | 837 | return templ_7745c5c3_Err | |
| 815 | 838 | } | |
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 | ||