detached e688ec5

fix(web): keep MR form drafts and the live stream across refreshes

A comment form whose radios, inputs or selects differ from their defaults
is a draft, and Cancel resets it. The replacement live stream resumes
after the last event id through an after= parameter, and the expected
AbortError of the old stream's teardown is no longer reported.
commit
e688ec5af2b64e91699cffee434676248d2dc2ff
author
brice · · 2026-10-20 06:09 UTC
parent
cff892b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +145 -16
M internal/adapters/web/events.go +8 -2
@@ -44,7 +44,9 @@ import (
4444 // lists first: the banner counts the commits between that head and the new
4545 // tip, so it stays right however many pushes came in and however often the
4646 // stream reconnected. A Last-Event-ID header resumes the stream
47-// (resume_after).
47+// (resume_after). With no header, after=<event id> supplies the resume cursor
48+// for a replacement stream. The header wins. Both use Watch's parseID
49+// validation: an invalid or expired cursor produces a gap.
4850 //
4951 // Events, each with the id of the Watch event it comes from:
5052 //
@@ -188,8 +190,12 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
188190 var startOnce sync.Once
189191 start := func() { startOnce.Do(func() { close(started) }) }
190192 finished := make(chan struct{})
193+ resume := r.Header.Get("Last-Event-ID")
194+ if resume == "" {
195+ resume = q.Get("after")
196+ }
191197 params := ports.WatchParams{
192- Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
198+ Topics: []string{topic}, ResumeAfter: resume,
193199 // The visitor is the client of the watch, for the limit per client.
194200 Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
195201 Ready: start,
@@ -44,7 +44,9 @@ import (
44 // lists first: the banner counts the commits between that head and the new44 // lists first: the banner counts the commits between that head and the new
45 // tip, so it stays right however many pushes came in and however often the45 // tip, so it stays right however many pushes came in and however often the
46 // stream reconnected. A Last-Event-ID header resumes the stream46 // stream reconnected. A Last-Event-ID header resumes the stream
47-// (resume_after).47+// (resume_after). With no header, after=<event id> supplies the resume cursor
48+// for a replacement stream. The header wins. Both use Watch's parseID
49+// validation: an invalid or expired cursor produces a gap.
48 //50 //
49 // Events, each with the id of the Watch event it comes from:51 // Events, each with the id of the Watch event it comes from:
50 //52 //
@@ -188,8 +190,12 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
188 var startOnce sync.Once190 var startOnce sync.Once
189 start := func() { startOnce.Do(func() { close(started) }) }191 start := func() { startOnce.Do(func() { close(started) }) }
190 finished := make(chan struct{})192 finished := make(chan struct{})
193+ resume := r.Header.Get("Last-Event-ID")
194+ if resume == "" {
195+ resume = q.Get("after")
196+ }
191 params := ports.WatchParams{197 params := ports.WatchParams{
192- Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),198+ Topics: []string{topic}, ResumeAfter: resume,
193 // The visitor is the client of the watch, for the limit per client.199 // The visitor is the client of the watch, for the limit per client.
194 Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),200 Client: clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies)),
195 Ready: start,201 Ready: start,
M internal/adapters/web/jstest/live.test.mjs +39 -2
@@ -12,10 +12,11 @@ const live = src.slice(src.indexOf('window.ermiteLive = '));
1212 function setup(t) {
1313 t.mock.timers.enable({ apis: ['setTimeout'] });
1414 const hint = { dataset: { state: 'connecting' } };
15- globalThis.window = {};
15+ const listeners = {};
16+ globalThis.window = { addEventListener: (name, fn) => { listeners[name] = fn; } };
1617 globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) };
1718 new Function(live)();
18- return { live: globalThis.window.ermiteLive, hint };
19+ return { live: globalThis.window.ermiteLive, hint, listeners };
1920 }
2021
2122 test('a drop shorter than the delay says nothing', (t) => {
@@ -38,3 +39,39 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => {
3839 live.back();
3940 assert.equal(hint.dataset.state, 'live');
4041 });
42+
43+test('controlled stream deletion suppresses only teardown AbortError rejections', async (t) => {
44+ const { live, listeners } = setup(t);
45+ const rejection = (name) => {
46+ const event = { reason: { name }, prevented: false, preventDefault() { this.prevented = true; } };
47+ listeners.unhandledrejection(event);
48+ return event.prevented;
49+ };
50+ assert.equal(rejection('AbortError'), false);
51+ await live.teardown(async () => {
52+ assert.equal(rejection('AbortError'), true, 'no AbortError escapes the delete swap');
53+ assert.equal(rejection('TypeError'), false);
54+ await Promise.resolve();
55+ });
56+ assert.equal(rejection('AbortError'), true, 'async cancellation reporting is covered');
57+ t.mock.timers.tick(1);
58+ assert.equal(rejection('AbortError'), false, 'unrelated later aborts remain visible');
59+});
60+
61+test('replacement stream URL carries the last connection id, including id-only events', (t) => {
62+ const { live, listeners } = setup(t);
63+ const connection = { lastEventId: 'boot-7' };
64+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
65+ connection.lastEventId = 'boot-8'; // hx-sse updates this even for id-only events.
66+ const saved = globalThis.location;
67+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1' };
68+ const element = { url: '/-/events?topic=repo%3Ademo&head=new', getAttribute() { return this.url; },
69+ setAttribute(name, value) { assert.equal(name, 'hx-sse:connect'); this.url = value; } };
70+ try {
71+ live.resumeStream(element);
72+ const url = new URL(element.url, location.href);
73+ assert.equal(url.searchParams.get('after'), 'boot-8');
74+ assert.equal(url.searchParams.get('head'), 'new');
75+ assert.equal(url.searchParams.get('topic'), 'repo:demo');
76+ } finally { globalThis.location = saved; }
77+});
@@ -12,10 +12,11 @@ const live = src.slice(src.indexOf('window.ermiteLive = '));
12 function setup(t) {12 function setup(t) {
13 t.mock.timers.enable({ apis: ['setTimeout'] });13 t.mock.timers.enable({ apis: ['setTimeout'] });
14 const hint = { dataset: { state: 'connecting' } };14 const hint = { dataset: { state: 'connecting' } };
15- globalThis.window = {};15+ const listeners = {};
16+ globalThis.window = { addEventListener: (name, fn) => { listeners[name] = fn; } };
16 globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) };17 globalThis.document = { querySelector: (sel) => (sel === '.hint-live' ? hint : null) };
17 new Function(live)();18 new Function(live)();
18- return { live: globalThis.window.ermiteLive, hint };19+ return { live: globalThis.window.ermiteLive, hint, listeners };
19 }20 }
20 21
21 test('a drop shorter than the delay says nothing', (t) => {22 test('a drop shorter than the delay says nothing', (t) => {
@@ -38,3 +39,39 @@ test('a drop that lasts shows offline, once, until the stream is back', (t) => {
38 live.back();39 live.back();
39 assert.equal(hint.dataset.state, 'live');40 assert.equal(hint.dataset.state, 'live');
40 });41 });
42+
43+test('controlled stream deletion suppresses only teardown AbortError rejections', async (t) => {
44+ const { live, listeners } = setup(t);
45+ const rejection = (name) => {
46+ const event = { reason: { name }, prevented: false, preventDefault() { this.prevented = true; } };
47+ listeners.unhandledrejection(event);
48+ return event.prevented;
49+ };
50+ assert.equal(rejection('AbortError'), false);
51+ await live.teardown(async () => {
52+ assert.equal(rejection('AbortError'), true, 'no AbortError escapes the delete swap');
53+ assert.equal(rejection('TypeError'), false);
54+ await Promise.resolve();
55+ });
56+ assert.equal(rejection('AbortError'), true, 'async cancellation reporting is covered');
57+ t.mock.timers.tick(1);
58+ assert.equal(rejection('AbortError'), false, 'unrelated later aborts remain visible');
59+});
60+
61+test('replacement stream URL carries the last connection id, including id-only events', (t) => {
62+ const { live, listeners } = setup(t);
63+ const connection = { lastEventId: 'boot-7' };
64+ listeners['htmx:sse:after:connection']({ target: { matches: () => true }, detail: { connection } });
65+ connection.lastEventId = 'boot-8'; // hx-sse updates this even for id-only events.
66+ const saved = globalThis.location;
67+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1' };
68+ const element = { url: '/-/events?topic=repo%3Ademo&head=new', getAttribute() { return this.url; },
69+ setAttribute(name, value) { assert.equal(name, 'hx-sse:connect'); this.url = value; } };
70+ try {
71+ live.resumeStream(element);
72+ const url = new URL(element.url, location.href);
73+ assert.equal(url.searchParams.get('after'), 'boot-8');
74+ assert.equal(url.searchParams.get('head'), 'new');
75+ assert.equal(url.searchParams.get('topic'), 'repo:demo');
76+ } finally { globalThis.location = saved; }
77+});
M internal/adapters/web/jstest/mr.test.mjs +48 -4
@@ -346,15 +346,17 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in
346346 } finally { c.restore(); }
347347 });
348348
349-test('typing during fetch defers even manual refresh until the draft ends', async () => {
350- const c = clock(); let blocked = false, applied = 0, resolve; const notices = [];
349+test('typing during fetch defers even manual refresh until the draft ends', { timeout: 2000 }, async () => {
350+ const c = clock(); let blocked = false, applied = 0, resolve, fetched = 0; const notices = [];
351351 const r = window.ermiteMR.refresher({ blocked: () => blocked,
352- fetchPage: () => new Promise((r) => { resolve = r; }), apply: () => { applied++; },
352+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); }, apply: () => { applied++; },
353353 notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
354354 try {
355355 r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
356356 assert.equal(applied, 0); assert.deepEqual(notices, [true]);
357- r.request(true); await c.tick(); assert.equal(applied, 0);
357+ r.request(true); const manual = c.tick();
358+ assert.equal(fetched, 1, 'manual refresh must not start a fetch during a draft');
359+ await manual; assert.equal(applied, 0);
358360 blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;
359361 assert.equal(applied, 1); assert.equal(notices.at(-1), false);
360362 } finally { c.restore(); }
@@ -453,3 +455,45 @@ test('anchor restoration retains the server encoding of tilde and star paths', (
453455 }
454456 } finally { Object.assign(globalThis, saved); }
455457 });
458+
459+for (const control of [
460+ { type: 'radio', checked: true, defaultChecked: false },
461+ { type: 'checkbox', checked: false, defaultChecked: true },
462+ { type: 'number', name: 'start_line', value: '10', defaultValue: '12' },
463+ { tagName: 'SELECT', options: [{ selected: true, defaultSelected: false }] },
464+]) test(`changed ${control.name || control.type || control.tagName} remains a draft after blur`, () => {
465+ const saved = document;
466+ try {
467+ globalThis.document = { activeElement: null, querySelectorAll(sel) {
468+ assert.equal(sel, ':is(form.cform, .cmt form) :is(textarea, input, select)');
469+ return [control];
470+ } };
471+ assert.equal(window.ermiteMR.hasDraft(), true);
472+ } finally { globalThis.document = saved; }
473+});
474+
475+test('intercepted Cancel resets the entire comment form', () => {
476+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'matchMedia'].map(k => [k, globalThis[k]]));
477+ const c = clock(), handlers = {};
478+ const diffs = { dataset: { view: 'split' }, querySelector() { return null; }, querySelectorAll() { return []; } };
479+ const radio = { type: 'radio', checked: true, defaultChecked: false };
480+ const line = { type: 'number', value: '2', defaultValue: '4' };
481+ let reset = 0, prevented = false;
482+ const form = { reset() { reset++; radio.checked = radio.defaultChecked; line.value = line.defaultValue; }, contains() { return false; } };
483+ const cancel = { closest: () => form };
484+ globalThis.document = { activeElement: null,
485+ querySelector: s => ['#diffs', '#diffs[data-mr]'].includes(s) ? diffs : null,
486+ querySelectorAll: s => s.includes('form.cform') ? [radio, line] : [],
487+ addEventListener: (type, fn) => { handlers[type] = fn; } };
488+ globalThis.location = { pathname: '/demo/merge-requests/1', hash: '', href: 'https://forge.test/demo/merge-requests/1', replace() {} };
489+ globalThis.addEventListener = () => {};
490+ globalThis.matchMedia = () => ({ matches: true, addEventListener() {} });
491+ try {
492+ window.ermiteMR.start();
493+ window.ermiteMR.headChanged();
494+ handlers.click({ target: { closest: () => cancel }, preventDefault() { prevented = true; } });
495+ assert.equal(prevented, true);
496+ assert.equal(reset, 1);
497+ assert.equal(window.ermiteMR.hasDraft(), false);
498+ } finally { Object.assign(globalThis, saved); c.restore(); }
499+});
@@ -346,15 +346,17 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in
346 } finally { c.restore(); }346 } finally { c.restore(); }
347 });347 });
348 348
349-test('typing during fetch defers even manual refresh until the draft ends', async () => {349+test('typing during fetch defers even manual refresh until the draft ends', { timeout: 2000 }, async () => {
350- const c = clock(); let blocked = false, applied = 0, resolve; const notices = [];350+ const c = clock(); let blocked = false, applied = 0, resolve, fetched = 0; const notices = [];
351 const r = window.ermiteMR.refresher({ blocked: () => blocked,351 const r = window.ermiteMR.refresher({ blocked: () => blocked,
352- fetchPage: () => new Promise((r) => { resolve = r; }), apply: () => { applied++; },352+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); }, apply: () => { applied++; },
353 notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });353 notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
354 try {354 try {
355 r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;355 r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
356 assert.equal(applied, 0); assert.deepEqual(notices, [true]);356 assert.equal(applied, 0); assert.deepEqual(notices, [true]);
357- r.request(true); await c.tick(); assert.equal(applied, 0);357+ r.request(true); const manual = c.tick();
358+ assert.equal(fetched, 1, 'manual refresh must not start a fetch during a draft');
359+ await manual; assert.equal(applied, 0);
358 blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;360 blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;
359 assert.equal(applied, 1); assert.equal(notices.at(-1), false);361 assert.equal(applied, 1); assert.equal(notices.at(-1), false);
360 } finally { c.restore(); }362 } finally { c.restore(); }
@@ -453,3 +455,45 @@ test('anchor restoration retains the server encoding of tilde and star paths', (
453 }455 }
454 } finally { Object.assign(globalThis, saved); }456 } finally { Object.assign(globalThis, saved); }
455 });457 });
458+
459+for (const control of [
460+ { type: 'radio', checked: true, defaultChecked: false },
461+ { type: 'checkbox', checked: false, defaultChecked: true },
462+ { type: 'number', name: 'start_line', value: '10', defaultValue: '12' },
463+ { tagName: 'SELECT', options: [{ selected: true, defaultSelected: false }] },
464+]) test(`changed ${control.name || control.type || control.tagName} remains a draft after blur`, () => {
465+ const saved = document;
466+ try {
467+ globalThis.document = { activeElement: null, querySelectorAll(sel) {
468+ assert.equal(sel, ':is(form.cform, .cmt form) :is(textarea, input, select)');
469+ return [control];
470+ } };
471+ assert.equal(window.ermiteMR.hasDraft(), true);
472+ } finally { globalThis.document = saved; }
473+});
474+
475+test('intercepted Cancel resets the entire comment form', () => {
476+ const saved = Object.fromEntries(['document', 'location', 'addEventListener', 'matchMedia'].map(k => [k, globalThis[k]]));
477+ const c = clock(), handlers = {};
478+ const diffs = { dataset: { view: 'split' }, querySelector() { return null; }, querySelectorAll() { return []; } };
479+ const radio = { type: 'radio', checked: true, defaultChecked: false };
480+ const line = { type: 'number', value: '2', defaultValue: '4' };
481+ let reset = 0, prevented = false;
482+ const form = { reset() { reset++; radio.checked = radio.defaultChecked; line.value = line.defaultValue; }, contains() { return false; } };
483+ const cancel = { closest: () => form };
484+ globalThis.document = { activeElement: null,
485+ querySelector: s => ['#diffs', '#diffs[data-mr]'].includes(s) ? diffs : null,
486+ querySelectorAll: s => s.includes('form.cform') ? [radio, line] : [],
487+ addEventListener: (type, fn) => { handlers[type] = fn; } };
488+ globalThis.location = { pathname: '/demo/merge-requests/1', hash: '', href: 'https://forge.test/demo/merge-requests/1', replace() {} };
489+ globalThis.addEventListener = () => {};
490+ globalThis.matchMedia = () => ({ matches: true, addEventListener() {} });
491+ try {
492+ window.ermiteMR.start();
493+ window.ermiteMR.headChanged();
494+ handlers.click({ target: { closest: () => cancel }, preventDefault() { prevented = true; } });
495+ assert.equal(prevented, true);
496+ assert.equal(reset, 1);
497+ assert.equal(window.ermiteMR.hasDraft(), false);
498+ } finally { Object.assign(globalThis, saved); c.restore(); }
499+});
M internal/adapters/web/static/js/ermite.js +24 -0
@@ -853,6 +853,17 @@
853853 // reloads the page. A stream the server refuses on reconnection (the
854854 // repository is gone, the session ended) makes the page reload.
855855 window.ermiteLive = (() => {
856+ let connection = null;
857+ let tearingDown = 0;
858+ // hx-sse has no public close API. Its cleanup aborts the fetch and leaves
859+ // reader.cancel() uncaught. Suppress only AbortError during our delete swap
860+ // (through the rejection-reporting task), not ordinary stream/app failures.
861+ window.addEventListener?.('unhandledrejection', (event) => {
862+ if (tearingDown && event.reason?.name === 'AbortError') event.preventDefault();
863+ });
864+ window.addEventListener?.('htmx:sse:after:connection', (event) => {
865+ if (event.target.matches?.('.hint-live')) connection = event.detail.connection;
866+ });
856867 let reloading = false;
857868 let stopped = false;
858869 // A stream that drops and comes back within LOST_AFTER (a proxy or the
@@ -907,6 +918,19 @@ window.ermiteLive = (() => {
907918 // fitIn lays out the one-line chip lists of a fragment now in the page.
908919 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
909920 return {
921+ async teardown(remove) {
922+ ++tearingDown;
923+ try { return await remove(); }
924+ finally { setTimeout(() => { --tearingDown; }, 0); }
925+ },
926+ resumeStream(element) {
927+ // The public connection includes id-only messages as well as named ones.
928+ const id = connection?.lastEventId;
929+ if (!id) return;
930+ const url = new URL(element.getAttribute('hx-sse:connect'), location.href);
931+ url.searchParams.set('after', id);
932+ element.setAttribute('hx-sse:connect', url.pathname + url.search);
933+ },
910934 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
911935 // only if the stream is still down LOST_AFTER later.
912936 lost() {
@@ -853,6 +853,17 @@
853 // reloads the page. A stream the server refuses on reconnection (the853 // reloads the page. A stream the server refuses on reconnection (the
854 // repository is gone, the session ended) makes the page reload.854 // repository is gone, the session ended) makes the page reload.
855 window.ermiteLive = (() => {855 window.ermiteLive = (() => {
856+ let connection = null;
857+ let tearingDown = 0;
858+ // hx-sse has no public close API. Its cleanup aborts the fetch and leaves
859+ // reader.cancel() uncaught. Suppress only AbortError during our delete swap
860+ // (through the rejection-reporting task), not ordinary stream/app failures.
861+ window.addEventListener?.('unhandledrejection', (event) => {
862+ if (tearingDown && event.reason?.name === 'AbortError') event.preventDefault();
863+ });
864+ window.addEventListener?.('htmx:sse:after:connection', (event) => {
865+ if (event.target.matches?.('.hint-live')) connection = event.detail.connection;
866+ });
856 let reloading = false;867 let reloading = false;
857 let stopped = false;868 let stopped = false;
858 // A stream that drops and comes back within LOST_AFTER (a proxy or the869 // A stream that drops and comes back within LOST_AFTER (a proxy or the
@@ -907,6 +918,19 @@ window.ermiteLive = (() => {
907 // fitIn lays out the one-line chip lists of a fragment now in the page.918 // fitIn lays out the one-line chip lists of a fragment now in the page.
908 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };919 const fitIn = (el) => { for (const ul of el.querySelectorAll('[data-fit]')) window.ermiteFit?.fit(ul); };
909 return {920 return {
921+ async teardown(remove) {
922+ ++tearingDown;
923+ try { return await remove(); }
924+ finally { setTimeout(() => { --tearingDown; }, 0); }
925+ },
926+ resumeStream(element) {
927+ // The public connection includes id-only messages as well as named ones.
928+ const id = connection?.lastEventId;
929+ if (!id) return;
930+ const url = new URL(element.getAttribute('hx-sse:connect'), location.href);
931+ url.searchParams.set('after', id);
932+ element.setAttribute('hx-sse:connect', url.pathname + url.search);
933+ },
910 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows934 // lost is htmx:sse:close and htmx:sse:error: "live updates paused" shows
911 // only if the stream is still down LOST_AFTER later.935 // only if the stream is still down LOST_AFTER later.
912 lost() {936 lost() {
M internal/adapters/web/static/js/mr.js +14 -3
@@ -456,7 +456,14 @@ window.ermiteMR = (() => {
456456 }
457457
458458 function hasDraft() {
459- return $$('textarea').some((t) => (t.value !== t.defaultValue) || document.activeElement === t) ||
459+ // Only form controls, never diff rows. Inline edits live in .cmt forms.
460+ return $$(':is(form.cform, .cmt form) :is(textarea, input, select)').some((t) => {
461+ if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false;
462+ if (document.activeElement === t) return true;
463+ if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked;
464+ if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected);
465+ return t.value !== t.defaultValue;
466+ }) ||
460467 !!document.activeElement?.closest?.('.cform');
461468 }
462469
@@ -523,8 +530,12 @@ window.ermiteMR = (() => {
523530 for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
524531 const marker = document.createComment('MR region');
525532 old.before(marker);
526- await window.htmx.swap({ sourceElement: old, target: old, text: '',
533+ const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '',
527534 swap: 'delete settle:0 transition:false', push: false, replace: false });
535+ if (old.matches('.hint-live')) {
536+ window.ermiteLive.resumeStream(fresh);
537+ await window.ermiteLive.teardown(remove);
538+ } else await remove();
528539 const node = document.adoptNode(fresh);
529540 marker.replaceWith(node);
530541 window.htmx.process(node);
@@ -639,7 +650,7 @@ window.ermiteMR = (() => {
639650 if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
640651 e.preventDefault();
641652 const form = cancel.closest('.cform');
642- for (const t of $$('textarea', form)) t.value = t.defaultValue;
653+ form.reset();
643654 if (form.contains(document.activeElement)) document.activeElement.blur();
644655 headRefresh.resume();
645656 });
@@ -456,7 +456,14 @@ window.ermiteMR = (() => {
456 }456 }
457 457
458 function hasDraft() {458 function hasDraft() {
459- return $$('textarea').some((t) => (t.value !== t.defaultValue) || document.activeElement === t) ||459+ // Only form controls, never diff rows. Inline edits live in .cmt forms.
460+ return $$(':is(form.cform, .cmt form) :is(textarea, input, select)').some((t) => {
461+ if (['hidden', 'submit', 'button', 'reset', 'image'].includes(t.type)) return false;
462+ if (document.activeElement === t) return true;
463+ if (t.type === 'radio' || t.type === 'checkbox') return t.checked !== t.defaultChecked;
464+ if (t.tagName === 'SELECT') return [...t.options].some((o) => o.selected !== o.defaultSelected);
465+ return t.value !== t.defaultValue;
466+ }) ||
460 !!document.activeElement?.closest?.('.cform');467 !!document.activeElement?.closest?.('.cform');
461 }468 }
462 469
@@ -523,8 +530,12 @@ window.ermiteMR = (() => {
523 for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');530 for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
524 const marker = document.createComment('MR region');531 const marker = document.createComment('MR region');
525 old.before(marker);532 old.before(marker);
526- await window.htmx.swap({ sourceElement: old, target: old, text: '',533+ const remove = () => window.htmx.swap({ sourceElement: old, target: old, text: '',
527 swap: 'delete settle:0 transition:false', push: false, replace: false });534 swap: 'delete settle:0 transition:false', push: false, replace: false });
535+ if (old.matches('.hint-live')) {
536+ window.ermiteLive.resumeStream(fresh);
537+ await window.ermiteLive.teardown(remove);
538+ } else await remove();
528 const node = document.adoptNode(fresh);539 const node = document.adoptNode(fresh);
529 marker.replaceWith(node);540 marker.replaceWith(node);
530 window.htmx.process(node);541 window.htmx.process(node);
@@ -639,7 +650,7 @@ window.ermiteMR = (() => {
639 if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;650 if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
640 e.preventDefault();651 e.preventDefault();
641 const form = cancel.closest('.cform');652 const form = cancel.closest('.cform');
642- for (const t of $$('textarea', form)) t.value = t.defaultValue;653+ form.reset();
643 if (form.contains(document.activeElement)) document.activeElement.blur();654 if (form.contains(document.activeElement)) document.activeElement.blur();
644 headRefresh.resume();655 headRefresh.resume();
645 });656 });
M internal/app/web_events_test.go +12 -5
@@ -245,12 +245,19 @@ func TestWebEventsResumeAndGap(t *testing.T) {
245245 }
246246 s.cancel()
247247
248- r := h.sse("topic=home", ids[0])
249- for _, want := range ids[1:] {
250- ev := r.next(t, watchWait)
251- assert.Equal(t, want, ev.id)
252- assert.Equal(t, "home-list", ev.name)
248+ // A replacement stream resumes via the URL; reconnect headers still win.
249+ for _, r := range []*sseStream{
250+ h.sse("topic=home&after="+ids[0], ""),
251+ h.sse("topic=home&after=invalid", ids[0]),
252+ } {
253+ for _, want := range ids[1:] {
254+ ev := r.next(t, watchWait)
255+ assert.Equal(t, want, ev.id)
256+ assert.Equal(t, "home-list", ev.name)
257+ }
258+ r.cancel()
253259 }
260+ r := h.sse("topic=home", ids[2])
254261 h.writeFile(wt, "live.txt", []byte("x\n"))
255262 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
256263 live := r.next(t, watchWait)
@@ -245,12 +245,19 @@ func TestWebEventsResumeAndGap(t *testing.T) {
245 }245 }
246 s.cancel()246 s.cancel()
247 247
248- r := h.sse("topic=home", ids[0])248+ // A replacement stream resumes via the URL; reconnect headers still win.
249- for _, want := range ids[1:] {249+ for _, r := range []*sseStream{
250- ev := r.next(t, watchWait)250+ h.sse("topic=home&after="+ids[0], ""),
251- assert.Equal(t, want, ev.id)251+ h.sse("topic=home&after=invalid", ids[0]),
252- assert.Equal(t, "home-list", ev.name)252+ } {
253+ for _, want := range ids[1:] {
254+ ev := r.next(t, watchWait)
255+ assert.Equal(t, want, ev.id)
256+ assert.Equal(t, "home-list", ev.name)
257+ }
258+ r.cancel()
253 }259 }
260+ r := h.sse("topic=home", ids[2])
254 h.writeFile(wt, "live.txt", []byte("x\n"))261 h.writeFile(wt, "live.txt", []byte("x\n"))
255 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")262 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
256 live := r.next(t, watchWait)263 live := r.next(t, watchWait)

Keyboard shortcuts

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

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

Branches 3

Tags 43

↑↓ moveenter openesc close