detached a889e05

feat(web): add the merge request pages, compare and the owner's writes

commit
a889e05916334bb8b55156d0640b20d38d944076
author
brice · · 2026-10-10 18:29 UTC
parent
951a93a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
55 files · +5887 -255
M cmd/ermite/testdata/web/blob-binary.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -168,11 +171,24 @@
168171 </div>
169172 <div>
170173 <dt>
174+<kbd>m</kbd>
175+<kbd>g m</kbd>
176+</dt>
177+<dd>merge requests (when shown)</dd>
178+</div>
179+<div>
180+<dt>
171181 <kbd>d</kbd>
172182 <kbd>u</kbd>
173183 <kbd>x</kbd>
174184 </dt>
175-<dd>commit: split / unified / collapse all</dd>
185+<dd>diff: split / unified / collapse all</dd>
186+</div>
187+<div>
188+<dt>
189+<kbd>n</kbd>
190+</dt>
191+<dd>merge request: next note in the diff</dd>
176192 </div>
177193 <div>
178194 <dt>
@@ -248,6 +264,10 @@
248264 <a href="/files/refs" tabindex="-1">
249265 <span class="k">section</span>refs</a>
250266 </li>
267+<li data-k="section merge requests">
268+<a href="/files/merge-requests" tabindex="-1">
269+<span class="k">section</span>merge requests</a>
270+</li>
251271 <li data-k="branch feature/x">
252272 <a href="/files/tree/feature/x" tabindex="-1">
253273 <span class="k">branch</span>feature/x</a>
@@ -308,6 +328,13 @@
308328 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
309329 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
310330 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
331+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
332+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
333+ k-next from:body -> {
334+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
336+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
337+ };
311338 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
312339 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
313340 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-code.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -238,11 +241,24 @@
238241 </div>
239242 <div>
240243 <dt>
244+<kbd>m</kbd>
245+<kbd>g m</kbd>
246+</dt>
247+<dd>merge requests (when shown)</dd>
248+</div>
249+<div>
250+<dt>
241251 <kbd>d</kbd>
242252 <kbd>u</kbd>
243253 <kbd>x</kbd>
244254 </dt>
245-<dd>commit: split / unified / collapse all</dd>
255+<dd>diff: split / unified / collapse all</dd>
256+</div>
257+<div>
258+<dt>
259+<kbd>n</kbd>
260+</dt>
261+<dd>merge request: next note in the diff</dd>
246262 </div>
247263 <div>
248264 <dt>
@@ -318,6 +334,10 @@
318334 <a href="/files/refs" tabindex="-1">
319335 <span class="k">section</span>refs</a>
320336 </li>
337+<li data-k="section merge requests">
338+<a href="/files/merge-requests" tabindex="-1">
339+<span class="k">section</span>merge requests</a>
340+</li>
321341 <li data-k="branch feature/x">
322342 <a href="/files/tree/feature/x" tabindex="-1">
323343 <span class="k">branch</span>feature/x</a>
@@ -379,6 +399,13 @@
379399 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
380400 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
381401 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
402+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
403+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
404+ k-next from:body -> {
405+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
406+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
407+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
408+ };
382409 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
383410 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
384411 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-empty.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -168,11 +171,24 @@
168171 </div>
169172 <div>
170173 <dt>
174+<kbd>m</kbd>
175+<kbd>g m</kbd>
176+</dt>
177+<dd>merge requests (when shown)</dd>
178+</div>
179+<div>
180+<dt>
171181 <kbd>d</kbd>
172182 <kbd>u</kbd>
173183 <kbd>x</kbd>
174184 </dt>
175-<dd>commit: split / unified / collapse all</dd>
185+<dd>diff: split / unified / collapse all</dd>
186+</div>
187+<div>
188+<dt>
189+<kbd>n</kbd>
190+</dt>
191+<dd>merge request: next note in the diff</dd>
176192 </div>
177193 <div>
178194 <dt>
@@ -248,6 +264,10 @@
248264 <a href="/files/refs" tabindex="-1">
249265 <span class="k">section</span>refs</a>
250266 </li>
267+<li data-k="section merge requests">
268+<a href="/files/merge-requests" tabindex="-1">
269+<span class="k">section</span>merge requests</a>
270+</li>
251271 <li data-k="branch feature/x">
252272 <a href="/files/tree/feature/x" tabindex="-1">
253273 <span class="k">branch</span>feature/x</a>
@@ -308,6 +328,13 @@
308328 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
309329 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
310330 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
331+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
332+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
333+ k-next from:body -> {
334+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
336+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
337+ };
311338 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
312339 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
313340 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-image.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -170,11 +173,24 @@
170173 </div>
171174 <div>
172175 <dt>
176+<kbd>m</kbd>
177+<kbd>g m</kbd>
178+</dt>
179+<dd>merge requests (when shown)</dd>
180+</div>
181+<div>
182+<dt>
173183 <kbd>d</kbd>
174184 <kbd>u</kbd>
175185 <kbd>x</kbd>
176186 </dt>
177-<dd>commit: split / unified / collapse all</dd>
187+<dd>diff: split / unified / collapse all</dd>
188+</div>
189+<div>
190+<dt>
191+<kbd>n</kbd>
192+</dt>
193+<dd>merge request: next note in the diff</dd>
178194 </div>
179195 <div>
180196 <dt>
@@ -250,6 +266,10 @@
250266 <a href="/files/refs" tabindex="-1">
251267 <span class="k">section</span>refs</a>
252268 </li>
269+<li data-k="section merge requests">
270+<a href="/files/merge-requests" tabindex="-1">
271+<span class="k">section</span>merge requests</a>
272+</li>
253273 <li data-k="branch feature/x">
254274 <a href="/files/tree/feature/x" tabindex="-1">
255275 <span class="k">branch</span>feature/x</a>
@@ -310,6 +330,13 @@
310330 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
311331 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
312332 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
333+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
334+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
335+ k-next from:body -> {
336+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
337+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
338+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
339+ };
313340 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
314341 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
315342 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-markdown.html +28 -1
@@ -55,6 +55,9 @@
5555 <li>
5656 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5757 </li>
58+<li>
59+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
60+</li>
5861 </ul>
5962 </nav>
6063 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -235,11 +238,24 @@
235238 </div>
236239 <div>
237240 <dt>
241+<kbd>m</kbd>
242+<kbd>g m</kbd>
243+</dt>
244+<dd>merge requests (when shown)</dd>
245+</div>
246+<div>
247+<dt>
238248 <kbd>d</kbd>
239249 <kbd>u</kbd>
240250 <kbd>x</kbd>
241251 </dt>
242-<dd>commit: split / unified / collapse all</dd>
252+<dd>diff: split / unified / collapse all</dd>
253+</div>
254+<div>
255+<dt>
256+<kbd>n</kbd>
257+</dt>
258+<dd>merge request: next note in the diff</dd>
243259 </div>
244260 <div>
245261 <dt>
@@ -315,6 +331,10 @@
315331 <a href="/files/refs" tabindex="-1">
316332 <span class="k">section</span>refs</a>
317333 </li>
334+<li data-k="section merge requests">
335+<a href="/files/merge-requests" tabindex="-1">
336+<span class="k">section</span>merge requests</a>
337+</li>
318338 <li data-k="branch feature/x">
319339 <a href="/files/tree/feature/x" tabindex="-1">
320340 <span class="k">branch</span>feature/x</a>
@@ -376,6 +396,13 @@
376396 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
377397 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
378398 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
399+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
400+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
401+ k-next from:body -> {
402+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
403+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
404+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
405+ };
379406 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
380407 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
381408 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-svg.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -193,11 +196,24 @@
193196 </div>
194197 <div>
195198 <dt>
199+<kbd>m</kbd>
200+<kbd>g m</kbd>
201+</dt>
202+<dd>merge requests (when shown)</dd>
203+</div>
204+<div>
205+<dt>
196206 <kbd>d</kbd>
197207 <kbd>u</kbd>
198208 <kbd>x</kbd>
199209 </dt>
200-<dd>commit: split / unified / collapse all</dd>
210+<dd>diff: split / unified / collapse all</dd>
211+</div>
212+<div>
213+<dt>
214+<kbd>n</kbd>
215+</dt>
216+<dd>merge request: next note in the diff</dd>
201217 </div>
202218 <div>
203219 <dt>
@@ -273,6 +289,10 @@
273289 <a href="/files/refs" tabindex="-1">
274290 <span class="k">section</span>refs</a>
275291 </li>
292+<li data-k="section merge requests">
293+<a href="/files/merge-requests" tabindex="-1">
294+<span class="k">section</span>merge requests</a>
295+</li>
276296 <li data-k="branch feature/x">
277297 <a href="/files/tree/feature/x" tabindex="-1">
278298 <span class="k">branch</span>feature/x</a>
@@ -334,6 +354,13 @@
334354 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
335355 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
336356 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
357+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
358+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
359+ k-next from:body -> {
360+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
361+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
362+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
363+ };
337364 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
338365 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
339366 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-text.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -188,11 +191,24 @@
188191 </div>
189192 <div>
190193 <dt>
194+<kbd>m</kbd>
195+<kbd>g m</kbd>
196+</dt>
197+<dd>merge requests (when shown)</dd>
198+</div>
199+<div>
200+<dt>
191201 <kbd>d</kbd>
192202 <kbd>u</kbd>
193203 <kbd>x</kbd>
194204 </dt>
195-<dd>commit: split / unified / collapse all</dd>
205+<dd>diff: split / unified / collapse all</dd>
206+</div>
207+<div>
208+<dt>
209+<kbd>n</kbd>
210+</dt>
211+<dd>merge request: next note in the diff</dd>
196212 </div>
197213 <div>
198214 <dt>
@@ -268,6 +284,10 @@
268284 <a href="/files/refs" tabindex="-1">
269285 <span class="k">section</span>refs</a>
270286 </li>
287+<li data-k="section merge requests">
288+<a href="/files/merge-requests" tabindex="-1">
289+<span class="k">section</span>merge requests</a>
290+</li>
271291 <li data-k="branch feature/x">
272292 <a href="/files/tree/feature/x" tabindex="-1">
273293 <span class="k">branch</span>feature/x</a>
@@ -329,6 +349,13 @@
329349 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
330350 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
331351 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
352+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
353+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
354+ k-next from:body -> {
355+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
356+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
357+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
358+ };
332359 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
333360 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
334361 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-too-large.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -168,11 +171,24 @@
168171 </div>
169172 <div>
170173 <dt>
174+<kbd>m</kbd>
175+<kbd>g m</kbd>
176+</dt>
177+<dd>merge requests (when shown)</dd>
178+</div>
179+<div>
180+<dt>
171181 <kbd>d</kbd>
172182 <kbd>u</kbd>
173183 <kbd>x</kbd>
174184 </dt>
175-<dd>commit: split / unified / collapse all</dd>
185+<dd>diff: split / unified / collapse all</dd>
186+</div>
187+<div>
188+<dt>
189+<kbd>n</kbd>
190+</dt>
191+<dd>merge request: next note in the diff</dd>
176192 </div>
177193 <div>
178194 <dt>
@@ -248,6 +264,10 @@
248264 <a href="/files/refs" tabindex="-1">
249265 <span class="k">section</span>refs</a>
250266 </li>
267+<li data-k="section merge requests">
268+<a href="/files/merge-requests" tabindex="-1">
269+<span class="k">section</span>merge requests</a>
270+</li>
251271 <li data-k="branch feature/x">
252272 <a href="/files/tree/feature/x" tabindex="-1">
253273 <span class="k">branch</span>feature/x</a>
@@ -308,6 +328,13 @@
308328 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
309329 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
310330 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
331+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
332+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
333+ k-next from:body -> {
334+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
335+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
336+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
337+ };
311338 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
312339 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
313340 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/blob-trojan.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -235,11 +238,24 @@
235238 </div>
236239 <div>
237240 <dt>
241+<kbd>m</kbd>
242+<kbd>g m</kbd>
243+</dt>
244+<dd>merge requests (when shown)</dd>
245+</div>
246+<div>
247+<dt>
238248 <kbd>d</kbd>
239249 <kbd>u</kbd>
240250 <kbd>x</kbd>
241251 </dt>
242-<dd>commit: split / unified / collapse all</dd>
252+<dd>diff: split / unified / collapse all</dd>
253+</div>
254+<div>
255+<dt>
256+<kbd>n</kbd>
257+</dt>
258+<dd>merge request: next note in the diff</dd>
243259 </div>
244260 <div>
245261 <dt>
@@ -315,6 +331,10 @@
315331 <a href="/files/refs" tabindex="-1">
316332 <span class="k">section</span>refs</a>
317333 </li>
334+<li data-k="section merge requests">
335+<a href="/files/merge-requests" tabindex="-1">
336+<span class="k">section</span>merge requests</a>
337+</li>
318338 <li data-k="branch feature/x">
319339 <a href="/files/tree/feature/x" tabindex="-1">
320340 <span class="k">branch</span>feature/x</a>
@@ -376,6 +396,13 @@
376396 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
377397 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
378398 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
399+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
400+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
401+ k-next from:body -> {
402+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
403+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
404+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
405+ };
379406 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
380407 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
381408 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-big.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -198,11 +201,24 @@
198201 </div>
199202 <div>
200203 <dt>
204+<kbd>m</kbd>
205+<kbd>g m</kbd>
206+</dt>
207+<dd>merge requests (when shown)</dd>
208+</div>
209+<div>
210+<dt>
201211 <kbd>d</kbd>
202212 <kbd>u</kbd>
203213 <kbd>x</kbd>
204214 </dt>
205-<dd>commit: split / unified / collapse all</dd>
215+<dd>diff: split / unified / collapse all</dd>
216+</div>
217+<div>
218+<dt>
219+<kbd>n</kbd>
220+</dt>
221+<dd>merge request: next note in the diff</dd>
206222 </div>
207223 <div>
208224 <dt>
@@ -278,6 +294,10 @@
278294 <a href="/diffs/refs" tabindex="-1">
279295 <span class="k">section</span>refs</a>
280296 </li>
297+<li data-k="section merge requests">
298+<a href="/diffs/merge-requests" tabindex="-1">
299+<span class="k">section</span>merge requests</a>
300+</li>
281301 <li data-k="branch main">
282302 <a href="/diffs/tree/main" tabindex="-1">
283303 <span class="k">branch</span>main</a>
@@ -338,6 +358,13 @@
338358 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
339359 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
340360 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
361+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
362+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
363+ k-next from:body -> {
364+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
365+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
366+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
367+ };
341368 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
342369 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
343370 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-binary.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -195,11 +198,24 @@
195198 </div>
196199 <div>
197200 <dt>
201+<kbd>m</kbd>
202+<kbd>g m</kbd>
203+</dt>
204+<dd>merge requests (when shown)</dd>
205+</div>
206+<div>
207+<dt>
198208 <kbd>d</kbd>
199209 <kbd>u</kbd>
200210 <kbd>x</kbd>
201211 </dt>
202-<dd>commit: split / unified / collapse all</dd>
212+<dd>diff: split / unified / collapse all</dd>
213+</div>
214+<div>
215+<dt>
216+<kbd>n</kbd>
217+</dt>
218+<dd>merge request: next note in the diff</dd>
203219 </div>
204220 <div>
205221 <dt>
@@ -275,6 +291,10 @@
275291 <a href="/diffs/refs" tabindex="-1">
276292 <span class="k">section</span>refs</a>
277293 </li>
294+<li data-k="section merge requests">
295+<a href="/diffs/merge-requests" tabindex="-1">
296+<span class="k">section</span>merge requests</a>
297+</li>
278298 <li data-k="branch main">
279299 <a href="/diffs/tree/main" tabindex="-1">
280300 <span class="k">branch</span>main</a>
@@ -335,6 +355,13 @@
335355 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
336356 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
337357 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
358+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
359+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
360+ k-next from:body -> {
361+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
362+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
363+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
364+ };
338365 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
339366 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
340367 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-merge.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -226,11 +229,24 @@
226229 </div>
227230 <div>
228231 <dt>
232+<kbd>m</kbd>
233+<kbd>g m</kbd>
234+</dt>
235+<dd>merge requests (when shown)</dd>
236+</div>
237+<div>
238+<dt>
229239 <kbd>d</kbd>
230240 <kbd>u</kbd>
231241 <kbd>x</kbd>
232242 </dt>
233-<dd>commit: split / unified / collapse all</dd>
243+<dd>diff: split / unified / collapse all</dd>
244+</div>
245+<div>
246+<dt>
247+<kbd>n</kbd>
248+</dt>
249+<dd>merge request: next note in the diff</dd>
234250 </div>
235251 <div>
236252 <dt>
@@ -306,6 +322,10 @@
306322 <a href="/diffs/refs" tabindex="-1">
307323 <span class="k">section</span>refs</a>
308324 </li>
325+<li data-k="section merge requests">
326+<a href="/diffs/merge-requests" tabindex="-1">
327+<span class="k">section</span>merge requests</a>
328+</li>
309329 <li data-k="branch main">
310330 <a href="/diffs/tree/main" tabindex="-1">
311331 <span class="k">branch</span>main</a>
@@ -366,6 +386,13 @@
366386 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
367387 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
368388 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
389+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
390+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
391+ k-next from:body -> {
392+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
393+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
394+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
395+ };
369396 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
370397 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
371398 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-modify-split.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -345,11 +348,24 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
345348 </div>
346349 <div>
347350 <dt>
351+<kbd>m</kbd>
352+<kbd>g m</kbd>
353+</dt>
354+<dd>merge requests (when shown)</dd>
355+</div>
356+<div>
357+<dt>
348358 <kbd>d</kbd>
349359 <kbd>u</kbd>
350360 <kbd>x</kbd>
351361 </dt>
352-<dd>commit: split / unified / collapse all</dd>
362+<dd>diff: split / unified / collapse all</dd>
363+</div>
364+<div>
365+<dt>
366+<kbd>n</kbd>
367+</dt>
368+<dd>merge request: next note in the diff</dd>
353369 </div>
354370 <div>
355371 <dt>
@@ -425,6 +441,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
425441 <a href="/diffs/refs" tabindex="-1">
426442 <span class="k">section</span>refs</a>
427443 </li>
444+<li data-k="section merge requests">
445+<a href="/diffs/merge-requests" tabindex="-1">
446+<span class="k">section</span>merge requests</a>
447+</li>
428448 <li data-k="branch main">
429449 <a href="/diffs/tree/main" tabindex="-1">
430450 <span class="k">branch</span>main</a>
@@ -485,6 +505,13 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
485505 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
486506 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
487507 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
508+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
509+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
510+ k-next from:body -> {
511+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
512+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
513+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
514+ };
488515 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
489516 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
490517 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-modify.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -345,11 +348,24 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
345348 </div>
346349 <div>
347350 <dt>
351+<kbd>m</kbd>
352+<kbd>g m</kbd>
353+</dt>
354+<dd>merge requests (when shown)</dd>
355+</div>
356+<div>
357+<dt>
348358 <kbd>d</kbd>
349359 <kbd>u</kbd>
350360 <kbd>x</kbd>
351361 </dt>
352-<dd>commit: split / unified / collapse all</dd>
362+<dd>diff: split / unified / collapse all</dd>
363+</div>
364+<div>
365+<dt>
366+<kbd>n</kbd>
367+</dt>
368+<dd>merge request: next note in the diff</dd>
353369 </div>
354370 <div>
355371 <dt>
@@ -425,6 +441,10 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
425441 <a href="/diffs/refs" tabindex="-1">
426442 <span class="k">section</span>refs</a>
427443 </li>
444+<li data-k="section merge requests">
445+<a href="/diffs/merge-requests" tabindex="-1">
446+<span class="k">section</span>merge requests</a>
447+</li>
428448 <li data-k="branch main">
429449 <a href="/diffs/tree/main" tabindex="-1">
430450 <span class="k">branch</span>main</a>
@@ -485,6 +505,13 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
485505 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
486506 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
487507 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
508+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
509+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
510+ k-next from:body -> {
511+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
512+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
513+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
514+ };
488515 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
489516 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
490517 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-rename.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -252,11 +255,24 @@
252255 </div>
253256 <div>
254257 <dt>
258+<kbd>m</kbd>
259+<kbd>g m</kbd>
260+</dt>
261+<dd>merge requests (when shown)</dd>
262+</div>
263+<div>
264+<dt>
255265 <kbd>d</kbd>
256266 <kbd>u</kbd>
257267 <kbd>x</kbd>
258268 </dt>
259-<dd>commit: split / unified / collapse all</dd>
269+<dd>diff: split / unified / collapse all</dd>
270+</div>
271+<div>
272+<dt>
273+<kbd>n</kbd>
274+</dt>
275+<dd>merge request: next note in the diff</dd>
260276 </div>
261277 <div>
262278 <dt>
@@ -332,6 +348,10 @@
332348 <a href="/diffs/refs" tabindex="-1">
333349 <span class="k">section</span>refs</a>
334350 </li>
351+<li data-k="section merge requests">
352+<a href="/diffs/merge-requests" tabindex="-1">
353+<span class="k">section</span>merge requests</a>
354+</li>
335355 <li data-k="branch main">
336356 <a href="/diffs/tree/main" tabindex="-1">
337357 <span class="k">branch</span>main</a>
@@ -392,6 +412,13 @@
392412 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
393413 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
394414 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
415+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
416+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
417+ k-next from:body -> {
418+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
419+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
420+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
421+ };
395422 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
396423 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
397424 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-root.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -544,11 +547,24 @@
544547 </div>
545548 <div>
546549 <dt>
550+<kbd>m</kbd>
551+<kbd>g m</kbd>
552+</dt>
553+<dd>merge requests (when shown)</dd>
554+</div>
555+<div>
556+<dt>
547557 <kbd>d</kbd>
548558 <kbd>u</kbd>
549559 <kbd>x</kbd>
550560 </dt>
551-<dd>commit: split / unified / collapse all</dd>
561+<dd>diff: split / unified / collapse all</dd>
562+</div>
563+<div>
564+<dt>
565+<kbd>n</kbd>
566+</dt>
567+<dd>merge request: next note in the diff</dd>
552568 </div>
553569 <div>
554570 <dt>
@@ -624,6 +640,10 @@
624640 <a href="/diffs/refs" tabindex="-1">
625641 <span class="k">section</span>refs</a>
626642 </li>
643+<li data-k="section merge requests">
644+<a href="/diffs/merge-requests" tabindex="-1">
645+<span class="k">section</span>merge requests</a>
646+</li>
627647 <li data-k="branch main">
628648 <a href="/diffs/tree/main" tabindex="-1">
629649 <span class="k">branch</span>main</a>
@@ -684,6 +704,13 @@
684704 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
685705 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
686706 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
707+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
708+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
709+ k-next from:body -> {
710+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
711+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
712+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
713+ };
687714 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
688715 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
689716 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commit-trojan.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">detached<span class="sha">@SHA7</span>
@@ -361,11 +364,24 @@
361364 </div>
362365 <div>
363366 <dt>
367+<kbd>m</kbd>
368+<kbd>g m</kbd>
369+</dt>
370+<dd>merge requests (when shown)</dd>
371+</div>
372+<div>
373+<dt>
364374 <kbd>d</kbd>
365375 <kbd>u</kbd>
366376 <kbd>x</kbd>
367377 </dt>
368-<dd>commit: split / unified / collapse all</dd>
378+<dd>diff: split / unified / collapse all</dd>
379+</div>
380+<div>
381+<dt>
382+<kbd>n</kbd>
383+</dt>
384+<dd>merge request: next note in the diff</dd>
369385 </div>
370386 <div>
371387 <dt>
@@ -441,6 +457,10 @@
441457 <a href="/diffs/refs" tabindex="-1">
442458 <span class="k">section</span>refs</a>
443459 </li>
460+<li data-k="section merge requests">
461+<a href="/diffs/merge-requests" tabindex="-1">
462+<span class="k">section</span>merge requests</a>
463+</li>
444464 <li data-k="branch main">
445465 <a href="/diffs/tree/main" tabindex="-1">
446466 <span class="k">branch</span>main</a>
@@ -501,6 +521,13 @@
501521 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
502522 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
503523 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
524+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
525+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
526+ k-next from:body -> {
527+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
528+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
529+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
530+ };
504531 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
505532 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
506533 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commits-page2.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -197,11 +200,24 @@
197200 </div>
198201 <div>
199202 <dt>
203+<kbd>m</kbd>
204+<kbd>g m</kbd>
205+</dt>
206+<dd>merge requests (when shown)</dd>
207+</div>
208+<div>
209+<dt>
200210 <kbd>d</kbd>
201211 <kbd>u</kbd>
202212 <kbd>x</kbd>
203213 </dt>
204-<dd>commit: split / unified / collapse all</dd>
214+<dd>diff: split / unified / collapse all</dd>
215+</div>
216+<div>
217+<dt>
218+<kbd>n</kbd>
219+</dt>
220+<dd>merge request: next note in the diff</dd>
205221 </div>
206222 <div>
207223 <dt>
@@ -269,6 +285,10 @@
269285 <a href="/long/refs" tabindex="-1">
270286 <span class="k">section</span>refs</a>
271287 </li>
288+<li data-k="section merge requests">
289+<a href="/long/merge-requests" tabindex="-1">
290+<span class="k">section</span>merge requests</a>
291+</li>
272292 <li data-k="branch main">
273293 <a href="/long/tree/main" tabindex="-1">
274294 <span class="k">branch</span>main</a>
@@ -326,6 +346,13 @@
326346 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
327347 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
328348 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
349+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
350+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
351+ k-next from:body -> {
352+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
353+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
354+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
355+ };
329356 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
330357 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
331358 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commits-path.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -212,11 +215,24 @@
212215 </div>
213216 <div>
214217 <dt>
218+<kbd>m</kbd>
219+<kbd>g m</kbd>
220+</dt>
221+<dd>merge requests (when shown)</dd>
222+</div>
223+<div>
224+<dt>
215225 <kbd>d</kbd>
216226 <kbd>u</kbd>
217227 <kbd>x</kbd>
218228 </dt>
219-<dd>commit: split / unified / collapse all</dd>
229+<dd>diff: split / unified / collapse all</dd>
230+</div>
231+<div>
232+<dt>
233+<kbd>n</kbd>
234+</dt>
235+<dd>merge request: next note in the diff</dd>
220236 </div>
221237 <div>
222238 <dt>
@@ -284,6 +300,10 @@
284300 <a href="/long/refs" tabindex="-1">
285301 <span class="k">section</span>refs</a>
286302 </li>
303+<li data-k="section merge requests">
304+<a href="/long/merge-requests" tabindex="-1">
305+<span class="k">section</span>merge requests</a>
306+</li>
287307 <li data-k="branch main">
288308 <a href="/long/tree/main" tabindex="-1">
289309 <span class="k">branch</span>main</a>
@@ -341,6 +361,13 @@
341361 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
342362 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
343363 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
364+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
365+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
366+ k-next from:body -> {
367+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
368+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
369+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
370+ };
344371 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
345372 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
346373 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commits-root.html +29 -2
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -994,11 +997,24 @@
994997 </div>
995998 <div>
996999 <dt>
1000+<kbd>m</kbd>
1001+<kbd>g m</kbd>
1002+</dt>
1003+<dd>merge requests (when shown)</dd>
1004+</div>
1005+<div>
1006+<dt>
9971007 <kbd>d</kbd>
9981008 <kbd>u</kbd>
9991009 <kbd>x</kbd>
10001010 </dt>
1001-<dd>commit: split / unified / collapse all</dd>
1011+<dd>diff: split / unified / collapse all</dd>
1012+</div>
1013+<div>
1014+<dt>
1015+<kbd>n</kbd>
1016+</dt>
1017+<dd>merge request: next note in the diff</dd>
10021018 </div>
10031019 <div>
10041020 <dt>
@@ -1066,6 +1082,10 @@
10661082 <a href="/long/refs" tabindex="-1">
10671083 <span class="k">section</span>refs</a>
10681084 </li>
1085+<li data-k="section merge requests">
1086+<a href="/long/merge-requests" tabindex="-1">
1087+<span class="k">section</span>merge requests</a>
1088+</li>
10691089 <li data-k="branch main">
10701090 <a href="/long/tree/main" tabindex="-1">
10711091 <span class="k">branch</span>main</a>
@@ -1090,7 +1110,7 @@
10901110 </span>
10911111 <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Along&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
10921112 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
1093- home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
1113+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
10941114 gap -> ermiteLive.reload()">offline</span>
10951115 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span>
10961116 </footer>
@@ -1126,6 +1146,13 @@
11261146 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
11271147 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
11281148 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
1149+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
1150+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
1151+ k-next from:body -> {
1152+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
1153+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
1154+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
1155+ };
11291156 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
11301157 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
11311158 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/commits-small.html +29 -2
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -338,11 +341,24 @@
338341 </div>
339342 <div>
340343 <dt>
344+<kbd>m</kbd>
345+<kbd>g m</kbd>
346+</dt>
347+<dd>merge requests (when shown)</dd>
348+</div>
349+<div>
350+<dt>
341351 <kbd>d</kbd>
342352 <kbd>u</kbd>
343353 <kbd>x</kbd>
344354 </dt>
345-<dd>commit: split / unified / collapse all</dd>
355+<dd>diff: split / unified / collapse all</dd>
356+</div>
357+<div>
358+<dt>
359+<kbd>n</kbd>
360+</dt>
361+<dd>merge request: next note in the diff</dd>
346362 </div>
347363 <div>
348364 <dt>
@@ -418,6 +434,10 @@
418434 <a href="/diffs/refs" tabindex="-1">
419435 <span class="k">section</span>refs</a>
420436 </li>
437+<li data-k="section merge requests">
438+<a href="/diffs/merge-requests" tabindex="-1">
439+<span class="k">section</span>merge requests</a>
440+</li>
421441 <li data-k="branch main">
422442 <a href="/diffs/tree/main" tabindex="-1">
423443 <span class="k">branch</span>main</a>
@@ -450,7 +470,7 @@
450470 </span>
451471 <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Adiffs&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
452472 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
453- home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
473+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
454474 gap -> ermiteLive.reload()">offline</span>
455475 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span>
456476 </footer>
@@ -486,6 +506,13 @@
486506 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
487507 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
488508 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
509+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
510+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
511+ k-next from:body -> {
512+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
513+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
514+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
515+ };
489516 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
490517 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
491518 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/empty.html +21 -1
@@ -130,11 +130,24 @@
130130 </div>
131131 <div>
132132 <dt>
133+<kbd>m</kbd>
134+<kbd>g m</kbd>
135+</dt>
136+<dd>merge requests (when shown)</dd>
137+</div>
138+<div>
139+<dt>
133140 <kbd>d</kbd>
134141 <kbd>u</kbd>
135142 <kbd>x</kbd>
136143 </dt>
137-<dd>commit: split / unified / collapse all</dd>
144+<dd>diff: split / unified / collapse all</dd>
145+</div>
146+<div>
147+<dt>
148+<kbd>n</kbd>
149+</dt>
150+<dd>merge request: next note in the diff</dd>
138151 </div>
139152 <div>
140153 <dt>
@@ -213,6 +226,13 @@
213226 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
214227 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
215228 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
229+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
230+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
231+ k-next from:body -> {
232+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
233+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
234+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
235+ };
216236 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
217237 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
218238 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/home.html +22 -2
@@ -166,11 +166,24 @@
166166 </div>
167167 <div>
168168 <dt>
169+<kbd>m</kbd>
170+<kbd>g m</kbd>
171+</dt>
172+<dd>merge requests (when shown)</dd>
173+</div>
174+<div>
175+<dt>
169176 <kbd>d</kbd>
170177 <kbd>u</kbd>
171178 <kbd>x</kbd>
172179 </dt>
173-<dd>commit: split / unified / collapse all</dd>
180+<dd>diff: split / unified / collapse all</dd>
181+</div>
182+<div>
183+<dt>
184+<kbd>n</kbd>
185+</dt>
186+<dd>merge request: next note in the diff</dd>
174187 </div>
175188 <div>
176189 <dt>
@@ -221,7 +234,7 @@
221234 </span>
222235 <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
223236 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
224- home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
237+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
225238 gap -> ermiteLive.reload()">offline</span>
226239 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
227240 </footer>
@@ -257,6 +270,13 @@
257270 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
258271 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
259272 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
273+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
274+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
275+ k-next from:body -> {
276+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
277+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
278+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
279+ };
260280 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
261281 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
262282 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/not-found-page.html +21 -1
@@ -129,11 +129,24 @@
129129 </div>
130130 <div>
131131 <dt>
132+<kbd>m</kbd>
133+<kbd>g m</kbd>
134+</dt>
135+<dd>merge requests (when shown)</dd>
136+</div>
137+<div>
138+<dt>
132139 <kbd>d</kbd>
133140 <kbd>u</kbd>
134141 <kbd>x</kbd>
135142 </dt>
136-<dd>commit: split / unified / collapse all</dd>
143+<dd>diff: split / unified / collapse all</dd>
144+</div>
145+<div>
146+<dt>
147+<kbd>n</kbd>
148+</dt>
149+<dd>merge request: next note in the diff</dd>
137150 </div>
138151 <div>
139152 <dt>
@@ -208,6 +221,13 @@
208221 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
209222 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
210223 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
224+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
225+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
226+ k-next from:body -> {
227+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
228+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
229+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
230+ };
211231 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
212232 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
213233 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/not-found.html +21 -1
@@ -129,11 +129,24 @@
129129 </div>
130130 <div>
131131 <dt>
132+<kbd>m</kbd>
133+<kbd>g m</kbd>
134+</dt>
135+<dd>merge requests (when shown)</dd>
136+</div>
137+<div>
138+<dt>
132139 <kbd>d</kbd>
133140 <kbd>u</kbd>
134141 <kbd>x</kbd>
135142 </dt>
136-<dd>commit: split / unified / collapse all</dd>
143+<dd>diff: split / unified / collapse all</dd>
144+</div>
145+<div>
146+<dt>
147+<kbd>n</kbd>
148+</dt>
149+<dd>merge request: next note in the diff</dd>
137150 </div>
138151 <div>
139152 <dt>
@@ -208,6 +221,13 @@
208221 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
209222 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
210223 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
224+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
225+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
226+ k-next from:body -> {
227+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
228+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
229+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
230+ };
211231 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
212232 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
213233 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/refs.html +29 -2
@@ -46,6 +46,9 @@
4646 <li>
4747 <a href="/demo/refs" data-key="r" aria-keyshortcuts="g r" aria-current="page">refs</a>
4848 </li>
49+<li>
50+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
51+</li>
4952 </ul>
5053 </nav>
5154 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -237,11 +240,24 @@
237240 </div>
238241 <div>
239242 <dt>
243+<kbd>m</kbd>
244+<kbd>g m</kbd>
245+</dt>
246+<dd>merge requests (when shown)</dd>
247+</div>
248+<div>
249+<dt>
240250 <kbd>d</kbd>
241251 <kbd>u</kbd>
242252 <kbd>x</kbd>
243253 </dt>
244-<dd>commit: split / unified / collapse all</dd>
254+<dd>diff: split / unified / collapse all</dd>
255+</div>
256+<div>
257+<dt>
258+<kbd>n</kbd>
259+</dt>
260+<dd>merge request: next note in the diff</dd>
245261 </div>
246262 <div>
247263 <dt>
@@ -321,6 +337,10 @@
321337 <a href="/demo/refs" tabindex="-1">
322338 <span class="k">section</span>refs</a>
323339 </li>
340+<li data-k="section merge requests">
341+<a href="/demo/merge-requests" tabindex="-1">
342+<span class="k">section</span>merge requests</a>
343+</li>
324344 <li data-k="branch feature/x">
325345 <a href="/demo/tree/feature/x" tabindex="-1">
326346 <span class="k">branch</span>feature/x</a>
@@ -357,7 +377,7 @@
357377 </span>
358378 <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
359379 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
360- home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
380+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
361381 gap -> ermiteLive.reload()">offline</span>
362382 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span>
363383 </footer>
@@ -393,6 +413,13 @@
393413 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
394414 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
395415 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
416+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
417+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
418+ k-next from:body -> {
419+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
420+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
421+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
422+ };
396423 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
397424 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
398425 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/summary.html +29 -2
@@ -43,6 +43,9 @@
4343 <li>
4444 <a href="/demo/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
4545 </li>
46+<li>
47+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
48+</li>
4649 </ul>
4750 </nav>
4851 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -231,11 +234,24 @@
231234 </div>
232235 <div>
233236 <dt>
237+<kbd>m</kbd>
238+<kbd>g m</kbd>
239+</dt>
240+<dd>merge requests (when shown)</dd>
241+</div>
242+<div>
243+<dt>
234244 <kbd>d</kbd>
235245 <kbd>u</kbd>
236246 <kbd>x</kbd>
237247 </dt>
238-<dd>commit: split / unified / collapse all</dd>
248+<dd>diff: split / unified / collapse all</dd>
249+</div>
250+<div>
251+<dt>
252+<kbd>n</kbd>
253+</dt>
254+<dd>merge request: next note in the diff</dd>
239255 </div>
240256 <div>
241257 <dt>
@@ -315,6 +331,10 @@
315331 <a href="/demo/refs" tabindex="-1">
316332 <span class="k">section</span>refs</a>
317333 </li>
334+<li data-k="section merge requests">
335+<a href="/demo/merge-requests" tabindex="-1">
336+<span class="k">section</span>merge requests</a>
337+</li>
318338 <li data-k="branch feature/x">
319339 <a href="/demo/tree/feature/x" tabindex="-1">
320340 <span class="k">branch</span>feature/x</a>
@@ -351,7 +371,7 @@
351371 </span>
352372 <span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
353373 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
354- home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
374+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
355375 gap -> ermiteLive.reload()">offline</span>
356376 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
357377 </footer>
@@ -387,6 +407,13 @@
387407 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
388408 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
389409 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
410+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
411+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
412+ k-next from:body -> {
413+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
414+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
415+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
416+ };
390417 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
391418 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
392419 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/tree-nested.html +28 -1
@@ -55,6 +55,9 @@
5555 <li>
5656 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5757 </li>
58+<li>
59+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
60+</li>
5861 </ul>
5962 </nav>
6063 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -177,11 +180,24 @@
177180 </div>
178181 <div>
179182 <dt>
183+<kbd>m</kbd>
184+<kbd>g m</kbd>
185+</dt>
186+<dd>merge requests (when shown)</dd>
187+</div>
188+<div>
189+<dt>
180190 <kbd>d</kbd>
181191 <kbd>u</kbd>
182192 <kbd>x</kbd>
183193 </dt>
184-<dd>commit: split / unified / collapse all</dd>
194+<dd>diff: split / unified / collapse all</dd>
195+</div>
196+<div>
197+<dt>
198+<kbd>n</kbd>
199+</dt>
200+<dd>merge request: next note in the diff</dd>
185201 </div>
186202 <div>
187203 <dt>
@@ -257,6 +273,10 @@
257273 <a href="/files/refs" tabindex="-1">
258274 <span class="k">section</span>refs</a>
259275 </li>
276+<li data-k="section merge requests">
277+<a href="/files/merge-requests" tabindex="-1">
278+<span class="k">section</span>merge requests</a>
279+</li>
260280 <li data-k="branch feature/x">
261281 <a href="/files/tree/feature/x" tabindex="-1">
262282 <span class="k">branch</span>feature/x</a>
@@ -322,6 +342,13 @@
322342 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
323343 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
324344 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
345+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
346+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
347+ k-next from:body -> {
348+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
349+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
350+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
351+ };
325352 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
326353 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
327354 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/tree-root.html +28 -1
@@ -49,6 +49,9 @@
4949 <li>
5050 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5151 </li>
52+<li>
53+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
54+</li>
5255 </ul>
5356 </nav>
5457 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -402,11 +405,24 @@
402405 </div>
403406 <div>
404407 <dt>
408+<kbd>m</kbd>
409+<kbd>g m</kbd>
410+</dt>
411+<dd>merge requests (when shown)</dd>
412+</div>
413+<div>
414+<dt>
405415 <kbd>d</kbd>
406416 <kbd>u</kbd>
407417 <kbd>x</kbd>
408418 </dt>
409-<dd>commit: split / unified / collapse all</dd>
419+<dd>diff: split / unified / collapse all</dd>
420+</div>
421+<div>
422+<dt>
423+<kbd>n</kbd>
424+</dt>
425+<dd>merge request: next note in the diff</dd>
410426 </div>
411427 <div>
412428 <dt>
@@ -482,6 +498,10 @@
482498 <a href="/files/refs" tabindex="-1">
483499 <span class="k">section</span>refs</a>
484500 </li>
501+<li data-k="section merge requests">
502+<a href="/files/merge-requests" tabindex="-1">
503+<span class="k">section</span>merge requests</a>
504+</li>
485505 <li data-k="branch feature/x">
486506 <a href="/files/tree/feature/x" tabindex="-1">
487507 <span class="k">branch</span>feature/x</a>
@@ -595,6 +615,13 @@
595615 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
596616 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
597617 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
618+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
619+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
620+ k-next from:body -> {
621+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
622+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
623+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
624+ };
598625 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
599626 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
600627 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/tree-slash-ref.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">feature/x<span class="sha">@SHA7</span>
@@ -174,11 +177,24 @@
174177 </div>
175178 <div>
176179 <dt>
180+<kbd>m</kbd>
181+<kbd>g m</kbd>
182+</dt>
183+<dd>merge requests (when shown)</dd>
184+</div>
185+<div>
186+<dt>
177187 <kbd>d</kbd>
178188 <kbd>u</kbd>
179189 <kbd>x</kbd>
180190 </dt>
181-<dd>commit: split / unified / collapse all</dd>
191+<dd>diff: split / unified / collapse all</dd>
192+</div>
193+<div>
194+<dt>
195+<kbd>n</kbd>
196+</dt>
197+<dd>merge request: next note in the diff</dd>
182198 </div>
183199 <div>
184200 <dt>
@@ -254,6 +270,10 @@
254270 <a href="/files/refs" tabindex="-1">
255271 <span class="k">section</span>refs</a>
256272 </li>
273+<li data-k="section merge requests">
274+<a href="/files/merge-requests" tabindex="-1">
275+<span class="k">section</span>merge requests</a>
276+</li>
257277 <li data-k="branch feature/x">
258278 <a href="/files/tree/feature/x" tabindex="-1">
259279 <span class="k">branch</span>feature/x</a>
@@ -319,6 +339,13 @@
319339 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
320340 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
321341 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
342+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
343+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
344+ k-next from:body -> {
345+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
346+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
347+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
348+ };
322349 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
323350 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
324351 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/testdata/web/tree-subdir.html +28 -1
@@ -52,6 +52,9 @@
5252 <li>
5353 <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a>
5454 </li>
55+<li>
56+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="g m">merge requests</a>
57+</li>
5558 </ul>
5659 </nav>
5760 <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span>
@@ -197,11 +200,24 @@
197200 </div>
198201 <div>
199202 <dt>
203+<kbd>m</kbd>
204+<kbd>g m</kbd>
205+</dt>
206+<dd>merge requests (when shown)</dd>
207+</div>
208+<div>
209+<dt>
200210 <kbd>d</kbd>
201211 <kbd>u</kbd>
202212 <kbd>x</kbd>
203213 </dt>
204-<dd>commit: split / unified / collapse all</dd>
214+<dd>diff: split / unified / collapse all</dd>
215+</div>
216+<div>
217+<dt>
218+<kbd>n</kbd>
219+</dt>
220+<dd>merge request: next note in the diff</dd>
205221 </div>
206222 <div>
207223 <dt>
@@ -277,6 +293,10 @@
277293 <a href="/files/refs" tabindex="-1">
278294 <span class="k">section</span>refs</a>
279295 </li>
296+<li data-k="section merge requests">
297+<a href="/files/merge-requests" tabindex="-1">
298+<span class="k">section</span>merge requests</a>
299+</li>
280300 <li data-k="branch feature/x">
281301 <a href="/files/tree/feature/x" tabindex="-1">
282302 <span class="k">branch</span>feature/x</a>
@@ -346,6 +366,13 @@
346366 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
347367 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
348368 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
369+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
370+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
371+ k-next from:body -> {
372+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
373+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
374+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
375+ };
349376 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
350377 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
351378 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M cmd/ermite/web.go +7 -6
@@ -27,12 +27,13 @@ import (
2727 func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) {
2828 hc := api.InProcessClient()
2929 srv, err := web.New(web.Config{
30- Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
31- Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
32- Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL),
33- PublicURL: cfg.PublicURL,
34- SourceURL: cfg.SourceURL,
35- Version: serverVersion(),
30+ Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
31+ Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
32+ MergeRequests: ermiteconnect.NewMergeRequestServiceClient(hc, rpc.InProcessURL),
33+ Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL),
34+ PublicURL: cfg.PublicURL,
35+ SourceURL: cfg.SourceURL,
36+ Version: serverVersion(),
3637
3738 Sessions: webSessions{sessions},
3839 Limiter: limiter,
A internal/adapters/web/compare.go +221 -0
@@ -0,0 +1,221 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "slices"
9+
10+ "connectrpc.com/connect"
11+ "github.com/a-h/templ"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+)
15+
16+// compareDepth bounds the commits read on each side to find where a branch
17+// left its target.
18+const compareDepth = maxComparePages * pageSize
19+
20+// compare shows a branch against the default branch of the repository: the
21+// commits it is ahead by and, for the owner, the form that opens a merge
22+// request.
23+//
24+// The API has no "diff between two branches" call outside a merge request, so
25+// the page shows the diff only when it is exact without one: the branch is
26+// ahead by a single commit that sits on the tip of the target (that commit's
27+// own diff is then the whole change). Otherwise it lists the commits, which
28+// link to their own diffs, and says the whole diff comes with the merge
29+// request.
30+func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component, int, error) {
31+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
32+ if err != nil {
33+ return nil, 0, err
34+ }
35+ src, rest, ok := resolveRef(d.branches, r.PathValue("rest"))
36+ if !ok || rest != "" || d.head == nil {
37+ return nil, 0, errNotFound
38+ }
39+ tgt := d.head
40+ name := d.name()
41+
42+ p := ComparePage{
43+ Source: visibleText(src.GetShortName()), Target: visibleText(tgt.GetShortName()),
44+ SourceHref: repoHref(name, "tree", src.GetShortName()), TargetHref: repoHref(name, "tree", tgt.GetShortName()),
45+ CLI: Clone{ID: "cli-create", Lines: []string{
46+ "ermite mr create " + shellWord(name) + " --source " + shellWord(src.GetShortName()) + " --title <title>",
47+ }},
48+ }
49+
50+ same := src.GetShortName() == tgt.GetShortName()
51+ var ahead []*pb.Commit
52+ exact, behind := false, false
53+ if same {
54+ p.Note = "this is the default branch: there is nothing to compare it with."
55+ p.AheadText = "0 commits"
56+ } else {
57+ ahead, exact, behind, err = s.commitsAhead(ctx, name, src.GetCommitId(), tgt.GetCommitId())
58+ if err != nil {
59+ return nil, 0, err
60+ }
61+ now := s.cfg.Now()
62+ for _, c := range ahead {
63+ p.Ahead = append(p.Ahead, commitRow(name, c, now))
64+ }
65+ p.AheadText = plural(len(ahead), "commit", "commits")
66+ if !exact {
67+ p.AheadText += " or more"
68+ }
69+ switch {
70+ case !exact:
71+ p.Note = "the branch left " + p.Target + " too long ago to tell where: only the newest commits are listed."
72+ case len(ahead) == 0:
73+ p.Note = "nothing to merge: " + p.Target + " already has every commit of " + p.Source + "."
74+ case behind:
75+ p.Note = p.Target + " has moved on since the branch started: a merge request can only be merged after " +
76+ p.Source + " is rebased on " + p.Target + " and pushed again."
77+ }
78+ }
79+
80+ // The exact diff: one commit on top of the tip of the target.
81+ if exact && !behind && len(ahead) == 1 {
82+ res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: name, Revision: ahead[0].GetId()}))
83+ if err != nil {
84+ return nil, 0, err
85+ }
86+ p.HasDiff = true
87+ p.Files = diffFiles(res.Msg.GetDiff().GetFiles())
88+ p.NFiles, p.Adds, p.Dels = int(res.Msg.GetStats().GetFilesChanged()), int(res.Msg.GetStats().GetAdditions()), int(res.Msg.GetStats().GetDeletions())
89+ if n := diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles); n != "" {
90+ p.Note = n
91+ }
92+ lines, invisible := diffLines(p.Files)
93+ p.Invisible = invisible
94+ self := repoHref(name, "compare", src.GetShortName())
95+ p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
96+ } else if len(ahead) > 1 && exact {
97+ if p.Note == "" {
98+ p.Note = "the whole diff of the branch is on its merge request; each commit links to its own."
99+ }
100+ }
101+
102+ // An open request for the same pair, or the form to open one.
103+ var existing *pb.MergeRequest
104+ if !same {
105+ existing, err = s.openRequestFor(ctx, name, src.GetShortName(), tgt.GetShortName())
106+ if err != nil {
107+ return nil, 0, err
108+ }
109+ }
110+ switch {
111+ case existing != nil:
112+ p.Existing = &Link{Label: "!" + itoa(int(existing.GetNumber())) + " " + visibleText(existing.GetTitle()), Href: mrHref(name, existing.GetNumber())}
113+ case SignedIn(ctx) && !same && len(ahead) > 0:
114+ title := ""
115+ if len(ahead) == 1 {
116+ title = ahead[0].GetSummary()
117+ } else {
118+ title = ahead[len(ahead)-1].GetSummary()
119+ }
120+ p.Create = &CreateForm{
121+ Action: mrBase(name), Source: src.GetShortName(), Target: tgt.GetShortName(),
122+ Title: cutRunes(title, 120),
123+ }
124+ }
125+
126+ l := s.repoLayout(ctx, d, "merge-requests")
127+ l.Title = "compare " + p.Source + " · " + name + " · " + s.instance
128+ pinRef(&l, target{rev: src.GetCommitId(), name: src.GetShortName()})
129+ l.Crumbs[0].Current = false
130+ l.Crumbs = append(l.Crumbs,
131+ Crumb{Label: "merge requests", Href: mrBase(name)},
132+ Crumb{Label: "compare", Current: false},
133+ Crumb{Label: p.Source, Current: true},
134+ )
135+ l.Status = Status{
136+ Mode: "compare", Context: p.Source + " → " + p.Target + " · " + p.AheadText,
137+ Hints: "d split · u unified · x collapse all · ? help",
138+ }
139+ l.HasRail, l.RailSum = true, "commits · files"
140+ return Compare(l, p), http.StatusOK, nil
141+}
142+
143+// commitsAhead lists the commits of the source that the target lacks, newest
144+// first. exact is false when the walk gave up before finding where the source
145+// meets the target. behind is true when the target has commits the source
146+// lacks (the tip of the target is not an ancestor of the source).
147+func (s *Server) commitsAhead(ctx context.Context, repo, srcTip, tgtTip string) (ahead []*pb.Commit, exact, behind bool, err error) {
148+ if srcTip == tgtTip {
149+ return nil, true, false, nil
150+ }
151+ onTarget := map[string]bool{}
152+ tokenT := ""
153+ for len(onTarget) < compareDepth {
154+ res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
155+ Repo: repo, Revision: tgtTip, PageSize: pageSize, PageToken: tokenT,
156+ }))
157+ if err != nil {
158+ return nil, false, false, err
159+ }
160+ for _, c := range res.Msg.GetCommits() {
161+ onTarget[c.GetId()] = true
162+ }
163+ if tokenT = res.Msg.GetNextPageToken(); tokenT == "" {
164+ break
165+ }
166+ }
167+ token := ""
168+ for n := 0; n < compareDepth; {
169+ res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
170+ Repo: repo, Revision: srcTip, PageSize: pageSize, PageToken: token,
171+ }))
172+ if err != nil {
173+ return nil, false, false, err
174+ }
175+ for _, c := range res.Msg.GetCommits() {
176+ n++
177+ if onTarget[c.GetId()] {
178+ // The first commit of the source that the target has: where the
179+ // branch left it. The target moved on when this is not its tip.
180+ return ahead, true, c.GetId() != tgtTip, nil
181+ }
182+ ahead = append(ahead, c)
183+ }
184+ if token = res.Msg.GetNextPageToken(); token == "" {
185+ // The whole history of the source is ahead: no common history.
186+ return ahead, true, true, nil
187+ }
188+ }
189+ return ahead, false, !slices.ContainsFunc(ahead, func(c *pb.Commit) bool { return c.GetId() == tgtTip }), nil
190+}
191+
192+// openRequestFor finds the open merge request with this source and target.
193+func (s *Server) openRequestFor(ctx context.Context, repo, source, target string) (*pb.MergeRequest, error) {
194+ token := ""
195+ for range maxOpenPages {
196+ res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
197+ Repo: repo, State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: token,
198+ }))
199+ if err != nil {
200+ return nil, err
201+ }
202+ for _, m := range res.Msg.GetMergeRequests() {
203+ if m.GetSourceBranch() == source && m.GetTargetBranch() == target {
204+ return m, nil
205+ }
206+ }
207+ if token = res.Msg.GetNextPageToken(); token == "" {
208+ break
209+ }
210+ }
211+ return nil, nil
212+}
213+
214+// cutRunes shortens s to at most n characters.
215+func cutRunes(s string, n int) string {
216+ r := []rune(s)
217+ if len(r) <= n {
218+ return s
219+ }
220+ return string(r[:n])
221+}
M internal/adapters/web/diff.go +44 -3
@@ -31,12 +31,48 @@ type DiffLine struct {
3131 Old, New string // line numbers, "" when the side has none
3232 Segs []Seg
3333 NoEOL bool // the side ends without a line break
34+ // OldHref and NewHref link the line number of a side to the comment form
35+ // of that side; they are set on a merge request that can be commented on.
36+ OldHref, NewHref string
37+ // Notes are the comments of a merge request on this line, and Form the
38+ // form to write one, when the reader asked for it.
39+ Notes []CommentView
40+ Form *CommentForm
41+
42+ // oldN and newN are the line numbers of the sides, 0 for none.
43+ oldN, newN int
3444 }
3545
3646 // SplitRow pairs a deleted line (left) with an added one (right); a context
3747 // line fills both sides.
3848 type SplitRow struct{ L, R *DiffLine }
3949
50+// Notes are the comments under a row of the split view: those of its left
51+// line, then those of its right line; a context line, on both sides, counts
52+// once.
53+func (r SplitRow) Notes() []CommentView {
54+ switch {
55+ case r.L == nil && r.R == nil:
56+ return nil
57+ case r.L == nil:
58+ return r.R.Notes
59+ case r.R == nil || r.L == r.R:
60+ return r.L.Notes
61+ }
62+ return append(append([]CommentView(nil), r.L.Notes...), r.R.Notes...)
63+}
64+
65+// Form is the comment form of the row, nil when there is none.
66+func (r SplitRow) Form() *CommentForm {
67+ switch {
68+ case r.R != nil && r.R.Form != nil:
69+ return r.R.Form
70+ case r.L != nil:
71+ return r.L.Form
72+ }
73+ return nil
74+}
75+
4076 // DiffHunk is a changed region.
4177 type DiffHunk struct {
4278 Header string
@@ -50,8 +86,11 @@ type Bar struct{ Add, Del, Rest string }
5086
5187 // DiffFile is one changed file.
5288 type DiffFile struct {
53- ID string
54- Status string // A D M R C T
89+ ID string
90+ Status string // A D M R C T
91+ // Key is the path comments name: the new path, the old path of a deleted
92+ // file. It is not fit to display (Path is).
93+ Key string
5594 Path string // display path, "old → new" for a rename
5695 Add, Del int
5796 Bar Bar
@@ -185,7 +224,7 @@ func hunkHeader(h *pb.Hunk) string {
185224
186225 // diffLine converts a line of the API.
187226 func diffLine(l *pb.Line) DiffLine {
188- dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof()}
227+ dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof(), oldN: int(l.GetOldNumber()), newN: int(l.GetNewNumber())}
189228 switch l.GetKind() {
190229 case pb.LineKind_LINE_KIND_ADDITION:
191230 dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber())
@@ -214,8 +253,10 @@ func diffFiles(files []*pb.FileDiff) []DiffFile {
214253 // (binary, truncated) stays closed under its note.
215254 Open: len(f.GetHunks()) > 0 && !f.GetTruncated(),
216255 }
256+ df.Key = f.GetNewPath()
217257 switch f.GetStatus() {
218258 case pb.FileStatus_FILE_STATUS_DELETED:
259+ df.Key = f.GetOldPath()
219260 df.Path = visibleText(f.GetOldPath())
220261 case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED:
221262 df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath())
M internal/adapters/web/diff.templ +25 -4
@@ -175,11 +175,17 @@ templ DiffFileView(f DiffFile, p DiffView) {
175175 <tr class="hunk"><td colspan="4">{ h.Header }</td></tr>
176176 for _, ln := range h.Lines {
177177 <tr class={ ln.Kind }>
178- <td class="ln">{ ln.Old }</td>
179- <td class="ln">{ ln.New }</td>
178+ <td class="ln">@gutter(ln.Old, ln.OldHref)</td>
179+ <td class="ln">@gutter(ln.New, ln.NewHref)</td>
180180 <td class="m">{ ln.Mark }</td>
181181 <td class="tx">@codeLine(ln)</td>
182182 </tr>
183+ if len(ln.Notes) > 0 || ln.Form != nil {
184+ <tr class="fnote">
185+ <td colspan="3"></td>
186+ <td>@lineNotes(ln.Notes, ln.Form, "")</td>
187+ </tr>
188+ }
183189 }
184190 </tbody>
185191 }
@@ -195,6 +201,11 @@ templ DiffFileView(f DiffFile, p DiffView) {
195201 @splitSide(row.L, true)
196202 @splitSide(row.R, false)
197203 </tr>
204+ if notes, form := row.Notes(), row.Form(); len(notes) > 0 || form != nil {
205+ <tr class="fnote">
206+ <td colspan="6">@lineNotes(notes, form, "-s")</td>
207+ </tr>
208+ }
198209 }
199210 </tbody>
200211 }
@@ -223,12 +234,22 @@ templ splitSide(ln *DiffLine, old bool) {
223234 } else {
224235 <td class={ "ln", ln.Kind }>
225236 if old {
226- { ln.Old }
237+ @gutter(ln.Old, ln.OldHref)
227238 } else {
228- { ln.New }
239+ @gutter(ln.New, ln.NewHref)
229240 }
230241 </td>
231242 <td class={ "m", ln.Kind }>{ ln.Mark }</td>
232243 <td class={ "tx", ln.Kind }>@codeLine(*ln)</td>
233244 }
234245 }
246+
247+// gutter is a line number of the gutter. On a merge request that can be
248+// commented on, it links to the form that comments on that line.
249+templ gutter(n, href string) {
250+ if href != "" {
251+ <a href={ templ.SafeURL(href) } title="comment on this line">{ n }</a>
252+ } else {
253+ { n }
254+ }
255+}
M internal/adapters/web/diff_templ.go +163 -86 generated file: collapsed
M internal/adapters/web/diff_test.go +2 -2
@@ -78,8 +78,8 @@ func TestPairLines(t *testing.T) {
7878 assert.Equal(t, []pair{
7979 {" c", " c"}, {"-d1", "+a1"}, {"-d2", ""}, {"-d3", ""}, {" c2", " c2"}, {"", "+a2"}, {"", "+a3"},
8080 }, got)
81- assert.Equal(t, DiffLine{Kind: "del", Mark: "-", Old: "2", Segs: []Seg{{Text: "d1"}}}, ls[1])
82- assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}}, ls[4])
81+ assert.Equal(t, DiffLine{Kind: "del", Mark: "-", Old: "2", Segs: []Seg{{Text: "d1"}}, oldN: 2}, ls[1])
82+ assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}, newN: 2}, ls[4])
8383 }
8484
8585 func TestChangeBar(t *testing.T) {
M internal/adapters/web/events.go +5 -1
@@ -26,6 +26,8 @@ import (
2626 // GET /-/events?topic=home
2727 // GET /-/events?topic=repo:<name>&view=summary|refs
2828 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
29+// GET /-/events?topic=repo:<name>&view=mrs
30+// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
2931 //
3032 // topic is the Watch topic. view says which fragments of a repository the
3133 // page shows, so that the server renders only those (summary when absent).
@@ -43,6 +45,8 @@ import (
4345 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
4446 // <dl id="refs-kv">…</dl>, the facts of the rail
4547 // commits-head data: <div id="commits-head">N new commits — reload</div>
48+// mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
49+// something changed, with the link that reloads the page
4650 // gap data: gap; events were lost, or what the page shows is gone or
4751 // changed: the page fetches itself again (ermiteLive in ermite.js)
4852 //
@@ -172,7 +176,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
172176 return
173177 }
174178
175- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head")}
179+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")}
176180 handle := func(m recvd) bool {
177181 if m.err != nil {
178182 if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled {
M internal/adapters/web/fragments.go +54 -0
@@ -20,6 +20,7 @@ type liveStream struct {
2020 view string // "summary", "refs" or "commits"
2121 ref string // commits: the branch listed
2222 head string // commits: the commit listed first
23+ n string // mr: the number of the merge request shown
2324 }
2425
2526 // renderFragment renders a component to the data of an event.
@@ -103,7 +104,21 @@ func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, er
103104 return gap, nil // the description, the default branch
104105 }
105106 return nil, nil
107+ case *pb.Event_MergeRequestOpened:
108+ return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload")
109+ case *pb.Event_MergeRequestMerged:
110+ return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload")
111+ case *pb.Event_MergeRequestClosed:
112+ return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload")
113+ case *pb.Event_CommentAdded:
114+ return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload")
106115 case *pb.Event_Pushed:
116+ switch l.view {
117+ case "mr":
118+ return l.mrPushed(ctx, k.Pushed)
119+ case "mrs":
120+ return nil, nil
121+ }
107122 switch l.view {
108123 case "refs":
109124 return l.refs(ctx)
@@ -206,3 +221,42 @@ func newCommits(count string, n int) string {
206221
207222 // isFullID reports whether s is a full commit id.
208223 func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }
224+
225+// mrNotice tells a merge request page that something happened to the request
226+// it shows, or the list that its content changed. Only a fixed sentence is sent:
227+// the text of a comment or a title never leaves through the stream.
228+func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
229+ switch l.view {
230+ case "mrs":
231+ text = "merge requests changed — reload"
232+ return l.notice(ctx, text, mrBase(l.repo))
233+ case "mr":
234+ if strconv.FormatInt(number, 10) != l.n {
235+ return nil, nil
236+ }
237+ return l.notice(ctx, text, mrHref(l.repo, number))
238+ }
239+ return nil, nil
240+}
241+
242+// mrPushed announces a push to the source branch of the merge request shown.
243+func (l *liveStream) mrPushed(ctx context.Context, p *pb.Pushed) ([]fragment, error) {
244+ if l.ref == "" {
245+ return nil, nil
246+ }
247+ for _, u := range p.GetRefUpdates() {
248+ if u.GetRef() == "refs/heads/"+l.ref {
249+ n, err := strconv.ParseInt(l.n, 10, 64)
250+ if err != nil {
251+ return nil, nil
252+ }
253+ return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
254+ }
255+ }
256+ return nil, nil
257+}
258+
259+func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
260+ f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
261+ return []fragment{f}, err
262+}
M internal/adapters/web/fragments.templ +9 -1
@@ -68,6 +68,14 @@ templ CommitsBanner(text, href string) {
6868 </div>
6969 }
7070
71+// MRNotice tells a merge request page that it is out of date. The empty slot
72+// of the page has the same id, so the first notice replaces it.
73+templ MRNotice(text, href string) {
74+ <div id="mr-notice" class="live-banner" role="status">
75+ <a href={ templ.SafeURL(href) }>{ text }</a>
76+ </div>
77+}
78+
7179 // Live is the connection state of the event stream, in the status bar.
7280 // Without a stream it reads "offline" and does nothing. The events of the
7381 // stream are fragments that ermiteLive (ermite.js) swaps in the page.
@@ -83,7 +91,7 @@ templ Live(l Layout) {
8391 :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'"
8492 hx-on="htmx:sse:after:connection -> data.state = 'live';
8593 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
86- home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
94+ home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
8795 gap -> ermiteLive.reload()"
8896 >offline</span>
8997 } else {
M internal/adapters/web/fragments_templ.go +67 -10 generated file: collapsed
M internal/adapters/web/keys.templ +7 -0
@@ -41,6 +41,13 @@ templ Keys() {
4141 k-t from:body -> document.querySelector('.secs [data-key=t]')?.click();
4242 k-c from:body -> document.querySelector('.secs [data-key=c]')?.click();
4343 k-r from:body -> document.querySelector('.secs [data-key=r]')?.click();
44+ k-m from:body -> document.querySelector('.secs [data-key=m]')?.click();
45+ k-gm from:body -> document.querySelector('.secs [data-key=m]')?.click();
46+ k-next from:body -> {
47+ let ns = q('#diffs .cmt').arr(); if (!ns.length) return;
48+ let i = ns.findIndex(n => n.getBoundingClientRect().top > innerHeight / 3);
49+ let n = ns[i < 0 ? 0 : i]; n.closest('details') && (n.closest('details').open = true); n.scrollIntoView({ block: 'center' })
50+ };
4451 k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split');
4552 k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified');
4653 k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false);
M internal/adapters/web/keys_templ.go +1 -1 generated file: collapsed
A internal/adapters/web/mr.go +758 -0
@@ -0,0 +1,758 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "html"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+
14+ "connectrpc.com/connect"
15+ "github.com/a-h/templ"
16+
17+ pb "bnema.dev/ermite-api/gen/ermite"
18+)
19+
20+// The merge request pages (ADR 0017). They read through MergeRequestService
21+// and GitService as the visitor of the request: anonymous sees the merge
22+// requests of public repositories, the signed-in owner those of every
23+// repository, and only the owner gets the forms (mr_post.go handles them).
24+//
25+// Routes:
26+//
27+// GET /<repo>/merge-requests the list, open ones first
28+// GET /<repo>/merge-requests/<n> the request with its diff
29+// GET /<repo>/merge-requests/<n>/since the diff since the last read
30+// GET /<repo>/compare/<branch> a branch against the default branch
31+//
32+// Reading marks nothing as read. MarkRead stores state, and a GET must stay
33+// safe: a prefetcher, a link preview or a reload of the owner's tab would move
34+// the read position, and the "since your last read" diff would lose what the
35+// owner never saw. So the owner marks a head read with a POST button
36+// ("[mark read]", /<n>/read), which carries the head the page showed; the
37+// button only shows when that head is not the one already read. The since page
38+// is the owner's alone (the position is the owner's), and answers 404 to
39+// anyone else.
40+//
41+// Every page here is rendered through page(), so for the owner it is
42+// "private, no-store" with "Vary: Cookie", and for a visitor "no-cache": the
43+// forms and the CLI lines exist only in the owner's copy.
44+
45+const (
46+ // mrPerPage is how many decided merge requests a page of the list shows,
47+ // at least; pages of the API are read whole.
48+ mrPerPage = 50
49+ // maxMRPages bounds the API pages one list page reads.
50+ maxMRPages = 20
51+ // maxOpenPages bounds the pages read for the open merge requests, which
52+ // the first list page shows in full.
53+ maxOpenPages = 5
54+ // maxComparePages bounds how many pages of history the compare page reads
55+ // to find where the branch left the target.
56+ maxComparePages = 5
57+ // railBranches is how many branches the list offers to compare.
58+ railBranches = 15
59+)
60+
61+// MRRow is one merge request in the list.
62+type MRRow struct {
63+ Number string
64+ Href string
65+ Title string
66+ Branches string
67+ Author string
68+ State string
69+ When string
70+ WhenAt string
71+ Cursor bool
72+}
73+
74+// BranchLink is a branch the list offers to open a merge request for.
75+type BranchLink struct{ Name, Href string }
76+
77+// MRListPage is the list of the merge requests of a repository.
78+type MRListPage struct {
79+ Open, Done []MRRow
80+ // OpenNote says the open list was cut.
81+ OpenNote string
82+ // Older and Newest are the paging links of the decided requests.
83+ Older, Newest string
84+ Branches []BranchLink
85+ Default string
86+ CLI Clone
87+ // Live is true when the page announces changes.
88+ Live bool
89+}
90+
91+// CommentView is a comment of a merge request, ready to show.
92+type CommentView struct {
93+ ID string
94+ Author string
95+ When string
96+ WhenAt string
97+ Abs string
98+ Parts []TextPart
99+ Resolved bool
100+ // Outdated is true when the head moved since the comment was written, so
101+ // its line may have moved too.
102+ Outdated bool
103+ // Where names the line of a comment that is not under a line of the diff
104+ // shown ("path:12 (new)").
105+ Where string
106+ // Resolve is the form that resolves or unresolves it; nil when the reader
107+ // cannot.
108+ Resolve *ResolveForm
109+}
110+
111+// ResolveForm is the POST that changes the resolved state of a comment.
112+type ResolveForm struct {
113+ Action string
114+ // Value is the state the form sets.
115+ Value bool
116+ Label string
117+}
118+
119+// CommentForm is the POST that adds a comment.
120+type CommentForm struct {
121+ ID string
122+ Action string
123+ Title string
124+ // Path, Line and Side designate the line; empty for a general comment.
125+ Path, Line, Side string
126+ Cancel string
127+ Focus bool
128+}
129+
130+// MRCommit is a commit of the request.
131+type MRCommit struct {
132+ Short, Href, Subject, Author, When, WhenAt string
133+ // New is true for a commit pushed after the head the reviewer last read.
134+ New bool
135+}
136+
137+// MergeBox is what the owner does with an open request.
138+type MergeBox struct {
139+ MergeAction string
140+ CloseAction string
141+ ReadAction string
142+ Head string
143+ Modes []ModeOption
144+ // HeadGone is true when the source branch no longer exists: there is
145+ // nothing to merge.
146+ HeadGone bool
147+ CLI Clone
148+ // CanMarkRead is true when the head differs from the one last read.
149+ CanMarkRead bool
150+}
151+
152+// ModeOption is a merge mode the repository allows.
153+type ModeOption struct{ Value, Label string }
154+
155+// SinceNav switches between the whole diff and the diff since the last read.
156+type SinceNav struct {
157+ AllHref, SinceHref string
158+ Files int
159+ ReadShort string
160+ HeadShort string
161+ Current bool
162+}
163+
164+// MRPage is a merge request with its diff and comments.
165+type MRPage struct {
166+ Number string
167+ Title string
168+ State string
169+ Open bool
170+ Signed bool
171+ Source, Target string
172+ SourceHref string
173+ TargetHref string
174+ Author string
175+ Opened Person
176+ Updated string
177+ UpdatedAt string
178+ Desc []TextPart
179+ Head string
180+ HeadShort string
181+ MergedShort string
182+ MergedHref string
183+ MergeMode string
184+
185+ Since *SinceNav
186+ SinceNote string
187+
188+ NFiles, Adds, Dels int
189+ Files []DiffFile
190+ DiffView
191+ Note string
192+ Invisible bool
193+
194+ // General are the comments that are not on a line, Loose those on a line
195+ // that the diff shown does not have.
196+ General, Loose []CommentView
197+ NComments int
198+ NUnresolved int
199+ GeneralForm *CommentForm
200+
201+ Commits []MRCommit
202+ CommitsNote string
203+ Modes []string
204+ ChecksBlock bool
205+
206+ Box *MergeBox
207+ // Live is true when the page announces changes.
208+ Live bool
209+}
210+
211+// CompareCommit is a commit of a branch that the target lacks.
212+type CompareCommit = CommitRow
213+
214+// ComparePage is a branch compared with the default branch.
215+type ComparePage struct {
216+ Source, Target string
217+ SourceHref string
218+ TargetHref string
219+ Ahead []CommitRow
220+ AheadText string
221+ CLI Clone
222+ // Note says what the page could not tell.
223+ Note string
224+ // The diff is the one of the single commit the branch is ahead by.
225+ NFiles, Adds, Dels int
226+ Files []DiffFile
227+ DiffView
228+ Invisible bool
229+ HasDiff bool
230+ // Existing is the open merge request for this pair, when there is one.
231+ Existing *Link
232+ // Create is the form to open one; nil when the reader cannot or there is
233+ // one already.
234+ Create *CreateForm
235+}
236+
237+// CreateForm is the POST that opens a merge request.
238+type CreateForm struct {
239+ Action, Source, Target, Title string
240+}
241+
242+// mrBase is the URL of the merge request pages of a repository.
243+func mrBase(repo string) string { return repoHref(repo, "merge-requests") }
244+
245+func mrHref(repo string, n int64, sub ...string) string {
246+ return repoHref(repo, append([]string{"merge-requests", strconv.FormatInt(n, 10)}, sub...)...)
247+}
248+
249+// mrNumber reads the number of the path: a positive decimal number, written
250+// without sign or leading zeros, else the page is not found.
251+func mrNumber(r *http.Request) (int64, error) {
252+ s := r.PathValue("n")
253+ n, err := strconv.ParseInt(s, 10, 64)
254+ if err != nil || n < 1 || strconv.FormatInt(n, 10) != s {
255+ return 0, errNotFound
256+ }
257+ return n, nil
258+}
259+
260+func stateName(s pb.MergeRequestState) string {
261+ switch s {
262+ case pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN:
263+ return "open"
264+ case pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED:
265+ return "merged"
266+ case pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED:
267+ return "closed"
268+ }
269+ return "unknown"
270+}
271+
272+func modeName(m pb.MergeMode) string {
273+ switch m {
274+ case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
275+ return "fast-forward"
276+ case pb.MergeMode_MERGE_MODE_SQUASH:
277+ return "squash"
278+ }
279+ return ""
280+}
281+
282+// mrRow converts a merge request of a listing.
283+func (s *Server) mrRow(repo string, m *pb.MergeRequest) MRRow {
284+ row := MRRow{
285+ Number: "!" + strconv.FormatInt(m.GetNumber(), 10), Href: mrHref(repo, m.GetNumber()),
286+ Title: visibleText(m.GetTitle()), State: stateName(m.GetState()),
287+ Branches: visibleText(m.GetSourceBranch()) + " → " + visibleText(m.GetTargetBranch()),
288+ Author: visibleText(m.GetAuthor()),
289+ }
290+ ts := m.GetUpdatedAt()
291+ if !ts.IsValid() {
292+ ts = m.GetCreatedAt()
293+ }
294+ row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
295+ return row
296+}
297+
298+// mrList lists the merge requests: the open ones, then the merged and closed
299+// ones, a page at a time. The decided ones are paged with the page token of
300+// the API in "page"; the open ones show on the first page only.
301+func (s *Server) mrList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
302+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
303+ if err != nil {
304+ return nil, 0, err
305+ }
306+ token := r.URL.Query().Get("page")
307+ p := MRListPage{Default: visibleText(d.repo.GetDefaultBranch())}
308+ p.CLI = Clone{ID: "cli-new", Lines: []string{"ermite mr create " + shellWord(d.name()) + " --source <branch> --title <title>"}}
309+ base := mrBase(d.name())
310+
311+ if token == "" {
312+ tok := ""
313+ for i := 0; i < maxOpenPages; i++ {
314+ res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
315+ Repo: d.name(), State: pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, PageSize: pageSize, PageToken: tok,
316+ }))
317+ if err != nil {
318+ return nil, 0, err
319+ }
320+ for _, m := range res.Msg.GetMergeRequests() {
321+ p.Open = append(p.Open, s.mrRow(d.name(), m))
322+ }
323+ if tok = res.Msg.GetNextPageToken(); tok == "" {
324+ break
325+ }
326+ }
327+ if tok != "" {
328+ p.OpenNote = "more open merge requests than are listed here."
329+ }
330+ }
331+
332+ tok := token
333+ for i := 0; i < maxMRPages; i++ {
334+ res, err := s.cfg.MergeRequests.ListMergeRequests(ctx, connect.NewRequest(&pb.ListMergeRequestsRequest{
335+ Repo: d.name(), PageSize: pageSize, PageToken: tok,
336+ }))
337+ if err != nil {
338+ return nil, 0, err
339+ }
340+ for _, m := range res.Msg.GetMergeRequests() {
341+ if m.GetState() != pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN {
342+ p.Done = append(p.Done, s.mrRow(d.name(), m))
343+ }
344+ }
345+ tok = res.Msg.GetNextPageToken()
346+ if tok == "" || len(p.Done) >= mrPerPage {
347+ break
348+ }
349+ }
350+ if tok != "" {
351+ p.Older = base + "?page=" + url.QueryEscape(tok)
352+ }
353+ if token != "" {
354+ p.Newest = base
355+ }
356+ switch {
357+ case len(p.Open) > 0:
358+ p.Open[0].Cursor = true
359+ case len(p.Done) > 0:
360+ p.Done[0].Cursor = true
361+ }
362+ for _, b := range d.branches {
363+ if b.GetShortName() == d.repo.GetDefaultBranch() || len(p.Branches) >= railBranches {
364+ continue
365+ }
366+ p.Branches = append(p.Branches, BranchLink{Name: visibleText(b.GetShortName()), Href: repoHref(d.name(), "compare", b.GetShortName())})
367+ }
368+
369+ l := s.repoLayout(ctx, d, "merge-requests")
370+ l.Title = "merge requests · " + d.name() + " · " + s.instance
371+ l.Crumbs[0].Current = false
372+ l.Crumbs = append(l.Crumbs, Crumb{Label: "merge requests", Current: true})
373+ if token == "" {
374+ p.Live = true
375+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mrs")
376+ }
377+ rows := len(p.Open) + len(p.Done)
378+ l.Status = Status{
379+ Mode: "merge requests", Context: itoa(len(p.Open)) + " open · " + itoa(len(p.Done)) + " decided",
380+ Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: rows,
381+ }
382+ l.HasRail, l.RailSum = true, "states · open one"
383+ return MRList(l, p), http.StatusOK, nil
384+}
385+
386+// mrShow shows a merge request: the whole diff against the target.
387+func (s *Server) mrShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
388+ return s.mrPage(ctx, r, false)
389+}
390+
391+// mrSince shows the diff since the head the reviewer last marked read.
392+func (s *Server) mrSince(ctx context.Context, r *http.Request) (templ.Component, int, error) {
393+ return s.mrPage(ctx, r, true)
394+}
395+
396+func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ.Component, int, error) {
397+ n, err := mrNumber(r)
398+ if err != nil {
399+ return nil, 0, err
400+ }
401+ if since && !SignedIn(ctx) {
402+ // The read position is the owner's: there is nothing to show others.
403+ return nil, 0, errNotFound
404+ }
405+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
406+ if err != nil {
407+ return nil, 0, err
408+ }
409+ res, err := s.cfg.MergeRequests.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{
410+ Repo: d.name(), Number: n, SinceLastRead: since,
411+ }))
412+ if err != nil {
413+ return nil, 0, err
414+ }
415+ m := res.Msg.GetMergeRequest()
416+ if m == nil {
417+ return nil, 0, errNotFound
418+ }
419+ now := s.cfg.Now()
420+ signed := SignedIn(ctx)
421+ open := m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN
422+ head := m.GetHeadId()
423+ self := mrHref(d.name(), n)
424+ if since {
425+ self = mrHref(d.name(), n, "since")
426+ }
427+
428+ p := MRPage{
429+ Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.GetTitle()),
430+ State: stateName(m.GetState()), Open: open, Signed: signed,
431+ Source: visibleText(m.GetSourceBranch()), Target: visibleText(m.GetTargetBranch()),
432+ Author: visibleText(m.GetAuthor()),
433+ Desc: plainParts(m.GetDescription()),
434+ Head: head, HeadShort: short(head),
435+ NFiles: int(res.Msg.GetStats().GetFilesChanged()),
436+ Adds: int(res.Msg.GetStats().GetAdditions()),
437+ Dels: int(res.Msg.GetStats().GetDeletions()),
438+ Files: diffFiles(res.Msg.GetDiff().GetFiles()),
439+ }
440+ p.Opened.Name = p.Author
441+ p.Opened.When, p.Opened.WhenAt = whenOf(now, m.GetCreatedAt())
442+ if t := m.GetCreatedAt(); t.IsValid() {
443+ p.Opened.Abs = absolute(t.AsTime())
444+ }
445+ p.Updated, p.UpdatedAt = whenOf(now, m.GetUpdatedAt())
446+ for _, b := range d.branches {
447+ switch b.GetShortName() {
448+ case m.GetSourceBranch():
449+ p.SourceHref = repoHref(d.name(), "tree", b.GetShortName())
450+ case m.GetTargetBranch():
451+ p.TargetHref = repoHref(d.name(), "tree", b.GetShortName())
452+ }
453+ }
454+ if m.GetState() == pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED && m.GetMergedCommitId() != "" {
455+ p.MergedShort = short(m.GetMergedCommitId())
456+ p.MergedHref = repoHref(d.name(), "commit", m.GetMergedCommitId())
457+ p.MergeMode = modeName(m.GetMergeMode())
458+ }
459+ if st := d.repo.GetSettings(); st != nil {
460+ for _, mode := range st.GetAllowedMergeModes() {
461+ if name := modeName(mode); name != "" {
462+ p.Modes = append(p.Modes, name)
463+ }
464+ }
465+ p.ChecksBlock = st.GetFailingChecksBlock()
466+ }
467+ p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
468+
469+ // The commits of the request, newest first.
470+ for _, c := range res.Msg.GetCommits() {
471+ row := commitRow(d.name(), c, now)
472+ p.Commits = append(p.Commits, MRCommit{
473+ Short: row.Short, Href: row.CHref, Subject: row.Subject, Author: row.Author, When: row.When, WhenAt: row.WhenAt,
474+ })
475+ }
476+ lastRead := res.Msg.GetLastReadId()
477+ if signed && lastRead != "" {
478+ // The commits are newest first: those before the last read head are new.
479+ // A head that is not among them (the branch was rewritten) marks none.
480+ for i, c := range res.Msg.GetCommits() {
481+ if c.GetId() != lastRead {
482+ continue
483+ }
484+ for j := 0; j < i; j++ {
485+ p.Commits[j].New = true
486+ }
487+ }
488+ }
489+ if res.Msg.GetCommitsTruncated() {
490+ p.CommitsNote = "the branch has more commits than the " + itoa(len(p.Commits)) + " listed."
491+ }
492+
493+ // Since the last read: the navigation, and what the page says about it.
494+ if !signed {
495+ lastRead = "" // the read position is the owner's
496+ }
497+ if signed && lastRead != "" {
498+ p.Since = &SinceNav{
499+ AllHref: mrHref(d.name(), n), SinceHref: mrHref(d.name(), n, "since"),
500+ ReadShort: short(lastRead), HeadShort: short(head), Current: since,
501+ }
502+ }
503+ if since {
504+ switch {
505+ case lastRead == "":
506+ p.SinceNote = "nothing was marked read yet: this is the whole change."
507+ case res.Msg.GetLastReadLost():
508+ p.SinceNote = "the commit you last read (" + short(lastRead) + ") is gone, the branch was rewritten: this is the whole change, review it again."
509+ case len(p.Files) == 0:
510+ p.SinceNote = "nothing changed since " + short(lastRead) + "."
511+ default:
512+ p.SinceNote = "changes since " + short(lastRead) + ", the head you last read."
513+ }
514+ }
515+ if p.Since != nil {
516+ p.Since.Files = len(p.Files)
517+ }
518+
519+ // Comments: under their line, else in the list below the diff. The forms
520+ // are the owner's, on the whole diff of an open request.
521+ canWrite := signed && open && !since
522+ var on *lineRef
523+ if canWrite {
524+ on = parseOn(r.URL.Query().Get("on"))
525+ }
526+ s.placeComments(&p, d.name(), n, res.Msg.GetComments(), head, !since, canWrite, on)
527+ lines, invisible := diffLines(p.Files)
528+ p.Invisible = invisible
529+ p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
530+ if signed && open {
531+ p.Box = s.mergeBox(d, n, head, lastRead)
532+ }
533+ if canWrite {
534+ p.GeneralForm = &CommentForm{
535+ ID: "cf-general", Action: mrHref(d.name(), n, "comments"), Title: "comment on the merge request",
536+ }
537+ }
538+
539+ l := s.repoLayout(ctx, d, "merge-requests")
540+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
541+ if head != "" {
542+ pinRef(&l, target{rev: head, name: m.GetSourceBranch()})
543+ if !d.hasBranch(m.GetSourceBranch()) {
544+ pinRef(&l, target{rev: head, name: short(head), detached: true})
545+ }
546+ }
547+ l.Crumbs[0].Current = false
548+ l.Crumbs = append(l.Crumbs,
549+ Crumb{Label: "merge requests", Href: mrBase(d.name())},
550+ Crumb{Label: p.Number, Href: mrHref(d.name(), n), Current: !since},
551+ )
552+ if since {
553+ l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
554+ }
555+ p.Live = true
556+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.GetSourceBranch()))
557+ l.Status = Status{
558+ Mode: "merge request", Context: p.Number + " · " + p.Source + " → " + p.Target + " · " + p.State,
559+ Hints: "n next note · d split · u unified · x collapse all · ? help",
560+ }
561+ l.HasRail, l.RailSum = true, "merge · checks · commits · files"
562+ return MergeRequest(l, p), http.StatusOK, nil
563+}
564+
565+// hasBranch tells whether the repository has the branch.
566+func (d *repoData) hasBranch(name string) bool {
567+ for _, b := range d.branches {
568+ if b.GetShortName() == name {
569+ return true
570+ }
571+ }
572+ return false
573+}
574+
575+// mergeBox is what the owner can do with an open request.
576+func (s *Server) mergeBox(d *repoData, n int64, head, lastRead string) *MergeBox {
577+ b := &MergeBox{
578+ MergeAction: mrHref(d.name(), n, "merge"), CloseAction: mrHref(d.name(), n, "close"),
579+ ReadAction: mrHref(d.name(), n, "read"), Head: head, HeadGone: head == "",
580+ CanMarkRead: head != "" && head != lastRead,
581+ }
582+ ff, sq := false, false
583+ if st := d.repo.GetSettings(); st != nil {
584+ for _, mode := range st.GetAllowedMergeModes() {
585+ switch mode {
586+ case pb.MergeMode_MERGE_MODE_FAST_FORWARD:
587+ ff = true
588+ b.Modes = append(b.Modes, ModeOption{Value: "fast-forward", Label: "fast-forward"})
589+ case pb.MergeMode_MERGE_MODE_SQUASH:
590+ sq = true
591+ b.Modes = append(b.Modes, ModeOption{Value: "squash", Label: "squash (one signed commit)"})
592+ }
593+ }
594+ }
595+ if head != "" {
596+ cmd := "ermite mr merge " + shellWord(d.name()) + " " + strconv.FormatInt(n, 10) + " --head " + head
597+ b.CLI = Clone{ID: "cli-merge"}
598+ if ff {
599+ b.CLI.Lines = append(b.CLI.Lines, cmd)
600+ }
601+ if sq {
602+ b.CLI.Lines = append(b.CLI.Lines, cmd+" --squash")
603+ }
604+ }
605+ return b
606+}
607+
608+// shellWord quotes s for a shell unless it is made of safe characters, which
609+// a repository name always is.
610+func shellWord(s string) string {
611+ for _, c := range []byte(s) {
612+ if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' && c != '.' && c != '_' && c != '/' {
613+ return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
614+ }
615+ }
616+ return s
617+}
618+
619+// lineRef designates a line of the diff in the query of the comment form:
620+// "?on=new:12:path/to/file".
621+type lineRef struct {
622+ side string
623+ line int
624+ path string
625+}
626+
627+func parseOn(s string) *lineRef {
628+ side, rest, ok := strings.Cut(s, ":")
629+ if !ok || (side != "new" && side != "old") {
630+ return nil
631+ }
632+ num, path, ok := strings.Cut(rest, ":")
633+ line, err := strconv.Atoi(num)
634+ if !ok || err != nil || line < 1 || path == "" {
635+ return nil
636+ }
637+ return &lineRef{side: side, line: line, path: path}
638+}
639+
640+// placeComments puts every comment under its line when the diff shown has it,
641+// else in the list below the diff. The comments name lines of the diff against
642+// the target, so a page that shows another diff (since the last read) lists
643+// them all below, with their line. A reader who can write gets the line numbers
644+// as links to the form of their line; on designates the line whose form shows.
645+func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []*pb.Comment, head string, inline, canWrite bool, on *lineRef) {
646+ now := s.cfg.Now()
647+ self := mrHref(repo, n)
648+ resolveAction := func(id string) string { return mrHref(repo, n, "comments", id, "resolve") }
649+
650+ type lineKey struct {
651+ path string
652+ side pb.DiffSide
653+ line int
654+ }
655+ index := map[lineKey]*DiffLine{}
656+ fileIDs := map[string]string{}
657+ for fi := range p.Files {
658+ f := &p.Files[fi]
659+ fileIDs[f.Key] = f.ID
660+ linkable := canWrite && f.Key != "" && !strings.ContainsFunc(f.Key, invisible)
661+ for hi := range f.Hunks {
662+ for li := range f.Hunks[hi].Lines {
663+ ln := &f.Hunks[hi].Lines[li]
664+ if ln.oldN > 0 && ln.Kind != "add" {
665+ index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_OLD, ln.oldN}] = ln
666+ if linkable {
667+ ln.OldHref = self + "?on=old:" + itoa(ln.oldN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
668+ }
669+ }
670+ if ln.newN > 0 && ln.Kind != "del" {
671+ index[lineKey{f.Key, pb.DiffSide_DIFF_SIDE_NEW, ln.newN}] = ln
672+ if linkable {
673+ ln.NewHref = self + "?on=new:" + itoa(ln.newN) + ":" + url.QueryEscape(f.Key) + "#" + f.ID
674+ }
675+ }
676+ }
677+ }
678+ }
679+ find := func(path string, side pb.DiffSide, line int) *DiffLine {
680+ if !inline {
681+ return nil
682+ }
683+ return index[lineKey{path, side, line}]
684+ }
685+
686+ for _, c := range comments {
687+ v := CommentView{
688+ ID: c.GetId(), Author: visibleText(c.GetAuthor()),
689+ Parts: plainParts(c.GetText()), Resolved: c.GetResolved(),
690+ Outdated: c.GetCommitId() != "" && c.GetCommitId() != head,
691+ }
692+ v.When, v.WhenAt = whenOf(now, c.GetCreatedAt())
693+ if t := c.GetCreatedAt(); t.IsValid() {
694+ v.Abs = absolute(t.AsTime())
695+ }
696+ p.NComments++
697+ if !c.GetResolved() {
698+ p.NUnresolved++
699+ }
700+ general := c.GetPath() == ""
701+ var ln *DiffLine
702+ if !general {
703+ ln = find(c.GetPath(), c.GetSide(), int(c.GetLine()))
704+ if ln == nil {
705+ side := "new"
706+ if c.GetSide() == pb.DiffSide_DIFF_SIDE_OLD {
707+ side = "old"
708+ }
709+ v.Where = visibleText(c.GetPath()) + ":" + itoa(int(c.GetLine())) + " (" + side + ")"
710+ }
711+ }
712+ if canWrite {
713+ v.Resolve = &ResolveForm{Action: resolveAction(c.GetId()), Value: !c.GetResolved(), Label: "[resolve]"}
714+ if c.GetResolved() {
715+ v.Resolve.Label = "[unresolve]"
716+ }
717+ }
718+ switch {
719+ case ln != nil:
720+ ln.Notes = append(ln.Notes, v)
721+ case general:
722+ p.General = append(p.General, v)
723+ default:
724+ p.Loose = append(p.Loose, v)
725+ }
726+ }
727+
728+ if on != nil {
729+ side := pb.DiffSide_DIFF_SIDE_NEW
730+ if on.side == "old" {
731+ side = pb.DiffSide_DIFF_SIDE_OLD
732+ }
733+ if ln := find(on.path, side, on.line); ln != nil {
734+ ln.Form = &CommentForm{
735+ ID: "cf-line", Action: mrHref(repo, n, "comments"), Path: on.path, Line: itoa(on.line), Side: on.side,
736+ Title: "comment on " + visibleText(on.path) + ":" + itoa(on.line) + " (" + on.side + ")",
737+ Cancel: self + "#" + fileIDs[on.path], Focus: true,
738+ }
739+ }
740+ }
741+}
742+
743+// userText renders the parts of a plain text: the text escaped, the http(s)
744+// addresses as links. Its white space is kept by the style of the container.
745+func userText(parts []TextPart) templ.Component {
746+ return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
747+ var b strings.Builder
748+ for _, part := range parts {
749+ if part.Href != "" {
750+ b.WriteString(`<a href="` + html.EscapeString(part.Href) + `" rel="noopener nofollow ugc">` + html.EscapeString(part.Text) + `</a>`)
751+ } else {
752+ b.WriteString(html.EscapeString(part.Text))
753+ }
754+ }
755+ _, err := io.WriteString(w, b.String())
756+ return err
757+ })
758+}
A internal/adapters/web/mr.templ +506 -0
@@ -0,0 +1,506 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import "strings"
6+
7+// MRList is the list of the merge requests of a repository: the open ones,
8+// then the merged and closed ones.
9+templ MRList(l Layout, p MRListPage) {
10+ @Shell(l, mrListRail(p)) {
11+ if p.Live {
12+ <div id="mr-notice"></div>
13+ }
14+ <div class="filter">
15+ <label for="mr-filter">filter&gt;</label>
16+ <input
17+ id="mr-filter"
18+ type="search"
19+ data-filter
20+ placeholder="title, branch, author or state ( / )"
21+ autocomplete="off"
22+ hx-on:input="let t = this.value.toLowerCase(); q('.mrs tbody tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t));
23+ let first = q('.mrs tbody tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.mrs tbody tr')"
24+ />
25+ </div>
26+ if p.Newest == "" {
27+ <h2 class="rule-h"><span>open</span><span>{ itoa(len(p.Open)) }</span></h2>
28+ @mrRows(p.Open, "Open merge requests")
29+ if len(p.Open) == 0 {
30+ <p class="lede dim">no open merge requests.</p>
31+ }
32+ if p.OpenNote != "" {
33+ <p class="note">{ p.OpenNote }</p>
34+ }
35+ }
36+ <h2 class="rule-h"><span>merged and closed</span><span>{ itoa(len(p.Done)) }</span></h2>
37+ @mrRows(p.Done, "Merged and closed merge requests")
38+ if len(p.Done) == 0 {
39+ <p class="lede dim">nothing merged or closed yet.</p>
40+ }
41+ if p.Older != "" || p.Newest != "" {
42+ <nav class="pager" aria-label="Pages">
43+ if p.Newest != "" {
44+ <a href={ templ.SafeURL(p.Newest) }>← newest</a>
45+ } else {
46+ <span class="mute">← newest</span>
47+ }
48+ if p.Older != "" {
49+ <a href={ templ.SafeURL(p.Older) } rel="next">older →</a>
50+ } else {
51+ <span class="mute">older →</span>
52+ }
53+ </nav>
54+ }
55+ }
56+}
57+
58+templ mrRows(rows []MRRow, label string) {
59+ if len(rows) > 0 {
60+ <table class="list mrs" aria-label={ label }>
61+ <tbody data-rows>
62+ for _, m := range rows {
63+ <tr data-cursor?={ m.Cursor } data-state={ m.State }>
64+ <td class="cur" aria-hidden="true"></td>
65+ <td class="c-n mute">{ m.Number }</td>
66+ <td class="c-state">{ m.State }</td>
67+ <td class="c-subject"><a href={ templ.SafeURL(m.Href) }>{ m.Title }</a> <span class="mute">{ m.Branches }</span></td>
68+ <td class="c-who dim">{ m.Author }</td>
69+ <td class="c-when nowrap dim">
70+ if m.WhenAt != "" {
71+ <time datetime={ m.WhenAt } title={ m.WhenAt }>{ m.When }</time>
72+ }
73+ </td>
74+ </tr>
75+ }
76+ </tbody>
77+ </table>
78+ }
79+}
80+
81+templ mrListRail(p MRListPage) {
82+ <section aria-labelledby="r-states">
83+ <h2 id="r-states">states</h2>
84+ <dl class="kv">
85+ <dt>open</dt>
86+ <dd>awaits review</dd>
87+ <dt>merged</dt>
88+ <dd>the target took the branch</dd>
89+ <dt>closed</dt>
90+ <dd>refused, branch kept</dd>
91+ </dl>
92+ </section>
93+ <section aria-labelledby="r-new">
94+ <h2 id="r-new">open one</h2>
95+ if len(p.Branches) > 0 {
96+ <p class="dim">compare a branch with { p.Default }:</p>
97+ <nav class="outline" aria-label="Branches">
98+ for _, b := range p.Branches {
99+ <a href={ templ.SafeURL(b.Href) }>{ b.Name }</a>
100+ }
101+ </nav>
102+ } else {
103+ <p class="dim">there is no other branch to merge yet.</p>
104+ }
105+ @CloneBlock(p.CLI)
106+ </section>
107+}
108+
109+// MergeRequest is a merge request: its facts, its description, the diff with
110+// the notes under their lines, the general comments; and in the rail the merge
111+// form, the checks, the settings, the commits and the files.
112+templ MergeRequest(l Layout, p MRPage) {
113+ @Shell(l, mrRail(p)) {
114+ <header class="show mr-h">
115+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
116+ <dl class="kv show-kv">
117+ <dt>state</dt>
118+ <dd class="mr-state" data-state={ p.State }>
119+ { p.State }
120+ if p.MergedShort != "" {
121+ { " · " }<a href={ templ.SafeURL(p.MergedHref) }>{ p.MergedShort }</a>{ " · " + p.MergeMode }
122+ }
123+ </dd>
124+ <dt>branch</dt>
125+ <dd>
126+ @branchLink(p.Source, p.SourceHref)
127+ { " → " }
128+ @branchLink(p.Target, p.TargetHref)
129+ </dd>
130+ <dt>opened</dt>
131+ <dd>
132+ { p.Opened.Name }
133+ if p.Opened.WhenAt != "" {
134+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
135+ }
136+ </dd>
137+ if p.UpdatedAt != "" {
138+ <dt>updated</dt>
139+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
140+ }
141+ </dl>
142+ </header>
143+ <div id="mr-notice"></div>
144+ if len(p.Desc) > 0 {
145+ <p class="mr-desc">@userText(p.Desc)</p>
146+ }
147+ if p.Since != nil {
148+ <nav class="since" aria-label="Diff range">
149+ <a
150+ href={ templ.SafeURL(p.Since.AllHref) }
151+ if !p.Since.Current {
152+ aria-current="page"
153+ }
154+ >all changes <span class="mute">vs { p.Target }</span></a>
155+ <a
156+ href={ templ.SafeURL(p.Since.SinceHref) }
157+ if p.Since.Current {
158+ aria-current="page"
159+ }
160+ >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
161+ </nav>
162+ }
163+ if p.SinceNote != "" {
164+ <p class="notice">{ p.SinceNote }</p>
165+ }
166+ <div class="diff-tools" role="toolbar" aria-label="Diff view">
167+ <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
168+ <span class="grow"></span>
169+ @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)
170+ @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)
171+ <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
172+ </div>
173+ if p.Invisible {
174+ <p class="notice">
175+ this diff holds invisible characters (direction controls, zero-width characters). They are shown as
176+ <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
177+ </p>
178+ }
179+ if p.Note != "" {
180+ <p class="notice">{ p.Note }</p>
181+ }
182+ <div
183+ id="diffs"
184+ if p.Both {
185+ data-diff={ p.View }
186+ } else {
187+ data-view={ p.View }
188+ }
189+ if p.Both && !p.Pinned {
190+ hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
191+ }
192+ >
193+ for _, f := range p.Files {
194+ @DiffFileView(f, p.DiffView)
195+ }
196+ if len(p.Files) == 0 {
197+ <p class="note">no changed files.</p>
198+ }
199+ </div>
200+ <h2 class="rule-h" id="comments"><span>comments</span><span>{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
201+ for _, c := range p.Loose {
202+ @commentView(c, "")
203+ }
204+ for _, c := range p.General {
205+ @commentView(c, "")
206+ }
207+ if p.NComments == 0 {
208+ <p class="dim">no comments yet.</p>
209+ }
210+ if p.GeneralForm != nil {
211+ @commentForm(*p.GeneralForm, "")
212+ }
213+ }
214+}
215+
216+templ branchLink(name, href string) {
217+ if href != "" {
218+ <a href={ templ.SafeURL(href) }>{ name }</a>
219+ } else {
220+ { name }
221+ }
222+}
223+
224+// lineNotes are the comments under a line of the diff, and the form to write
225+// one when the reader asked for it. The diff is in the page twice (unified and
226+// split); the suffix keeps the ids unique, the unified table having none.
227+templ lineNotes(notes []CommentView, form *CommentForm, suffix string) {
228+ for _, c := range notes {
229+ @commentView(c, suffix)
230+ }
231+ if form != nil {
232+ @commentForm(*form, suffix)
233+ }
234+}
235+
236+// commentView is a note: a ruled block, under its line in the diff or in the
237+// list of comments. The text is plain: escaped, its line breaks kept, its
238+// http(s) addresses linked.
239+templ commentView(c CommentView, suffix string) {
240+ <div class="cmt" id={ "c" + c.ID + suffix } data-resolved={ boolText(c.Resolved) }>
241+ <p class="c-head">
242+ <span class="c-who">{ c.Author }</span>
243+ if c.WhenAt != "" {
244+ { " " }<time class="mute" datetime={ c.WhenAt } title={ c.Abs }>{ c.When }</time>
245+ }
246+ if c.Resolved {
247+ { " " }<span class="c-state">resolved</span>
248+ }
249+ if c.Outdated {
250+ { " " }<span class="c-state" title="written on an earlier head; the line may have moved">outdated</span>
251+ }
252+ if c.Where != "" {
253+ { " " }<span class="c-where mute">{ c.Where }</span>
254+ }
255+ </p>
256+ <p class="c-text">@userText(c.Parts)</p>
257+ if c.Resolve != nil {
258+ <form class="c-act" method="post" action={ templ.SafeURL(c.Resolve.Action) }>
259+ <input type="hidden" name="resolved" value={ boolText(c.Resolve.Value) }/>
260+ <button type="submit">{ c.Resolve.Label }</button>
261+ </form>
262+ }
263+ </div>
264+}
265+
266+func boolText(b bool) string {
267+ if b {
268+ return "true"
269+ }
270+ return "false"
271+}
272+
273+// commentForm writes a comment, general or on a line.
274+templ commentForm(f CommentForm, suffix string) {
275+ <form class="cform" method="post" action={ templ.SafeURL(f.Action) }>
276+ <label for={ f.ID + suffix }>{ f.Title }</label>
277+ <textarea
278+ id={ f.ID + suffix }
279+ name="text"
280+ rows="3"
281+ maxlength="8000"
282+ required
283+ placeholder="plain text, 8000 characters at most"
284+ if f.Focus {
285+ autofocus
286+ }
287+ ></textarea>
288+ if f.Path != "" {
289+ <input type="hidden" name="path" value={ f.Path }/>
290+ <input type="hidden" name="line" value={ f.Line }/>
291+ <input type="hidden" name="side" value={ f.Side }/>
292+ }
293+ <div class="f-btns">
294+ <button type="submit" class="f-main">[comment]</button>
295+ if f.Cancel != "" {
296+ <a href={ templ.SafeURL(f.Cancel) }>[cancel]</a>
297+ }
298+ </div>
299+ </form>
300+}
301+
302+templ mrRail(p MRPage) {
303+ if p.Box != nil {
304+ @mergeBox(p)
305+ }
306+ <section aria-labelledby="r-checks">
307+ <h2 id="r-checks">checks <span>0</span></h2>
308+ <p class="dim">no checks</p>
309+ <p class="mute">there is no CI yet: nothing blocks the merge.</p>
310+ </section>
311+ <section aria-labelledby="r-settings">
312+ <h2 id="r-settings">settings</h2>
313+ <dl class="kv">
314+ <dt>modes</dt>
315+ <dd>{ strings.Join(p.Modes, ", ") }</dd>
316+ <dt>checks</dt>
317+ <dd>
318+ if p.ChecksBlock {
319+ failing ones block the merge
320+ } else {
321+ do not block the merge
322+ }
323+ </dd>
324+ </dl>
325+ </section>
326+ <section aria-labelledby="r-commits">
327+ <h2 id="r-commits">commits <span>{ itoa(len(p.Commits)) }</span></h2>
328+ <ul class="pcommits">
329+ for _, c := range p.Commits {
330+ <li data-new?={ c.New }>
331+ <a href={ templ.SafeURL(c.Href) }>{ c.Short }</a>
332+ <span class="s">{ c.Subject }</span>
333+ <span class="mute">
334+ { c.Author }
335+ if c.WhenAt != "" {
336+ { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
337+ }
338+ if c.New {
339+ { " · new" }
340+ }
341+ </span>
342+ </li>
343+ }
344+ </ul>
345+ if len(p.Commits) == 0 {
346+ <p class="dim">no commit to merge.</p>
347+ }
348+ if p.CommitsNote != "" {
349+ <p class="note">{ p.CommitsNote }</p>
350+ }
351+ </section>
352+ <section aria-labelledby="r-files">
353+ <h2 id="r-files">files <span>{ itoa(len(p.Files)) }</span></h2>
354+ @FileList(p.Files)
355+ </section>
356+}
357+
358+// mergeBox is the first block of the rail of an open request, for the owner:
359+// the merge form (it carries the head that was read), the terminal lines, and
360+// the other things one does with the request.
361+templ mergeBox(p MRPage) {
362+ <section class="decide" aria-labelledby="r-merge" data-state="open">
363+ <h2 id="r-merge">merge</h2>
364+ <dl class="kv">
365+ <dt>head</dt>
366+ <dd>
367+ if p.Box.HeadGone {
368+ gone: the source branch was deleted
369+ } else {
370+ { p.HeadShort }
371+ }
372+ </dd>
373+ <dt>into</dt>
374+ <dd>{ p.Target }</dd>
375+ </dl>
376+ if !p.Box.HeadGone {
377+ <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
378+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
379+ <label class="sr" for="merge-mode">merge mode</label>
380+ <select id="merge-mode" name="mode">
381+ for _, m := range p.Box.Modes {
382+ <option value={ m.Value }>{ m.Label }</option>
383+ }
384+ </select>
385+ <div class="d-btns">
386+ <button type="submit" class="d-main">[merge]</button>
387+ </div>
388+ </form>
389+ }
390+ <div class="d-btns">
391+ if p.Box.CanMarkRead {
392+ <form method="post" action={ templ.SafeURL(p.Box.ReadAction) }>
393+ <input type="hidden" name="head_id" value={ p.Box.Head }/>
394+ <button type="submit" title="the next visit can show only what changed after this head">[mark read]</button>
395+ </form>
396+ }
397+ <form method="post" action={ templ.SafeURL(p.Box.CloseAction) }>
398+ <button type="submit" title="close without merging; the branch is kept">[close]</button>
399+ </form>
400+ </div>
401+ if !p.Box.HeadGone {
402+ <p class="mute d-or">or from a terminal</p>
403+ @CloneBlock(p.Box.CLI)
404+ }
405+ </section>
406+}
407+
408+// Compare is a branch against the default branch: the commits it is ahead by,
409+// its diff when the API can give one, and the form that opens a merge request.
410+templ Compare(l Layout, p ComparePage) {
411+ @Shell(l, compareRail(p)) {
412+ <header class="show mr-h">
413+ <h1>{ p.Source } → { p.Target }</h1>
414+ <dl class="kv show-kv">
415+ <dt>branch</dt>
416+ <dd><a href={ templ.SafeURL(p.SourceHref) }>{ p.Source }</a></dd>
417+ <dt>target</dt>
418+ <dd><a href={ templ.SafeURL(p.TargetHref) }>{ p.Target }</a></dd>
419+ <dt>ahead</dt>
420+ <dd>{ p.AheadText }</dd>
421+ </dl>
422+ </header>
423+ if p.Note != "" {
424+ <p class="notice">{ p.Note }</p>
425+ }
426+ if p.Existing != nil {
427+ <p class="notice">an open merge request already covers these branches: <a href={ templ.SafeURL(p.Existing.Href) }>{ p.Existing.Label }</a></p>
428+ }
429+ if p.Create != nil {
430+ <form class="cform create" method="post" action={ templ.SafeURL(p.Create.Action) }>
431+ <input type="hidden" name="source" value={ p.Create.Source }/>
432+ <input type="hidden" name="target" value={ p.Create.Target }/>
433+ <label for="mr-title">title</label>
434+ <input id="mr-title" name="title" type="text" maxlength="120" required value={ p.Create.Title } autocomplete="off"/>
435+ <label for="mr-desc">description</label>
436+ <textarea id="mr-desc" name="description" rows="4" maxlength="8000" placeholder="plain text, optional"></textarea>
437+ <div class="f-btns">
438+ <button type="submit" class="f-main">[create merge request]</button>
439+ </div>
440+ </form>
441+ }
442+ if p.HasDiff {
443+ <div class="diff-tools" role="toolbar" aria-label="Diff view">
444+ <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span>
445+ <span class="grow"></span>
446+ @viewLink(p.DiffView, "unified", "[u]nified", "u", p.UnifiedHref)
447+ @viewLink(p.DiffView, "split", "[d] split", "d", p.SplitHref)
448+ <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button>
449+ </div>
450+ if p.Invisible {
451+ <p class="notice">
452+ this diff holds invisible characters (direction controls, zero-width characters). They are shown as
453+ <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
454+ </p>
455+ }
456+ <div
457+ id="diffs"
458+ if p.Both {
459+ data-diff={ p.View }
460+ } else {
461+ data-view={ p.View }
462+ }
463+ if p.Both && !p.Pinned {
464+ hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"
465+ }
466+ >
467+ for _, f := range p.Files {
468+ @DiffFileView(f, p.DiffView)
469+ }
470+ </div>
471+ }
472+ }
473+}
474+
475+templ compareRail(p ComparePage) {
476+ <section aria-labelledby="r-ahead">
477+ <h2 id="r-ahead">commits <span>{ itoa(len(p.Ahead)) }</span></h2>
478+ <ul class="pcommits">
479+ for _, c := range p.Ahead {
480+ <li>
481+ <a href={ templ.SafeURL(c.CHref) }>{ c.Short }</a>
482+ <span class="s">{ c.Subject }</span>
483+ <span class="mute">
484+ { c.Author }
485+ if c.WhenAt != "" {
486+ { " · " }<time datetime={ c.WhenAt }>{ c.When }</time>
487+ }
488+ </span>
489+ </li>
490+ }
491+ </ul>
492+ if len(p.Ahead) == 0 {
493+ <p class="dim">no commit ahead of { p.Target }.</p>
494+ }
495+ </section>
496+ if p.HasDiff {
497+ <section aria-labelledby="r-files">
498+ <h2 id="r-files">files <span>{ itoa(len(p.Files)) }</span></h2>
499+ @FileList(p.Files)
500+ </section>
501+ }
502+ <section aria-labelledby="r-cli">
503+ <h2 id="r-cli">from a terminal</h2>
504+ @CloneBlock(p.CLI)
505+ </section>
506+}
A internal/adapters/web/mr_post.go +267 -0
@@ -0,0 +1,267 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "net/http"
10+ "strconv"
11+ "strings"
12+
13+ "connectrpc.com/connect"
14+ "github.com/bnema/zerowrap"
15+
16+ pb "bnema.dev/ermite-api/gen/ermite"
17+)
18+
19+// The writes of the merge request pages. Each is a POST that passed the gate
20+// (an Origin of this site and the owner's session), calls the API as the
21+// owner, and answers 303 to the page that shows the result, with
22+// "Cache-Control: no-store". A request the API refuses is answered with a
23+// state page that says why, in the words of the API for the rules the owner
24+// can fix (a text rule, a moved branch, a full merge request), and without
25+// detail for anything else.
26+
27+// maxFormBytes bounds the body of a form: a description of 8,000 characters is
28+// at most 32,000 bytes.
29+const maxFormBytes = 128 << 10
30+
31+// form reads the form of a POST, bounded.
32+func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
33+ r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
34+ if err := r.ParseForm(); err != nil {
35+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))
36+ return false
37+ }
38+ return true
39+}
40+
41+// seeOther redirects to a page, never cached.
42+func seeOther(w http.ResponseWriter, r *http.Request, to string) {
43+ w.Header().Set("Cache-Control", "no-store")
44+ http.Redirect(w, r, to, http.StatusSeeOther)
45+}
46+
47+// mrAction wraps a POST handler: it bounds the API calls, reads the form and
48+// maps the error of the API to a page.
49+func (s *Server) mrAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
50+ return func(w http.ResponseWriter, r *http.Request) {
51+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
52+ defer cancel()
53+ if !s.form(w, r) {
54+ return
55+ }
56+ to, back, err := h(ctx, r)
57+ if err != nil {
58+ s.actionFailed(ctx, w, r, err, back)
59+ return
60+ }
61+ seeOther(w, r, to)
62+ }
63+}
64+
65+// actionFailed answers a refused action.
66+func (s *Server) actionFailed(ctx context.Context, w http.ResponseWriter, r *http.Request, err error, back string) {
67+ msg := ""
68+ var ce *connect.Error
69+ if errors.As(err, &ce) {
70+ msg = ce.Message()
71+ }
72+ switch code := connect.CodeOf(err); {
73+ case code == connect.CodeNotFound:
74+ w.Header().Set("Cache-Control", "no-store")
75+ s.write(ctx, w, r, http.StatusNotFound, s.notFoundPage())
76+ case code == connect.CodeInvalidArgument:
77+ s.reject(ctx, w, r, http.StatusBadRequest, "not accepted", rule(msg, "the server refused what was sent."), back)
78+ case code == connect.CodeFailedPrecondition, code == connect.CodeAborted:
79+ s.reject(ctx, w, r, http.StatusConflict, "not possible now", rule(msg, "the merge request is not in a state that allows this."), back)
80+ case code == connect.CodeAlreadyExists:
81+ s.reject(ctx, w, r, http.StatusConflict, "already there", rule(msg, "this already exists."), back)
82+ case code == connect.CodeResourceExhausted:
83+ s.reject(ctx, w, r, http.StatusConflict, "limit reached", rule(msg, "a limit of the merge request is reached."), back)
84+ case code == connect.CodePermissionDenied, code == connect.CodeUnauthenticated:
85+ s.reject(ctx, w, r, http.StatusForbidden, "forbidden", "the session is not allowed to do this.", back)
86+ case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
87+ // The browser is gone.
88+ default:
89+ id := requestID()
90+ log := zerowrap.FromCtx(ctx)
91+ log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web action failed")
92+ w.Header().Set("Cache-Control", "no-store")
93+ s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id))
94+ }
95+}
96+
97+// rule is a message of the API fit to show: the invisible characters marked,
98+// bounded, and a fallback for an empty one.
99+func rule(msg, fallback string) string {
100+ msg = strings.TrimSpace(visibleText(msg))
101+ if msg == "" {
102+ return fallback
103+ }
104+ return cutRunes(msg, 400)
105+}
106+
107+// reject writes the page of a refused action, with the way back.
108+func (s *Server) reject(ctx context.Context, w http.ResponseWriter, r *http.Request, status int, title, line, back string) {
109+ if back == "" {
110+ back = "/"
111+ }
112+ l := s.stateLayout(title, itoa(status)+" · "+title, []Crumb{{Label: "-"}, {Label: title}})
113+ w.Header().Set("Cache-Control", "no-store")
114+ s.write(ctx, w, r, status, State(l, StatePage{
115+ Code: itoa(status), Title: title,
116+ Lines: []string{line, "nothing was changed by this request."},
117+ Back: []Link{{Label: "back", Href: back}, {Label: "home", Href: "/"}},
118+ }))
119+}
120+
121+// number reads the merge request number of the path.
122+func number(r *http.Request) (int64, error) { return mrNumber(r) }
123+
124+// mrCreate is POST /<repo>/merge-requests: it opens a merge request.
125+func (s *Server) mrCreate(ctx context.Context, r *http.Request) (string, string, error) {
126+ repo := r.PathValue("repo")
127+ source := r.PostFormValue("source")
128+ back := repoHref(repo, "compare", source)
129+ res, err := s.cfg.MergeRequests.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
130+ Repo: repo, Title: r.PostFormValue("title"), Description: normalizeNewlines(r.PostFormValue("description")),
131+ SourceBranch: source, TargetBranch: r.PostFormValue("target"),
132+ }))
133+ if err != nil {
134+ return "", back, err
135+ }
136+ return mrHref(repo, res.Msg.GetMergeRequest().GetNumber()), back, nil
137+}
138+
139+// mrComment is POST /<repo>/merge-requests/<n>/comments: a general comment, or
140+// one on a line (path, line and side).
141+func (s *Server) mrComment(ctx context.Context, r *http.Request) (string, string, error) {
142+ repo := r.PathValue("repo")
143+ n, err := number(r)
144+ if err != nil {
145+ return "", "", connect.NewError(connect.CodeNotFound, err)
146+ }
147+ back := mrHref(repo, n)
148+ req := &pb.AddCommentRequest{Repo: repo, Number: n, Text: normalizeNewlines(r.PostFormValue("text")), Path: r.PostFormValue("path")}
149+ if req.Path != "" || r.PostFormValue("line") != "" || r.PostFormValue("side") != "" {
150+ line, err := strconv.ParseInt(r.PostFormValue("line"), 10, 32)
151+ if err != nil {
152+ return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("line is not a number"))
153+ }
154+ req.Line = int32(line)
155+ switch r.PostFormValue("side") {
156+ case "new":
157+ req.Side = pb.DiffSide_DIFF_SIDE_NEW
158+ case "old":
159+ req.Side = pb.DiffSide_DIFF_SIDE_OLD
160+ default:
161+ return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("side must be new or old"))
162+ }
163+ }
164+ res, err := s.cfg.MergeRequests.AddComment(ctx, connect.NewRequest(req))
165+ if err != nil {
166+ return "", back, err
167+ }
168+ return back + "#c" + res.Msg.GetComment().GetId(), back, nil
169+}
170+
171+// mrResolve is POST /<repo>/merge-requests/<n>/comments/<id>/resolve.
172+//
173+//hexcheck:ignore suspicious-business-logic-in-adapter reads two form fields into an API call; the state of the comment is decided by the API
174+func (s *Server) mrResolve(ctx context.Context, r *http.Request) (string, string, error) {
175+ repo := r.PathValue("repo")
176+ n, err := number(r)
177+ if err != nil {
178+ return "", "", connect.NewError(connect.CodeNotFound, err)
179+ }
180+ back := mrHref(repo, n)
181+ resolved, err := formBool(r.PostFormValue("resolved"))
182+ if err != nil {
183+ return "", back, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("resolved: %w", err))
184+ }
185+ res, err := s.cfg.MergeRequests.ResolveComment(ctx, connect.NewRequest(&pb.ResolveCommentRequest{
186+ Repo: repo, Number: n, CommentId: r.PathValue("id"), Resolved: resolved,
187+ }))
188+ if err != nil {
189+ return "", back, err
190+ }
191+ return back + "#c" + res.Msg.GetComment().GetId(), back, nil
192+}
193+
194+// mrClose is POST /<repo>/merge-requests/<n>/close.
195+func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string, error) {
196+ repo := r.PathValue("repo")
197+ n, err := number(r)
198+ if err != nil {
199+ return "", "", connect.NewError(connect.CodeNotFound, err)
200+ }
201+ back := mrHref(repo, n)
202+ if _, err := s.cfg.MergeRequests.CloseMergeRequest(ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: repo, Number: n})); err != nil {
203+ return "", back, err
204+ }
205+ return back, back, nil
206+}
207+
208+// mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode
209+// and the head the owner was shown (head_id): the server merges only if the
210+// branch still points to it.
211+func (s *Server) mrMerge(ctx context.Context, r *http.Request) (string, string, error) {
212+ repo := r.PathValue("repo")
213+ n, err := number(r)
214+ if err != nil {
215+ return "", "", connect.NewError(connect.CodeNotFound, err)
216+ }
217+ back := mrHref(repo, n)
218+ var mode pb.MergeMode
219+ switch r.PostFormValue("mode") {
220+ case "fast-forward":
221+ mode = pb.MergeMode_MERGE_MODE_FAST_FORWARD
222+ case "squash":
223+ mode = pb.MergeMode_MERGE_MODE_SQUASH
224+ default:
225+ return "", back, connect.NewError(connect.CodeInvalidArgument, errors.New("mode must be fast-forward or squash"))
226+ }
227+ if _, err := s.cfg.MergeRequests.MergeMergeRequest(ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
228+ Repo: repo, Number: n, Mode: mode, HeadId: r.PostFormValue("head_id"),
229+ })); err != nil {
230+ return "", back, err
231+ }
232+ return back, back, nil
233+}
234+
235+// mrRead is POST /<repo>/merge-requests/<n>/read: the owner marks the head
236+// they were shown as read. Reading a page never does it (see the top of mr.go).
237+func (s *Server) mrRead(ctx context.Context, r *http.Request) (string, string, error) {
238+ repo := r.PathValue("repo")
239+ n, err := number(r)
240+ if err != nil {
241+ return "", "", connect.NewError(connect.CodeNotFound, err)
242+ }
243+ back := mrHref(repo, n)
244+ if _, err := s.cfg.MergeRequests.MarkRead(ctx, connect.NewRequest(&pb.MarkReadRequest{
245+ Repo: repo, Number: n, HeadId: r.PostFormValue("head_id"),
246+ })); err != nil {
247+ return "", back, err
248+ }
249+ return back, back, nil
250+}
251+
252+// formBool reads the "true" or "false" a form field carries.
253+func formBool(s string) (bool, error) {
254+ switch s {
255+ case "true":
256+ return true, nil
257+ case "false":
258+ return false, nil
259+ }
260+ return false, errors.New("must be true or false")
261+}
262+
263+// normalizeNewlines turns the CRLF of a browser textarea into LF, the line
264+// break the API stores.
265+func normalizeNewlines(s string) string {
266+ return strings.ReplaceAll(s, "\r\n", "\n")
267+}
A internal/adapters/web/mr_templ.go +2717 -0 generated file: collapsed
M internal/adapters/web/overlays.templ +3 -1
@@ -19,7 +19,9 @@ templ OverlayHelp() {
1919 <div><dt><kbd>:</kbd> <kbd>f</kbd></dt><dd>go to: section, ref, file, repo</dd></div>
2020 <div><dt><kbd>y</kbd></dt><dd>permalink: ref becomes the full sha</dd></div>
2121 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd></dt><dd>repo sections (when shown)</dd></div>
22- <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>commit: split / unified / collapse all</dd></div>
22+ <div><dt><kbd>m</kbd> <kbd>g m</kbd></dt><dd>merge requests (when shown)</dd></div>
23+ <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse all</dd></div>
24+ <div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>
2325 <div><dt><kbd>esc</kbd></dt><dd>close panel</dd></div>
2426 </dl>
2527 </dialog>
M internal/adapters/web/overlays_templ.go +9 -9 generated file: collapsed
A internal/adapters/web/plaintext.go +59 -0
@@ -0,0 +1,59 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/url"
7+ "regexp"
8+ "strings"
9+)
10+
11+// TextPart is a run of plain text, or a link found in it.
12+type TextPart struct {
13+ Text string
14+ // Href is the target of a link, "" for plain text. Only http and https
15+ // URLs are ever linked.
16+ Href string
17+}
18+
19+// reURL finds the candidates for a link in plain text.
20+var reURL = regexp.MustCompile(`https?://[^\s<>"'` + "`" + `]+`)
21+
22+// linkTrail is what ends a sentence rather than an address.
23+const linkTrail = ".,;:!?)]}"
24+
25+// plainParts splits a user text (a description, a comment) into runs: the text
26+// stays text, escaped by the template with its line breaks kept, and the
27+// http(s) URLs in it become links (ADR 0017: "text is plain everywhere"). The
28+// invisible characters of the text are shown as markers.
29+func plainParts(s string) []TextPart {
30+ s = strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n")
31+ var out []TextPart
32+ last := 0
33+ for _, loc := range reURL.FindAllStringIndex(s, -1) {
34+ raw := s[loc[0]:loc[1]]
35+ // A closing bracket belongs to the address only when it has an opening
36+ // one in it: "(see https://x.test/a)" ends before the ")".
37+ end := len(raw)
38+ for end > 0 && strings.ContainsRune(linkTrail, rune(raw[end-1])) {
39+ if raw[end-1] == ')' && strings.Count(raw[:end], "(") >= strings.Count(raw[:end], ")") {
40+ break
41+ }
42+ end--
43+ }
44+ raw = raw[:end]
45+ u, err := url.Parse(raw)
46+ if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
47+ continue
48+ }
49+ if loc[0] > last {
50+ out = append(out, TextPart{Text: visibleText(s[last:loc[0]])})
51+ }
52+ out = append(out, TextPart{Text: visibleText(raw), Href: u.String()})
53+ last = loc[0] + len(raw)
54+ }
55+ if last < len(s) {
56+ out = append(out, TextPart{Text: visibleText(s[last:])})
57+ }
58+ return out
59+}
M internal/adapters/web/repo.go +3 -0
@@ -107,6 +107,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
107107 mk("c", "ommits", repoHref(d.name(), "commits", def), "commits"),
108108 mk("r", "efs", repoHref(d.name(), "refs"), "refs"),
109109 }
110+ if s.cfg.MergeRequests != nil {
111+ l.Sections = append(l.Sections, mk("m", "erge requests", mrBase(d.name()), "merge-requests"))
112+ }
110113 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
111114
112115 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
A internal/adapters/web/static/css/60-mr.css +96 -0
@@ -0,0 +1,96 @@
1+@layer components {
2+ /* merge requests: the review page. Hue only where it carries a verdict. */
3+ .mr-h .show-kv { margin-top: 0; }
4+ .mr-h + * { margin-top: var(--lh); }
5+ .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
6+ .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
7+ .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
8+ .mr-desc { margin-top: var(--lh); max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; }
9+ .mr-desc a, .c-text a { text-decoration: underline; }
10+
11+ .since {
12+ display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh);
13+ & a { text-decoration: none; color: var(--ink-dim); }
14+ & a:hover { color: var(--ink); text-decoration: underline; }
15+ & a[aria-current] { color: var(--ink-hi); font-weight: bold; }
16+ & a[aria-current]::before { content: "▸ "; }
17+ & .mute { font-weight: normal; }
18+ }
19+
20+ /* merge block: first thing in the rail */
21+ .decide {
22+ & .d-acts { margin-top: calc(var(--lh) / 2); }
23+ & select {
24+ display: block; width: 100%; font: inherit; color: inherit; background: var(--paper-alt);
25+ border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;
26+ }
27+ & .d-btns { display: flex; gap: 2ch; margin-top: 4px; flex-wrap: wrap; }
28+ & .d-btns button { color: var(--ink-dim); }
29+ & .d-btns button:hover { color: var(--ink); text-decoration: underline; }
30+ & .d-btns .d-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; }
31+ & .d-btns .d-main:hover { background: var(--ink-hi); text-decoration: none; }
32+ & .d-or { margin-top: calc(var(--lh) / 2); }
33+ & .clone { margin-top: 0; }
34+ & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
35+ }
36+
37+ .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
38+ .pcommits li[data-new] { background: var(--row); }
39+ .pcommits .s { color: var(--ink); }
40+ .rail code { border-bottom: 1px dotted var(--ink-mute); }
41+
42+ /* a note: a ruled block, under its line in the diff and in the list of comments */
43+ .cmt {
44+ border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2); max-width: var(--measure);
45+ white-space: normal;
46+ & p { white-space: normal; }
47+ & .c-who { font-weight: bold; }
48+ & .c-state { color: var(--ink-dim); }
49+ & .c-text { white-space: pre-wrap; overflow-wrap: anywhere; }
50+ & .c-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
51+ & .c-act button { color: var(--ink-dim); }
52+ & .c-act button:hover { color: var(--ink); text-decoration: underline; }
53+ &[data-resolved="true"] { border-left-color: var(--rule); }
54+ &[data-resolved="true"] .c-text { color: var(--ink-dim); }
55+ }
56+ .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; }
57+ .dt .fnote .cmt, .dt .fnote .cform { margin: 0; font-size: var(--fs); line-height: var(--lh); }
58+ .dt .fnote .cmt + .cmt, .dt .fnote .cmt + .cform { margin-top: calc(var(--lh) / 2); }
59+ .ln a { color: inherit; text-decoration: none; }
60+ .ln a:hover, .ln a:focus-visible { text-decoration: underline; color: var(--ink-hi); }
61+
62+ /* the forms: a comment, a new merge request */
63+ .cform {
64+ display: grid; gap: 2px; margin-top: var(--lh); max-width: var(--measure);
65+ & label { color: var(--ink-dim); }
66+ & textarea, & input[type="text"] {
67+ display: block; width: 100%; font: inherit; color: inherit; resize: vertical;
68+ background: var(--paper-alt); border: 1px solid var(--rule); border-radius: 0; padding: 0 1ch;
69+ }
70+ & textarea::placeholder { color: var(--ink-mute); opacity: 1; }
71+ & .f-btns { display: flex; gap: 2ch; align-items: baseline; margin-top: 4px; }
72+ & .f-btns a { color: var(--ink-dim); }
73+ & .f-btns button { color: var(--ink-dim); }
74+ & .f-btns button:hover { color: var(--ink); text-decoration: underline; }
75+ & .f-btns .f-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; }
76+ & .f-btns .f-main:hover { background: var(--ink-hi); text-decoration: none; }
77+ }
78+ .cform.create { margin-top: var(--lh); }
79+
80+ .list.mrs {
81+ & .c-n { width: 5ch; }
82+ & .c-state { width: 9ch; }
83+ & .c-subject { overflow: hidden; text-overflow: ellipsis; }
84+ & .c-who { width: 12ch; }
85+ & .c-when { width: 11ch; }
86+ & tr[data-state="open"] .c-state { font-weight: bold; }
87+ & tr[data-state="open"] .c-state::before { content: "● "; color: var(--teal); }
88+ & tr[data-state="merged"] .c-state::before { content: "✓ "; color: var(--teal); }
89+ & tr[data-state="closed"] .c-state::before { content: "✗ "; color: var(--red); }
90+ }
91+ #mr-notice:empty { display: none; }
92+
93+ @media (max-width: 700px) {
94+ .list.mrs .c-who { display: none; }
95+ }
96+}
D internal/adapters/web/static/css/60-proposal.css +0 -93
@@ -1,93 +0,0 @@
1-@layer components {
2- /* proposals: the decision gate. Hue only where it carries a verdict:
3- check state (pass teal, fail red) and open high/medium findings (red). */
4- .criteria {
5- margin-top: calc(var(--lh) / 2); max-width: var(--measure);
6- & li { padding-left: 4ch; text-indent: -4ch; }
7- & .ck { color: var(--teal); font-weight: bold; }
8- & li[data-met="false"] .ck { color: var(--red); }
9- & .mute { font-size: var(--fs-s); overflow-wrap: anywhere; }
10- }
11- .prop-h .show-kv { margin-top: 0; }
12- .prop-h + .rule-h { margin-top: var(--lh); }
13-
14- .since {
15- display: flex; flex-wrap: wrap; column-gap: 3ch; margin-top: var(--lh);
16- & a { text-decoration: none; color: var(--ink-dim); }
17- & a:hover { color: var(--ink); text-decoration: underline; }
18- & a[aria-current] { color: var(--ink-hi); font-weight: bold; }
19- & a[aria-current]::before { content: "▸ "; }
20- & .mute { font-weight: normal; }
21- }
22-
23- /* decision block: first thing in the rail */
24- .decide {
25- & .d-state { font-weight: bold; color: var(--ink-hi); text-transform: uppercase; letter-spacing: .1ch; }
26- &[data-state="ready"] .d-state::before { content: "● "; color: var(--teal); }
27- &[data-state="accepted"] .d-state::before { content: "✓ "; color: var(--teal); }
28- &[data-state="refused"] .d-state::before { content: "✗ "; color: var(--red); }
29- & p + p { margin-top: 0; }
30- & .kv { grid-template-columns: 9ch minmax(0, 1fr); }
31- & .kv dd { white-space: normal; }
32- & .d-acts { margin-top: calc(var(--lh) / 2); }
33- & textarea {
34- display: block; width: 100%; resize: vertical; font: inherit; color: inherit;
35- background: var(--paper-alt); border: 1px solid var(--rule); padding: 0 1ch;
36- }
37- & textarea::placeholder { color: var(--ink-mute); opacity: 1; }
38- & .d-btns { display: flex; gap: 2ch; margin-top: 4px; }
39- & .d-btns button { color: var(--ink-dim); }
40- & .d-btns button:hover { color: var(--ink); text-decoration: underline; }
41- & .d-btns .d-main { background: var(--ink); color: var(--paper); font-weight: bold; padding: 0 1ch; }
42- & .d-btns .d-main:hover { background: var(--ink-hi); text-decoration: none; }
43- & .d-or { margin-top: calc(var(--lh) / 2); }
44- & .clone { margin-top: 0; }
45- & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
46- }
47- .closed { margin-top: calc(var(--lh) / 2); & > summary { color: var(--ink-dim); } }
48-
49- .checks li { display: grid; grid-template-columns: 5ch minmax(0, 1fr) auto; column-gap: 1ch; }
50- .checks .c-n { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51- .checks .c-st { font-weight: bold; color: var(--teal); }
52- .checks [data-state="fail"] .c-st { color: var(--red); }
53- .checks [data-state="run"] .c-st { color: var(--ink-dim); }
54-
55- .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
56- .pcommits li[data-new] { background: var(--row); }
57- .pcommits .s { color: var(--ink); }
58-
59- /* a finding: a ruled note, in the rail and under its line in the diff */
60- .finding {
61- border-left: 2px solid var(--ink); padding-left: 1ch; margin-top: calc(var(--lh) / 2);
62- & p { white-space: normal; }
63- & .f-sev { font-weight: bold; text-transform: uppercase; }
64- & .f-state { color: var(--ink-dim); }
65- & .f-where { font-size: var(--fs-s); overflow-wrap: anywhere; }
66- & .f-reason { color: var(--ink-dim); }
67- &[data-state="open"][data-sev="high"], &[data-state="open"][data-sev="medium"] { border-left-color: var(--red); }
68- &[data-state="open"][data-sev="high"] .f-sev, &[data-state="open"][data-sev="medium"] .f-sev { color: var(--red); }
69- &[data-state="fixed"], &[data-state="dismissed"] { border-left-color: var(--rule); }
70- &[data-state="fixed"] .f-text, &[data-state="dismissed"] .f-text { color: var(--ink-dim); }
71- &[data-state="fixed"] .f-sev, &[data-state="dismissed"] .f-sev { font-weight: normal; color: var(--ink-mute); }
72- }
73- .dt .fnote td { background: var(--paper-alt); padding: 2px 0 4px; white-space: normal; }
74- .dt .fnote .finding { margin: 0; max-width: var(--measure); font-size: var(--fs); line-height: var(--lh); }
75- .dfile .fcount { color: var(--red); font-weight: bold; }
76-
77- .list.props {
78- & .c-n { width: 5ch; }
79- & .c-state { width: 18ch; }
80- & .c-subject { overflow: hidden; text-overflow: ellipsis; }
81- & .c-chk { width: 6ch; }
82- & .c-fnd { width: 9ch; }
83- & .c-who { width: 12ch; }
84- & .c-when { width: 9ch; }
85- & tr[data-state="ready"] .c-state { font-weight: bold; }
86- & tr[data-state="ready"] .c-state::before { content: "● "; color: var(--teal); }
87- }
88- .rail code { border-bottom: 1px dotted var(--ink-mute); }
89-
90- @media (max-width: 700px) {
91- .list.props .c-chk, .list.props .c-fnd { display: none; }
92- }
93-}
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -4,8 +4,8 @@
44 // (cursor, panels, filters, permalink) is declared in templates/components/keys.tmpl with hx-on + hx-live.
55 (() => {
66 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
7- s: 's', t: 't', c: 'c', r: 'r', p: 'p', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8- const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', p: 'gp', g: 'top' };
7+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', d: 'split', u: 'unified', x: 'collapse', Enter: 'open', Escape: 'esc' };
8+ const goto = { h: 'gh', t: 'gt', c: 'gc', r: 'gr', m: 'gm', g: 'top' };
99 let pending = 0;
1010 const fire = (name) => document.body.dispatchEvent(new CustomEvent('k-' + name, { bubbles: false }));
1111 addEventListener('keydown', (e) => {
M internal/adapters/web/web.go +17 -0
@@ -49,6 +49,9 @@ type Config struct {
4949 // Repos and Git are the API clients the pages are built from.
5050 Repos ermiteconnect.RepoServiceClient
5151 Git ermiteconnect.GitServiceClient
52+ // MergeRequests is the client of the merge request pages (ADR 0017).
53+ // Without it there are no such pages, nor section for them.
54+ MergeRequests ermiteconnect.MergeRequestServiceClient
5255 // Events is the client of the event stream behind /-/events. Without
5356 // it the pages are not live and the route answers 404.
5457 Events ermiteconnect.EventServiceClient
@@ -134,6 +137,20 @@ func (s *Server) routes() *http.ServeMux {
134137 mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree))
135138 mux.HandleFunc("GET /{repo}/blob/{rest...}", s.page(s.blob))
136139 mux.HandleFunc("GET /{repo}/raw/{rest...}", s.raw)
140+ if s.cfg.MergeRequests != nil {
141+ mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
142+ mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
143+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
144+ mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
145+ mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
146+ // The writes: POST only, behind the gate (ServeHTTP).
147+ mux.HandleFunc("POST /{repo}/merge-requests", s.mrAction(s.mrCreate))
148+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments", s.mrAction(s.mrComment))
149+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
150+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
151+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
152+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
153+ }
137154 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
138155 return mux
139156 }

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 4

Tags 45

↑↓ moveenter openesc close