Merge b46e8c113e6ca1da6e02aee22255c67f8729c736 into cc7ba64ee52412f41c3ce5207bac7e47b9f01f30
26 files · +1484 -886
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/diff.templ +6 -2
| @@ -117,7 +117,7 @@ templ diffViewMenu(p DiffView) { | |||
| 117 | 117 | <details class="menu vmenu"> | |
| 118 | 118 | <summary class="btn" aria-label="Diff view"> | |
| 119 | 119 | if p.Both { | |
| 120 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span> | |
| 120 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span> | |
| 121 | 121 | } else { | |
| 122 | 122 | { viewLabel(p.View) } | |
| 123 | 123 | } | |
| @@ -139,12 +139,16 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||
| 139 | 139 | class="btn vbtn" | |
| 140 | 140 | data-view={ view } | |
| 141 | 141 | aria-keyshortcuts={ key } | |
| 142 | + | if view == "split" { | |
| 143 | + | :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" | |
| 144 | + | :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" | |
| 145 | + | } | |
| 142 | 146 | if p.View == view { | |
| 143 | 147 | aria-current="true" | |
| 144 | 148 | } | |
| 145 | 149 | if p.Both { | |
| 146 | 150 | data-fire={ view } | |
| 147 | - | :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false" | |
| 151 | + | :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false" | |
| 148 | 152 | } | |
| 149 | 153 | >{ label }<kbd aria-hidden="true">{ key }</kbd></a> | |
| 150 | 154 | } | |
| @@ -117,7 +117,7 @@ templ diffViewMenu(p DiffView) { | |||||
| 117 | <details class="menu vmenu"> | 117 | <details class="menu vmenu"> | ||
| 118 | <summary class="btn" aria-label="Diff view"> | 118 | <summary class="btn" aria-label="Diff view"> | ||
| 119 | if p.Both { | 119 | if p.Both { | ||
| 120 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span> | 120 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">{ viewLabel(p.View) }</span> |
| 121 | } else { | 121 | } else { | ||
| 122 | { viewLabel(p.View) } | 122 | { viewLabel(p.View) } | ||
| 123 | } | 123 | } | ||
| @@ -139,12 +139,16 @@ templ viewLink(p DiffView, view, label, key, href string) { | |||||
| 139 | class="btn vbtn" | 139 | class="btn vbtn" | ||
| 140 | data-view={ view } | 140 | data-view={ view } | ||
| 141 | aria-keyshortcuts={ key } | 141 | aria-keyshortcuts={ key } | ||
| 142 | + | if view == "split" { | |||
| 143 | + | :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" | |||
| 144 | + | :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" | |||
| 145 | + | } | |||
| 142 | if p.View == view { | 146 | if p.View == view { | ||
| 143 | aria-current="true" | 147 | aria-current="true" | ||
| 144 | } | 148 | } | ||
| 145 | if p.Both { | 149 | if p.Both { | ||
| 146 | data-fire={ view } | 150 | data-fire={ view } | ||
| 147 | - | :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false" | 151 | + | :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false" |
| 148 | } | 152 | } | ||
| 149 | >{ label }<kbd aria-hidden="true">{ key }</kbd></a> | 153 | >{ label }<kbd aria-hidden="true">{ key }</kbd></a> | ||
| 150 | } | 154 | } | ||
M internal/adapters/web/diff_templ.go +190 -184 generated file: collapsed
M internal/adapters/web/jstest/a11y.test.mjs +23 -0
| @@ -358,3 +358,26 @@ test('activity moves a home row up only in newest activity order, preserving foc | |||
| 358 | 358 | assert.ok(fresh.hasAttribute('data-cursor')); | |
| 359 | 359 | } | |
| 360 | 360 | }); | |
| 361 | + | ||
| 362 | + | test('mobile MR layout shortcuts preserve the saved preference until back on desktop', () => { | |
| 363 | + | const diffs = el('div', { id: 'diffs', attrs: { 'data-mr': '', 'data-diff': 'split', 'data-mobile': 'true' } }); | |
| 364 | + | dom.body.replaceChildren(diffs); | |
| 365 | + | const oldQuery = doc.querySelector; | |
| 366 | + | doc.querySelector = query.one; | |
| 367 | + | const previous = globalThis.sessionStorage; | |
| 368 | + | const writes = []; | |
| 369 | + | globalThis.sessionStorage = { setItem: (...args) => writes.push(args) }; | |
| 370 | + | try { | |
| 371 | + | window.ermite.layout('unified'); | |
| 372 | + | window.ermite.layout('split'); | |
| 373 | + | assert.equal(diffs.dataset.diff, 'split'); | |
| 374 | + | assert.deepEqual(writes, []); | |
| 375 | + | diffs.dataset.mobile = 'false'; | |
| 376 | + | window.ermite.layout('unified'); | |
| 377 | + | assert.equal(diffs.dataset.diff, 'unified'); | |
| 378 | + | assert.deepEqual(writes, [['diff', 'unified']]); | |
| 379 | + | } finally { | |
| 380 | + | globalThis.sessionStorage = previous; | |
| 381 | + | doc.querySelector = oldQuery; | |
| 382 | + | } | |
| 383 | + | }); | |
| @@ -358,3 +358,26 @@ test('activity moves a home row up only in newest activity order, preserving foc | |||||
| 358 | assert.ok(fresh.hasAttribute('data-cursor')); | 358 | assert.ok(fresh.hasAttribute('data-cursor')); | ||
| 359 | } | 359 | } | ||
| 360 | }); | 360 | }); | ||
| 361 | + | ||||
| 362 | + | test('mobile MR layout shortcuts preserve the saved preference until back on desktop', () => { | |||
| 363 | + | const diffs = el('div', { id: 'diffs', attrs: { 'data-mr': '', 'data-diff': 'split', 'data-mobile': 'true' } }); | |||
| 364 | + | dom.body.replaceChildren(diffs); | |||
| 365 | + | const oldQuery = doc.querySelector; | |||
| 366 | + | doc.querySelector = query.one; | |||
| 367 | + | const previous = globalThis.sessionStorage; | |||
| 368 | + | const writes = []; | |||
| 369 | + | globalThis.sessionStorage = { setItem: (...args) => writes.push(args) }; | |||
| 370 | + | try { | |||
| 371 | + | window.ermite.layout('unified'); | |||
| 372 | + | window.ermite.layout('split'); | |||
| 373 | + | assert.equal(diffs.dataset.diff, 'split'); | |||
| 374 | + | assert.deepEqual(writes, []); | |||
| 375 | + | diffs.dataset.mobile = 'false'; | |||
| 376 | + | window.ermite.layout('unified'); | |||
| 377 | + | assert.equal(diffs.dataset.diff, 'unified'); | |||
| 378 | + | assert.deepEqual(writes, [['diff', 'unified']]); | |||
| 379 | + | } finally { | |||
| 380 | + | globalThis.sessionStorage = previous; | |||
| 381 | + | doc.querySelector = oldQuery; | |||
| 382 | + | } | |||
| 383 | + | }); | |||
M internal/adapters/web/jstest/mr.test.mjs +208 -0
| @@ -92,3 +92,211 @@ 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 oldHistory = globalThis.history; | |
| 101 | + | const listeners = {}; | |
| 102 | + | const replaced = []; | |
| 103 | + | globalThis.history = { replaceState: (...args) => replaced.push(args) }; | |
| 104 | + | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |
| 105 | + | setAttribute(k,v) { this.attrs[k]=v; }, | |
| 106 | + | addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } }); | |
| 107 | + | const tabs = [node('mr-tab-commits'), node('mr-tab-checks')]; | |
| 108 | + | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |
| 109 | + | const footer = node('mr-panels'); footer.open=false; | |
| 110 | + | const nav = node('tabs'); nav.querySelectorAll=()=>tabs; | |
| 111 | + | footer.querySelectorAll=()=>panels; | |
| 112 | + | globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null, | |
| 113 | + | addEventListener(k,v) {listeners[k]=v;} }; | |
| 114 | + | globalThis.location = {hash:''}; | |
| 115 | + | globalThis.addEventListener = (k,v) => {listeners[k]=v;}; | |
| 116 | + | try { | |
| 117 | + | window.ermiteMR.panels(); | |
| 118 | + | assert.equal(footer.open,false); | |
| 119 | + | assert.equal(panels[0].hidden,false); | |
| 120 | + | assert.equal(panels[1].hidden,true); | |
| 121 | + | assert.deepEqual(replaced, [], 'initial selection leaves the URL alone'); | |
| 122 | + | tabs[1].events.click({preventDefault(){}}); | |
| 123 | + | assert.deepEqual(replaced, [[null, '', '#mr-checks-panel']]); | |
| 124 | + | assert.equal(panels[0].hidden,true); | |
| 125 | + | assert.equal(tabs[1].attrs['aria-selected'],'true'); | |
| 126 | + | tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}}); | |
| 127 | + | assert.equal(tabs[0].focused,true); | |
| 128 | + | assert.equal(panels[0].hidden,false); | |
| 129 | + | assert.deepEqual(replaced.at(-1), [null, '', '#mr-commits']); | |
| 130 | + | location.hash='#mr-checks'; replaced.length = 0; listeners.hashchange(); | |
| 131 | + | assert.deepEqual(replaced, [], 'hash navigation does not rewrite the URL'); | |
| 132 | + | assert.equal(footer.open,true); | |
| 133 | + | assert.equal(panels[1].hidden,false); | |
| 134 | + | footer.open=false; | |
| 135 | + | listeners.click({target:{closest:()=>true}}); | |
| 136 | + | assert.equal(footer.open,true); | |
| 137 | + | assert.equal(C.tabIndex('Home',1,2),0); | |
| 138 | + | assert.equal(C.tabIndex('End',0,2),1); | |
| 139 | + | assert.equal(C.tabIndex('ArrowLeft',0,2),1); | |
| 140 | + | assert.equal(C.tabIndex('Escape',0,2),null); | |
| 141 | + | } finally { | |
| 142 | + | globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd; | |
| 143 | + | if (oldHistory === undefined) delete globalThis.history; else globalThis.history = oldHistory; | |
| 144 | + | } | |
| 145 | + | }); | |
| 146 | + | ||
| 147 | + | test('footer: #mr-ck syncs the checks label and a click opens the footer on Checks', () => { | |
| 148 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]])); | |
| 149 | + | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |
| 150 | + | setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, v) { this.events[k] = v; }, focus() {} }); | |
| 151 | + | const tabs = [node('t0'), node('t1')]; | |
| 152 | + | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |
| 153 | + | const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels; | |
| 154 | + | const label = { textContent: '' }; | |
| 155 | + | const nav = node('tabs'); nav.querySelectorAll = () => tabs; | |
| 156 | + | nav.querySelector = (s) => s === '[data-checks-label]' ? label : null; | |
| 157 | + | const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} }; | |
| 158 | + | const listeners = {}; | |
| 159 | + | let observed = null, callback = null; | |
| 160 | + | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | |
| 161 | + | globalThis.document = { querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? summary : null, | |
| 162 | + | addEventListener(k, v) { listeners[k] = v; } }; | |
| 163 | + | globalThis.location = { hash: '' }; | |
| 164 | + | globalThis.addEventListener = () => {}; | |
| 165 | + | globalThis.history = { replaceState() {} }; | |
| 166 | + | try { | |
| 167 | + | window.ermiteMR.panels(); | |
| 168 | + | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | |
| 169 | + | assert.equal(observed[0], summary.parentElement); | |
| 170 | + | summary.title = ''; callback(); | |
| 171 | + | assert.equal(label.textContent, '1/2', 'observer re-syncs from the text'); | |
| 172 | + | summary.textContent = ' '; callback(); | |
| 173 | + | assert.equal(label.textContent, 'no checks'); | |
| 174 | + | listeners.click({ target: { closest: (s) => s === '#mr-ck' ? summary : null } }); | |
| 175 | + | assert.equal(footer.open, true); | |
| 176 | + | assert.equal(panels[1].hidden, false); | |
| 177 | + | assert.equal(panels[0].hidden, true); | |
| 178 | + | assert.equal(tabs[1].attrs['aria-selected'], 'true'); | |
| 179 | + | } finally { | |
| 180 | + | for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } | |
| 181 | + | } | |
| 182 | + | }); | |
| 183 | + | ||
| 184 | + | test('touch range: first tap anchors, second tap opens the sorted range', () => { | |
| 185 | + | const base = 'https://forge.test/demo/merge-requests/1'; | |
| 186 | + | const first = base + '?on=new:24:a.go#f1'; | |
| 187 | + | const last = base + '?on=new:10:a.go#f1'; | |
| 188 | + | assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null }); | |
| 189 | + | const result = C.touchRange(first, last, base); | |
| 190 | + | assert.equal(result.start, null); | |
| 191 | + | assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go'); | |
| 192 | + | assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first }); | |
| 193 | + | }); | |
| 194 | + | ||
| 195 | + | test('touch range: a different file, side or request becomes a new anchor', () => { | |
| 196 | + | const base = 'https://forge.test/demo/merge-requests/1'; | |
| 197 | + | const first = base + '?on=new:24:a.go#f1'; | |
| 198 | + | for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) { | |
| 199 | + | assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null }); | |
| 200 | + | } | |
| 201 | + | }); | |
| 202 | + | ||
| 203 | + | test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => { | |
| 204 | + | const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26'; | |
| 205 | + | assert.equal(C.mobileURL(href, 'split', false), null); | |
| 206 | + | assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS'); | |
| 207 | + | assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop'); | |
| 208 | + | const url = new URL(C.mobileURL(href, 'split', true)); | |
| 209 | + | assert.equal(url.searchParams.get('view'), 'unified'); | |
| 210 | + | assert.equal(url.searchParams.get('from'), '25'); | |
| 211 | + | assert.equal(url.searchParams.get('on'), 'new:3:a.go'); | |
| 212 | + | assert.equal(url.hash, '#f26'); | |
| 213 | + | }); | |
| 214 | + | ||
| 215 | + | test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => { | |
| 216 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]])); | |
| 217 | + | const make = () => ({ | |
| 218 | + | attrs: {}, events: {}, children: [], textContent: '', | |
| 219 | + | setAttribute(k, v) { this.attrs[k] = v; }, | |
| 220 | + | removeAttribute(k) { delete this.attrs[k]; }, | |
| 221 | + | addEventListener(k, fn) { this.events[k] = fn; }, | |
| 222 | + | append(...nodes) { this.children.push(...nodes); }, | |
| 223 | + | prepend(node) { this.children.unshift(node); }, | |
| 224 | + | querySelector() { return null; }, querySelectorAll() { return []; }, | |
| 225 | + | }); | |
| 226 | + | const diffs = make(); | |
| 227 | + | diffs.dataset = { diff: 'split' }; | |
| 228 | + | const base = 'https://forge.test/demo/merge-requests/1'; | |
| 229 | + | const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) }); | |
| 230 | + | const a = line(20), b = line(10); | |
| 231 | + | let loaded = false; | |
| 232 | + | diffs.querySelector = () => loaded ? a : null; | |
| 233 | + | let assigned = null; | |
| 234 | + | globalThis.document = { | |
| 235 | + | querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null, | |
| 236 | + | querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(), | |
| 237 | + | }; | |
| 238 | + | globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } }; | |
| 239 | + | globalThis.addEventListener = () => {}; | |
| 240 | + | const changes = {}; | |
| 241 | + | const medias = {}; | |
| 242 | + | globalThis.matchMedia = (q) => medias[q] ??= { matches: true, addEventListener(type, fn) { changes[q] = fn; } }; | |
| 243 | + | const media = globalThis.matchMedia('(max-width: 720px)'); | |
| 244 | + | const touchQuery = '(pointer: coarse), (hover: none)'; | |
| 245 | + | const resize = () => changes['(max-width: 720px)'](); | |
| 246 | + | globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; }; | |
| 247 | + | globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null }; | |
| 248 | + | const click = (link, modifiers = {}) => { | |
| 249 | + | const e = { target: { closest: () => link }, prevented: false, stopped: false, | |
| 250 | + | preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers }; | |
| 251 | + | diffs.events.click(e); | |
| 252 | + | return e; | |
| 253 | + | }; | |
| 254 | + | try { | |
| 255 | + | window.ermiteMR.start(); | |
| 256 | + | const bar = diffs.children[0]; | |
| 257 | + | const [button, status] = bar.children; | |
| 258 | + | assert.equal(bar.hidden, true, 'no commentable links in the initial window'); | |
| 259 | + | assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load'); | |
| 260 | + | await window.ermiteMR.load({ dataset: { src: '/files?from=25' } }); | |
| 261 | + | assert.equal(bar.hidden, false, 'lazy window refresh reveals range control'); | |
| 262 | + | assert.equal(diffs.dataset.mobile, 'true'); | |
| 263 | + | assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained'); | |
| 264 | + | media.matches = false; | |
| 265 | + | resize(); | |
| 266 | + | assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout'); | |
| 267 | + | assert.equal(diffs.dataset.diff, 'split'); | |
| 268 | + | assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched'); | |
| 269 | + | button.events.click(); | |
| 270 | + | assert.equal(button.attrs['aria-pressed'], 'true'); | |
| 271 | + | assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched'); | |
| 272 | + | const event = click(a); | |
| 273 | + | assert.equal(event.prevented, true); | |
| 274 | + | assert.equal(event.stopped, true); | |
| 275 | + | assert.ok('data-range-start' in a.attrs); | |
| 276 | + | assert.match(status.textContent, /20/); | |
| 277 | + | click(b); | |
| 278 | + | assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go'); | |
| 279 | + | assert.equal('data-range-start' in a.attrs, false); | |
| 280 | + | click(a); | |
| 281 | + | button.events.click(); | |
| 282 | + | assert.equal(button.attrs['aria-pressed'], 'false'); | |
| 283 | + | assert.equal(status.textContent, ''); | |
| 284 | + | assert.equal('data-range-start' in a.attrs, false); | |
| 285 | + | assert.equal(click(b).prevented, false); | |
| 286 | + | // The touch media query stops matching: range mode is switched off. | |
| 287 | + | button.events.click(); | |
| 288 | + | click(a); | |
| 289 | + | assert.equal(button.attrs['aria-pressed'], 'true'); | |
| 290 | + | assert.ok('data-range-start' in a.attrs); | |
| 291 | + | medias[touchQuery].matches = false; | |
| 292 | + | changes[touchQuery](); | |
| 293 | + | assert.equal(button.attrs['aria-pressed'], 'false'); | |
| 294 | + | assert.equal(status.textContent, ''); | |
| 295 | + | assert.equal('data-range-start' in a.attrs, false); | |
| 296 | + | assert.equal(click(b).prevented, false, 'clicks pass through once touch stops matching'); | |
| 297 | + | } finally { | |
| 298 | + | for (const [k, v] of Object.entries(saved)) { | |
| 299 | + | if (v === undefined) delete globalThis[k]; else globalThis[k] = v; | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | }); | |
| @@ -92,3 +92,211 @@ 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 oldHistory = globalThis.history; | |||
| 101 | + | const listeners = {}; | |||
| 102 | + | const replaced = []; | |||
| 103 | + | globalThis.history = { replaceState: (...args) => replaced.push(args) }; | |||
| 104 | + | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |||
| 105 | + | setAttribute(k,v) { this.attrs[k]=v; }, | |||
| 106 | + | addEventListener(k,v) { this.events[k]=v; }, focus() { this.focused=true; } }); | |||
| 107 | + | const tabs = [node('mr-tab-commits'), node('mr-tab-checks')]; | |||
| 108 | + | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |||
| 109 | + | const footer = node('mr-panels'); footer.open=false; | |||
| 110 | + | const nav = node('tabs'); nav.querySelectorAll=()=>tabs; | |||
| 111 | + | footer.querySelectorAll=()=>panels; | |||
| 112 | + | globalThis.document = {querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : null, | |||
| 113 | + | addEventListener(k,v) {listeners[k]=v;} }; | |||
| 114 | + | globalThis.location = {hash:''}; | |||
| 115 | + | globalThis.addEventListener = (k,v) => {listeners[k]=v;}; | |||
| 116 | + | try { | |||
| 117 | + | window.ermiteMR.panels(); | |||
| 118 | + | assert.equal(footer.open,false); | |||
| 119 | + | assert.equal(panels[0].hidden,false); | |||
| 120 | + | assert.equal(panels[1].hidden,true); | |||
| 121 | + | assert.deepEqual(replaced, [], 'initial selection leaves the URL alone'); | |||
| 122 | + | tabs[1].events.click({preventDefault(){}}); | |||
| 123 | + | assert.deepEqual(replaced, [[null, '', '#mr-checks-panel']]); | |||
| 124 | + | assert.equal(panels[0].hidden,true); | |||
| 125 | + | assert.equal(tabs[1].attrs['aria-selected'],'true'); | |||
| 126 | + | tabs[1].events.keydown({key:'ArrowRight',preventDefault(){}}); | |||
| 127 | + | assert.equal(tabs[0].focused,true); | |||
| 128 | + | assert.equal(panels[0].hidden,false); | |||
| 129 | + | assert.deepEqual(replaced.at(-1), [null, '', '#mr-commits']); | |||
| 130 | + | location.hash='#mr-checks'; replaced.length = 0; listeners.hashchange(); | |||
| 131 | + | assert.deepEqual(replaced, [], 'hash navigation does not rewrite the URL'); | |||
| 132 | + | assert.equal(footer.open,true); | |||
| 133 | + | assert.equal(panels[1].hidden,false); | |||
| 134 | + | footer.open=false; | |||
| 135 | + | listeners.click({target:{closest:()=>true}}); | |||
| 136 | + | assert.equal(footer.open,true); | |||
| 137 | + | assert.equal(C.tabIndex('Home',1,2),0); | |||
| 138 | + | assert.equal(C.tabIndex('End',0,2),1); | |||
| 139 | + | assert.equal(C.tabIndex('ArrowLeft',0,2),1); | |||
| 140 | + | assert.equal(C.tabIndex('Escape',0,2),null); | |||
| 141 | + | } finally { | |||
| 142 | + | globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd; | |||
| 143 | + | if (oldHistory === undefined) delete globalThis.history; else globalThis.history = oldHistory; | |||
| 144 | + | } | |||
| 145 | + | }); | |||
| 146 | + | ||||
| 147 | + | test('footer: #mr-ck syncs the checks label and a click opens the footer on Checks', () => { | |||
| 148 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'history', 'MutationObserver'].map((k) => [k, globalThis[k]])); | |||
| 149 | + | const node = (id) => ({ id, attrs: {}, events: {}, hidden: false, | |||
| 150 | + | setAttribute(k, v) { this.attrs[k] = v; }, addEventListener(k, v) { this.events[k] = v; }, focus() {} }); | |||
| 151 | + | const tabs = [node('t0'), node('t1')]; | |||
| 152 | + | const panels = [node('mr-commits'), node('mr-checks-panel')]; | |||
| 153 | + | const footer = node('mr-panels'); footer.open = false; footer.querySelectorAll = () => panels; | |||
| 154 | + | const label = { textContent: '' }; | |||
| 155 | + | const nav = node('tabs'); nav.querySelectorAll = () => tabs; | |||
| 156 | + | nav.querySelector = (s) => s === '[data-checks-label]' ? label : null; | |||
| 157 | + | const summary = { title: '1/2 failed', textContent: ' 1/2 ', parentElement: {} }; | |||
| 158 | + | const listeners = {}; | |||
| 159 | + | let observed = null, callback = null; | |||
| 160 | + | globalThis.MutationObserver = class { constructor(fn) { callback = fn; } observe(target, opts) { observed = [target, opts]; } }; | |||
| 161 | + | globalThis.document = { querySelector: (s) => s === '#mr-panels' ? footer : s === '[data-mr-tabs]' ? nav : s === '#mr-ck' ? summary : null, | |||
| 162 | + | addEventListener(k, v) { listeners[k] = v; } }; | |||
| 163 | + | globalThis.location = { hash: '' }; | |||
| 164 | + | globalThis.addEventListener = () => {}; | |||
| 165 | + | globalThis.history = { replaceState() {} }; | |||
| 166 | + | try { | |||
| 167 | + | window.ermiteMR.panels(); | |||
| 168 | + | assert.equal(label.textContent, '1/2 failed', 'initial label sync'); | |||
| 169 | + | assert.equal(observed[0], summary.parentElement); | |||
| 170 | + | summary.title = ''; callback(); | |||
| 171 | + | assert.equal(label.textContent, '1/2', 'observer re-syncs from the text'); | |||
| 172 | + | summary.textContent = ' '; callback(); | |||
| 173 | + | assert.equal(label.textContent, 'no checks'); | |||
| 174 | + | listeners.click({ target: { closest: (s) => s === '#mr-ck' ? summary : null } }); | |||
| 175 | + | assert.equal(footer.open, true); | |||
| 176 | + | assert.equal(panels[1].hidden, false); | |||
| 177 | + | assert.equal(panels[0].hidden, true); | |||
| 178 | + | assert.equal(tabs[1].attrs['aria-selected'], 'true'); | |||
| 179 | + | } finally { | |||
| 180 | + | for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } | |||
| 181 | + | } | |||
| 182 | + | }); | |||
| 183 | + | ||||
| 184 | + | test('touch range: first tap anchors, second tap opens the sorted range', () => { | |||
| 185 | + | const base = 'https://forge.test/demo/merge-requests/1'; | |||
| 186 | + | const first = base + '?on=new:24:a.go#f1'; | |||
| 187 | + | const last = base + '?on=new:10:a.go#f1'; | |||
| 188 | + | assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null }); | |||
| 189 | + | const result = C.touchRange(first, last, base); | |||
| 190 | + | assert.equal(result.start, null); | |||
| 191 | + | assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go'); | |||
| 192 | + | assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first }); | |||
| 193 | + | }); | |||
| 194 | + | ||||
| 195 | + | test('touch range: a different file, side or request becomes a new anchor', () => { | |||
| 196 | + | const base = 'https://forge.test/demo/merge-requests/1'; | |||
| 197 | + | const first = base + '?on=new:24:a.go#f1'; | |||
| 198 | + | for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) { | |||
| 199 | + | assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null }); | |||
| 200 | + | } | |||
| 201 | + | }); | |||
| 202 | + | ||||
| 203 | + | test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => { | |||
| 204 | + | const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26'; | |||
| 205 | + | assert.equal(C.mobileURL(href, 'split', false), null); | |||
| 206 | + | assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS'); | |||
| 207 | + | assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop'); | |||
| 208 | + | const url = new URL(C.mobileURL(href, 'split', true)); | |||
| 209 | + | assert.equal(url.searchParams.get('view'), 'unified'); | |||
| 210 | + | assert.equal(url.searchParams.get('from'), '25'); | |||
| 211 | + | assert.equal(url.searchParams.get('on'), 'new:3:a.go'); | |||
| 212 | + | assert.equal(url.hash, '#f26'); | |||
| 213 | + | }); | |||
| 214 | + | ||||
| 215 | + | test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => { | |||
| 216 | + | const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]])); | |||
| 217 | + | const make = () => ({ | |||
| 218 | + | attrs: {}, events: {}, children: [], textContent: '', | |||
| 219 | + | setAttribute(k, v) { this.attrs[k] = v; }, | |||
| 220 | + | removeAttribute(k) { delete this.attrs[k]; }, | |||
| 221 | + | addEventListener(k, fn) { this.events[k] = fn; }, | |||
| 222 | + | append(...nodes) { this.children.push(...nodes); }, | |||
| 223 | + | prepend(node) { this.children.unshift(node); }, | |||
| 224 | + | querySelector() { return null; }, querySelectorAll() { return []; }, | |||
| 225 | + | }); | |||
| 226 | + | const diffs = make(); | |||
| 227 | + | diffs.dataset = { diff: 'split' }; | |||
| 228 | + | const base = 'https://forge.test/demo/merge-requests/1'; | |||
| 229 | + | const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) }); | |||
| 230 | + | const a = line(20), b = line(10); | |||
| 231 | + | let loaded = false; | |||
| 232 | + | diffs.querySelector = () => loaded ? a : null; | |||
| 233 | + | let assigned = null; | |||
| 234 | + | globalThis.document = { | |||
| 235 | + | querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null, | |||
| 236 | + | querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(), | |||
| 237 | + | }; | |||
| 238 | + | globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } }; | |||
| 239 | + | globalThis.addEventListener = () => {}; | |||
| 240 | + | const changes = {}; | |||
| 241 | + | const medias = {}; | |||
| 242 | + | globalThis.matchMedia = (q) => medias[q] ??= { matches: true, addEventListener(type, fn) { changes[q] = fn; } }; | |||
| 243 | + | const media = globalThis.matchMedia('(max-width: 720px)'); | |||
| 244 | + | const touchQuery = '(pointer: coarse), (hover: none)'; | |||
| 245 | + | const resize = () => changes['(max-width: 720px)'](); | |||
| 246 | + | globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; }; | |||
| 247 | + | globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null }; | |||
| 248 | + | const click = (link, modifiers = {}) => { | |||
| 249 | + | const e = { target: { closest: () => link }, prevented: false, stopped: false, | |||
| 250 | + | preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers }; | |||
| 251 | + | diffs.events.click(e); | |||
| 252 | + | return e; | |||
| 253 | + | }; | |||
| 254 | + | try { | |||
| 255 | + | window.ermiteMR.start(); | |||
| 256 | + | const bar = diffs.children[0]; | |||
| 257 | + | const [button, status] = bar.children; | |||
| 258 | + | assert.equal(bar.hidden, true, 'no commentable links in the initial window'); | |||
| 259 | + | assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load'); | |||
| 260 | + | await window.ermiteMR.load({ dataset: { src: '/files?from=25' } }); | |||
| 261 | + | assert.equal(bar.hidden, false, 'lazy window refresh reveals range control'); | |||
| 262 | + | assert.equal(diffs.dataset.mobile, 'true'); | |||
| 263 | + | assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained'); | |||
| 264 | + | media.matches = false; | |||
| 265 | + | resize(); | |||
| 266 | + | assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout'); | |||
| 267 | + | assert.equal(diffs.dataset.diff, 'split'); | |||
| 268 | + | assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched'); | |||
| 269 | + | button.events.click(); | |||
| 270 | + | assert.equal(button.attrs['aria-pressed'], 'true'); | |||
| 271 | + | assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched'); | |||
| 272 | + | const event = click(a); | |||
| 273 | + | assert.equal(event.prevented, true); | |||
| 274 | + | assert.equal(event.stopped, true); | |||
| 275 | + | assert.ok('data-range-start' in a.attrs); | |||
| 276 | + | assert.match(status.textContent, /20/); | |||
| 277 | + | click(b); | |||
| 278 | + | assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go'); | |||
| 279 | + | assert.equal('data-range-start' in a.attrs, false); | |||
| 280 | + | click(a); | |||
| 281 | + | button.events.click(); | |||
| 282 | + | assert.equal(button.attrs['aria-pressed'], 'false'); | |||
| 283 | + | assert.equal(status.textContent, ''); | |||
| 284 | + | assert.equal('data-range-start' in a.attrs, false); | |||
| 285 | + | assert.equal(click(b).prevented, false); | |||
| 286 | + | // The touch media query stops matching: range mode is switched off. | |||
| 287 | + | button.events.click(); | |||
| 288 | + | click(a); | |||
| 289 | + | assert.equal(button.attrs['aria-pressed'], 'true'); | |||
| 290 | + | assert.ok('data-range-start' in a.attrs); | |||
| 291 | + | medias[touchQuery].matches = false; | |||
| 292 | + | changes[touchQuery](); | |||
| 293 | + | assert.equal(button.attrs['aria-pressed'], 'false'); | |||
| 294 | + | assert.equal(status.textContent, ''); | |||
| 295 | + | assert.equal('data-range-start' in a.attrs, false); | |||
| 296 | + | assert.equal(click(b).prevented, false, 'clicks pass through once touch stops matching'); | |||
| 297 | + | } finally { | |||
| 298 | + | for (const [k, v] of Object.entries(saved)) { | |||
| 299 | + | if (v === undefined) delete globalThis[k]; else globalThis[k] = v; | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | }); | |||
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 +108 -84
| @@ -108,8 +108,9 @@ templ mrListRail(p MRListPage) { | |||
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | // MergeRequest is a merge request: its facts, its description, the diff with | |
| 111 | - | // the notes under their lines, the general comments; and in the rail the merge | |
| 112 | - | // form, the checks, the settings, the commits and the files. | |
| 111 | + | // the notes under their lines, the general comments. The merge and More menus | |
| 112 | + | // sit in the toolbar, the commits and checks in the card footer, and the rail | |
| 113 | + | // holds the files and verdicts. | |
| 113 | 114 | templ MergeRequest(l Layout, p MRPage) { | |
| 114 | 115 | @Shell(l, mrRail(p)) { | |
| 115 | 116 | <section class="mr-card"> | |
| @@ -147,6 +148,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 147 | 148 | @templ.Raw(string(p.Desc)) | |
| 148 | 149 | </article> | |
| 149 | 150 | } | |
| 151 | + | @mrFooter(p) | |
| 150 | 152 | </section> | |
| 151 | 153 | if p.Deleting != "" { | |
| 152 | 154 | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | |
| @@ -366,6 +368,65 @@ templ commentForm(f CommentForm, suffix string) { | |||
| 366 | 368 | </form> | |
| 367 | 369 | } | |
| 368 | 370 | ||
| 371 | + | templ mrFooter(p MRPage) { | |
| 372 | + | <footer class="mr-footer"> | |
| 373 | + | <details id="mr-panels"> | |
| 374 | + | <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary> | |
| 375 | + | <nav data-mr-tabs aria-label="Branch details"> | |
| 376 | + | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> | |
| 377 | + | <a id="mr-tab-checks" href="#mr-checks"> | |
| 378 | + | { "Checks " } | |
| 379 | + | <span data-checks-label> | |
| 380 | + | if p.ChecksSum.Total > 0 { | |
| 381 | + | { p.ChecksSum.Title } | |
| 382 | + | } else { | |
| 383 | + | no checks | |
| 384 | + | } | |
| 385 | + | </span> | |
| 386 | + | </a> | |
| 387 | + | </nav> | |
| 388 | + | <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits"> | |
| 389 | + | <table class="mr-commits"> | |
| 390 | + | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | |
| 391 | + | <tbody> | |
| 392 | + | for _, c := range p.Commits { | |
| 393 | + | <tr data-new?={ c.New }> | |
| 394 | + | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> | |
| 395 | + | <td>{ c.Subject }</td> | |
| 396 | + | <td>{ c.Author }</td> | |
| 397 | + | <td> | |
| 398 | + | if c.WhenAt != "" { | |
| 399 | + | <time datetime={ c.WhenAt }>{ c.When }</time> | |
| 400 | + | } | |
| 401 | + | </td> | |
| 402 | + | <td> | |
| 403 | + | if c.Sig != nil { | |
| 404 | + | @signaturePill(c.Sig) | |
| 405 | + | } | |
| 406 | + | </td> | |
| 407 | + | <td> | |
| 408 | + | if c.New { | |
| 409 | + | <span>new</span> | |
| 410 | + | } | |
| 411 | + | </td> | |
| 412 | + | </tr> | |
| 413 | + | } | |
| 414 | + | </tbody> | |
| 415 | + | </table> | |
| 416 | + | if len(p.Commits) == 0 { | |
| 417 | + | <p class="dim">no commit to merge.</p> | |
| 418 | + | } | |
| 419 | + | if p.CommitsNote != "" { | |
| 420 | + | <p class="note">{ p.CommitsNote }</p> | |
| 421 | + | } | |
| 422 | + | </div> | |
| 423 | + | <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks"> | |
| 424 | + | @MRChecks(p.Checks) | |
| 425 | + | </div> | |
| 426 | + | </details> | |
| 427 | + | </footer> | |
| 428 | + | } | |
| 429 | + | ||
| 369 | 430 | templ mrRail(p MRPage) { | |
| 370 | 431 | <section aria-labelledby="r-files" class="r-files"> | |
| 371 | 432 | <h2 id="r-files"> | |
| @@ -391,51 +452,6 @@ templ mrRail(p MRPage) { | |||
| 391 | 452 | </ul> | |
| 392 | 453 | </section> | |
| 393 | 454 | } | |
| 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 | 455 | } | |
| 440 | 456 | ||
| 441 | 457 | // mrBar is the bar above the diff, which follows the review: the totals, the | |
| @@ -450,8 +466,8 @@ templ mrBar(p MRPage) { | |||
| 450 | 466 | @MRChecksSum(p.ChecksSum) | |
| 451 | 467 | if p.Box != nil { | |
| 452 | 468 | @mergeMenu(p) | |
| 453 | - | @moreMenu(p) | |
| 454 | 469 | } | |
| 470 | + | @moreMenu(p) | |
| 455 | 471 | </div> | |
| 456 | 472 | } | |
| 457 | 473 | ||
| @@ -474,18 +490,9 @@ templ mergeMenu(p MRPage) { | |||
| 474 | 490 | <kbd aria-hidden="true">M</kbd> | |
| 475 | 491 | </summary> | |
| 476 | 492 | <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> | |
| 493 | + | if p.Box.HeadGone { | |
| 494 | + | <p class="dim">the source branch was deleted</p> | |
| 495 | + | } | |
| 489 | 496 | if p.Box.Error != "" { | |
| 490 | 497 | <p class="d-err" role="alert">{ p.Box.Error }</p> | |
| 491 | 498 | } | |
| @@ -519,15 +526,6 @@ templ mergeMenu(p MRPage) { | |||
| 519 | 526 | </label> | |
| 520 | 527 | } | |
| 521 | 528 | </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 | 529 | if p.Box.CanDeleteBranch { | |
| 532 | 530 | <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 | 531 | } | |
| @@ -589,26 +587,52 @@ templ MRMergeabilityFragment(v MergeabilityView) { | |||
| 589 | 587 | @mrMergeabilitySlot(v) | |
| 590 | 588 | } | |
| 591 | 589 | ||
| 592 | - | // moreMenu is what else the owner does with an open request: mark the head | |
| 593 | - | // read, close it, merge it from a terminal. | |
| 590 | + | // moreMenu shows read-only settings to every viewer, and keeps owner actions | |
| 591 | + | // behind the same merge-box permission as before. | |
| 594 | 592 | templ moreMenu(p MRPage) { | |
| 595 | 593 | <details class="menu omenu"> | |
| 596 | 594 | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | |
| 597 | 595 | <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> | |
| 596 | + | <section class="merge-settings"> | |
| 597 | + | <h2>Merge settings</h2> | |
| 598 | + | <dl class="kv"> | |
| 599 | + | <dt>modes</dt> | |
| 600 | + | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |
| 601 | + | <dt>checks</dt> | |
| 602 | + | <dd> | |
| 603 | + | if p.ChecksBlock { | |
| 604 | + | failing ones block the merge | |
| 605 | + | } else { | |
| 606 | + | do not block the merge | |
| 607 | + | } | |
| 608 | + | </dd> | |
| 609 | + | </dl> | |
| 610 | + | </section> | |
| 611 | + | if p.Box != nil { | |
| 612 | + | if p.Box.Signed { | |
| 613 | + | <p class="d-note" id="mr-signing"> | |
| 614 | + | squash and merge commits are signed with ermite's own key (generated on first start). | |
| 615 | + | if p.Box.Fingerprint != "" { | |
| 616 | + | fingerprint <code>{ p.Box.Fingerprint }</code> | |
| 617 | + | } | |
| 618 | + | <code>ermite signing-key show</code> prints it. | |
| 619 | + | </p> | |
| 620 | + | } | |
| 621 | + | if p.Box.CanMarkRead { | |
| 622 | + | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | |
| 623 | + | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |
| 624 | + | <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> | |
| 625 | + | </form> | |
| 626 | + | } | |
| 627 | + | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | |
| 628 | + | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | |
| 602 | 629 | </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> | |
| 630 | + | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | |
| 631 | + | <details class="d-or"> | |
| 632 | + | <summary class="mute">or from a terminal</summary> | |
| 633 | + | @CloneBlock(p.Box.CLI) | |
| 634 | + | </details> | |
| 635 | + | } | |
| 612 | 636 | } | |
| 613 | 637 | </div> | |
| 614 | 638 | </details> | |
| @@ -108,8 +108,9 @@ templ mrListRail(p MRListPage) { | |||||
| 108 | } | 108 | } | ||
| 109 | 109 | ||||
| 110 | // MergeRequest is a merge request: its facts, its description, the diff with | 110 | // MergeRequest is a merge request: its facts, its description, the diff with | ||
| 111 | - | // the notes under their lines, the general comments; and in the rail the merge | 111 | + | // the notes under their lines, the general comments. The merge and More menus |
| 112 | - | // form, the checks, the settings, the commits and the files. | 112 | + | // sit in the toolbar, the commits and checks in the card footer, and the rail |
| 113 | + | // holds the files and verdicts. | |||
| 113 | templ MergeRequest(l Layout, p MRPage) { | 114 | templ MergeRequest(l Layout, p MRPage) { | ||
| 114 | @Shell(l, mrRail(p)) { | 115 | @Shell(l, mrRail(p)) { | ||
| 115 | <section class="mr-card"> | 116 | <section class="mr-card"> | ||
| @@ -147,6 +148,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||||
| 147 | @templ.Raw(string(p.Desc)) | 148 | @templ.Raw(string(p.Desc)) | ||
| 148 | </article> | 149 | </article> | ||
| 149 | } | 150 | } | ||
| 151 | + | @mrFooter(p) | |||
| 150 | </section> | 152 | </section> | ||
| 151 | if p.Deleting != "" { | 153 | if p.Deleting != "" { | ||
| 152 | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | 154 | <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p> | ||
| @@ -366,6 +368,65 @@ templ commentForm(f CommentForm, suffix string) { | |||||
| 366 | </form> | 368 | </form> | ||
| 367 | } | 369 | } | ||
| 368 | 370 | ||||
| 371 | + | templ mrFooter(p MRPage) { | |||
| 372 | + | <footer class="mr-footer"> | |||
| 373 | + | <details id="mr-panels"> | |||
| 374 | + | <summary>Commits <span class="count">{ p.CommitCount }</span> · Checks</summary> | |||
| 375 | + | <nav data-mr-tabs aria-label="Branch details"> | |||
| 376 | + | <a id="mr-tab-commits" href="#mr-commits">Commits <span class="count">{ p.CommitCount }</span></a> | |||
| 377 | + | <a id="mr-tab-checks" href="#mr-checks"> | |||
| 378 | + | { "Checks " } | |||
| 379 | + | <span data-checks-label> | |||
| 380 | + | if p.ChecksSum.Total > 0 { | |||
| 381 | + | { p.ChecksSum.Title } | |||
| 382 | + | } else { | |||
| 383 | + | no checks | |||
| 384 | + | } | |||
| 385 | + | </span> | |||
| 386 | + | </a> | |||
| 387 | + | </nav> | |||
| 388 | + | <div id="mr-commits" data-mr-panel aria-labelledby="mr-tab-commits"> | |||
| 389 | + | <table class="mr-commits"> | |||
| 390 | + | <thead><tr><th>Commit</th><th>Subject</th><th>Author</th><th>Date</th><th>Signature</th><th>Status</th></tr></thead> | |||
| 391 | + | <tbody> | |||
| 392 | + | for _, c := range p.Commits { | |||
| 393 | + | <tr data-new?={ c.New }> | |||
| 394 | + | <td><a href={ templ.SafeURL(c.Href) }>{ c.Short }</a></td> | |||
| 395 | + | <td>{ c.Subject }</td> | |||
| 396 | + | <td>{ c.Author }</td> | |||
| 397 | + | <td> | |||
| 398 | + | if c.WhenAt != "" { | |||
| 399 | + | <time datetime={ c.WhenAt }>{ c.When }</time> | |||
| 400 | + | } | |||
| 401 | + | </td> | |||
| 402 | + | <td> | |||
| 403 | + | if c.Sig != nil { | |||
| 404 | + | @signaturePill(c.Sig) | |||
| 405 | + | } | |||
| 406 | + | </td> | |||
| 407 | + | <td> | |||
| 408 | + | if c.New { | |||
| 409 | + | <span>new</span> | |||
| 410 | + | } | |||
| 411 | + | </td> | |||
| 412 | + | </tr> | |||
| 413 | + | } | |||
| 414 | + | </tbody> | |||
| 415 | + | </table> | |||
| 416 | + | if len(p.Commits) == 0 { | |||
| 417 | + | <p class="dim">no commit to merge.</p> | |||
| 418 | + | } | |||
| 419 | + | if p.CommitsNote != "" { | |||
| 420 | + | <p class="note">{ p.CommitsNote }</p> | |||
| 421 | + | } | |||
| 422 | + | </div> | |||
| 423 | + | <div id="mr-checks-panel" data-mr-panel aria-labelledby="mr-tab-checks"> | |||
| 424 | + | @MRChecks(p.Checks) | |||
| 425 | + | </div> | |||
| 426 | + | </details> | |||
| 427 | + | </footer> | |||
| 428 | + | } | |||
| 429 | + | ||||
| 369 | templ mrRail(p MRPage) { | 430 | templ mrRail(p MRPage) { | ||
| 370 | <section aria-labelledby="r-files" class="r-files"> | 431 | <section aria-labelledby="r-files" class="r-files"> | ||
| 371 | <h2 id="r-files"> | 432 | <h2 id="r-files"> | ||
| @@ -391,51 +452,6 @@ templ mrRail(p MRPage) { | |||||
| 391 | </ul> | 452 | </ul> | ||
| 392 | </section> | 453 | </section> | ||
| 393 | } | 454 | } | ||
| 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 | } | 455 | } | ||
| 440 | 456 | ||||
| 441 | // mrBar is the bar above the diff, which follows the review: the totals, the | 457 | // mrBar is the bar above the diff, which follows the review: the totals, the | ||
| @@ -450,8 +466,8 @@ templ mrBar(p MRPage) { | |||||
| 450 | @MRChecksSum(p.ChecksSum) | 466 | @MRChecksSum(p.ChecksSum) | ||
| 451 | if p.Box != nil { | 467 | if p.Box != nil { | ||
| 452 | @mergeMenu(p) | 468 | @mergeMenu(p) | ||
| 453 | - | @moreMenu(p) | |||
| 454 | } | 469 | } | ||
| 470 | + | @moreMenu(p) | |||
| 455 | </div> | 471 | </div> | ||
| 456 | } | 472 | } | ||
| 457 | 473 | ||||
| @@ -474,18 +490,9 @@ templ mergeMenu(p MRPage) { | |||||
| 474 | <kbd aria-hidden="true">M</kbd> | 490 | <kbd aria-hidden="true">M</kbd> | ||
| 475 | </summary> | 491 | </summary> | ||
| 476 | <div class="menu-pop" role="group" aria-label="Merge"> | 492 | <div class="menu-pop" role="group" aria-label="Merge"> | ||
| 477 | - | <dl class="kv"> | 493 | + | if p.Box.HeadGone { |
| 478 | - | <dt>head</dt> | 494 | + | <p class="dim">the source branch was deleted</p> |
| 479 | - | <dd> | 495 | + | } |
| 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 != "" { | 496 | if p.Box.Error != "" { | ||
| 490 | <p class="d-err" role="alert">{ p.Box.Error }</p> | 497 | <p class="d-err" role="alert">{ p.Box.Error }</p> | ||
| 491 | } | 498 | } | ||
| @@ -519,15 +526,6 @@ templ mergeMenu(p MRPage) { | |||||
| 519 | </label> | 526 | </label> | ||
| 520 | } | 527 | } | ||
| 521 | </fieldset> | 528 | </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 { | 529 | 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> | 530 | <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 | } | 531 | } | ||
| @@ -589,26 +587,52 @@ templ MRMergeabilityFragment(v MergeabilityView) { | |||||
| 589 | @mrMergeabilitySlot(v) | 587 | @mrMergeabilitySlot(v) | ||
| 590 | } | 588 | } | ||
| 591 | 589 | ||||
| 592 | - | // moreMenu is what else the owner does with an open request: mark the head | 590 | + | // moreMenu shows read-only settings to every viewer, and keeps owner actions |
| 593 | - | // read, close it, merge it from a terminal. | 591 | + | // behind the same merge-box permission as before. |
| 594 | templ moreMenu(p MRPage) { | 592 | templ moreMenu(p MRPage) { | ||
| 595 | <details class="menu omenu"> | 593 | <details class="menu omenu"> | ||
| 596 | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | 594 | <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary> | ||
| 597 | <div class="menu-pop" role="group" aria-label="More actions"> | 595 | <div class="menu-pop" role="group" aria-label="More actions"> | ||
| 598 | - | if p.Box.CanMarkRead { | 596 | + | <section class="merge-settings"> |
| 599 | - | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | 597 | + | <h2>Merge settings</h2> |
| 600 | - | <input type="hidden" name="head_id" value={ p.Box.Head }/> | 598 | + | <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> | 599 | + | <dt>modes</dt> |
| 600 | + | <dd>{ strings.Join(p.Modes, ", ") }</dd> | |||
| 601 | + | <dt>checks</dt> | |||
| 602 | + | <dd> | |||
| 603 | + | if p.ChecksBlock { | |||
| 604 | + | failing ones block the merge | |||
| 605 | + | } else { | |||
| 606 | + | do not block the merge | |||
| 607 | + | } | |||
| 608 | + | </dd> | |||
| 609 | + | </dl> | |||
| 610 | + | </section> | |||
| 611 | + | if p.Box != nil { | |||
| 612 | + | if p.Box.Signed { | |||
| 613 | + | <p class="d-note" id="mr-signing"> | |||
| 614 | + | squash and merge commits are signed with ermite's own key (generated on first start). | |||
| 615 | + | if p.Box.Fingerprint != "" { | |||
| 616 | + | fingerprint <code>{ p.Box.Fingerprint }</code> | |||
| 617 | + | } | |||
| 618 | + | <code>ermite signing-key show</code> prints it. | |||
| 619 | + | </p> | |||
| 620 | + | } | |||
| 621 | + | if p.Box.CanMarkRead { | |||
| 622 | + | <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }> | |||
| 623 | + | <input type="hidden" name="head_id" value={ p.Box.Head }/> | |||
| 624 | + | <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> | |||
| 625 | + | </form> | |||
| 626 | + | } | |||
| 627 | + | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | |||
| 628 | + | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | |||
| 602 | </form> | 629 | </form> | ||
| 603 | - | } | 630 | + | if !p.Box.HeadGone && p.Box.ReadyAction == "" { |
| 604 | - | <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }> | 631 | + | <details class="d-or"> |
| 605 | - | <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button> | 632 | + | <summary class="mute">or from a terminal</summary> |
| 606 | - | </form> | 633 | + | @CloneBlock(p.Box.CLI) |
| 607 | - | if !p.Box.HeadGone && p.Box.ReadyAction == "" { | 634 | + | </details> |
| 608 | - | <details class="d-or"> | 635 | + | } |
| 609 | - | <summary class="mute">or from a terminal</summary> | |||
| 610 | - | @CloneBlock(p.Box.CLI) | |||
| 611 | - | </details> | |||
| 612 | } | 636 | } | ||
| 613 | </div> | 637 | </div> | ||
| 614 | </details> | 638 | </details> | ||
M internal/adapters/web/mr_templ.go +626 -581 generated file: collapsed
M internal/adapters/web/mr_test.go +57 -0
| @@ -4,6 +4,7 @@ package web | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | + | "time" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/stretchr/testify/assert" | |
| @@ -128,3 +129,59 @@ func TestMergeabilityLiveEvents(t *testing.T) { | |||
| 128 | 129 | require.NoError(t, err) | |
| 129 | 130 | assert.Empty(t, fs) | |
| 130 | 131 | } | |
| 132 | + | ||
| 133 | + | func TestMRPanelsAndMenus(t *testing.T) { | |
| 134 | + | p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true, | |
| 135 | + | ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}} | |
| 136 | + | footer := renderToString(t, mrFooter(p)) | |
| 137 | + | assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`) | |
| 138 | + | assert.Contains(t, footer, `data-mr-tabs`) | |
| 139 | + | assert.Contains(t, footer, `id="mr-commits"`) | |
| 140 | + | assert.Contains(t, footer, `id="mr-checks"`) | |
| 141 | + | assert.Contains(t, footer, `<table class="mr-commits">`) | |
| 142 | + | assert.Contains(t, footer, "500+") | |
| 143 | + | assert.Contains(t, footer, "more commits") | |
| 144 | + | assert.Contains(t, footer, "1/2 failed") | |
| 145 | + | rail := renderToString(t, mrRail(p)) | |
| 146 | + | assert.Contains(t, rail, "g f") | |
| 147 | + | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | |
| 148 | + | assert.NotContains(t, rail, text) | |
| 149 | + | } | |
| 150 | + | more := renderToString(t, moreMenu(p)) | |
| 151 | + | for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} { | |
| 152 | + | assert.Contains(t, more, text) | |
| 153 | + | } | |
| 154 | + | merge := renderToString(t, mergeMenu(p)) | |
| 155 | + | assert.NotContains(t, merge, `<dl`) | |
| 156 | + | assert.NotContains(t, merge, `mr-signing`) | |
| 157 | + | assert.Contains(t, merge, "the source branch was deleted") | |
| 158 | + | bar := renderToString(t, mrBar(MRPage{})) | |
| 159 | + | assert.Contains(t, bar, `id="mr-ck"`) | |
| 160 | + | assert.Contains(t, bar, `href="#mr-checks"`) | |
| 161 | + | assert.Contains(t, bar, "Merge settings") | |
| 162 | + | assert.NotContains(t, bar, ">Close</button>") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestMRReadPresentationCommitCount(t *testing.T) { | |
| 166 | + | commits := func(n int) []domain.Commit { return make([]domain.Commit, n) } | |
| 167 | + | for _, tc := range []struct { | |
| 168 | + | name string | |
| 169 | + | commits []domain.Commit | |
| 170 | + | truncated bool | |
| 171 | + | count string | |
| 172 | + | note string | |
| 173 | + | }{ | |
| 174 | + | {"empty", nil, false, "0", ""}, | |
| 175 | + | {"complete", commits(2), false, "2", ""}, | |
| 176 | + | {"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."}, | |
| 177 | + | } { | |
| 178 | + | t.Run(tc.name, func(t *testing.T) { | |
| 179 | + | s := &Server{cfg: Config{Now: time.Now}} | |
| 180 | + | p := &MRPage{} | |
| 181 | + | s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false) | |
| 182 | + | assert.Equal(t, tc.count, p.CommitCount) | |
| 183 | + | assert.Equal(t, tc.note, p.CommitsNote) | |
| 184 | + | assert.Len(t, p.Commits, len(tc.commits)) | |
| 185 | + | }) | |
| 186 | + | } | |
| 187 | + | } | |
| @@ -4,6 +4,7 @@ package web | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | + | "time" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 9 | "github.com/stretchr/testify/assert" | 10 | "github.com/stretchr/testify/assert" | ||
| @@ -128,3 +129,59 @@ func TestMergeabilityLiveEvents(t *testing.T) { | |||||
| 128 | require.NoError(t, err) | 129 | require.NoError(t, err) | ||
| 129 | assert.Empty(t, fs) | 130 | assert.Empty(t, fs) | ||
| 130 | } | 131 | } | ||
| 132 | + | ||||
| 133 | + | func TestMRPanelsAndMenus(t *testing.T) { | |||
| 134 | + | p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true, | |||
| 135 | + | ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}} | |||
| 136 | + | footer := renderToString(t, mrFooter(p)) | |||
| 137 | + | assert.Contains(t, footer, `<footer class="mr-footer"><details id="mr-panels">`) | |||
| 138 | + | assert.Contains(t, footer, `data-mr-tabs`) | |||
| 139 | + | assert.Contains(t, footer, `id="mr-commits"`) | |||
| 140 | + | assert.Contains(t, footer, `id="mr-checks"`) | |||
| 141 | + | assert.Contains(t, footer, `<table class="mr-commits">`) | |||
| 142 | + | assert.Contains(t, footer, "500+") | |||
| 143 | + | assert.Contains(t, footer, "more commits") | |||
| 144 | + | assert.Contains(t, footer, "1/2 failed") | |||
| 145 | + | rail := renderToString(t, mrRail(p)) | |||
| 146 | + | assert.Contains(t, rail, "g f") | |||
| 147 | + | for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} { | |||
| 148 | + | assert.NotContains(t, rail, text) | |||
| 149 | + | } | |||
| 150 | + | more := renderToString(t, moreMenu(p)) | |||
| 151 | + | for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} { | |||
| 152 | + | assert.Contains(t, more, text) | |||
| 153 | + | } | |||
| 154 | + | merge := renderToString(t, mergeMenu(p)) | |||
| 155 | + | assert.NotContains(t, merge, `<dl`) | |||
| 156 | + | assert.NotContains(t, merge, `mr-signing`) | |||
| 157 | + | assert.Contains(t, merge, "the source branch was deleted") | |||
| 158 | + | bar := renderToString(t, mrBar(MRPage{})) | |||
| 159 | + | assert.Contains(t, bar, `id="mr-ck"`) | |||
| 160 | + | assert.Contains(t, bar, `href="#mr-checks"`) | |||
| 161 | + | assert.Contains(t, bar, "Merge settings") | |||
| 162 | + | assert.NotContains(t, bar, ">Close</button>") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestMRReadPresentationCommitCount(t *testing.T) { | |||
| 166 | + | commits := func(n int) []domain.Commit { return make([]domain.Commit, n) } | |||
| 167 | + | for _, tc := range []struct { | |||
| 168 | + | name string | |||
| 169 | + | commits []domain.Commit | |||
| 170 | + | truncated bool | |||
| 171 | + | count string | |||
| 172 | + | note string | |||
| 173 | + | }{ | |||
| 174 | + | {"empty", nil, false, "0", ""}, | |||
| 175 | + | {"complete", commits(2), false, "2", ""}, | |||
| 176 | + | {"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."}, | |||
| 177 | + | } { | |||
| 178 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 179 | + | s := &Server{cfg: Config{Now: time.Now}} | |||
| 180 | + | p := &MRPage{} | |||
| 181 | + | s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false) | |||
| 182 | + | assert.Equal(t, tc.count, p.CommitCount) | |||
| 183 | + | assert.Equal(t, tc.note, p.CommitsNote) | |||
| 184 | + | assert.Len(t, p.Commits, len(tc.commits)) | |||
| 185 | + | }) | |||
| 186 | + | } | |||
| 187 | + | } | |||
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) { | ||
A internal/adapters/web/static/css/85-mobile.css +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | .mr-touch-range { display: none; } | |
| 3 | + | ||
| 4 | + | /* MR only: retain the shell's desktop layout and other pages' spacing. */ | |
| 5 | + | @media (max-width: 720px) { | |
| 6 | + | body:has(#diffs[data-mr]) { | |
| 7 | + | --pad: 0px; | |
| 8 | + | --hdr-h: 97px; | |
| 9 | + | overflow-x: clip; | |
| 10 | + | } | |
| 11 | + | body:has(#diffs[data-mr]) .work { padding-inline: 0; margin-inline: 0; gap: var(--s3); } | |
| 12 | + | body:has(#diffs[data-mr]) main { min-width: 0; padding-inline: 0; } | |
| 13 | + | body:has(#diffs[data-mr]) :is(.card, .mr-card, .mr-desc, .dfile, .dscroll, .code, .rail section) { | |
| 14 | + | margin-inline: 0; border-inline: 0; border-radius: 0; min-width: 0; max-width: 100%; | |
| 15 | + | } | |
| 16 | + | body:has(#diffs[data-mr]) :is(.mr-h, .mr-desc.prose, .card-h, .mr-footer) { padding-inline: 4px; } | |
| 17 | + | body:has(#diffs[data-mr]) .mr-footer :is(th, td) { padding-inline: var(--s1); } | |
| 18 | + | body:has(#diffs[data-mr]) .mr-desc.prose > pre { margin-inline: -4px; padding-inline: 0; max-width: calc(100% + 8px); } | |
| 19 | + | #diffs[data-mr] .dfile > summary { padding-inline: 0; border-inline: 0; border-radius: 0; } | |
| 20 | + | #diffs[data-mr] :is(.dscroll, .code) { overflow-x: auto; overscroll-behavior-x: contain; padding-inline: 0; } | |
| 21 | + | #diffs[data-mr] .dt .ln { width: calc(var(--lnw) + .5ch); min-width: calc(var(--lnw) + .5ch); padding-inline: .25ch; } | |
| 22 | + | #diffs[data-mr] .dt .m { width: 1ch; } | |
| 23 | + | #diffs[data-mr][data-diff] .uni { display: table; } | |
| 24 | + | #diffs[data-mr][data-diff] .spl { display: none; } | |
| 25 | + | #diffs[data-mr] .fnote td { padding-inline: 0; } | |
| 26 | + | #diffs[data-mr] :is(.cform, .cmt) { width: min(65ch, calc(100vw - 5rem)); max-width: calc(100vw - 5rem); overflow-wrap: anywhere; } | |
| 27 | + | body:has(#diffs[data-mr]) :is(input, textarea, select) { font-size: 16px; } | |
| 28 | + | body:has(#diffs[data-mr]) .f-btns { flex-wrap: wrap; } | |
| 29 | + | body:has(#diffs[data-mr]) .menu .menu-pop { | |
| 30 | + | position: fixed; inset: auto 0 0; width: auto; max-width: 100%; | |
| 31 | + | max-height: 75dvh; overflow-y: auto; border-radius: var(--r) var(--r) 0 0; | |
| 32 | + | padding-bottom: max(var(--s4), env(safe-area-inset-bottom)); | |
| 33 | + | } | |
| 34 | + | body:has(#diffs[data-mr]) .mr-bar { position: static; } | |
| 35 | + | /* A wrapping toolbar has no reliable fixed sticky height. */ | |
| 36 | + | body:has(#diffs[data-mr]) { --bar-h: 0px; } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | @media (pointer: coarse), (hover: none) { | |
| 40 | + | .mr-touch-range { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); } | |
| 41 | + | /* Real, disjoint cells: never expand invisible targets into other lines | |
| 42 | + | or into the neighbouring old/new gutter. */ | |
| 43 | + | #diffs[data-mr] .dt { line-height: max(var(--lh-code), 40px); } | |
| 44 | + | #diffs[data-mr] .dt .ln { width: max(calc(var(--lnw) + .5ch), 40px); min-width: 40px; padding: 0; } | |
| 45 | + | #diffs[data-mr] .spl .c-ln { width: max(calc(var(--lnw) + .5ch), 40px); } | |
| 46 | + | #diffs[data-mr] .ln a { display: block; width: 100%; min-height: 40px; text-align: center; text-decoration: underline; } | |
| 47 | + | .mr-touch-range[hidden] { display: none; } | |
| 48 | + | #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); } | |
| 49 | + | body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); } | |
| 50 | + | #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); } | |
| 51 | + | .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; } | |
| 52 | + | /* No display change: the summary stays a list-item and keeps its marker. */ | |
| 53 | + | .mr-footer summary { min-height: 40px; line-height: 40px; } | |
| 54 | + | } | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | @layer components { | |||
| 2 | + | .mr-touch-range { display: none; } | |||
| 3 | + | ||||
| 4 | + | /* MR only: retain the shell's desktop layout and other pages' spacing. */ | |||
| 5 | + | @media (max-width: 720px) { | |||
| 6 | + | body:has(#diffs[data-mr]) { | |||
| 7 | + | --pad: 0px; | |||
| 8 | + | --hdr-h: 97px; | |||
| 9 | + | overflow-x: clip; | |||
| 10 | + | } | |||
| 11 | + | body:has(#diffs[data-mr]) .work { padding-inline: 0; margin-inline: 0; gap: var(--s3); } | |||
| 12 | + | body:has(#diffs[data-mr]) main { min-width: 0; padding-inline: 0; } | |||
| 13 | + | body:has(#diffs[data-mr]) :is(.card, .mr-card, .mr-desc, .dfile, .dscroll, .code, .rail section) { | |||
| 14 | + | margin-inline: 0; border-inline: 0; border-radius: 0; min-width: 0; max-width: 100%; | |||
| 15 | + | } | |||
| 16 | + | body:has(#diffs[data-mr]) :is(.mr-h, .mr-desc.prose, .card-h, .mr-footer) { padding-inline: 4px; } | |||
| 17 | + | body:has(#diffs[data-mr]) .mr-footer :is(th, td) { padding-inline: var(--s1); } | |||
| 18 | + | body:has(#diffs[data-mr]) .mr-desc.prose > pre { margin-inline: -4px; padding-inline: 0; max-width: calc(100% + 8px); } | |||
| 19 | + | #diffs[data-mr] .dfile > summary { padding-inline: 0; border-inline: 0; border-radius: 0; } | |||
| 20 | + | #diffs[data-mr] :is(.dscroll, .code) { overflow-x: auto; overscroll-behavior-x: contain; padding-inline: 0; } | |||
| 21 | + | #diffs[data-mr] .dt .ln { width: calc(var(--lnw) + .5ch); min-width: calc(var(--lnw) + .5ch); padding-inline: .25ch; } | |||
| 22 | + | #diffs[data-mr] .dt .m { width: 1ch; } | |||
| 23 | + | #diffs[data-mr][data-diff] .uni { display: table; } | |||
| 24 | + | #diffs[data-mr][data-diff] .spl { display: none; } | |||
| 25 | + | #diffs[data-mr] .fnote td { padding-inline: 0; } | |||
| 26 | + | #diffs[data-mr] :is(.cform, .cmt) { width: min(65ch, calc(100vw - 5rem)); max-width: calc(100vw - 5rem); overflow-wrap: anywhere; } | |||
| 27 | + | body:has(#diffs[data-mr]) :is(input, textarea, select) { font-size: 16px; } | |||
| 28 | + | body:has(#diffs[data-mr]) .f-btns { flex-wrap: wrap; } | |||
| 29 | + | body:has(#diffs[data-mr]) .menu .menu-pop { | |||
| 30 | + | position: fixed; inset: auto 0 0; width: auto; max-width: 100%; | |||
| 31 | + | max-height: 75dvh; overflow-y: auto; border-radius: var(--r) var(--r) 0 0; | |||
| 32 | + | padding-bottom: max(var(--s4), env(safe-area-inset-bottom)); | |||
| 33 | + | } | |||
| 34 | + | body:has(#diffs[data-mr]) .mr-bar { position: static; } | |||
| 35 | + | /* A wrapping toolbar has no reliable fixed sticky height. */ | |||
| 36 | + | body:has(#diffs[data-mr]) { --bar-h: 0px; } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | @media (pointer: coarse), (hover: none) { | |||
| 40 | + | .mr-touch-range { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); } | |||
| 41 | + | /* Real, disjoint cells: never expand invisible targets into other lines | |||
| 42 | + | or into the neighbouring old/new gutter. */ | |||
| 43 | + | #diffs[data-mr] .dt { line-height: max(var(--lh-code), 40px); } | |||
| 44 | + | #diffs[data-mr] .dt .ln { width: max(calc(var(--lnw) + .5ch), 40px); min-width: 40px; padding: 0; } | |||
| 45 | + | #diffs[data-mr] .spl .c-ln { width: max(calc(var(--lnw) + .5ch), 40px); } | |||
| 46 | + | #diffs[data-mr] .ln a { display: block; width: 100%; min-height: 40px; text-align: center; text-decoration: underline; } | |||
| 47 | + | .mr-touch-range[hidden] { display: none; } | |||
| 48 | + | #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); } | |||
| 49 | + | body:has(#diffs[data-mr]) :is(.cform .btn, .menu .btn, .mr-touch-range .btn, .c-act button) { min-height: 40px; height: auto; padding-block: var(--s2); } | |||
| 50 | + | #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); } | |||
| 51 | + | .mr-footer [data-mr-tabs] a { display: inline-flex; align-items: center; min-height: 40px; } | |||
| 52 | + | /* No display change: the summary stays a list-item and keeps its marker. */ | |||
| 53 | + | .mr-footer summary { min-height: 40px; line-height: 40px; } | |||
| 54 | + | } | |||
| 55 | + | } | |||
M internal/adapters/web/static/js/ermite.js +1 -0
| @@ -183,6 +183,7 @@ | |||
| 183 | 183 | layout(mode) { | |
| 184 | 184 | const el = $('[data-diff]'); | |
| 185 | 185 | if (!el) return; | |
| 186 | + | if (el.hasAttribute('data-mr') && el.dataset.mobile === 'true') return; | |
| 186 | 187 | el.dataset.diff = mode; | |
| 187 | 188 | try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ } | |
| 188 | 189 | for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true; | |
| @@ -183,6 +183,7 @@ | |||||
| 183 | layout(mode) { | 183 | layout(mode) { | ||
| 184 | const el = $('[data-diff]'); | 184 | const el = $('[data-diff]'); | ||
| 185 | if (!el) return; | 185 | if (!el) return; | ||
| 186 | + | if (el.hasAttribute('data-mr') && el.dataset.mobile === 'true') return; | |||
| 186 | el.dataset.diff = mode; | 187 | el.dataset.diff = mode; | ||
| 187 | try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ } | 188 | try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ } | ||
| 188 | for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true; | 189 | for (const d of $$('[data-file]')) if ($('.dt', d)) d.open = true; | ||
M internal/adapters/web/static/js/mr.js +143 -2
| @@ -27,6 +27,20 @@ window.ermiteMR = (() => { | |||
| 27 | 27 | b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`); | |
| 28 | 28 | return b.href; | |
| 29 | 29 | }, | |
| 30 | + | // Range mode is explicit: ordinary taps still open single-line forms. | |
| 31 | + | touchRange(previous, current, base) { | |
| 32 | + | if (!previous) return { start: current, url: null }; | |
| 33 | + | const url = core.rangeURL(previous, current, base); | |
| 34 | + | if (url) return { start: null, url }; | |
| 35 | + | if (previous === current) return { start: null, url: current }; | |
| 36 | + | return { start: current, url: null }; // another file/side starts afresh | |
| 37 | + | }, | |
| 38 | + | mobileURL(href, view, narrow) { | |
| 39 | + | if (!narrow || view !== 'split') return null; | |
| 40 | + | const url = new URL(href); | |
| 41 | + | url.searchParams.set('view', 'unified'); | |
| 42 | + | return url.href; | |
| 43 | + | }, | |
| 30 | 44 | quote(button) { | |
| 31 | 45 | const thread = button.closest('.cmt').closest('[data-thread]'); | |
| 32 | 46 | const details = thread?.querySelector(':scope > details[data-reply]'); | |
| @@ -49,6 +63,13 @@ window.ermiteMR = (() => { | |||
| 49 | 63 | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | |
| 50 | 64 | return null; | |
| 51 | 65 | }, | |
| 66 | + | tabIndex(key, index, count) { | |
| 67 | + | if (key === 'Home') return 0; | |
| 68 | + | if (key === 'End') return count - 1; | |
| 69 | + | if (key === 'ArrowRight') return (index + 1) % count; | |
| 70 | + | if (key === 'ArrowLeft') return (index + count - 1) % count; | |
| 71 | + | return null; | |
| 72 | + | }, | |
| 52 | 73 | // step moves an index within [0, n). | |
| 53 | 74 | step(i, d, n) { | |
| 54 | 75 | if (n === 0) return -1; | |
| @@ -60,6 +81,7 @@ window.ermiteMR = (() => { | |||
| 60 | 81 | const $ = (sel, root = document) => root.querySelector(sel); | |
| 61 | 82 | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | |
| 62 | 83 | const loading = new Map(); // src -> promise of the swap | |
| 84 | + | let refreshTouchComments = () => {}; | |
| 63 | 85 | let moved = false; | |
| 64 | 86 | let lazyIO = null; // the observer of the placeholders (watch) | |
| 65 | 87 | let revealGen = 0; // the last reveal wins the scroll | |
| @@ -108,6 +130,7 @@ window.ermiteMR = (() => { | |||
| 108 | 130 | window.htmx?.process(f); | |
| 109 | 131 | window.ermite?.watchFile?.(f, old); | |
| 110 | 132 | } | |
| 133 | + | refreshTouchComments(); | |
| 111 | 134 | } | |
| 112 | 135 | ||
| 113 | 136 | // stale: the diff changed under the page. The placeholders say so; no file | |
| @@ -130,7 +153,7 @@ window.ermiteMR = (() => { | |||
| 130 | 153 | // A page with both layouts says which shows in data-diff; a large diff | |
| 131 | 154 | // rendered in one layout only says it in data-view. | |
| 132 | 155 | const diffs = $('#diffs')?.dataset; | |
| 133 | - | if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') { | |
| 156 | + | if (/^c\d+$/.test(id) && !window.matchMedia?.('(max-width: 720px)').matches && (diffs?.diff || diffs?.view) === 'split') { | |
| 134 | 157 | const split = document.getElementById(id + '-s'); | |
| 135 | 158 | if (split) return split; | |
| 136 | 159 | } | |
| @@ -285,8 +308,126 @@ window.ermiteMR = (() => { | |||
| 285 | 308 | reveal(id); | |
| 286 | 309 | } | |
| 287 | 310 | ||
| 311 | + | // Enhance the ordinary anchor navigation only when scripts are available. | |
| 312 | + | function panels() { | |
| 313 | + | const footer = $('#mr-panels'), nav = $('[data-mr-tabs]'); | |
| 314 | + | if (!footer || !nav) return; | |
| 315 | + | const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer); | |
| 316 | + | nav.setAttribute('role', 'tablist'); | |
| 317 | + | function select(index, focus = false, fromUser = false) { | |
| 318 | + | tabs.forEach((tab, i) => { | |
| 319 | + | tab.setAttribute('role', 'tab'); | |
| 320 | + | tab.setAttribute('aria-controls', panels[i].id); | |
| 321 | + | tab.setAttribute('aria-selected', String(i === index)); | |
| 322 | + | tab.tabIndex = i === index ? 0 : -1; | |
| 323 | + | panels[i].setAttribute('role', 'tabpanel'); | |
| 324 | + | panels[i].tabIndex = 0; | |
| 325 | + | panels[i].hidden = i !== index; | |
| 326 | + | }); | |
| 327 | + | if (focus) tabs[index].focus(); | |
| 328 | + | if (fromUser && location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id); | |
| 329 | + | } | |
| 330 | + | tabs.forEach((tab, i) => { | |
| 331 | + | tab.addEventListener('click', (e) => { e.preventDefault(); select(i, false, true); }); | |
| 332 | + | tab.addEventListener('keydown', (e) => { | |
| 333 | + | const next = core.tabIndex(e.key, i, tabs.length); | |
| 334 | + | if (next === null) return; | |
| 335 | + | e.preventDefault(); select(next, true, true); | |
| 336 | + | }); | |
| 337 | + | }); | |
| 338 | + | function hash() { | |
| 339 | + | const h = location.hash; | |
| 340 | + | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | |
| 341 | + | if (index < 0) return; | |
| 342 | + | footer.open = true; select(index); | |
| 343 | + | } | |
| 344 | + | // A repeated click on the same hash must also reopen a folded footer. | |
| 345 | + | document.addEventListener('click', (e) => { | |
| 346 | + | if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); } | |
| 347 | + | }); | |
| 348 | + | const summary = $('#mr-ck'); | |
| 349 | + | if (summary) { | |
| 350 | + | const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; }; | |
| 351 | + | new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true }); | |
| 352 | + | sync(); | |
| 353 | + | } | |
| 354 | + | select(0); hash(); | |
| 355 | + | addEventListener('hashchange', hash); | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | function touchComments() { | |
| 359 | + | const diffs = $('#diffs'); | |
| 360 | + | const bar = document.createElement('div'); | |
| 361 | + | bar.className = 'mr-touch-range'; | |
| 362 | + | const button = document.createElement('button'); | |
| 363 | + | button.type = 'button'; | |
| 364 | + | button.className = 'btn'; | |
| 365 | + | button.textContent = 'Select line range'; | |
| 366 | + | button.setAttribute('aria-pressed', 'false'); | |
| 367 | + | const status = document.createElement('span'); | |
| 368 | + | status.setAttribute('role', 'status'); | |
| 369 | + | bar.append(button, status); | |
| 370 | + | diffs.prepend(bar); | |
| 371 | + | refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); }; | |
| 372 | + | refreshTouchComments(); | |
| 373 | + | const touch = window.matchMedia?.('(pointer: coarse), (hover: none)'); | |
| 374 | + | let enabled = false, start = null, marker = null; | |
| 375 | + | const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; }; | |
| 376 | + | button.addEventListener('click', () => { | |
| 377 | + | enabled = !enabled; | |
| 378 | + | clear(); | |
| 379 | + | button.setAttribute('aria-pressed', String(enabled)); | |
| 380 | + | status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : ''; | |
| 381 | + | }); | |
| 382 | + | diffs.addEventListener('click', (e) => { | |
| 383 | + | const link = e.target.closest?.('.ln a[href*="on="]'); | |
| 384 | + | if (!touch?.matches || !enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; | |
| 385 | + | e.preventDefault(); | |
| 386 | + | e.stopPropagation(); // do not alter the desktop shift-click anchor | |
| 387 | + | const next = core.touchRange(start, link.href, location.href); | |
| 388 | + | clear(); | |
| 389 | + | if (next.url) { location.assign(next.url); return; } | |
| 390 | + | start = next.start; | |
| 391 | + | marker = link; | |
| 392 | + | marker.setAttribute('data-range-start', ''); | |
| 393 | + | status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`; | |
| 394 | + | }); | |
| 395 | + | touch?.addEventListener('change', () => { | |
| 396 | + | if (touch.matches) return; | |
| 397 | + | enabled = false; | |
| 398 | + | clear(); | |
| 399 | + | button.setAttribute('aria-pressed', 'false'); | |
| 400 | + | status.textContent = ''; | |
| 401 | + | }); | |
| 402 | + | // The server opens the line form. Scroll after layout without stealing | |
| 403 | + | // focus or forcing a mobile keyboard to appear. | |
| 404 | + | if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => { | |
| 405 | + | const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length); | |
| 406 | + | form?.scrollIntoView({ block: 'center' }); | |
| 407 | + | }); | |
| 408 | + | } | |
| 409 | + | ||
| 288 | 410 | function start() { | |
| 289 | 411 | if (!$('#diffs[data-mr]')) return; | |
| 412 | + | panels(); | |
| 413 | + | // Large diffs may contain only the split table. Ask the existing server | |
| 414 | + | // route for unified rather than writing the saved session preference. | |
| 415 | + | const narrow = window.matchMedia?.('(max-width: 720px)'); | |
| 416 | + | const unify = () => { | |
| 417 | + | $('#diffs').dataset.mobile = String(!!narrow?.matches); | |
| 418 | + | const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches); | |
| 419 | + | if (url) { location.replace(url); return true; } | |
| 420 | + | return false; | |
| 421 | + | }; | |
| 422 | + | if (unify()) return; | |
| 423 | + | narrow?.addEventListener('change', unify); | |
| 424 | + | addEventListener('click', (e) => { | |
| 425 | + | if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) { | |
| 426 | + | e.preventDefault(); | |
| 427 | + | e.stopImmediatePropagation(); | |
| 428 | + | } | |
| 429 | + | }, true); | |
| 430 | + | touchComments(); | |
| 290 | 431 | let folded = false; | |
| 291 | 432 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | |
| 292 | 433 | if (folded) foldFiles(true); | |
| @@ -316,7 +457,7 @@ window.ermiteMR = (() => { | |||
| 316 | 457 | }); | |
| 317 | 458 | } | |
| 318 | 459 | ||
| 319 | - | return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | |
| 460 | + | return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | |
| 320 | 461 | })(); | |
| 321 | 462 | ||
| 322 | 463 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | |
| @@ -27,6 +27,20 @@ window.ermiteMR = (() => { | |||||
| 27 | b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`); | 27 | b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`); | ||
| 28 | return b.href; | 28 | return b.href; | ||
| 29 | }, | 29 | }, | ||
| 30 | + | // Range mode is explicit: ordinary taps still open single-line forms. | |||
| 31 | + | touchRange(previous, current, base) { | |||
| 32 | + | if (!previous) return { start: current, url: null }; | |||
| 33 | + | const url = core.rangeURL(previous, current, base); | |||
| 34 | + | if (url) return { start: null, url }; | |||
| 35 | + | if (previous === current) return { start: null, url: current }; | |||
| 36 | + | return { start: current, url: null }; // another file/side starts afresh | |||
| 37 | + | }, | |||
| 38 | + | mobileURL(href, view, narrow) { | |||
| 39 | + | if (!narrow || view !== 'split') return null; | |||
| 40 | + | const url = new URL(href); | |||
| 41 | + | url.searchParams.set('view', 'unified'); | |||
| 42 | + | return url.href; | |||
| 43 | + | }, | |||
| 30 | quote(button) { | 44 | quote(button) { | ||
| 31 | const thread = button.closest('.cmt').closest('[data-thread]'); | 45 | const thread = button.closest('.cmt').closest('[data-thread]'); | ||
| 32 | const details = thread?.querySelector(':scope > details[data-reply]'); | 46 | const details = thread?.querySelector(':scope > details[data-reply]'); | ||
| @@ -49,6 +63,13 @@ window.ermiteMR = (() => { | |||||
| 49 | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | 63 | if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 }; | ||
| 50 | return null; | 64 | return null; | ||
| 51 | }, | 65 | }, | ||
| 66 | + | tabIndex(key, index, count) { | |||
| 67 | + | if (key === 'Home') return 0; | |||
| 68 | + | if (key === 'End') return count - 1; | |||
| 69 | + | if (key === 'ArrowRight') return (index + 1) % count; | |||
| 70 | + | if (key === 'ArrowLeft') return (index + count - 1) % count; | |||
| 71 | + | return null; | |||
| 72 | + | }, | |||
| 52 | // step moves an index within [0, n). | 73 | // step moves an index within [0, n). | ||
| 53 | step(i, d, n) { | 74 | step(i, d, n) { | ||
| 54 | if (n === 0) return -1; | 75 | if (n === 0) return -1; | ||
| @@ -60,6 +81,7 @@ window.ermiteMR = (() => { | |||||
| 60 | const $ = (sel, root = document) => root.querySelector(sel); | 81 | const $ = (sel, root = document) => root.querySelector(sel); | ||
| 61 | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | 82 | const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; | ||
| 62 | const loading = new Map(); // src -> promise of the swap | 83 | const loading = new Map(); // src -> promise of the swap | ||
| 84 | + | let refreshTouchComments = () => {}; | |||
| 63 | let moved = false; | 85 | let moved = false; | ||
| 64 | let lazyIO = null; // the observer of the placeholders (watch) | 86 | let lazyIO = null; // the observer of the placeholders (watch) | ||
| 65 | let revealGen = 0; // the last reveal wins the scroll | 87 | let revealGen = 0; // the last reveal wins the scroll | ||
| @@ -108,6 +130,7 @@ window.ermiteMR = (() => { | |||||
| 108 | window.htmx?.process(f); | 130 | window.htmx?.process(f); | ||
| 109 | window.ermite?.watchFile?.(f, old); | 131 | window.ermite?.watchFile?.(f, old); | ||
| 110 | } | 132 | } | ||
| 133 | + | refreshTouchComments(); | |||
| 111 | } | 134 | } | ||
| 112 | 135 | ||||
| 113 | // stale: the diff changed under the page. The placeholders say so; no file | 136 | // stale: the diff changed under the page. The placeholders say so; no file | ||
| @@ -130,7 +153,7 @@ window.ermiteMR = (() => { | |||||
| 130 | // A page with both layouts says which shows in data-diff; a large diff | 153 | // A page with both layouts says which shows in data-diff; a large diff | ||
| 131 | // rendered in one layout only says it in data-view. | 154 | // rendered in one layout only says it in data-view. | ||
| 132 | const diffs = $('#diffs')?.dataset; | 155 | const diffs = $('#diffs')?.dataset; | ||
| 133 | - | if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') { | 156 | + | if (/^c\d+$/.test(id) && !window.matchMedia?.('(max-width: 720px)').matches && (diffs?.diff || diffs?.view) === 'split') { |
| 134 | const split = document.getElementById(id + '-s'); | 157 | const split = document.getElementById(id + '-s'); | ||
| 135 | if (split) return split; | 158 | if (split) return split; | ||
| 136 | } | 159 | } | ||
| @@ -285,8 +308,126 @@ window.ermiteMR = (() => { | |||||
| 285 | reveal(id); | 308 | reveal(id); | ||
| 286 | } | 309 | } | ||
| 287 | 310 | ||||
| 311 | + | // Enhance the ordinary anchor navigation only when scripts are available. | |||
| 312 | + | function panels() { | |||
| 313 | + | const footer = $('#mr-panels'), nav = $('[data-mr-tabs]'); | |||
| 314 | + | if (!footer || !nav) return; | |||
| 315 | + | const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer); | |||
| 316 | + | nav.setAttribute('role', 'tablist'); | |||
| 317 | + | function select(index, focus = false, fromUser = false) { | |||
| 318 | + | tabs.forEach((tab, i) => { | |||
| 319 | + | tab.setAttribute('role', 'tab'); | |||
| 320 | + | tab.setAttribute('aria-controls', panels[i].id); | |||
| 321 | + | tab.setAttribute('aria-selected', String(i === index)); | |||
| 322 | + | tab.tabIndex = i === index ? 0 : -1; | |||
| 323 | + | panels[i].setAttribute('role', 'tabpanel'); | |||
| 324 | + | panels[i].tabIndex = 0; | |||
| 325 | + | panels[i].hidden = i !== index; | |||
| 326 | + | }); | |||
| 327 | + | if (focus) tabs[index].focus(); | |||
| 328 | + | if (fromUser && location.hash !== '#' + panels[index].id) history.replaceState(null, '', '#' + panels[index].id); | |||
| 329 | + | } | |||
| 330 | + | tabs.forEach((tab, i) => { | |||
| 331 | + | tab.addEventListener('click', (e) => { e.preventDefault(); select(i, false, true); }); | |||
| 332 | + | tab.addEventListener('keydown', (e) => { | |||
| 333 | + | const next = core.tabIndex(e.key, i, tabs.length); | |||
| 334 | + | if (next === null) return; | |||
| 335 | + | e.preventDefault(); select(next, true, true); | |||
| 336 | + | }); | |||
| 337 | + | }); | |||
| 338 | + | function hash() { | |||
| 339 | + | const h = location.hash; | |||
| 340 | + | const index = h === '#mr-checks' || h === '#mr-checks-panel' ? 1 : h === '#mr-commits' ? 0 : -1; | |||
| 341 | + | if (index < 0) return; | |||
| 342 | + | footer.open = true; select(index); | |||
| 343 | + | } | |||
| 344 | + | // A repeated click on the same hash must also reopen a folded footer. | |||
| 345 | + | document.addEventListener('click', (e) => { | |||
| 346 | + | if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); } | |||
| 347 | + | }); | |||
| 348 | + | const summary = $('#mr-ck'); | |||
| 349 | + | if (summary) { | |||
| 350 | + | const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; }; | |||
| 351 | + | new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true }); | |||
| 352 | + | sync(); | |||
| 353 | + | } | |||
| 354 | + | select(0); hash(); | |||
| 355 | + | addEventListener('hashchange', hash); | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | function touchComments() { | |||
| 359 | + | const diffs = $('#diffs'); | |||
| 360 | + | const bar = document.createElement('div'); | |||
| 361 | + | bar.className = 'mr-touch-range'; | |||
| 362 | + | const button = document.createElement('button'); | |||
| 363 | + | button.type = 'button'; | |||
| 364 | + | button.className = 'btn'; | |||
| 365 | + | button.textContent = 'Select line range'; | |||
| 366 | + | button.setAttribute('aria-pressed', 'false'); | |||
| 367 | + | const status = document.createElement('span'); | |||
| 368 | + | status.setAttribute('role', 'status'); | |||
| 369 | + | bar.append(button, status); | |||
| 370 | + | diffs.prepend(bar); | |||
| 371 | + | refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); }; | |||
| 372 | + | refreshTouchComments(); | |||
| 373 | + | const touch = window.matchMedia?.('(pointer: coarse), (hover: none)'); | |||
| 374 | + | let enabled = false, start = null, marker = null; | |||
| 375 | + | const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; }; | |||
| 376 | + | button.addEventListener('click', () => { | |||
| 377 | + | enabled = !enabled; | |||
| 378 | + | clear(); | |||
| 379 | + | button.setAttribute('aria-pressed', String(enabled)); | |||
| 380 | + | status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : ''; | |||
| 381 | + | }); | |||
| 382 | + | diffs.addEventListener('click', (e) => { | |||
| 383 | + | const link = e.target.closest?.('.ln a[href*="on="]'); | |||
| 384 | + | if (!touch?.matches || !enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; | |||
| 385 | + | e.preventDefault(); | |||
| 386 | + | e.stopPropagation(); // do not alter the desktop shift-click anchor | |||
| 387 | + | const next = core.touchRange(start, link.href, location.href); | |||
| 388 | + | clear(); | |||
| 389 | + | if (next.url) { location.assign(next.url); return; } | |||
| 390 | + | start = next.start; | |||
| 391 | + | marker = link; | |||
| 392 | + | marker.setAttribute('data-range-start', ''); | |||
| 393 | + | status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`; | |||
| 394 | + | }); | |||
| 395 | + | touch?.addEventListener('change', () => { | |||
| 396 | + | if (touch.matches) return; | |||
| 397 | + | enabled = false; | |||
| 398 | + | clear(); | |||
| 399 | + | button.setAttribute('aria-pressed', 'false'); | |||
| 400 | + | status.textContent = ''; | |||
| 401 | + | }); | |||
| 402 | + | // The server opens the line form. Scroll after layout without stealing | |||
| 403 | + | // focus or forcing a mobile keyboard to appear. | |||
| 404 | + | if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => { | |||
| 405 | + | const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length); | |||
| 406 | + | form?.scrollIntoView({ block: 'center' }); | |||
| 407 | + | }); | |||
| 408 | + | } | |||
| 409 | + | ||||
| 288 | function start() { | 410 | function start() { | ||
| 289 | if (!$('#diffs[data-mr]')) return; | 411 | if (!$('#diffs[data-mr]')) return; | ||
| 412 | + | panels(); | |||
| 413 | + | // Large diffs may contain only the split table. Ask the existing server | |||
| 414 | + | // route for unified rather than writing the saved session preference. | |||
| 415 | + | const narrow = window.matchMedia?.('(max-width: 720px)'); | |||
| 416 | + | const unify = () => { | |||
| 417 | + | $('#diffs').dataset.mobile = String(!!narrow?.matches); | |||
| 418 | + | const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches); | |||
| 419 | + | if (url) { location.replace(url); return true; } | |||
| 420 | + | return false; | |||
| 421 | + | }; | |||
| 422 | + | if (unify()) return; | |||
| 423 | + | narrow?.addEventListener('change', unify); | |||
| 424 | + | addEventListener('click', (e) => { | |||
| 425 | + | if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) { | |||
| 426 | + | e.preventDefault(); | |||
| 427 | + | e.stopImmediatePropagation(); | |||
| 428 | + | } | |||
| 429 | + | }, true); | |||
| 430 | + | touchComments(); | |||
| 290 | let folded = false; | 431 | let folded = false; | ||
| 291 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | 432 | try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ } | ||
| 292 | if (folded) foldFiles(true); | 433 | if (folded) foldFiles(true); | ||
| @@ -316,7 +457,7 @@ window.ermiteMR = (() => { | |||||
| 316 | }); | 457 | }); | ||
| 317 | } | 458 | } | ||
| 318 | 459 | ||||
| 319 | - | return { core, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; | 460 | + | return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile }; |
| 320 | })(); | 461 | })(); | ||
| 321 | 462 | ||||
| 322 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | 463 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start()); | ||
M internal/app/testdata/web/commit-big.html +3 -3
| @@ -127,12 +127,12 @@ | |||
| 127 | 127 | </span> | |
| 128 | 128 | <details class="menu vmenu"> | |
| 129 | 129 | <summary class="btn" aria-label="Diff view"> | |
| 130 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 130 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 131 | 131 | </summary> | |
| 132 | 132 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 133 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 133 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 134 | 134 | </a> | |
| 135 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 135 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 136 | 136 | </a> | |
| 137 | 137 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 138 | 138 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -127,12 +127,12 @@ | |||||
| 127 | </span> | 127 | </span> | ||
| 128 | <details class="menu vmenu"> | 128 | <details class="menu vmenu"> | ||
| 129 | <summary class="btn" aria-label="Diff view"> | 129 | <summary class="btn" aria-label="Diff view"> | ||
| 130 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | 130 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> |
| 131 | </summary> | 131 | </summary> | ||
| 132 | <div class="menu-pop" role="group" aria-label="Diff view"> | 132 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 133 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 133 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 134 | </a> | 134 | </a> | ||
| 135 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 135 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 136 | </a> | 136 | </a> | ||
| 137 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 137 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 138 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 138 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
M internal/app/testdata/web/commit-binary.html +3 -3
| @@ -123,12 +123,12 @@ | |||
| 123 | 123 | </span> | |
| 124 | 124 | <details class="menu vmenu"> | |
| 125 | 125 | <summary class="btn" aria-label="Diff view"> | |
| 126 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 127 | 127 | </summary> | |
| 128 | 128 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 129 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 129 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 130 | 130 | </a> | |
| 131 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 131 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 132 | 132 | </a> | |
| 133 | 133 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 134 | 134 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -123,12 +123,12 @@ | |||||
| 123 | </span> | 123 | </span> | ||
| 124 | <details class="menu vmenu"> | 124 | <details class="menu vmenu"> | ||
| 125 | <summary class="btn" aria-label="Diff view"> | 125 | <summary class="btn" aria-label="Diff view"> | ||
| 126 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> |
| 127 | </summary> | 127 | </summary> | ||
| 128 | <div class="menu-pop" role="group" aria-label="Diff view"> | 128 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 129 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 129 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 130 | </a> | 130 | </a> | ||
| 131 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 131 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 132 | </a> | 132 | </a> | ||
| 133 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 133 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 134 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 134 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
M internal/app/testdata/web/commit-merge.html +3 -3
| @@ -122,12 +122,12 @@ | |||
| 122 | 122 | </span> | |
| 123 | 123 | <details class="menu vmenu"> | |
| 124 | 124 | <summary class="btn" aria-label="Diff view"> | |
| 125 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 125 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 126 | 126 | </summary> | |
| 127 | 127 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 128 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 128 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 129 | 129 | </a> | |
| 130 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 130 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 131 | 131 | </a> | |
| 132 | 132 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 133 | 133 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -122,12 +122,12 @@ | |||||
| 122 | </span> | 122 | </span> | ||
| 123 | <details class="menu vmenu"> | 123 | <details class="menu vmenu"> | ||
| 124 | <summary class="btn" aria-label="Diff view"> | 124 | <summary class="btn" aria-label="Diff view"> | ||
| 125 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | 125 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> |
| 126 | </summary> | 126 | </summary> | ||
| 127 | <div class="menu-pop" role="group" aria-label="Diff view"> | 127 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 128 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 128 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 129 | </a> | 129 | </a> | ||
| 130 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 130 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 131 | </a> | 131 | </a> | ||
| 132 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 132 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 133 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 133 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
M internal/app/testdata/web/commit-modify-split.html +3 -3
| @@ -129,12 +129,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 129 | 129 | </span> | |
| 130 | 130 | <details class="menu vmenu"> | |
| 131 | 131 | <summary class="btn" aria-label="Diff view"> | |
| 132 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span> | |
| 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span> | |
| 133 | 133 | </summary> | |
| 134 | 134 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 135 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 135 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 136 | 136 | </a> | |
| 137 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 137 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" aria-current="true" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 138 | 138 | </a> | |
| 139 | 139 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 140 | 140 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -129,12 +129,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 129 | </span> | 129 | </span> | ||
| 130 | <details class="menu vmenu"> | 130 | <details class="menu vmenu"> | ||
| 131 | <summary class="btn" aria-label="Diff view"> | 131 | <summary class="btn" aria-label="Diff view"> | ||
| 132 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span> | 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Split</span> |
| 133 | </summary> | 133 | </summary> | ||
| 134 | <div class="menu-pop" role="group" aria-label="Diff view"> | 134 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 135 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 135 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 136 | </a> | 136 | </a> | ||
| 137 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 137 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" aria-current="true" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 138 | </a> | 138 | </a> | ||
| 139 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 139 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 140 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 140 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
M internal/app/testdata/web/commit-modify.html +3 -3
| @@ -129,12 +129,12 @@ And <b>html</b> & "quotes".</pre> | |||
| 129 | 129 | </span> | |
| 130 | 130 | <details class="menu vmenu"> | |
| 131 | 131 | <summary class="btn" aria-label="Diff view"> | |
| 132 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 133 | 133 | </summary> | |
| 134 | 134 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 135 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 135 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 136 | 136 | </a> | |
| 137 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 137 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 138 | 138 | </a> | |
| 139 | 139 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 140 | 140 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -129,12 +129,12 @@ And <b>html</b> & "quotes".</pre> | |||||
| 129 | </span> | 129 | </span> | ||
| 130 | <details class="menu vmenu"> | 130 | <details class="menu vmenu"> | ||
| 131 | <summary class="btn" aria-label="Diff view"> | 131 | <summary class="btn" aria-label="Diff view"> | ||
| 132 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | 132 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> |
| 133 | </summary> | 133 | </summary> | ||
| 134 | <div class="menu-pop" role="group" aria-label="Diff view"> | 134 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 135 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 135 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 136 | </a> | 136 | </a> | ||
| 137 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 137 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 138 | </a> | 138 | </a> | ||
| 139 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 139 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 140 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 140 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
M internal/app/testdata/web/commit-rename.html +3 -3
| @@ -123,12 +123,12 @@ | |||
| 123 | 123 | </span> | |
| 124 | 124 | <details class="menu vmenu"> | |
| 125 | 125 | <summary class="btn" aria-label="Diff view"> | |
| 126 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 127 | 127 | </summary> | |
| 128 | 128 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 129 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 129 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 130 | 130 | </a> | |
| 131 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 131 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 132 | 132 | </a> | |
| 133 | 133 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 134 | 134 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -123,12 +123,12 @@ | |||||
| 123 | </span> | 123 | </span> | ||
| 124 | <details class="menu vmenu"> | 124 | <details class="menu vmenu"> | ||
| 125 | <summary class="btn" aria-label="Diff view"> | 125 | <summary class="btn" aria-label="Diff view"> | ||
| 126 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | 126 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> |
| 127 | </summary> | 127 | </summary> | ||
| 128 | <div class="menu-pop" role="group" aria-label="Diff view"> | 128 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 129 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 129 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 130 | </a> | 130 | </a> | ||
| 131 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 131 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 132 | </a> | 132 | </a> | ||
| 133 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 133 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 134 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 134 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
M internal/app/testdata/web/commit-root.html +3 -3
| @@ -121,12 +121,12 @@ | |||
| 121 | 121 | </span> | |
| 122 | 122 | <details class="menu vmenu"> | |
| 123 | 123 | <summary class="btn" aria-label="Diff view"> | |
| 124 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 124 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 125 | 125 | </summary> | |
| 126 | 126 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 127 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 127 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 128 | 128 | </a> | |
| 129 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 129 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 130 | 130 | </a> | |
| 131 | 131 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 132 | 132 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -121,12 +121,12 @@ | |||||
| 121 | </span> | 121 | </span> | ||
| 122 | <details class="menu vmenu"> | 122 | <details class="menu vmenu"> | ||
| 123 | <summary class="btn" aria-label="Diff view"> | 123 | <summary class="btn" aria-label="Diff view"> | ||
| 124 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | 124 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> |
| 125 | </summary> | 125 | </summary> | ||
| 126 | <div class="menu-pop" role="group" aria-label="Diff view"> | 126 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 127 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 127 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 128 | </a> | 128 | </a> | ||
| 129 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 129 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 130 | </a> | 130 | </a> | ||
| 131 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 131 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 132 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 132 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
M internal/app/testdata/web/commit-trojan.html +3 -3
| @@ -124,12 +124,12 @@ | |||
| 124 | 124 | </span> | |
| 125 | 125 | <details class="menu vmenu"> | |
| 126 | 126 | <summary class="btn" aria-label="Diff view"> | |
| 127 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 127 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | |
| 128 | 128 | </summary> | |
| 129 | 129 | <div class="menu-pop" role="group" aria-label="Diff view"> | |
| 130 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 130 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | |
| 131 | 131 | </a> | |
| 132 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 132 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | |
| 133 | 133 | </a> | |
| 134 | 134 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | |
| 135 | 135 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | |
| @@ -124,12 +124,12 @@ | |||||
| 124 | </span> | 124 | </span> | ||
| 125 | <details class="menu vmenu"> | 125 | <details class="menu vmenu"> | ||
| 126 | <summary class="btn" aria-label="Diff view"> | 126 | <summary class="btn" aria-label="Diff view"> | ||
| 127 | - | <span hx-live="this.textContent = q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> | 127 | + | <span hx-live="this.textContent = q('#diffs').attr['data-mobile'] !== 'true' && q('#diffs').attr['data-diff'] === 'split' ? 'Split' : 'Unified'">Unified</span> |
| 128 | </summary> | 128 | </summary> | ||
| 129 | <div class="menu-pop" role="group" aria-label="Diff view"> | 129 | <div class="menu-pop" role="group" aria-label="Diff view"> | ||
| 130 | - | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> | 130 | + | <a href="/diffs/commit/SHA40?view=unified" class="btn vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" data-fire="unified" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Unified<kbd aria-hidden="true">u</kbd> |
| 131 | </a> | 131 | </a> | ||
| 132 | - | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" data-fire="split" :aria-current="q('#diffs').attr['data-diff'] === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> | 132 | + | <a href="/diffs/commit/SHA40?view=split" class="btn vbtn" data-view="split" aria-keyshortcuts="d" :aria-disabled="q('#diffs').attr['data-mobile'] === 'true' ? 'true' : false" :title="q('#diffs').attr['data-mobile'] === 'true' ? 'Split view requires a wider screen' : 'Split view'" data-fire="split" :aria-current="(q('#diffs').attr['data-mobile'] === 'true' ? 'unified' : q('#diffs').attr['data-diff']) === this.dataset.view ? 'true' : false">Split<kbd aria-hidden="true">d</kbd> |
| 133 | </a> | 133 | </a> | ||
| 134 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | 134 | <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn"> | ||
| 135 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | 135 | <span hx-live="this.textContent = q('[data-file][open]').count || !q('[data-file]').count ? 'Collapse all' : 'Expand all'">Collapse all</span> | ||
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)) | ||