detached 16cdb71

Merge b46e8c113e6ca1da6e02aee22255c67f8729c736 into cc7ba64ee52412f41c3ce5207bac7e47b9f01f30

commit
16cdb716bf7d9f2729ce576567094090aef333f4
author
ermite · · 2026-10-20 05:18 UTC
merge of
cc7ba64 + b46e8c1 · diff against the first parent
signature
unsigned
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
104104 rewrite. Merge-request details include at most `ports.MaxMergeRequestCommits`
105105 (100) commits. These history queries remain on the merge-request service
106106 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 service105 (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) {
434434 }
435435
436436 // 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
438438 // with the checks section (same event, fragments.go).
439439 templ MRChecksSum(c ChecksSummary) {
440440 <a class="ck-sum mr-ck" id="mr-ck" href="#mr-checks" title={ c.Title }>
@@ -447,7 +447,7 @@ templ MRChecksSum(c ChecksSummary) {
447447 </a>
448448 }
449449
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.
451451 templ MRChecks(c ChecksSection) {
452452 <section aria-labelledby="r-checks" id="mr-checks">
453453 <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 above436 // 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 it437+// 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
8181 return sum
8282 }
8383
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
8585 // the repository that applies to them.
8686 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
8787 sec := ChecksSection{}
@@ -81,7 +81,7 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
81 return sum81 return sum
82 }82 }
83 83
84-// checksSection is the checks of the rail of a merge request, with the rule of84+// 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) {
117117 <details class="menu vmenu">
118118 <summary class="btn" aria-label="Diff view">
119119 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>
121121 } else {
122122 { viewLabel(p.View) }
123123 }
@@ -139,12 +139,16 @@ templ viewLink(p DiffView, view, label, key, href string) {
139139 class="btn vbtn"
140140 data-view={ view }
141141 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+ }
142146 if p.View == view {
143147 aria-current="true"
144148 }
145149 if p.Both {
146150 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"
148152 }
149153 >{ label }<kbd aria-hidden="true">{ key }</kbd></a>
150154 }
@@ -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
358358 assert.ok(fresh.hasAttribute('data-cursor'));
359359 }
360360 });
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
9292 globalThis.requestAnimationFrame=savedRAF;
9393 }
9494 });
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 {
291291
292292 Commits []MRCommit
293293 CommitsNote string
294+ CommitCount string
294295 Modes []string
295296 ChecksBlock bool
296297 // 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
734735 })
735736 }
736737 lastRead := res.LastReadID.String()
738+ p.CommitCount = itoa(len(p.Commits))
737739 if res.CommitsTruncated {
740+ p.CommitCount += "+"
738741 p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
739742 }
740743
@@ -291,6 +291,7 @@ type MRPage struct {
291 291
292 Commits []MRCommit292 Commits []MRCommit
293 CommitsNote string293 CommitsNote string
294+ CommitCount string
294 Modes []string295 Modes []string
295 ChecksBlock bool296 ChecksBlock bool
296 // Checks are the CI checks of the head of the request, ChecksSum their297 // 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) {
108108 }
109109
110110 // 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.
113114 templ MergeRequest(l Layout, p MRPage) {
114115 @Shell(l, mrRail(p)) {
115116 <section class="mr-card">
@@ -147,6 +148,7 @@ templ MergeRequest(l Layout, p MRPage) {
147148 @templ.Raw(string(p.Desc))
148149 </article>
149150 }
151+ @mrFooter(p)
150152 </section>
151153 if p.Deleting != "" {
152154 <p class="note" role="status">merged. the branch { p.Deleting } is being deleted.</p>
@@ -366,6 +368,65 @@ templ commentForm(f CommentForm, suffix string) {
366368 </form>
367369 }
368370
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+
369430 templ mrRail(p MRPage) {
370431 <section aria-labelledby="r-files" class="r-files">
371432 <h2 id="r-files">
@@ -391,51 +452,6 @@ templ mrRail(p MRPage) {
391452 </ul>
392453 </section>
393454 }
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>
439455 }
440456
441457 // mrBar is the bar above the diff, which follows the review: the totals, the
@@ -450,8 +466,8 @@ templ mrBar(p MRPage) {
450466 @MRChecksSum(p.ChecksSum)
451467 if p.Box != nil {
452468 @mergeMenu(p)
453- @moreMenu(p)
454469 }
470+ @moreMenu(p)
455471 </div>
456472 }
457473
@@ -474,18 +490,9 @@ templ mergeMenu(p MRPage) {
474490 <kbd aria-hidden="true">M</kbd>
475491 </summary>
476492 <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+ }
489496 if p.Box.Error != "" {
490497 <p class="d-err" role="alert">{ p.Box.Error }</p>
491498 }
@@ -519,15 +526,6 @@ templ mergeMenu(p MRPage) {
519526 </label>
520527 }
521528 </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- }
531529 if p.Box.CanDeleteBranch {
532530 <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>
533531 }
@@ -589,26 +587,52 @@ templ MRMergeabilityFragment(v MergeabilityView) {
589587 @mrMergeabilitySlot(v)
590588 }
591589
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.
594592 templ moreMenu(p MRPage) {
595593 <details class="menu omenu">
596594 <summary class="btn icon" aria-label="More actions" title="more actions">⋯</summary>
597595 <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>
602629 </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+ }
612636 }
613637 </div>
614638 </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 with110 // 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 merge111+// 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, the457 // 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 head590+// 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
44
55 import (
66 "context"
7+ "time"
78 "testing"
89
910 "github.com/stretchr/testify/assert"
@@ -128,3 +129,59 @@ func TestMergeabilityLiveEvents(t *testing.T) {
128129 require.NoError(t, err)
129130 assert.Empty(t, fs)
130131 }
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) {
145145 func TestMergeRequestCommitsShowTheSignatureBadge(t *testing.T) {
146146 t.Parallel()
147147 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{
149149 {Short: "aaaaaaa", Href: "/r/commit/a", Subject: "signed", Sig: signed},
150150 {Short: "bbbbbbb", Href: "/r/commit/b", Subject: "unsigned"},
151151 }}))
@@ -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 @@
88 & > .mr-desc.prose { border: 0; border-top: 1px solid var(--border); border-radius: 0; }
99 }
1010
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+
1128 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
1229 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
1330 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
@@ -112,11 +129,11 @@
112129 & .d-or > summary:hover { color: var(--ink-hi); }
113130 & .d-or[open] > .clone { margin-top: var(--s2); }
114131 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
132+ & #mr-signing code { overflow-wrap: anywhere; }
115133 }
116134
117135 /* 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
120137 that the blocks below it stay in reach. Only on a two-column layout. */
121138 @media (min-width: 1081px) {
122139 .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 the135 /* 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, commits136+ 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 @@
183183 layout(mode) {
184184 const el = $('[data-diff]');
185185 if (!el) return;
186+ if (el.hasAttribute('data-mr') && el.dataset.mobile === 'true') return;
186187 el.dataset.diff = mode;
187188 try { sessionStorage.setItem('diff', mode); } catch { /* storage refused: the layout holds for this page */ }
188189 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 = (() => {
2727 b.searchParams.set('on', `${y[1]}:${nums[0]}-${nums[1]}:${y[3]}`);
2828 return b.href;
2929 },
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+ },
3044 quote(button) {
3145 const thread = button.closest('.cmt').closest('[data-thread]');
3246 const details = thread?.querySelector(':scope > details[data-reply]');
@@ -49,6 +63,13 @@ window.ermiteMR = (() => {
4963 if (/^[1-9]$/.test(key) && Number(key) <= modes) return { mode: Number(key) - 1 };
5064 return null;
5165 },
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+ },
5273 // step moves an index within [0, n).
5374 step(i, d, n) {
5475 if (n === 0) return -1;
@@ -60,6 +81,7 @@ window.ermiteMR = (() => {
6081 const $ = (sel, root = document) => root.querySelector(sel);
6182 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
6283 const loading = new Map(); // src -> promise of the swap
84+ let refreshTouchComments = () => {};
6385 let moved = false;
6486 let lazyIO = null; // the observer of the placeholders (watch)
6587 let revealGen = 0; // the last reveal wins the scroll
@@ -108,6 +130,7 @@ window.ermiteMR = (() => {
108130 window.htmx?.process(f);
109131 window.ermite?.watchFile?.(f, old);
110132 }
133+ refreshTouchComments();
111134 }
112135
113136 // stale: the diff changed under the page. The placeholders say so; no file
@@ -130,7 +153,7 @@ window.ermiteMR = (() => {
130153 // A page with both layouts says which shows in data-diff; a large diff
131154 // rendered in one layout only says it in data-view.
132155 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') {
134157 const split = document.getElementById(id + '-s');
135158 if (split) return split;
136159 }
@@ -285,8 +308,126 @@ window.ermiteMR = (() => {
285308 reveal(id);
286309 }
287310
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+
288410 function start() {
289411 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();
290431 let folded = false;
291432 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
292433 if (folded) foldFiles(true);
@@ -316,7 +457,7 @@ window.ermiteMR = (() => {
316457 });
317458 }
318459
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 };
320461 })();
321462
322463 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 swap83 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 scroll87 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 file136 // 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 diff153 // 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 @@
127127 </span>
128128 <details class="menu vmenu">
129129 <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>
131131 </summary>
132132 <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>
134134 </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>
136136 </a>
137137 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
138138 <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 @@
123123 </span>
124124 <details class="menu vmenu">
125125 <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>
127127 </summary>
128128 <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>
130130 </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>
132132 </a>
133133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
134134 <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 @@
122122 </span>
123123 <details class="menu vmenu">
124124 <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>
126126 </summary>
127127 <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>
129129 </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>
131131 </a>
132132 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
133133 <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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
129129 </span>
130130 <details class="menu vmenu">
131131 <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>
133133 </summary>
134134 <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>
136136 </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>
138138 </a>
139139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
140140 <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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
129129 </span>
130130 <details class="menu vmenu">
131131 <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>
133133 </summary>
134134 <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>
136136 </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>
138138 </a>
139139 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
140140 <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 &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</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 @@
123123 </span>
124124 <details class="menu vmenu">
125125 <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>
127127 </summary>
128128 <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>
130130 </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>
132132 </a>
133133 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
134134 <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 @@
121121 </span>
122122 <details class="menu vmenu">
123123 <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>
125125 </summary>
126126 <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>
128128 </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>
130130 </a>
131131 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
132132 <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 @@
124124 </span>
125125 <details class="menu vmenu">
126126 <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>
128128 </summary>
129129 <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>
131131 </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>
133133 </a>
134134 <button type="button" aria-keyshortcuts="x" data-js data-fire="collapse" class="btn">
135135 <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) {
335335 all := b.get(base, as(token))
336336 assert.Contains(t, all.body, `href="/proj/merge-requests/1/since"`)
337337 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")
339339 assert.Contains(t, all.body, "data-read>Mark read")
340340 assert.Contains(t, all.body, "outdated", "the notes were written on the earlier head")
341341 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))

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close