detached 97c558f

Merge branch 'feat/mr-view-ux-mobile' into feat/mr-view-ux

# Conflicts:
#	internal/adapters/web/jstest/mr.test.mjs
#	internal/adapters/web/static/js/mr.js
commit
97c558ff0731b75b5aaa8685f4dd6f4ea897afe3
author
brice · · 2026-10-20 05:15 UTC
committer
brice · · 2026-10-20 05:15 UTC
merge of
966bf44 + d92c6dc · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
15 files · +480 -211
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 +106 -0
@@ -135,3 +135,109 @@ test('footer tabs switch panels, support arrows, and open on checks hash', () =>
135135 globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd;
136136 }
137137 });
138+
139+test('touch range: first tap anchors, second tap opens the sorted range', () => {
140+ const base = 'https://forge.test/demo/merge-requests/1';
141+ const first = base + '?on=new:24:a.go#f1';
142+ const last = base + '?on=new:10:a.go#f1';
143+ assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null });
144+ const result = C.touchRange(first, last, base);
145+ assert.equal(result.start, null);
146+ assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go');
147+ assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first });
148+});
149+
150+test('touch range: a different file, side or request becomes a new anchor', () => {
151+ const base = 'https://forge.test/demo/merge-requests/1';
152+ const first = base + '?on=new:24:a.go#f1';
153+ for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) {
154+ assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null });
155+ }
156+});
157+
158+test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => {
159+ const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26';
160+ assert.equal(C.mobileURL(href, 'split', false), null);
161+ assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS');
162+ assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop');
163+ const url = new URL(C.mobileURL(href, 'split', true));
164+ assert.equal(url.searchParams.get('view'), 'unified');
165+ assert.equal(url.searchParams.get('from'), '25');
166+ assert.equal(url.searchParams.get('on'), 'new:3:a.go');
167+ assert.equal(url.hash, '#f26');
168+});
169+
170+test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => {
171+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]]));
172+ const make = () => ({
173+ attrs: {}, events: {}, children: [], textContent: '',
174+ setAttribute(k, v) { this.attrs[k] = v; },
175+ removeAttribute(k) { delete this.attrs[k]; },
176+ addEventListener(k, fn) { this.events[k] = fn; },
177+ append(...nodes) { this.children.push(...nodes); },
178+ prepend(node) { this.children.unshift(node); },
179+ querySelector() { return null; }, querySelectorAll() { return []; },
180+ });
181+ const diffs = make();
182+ diffs.dataset = { diff: 'split' };
183+ const base = 'https://forge.test/demo/merge-requests/1';
184+ const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) });
185+ const a = line(20), b = line(10);
186+ let loaded = false;
187+ diffs.querySelector = () => loaded ? a : null;
188+ let assigned = null;
189+ globalThis.document = {
190+ querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null,
191+ querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(),
192+ };
193+ globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } };
194+ globalThis.addEventListener = () => {};
195+ let resize;
196+ const media = { matches: true, addEventListener(type, fn) { resize = fn; } };
197+ globalThis.matchMedia = () => media;
198+ globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; };
199+ globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null };
200+ const click = (link, modifiers = {}) => {
201+ const e = { target: { closest: () => link }, prevented: false, stopped: false,
202+ preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers };
203+ diffs.events.click(e);
204+ return e;
205+ };
206+ try {
207+ window.ermiteMR.start();
208+ const bar = diffs.children[0];
209+ const [button, status] = bar.children;
210+ assert.equal(bar.hidden, true, 'no commentable links in the initial window');
211+ assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load');
212+ await window.ermiteMR.load({ dataset: { src: '/files?from=25' } });
213+ assert.equal(bar.hidden, false, 'lazy window refresh reveals range control');
214+ assert.equal(diffs.dataset.mobile, 'true');
215+ assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained');
216+ media.matches = false;
217+ resize();
218+ assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout');
219+ assert.equal(diffs.dataset.diff, 'split');
220+ assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched');
221+ button.events.click();
222+ assert.equal(button.attrs['aria-pressed'], 'true');
223+ assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched');
224+ const event = click(a);
225+ assert.equal(event.prevented, true);
226+ assert.equal(event.stopped, true);
227+ assert.ok('data-range-start' in a.attrs);
228+ assert.match(status.textContent, /20/);
229+ click(b);
230+ assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go');
231+ assert.equal('data-range-start' in a.attrs, false);
232+ click(a);
233+ button.events.click();
234+ assert.equal(button.attrs['aria-pressed'], 'false');
235+ assert.equal(status.textContent, '');
236+ assert.equal('data-range-start' in a.attrs, false);
237+ assert.equal(click(b).prevented, false);
238+ } finally {
239+ for (const [k, v] of Object.entries(saved)) {
240+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
241+ }
242+ }
243+});
@@ -135,3 +135,109 @@ test('footer tabs switch panels, support arrows, and open on checks hash', () =>
135 globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd;135 globalThis.document=oldDocument; globalThis.location=oldLocation; globalThis.addEventListener=oldAdd;
136 }136 }
137 });137 });
138+
139+test('touch range: first tap anchors, second tap opens the sorted range', () => {
140+ const base = 'https://forge.test/demo/merge-requests/1';
141+ const first = base + '?on=new:24:a.go#f1';
142+ const last = base + '?on=new:10:a.go#f1';
143+ assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null });
144+ const result = C.touchRange(first, last, base);
145+ assert.equal(result.start, null);
146+ assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go');
147+ assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first });
148+});
149+
150+test('touch range: a different file, side or request becomes a new anchor', () => {
151+ const base = 'https://forge.test/demo/merge-requests/1';
152+ const first = base + '?on=new:24:a.go#f1';
153+ for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) {
154+ assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null });
155+ }
156+});
157+
158+test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => {
159+ const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26';
160+ assert.equal(C.mobileURL(href, 'split', false), null);
161+ assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS');
162+ assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop');
163+ const url = new URL(C.mobileURL(href, 'split', true));
164+ assert.equal(url.searchParams.get('view'), 'unified');
165+ assert.equal(url.searchParams.get('from'), '25');
166+ assert.equal(url.searchParams.get('on'), 'new:3:a.go');
167+ assert.equal(url.hash, '#f26');
168+});
169+
170+test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => {
171+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]]));
172+ const make = () => ({
173+ attrs: {}, events: {}, children: [], textContent: '',
174+ setAttribute(k, v) { this.attrs[k] = v; },
175+ removeAttribute(k) { delete this.attrs[k]; },
176+ addEventListener(k, fn) { this.events[k] = fn; },
177+ append(...nodes) { this.children.push(...nodes); },
178+ prepend(node) { this.children.unshift(node); },
179+ querySelector() { return null; }, querySelectorAll() { return []; },
180+ });
181+ const diffs = make();
182+ diffs.dataset = { diff: 'split' };
183+ const base = 'https://forge.test/demo/merge-requests/1';
184+ const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) });
185+ const a = line(20), b = line(10);
186+ let loaded = false;
187+ diffs.querySelector = () => loaded ? a : null;
188+ let assigned = null;
189+ globalThis.document = {
190+ querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null,
191+ querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(),
192+ };
193+ globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } };
194+ globalThis.addEventListener = () => {};
195+ let resize;
196+ const media = { matches: true, addEventListener(type, fn) { resize = fn; } };
197+ globalThis.matchMedia = () => media;
198+ globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; };
199+ globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null };
200+ const click = (link, modifiers = {}) => {
201+ const e = { target: { closest: () => link }, prevented: false, stopped: false,
202+ preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers };
203+ diffs.events.click(e);
204+ return e;
205+ };
206+ try {
207+ window.ermiteMR.start();
208+ const bar = diffs.children[0];
209+ const [button, status] = bar.children;
210+ assert.equal(bar.hidden, true, 'no commentable links in the initial window');
211+ assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load');
212+ await window.ermiteMR.load({ dataset: { src: '/files?from=25' } });
213+ assert.equal(bar.hidden, false, 'lazy window refresh reveals range control');
214+ assert.equal(diffs.dataset.mobile, 'true');
215+ assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained');
216+ media.matches = false;
217+ resize();
218+ assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout');
219+ assert.equal(diffs.dataset.diff, 'split');
220+ assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched');
221+ button.events.click();
222+ assert.equal(button.attrs['aria-pressed'], 'true');
223+ assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched');
224+ const event = click(a);
225+ assert.equal(event.prevented, true);
226+ assert.equal(event.stopped, true);
227+ assert.ok('data-range-start' in a.attrs);
228+ assert.match(status.textContent, /20/);
229+ click(b);
230+ assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go');
231+ assert.equal('data-range-start' in a.attrs, false);
232+ click(a);
233+ button.events.click();
234+ assert.equal(button.attrs['aria-pressed'], 'false');
235+ assert.equal(status.textContent, '');
236+ assert.equal('data-range-start' in a.attrs, false);
237+ assert.equal(click(b).prevented, false);
238+ } finally {
239+ for (const [k, v] of Object.entries(saved)) {
240+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
241+ }
242+ }
243+});
A internal/adapters/web/static/css/85-mobile.css +51 -0
@@ -0,0 +1,51 @@
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) { padding-inline: 4px; }
17+ body:has(#diffs[data-mr]) .mr-desc.prose > pre { margin-inline: -4px; padding-inline: 0; max-width: calc(100% + 8px); }
18+ #diffs[data-mr] .dfile > summary { padding-inline: 0; border-inline: 0; border-radius: 0; }
19+ #diffs[data-mr] :is(.dscroll, .code) { overflow-x: auto; overscroll-behavior-x: contain; padding-inline: 0; }
20+ #diffs[data-mr] .dt .ln { width: calc(var(--lnw) + .5ch); min-width: calc(var(--lnw) + .5ch); padding-inline: .25ch; }
21+ #diffs[data-mr] .dt .m { width: 1ch; }
22+ #diffs[data-mr][data-diff] .uni { display: table; }
23+ #diffs[data-mr][data-diff] .spl { display: none; }
24+ #diffs[data-mr] .fnote td { padding-inline: 0; }
25+ #diffs[data-mr] :is(.cform, .cmt) { width: min(65ch, calc(100vw - 5rem)); max-width: calc(100vw - 5rem); overflow-wrap: anywhere; }
26+ body:has(#diffs[data-mr]) :is(input, textarea, select) { font-size: 16px; }
27+ body:has(#diffs[data-mr]) .f-btns { flex-wrap: wrap; }
28+ body:has(#diffs[data-mr]) .menu .menu-pop {
29+ position: fixed; inset: auto 0 0; width: auto; max-width: 100%;
30+ max-height: 75dvh; overflow-y: auto; border-radius: var(--r) var(--r) 0 0;
31+ padding-bottom: max(var(--s4), env(safe-area-inset-bottom));
32+ }
33+ body:has(#diffs[data-mr]) .mr-bar { position: static; }
34+ /* A wrapping toolbar has no reliable fixed sticky height. */
35+ body:has(#diffs[data-mr]) { --bar-h: 0px; }
36+ }
37+
38+ @media (pointer: coarse), (hover: none) {
39+ .mr-touch-range { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
40+ /* Real, disjoint cells: never expand invisible targets into other lines
41+ or into the neighbouring old/new gutter. */
42+ #diffs[data-mr] .dt { line-height: max(var(--lh-code), 40px); }
43+ #diffs[data-mr] .dt .ln { width: max(calc(var(--lnw) + .5ch), 40px); min-width: 40px; padding: 0; }
44+ #diffs[data-mr] .spl .c-ln { width: max(calc(var(--lnw) + .5ch), 40px); }
45+ #diffs[data-mr] .ln a { display: block; width: 100%; min-height: 40px; text-align: center; text-decoration: underline; }
46+ .mr-touch-range[hidden] { display: none; }
47+ #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
48+ 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); }
49+ #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
50+ }
51+}
@@ -0,0 +1,51 @@
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) { padding-inline: 4px; }
17+ body:has(#diffs[data-mr]) .mr-desc.prose > pre { margin-inline: -4px; padding-inline: 0; max-width: calc(100% + 8px); }
18+ #diffs[data-mr] .dfile > summary { padding-inline: 0; border-inline: 0; border-radius: 0; }
19+ #diffs[data-mr] :is(.dscroll, .code) { overflow-x: auto; overscroll-behavior-x: contain; padding-inline: 0; }
20+ #diffs[data-mr] .dt .ln { width: calc(var(--lnw) + .5ch); min-width: calc(var(--lnw) + .5ch); padding-inline: .25ch; }
21+ #diffs[data-mr] .dt .m { width: 1ch; }
22+ #diffs[data-mr][data-diff] .uni { display: table; }
23+ #diffs[data-mr][data-diff] .spl { display: none; }
24+ #diffs[data-mr] .fnote td { padding-inline: 0; }
25+ #diffs[data-mr] :is(.cform, .cmt) { width: min(65ch, calc(100vw - 5rem)); max-width: calc(100vw - 5rem); overflow-wrap: anywhere; }
26+ body:has(#diffs[data-mr]) :is(input, textarea, select) { font-size: 16px; }
27+ body:has(#diffs[data-mr]) .f-btns { flex-wrap: wrap; }
28+ body:has(#diffs[data-mr]) .menu .menu-pop {
29+ position: fixed; inset: auto 0 0; width: auto; max-width: 100%;
30+ max-height: 75dvh; overflow-y: auto; border-radius: var(--r) var(--r) 0 0;
31+ padding-bottom: max(var(--s4), env(safe-area-inset-bottom));
32+ }
33+ body:has(#diffs[data-mr]) .mr-bar { position: static; }
34+ /* A wrapping toolbar has no reliable fixed sticky height. */
35+ body:has(#diffs[data-mr]) { --bar-h: 0px; }
36+ }
37+
38+ @media (pointer: coarse), (hover: none) {
39+ .mr-touch-range { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s2); }
40+ /* Real, disjoint cells: never expand invisible targets into other lines
41+ or into the neighbouring old/new gutter. */
42+ #diffs[data-mr] .dt { line-height: max(var(--lh-code), 40px); }
43+ #diffs[data-mr] .dt .ln { width: max(calc(var(--lnw) + .5ch), 40px); min-width: 40px; padding: 0; }
44+ #diffs[data-mr] .spl .c-ln { width: max(calc(var(--lnw) + .5ch), 40px); }
45+ #diffs[data-mr] .ln a { display: block; width: 100%; min-height: 40px; text-align: center; text-decoration: underline; }
46+ .mr-touch-range[hidden] { display: none; }
47+ #diffs[data-mr] .ln a[data-range-start] { outline: 2px solid var(--focus); background: var(--sel); }
48+ 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); }
49+ #diffs[data-mr] .cform { scroll-margin-block: calc(var(--hdr-h) + 12px); }
50+ }
51+}
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 +79 -1
@@ -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]');
@@ -67,6 +81,7 @@ window.ermiteMR = (() => {
6781 const $ = (sel, root = document) => root.querySelector(sel);
6882 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
6983 const loading = new Map(); // src -> promise of the swap
84+ let refreshTouchComments = () => {};
7085 let moved = false;
7186 let lazyIO = null; // the observer of the placeholders (watch)
7287 let revealGen = 0; // the last reveal wins the scroll
@@ -115,6 +130,7 @@ window.ermiteMR = (() => {
115130 window.htmx?.process(f);
116131 window.ermite?.watchFile?.(f, old);
117132 }
133+ refreshTouchComments();
118134 }
119135
120136 // stale: the diff changed under the page. The placeholders say so; no file
@@ -137,7 +153,7 @@ window.ermiteMR = (() => {
137153 // A page with both layouts says which shows in data-diff; a large diff
138154 // rendered in one layout only says it in data-view.
139155 const diffs = $('#diffs')?.dataset;
140- 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') {
141157 const split = document.getElementById(id + '-s');
142158 if (split) return split;
143159 }
@@ -337,9 +353,71 @@ window.ermiteMR = (() => {
337353 addEventListener('hashchange', hash);
338354 }
339355
356+ function touchComments() {
357+ const diffs = $('#diffs');
358+ const bar = document.createElement('div');
359+ bar.className = 'mr-touch-range';
360+ const button = document.createElement('button');
361+ button.type = 'button';
362+ button.className = 'btn';
363+ button.textContent = 'Select line range';
364+ button.setAttribute('aria-pressed', 'false');
365+ const status = document.createElement('span');
366+ status.setAttribute('role', 'status');
367+ bar.append(button, status);
368+ diffs.prepend(bar);
369+ refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
370+ refreshTouchComments();
371+ let enabled = false, start = null, marker = null;
372+ const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
373+ button.addEventListener('click', () => {
374+ enabled = !enabled;
375+ clear();
376+ button.setAttribute('aria-pressed', String(enabled));
377+ status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
378+ });
379+ diffs.addEventListener('click', (e) => {
380+ const link = e.target.closest?.('.ln a[href*="on="]');
381+ if (!enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
382+ e.preventDefault();
383+ e.stopPropagation(); // do not alter the desktop shift-click anchor
384+ const next = core.touchRange(start, link.href, location.href);
385+ clear();
386+ if (next.url) { location.assign(next.url); return; }
387+ start = next.start;
388+ marker = link;
389+ marker.setAttribute('data-range-start', '');
390+ status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
391+ });
392+ // The server opens the line form. Scroll after layout without stealing
393+ // focus or forcing a mobile keyboard to appear.
394+ if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
395+ const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
396+ form?.scrollIntoView({ block: 'center' });
397+ });
398+ }
399+
340400 function start() {
341401 if (!$('#diffs[data-mr]')) return;
342402 panels();
403+ // Large diffs may contain only the split table. Ask the existing server
404+ // route for unified rather than writing the saved session preference.
405+ const narrow = window.matchMedia?.('(max-width: 720px)');
406+ const unify = () => {
407+ $('#diffs').dataset.mobile = String(!!narrow?.matches);
408+ const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
409+ if (url) { location.replace(url); return true; }
410+ return false;
411+ };
412+ if (unify()) return;
413+ narrow?.addEventListener('change', unify);
414+ addEventListener('click', (e) => {
415+ if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
416+ e.preventDefault();
417+ e.stopImmediatePropagation();
418+ }
419+ }, true);
420+ touchComments();
343421 let folded = false;
344422 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
345423 if (folded) foldFiles(true);
@@ -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]');
@@ -67,6 +81,7 @@ window.ermiteMR = (() => {
67 const $ = (sel, root = document) => root.querySelector(sel);81 const $ = (sel, root = document) => root.querySelector(sel);
68 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];82 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
69 const loading = new Map(); // src -> promise of the swap83 const loading = new Map(); // src -> promise of the swap
84+ let refreshTouchComments = () => {};
70 let moved = false;85 let moved = false;
71 let lazyIO = null; // the observer of the placeholders (watch)86 let lazyIO = null; // the observer of the placeholders (watch)
72 let revealGen = 0; // the last reveal wins the scroll87 let revealGen = 0; // the last reveal wins the scroll
@@ -115,6 +130,7 @@ window.ermiteMR = (() => {
115 window.htmx?.process(f);130 window.htmx?.process(f);
116 window.ermite?.watchFile?.(f, old);131 window.ermite?.watchFile?.(f, old);
117 }132 }
133+ refreshTouchComments();
118 }134 }
119 135
120 // 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
@@ -137,7 +153,7 @@ window.ermiteMR = (() => {
137 // 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
138 // rendered in one layout only says it in data-view.154 // rendered in one layout only says it in data-view.
139 const diffs = $('#diffs')?.dataset;155 const diffs = $('#diffs')?.dataset;
140- 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') {
141 const split = document.getElementById(id + '-s');157 const split = document.getElementById(id + '-s');
142 if (split) return split;158 if (split) return split;
143 }159 }
@@ -337,9 +353,71 @@ window.ermiteMR = (() => {
337 addEventListener('hashchange', hash);353 addEventListener('hashchange', hash);
338 }354 }
339 355
356+ function touchComments() {
357+ const diffs = $('#diffs');
358+ const bar = document.createElement('div');
359+ bar.className = 'mr-touch-range';
360+ const button = document.createElement('button');
361+ button.type = 'button';
362+ button.className = 'btn';
363+ button.textContent = 'Select line range';
364+ button.setAttribute('aria-pressed', 'false');
365+ const status = document.createElement('span');
366+ status.setAttribute('role', 'status');
367+ bar.append(button, status);
368+ diffs.prepend(bar);
369+ refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
370+ refreshTouchComments();
371+ let enabled = false, start = null, marker = null;
372+ const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
373+ button.addEventListener('click', () => {
374+ enabled = !enabled;
375+ clear();
376+ button.setAttribute('aria-pressed', String(enabled));
377+ status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
378+ });
379+ diffs.addEventListener('click', (e) => {
380+ const link = e.target.closest?.('.ln a[href*="on="]');
381+ if (!enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
382+ e.preventDefault();
383+ e.stopPropagation(); // do not alter the desktop shift-click anchor
384+ const next = core.touchRange(start, link.href, location.href);
385+ clear();
386+ if (next.url) { location.assign(next.url); return; }
387+ start = next.start;
388+ marker = link;
389+ marker.setAttribute('data-range-start', '');
390+ status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
391+ });
392+ // The server opens the line form. Scroll after layout without stealing
393+ // focus or forcing a mobile keyboard to appear.
394+ if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
395+ const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
396+ form?.scrollIntoView({ block: 'center' });
397+ });
398+ }
399+
340 function start() {400 function start() {
341 if (!$('#diffs[data-mr]')) return;401 if (!$('#diffs[data-mr]')) return;
342 panels();402 panels();
403+ // Large diffs may contain only the split table. Ask the existing server
404+ // route for unified rather than writing the saved session preference.
405+ const narrow = window.matchMedia?.('(max-width: 720px)');
406+ const unify = () => {
407+ $('#diffs').dataset.mobile = String(!!narrow?.matches);
408+ const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
409+ if (url) { location.replace(url); return true; }
410+ return false;
411+ };
412+ if (unify()) return;
413+ narrow?.addEventListener('change', unify);
414+ addEventListener('click', (e) => {
415+ if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
416+ e.preventDefault();
417+ e.stopImmediatePropagation();
418+ }
419+ }, true);
420+ touchComments();
343 let folded = false;421 let folded = false;
344 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }422 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
345 if (folded) foldFiles(true);423 if (folded) foldFiles(true);
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>

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 44

↑↓ moveenter openesc close