detached d92c6dc

feat(web): make the MR view edge to edge and touch friendly on mobile

Narrow screens drop side margins, force the unified diff without changing
the saved preference, and open menus as bottom sheets. Coarse pointers get
tall line-number targets and a tap-to-select line range for comments.
commit
d92c6dc3247ee58d9be537f6224e1cd009f97236
author
brice · · 2026-10-20 05:11 UTC
parent
cc7ba64
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
@@ -92,3 +92,109 @@ for (const dataset of [{diff:'split'}, {view:'split'}]) test(`reveal selects the
9292 globalThis.requestAnimationFrame=savedRAF;
9393 }
9494 });
95+
96+test('touch range: first tap anchors, second tap opens the sorted range', () => {
97+ const base = 'https://forge.test/demo/merge-requests/1';
98+ const first = base + '?on=new:24:a.go#f1';
99+ const last = base + '?on=new:10:a.go#f1';
100+ assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null });
101+ const result = C.touchRange(first, last, base);
102+ assert.equal(result.start, null);
103+ assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go');
104+ assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first });
105+});
106+
107+test('touch range: a different file, side or request becomes a new anchor', () => {
108+ const base = 'https://forge.test/demo/merge-requests/1';
109+ const first = base + '?on=new:24:a.go#f1';
110+ for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) {
111+ assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null });
112+ }
113+});
114+
115+test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => {
116+ const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26';
117+ assert.equal(C.mobileURL(href, 'split', false), null);
118+ assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS');
119+ assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop');
120+ const url = new URL(C.mobileURL(href, 'split', true));
121+ assert.equal(url.searchParams.get('view'), 'unified');
122+ assert.equal(url.searchParams.get('from'), '25');
123+ assert.equal(url.searchParams.get('on'), 'new:3:a.go');
124+ assert.equal(url.hash, '#f26');
125+});
126+
127+test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => {
128+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]]));
129+ const make = () => ({
130+ attrs: {}, events: {}, children: [], textContent: '',
131+ setAttribute(k, v) { this.attrs[k] = v; },
132+ removeAttribute(k) { delete this.attrs[k]; },
133+ addEventListener(k, fn) { this.events[k] = fn; },
134+ append(...nodes) { this.children.push(...nodes); },
135+ prepend(node) { this.children.unshift(node); },
136+ querySelector() { return null; }, querySelectorAll() { return []; },
137+ });
138+ const diffs = make();
139+ diffs.dataset = { diff: 'split' };
140+ const base = 'https://forge.test/demo/merge-requests/1';
141+ const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) });
142+ const a = line(20), b = line(10);
143+ let loaded = false;
144+ diffs.querySelector = () => loaded ? a : null;
145+ let assigned = null;
146+ globalThis.document = {
147+ querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null,
148+ querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(),
149+ };
150+ globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } };
151+ globalThis.addEventListener = () => {};
152+ let resize;
153+ const media = { matches: true, addEventListener(type, fn) { resize = fn; } };
154+ globalThis.matchMedia = () => media;
155+ globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; };
156+ globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null };
157+ const click = (link, modifiers = {}) => {
158+ const e = { target: { closest: () => link }, prevented: false, stopped: false,
159+ preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers };
160+ diffs.events.click(e);
161+ return e;
162+ };
163+ try {
164+ window.ermiteMR.start();
165+ const bar = diffs.children[0];
166+ const [button, status] = bar.children;
167+ assert.equal(bar.hidden, true, 'no commentable links in the initial window');
168+ assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load');
169+ await window.ermiteMR.load({ dataset: { src: '/files?from=25' } });
170+ assert.equal(bar.hidden, false, 'lazy window refresh reveals range control');
171+ assert.equal(diffs.dataset.mobile, 'true');
172+ assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained');
173+ media.matches = false;
174+ resize();
175+ assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout');
176+ assert.equal(diffs.dataset.diff, 'split');
177+ assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched');
178+ button.events.click();
179+ assert.equal(button.attrs['aria-pressed'], 'true');
180+ assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched');
181+ const event = click(a);
182+ assert.equal(event.prevented, true);
183+ assert.equal(event.stopped, true);
184+ assert.ok('data-range-start' in a.attrs);
185+ assert.match(status.textContent, /20/);
186+ click(b);
187+ assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go');
188+ assert.equal('data-range-start' in a.attrs, false);
189+ click(a);
190+ button.events.click();
191+ assert.equal(button.attrs['aria-pressed'], 'false');
192+ assert.equal(status.textContent, '');
193+ assert.equal('data-range-start' in a.attrs, false);
194+ assert.equal(click(b).prevented, false);
195+ } finally {
196+ for (const [k, v] of Object.entries(saved)) {
197+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
198+ }
199+ }
200+});
@@ -92,3 +92,109 @@ 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('touch range: first tap anchors, second tap opens the sorted range', () => {
97+ const base = 'https://forge.test/demo/merge-requests/1';
98+ const first = base + '?on=new:24:a.go#f1';
99+ const last = base + '?on=new:10:a.go#f1';
100+ assert.deepEqual(C.touchRange(null, first, base), { start: first, url: null });
101+ const result = C.touchRange(first, last, base);
102+ assert.equal(result.start, null);
103+ assert.equal(new URL(result.url).searchParams.get('on'), 'new:10-24:a.go');
104+ assert.deepEqual(C.touchRange(first, first, base), { start: null, url: first });
105+});
106+
107+test('touch range: a different file, side or request becomes a new anchor', () => {
108+ const base = 'https://forge.test/demo/merge-requests/1';
109+ const first = base + '?on=new:24:a.go#f1';
110+ for (const next of [first.replace('new:', 'old:'), first.replace('a.go', 'b.go'), first.replace('/1?', '/2?')]) {
111+ assert.deepEqual(C.touchRange(first, next, base), { start: next, url: null });
112+ }
113+});
114+
115+test('mobile unified fallback only redirects single-layout split pages and preserves query/hash', () => {
116+ const href = 'https://forge.test/demo/merge-requests/1?view=split&from=25&on=new:3:a.go#f26';
117+ assert.equal(C.mobileURL(href, 'split', false), null);
118+ assert.equal(C.mobileURL(href, undefined, true), null, 'both layouts are switched by CSS');
119+ assert.equal(C.mobileURL(href, 'unified', true), null, 'no redirect loop');
120+ const url = new URL(C.mobileURL(href, 'split', true));
121+ assert.equal(url.searchParams.get('view'), 'unified');
122+ assert.equal(url.searchParams.get('from'), '25');
123+ assert.equal(url.searchParams.get('on'), 'new:3:a.go');
124+ assert.equal(url.hash, '#f26');
125+});
126+
127+test('touch control: lazy links enable toolbar; ordinary taps pass through; ranges navigate and cancel', async () => {
128+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'localStorage', 'sessionStorage', 'fetch', 'matchMedia'].map((k) => [k, globalThis[k]]));
129+ const make = () => ({
130+ attrs: {}, events: {}, children: [], textContent: '',
131+ setAttribute(k, v) { this.attrs[k] = v; },
132+ removeAttribute(k) { delete this.attrs[k]; },
133+ addEventListener(k, fn) { this.events[k] = fn; },
134+ append(...nodes) { this.children.push(...nodes); },
135+ prepend(node) { this.children.unshift(node); },
136+ querySelector() { return null; }, querySelectorAll() { return []; },
137+ });
138+ const diffs = make();
139+ diffs.dataset = { diff: 'split' };
140+ const base = 'https://forge.test/demo/merge-requests/1';
141+ const line = (n) => Object.assign(make(), { href: `${base}?on=new:${n}:a.go#f1`, textContent: String(n) });
142+ const a = line(20), b = line(10);
143+ let loaded = false;
144+ diffs.querySelector = () => loaded ? a : null;
145+ let assigned = null;
146+ globalThis.document = {
147+ querySelector: (sel) => sel === '#diffs[data-mr]' || sel === '#diffs' ? diffs : null,
148+ querySelectorAll: () => [], createElement: (tag) => tag === 'template' ? { content: { querySelectorAll: () => [] } } : make(),
149+ };
150+ globalThis.location = { href: base, pathname: '/demo/merge-requests/1', hash: '', assign(url) { assigned = url; } };
151+ globalThis.addEventListener = () => {};
152+ let resize;
153+ const media = { matches: true, addEventListener(type, fn) { resize = fn; } };
154+ globalThis.matchMedia = () => media;
155+ globalThis.fetch = async () => { loaded = true; return { ok: true, status: 200, text: async () => '' }; };
156+ globalThis.localStorage = globalThis.sessionStorage = { getItem: () => null };
157+ const click = (link, modifiers = {}) => {
158+ const e = { target: { closest: () => link }, prevented: false, stopped: false,
159+ preventDefault() { this.prevented = true; }, stopPropagation() { this.stopped = true; }, ...modifiers };
160+ diffs.events.click(e);
161+ return e;
162+ };
163+ try {
164+ window.ermiteMR.start();
165+ const bar = diffs.children[0];
166+ const [button, status] = bar.children;
167+ assert.equal(bar.hidden, true, 'no commentable links in the initial window');
168+ assert.equal(typeof diffs.events.click, 'function', 'delegation installed before lazy load');
169+ await window.ermiteMR.load({ dataset: { src: '/files?from=25' } });
170+ assert.equal(bar.hidden, false, 'lazy window refresh reveals range control');
171+ assert.equal(diffs.dataset.mobile, 'true');
172+ assert.equal(diffs.dataset.diff, 'split', 'preferred layout is retained');
173+ media.matches = false;
174+ resize();
175+ assert.equal(diffs.dataset.mobile, 'false', 'desktop menu returns to preferred layout');
176+ assert.equal(diffs.dataset.diff, 'split');
177+ assert.equal(click(a).prevented, false, 'normal single-line navigation is untouched');
178+ button.events.click();
179+ assert.equal(button.attrs['aria-pressed'], 'true');
180+ assert.equal(click(a, { shiftKey: true }).prevented, false, 'desktop range selection is untouched');
181+ const event = click(a);
182+ assert.equal(event.prevented, true);
183+ assert.equal(event.stopped, true);
184+ assert.ok('data-range-start' in a.attrs);
185+ assert.match(status.textContent, /20/);
186+ click(b);
187+ assert.equal(new URL(assigned).searchParams.get('on'), 'new:10-20:a.go');
188+ assert.equal('data-range-start' in a.attrs, false);
189+ click(a);
190+ button.events.click();
191+ assert.equal(button.attrs['aria-pressed'], 'false');
192+ assert.equal(status.textContent, '');
193+ assert.equal('data-range-start' in a.attrs, false);
194+ assert.equal(click(b).prevented, false);
195+ } finally {
196+ for (const [k, v] of Object.entries(saved)) {
197+ if (v === undefined) delete globalThis[k]; else globalThis[k] = v;
198+ }
199+ }
200+});
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]');
@@ -60,6 +74,7 @@ window.ermiteMR = (() => {
6074 const $ = (sel, root = document) => root.querySelector(sel);
6175 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
6276 const loading = new Map(); // src -> promise of the swap
77+ let refreshTouchComments = () => {};
6378 let moved = false;
6479 let lazyIO = null; // the observer of the placeholders (watch)
6580 let revealGen = 0; // the last reveal wins the scroll
@@ -108,6 +123,7 @@ window.ermiteMR = (() => {
108123 window.htmx?.process(f);
109124 window.ermite?.watchFile?.(f, old);
110125 }
126+ refreshTouchComments();
111127 }
112128
113129 // stale: the diff changed under the page. The placeholders say so; no file
@@ -130,7 +146,7 @@ window.ermiteMR = (() => {
130146 // A page with both layouts says which shows in data-diff; a large diff
131147 // rendered in one layout only says it in data-view.
132148 const diffs = $('#diffs')?.dataset;
133- if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {
149+ if (/^c\d+$/.test(id) && !window.matchMedia?.('(max-width: 720px)').matches && (diffs?.diff || diffs?.view) === 'split') {
134150 const split = document.getElementById(id + '-s');
135151 if (split) return split;
136152 }
@@ -285,8 +301,70 @@ window.ermiteMR = (() => {
285301 reveal(id);
286302 }
287303
304+ function touchComments() {
305+ const diffs = $('#diffs');
306+ const bar = document.createElement('div');
307+ bar.className = 'mr-touch-range';
308+ const button = document.createElement('button');
309+ button.type = 'button';
310+ button.className = 'btn';
311+ button.textContent = 'Select line range';
312+ button.setAttribute('aria-pressed', 'false');
313+ const status = document.createElement('span');
314+ status.setAttribute('role', 'status');
315+ bar.append(button, status);
316+ diffs.prepend(bar);
317+ refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
318+ refreshTouchComments();
319+ let enabled = false, start = null, marker = null;
320+ const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
321+ button.addEventListener('click', () => {
322+ enabled = !enabled;
323+ clear();
324+ button.setAttribute('aria-pressed', String(enabled));
325+ status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
326+ });
327+ diffs.addEventListener('click', (e) => {
328+ const link = e.target.closest?.('.ln a[href*="on="]');
329+ if (!enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
330+ e.preventDefault();
331+ e.stopPropagation(); // do not alter the desktop shift-click anchor
332+ const next = core.touchRange(start, link.href, location.href);
333+ clear();
334+ if (next.url) { location.assign(next.url); return; }
335+ start = next.start;
336+ marker = link;
337+ marker.setAttribute('data-range-start', '');
338+ status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
339+ });
340+ // The server opens the line form. Scroll after layout without stealing
341+ // focus or forcing a mobile keyboard to appear.
342+ if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
343+ const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
344+ form?.scrollIntoView({ block: 'center' });
345+ });
346+ }
347+
288348 function start() {
289349 if (!$('#diffs[data-mr]')) return;
350+ // Large diffs may contain only the split table. Ask the existing server
351+ // route for unified rather than writing the saved session preference.
352+ const narrow = window.matchMedia?.('(max-width: 720px)');
353+ const unify = () => {
354+ $('#diffs').dataset.mobile = String(!!narrow?.matches);
355+ const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
356+ if (url) { location.replace(url); return true; }
357+ return false;
358+ };
359+ if (unify()) return;
360+ narrow?.addEventListener('change', unify);
361+ addEventListener('click', (e) => {
362+ if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
363+ e.preventDefault();
364+ e.stopImmediatePropagation();
365+ }
366+ }, true);
367+ touchComments();
290368 let folded = false;
291369 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
292370 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]');
@@ -60,6 +74,7 @@ window.ermiteMR = (() => {
60 const $ = (sel, root = document) => root.querySelector(sel);74 const $ = (sel, root = document) => root.querySelector(sel);
61 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];75 const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
62 const loading = new Map(); // src -> promise of the swap76 const loading = new Map(); // src -> promise of the swap
77+ let refreshTouchComments = () => {};
63 let moved = false;78 let moved = false;
64 let lazyIO = null; // the observer of the placeholders (watch)79 let lazyIO = null; // the observer of the placeholders (watch)
65 let revealGen = 0; // the last reveal wins the scroll80 let revealGen = 0; // the last reveal wins the scroll
@@ -108,6 +123,7 @@ window.ermiteMR = (() => {
108 window.htmx?.process(f);123 window.htmx?.process(f);
109 window.ermite?.watchFile?.(f, old);124 window.ermite?.watchFile?.(f, old);
110 }125 }
126+ refreshTouchComments();
111 }127 }
112 128
113 // stale: the diff changed under the page. The placeholders say so; no file129 // stale: the diff changed under the page. The placeholders say so; no file
@@ -130,7 +146,7 @@ window.ermiteMR = (() => {
130 // A page with both layouts says which shows in data-diff; a large diff146 // 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.147 // rendered in one layout only says it in data-view.
132 const diffs = $('#diffs')?.dataset;148 const diffs = $('#diffs')?.dataset;
133- if (/^c\d+$/.test(id) && (diffs?.diff || diffs?.view) === 'split') {149+ if (/^c\d+$/.test(id) && !window.matchMedia?.('(max-width: 720px)').matches && (diffs?.diff || diffs?.view) === 'split') {
134 const split = document.getElementById(id + '-s');150 const split = document.getElementById(id + '-s');
135 if (split) return split;151 if (split) return split;
136 }152 }
@@ -285,8 +301,70 @@ window.ermiteMR = (() => {
285 reveal(id);301 reveal(id);
286 }302 }
287 303
304+ function touchComments() {
305+ const diffs = $('#diffs');
306+ const bar = document.createElement('div');
307+ bar.className = 'mr-touch-range';
308+ const button = document.createElement('button');
309+ button.type = 'button';
310+ button.className = 'btn';
311+ button.textContent = 'Select line range';
312+ button.setAttribute('aria-pressed', 'false');
313+ const status = document.createElement('span');
314+ status.setAttribute('role', 'status');
315+ bar.append(button, status);
316+ diffs.prepend(bar);
317+ refreshTouchComments = () => { bar.hidden = !$('.ln a[href*="on="]', diffs); };
318+ refreshTouchComments();
319+ let enabled = false, start = null, marker = null;
320+ const clear = () => { marker?.removeAttribute('data-range-start'); marker = null; start = null; };
321+ button.addEventListener('click', () => {
322+ enabled = !enabled;
323+ clear();
324+ button.setAttribute('aria-pressed', String(enabled));
325+ status.textContent = enabled ? 'Tap the first line number, then the last. Tap the same line for one line.' : '';
326+ });
327+ diffs.addEventListener('click', (e) => {
328+ const link = e.target.closest?.('.ln a[href*="on="]');
329+ if (!enabled || !link || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return;
330+ e.preventDefault();
331+ e.stopPropagation(); // do not alter the desktop shift-click anchor
332+ const next = core.touchRange(start, link.href, location.href);
333+ clear();
334+ if (next.url) { location.assign(next.url); return; }
335+ start = next.start;
336+ marker = link;
337+ marker.setAttribute('data-range-start', '');
338+ status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
339+ });
340+ // The server opens the line form. Scroll after layout without stealing
341+ // focus or forcing a mobile keyboard to appear.
342+ if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
343+ const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
344+ form?.scrollIntoView({ block: 'center' });
345+ });
346+ }
347+
288 function start() {348 function start() {
289 if (!$('#diffs[data-mr]')) return;349 if (!$('#diffs[data-mr]')) return;
350+ // Large diffs may contain only the split table. Ask the existing server
351+ // route for unified rather than writing the saved session preference.
352+ const narrow = window.matchMedia?.('(max-width: 720px)');
353+ const unify = () => {
354+ $('#diffs').dataset.mobile = String(!!narrow?.matches);
355+ const url = core.mobileURL(location.href, $('#diffs')?.dataset.view, narrow?.matches);
356+ if (url) { location.replace(url); return true; }
357+ return false;
358+ };
359+ if (unify()) return;
360+ narrow?.addEventListener('change', unify);
361+ addEventListener('click', (e) => {
362+ if (narrow?.matches && e.target.closest?.('.vbtn[data-view="split"]')) {
363+ e.preventDefault();
364+ e.stopImmediatePropagation();
365+ }
366+ }, true);
367+ touchComments();
290 let folded = false;368 let folded = false;
291 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }369 try { folded = localStorage.getItem('mr-files-folded') === '1'; } catch { /* no storage: open */ }
292 if (folded) foldFiles(true);370 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 3

Tags 43

↑↓ moveenter openesc close