Merge branch 'feat/mr-view-ux-panels' into feat/mr-view-ux
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)) | ||