feat(web): move MR commits and checks into a tabbed card footer
Commits and checks leave the rail for a collapsed footer of the main card, with ARIA tabs and live checks. Merge settings and the signing note move to the More menu, and the merge menu drops the head and target rows.
- commit
- 42e48d01ef1c2af5adec8f291cf56a83fde8bc3e
- author
- brice · · 2026-10-20 05:08 UTC
- parent
- cc7ba64
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +884 -655
M docs/merge-requests.md +14 -0
| @@ -104,3 +104,17 @@ report whether more exist; ancestry, rather than the bounded list, detects a | |||
| 104 | 104 | rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits` | |
| 105 | 105 | (100) commits. These history queries remain on the merge-request service | |
| 106 | 106 | because it owns the merge-git port. | |
| 107 | + | ||
| 108 | + | ### Review page panels | |
| 109 | + | ||
| 110 | + | The main request card ends with a collapsed Commits / Checks footer. With | |
| 111 | + | scripts, arrow keys (and Home / End) switch its tabs; without scripts, opening | |
| 112 | + | the footer shows both sections. The checks summary above the diff opens the | |
| 113 | + | Checks tab and continues to update live. Commit totals use `N+` when the branch | |
| 114 | + | list is capped: the reader's existing count operation counts all reachable | |
| 115 | + | ancestors, not the source-minus-target range. | |
| 116 | + | ||
| 117 | + | The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings | |
| 118 | + | are read-only information in the More (⋯) menu, available to every viewer. | |
| 119 | + | Signing-key information is also there when a merge box offers signed modes; | |
| 120 | + | merge actions remain restricted to their existing permissions. | |
| @@ -104,3 +104,17 @@ report whether more exist; ancestry, rather than the bounded list, detects a | |||||
| 104 | rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits` | 104 | rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits` | ||
| 105 | (100) commits. These history queries remain on the merge-request service | 105 | (100) commits. These history queries remain on the merge-request service | ||
| 106 | because it owns the merge-git port. | 106 | because it owns the merge-git port. | ||
| 107 | + | ||||
| 108 | + | ### Review page panels | |||
| 109 | + | ||||
| 110 | + | The main request card ends with a collapsed Commits / Checks footer. With | |||
| 111 | + | scripts, arrow keys (and Home / End) switch its tabs; without scripts, opening | |||
| 112 | + | the footer shows both sections. The checks summary above the diff opens the | |||
| 113 | + | Checks tab and continues to update live. Commit totals use `N+` when the branch | |||
| 114 | + | list is capped: the reader's existing count operation counts all reachable | |||
| 115 | + | ancestors, not the source-minus-target range. | |||
| 116 | + | ||||
| 117 | + | The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings | |||
| 118 | + | are read-only information in the More (⋯) menu, available to every viewer. | |||
| 119 | + | Signing-key information is also there when a merge box offers signed modes; | |||
| 120 | + | merge actions remain restricted to their existing permissions. | |||
M internal/adapters/web/actions.templ +2 -2
| @@ -434,7 +434,7 @@ templ CommitChecks(c ChecksSummary) { | |||
| 434 | 434 | } | |
| 435 | 435 | ||
| 436 | 436 | // MRChecksSum is the state of the checks of a merge request in the bar above | |
| 437 | - | // the diff, a link to the checks of the rail. The event stream replaces it | |
| 437 | + | // the diff, a link to the checks of the card footer. The event stream replaces it | |
| 438 | 438 | // with the checks section (same event, fragments.go). | |
| 439 | 439 | templ MRChecksSum(c ChecksSummary) { | |
| 440 | 440 | <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }> | |
| @@ -447,7 +447,7 @@ templ MRChecksSum(c ChecksSummary) { | |||
| 447 | 447 | </a> | |
| 448 | 448 | } | |
| 449 | 449 | ||
| 450 | - | // MRChecks is the checks section of the rail of a merge request. | |
| 450 | + | // MRChecks is the checks section of the footer of a merge request. | |
| 451 | 451 | templ MRChecks(c ChecksSection) { | |
| 452 | 452 | <section aria-labelledby="r-checks" id="mr-checks"> | |
| 453 | 453 | <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2> | |
| @@ -434,7 +434,7 @@ templ CommitChecks(c ChecksSummary) { | |||||
| 434 | } | 434 | } | ||
| 435 | 435 | ||||
| 436 | // MRChecksSum is the state of the checks of a merge request in the bar above | 436 | // MRChecksSum is the state of the checks of a merge request in the bar above | ||
| 437 | - | // the diff, a link to the checks of the rail. The event stream replaces it | 437 | + | // the diff, a link to the checks of the card footer. The event stream replaces it |
| 438 | // with the checks section (same event, fragments.go). | 438 | // with the checks section (same event, fragments.go). | ||
| 439 | templ MRChecksSum(c ChecksSummary) { | 439 | templ MRChecksSum(c ChecksSummary) { | ||
| 440 | <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }> | 440 | <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }> | ||
| @@ -447,7 +447,7 @@ templ MRChecksSum(c ChecksSummary) { | |||||
| 447 | </a> | 447 | </a> | ||
| 448 | } | 448 | } | ||
| 449 | 449 | ||||
| 450 | - | // MRChecks is the checks section of the rail of a merge request. | 450 | + | // MRChecks is the checks section of the footer of a merge request. |
| 451 | templ MRChecks(c ChecksSection) { | 451 | templ MRChecks(c ChecksSection) { | ||
| 452 | <section aria-labelledby="r-checks" id="mr-checks"> | 452 | <section aria-labelledby="r-checks" id="mr-checks"> | ||
| 453 | <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2> | 453 | <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2> | ||
M internal/adapters/web/actions_templ.go +2 -2 generated file: collapsed
M internal/adapters/web/checks.go +1 -1
| @@ -81,7 +81,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C | |||
| 81 | 81 | return sum | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | - | // checksSection is the checks of the rail of a merge request, with the rule of | |
| 84 | + | // checksSection is the checks of the footer of a merge request, with the rule of | |
| 85 | 85 | // the repository that applies to them. | |
| 86 | 86 | func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection { | |
| 87 | 87 | sec := ChecksSection{} | |
| @@ -81,7 +81,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C | |||||
| 81 | return sum | 81 | return sum | ||
| 82 | } | 82 | } | ||
| 83 | 83 | ||||
| 84 | - | // checksSection is the checks of the rail of a merge request, with the rule of | 84 | + | // checksSection is the checks of the footer of a merge request, with the rule of |
| 85 | // the repository that applies to them. | 85 | // the repository that applies to them. | ||
| 86 | func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection { | 86 | func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection { | ||
| 87 | sec := ChecksSection{} | 87 | sec := ChecksSection{} | ||
M internal/adapters/web/jstest/mr.test.mjs +43 -0
| @@ -92,3 +92,46 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the | |||
| 92 | 92 | globalThis.requestAnimationFrame=savedRAF; | |
| 93 | 93 | } | |
| 94 | 94 | }); | |
| 95 | + | ||
| 96 | + | test('footer tabs switch panels, support arrows, and open on checks hash', () => { | |
| 97 | + | const oldDocument = globalThis.document; | |
| 98 | + | const oldLocation = globalThis.location; | |
| 99 | + | const oldAdd = globalThis.addEventListener; | |
| 100 | + | const listeners = {}; | |
| 101 | + | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |
| 102 | + | setAttribute(k,v) { this.attrs[k]=v; }, | |
| 103 | + | addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } }); | |
| 104 | + | const tabs = [node('mr-tab-commits'), node('mr-tab-checks')]; | |
| 105 | + | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |
| 106 | + | const footer = node('mr-panels'); footer.open=false; | |
| 107 | + | const nav = node('tabs'); nav.querySelectorAll=()=>tabs; | |
| 108 | + | footer.querySelectorAll=()=>panels; | |
| 109 | + | globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null, | |
| 110 | + | addEventListener(k,v) {listeners[k]=v;} }; | |
| 111 | + | globalThis.location = {hash:''}; | |
| 112 | + | globalThis.addEventListener = (k,v) => {listeners[k]=v;}; | |
| 113 | + | try { | |
| 114 | + | window.ermiteMR.panels(); | |
| 115 | + | assert.equal(footer.open,false); | |
| 116 | + | assert.equal(panels[0].hidden,false); | |
| 117 | + | assert.equal(panels[1].hidden,true); | |
| 118 | + | tabs[1].events.click({preventDefault(){}}); | |
| 119 | + | assert.equal(panels[0].hidden,true); | |
| 120 | + | assert.equal(tabs[1].attrs['aria-selected'],'true'); | |
| 121 | + | tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}}); | |
| 122 | + | assert.equal(tabs[0].focused,true); | |
| 123 | + | assert.equal(panels[0].hidden,false); | |
| 124 | + | location.hash='#mr-checks'; listeners.hashchange(); | |
| 125 | + | assert.equal(footer.open,true); | |
| 126 | + | assert.equal(panels[1].hidden,false); | |
| 127 | + | footer.open=false; | |
| 128 | + | listeners.click({target:{closest:()=>true}}); | |
| 129 | + | assert.equal(footer.open,true); | |
| 130 | + | assert.equal(C.tabIndex('Home',1,2),0); | |
| 131 | + | assert.equal(C.tabIndex('End',0,2),1); | |
| 132 | + | assert.equal(C.tabIndex('ArrowLeft',0,2),1); | |
| 133 | + | assert.equal(C.tabIndex('Escape',0,2),null); | |
| 134 | + | } finally { | |
| 135 | + | globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd; | |
| 136 | + | } | |
| 137 | + | }); | |
| @@ -92,3 +92,46 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the | |||||
| 92 | globalThis.requestAnimationFrame=savedRAF; | 92 | globalThis.requestAnimationFrame=savedRAF; | ||
| 93 | } | 93 | } | ||
| 94 | }); | 94 | }); | ||
| 95 | + | ||||
| 96 | + | test('footer tabs switch panels, support arrows, and open on checks hash', () => { | |||
| 97 | + | const oldDocument = globalThis.document; | |||
| 98 | + | const oldLocation = globalThis.location; | |||
| 99 | + | const oldAdd = globalThis.addEventListener; | |||
| 100 | + | const listeners = {}; | |||
| 101 | + | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |||
| 102 | + | setAttribute(k,v) { this.attrs[k]=v; }, | |||
| 103 | + | addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } }); | |||
| 104 | + | const tabs = [node('mr-tab-commits'), node('mr-tab-checks')]; | |||
| 105 | + | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |||
| 106 | + | const footer = node('mr-panels'); footer.open=false; | |||
| 107 | + | const nav = node('tabs'); nav.querySelectorAll=()=>tabs; | |||
| 108 | + | footer.querySelectorAll=()=>panels; | |||
| 109 | + | globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null, | |||
| 110 | + | addEventListener(k,v) {listeners[k]=v;} }; | |||
| 111 | + | globalThis.location = {hash:''}; | |||
| 112 | + | globalThis.addEventListener = (k,v) => {listeners[k]=v;}; | |||
| 113 | + | try { | |||
| 114 | + | window.ermiteMR.panels(); | |||
| 115 | + | assert.equal(footer.open,false); | |||
| 116 | + | assert.equal(panels[0].hidden,false); | |||
| 117 | + | assert.equal(panels[1].hidden,true); | |||
| 118 | + | tabs[1].events.click({preventDefault(){}}); | |||
| 119 | + | assert.equal(panels[0].hidden,true); | |||
| 120 | + | assert.equal(tabs[1].attrs['aria-selected'],'true'); | |||
| 121 | + | tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}}); | |||
| 122 | + | assert.equal(tabs[0].focused,true); | |||
| 123 | + | assert.equal(panels[0].hidden,false); | |||
| 124 | + | location.hash='#mr-checks'; listeners.hashchange(); | |||
| 125 | + | assert.equal(footer.open,true); | |||
| 126 | + | assert.equal(panels[1].hidden,false); | |||
| 127 | + | footer.open=false; | |||
| 128 | + | listeners.click({target:{closest:()=>true}}); | |||
| 129 | + | assert.equal(footer.open,true); | |||
| 130 | + | assert.equal(C.tabIndex('Home',1,2),0); | |||
| 131 | + | assert.equal(C.tabIndex('End',0,2),1); | |||
| 132 | + | assert.equal(C.tabIndex('ArrowLeft',0,2),1); | |||
| 133 | + | assert.equal(C.tabIndex('Escape',0,2),null); | |||
| 134 | + | } finally { | |||
| 135 | + | globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd; | |||
| 136 | + | } | |||
| 137 | + | }); | |||
M internal/adapters/web/mr.go +3 -0
| @@ -291,6 +291,7 @@ type MRPage struct { | |||
| 291 | 291 | ||
| 292 | 292 | Commits []MRCommit | |
| 293 | 293 | CommitsNote string | |
| 294 | + | CommitCount string | |
| 294 | 295 | Modes []string | |
| 295 | 296 | ChecksBlock bool | |
| 296 | 297 | // Checks are the CI checks of the head of the request, ChecksSum their | |
| @@ -734,7 +735,9 @@ func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.M | |||
| 734 | 735 | }) | |
| 735 | 736 | } | |
| 736 | 737 | lastRead := res.LastReadID.String() | |
| 738 | + | p.CommitCount = itoa(len(p.Commits)) | |
| 737 | 739 | if res.CommitsTruncated { | |
| 740 | + | p.CommitCount += "+" | |
| 738 | 741 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | |
| 739 | 742 | } | |
| 740 | 743 | ||
| @@ -291,6 +291,7 @@ type MRPage struct { | |||||
| 291 | 291 | ||||
| 292 | Commits []MRCommit | 292 | Commits []MRCommit | ||
| 293 | CommitsNote string | 293 | CommitsNote string | ||
| 294 | + | CommitCount string | |||
| 294 | Modes []string | 295 | Modes []string | ||
| 295 | ChecksBlock bool | 296 | ChecksBlock bool | ||
| 296 | // Checks are the CI checks of the head of the request, ChecksSum their | 297 | // Checks are the CI checks of the head of the request, ChecksSum their | ||
| @@ -734,7 +735,9 @@ func (s *Server) mrReadPresentation(p *MRPage, name string, n int64, res ports.M | |||||
| 734 | }) | 735 | }) | ||
| 735 | } | 736 | } | ||
| 736 | lastRead := res.LastReadID.String() | 737 | lastRead := res.LastReadID.String() | ||
| 738 | + | p.CommitCount = itoa(len(p.Commits)) | |||
| 737 | if res.CommitsTruncated { | 739 | if res.CommitsTruncated { | ||
| 740 | + | p.CommitCount += "+" | |||
| 738 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | 741 | p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed." | ||
| 739 | } | 742 | } | ||
| 740 | 743 | ||||
M internal/adapters/web/mr.templ +105 -82
| @@ -147,6 +147,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 147 | 147 | @templ.Raw(string(p.Desc)) | |
| 148 | 148 | </article> | |
| 149 | 149 | } | |
| 150 | + | @mrFooter(p) | |
| 150 | 151 | </section> | |
| 151 | 152 | if p.Deleting != "" { | |
| 152 | 153 | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | |
| @@ -366,6 +367,65 @@ templ commentForm(f CommentForm, suffix string) { | |||
| 366 | 367 | </form> | |
| 367 | 368 | } | |
| 368 | 369 | ||
| 370 | + | templ mrFooter(p MRPage) { | |
| 371 | + | <footer class="mr-footer"> | |
| 372 | + | <details id="mr-panels"> | |
| 373 | + | <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary> | |
| 374 | + | <nav data-mr-tabs aria-label="Branch details"> | |
| 375 | + | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> | |
| 376 | + | <a id="mr-tab-checks" href="#mr-checks"> | |
| 377 | + | { "Checks " } | |
| 378 | + | <span data-checks-label> | |
| 379 | + | if p.ChecksSum.Total > 0 { | |
| 380 | + | { p.ChecksSum.Title } | |
| 381 | + | } else { | |
| 382 | + | no checks | |
| 383 | + | } | |
| 384 | + | </span> | |
| 385 | + | </a> | |
| 386 | + | </nav> | |
| 387 | + | <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits"> | |
| 388 | + | <table class="mr-commits"> | |
| 389 | + | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | |
| 390 | + | <tbody> | |
| 391 | + | for _, c := range p.Commits { | |
| 392 | + | <tr data-new?={ c.New }> | |
| 393 | + | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> | |
| 394 | + | <td>{ c.Subject }</td> | |
| 395 | + | <td>{ c.Author }</td> | |
| 396 | + | <td> | |
| 397 | + | if c.WhenAt != "" { | |
| 398 | + | <time datetime={ c.WhenAt }>{ c.When }</time> | |
| 399 | + | } | |
| 400 | + | </td> | |
| 401 | + | <td> | |
| 402 | + | if c.Sig != nil { | |
| 403 | + | @signaturePill(c.Sig) | |
| 404 | + | } | |
| 405 | + | </td> | |
| 406 | + | <td> | |
| 407 | + | if c.New { | |
| 408 | + | <span>new</span> | |
| 409 | + | } | |
| 410 | + | </td> | |
| 411 | + | </tr> | |
| 412 | + | } | |
| 413 | + | </tbody> | |
| 414 | + | </table> | |
| 415 | + | if len(p.Commits) == 0 { | |
| 416 | + | <p class="dim">no commit to merge.</p> | |
| 417 | + | } | |
| 418 | + | if p.CommitsNote != "" { | |
| 419 | + | <p class="note">{ p.CommitsNote }</p> | |
| 420 | + | } | |
| 421 | + | </div> | |
| 422 | + | <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks"> | |
| 423 | + | @MRChecks(p.Checks) | |
| 424 | + | </div> | |
| 425 | + | </details> | |
| 426 | + | </footer> | |
| 427 | + | } | |
| 428 | + | ||
| 369 | 429 | templ mrRail(p MRPage) { | |
| 370 | 430 | <section aria-labelledby="r-files" class="r-files"> | |
| 371 | 431 | <h2 id="r-files"> | |
| @@ -391,51 +451,6 @@ templ mrRail(p MRPage) { | |||
| 391 | 451 | </ul> | |
| 392 | 452 | </section> | |
| 393 | 453 | } | |
| 394 | - | @MRChecks(p.Checks) | |
| 395 | - | <section aria-labelledby="r-commits"> | |
| 396 | - | <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2> | |
| 397 | - | <ul class="pcommits"> | |
| 398 | - | for _, c := range p.Commits { | |
| 399 | - | <li data-new?={ c.New }> | |
| 400 | - | <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a> | |
| 401 | - | <span class="s">{ c.Subject }</span> | |
| 402 | - | if c.Sig != nil { | |
| 403 | - | @signaturePill(c.Sig) | |
| 404 | - | } | |
| 405 | - | <span class="mute"> | |
| 406 | - | { c.Author } | |
| 407 | - | if c.WhenAt != "" { | |
| 408 | - | { " · " }<time datetime={ c.WhenAt }>{ c.When }</time> | |
| 409 | - | } | |
| 410 | - | if c.New { | |
| 411 | - | { " · new" } | |
| 412 | - | } | |
| 413 | - | </span> | |
| 414 | - | </li> | |
| 415 | - | } | |
| 416 | - | </ul> | |
| 417 | - | if len(p.Commits) == 0 { | |
| 418 | - | <p class="dim">no commit to merge.</p> | |
| 419 | - | } | |
| 420 | - | if p.CommitsNote != "" { | |
| 421 | - | <p class="note">{ p.CommitsNote }</p> | |
| 422 | - | } | |
| 423 | - | </section> | |
| 424 | - | <section aria-labelledby="r-settings"> | |
| 425 | - | <h2 id="r-settings">Settings</h2> | |
| 426 | - | <dl class="kv"> | |
| 427 | - | <dt>modes</dt> | |
| 428 | - | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |
| 429 | - | <dt>checks</dt> | |
| 430 | - | <dd> | |
| 431 | - | if p.ChecksBlock { | |
| 432 | - | failing ones block the merge | |
| 433 | - | } else { | |
| 434 | - | do not block the merge | |
| 435 | - | } | |
| 436 | - | </dd> | |
| 437 | - | </dl> | |
| 438 | - | </section> | |
| 439 | 454 | } | |
| 440 | 455 | ||
| 441 | 456 | // mrBar is the bar above the diff, which follows the review: the totals, the | |
| @@ -450,8 +465,8 @@ templ mrBar(p MRPage) { | |||
| 450 | 465 | @MRChecksSum(p.ChecksSum) | |
| 451 | 466 | if p.Box != nil { | |
| 452 | 467 | @mergeMenu(p) | |
| 453 | - | @moreMenu(p) | |
| 454 | 468 | } | |
| 469 | + | @moreMenu(p) | |
| 455 | 470 | </div> | |
| 456 | 471 | } | |
| 457 | 472 | ||
| @@ -474,18 +489,9 @@ templ mergeMenu(p MRPage) { | |||
| 474 | 489 | <kbd aria-hidden="true">M</kbd> | |
| 475 | 490 | </summary> | |
| 476 | 491 | <div class="menu-pop" role="group" aria-label="Merge"> | |
| 477 | - | <dl class="kv"> | |
| 478 | - | <dt>head</dt> | |
| 479 | - | <dd> | |
| 480 | - | if p.Box.HeadGone { | |
| 481 | - | gone: the source branch was deleted | |
| 482 | - | } else { | |
| 483 | - | { p.HeadShort } | |
| 484 | - | } | |
| 485 | - | </dd> | |
| 486 | - | <dt>into</dt> | |
| 487 | - | <dd>{ p.Target }</dd> | |
| 488 | - | </dl> | |
| 492 | + | if p.Box.HeadGone { | |
| 493 | + | <p class="dim">the source branch was deleted</p> | |
| 494 | + | } | |
| 489 | 495 | if p.Box.Error != "" { | |
| 490 | 496 | <p class="d-err" role="alert">{ p.Box.Error }</p> | |
| 491 | 497 | } | |
| @@ -519,15 +525,6 @@ templ mergeMenu(p MRPage) { | |||
| 519 | 525 | </label> | |
| 520 | 526 | } | |
| 521 | 527 | </fieldset> | |
| 522 | - | if p.Box.Signed { | |
| 523 | - | <p class="d-note" id="mr-signing"> | |
| 524 | - | squash and merge commits are signed with ermite's own key (generated on first start). | |
| 525 | - | if p.Box.Fingerprint != "" { | |
| 526 | - | fingerprint <code>{ p.Box.Fingerprint }</code> | |
| 527 | - | } | |
| 528 | - | <code>ermite signing-key show</code> prints it. | |
| 529 | - | </p> | |
| 530 | - | } | |
| 531 | 528 | if p.Box.CanDeleteBranch { | |
| 532 | 529 | <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> | |
| 533 | 530 | } | |
| @@ -589,26 +586,52 @@ templ MRMergeabilityFragment(v MergeabilityView) { | |||
| 589 | 586 | @mrMergeabilitySlot(v) | |
| 590 | 587 | } | |
| 591 | 588 | ||
| 592 | - | // moreMenu is what else the owner does with an open request: mark the head | |
| 593 | - | // read, close it, merge it from a terminal. | |
| 589 | + | // moreMenu shows read-only settings to every viewer, and keeps owner actions | |
| 590 | + | // behind the same merge-box permission as before. | |
| 594 | 591 | templ moreMenu(p MRPage) { | |
| 595 | 592 | <details class="menu omenu"> | |
| 596 | 593 | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | |
| 597 | 594 | <div class="menu-pop" role="group" aria-label="More actions"> | |
| 598 | - | if p.Box.CanMarkRead { | |
| 599 | - | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | |
| 600 | - | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 601 | - | <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> | |
| 595 | + | <section class="merge-settings"> | |
| 596 | + | <h2>Merge settings</h2> | |
| 597 | + | <dl class="kv"> | |
| 598 | + | <dt>modes</dt> | |
| 599 | + | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |
| 600 | + | <dt>checks</dt> | |
| 601 | + | <dd> | |
| 602 | + | if p.ChecksBlock { | |
| 603 | + | failing ones block the merge | |
| 604 | + | } else { | |
| 605 | + | do not block the merge | |
| 606 | + | } | |
| 607 | + | </dd> | |
| 608 | + | </dl> | |
| 609 | + | </section> | |
| 610 | + | if p.Box != nil { | |
| 611 | + | if p.Box.Signed { | |
| 612 | + | <p class="d-note" id="mr-signing"> | |
| 613 | + | squash and merge commits are signed with ermite's own key (generated on first start). | |
| 614 | + | if p.Box.Fingerprint != "" { | |
| 615 | + | fingerprint <code>{ p.Box.Fingerprint }</code> | |
| 616 | + | } | |
| 617 | + | <code>ermite signing-key show</code> prints it. | |
| 618 | + | </p> | |
| 619 | + | } | |
| 620 | + | if p.Box.CanMarkRead { | |
| 621 | + | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | |
| 622 | + | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 623 | + | <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> | |
| 624 | + | </form> | |
| 625 | + | } | |
| 626 | + | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | |
| 627 | + | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | |
| 602 | 628 | </form> | |
| 603 | - | } | |
| 604 | - | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | |
| 605 | - | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | |
| 606 | - | </form> | |
| 607 | - | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | |
| 608 | - | <details class="d-or"> | |
| 609 | - | <summary class="mute">or from a terminal</summary> | |
| 610 | - | @CloneBlock(p.Box.CLI) | |
| 611 | - | </details> | |
| 629 | + | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | |
| 630 | + | <details class="d-or"> | |
| 631 | + | <summary class="mute">or from a terminal</summary> | |
| 632 | + | @CloneBlock(p.Box.CLI) | |
| 633 | + | </details> | |
| 634 | + | } | |
| 612 | 635 | } | |
| 613 | 636 | </div> | |
| 614 | 637 | </details> | |
| @@ -147,6 +147,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 147 | @templ.Raw(string(p.Desc)) | 147 | @templ.Raw(string(p.Desc)) | ||
| 148 | </article> | 148 | </article> | ||
| 149 | } | 149 | } | ||
| 150 | + | @mrFooter(p) | |||
| 150 | </section> | 151 | </section> | ||
| 151 | if p.Deleting != "" { | 152 | if p.Deleting != "" { | ||
| 152 | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | 153 | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | ||
| @@ -366,6 +367,65 @@ templ commentForm(f CommentForm, suffix string) { | |||||
| 366 | </form> | 367 | </form> | ||
| 367 | } | 368 | } | ||
| 368 | 369 | ||||
| 370 | + | templ mrFooter(p MRPage) { | |||
| 371 | + | <footer class="mr-footer"> | |||
| 372 | + | <details id="mr-panels"> | |||
| 373 | + | <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary> | |||
| 374 | + | <nav data-mr-tabs aria-label="Branch details"> | |||
| 375 | + | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> | |||
| 376 | + | <a id="mr-tab-checks" href="#mr-checks"> | |||
| 377 | + | { "Checks " } | |||
| 378 | + | <span data-checks-label> | |||
| 379 | + | if p.ChecksSum.Total > 0 { | |||
| 380 | + | { p.ChecksSum.Title } | |||
| 381 | + | } else { | |||
| 382 | + | no checks | |||
| 383 | + | } | |||
| 384 | + | </span> | |||
| 385 | + | </a> | |||
| 386 | + | </nav> | |||
| 387 | + | <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits"> | |||
| 388 | + | <table class="mr-commits"> | |||
| 389 | + | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | |||
| 390 | + | <tbody> | |||
| 391 | + | for _, c := range p.Commits { | |||
| 392 | + | <tr data-new?={ c.New }> | |||
| 393 | + | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> | |||
| 394 | + | <td>{ c.Subject }</td> | |||
| 395 | + | <td>{ c.Author }</td> | |||
| 396 | + | <td> | |||
| 397 | + | if c.WhenAt != "" { | |||
| 398 | + | <time datetime={ c.WhenAt }>{ c.When }</time> | |||
| 399 | + | } | |||
| 400 | + | </td> | |||
| 401 | + | <td> | |||
| 402 | + | if c.Sig != nil { | |||
| 403 | + | @signaturePill(c.Sig) | |||
| 404 | + | } | |||
| 405 | + | </td> | |||
| 406 | + | <td> | |||
| 407 | + | if c.New { | |||
| 408 | + | <span>new</span> | |||
| 409 | + | } | |||
| 410 | + | </td> | |||
| 411 | + | </tr> | |||
| 412 | + | } | |||
| 413 | + | </tbody> | |||
| 414 | + | </table> | |||
| 415 | + | if len(p.Commits) == 0 { | |||
| 416 | + | <p class="dim">no commit to merge.</p> | |||
| 417 | + | } | |||
| 418 | + | if p.CommitsNote != "" { | |||
| 419 | + | <p class="note">{ p.CommitsNote }</p> | |||
| 420 | + | } | |||
| 421 | + | </div> | |||
| 422 | + | <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks"> | |||
| 423 | + | @MRChecks(p.Checks) | |||
| 424 | + | </div> | |||
| 425 | + | </details> | |||
| 426 | + | </footer> | |||
| 427 | + | } | |||
| 428 | + | ||||
| 369 | templ mrRail(p MRPage) { | 429 | templ mrRail(p MRPage) { | ||
| 370 | <section aria-labelledby="r-files" class="r-files"> | 430 | <section aria-labelledby="r-files" class="r-files"> | ||
| 371 | <h2 id="r-files"> | 431 | <h2 id="r-files"> | ||
| @@ -391,51 +451,6 @@ templ mrRail(p MRPage) { | |||||
| 391 | </ul> | 451 | </ul> | ||
| 392 | </section> | 452 | </section> | ||
| 393 | } | 453 | } | ||
| 394 | - | @MRChecks(p.Checks) | |||
| 395 | - | <section aria-labelledby="r-commits"> | |||
| 396 | - | <h2 id="r-commits">Commits <span class="count">{ itoa(len(p.Commits)) }</span></h2> | |||
| 397 | - | <ul class="pcommits"> | |||
| 398 | - | for _, c := range p.Commits { | |||
| 399 | - | <li data-new?={ c.New }> | |||
| 400 | - | <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a> | |||
| 401 | - | <span class="s">{ c.Subject }</span> | |||
| 402 | - | if c.Sig != nil { | |||
| 403 | - | @signaturePill(c.Sig) | |||
| 404 | - | } | |||
| 405 | - | <span class="mute"> | |||
| 406 | - | { c.Author } | |||
| 407 | - | if c.WhenAt != "" { | |||
| 408 | - | { " · " }<time datetime={ c.WhenAt }>{ c.When }</time> | |||
| 409 | - | } | |||
| 410 | - | if c.New { | |||
| 411 | - | { " · new" } | |||
| 412 | - | } | |||
| 413 | - | </span> | |||
| 414 | - | </li> | |||
| 415 | - | } | |||
| 416 | - | </ul> | |||
| 417 | - | if len(p.Commits) == 0 { | |||
| 418 | - | <p class="dim">no commit to merge.</p> | |||
| 419 | - | } | |||
| 420 | - | if p.CommitsNote != "" { | |||
| 421 | - | <p class="note">{ p.CommitsNote }</p> | |||
| 422 | - | } | |||
| 423 | - | </section> | |||
| 424 | - | <section aria-labelledby="r-settings"> | |||
| 425 | - | <h2 id="r-settings">Settings</h2> | |||
| 426 | - | <dl class="kv"> | |||
| 427 | - | <dt>modes</dt> | |||
| 428 | - | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |||
| 429 | - | <dt>checks</dt> | |||
| 430 | - | <dd> | |||
| 431 | - | if p.ChecksBlock { | |||
| 432 | - | failing ones block the merge | |||
| 433 | - | } else { | |||
| 434 | - | do not block the merge | |||
| 435 | - | } | |||
| 436 | - | </dd> | |||
| 437 | - | </dl> | |||
| 438 | - | </section> | |||
| 439 | } | 454 | } | ||
| 440 | 455 | ||||
| 441 | // mrBar is the bar above the diff, which follows the review: the totals, the | 456 | // mrBar is the bar above the diff, which follows the review: the totals, the | ||
| @@ -450,8 +465,8 @@ templ mrBar(p MRPage) { | |||||
| 450 | @MRChecksSum(p.ChecksSum) | 465 | @MRChecksSum(p.ChecksSum) | ||
| 451 | if p.Box != nil { | 466 | if p.Box != nil { | ||
| 452 | @mergeMenu(p) | 467 | @mergeMenu(p) | ||
| 453 | - | @moreMenu(p) | |||
| 454 | } | 468 | } | ||
| 469 | + | @moreMenu(p) | |||
| 455 | </div> | 470 | </div> | ||
| 456 | } | 471 | } | ||
| 457 | 472 | ||||
| @@ -474,18 +489,9 @@ templ mergeMenu(p MRPage) { | |||||
| 474 | <kbd aria-hidden="true">M</kbd> | 489 | <kbd aria-hidden="true">M</kbd> | ||
| 475 | </summary> | 490 | </summary> | ||
| 476 | <div class="menu-pop" role="group" aria-label="Merge"> | 491 | <div class="menu-pop" role="group" aria-label="Merge"> | ||
| 477 | - | <dl class="kv"> | 492 | + | if p.Box.HeadGone { |
| 478 | - | <dt>head</dt> | 493 | + | <p class="dim">the source branch was deleted</p> |
| 479 | - | <dd> | 494 | + | } |
| 480 | - | if p.Box.HeadGone { | |||
| 481 | - | gone: the source branch was deleted | |||
| 482 | - | } else { | |||
| 483 | - | { p.HeadShort } | |||
| 484 | - | } | |||
| 485 | - | </dd> | |||
| 486 | - | <dt>into</dt> | |||
| 487 | - | <dd>{ p.Target }</dd> | |||
| 488 | - | </dl> | |||
| 489 | if p.Box.Error != "" { | 495 | if p.Box.Error != "" { | ||
| 490 | <p class="d-err" role="alert">{ p.Box.Error }</p> | 496 | <p class="d-err" role="alert">{ p.Box.Error }</p> | ||
| 491 | } | 497 | } | ||
| @@ -519,15 +525,6 @@ templ mergeMenu(p MRPage) { | |||||
| 519 | </label> | 525 | </label> | ||
| 520 | } | 526 | } | ||
| 521 | </fieldset> | 527 | </fieldset> | ||
| 522 | - | if p.Box.Signed { | |||
| 523 | - | <p class="d-note" id="mr-signing"> | |||
| 524 | - | squash and merge commits are signed with ermite's own key (generated on first start). | |||
| 525 | - | if p.Box.Fingerprint != "" { | |||
| 526 | - | fingerprint <code>{ p.Box.Fingerprint }</code> | |||
| 527 | - | } | |||
| 528 | - | <code>ermite signing-key show</code> prints it. | |||
| 529 | - | </p> | |||
| 530 | - | } | |||
| 531 | if p.Box.CanDeleteBranch { | 528 | if p.Box.CanDeleteBranch { | ||
| 532 | <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> | 529 | <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> | ||
| 533 | } | 530 | } | ||
| @@ -589,26 +586,52 @@ templ MRMergeabilityFragment(v MergeabilityView) { | |||||
| 589 | @mrMergeabilitySlot(v) | 586 | @mrMergeabilitySlot(v) | ||
| 590 | } | 587 | } | ||
| 591 | 588 | ||||
| 592 | - | // moreMenu is what else the owner does with an open request: mark the head | 589 | + | // moreMenu shows read-only settings to every viewer, and keeps owner actions |
| 593 | - | // read, close it, merge it from a terminal. | 590 | + | // behind the same merge-box permission as before. |
| 594 | templ moreMenu(p MRPage) { | 591 | templ moreMenu(p MRPage) { | ||
| 595 | <details class="menu omenu"> | 592 | <details class="menu omenu"> | ||
| 596 | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | 593 | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | ||
| 597 | <div class="menu-pop" role="group" aria-label="More actions"> | 594 | <div class="menu-pop" role="group" aria-label="More actions"> | ||
| 598 | - | if p.Box.CanMarkRead { | 595 | + | <section class="merge-settings"> |
| 599 | - | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | 596 | + | <h2>Merge settings</h2> |
| 600 | - | <input type="hidden" name="head_id" value={ p.Box.Head }/> | 597 | + | <dl class="kv"> |
| 601 | - | <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> | 598 | + | <dt>modes</dt> |
| 599 | + | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |||
| 600 | + | <dt>checks</dt> | |||
| 601 | + | <dd> | |||
| 602 | + | if p.ChecksBlock { | |||
| 603 | + | failing ones block the merge | |||
| 604 | + | } else { | |||
| 605 | + | do not block the merge | |||
| 606 | + | } | |||
| 607 | + | </dd> | |||
| 608 | + | </dl> | |||
| 609 | + | </section> | |||
| 610 | + | if p.Box != nil { | |||
| 611 | + | if p.Box.Signed { | |||
| 612 | + | <p class="d-note" id="mr-signing"> | |||
| 613 | + | squash and merge commits are signed with ermite's own key (generated on first start). | |||
| 614 | + | if p.Box.Fingerprint != "" { | |||
| 615 | + | fingerprint <code>{ p.Box.Fingerprint }</code> | |||
| 616 | + | } | |||
| 617 | + | <code>ermite signing-key show</code> prints it. | |||
| 618 | + | </p> | |||
| 619 | + | } | |||
| 620 | + | if p.Box.CanMarkRead { | |||
| 621 | + | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | |||
| 622 | + | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |||
| 623 | + | <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> | |||
| 624 | + | </form> | |||
| 625 | + | } | |||
| 626 | + | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | |||
| 627 | + | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | |||
| 602 | </form> | 628 | </form> | ||
| 603 | - | } | 629 | + | if !p.Box.HeadGone && p.Box.ReadyAction == "" { |
| 604 | - | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | 630 | + | <details class="d-or"> |
| 605 | - | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | 631 | + | <summary class="mute">or from a terminal</summary> |
| 606 | - | </form> | 632 | + | @CloneBlock(p.Box.CLI) |
| 607 | - | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | 633 | + | </details> |
| 608 | - | <details class="d-or"> | 634 | + | } |
| 609 | - | <summary class="mute">or from a terminal</summary> | |||
| 610 | - | @CloneBlock(p.Box.CLI) | |||
| 611 | - | </details> | |||
| 612 | } | 635 | } | ||
| 613 | </div> | 636 | </div> | ||
| 614 | </details> | 637 | </details> | ||
M internal/adapters/web/mr_templ.go +607 -563 generated file: collapsed
M internal/adapters/web/mr_test.go +32 -0
| @@ -128,3 +128,35 @@ func TestMergeabilityLiveEvents(t *testing.T) { | |||
| 128 | 128 | require.NoError(t, err) | |
| 129 | 129 | assert.Empty(t, fs) | |
| 130 | 130 | } | |
| 131 | + | ||
| 132 | + | func TestMRPanelsAndMenus(t *testing.T) { | |
| 133 | + | p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true, | |
| 134 | + | ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}} | |
| 135 | + | footer := renderToString(t, mrFooter(p)) | |
| 136 | + | assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`) | |
| 137 | + | assert.Contains(t, footer, `data-mr-tabs`) | |
| 138 | + | assert.Contains(t, footer, `id="mr-commits"`) | |
| 139 | + | assert.Contains(t, footer, `id="mr-checks"`) | |
| 140 | + | assert.Contains(t, footer, `<table class="mr-commits">`) | |
| 141 | + | assert.Contains(t, footer, "500+") | |
| 142 | + | assert.Contains(t, footer, "more commits") | |
| 143 | + | assert.Contains(t, footer, "1/2 failed") | |
| 144 | + | rail := renderToString(t, mrRail(p)) | |
| 145 | + | assert.Contains(t, rail, "g f") | |
| 146 | + | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | |
| 147 | + | assert.NotContains(t, rail, text) | |
| 148 | + | } | |
| 149 | + | more := renderToString(t, moreMenu(p)) | |
| 150 | + | for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} { | |
| 151 | + | assert.Contains(t, more, text) | |
| 152 | + | } | |
| 153 | + | merge := renderToString(t, mergeMenu(p)) | |
| 154 | + | assert.NotContains(t, merge, `<dl`) | |
| 155 | + | assert.NotContains(t, merge, `mr-signing`) | |
| 156 | + | assert.Contains(t, merge, "the source branch was deleted") | |
| 157 | + | bar := renderToString(t, mrBar(MRPage{})) | |
| 158 | + | assert.Contains(t, bar, `id="mr-ck"`) | |
| 159 | + | assert.Contains(t, bar, `href="#mr-checks"`) | |
| 160 | + | assert.Contains(t, bar, "Merge settings") | |
| 161 | + | assert.NotContains(t, bar, ">Close</button>") | |
| 162 | + | } | |
| @@ -128,3 +128,35 @@ func TestMergeabilityLiveEvents(t *testing.T) { | |||||
| 128 | require.NoError(t, err) | 128 | require.NoError(t, err) | ||
| 129 | assert.Empty(t, fs) | 129 | assert.Empty(t, fs) | ||
| 130 | } | 130 | } | ||
| 131 | + | ||||
| 132 | + | func TestMRPanelsAndMenus(t *testing.T) { | |||
| 133 | + | p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true, | |||
| 134 | + | ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}} | |||
| 135 | + | footer := renderToString(t, mrFooter(p)) | |||
| 136 | + | assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`) | |||
| 137 | + | assert.Contains(t, footer, `data-mr-tabs`) | |||
| 138 | + | assert.Contains(t, footer, `id="mr-commits"`) | |||
| 139 | + | assert.Contains(t, footer, `id="mr-checks"`) | |||
| 140 | + | assert.Contains(t, footer, `<table class="mr-commits">`) | |||
| 141 | + | assert.Contains(t, footer, "500+") | |||
| 142 | + | assert.Contains(t, footer, "more commits") | |||
| 143 | + | assert.Contains(t, footer, "1/2 failed") | |||
| 144 | + | rail := renderToString(t, mrRail(p)) | |||
| 145 | + | assert.Contains(t, rail, "g f") | |||
| 146 | + | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | |||
| 147 | + | assert.NotContains(t, rail, text) | |||
| 148 | + | } | |||
| 149 | + | more := renderToString(t, moreMenu(p)) | |||
| 150 | + | for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} { | |||
| 151 | + | assert.Contains(t, more, text) | |||
| 152 | + | } | |||
| 153 | + | merge := renderToString(t, mergeMenu(p)) | |||
| 154 | + | assert.NotContains(t, merge, `<dl`) | |||
| 155 | + | assert.NotContains(t, merge, `mr-signing`) | |||
| 156 | + | assert.Contains(t, merge, "the source branch was deleted") | |||
| 157 | + | bar := renderToString(t, mrBar(MRPage{})) | |||
| 158 | + | assert.Contains(t, bar, `id="mr-ck"`) | |||
| 159 | + | assert.Contains(t, bar, `href="#mr-checks"`) | |||
| 160 | + | assert.Contains(t, bar, "Merge settings") | |||
| 161 | + | assert.NotContains(t, bar, ">Close</button>") | |||
| 162 | + | } | |||
M internal/adapters/web/signature_test.go +1 -1
| @@ -145,7 +145,7 @@ func TestCommitPageShowsTheSignature(t *testing.T) { | |||
| 145 | 145 | func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) { | |
| 146 | 146 | t.Parallel() | |
| 147 | 147 | signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false) | |
| 148 | - | got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{ | |
| 148 | + | got := renderToString(t, mrFooter(MRPage{Commits: []MRCommit{ | |
| 149 | 149 | {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed}, | |
| 150 | 150 | {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"}, | |
| 151 | 151 | }})) | |
| @@ -145,7 +145,7 @@ func TestCommitPageShowsTheSignature(t *testing.T) { | |||||
| 145 | func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) { | 145 | func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) { | ||
| 146 | t.Parallel() | 146 | t.Parallel() | ||
| 147 | signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false) | 147 | signed := signatureBadge(verification(domain.FormatGPG, domain.StatusVerified, gpgFP, ""), false) | ||
| 148 | - | got := renderToString(t, mrRail(MRPage{Commits: []MRCommit{ | 148 | + | got := renderToString(t, mrFooter(MRPage{Commits: []MRCommit{ |
| 149 | {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed}, | 149 | {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed}, | ||
| 150 | {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"}, | 150 | {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"}, | ||
| 151 | }})) | 151 | }})) | ||
M internal/adapters/web/static/css/60-mr.css +19 -2
| @@ -8,6 +8,23 @@ | |||
| 8 | 8 | & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | + | .mr-footer { | |
| 12 | + | border-top: 1px solid var(--border); padding: var(--s3) var(--s5); | |
| 13 | + | font-size: var(--fs-s); | |
| 14 | + | & summary { cursor: pointer; color: var(--ink-dim); } | |
| 15 | + | & nav { display: flex; gap: var(--s3); padding-block: var(--s3); } | |
| 16 | + | & [role="tab"] { padding: var(--s1) var(--s2); border-radius: var(--r-s); } | |
| 17 | + | & [aria-selected="true"] { background: var(--bg-raised); color: var(--ink-hi); } | |
| 18 | + | & [data-mr-panel] { overflow-x: auto; } | |
| 19 | + | & [hidden] { display: none; } | |
| 20 | + | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | |
| 21 | + | & table { width: 100%; border-collapse: collapse; } | |
| 22 | + | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | |
| 23 | + | & th { color: var(--ink-dim); font-weight: 500; } | |
| 24 | + | } | |
| 25 | + | .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); } | |
| 26 | + | .merge-settings h2 { font-size: var(--fs-s); } | |
| 27 | + | ||
| 11 | 28 | .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); } | |
| 12 | 29 | .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); } | |
| 13 | 30 | .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); } | |
| @@ -112,11 +129,11 @@ | |||
| 112 | 129 | & .d-or > summary:hover { color: var(--ink-hi); } | |
| 113 | 130 | & .d-or[open] > .clone { margin-top: var(--s2); } | |
| 114 | 131 | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | |
| 132 | + | & #mr-signing code { overflow-wrap: anywhere; } | |
| 115 | 133 | } | |
| 116 | 134 | ||
| 117 | 135 | /* the rail of a merge request follows the review: it sticks under the | |
| 118 | - | header, one screen high, and scrolls on its own to its checks, commits | |
| 119 | - | and settings. The list of files keeps its own scroll, under a height, so | |
| 136 | + | header, one screen high, and scrolls on its own to its verdicts. The list of files keeps its own scroll, under a height, so | |
| 120 | 137 | that the blocks below it stay in reach. Only on a two-column layout. */ | |
| 121 | 138 | @media (min-width: 1081px) { | |
| 122 | 139 | .rail:has(.r-files) { | |
| @@ -8,6 +8,23 @@ | |||||
| 8 | & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | 8 | & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| 11 | + | .mr-footer { | |||
| 12 | + | border-top: 1px solid var(--border); padding: var(--s3) var(--s5); | |||
| 13 | + | font-size: var(--fs-s); | |||
| 14 | + | & summary { cursor: pointer; color: var(--ink-dim); } | |||
| 15 | + | & nav { display: flex; gap: var(--s3); padding-block: var(--s3); } | |||
| 16 | + | & [role="tab"] { padding: var(--s1) var(--s2); border-radius: var(--r-s); } | |||
| 17 | + | & [aria-selected="true"] { background: var(--bg-raised); color: var(--ink-hi); } | |||
| 18 | + | & [data-mr-panel] { overflow-x: auto; } | |||
| 19 | + | & [hidden] { display: none; } | |||
| 20 | + | & section h2 { font-size: var(--fs-s); color: var(--ink-dim); } | |||
| 21 | + | & table { width: 100%; border-collapse: collapse; } | |||
| 22 | + | & th, & td { text-align: left; padding: var(--s2); border-bottom: 1px solid var(--border); } | |||
| 23 | + | & th { color: var(--ink-dim); font-weight: 500; } | |||
| 24 | + | } | |||
| 25 | + | .merge-settings { font-size: var(--fs-s); color: var(--ink-dim); } | |||
| 26 | + | .merge-settings h2 { font-size: var(--fs-s); } | |||
| 27 | + | ||||
| 11 | .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); } | 28 | .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); } | ||
| 12 | .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); } | 29 | .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); } | ||
| 13 | .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); } | 30 | .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); } | ||
| @@ -112,11 +129,11 @@ | |||||
| 112 | & .d-or > summary:hover { color: var(--ink-hi); } | 129 | & .d-or > summary:hover { color: var(--ink-hi); } | ||
| 113 | & .d-or[open] > .clone { margin-top: var(--s2); } | 130 | & .d-or[open] > .clone { margin-top: var(--s2); } | ||
| 114 | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | 131 | & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; } | ||
| 132 | + | & #mr-signing code { overflow-wrap: anywhere; } | |||
| 115 | } | 133 | } | ||
| 116 | 134 | ||||
| 117 | /* the rail of a merge request follows the review: it sticks under the | 135 | /* the rail of a merge request follows the review: it sticks under the | ||
| 118 | - | header, one screen high, and scrolls on its own to its checks, commits | 136 | + | header, one screen high, and scrolls on its own to its verdicts. The list of files keeps its own scroll, under a height, so |
| 119 | - | and settings. The list of files keeps its own scroll, under a height, so | |||
| 120 | that the blocks below it stay in reach. Only on a two-column layout. */ | 137 | that the blocks below it stay in reach. Only on a two-column layout. */ | ||
| 121 | @media (min-width: 1081px) { | 138 | @media (min-width: 1081px) { | ||
| 122 | .rail:has(.r-files) { | 139 | .rail:has(.r-files) { | ||
M internal/adapters/web/static/js/mr.js +54 -1
| @@ -49,6 +49,13 @@ window.ermiteMR = (() => { | |||
| 49 | 49 | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | |
| 50 | 50 | return null; | |
| 51 | 51 | }, | |
| 52 | + | tabIndex(key, index, count) { | |
| 53 | + | if (key === 'Home') return 0; | |
| 54 | + | if (key === 'End') return count - 1; | |
| 55 | + | if (key === 'ArrowRight') return (index + 1) % count; | |
| 56 | + | if (key === 'ArrowLeft') return (index + count - 1) % count; | |
| 57 | + | return null; | |
| 58 | + | }, | |
| 52 | 59 | // step moves an index within [0, n). | |
| 53 | 60 | step(i, d, n) { | |
| 54 | 61 | if (n === 0) return -1; | |
| @@ -285,8 +292,54 @@ window.ermiteMR = (() => { | |||
| 285 | 292 | reveal(id); | |
| 286 | 293 | } | |
| 287 | 294 | ||
| 295 | + | // Enhance the ordinary anchor navigation only when scripts are available. | |
| 296 | + | function panels() { | |
| 297 | + | const footer = $('#mr-panels'), nav = $('[data-mr-tabs]'); | |
| 298 | + | if (!footer || !nav) return; | |
| 299 | + | const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer); | |
| 300 | + | nav.setAttribute('role', 'tablist'); | |
| 301 | + | function select(index, focus = false) { | |
| 302 | + | tabs.forEach((tab, i) => { | |
| 303 | + | tab.setAttribute('role', 'tab'); | |
| 304 | + | tab.setAttribute('aria-controls', panels[i].id); | |
| 305 | + | tab.setAttribute('aria-selected', String(i === index)); | |
| 306 | + | tab.tabIndex = i === index ? 0 : -1; | |
| 307 | + | panels[i].setAttribute('role', 'tabpanel'); | |
| 308 | + | panels[i].tabIndex = 0; | |
| 309 | + | panels[i].hidden = i !== index; | |
| 310 | + | }); | |
| 311 | + | if (focus) tabs[index].focus(); | |
| 312 | + | } | |
| 313 | + | tabs.forEach((tab, i) => { | |
| 314 | + | tab.addEventListener('click', (e) => { e.preventDefault(); select(i); }); | |
| 315 | + | tab.addEventListener('keydown', (e) => { | |
| 316 | + | const next = core.tabIndex(e.key, i, tabs.length); | |
| 317 | + | if (next === null) return; | |
| 318 | + | e.preventDefault(); select(next, true); | |
| 319 | + | }); | |
| 320 | + | }); | |
| 321 | + | function hash() { | |
| 322 | + | const index = location.hash === '#mr-checks' ? 1 : location.hash === '#mr-commits' ? 0 : -1; | |
| 323 | + | if (index < 0) return; | |
| 324 | + | footer.open = true; select(index); | |
| 325 | + | } | |
| 326 | + | // A repeated click on the same hash must also reopen a folded footer. | |
| 327 | + | document.addEventListener('click', (e) => { | |
| 328 | + | if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); } | |
| 329 | + | }); | |
| 330 | + | const summary = $('#mr-ck'); | |
| 331 | + | if (summary) { | |
| 332 | + | const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; }; | |
| 333 | + | new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true }); | |
| 334 | + | sync(); | |
| 335 | + | } | |
| 336 | + | select(0); hash(); | |
| 337 | + | addEventListener('hashchange', hash); | |
| 338 | + | } | |
| 339 | + | ||
| 288 | 340 | function start() { | |
| 289 | 341 | if (!$('#diffs[data-mr]')) return; | |
| 342 | + | panels(); | |
| 290 | 343 | let folded = false; | |
| 291 | 344 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | |
| 292 | 345 | if (folded) foldFiles(true); | |
| @@ -316,7 +369,7 @@ window.ermiteMR = (() => { | |||
| 316 | 369 | }); | |
| 317 | 370 | } | |
| 318 | 371 | ||
| 319 | - | return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | |
| 372 | + | return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | |
| 320 | 373 | })(); | |
| 321 | 374 | ||
| 322 | 375 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | |
| @@ -49,6 +49,13 @@ window.ermiteMR = (() => { | |||||
| 49 | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | 49 | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | ||
| 50 | return null; | 50 | return null; | ||
| 51 | }, | 51 | }, | ||
| 52 | + | tabIndex(key, index, count) { | |||
| 53 | + | if (key === 'Home') return 0; | |||
| 54 | + | if (key === 'End') return count - 1; | |||
| 55 | + | if (key === 'ArrowRight') return (index + 1) % count; | |||
| 56 | + | if (key === 'ArrowLeft') return (index + count - 1) % count; | |||
| 57 | + | return null; | |||
| 58 | + | }, | |||
| 52 | // step moves an index within [0, n). | 59 | // step moves an index within [0, n). | ||
| 53 | step(i, d, n) { | 60 | step(i, d, n) { | ||
| 54 | if (n === 0) return -1; | 61 | if (n === 0) return -1; | ||
| @@ -285,8 +292,54 @@ window.ermiteMR = (() => { | |||||
| 285 | reveal(id); | 292 | reveal(id); | ||
| 286 | } | 293 | } | ||
| 287 | 294 | ||||
| 295 | + | // Enhance the ordinary anchor navigation only when scripts are available. | |||
| 296 | + | function panels() { | |||
| 297 | + | const footer = $('#mr-panels'), nav = $('[data-mr-tabs]'); | |||
| 298 | + | if (!footer || !nav) return; | |||
| 299 | + | const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer); | |||
| 300 | + | nav.setAttribute('role', 'tablist'); | |||
| 301 | + | function select(index, focus = false) { | |||
| 302 | + | tabs.forEach((tab, i) => { | |||
| 303 | + | tab.setAttribute('role', 'tab'); | |||
| 304 | + | tab.setAttribute('aria-controls', panels[i].id); | |||
| 305 | + | tab.setAttribute('aria-selected', String(i === index)); | |||
| 306 | + | tab.tabIndex = i === index ? 0 : -1; | |||
| 307 | + | panels[i].setAttribute('role', 'tabpanel'); | |||
| 308 | + | panels[i].tabIndex = 0; | |||
| 309 | + | panels[i].hidden = i !== index; | |||
| 310 | + | }); | |||
| 311 | + | if (focus) tabs[index].focus(); | |||
| 312 | + | } | |||
| 313 | + | tabs.forEach((tab, i) => { | |||
| 314 | + | tab.addEventListener('click', (e) => { e.preventDefault(); select(i); }); | |||
| 315 | + | tab.addEventListener('keydown', (e) => { | |||
| 316 | + | const next = core.tabIndex(e.key, i, tabs.length); | |||
| 317 | + | if (next === null) return; | |||
| 318 | + | e.preventDefault(); select(next, true); | |||
| 319 | + | }); | |||
| 320 | + | }); | |||
| 321 | + | function hash() { | |||
| 322 | + | const index = location.hash === '#mr-checks' ? 1 : location.hash === '#mr-commits' ? 0 : -1; | |||
| 323 | + | if (index < 0) return; | |||
| 324 | + | footer.open = true; select(index); | |||
| 325 | + | } | |||
| 326 | + | // A repeated click on the same hash must also reopen a folded footer. | |||
| 327 | + | document.addEventListener('click', (e) => { | |||
| 328 | + | if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); } | |||
| 329 | + | }); | |||
| 330 | + | const summary = $('#mr-ck'); | |||
| 331 | + | if (summary) { | |||
| 332 | + | const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; }; | |||
| 333 | + | new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true }); | |||
| 334 | + | sync(); | |||
| 335 | + | } | |||
| 336 | + | select(0); hash(); | |||
| 337 | + | addEventListener('hashchange', hash); | |||
| 338 | + | } | |||
| 339 | + | ||||
| 288 | function start() { | 340 | function start() { | ||
| 289 | if (!$('#diffs[data-mr]')) return; | 341 | if (!$('#diffs[data-mr]')) return; | ||
| 342 | + | panels(); | |||
| 290 | let folded = false; | 343 | let folded = false; | ||
| 291 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | 344 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | ||
| 292 | if (folded) foldFiles(true); | 345 | if (folded) foldFiles(true); | ||
| @@ -316,7 +369,7 @@ window.ermiteMR = (() => { | |||||
| 316 | }); | 369 | }); | ||
| 317 | } | 370 | } | ||
| 318 | 371 | ||||
| 319 | - | return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | 372 | + | return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; |
| 320 | })(); | 373 | })(); | ||
| 321 | 374 | ||||
| 322 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | 375 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | ||
M internal/app/web_mr_test.go +1 -1
| @@ -335,7 +335,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||
| 335 | 335 | all := b.get(base, as(token)) | |
| 336 | 336 | assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`) | |
| 337 | 337 | assert.Contains(t, all.body, "since your last read") | |
| 338 | - | assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked") | |
| 338 | + | assert.Contains(t, all.body, "<tr data-new>", "the commit pushed since is marked") | |
| 339 | 339 | assert.Contains(t, all.body, "data-read>Mark read") | |
| 340 | 340 | assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head") | |
| 341 | 341 | since := b.get(base+"/since", as(token)) | |
| @@ -335,7 +335,7 @@ func TestWebMergeRequestReviewAndFastForward(t *testing.T) { | |||||
| 335 | all := b.get(base, as(token)) | 335 | all := b.get(base, as(token)) | ||
| 336 | assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`) | 336 | assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`) | ||
| 337 | assert.Contains(t, all.body, "since your last read") | 337 | assert.Contains(t, all.body, "since your last read") | ||
| 338 | - | assert.Contains(t, all.body, "<li data-new>", "the commit pushed since is marked") | 338 | + | assert.Contains(t, all.body, "<tr data-new>", "the commit pushed since is marked") |
| 339 | assert.Contains(t, all.body, "data-read>Mark read") | 339 | assert.Contains(t, all.body, "data-read>Mark read") | ||
| 340 | assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head") | 340 | assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head") | ||
| 341 | since := b.get(base+"/since", as(token)) | 341 | since := b.get(base+"/since", as(token)) | ||