detached cff892b

fix(web): keep the MR refresh anchored, draft safe and lean

Probe below sticky file headers for the reader's line, follow the new
page's diff layout, defer every refresh and reload while a comment draft
exists, refresh the loose comments, adopt parsed nodes instead of
reserializing them, and announce updates in a persistent status region.
commit
cff892ba7f9dd173844d1dcb932f9914809ab5b0
author
brice · · 2026-10-20 05:55 UTC
parent
e97c08e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +448 -384
M docs/merge-requests.md +3 -4
@@ -128,7 +128,6 @@ The page request still reads only one 25-file window of hunks; the visible
128128 anchor's window is fetched too if needed, and other windows remain lazy.
129129
130130 While a comment is focused or has unsent edits, the refresh waits. A small
131-“branch updated” status offers **refresh now**, which also refreshes in place
132-and keeps unsent forms (including a comment whose old line disappeared).
133-Emptying or cancelling the draft lets the pending refresh proceed. Submitting
134-an ordinary comment navigates to the server's current review as usual.
131+“branch updated — refreshing after your comment” status explains the delay.
132+Sending, cancelling, or reverting the draft and leaving its form lets the
133+pending refresh proceed. Refresh never preserves or discards an unsent draft.
@@ -128,7 +128,6 @@ The page request still reads only one 25-file window of hunks; the visible
128 anchor's window is fetched too if needed, and other windows remain lazy.128 anchor's window is fetched too if needed, and other windows remain lazy.
129 129
130 While a comment is focused or has unsent edits, the refresh waits. A small130 While a comment is focused or has unsent edits, the refresh waits. A small
131-“branch updated” status offers **refresh now**, which also refreshes in place131+“branch updated — refreshing after your comment” status explains the delay.
132-and keeps unsent forms (including a comment whose old line disappeared).132+Sending, cancelling, or reverting the draft and leaving its form lets the
133-Emptying or cancelling the draft lets the pending refresh proceed. Submitting133+pending refresh proceed. Refresh never preserves or discards an unsent draft.
134-an ordinary comment navigates to the server's current review as usual.
M internal/adapters/web/events_test.go +25 -0
@@ -502,3 +502,28 @@ func TestMRPushEventsKeepMergeability(t *testing.T) {
502502 }
503503 }
504504 }
505+
506+func TestMRPushBothBranches(t *testing.T) {
507+ t.Parallel()
508+ for _, refs := range [][]string{{"feature", "main"}, {"main", "feature"}} {
509+ t.Run(strings.Join(refs, "-"), func(t *testing.T) {
510+ svc := mocks.NewMergeRequestService(t)
511+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Once()
512+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
513+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
514+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + refs[0]}, {Ref: "refs/heads/" + refs[1]}}})
515+ require.NoError(t, err)
516+ require.Len(t, fs, 2)
517+ assert.Equal(t, "mr-head", fs[0].event)
518+ assert.Equal(t, "mr-mergeability", fs[1].event)
519+ })
520+ }
521+}
522+
523+func TestNonMRPushRoutingNeverEmitsMRHead(t *testing.T) {
524+ t.Parallel()
525+ l := liveStream{s: &Server{}, repo: "demo", view: "mrs", n: "1", ref: "feature", target: "main"}
526+ fs, err := l.render(t.Context(), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "demo"), Updates: []domain.RefUpdate{{Ref: "refs/heads/feature"}}}})
527+ require.NoError(t, err)
528+ assert.Empty(t, fs)
529+}
@@ -502,3 +502,28 @@ func TestMRPushEventsKeepMergeability(t *testing.T) {
502 }502 }
503 }503 }
504 }504 }
505+
506+func TestMRPushBothBranches(t *testing.T) {
507+ t.Parallel()
508+ for _, refs := range [][]string{{"feature", "main"}, {"main", "feature"}} {
509+ t.Run(strings.Join(refs, "-"), func(t *testing.T) {
510+ svc := mocks.NewMergeRequestService(t)
511+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Once()
512+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
513+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
514+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + refs[0]}, {Ref: "refs/heads/" + refs[1]}}})
515+ require.NoError(t, err)
516+ require.Len(t, fs, 2)
517+ assert.Equal(t, "mr-head", fs[0].event)
518+ assert.Equal(t, "mr-mergeability", fs[1].event)
519+ })
520+ }
521+}
522+
523+func TestNonMRPushRoutingNeverEmitsMRHead(t *testing.T) {
524+ t.Parallel()
525+ l := liveStream{s: &Server{}, repo: "demo", view: "mrs", n: "1", ref: "feature", target: "main"}
526+ fs, err := l.render(t.Context(), ports.WatchEvent{Event: domain.PushEvent{Repo: repoName(t, "demo"), Updates: []domain.RefUpdate{{Ref: "refs/heads/feature"}}}})
527+ require.NoError(t, err)
528+ assert.Empty(t, fs)
529+}
M internal/adapters/web/jstest/mr.test.mjs +54 -9
@@ -304,10 +304,10 @@ test('touch control: lazy links enable toolbar; ordinary taps pass through; rang
304304 // Deterministic timers: no sleeps, and each test restores every mocked global.
305305 function clock() {
306306 const saved = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
307- const tasks = new Map(); let n = 0;
308- globalThis.setTimeout = (fn) => { tasks.set(++n, fn); return n; };
307+ const tasks = new Map(); let n = 0, now = 0;
308+ globalThis.setTimeout = (fn, delay = 0) => { tasks.set(++n, { fn, at: now + delay }); return n; };
309309 globalThis.clearTimeout = (id) => tasks.delete(id);
310- return { async tick() { const batch = [...tasks.values()]; tasks.clear(); for (const fn of batch) await fn(); },
310+ return { async tick(ms = 300) { now += ms; const batch = [...tasks].filter(([, t]) => t.at <= now); for (const [id] of batch) tasks.delete(id); for (const [, t] of batch) await t.fn(); },
311311 size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };
312312 }
313313
@@ -336,7 +336,9 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in
336336 apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });
337337 try {
338338 r.request(); r.request(); r.request(); assert.equal(c.size(), 1);
339- const first = c.tick(); assert.equal(fetched, 1);
339+ await c.tick(299); assert.equal(fetched, 0);
340+ await c.tick(0); assert.equal(fetched, 0);
341+ const first = c.tick(1); assert.equal(fetched, 1);
340342 r.request(); await c.tick(); assert.equal(fetched, 1, 'one fetch in flight');
341343 resolve('one'); await first; assert.equal(applied, 1); assert.equal(c.size(), 1);
342344 const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;
@@ -344,15 +346,16 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in
344346 } finally { c.restore(); }
345347 });
346348
347-test('typing during fetch defers the swap; manual refresh bypasses deferral', async () => {
349+test('typing during fetch defers even manual refresh until the draft ends', async () => {
348350 const c = clock(); let blocked = false, applied = 0, resolve; const notices = [];
349351 const r = window.ermiteMR.refresher({ blocked: () => blocked,
350- fetchPage: () => new Promise((r) => { resolve = r; }), apply: (_page, manual) => { applied++; assert.equal(manual, true); },
352+ fetchPage: () => new Promise((r) => { resolve = r; }), apply: () => { applied++; },
351353 notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
352354 try {
353355 r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
354356 assert.equal(applied, 0); assert.deepEqual(notices, [true]);
355- r.request(true); const second = c.tick(); resolve('new page'); await second;
357+ r.request(true); await c.tick(); assert.equal(applied, 0);
358+ blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;
356359 assert.equal(applied, 1); assert.equal(notices.at(-1), false);
357360 } finally { c.restore(); }
358361 });
@@ -386,8 +389,8 @@ test('viewport probe finds a stable path/side/line and restores its offset witho
386389 let lineTop = 160, headerTop = -200, found = true; const calls = [];
387390 const header = { getBoundingClientRect: () => rect(headerTop) };
388391 const row = { getBoundingClientRect: () => rect(lineTop), querySelector: () => link };
389- const link = { href: 'https://forge.test/demo/merge-requests/1?on=new:42:a.go#f2', closest: () => row };
390- const file = { id: 'f2', dataset: { file: 'a.go' }, querySelector: (s) => s.includes('summary') ? header : s.includes('href$') ? (assert.ok(s.includes('on=new:42:a.go#f3')), found ? link : null) : null };
392+ const link = { href: 'https://forge.test/demo/merge-requests/1?on=new:42:a.go#f2', closest: () => row, getAttribute: () => link.href };
393+ const file = { getBoundingClientRect: () => rect(headerTop), id: 'f2', dataset: { file: 'a.go' }, querySelector: (s) => s.includes('summary') ? header : s.includes('href$') ? (assert.ok(s.includes('on=new:42:a.go#f3')), found ? link : null) : null };
391394 const hit = { closest: (s) => s === 'tr' ? row : file };
392395 const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };
393396 globalThis.document = {
@@ -408,3 +411,45 @@ test('viewport probe finds a stable path/side/line and restores its offset witho
408411 assert.deepEqual(calls.pop(), ['to', { top: 9200, behavior: 'instant' }]);
409412 } finally { for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }
410413 });
414+
415+test('empty edited default and focused cform controls protect drafts', () => {
416+ const saved = document;
417+ try {
418+ globalThis.document = { querySelectorAll: () => [{ value: '', defaultValue: 'existing' }], activeElement: null };
419+ assert.equal(window.ermiteMR.hasDraft(), true);
420+ document.querySelectorAll = () => [];
421+ document.activeElement = { closest: (s) => s === '.cform' ? {} : null };
422+ assert.equal(window.ermiteMR.hasDraft(), true);
423+ } finally { globalThis.document = saved; }
424+});
425+
426+test('failure during a draft keeps pending and resumes after the draft ends', async () => {
427+ const c = clock(); let blocked = false, reject, applied = 0, count = 0;
428+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
429+ fetchPage: () => ++count === 1 ? new Promise((_, no) => { reject = no; }) : Promise.resolve('page'),
430+ apply: () => applied++, notice() {}, failed: () => assert.fail('draft must defer fallback') });
431+ try {
432+ r.request(); const first = c.tick(); blocked = true; reject(new Error('offline')); await first;
433+ assert.equal(r.pending(), true); assert.equal(applied, 0);
434+ blocked = false; r.resume(); await c.tick(); assert.equal(applied, 1);
435+ } finally { c.restore(); }
436+});
437+
438+test('anchor restoration retains the server encoding of tilde and star paths', () => {
439+ const saved = { document, CSS: globalThis.CSS, scrollBy: globalThis.scrollBy };
440+ try {
441+ globalThis.CSS = { escape: (s) => s };
442+ globalThis.scrollBy = () => {};
443+ for (const path of ['a~.go', 'a*.go']) {
444+ const encoded = path.replace('*', '%2A');
445+ const row = { getBoundingClientRect: () => ({ top: 5 }) };
446+ const file = { id: 'f9', dataset: { file: path }, querySelector: (s) => {
447+ if (s === '.dt.uni') return file;
448+ assert.equal(s, 'a[href$="on=new:12:' + encoded + '#f9"]');
449+ return { closest: () => row };
450+ } };
451+ globalThis.document = { querySelectorAll: () => [file], querySelector: () => ({ dataset: { diff: 'unified' } }) };
452+ window.ermiteMR.restoreAnchor({ file: path, suffix: 'new:12:' + encoded, on: 'new:12:' + path, offset: 5 });
453+ }
454+ } finally { Object.assign(globalThis, saved); }
455+});
@@ -304,10 +304,10 @@ test('touch control: lazy links enable toolbar; ordinary taps pass through; rang
304 // Deterministic timers: no sleeps, and each test restores every mocked global.304 // Deterministic timers: no sleeps, and each test restores every mocked global.
305 function clock() {305 function clock() {
306 const saved = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };306 const saved = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout };
307- const tasks = new Map(); let n = 0;307+ const tasks = new Map(); let n = 0, now = 0;
308- globalThis.setTimeout = (fn) => { tasks.set(++n, fn); return n; };308+ globalThis.setTimeout = (fn, delay = 0) => { tasks.set(++n, { fn, at: now + delay }); return n; };
309 globalThis.clearTimeout = (id) => tasks.delete(id);309 globalThis.clearTimeout = (id) => tasks.delete(id);
310- return { async tick() { const batch = [...tasks.values()]; tasks.clear(); for (const fn of batch) await fn(); },310+ return { async tick(ms = 300) { now += ms; const batch = [...tasks].filter(([, t]) => t.at <= now); for (const [id] of batch) tasks.delete(id); for (const [, t] of batch) await t.fn(); },
311 size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };311 size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };
312 }312 }
313 313
@@ -336,7 +336,9 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in
336 apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });336 apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });
337 try {337 try {
338 r.request(); r.request(); r.request(); assert.equal(c.size(), 1);338 r.request(); r.request(); r.request(); assert.equal(c.size(), 1);
339- const first = c.tick(); assert.equal(fetched, 1);339+ await c.tick(299); assert.equal(fetched, 0);
340+ await c.tick(0); assert.equal(fetched, 0);
341+ const first = c.tick(1); assert.equal(fetched, 1);
340 r.request(); await c.tick(); assert.equal(fetched, 1, 'one fetch in flight');342 r.request(); await c.tick(); assert.equal(fetched, 1, 'one fetch in flight');
341 resolve('one'); await first; assert.equal(applied, 1); assert.equal(c.size(), 1);343 resolve('one'); await first; assert.equal(applied, 1); assert.equal(c.size(), 1);
342 const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;344 const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;
@@ -344,15 +346,16 @@ test('head refresh debounces bursts, serializes requests, and reruns after an in
344 } finally { c.restore(); }346 } finally { c.restore(); }
345 });347 });
346 348
347-test('typing during fetch defers the swap; manual refresh bypasses deferral', async () => {349+test('typing during fetch defers even manual refresh until the draft ends', async () => {
348 const c = clock(); let blocked = false, applied = 0, resolve; const notices = [];350 const c = clock(); let blocked = false, applied = 0, resolve; const notices = [];
349 const r = window.ermiteMR.refresher({ blocked: () => blocked,351 const r = window.ermiteMR.refresher({ blocked: () => blocked,
350- fetchPage: () => new Promise((r) => { resolve = r; }), apply: (_page, manual) => { applied++; assert.equal(manual, true); },352+ fetchPage: () => new Promise((r) => { resolve = r; }), apply: () => { applied++; },
351 notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });353 notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
352 try {354 try {
353 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;
354 assert.equal(applied, 0); assert.deepEqual(notices, [true]);356 assert.equal(applied, 0); assert.deepEqual(notices, [true]);
355- r.request(true); const second = c.tick(); resolve('new page'); await second;357+ r.request(true); await c.tick(); assert.equal(applied, 0);
358+ blocked = false; r.resume(); const second = c.tick(); resolve('new page'); await second;
356 assert.equal(applied, 1); assert.equal(notices.at(-1), false);359 assert.equal(applied, 1); assert.equal(notices.at(-1), false);
357 } finally { c.restore(); }360 } finally { c.restore(); }
358 });361 });
@@ -386,8 +389,8 @@ test('viewport probe finds a stable path/side/line and restores its offset witho
386 let lineTop = 160, headerTop = -200, found = true; const calls = [];389 let lineTop = 160, headerTop = -200, found = true; const calls = [];
387 const header = { getBoundingClientRect: () => rect(headerTop) };390 const header = { getBoundingClientRect: () => rect(headerTop) };
388 const row = { getBoundingClientRect: () => rect(lineTop), querySelector: () => link };391 const row = { getBoundingClientRect: () => rect(lineTop), querySelector: () => link };
389- const link = { href: 'https://forge.test/demo/merge-requests/1?on=new:42:a.go#f2', closest: () => row };392+ const link = { href: 'https://forge.test/demo/merge-requests/1?on=new:42:a.go#f2', closest: () => row, getAttribute: () => link.href };
390- const file = { id: 'f2', dataset: { file: 'a.go' }, querySelector: (s) => s.includes('summary') ? header : s.includes('href$') ? (assert.ok(s.includes('on=new:42:a.go#f3')), found ? link : null) : null };393+ const file = { getBoundingClientRect: () => rect(headerTop), id: 'f2', dataset: { file: 'a.go' }, querySelector: (s) => s.includes('summary') ? header : s.includes('href$') ? (assert.ok(s.includes('on=new:42:a.go#f3')), found ? link : null) : null };
391 const hit = { closest: (s) => s === 'tr' ? row : file };394 const hit = { closest: (s) => s === 'tr' ? row : file };
392 const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };395 const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };
393 globalThis.document = {396 globalThis.document = {
@@ -408,3 +411,45 @@ test('viewport probe finds a stable path/side/line and restores its offset witho
408 assert.deepEqual(calls.pop(), ['to', { top: 9200, behavior: 'instant' }]);411 assert.deepEqual(calls.pop(), ['to', { top: 9200, behavior: 'instant' }]);
409 } finally { for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }412 } finally { for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; } }
410 });413 });
414+
415+test('empty edited default and focused cform controls protect drafts', () => {
416+ const saved = document;
417+ try {
418+ globalThis.document = { querySelectorAll: () => [{ value: '', defaultValue: 'existing' }], activeElement: null };
419+ assert.equal(window.ermiteMR.hasDraft(), true);
420+ document.querySelectorAll = () => [];
421+ document.activeElement = { closest: (s) => s === '.cform' ? {} : null };
422+ assert.equal(window.ermiteMR.hasDraft(), true);
423+ } finally { globalThis.document = saved; }
424+});
425+
426+test('failure during a draft keeps pending and resumes after the draft ends', async () => {
427+ const c = clock(); let blocked = false, reject, applied = 0, count = 0;
428+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
429+ fetchPage: () => ++count === 1 ? new Promise((_, no) => { reject = no; }) : Promise.resolve('page'),
430+ apply: () => applied++, notice() {}, failed: () => assert.fail('draft must defer fallback') });
431+ try {
432+ r.request(); const first = c.tick(); blocked = true; reject(new Error('offline')); await first;
433+ assert.equal(r.pending(), true); assert.equal(applied, 0);
434+ blocked = false; r.resume(); await c.tick(); assert.equal(applied, 1);
435+ } finally { c.restore(); }
436+});
437+
438+test('anchor restoration retains the server encoding of tilde and star paths', () => {
439+ const saved = { document, CSS: globalThis.CSS, scrollBy: globalThis.scrollBy };
440+ try {
441+ globalThis.CSS = { escape: (s) => s };
442+ globalThis.scrollBy = () => {};
443+ for (const path of ['a~.go', 'a*.go']) {
444+ const encoded = path.replace('*', '%2A');
445+ const row = { getBoundingClientRect: () => ({ top: 5 }) };
446+ const file = { id: 'f9', dataset: { file: path }, querySelector: (s) => {
447+ if (s === '.dt.uni') return file;
448+ assert.equal(s, 'a[href$="on=new:12:' + encoded + '#f9"]');
449+ return { closest: () => row };
450+ } };
451+ globalThis.document = { querySelectorAll: () => [file], querySelector: () => ({ dataset: { diff: 'unified' } }) };
452+ window.ermiteMR.restoreAnchor({ file: path, suffix: 'new:12:' + encoded, on: 'new:12:' + path, offset: 5 });
453+ }
454+ } finally { Object.assign(globalThis, saved); }
455+});
M internal/adapters/web/mr.templ +4 -0
@@ -13,6 +13,7 @@ templ MRList(l Layout, p MRListPage) {
1313 @Shell(l, mrListRail(p)) {
1414 if p.Live {
1515 @LiveSlot("mr-notice")
16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
1617 }
1718 <div class="filter">
1819 <label for="mr-filter" class="sr">filter</label>
@@ -157,6 +158,7 @@ templ MergeRequest(l Layout, p MRPage) {
157158 <p class="d-err" role="alert">{ p.MergeError }</p>
158159 }
159160 @LiveSlot("mr-notice")
161+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
160162 <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>
161163 if p.Since != nil {
162164 <nav class="since" aria-label="Diff range">
@@ -212,6 +214,7 @@ templ MergeRequest(l Layout, p MRPage) {
212214 <p class="note">no changed files.</p>
213215 }
214216 </div>
217+ <div id="mr-comments">
215218 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
216219 for _, c := range p.Loose {
217220 @commentView(c, "")
@@ -225,6 +228,7 @@ templ MergeRequest(l Layout, p MRPage) {
225228 if p.GeneralForm != nil {
226229 @commentForm(*p.GeneralForm, "")
227230 }
231+ </div>
228232 }
229233 }
230234
@@ -13,6 +13,7 @@ templ MRList(l Layout, p MRListPage) {
13 @Shell(l, mrListRail(p)) {13 @Shell(l, mrListRail(p)) {
14 if p.Live {14 if p.Live {
15 @LiveSlot("mr-notice")15 @LiveSlot("mr-notice")
16+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
16 }17 }
17 <div class="filter">18 <div class="filter">
18 <label for="mr-filter" class="sr">filter</label>19 <label for="mr-filter" class="sr">filter</label>
@@ -157,6 +158,7 @@ templ MergeRequest(l Layout, p MRPage) {
157 <p class="d-err" role="alert">{ p.MergeError }</p>158 <p class="d-err" role="alert">{ p.MergeError }</p>
158 }159 }
159 @LiveSlot("mr-notice")160 @LiveSlot("mr-notice")
161+ <div id="mr-refresh-status" role="status" aria-live="polite"></div>
160 <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>162 <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>
161 if p.Since != nil {163 if p.Since != nil {
162 <nav class="since" aria-label="Diff range">164 <nav class="since" aria-label="Diff range">
@@ -212,6 +214,7 @@ templ MergeRequest(l Layout, p MRPage) {
212 <p class="note">no changed files.</p>214 <p class="note">no changed files.</p>
213 }215 }
214 </div>216 </div>
217+ <div id="mr-comments">
215 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>218 <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2>
216 for _, c := range p.Loose {219 for _, c := range p.Loose {
217 @commentView(c, "")220 @commentView(c, "")
@@ -225,6 +228,7 @@ templ MergeRequest(l Layout, p MRPage) {
225 if p.GeneralForm != nil {228 if p.GeneralForm != nil {
226 @commentForm(*p.GeneralForm, "")229 @commentForm(*p.GeneralForm, "")
227 }230 }
231+ </div>
228 }232 }
229 }233 }
230 234
M internal/adapters/web/mr_templ.go +280 -280 generated file: collapsed
M internal/adapters/web/static/js/ermite.js +4 -1
@@ -965,7 +965,10 @@ window.ermiteLive = (() => {
965965 reload() {
966966 if (reloading) return;
967967 reloading = true;
968- setTimeout(() => location.reload(), 300);
968+ setTimeout(() => {
969+ reloading = false;
970+ if (window.ermiteLive.canReload?.() !== false) location.reload();
971+ }, 300);
969972 },
970973 // error handles htmx:sse:error (detail.status is the status of a refused
971974 // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
@@ -965,7 +965,10 @@ window.ermiteLive = (() => {
965 reload() {965 reload() {
966 if (reloading) return;966 if (reloading) return;
967 reloading = true;967 reloading = true;
968- setTimeout(() => location.reload(), 300);968+ setTimeout(() => {
969+ reloading = false;
970+ if (window.ermiteLive.canReload?.() !== false) location.reload();
971+ }, 300);
969 },972 },
970 // error handles htmx:sse:error (detail.status is the status of a refused973 // error handles htmx:sse:error (detail.status is the status of a refused
971 // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by974 // reconnection). hx-sse retries for ever; a 404 or a 401 will not change by
M internal/adapters/web/static/js/mr.js +78 -90
@@ -430,34 +430,33 @@ window.ermiteMR = (() => {
430430 // a dirty bit for pushes received during that request. Drafts are checked
431431 // again after the fetch: typing while the network is slow is safe too.
432432 function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
433- let timer, busy = false, pending = false, force = false;
433+ let timer, busy = false, pending = false;
434434 const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
435435 async function run() {
436436 if (busy || !pending) return;
437- if (!force && blocked()) { notice(true); return; }
437+ if (blocked()) { notice(true); return; }
438438 busy = true;
439439 pending = false;
440- const manual = force; force = false;
441440 try {
442441 const page = await fetchPage();
443- if (!manual && blocked()) { pending = true; notice(true); return; }
444- if (await apply(page, manual) === false) { pending = true; notice(true); }
442+ if (blocked()) { pending = true; notice(true); return; }
443+ if (await apply(page) === false) { pending = true; notice(true); }
445444 else notice(false);
446- } catch { failed(); }
445+ } catch { if (blocked()) { pending = true; notice(true); } else failed(); }
447446 finally {
448447 busy = false;
449- if (pending && (force || !blocked())) schedule();
448+ if (pending && !blocked()) schedule();
450449 }
451450 }
452451 return {
453- request(manual = false) { pending = true; force ||= manual; schedule(); },
452+ request() { pending = true; schedule(); },
454453 resume() { if (pending) schedule(); },
455454 pending() { return pending || busy; },
456455 };
457456 }
458457
459458 function hasDraft() {
460- return $$('textarea').some((t) => (t.value.length > 0 && t.value !== t.defaultValue) || document.activeElement === t) ||
459+ return $$('textarea').some((t) => (t.value !== t.defaultValue) || document.activeElement === t) ||
461460 !!document.activeElement?.closest?.('.cform');
462461 }
463462
@@ -470,19 +469,24 @@ window.ermiteMR = (() => {
470469 const bounds = diffs.getBoundingClientRect();
471470 const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2)));
472471 // A bounded number of probes, independent of the number of diff lines.
473- for (const y of [top + 2, top + 24, top + 64, top + 128]) {
474- if (y >= innerHeight) break;
472+ let y = top + 2, fallback;
473+ for (let probe = 0; probe < 6 && y < innerHeight; probe++) {
475474 const hit = document.elementFromPoint(x, y);
476475 const file = hit?.closest('details.dfile');
477- if (!file || !diffs.contains(file)) continue;
476+ if (!file || !diffs.contains(file)) { y += 32; continue; }
478477 const row = hit.closest('tr');
479478 const link = row && $('.ln a[href*="on="]', row);
480479 const header = $(':scope > summary', file);
481- return { file: file.dataset.file,
482- on: link && new URL(link.href, location.href).searchParams.get('on'),
483- offset: (link ? row : header).getBoundingClientRect().top,
484- headerOffset: header.getBoundingClientRect().top, y: scrollY };
480+ const headerOffset = file.getBoundingClientRect().top;
481+ fallback = { file: file.dataset.file, offset: headerOffset, headerOffset, y: scrollY };
482+ if (link) return { ...fallback,
483+ on: new URL(link.href, location.href).searchParams.get('on'),
484+ suffix: link.getAttribute('href').split('on=')[1].split('#')[0],
485+ offset: row.getBoundingClientRect().top };
486+ if (file.open && header) y = Math.max(y + 24, header.getBoundingClientRect().bottom + 2);
487+ else return fallback;
485488 }
489+ if (fallback) return fallback;
486490 return { y: scrollY };
487491 }
488492
@@ -490,110 +494,92 @@ window.ermiteMR = (() => {
490494 const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
491495 let el, offset = anchor.offset;
492496 if (file && anchor.on) {
493- // Exact href suffix: no traversal or measurement of every line. URLSearchParams
494- // uses the same form encoding as the Go line links (space is '+').
495- const parts = /^(new|old):(\d+):(.+)$/.exec(anchor.on);
496- const path = parts && new URLSearchParams({ path: parts[3] }).toString().slice(5);
497- const suffix = parts && 'on=' + parts[1] + ':' + parts[2] + ':' + path + '#' + file.id;
497+ const suffix = anchor.suffix && 'on=' + anchor.suffix + '#' + file.id;
498498 const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view;
499499 const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file;
500500 if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
501501 }
502- if (!el && file) { el = $(':scope > summary', file); offset = anchor.headerOffset ?? anchor.offset; }
502+ if (!el && file) { el = file; offset = anchor.headerOffset ?? anchor.offset; }
503503 if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' });
504504 else window.scrollTo({ top: Math.max(0, Math.min(anchor.y, document.documentElement.scrollHeight - innerHeight)), behavior: 'instant' });
505505 }
506506
507- async function fetchPage() {
508- const res = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
507+ async function fetchPage(url = location.href, mobileRetry = false) {
508+ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
509509 if (!res.ok || res.redirected) throw new Error('MR refresh refused');
510510 const page = new DOMParser().parseFromString(await res.text(), 'text/html');
511511 if (!$('#diffs[data-mr]', page) || !$('.mr-bar', page) || !$('.hint-live', page)) throw new Error('not an MR page');
512+ const unified = core.mobileURL(url, $('#diffs', page).dataset.view,
513+ !!window.matchMedia?.('(max-width: 720px)').matches);
514+ if (unified && !mobileRetry) return fetchPage(unified, true);
512515 return page;
513516 }
514517
515- // Use htmx's swap rather than replaceWith: its cleanup retires the old SSE
516- // reader and hx-live bindings, and process activates the inserted controls.
518+ // htmx 4's public delete swap invokes recursive before:cleanup (including
519+ // hx-sse's abort/reader.cancel and hx-live's binding disposal). Only an empty
520+ // string is parsed; the fetched nodes themselves are adopted, never serialized.
517521 async function replaceRegion(old, fresh) {
518522 if (!old || !fresh) throw new Error('missing MR refresh region');
519523 for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
520- await window.htmx.swap({ sourceElement: old, target: old, text: fresh.outerHTML,
521- swap: 'outerHTML settle:0 transition:false focusScroll:false', push: false, replace: false });
524+ const marker = document.createComment('MR region');
525+ old.before(marker);
526+ await window.htmx.swap({ sourceElement: old, target: old, text: '',
527+ swap: 'delete settle:0 transition:false', push: false, replace: false });
528+ const node = document.adoptNode(fresh);
529+ marker.replaceWith(node);
530+ window.htmx.process(node);
522531 }
523532
524- async function refreshPage(page, manual = false) {
525- const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '.rail', '.hint-live'];
533+ async function refreshPage(page) {
534+ const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '#mr-comments', '.rail', '.hint-live'];
526535 for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
527536 let anchor = scrollAnchor();
537+ const nextDiffs = $('#diffs', page);
538+ const mobile = !!window.matchMedia?.('(max-width: 720px)').matches;
539+ const chosen = mobile ? 'unified' : ($('#diffs').dataset.diff || $('#diffs').dataset.view);
540+ if (nextDiffs.dataset.diff) nextDiffs.dataset.diff = chosen;
541+ // A one-layout response must use its own contract, never the old page's.
542+ if (mobile && nextDiffs.dataset.view === 'split') throw new Error('mobile needs unified');
528543 const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
529544 const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
530545 if (placeholder) {
531- const d = $('#diffs').dataset;
546+ const d = nextDiffs.dataset;
532547 const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one');
533548 const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
534549 if (!res.ok || res.redirected) throw new Error('anchor window changed');
535- const windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');
536- const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));
537- for (const f of $$('details.dfile', windowPage)) byID.get(f.id)?.replaceWith(f);
538- if (!manual && hasDraft()) return false;
550+ let windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');
551+ if (hasDraft()) return false;
539552 anchor = scrollAnchor();
553+ const windowFiles = $$('details.dfile', windowPage);
554+ // If the reader moved outside this window, discard it; lazy loading will
555+ // fetch the new position rather than committing an unrelated window.
556+ if (windowFiles.some((f) => f.dataset.file === anchor.file)) {
557+ const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));
558+ for (const f of windowFiles) byID.get(f.id)?.replaceWith(f);
559+ }
560+ windowPage = null;
540561 }
562+ if (hasDraft()) return false;
541563 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
542564 const folded = $('.r-files')?.hasAttribute('data-folded');
543565 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
544566 const footerOpen = $('#mr-panels')?.open;
545567 const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;
546568 const active = document.activeElement;
547- const focus = active && { menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
548- // Manual refresh keeps even an active draft whose line disappeared. It
549- // stays an ordinary form, under an explicit previous-diff label.
550- const drafts = [...new Set($$('#diffs textarea').filter((t) => t.value !== t.defaultValue || t === active || t.closest('form')?.contains(active)).map((t) => t.closest('form')).filter(Boolean))];
551- let draftID = 0;
552- for (const form of drafts) {
553- for (const t of $$('textarea', form)) {
554- if (!t.id) t.id = 'mr-preserved-draft-' + (++draftID);
555- if (t === active) focus.id = t.id;
556- }
557- const id = $('textarea', form)?.id;
558- const replacement = id && $$('textarea', page).find((t) => t.id === id)?.closest('.cform');
559- const copy = form.cloneNode(true);
560- const originalFields = $$('textarea, input, select', form);
561- $$('textarea, input, select', copy).forEach((field, i) => {
562- const original = originalFields[i];
563- if (field.tagName === 'TEXTAREA') field.textContent = original.defaultValue;
564- else if (field.tagName === 'INPUT') { field.setAttribute('value', original.value); field.toggleAttribute('checked', original.checked); }
565- });
566- if (replacement) replacement.replaceWith(copy);
567- else {
568- const box = page.createElement('div');
569- const label = page.createElement('p'); label.className = 'notice';
570- label.textContent = 'Unsent comment on the previous diff (the line may have moved).';
571- box.append(label, copy); $('#diffs', page).prepend(box);
572- }
573- }
574- // Serializing markup does not retain textarea.value or selection. Restore
575- // values after the htmx swap, without ever clearing the source drafts.
576- const values = drafts.flatMap((f) => $$('textarea', f).map((t) => ({ id: t.id, value: t.value, start: t.selectionStart, end: t.selectionEnd })));
569+ const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
577570 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
578571 ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
579- // Keep the chosen client-side layout when the URL was not pinned.
580- const layout = $('#diffs')?.dataset;
581- if (layout?.diff && $('#diffs', page).dataset.diff) $('#diffs', page).dataset.diff = layout.diff;
582572 $('#diffs', page).dataset.mrStarted = 'true';
583573 for (const sel of regions) {
584574 const fresh = $(sel, page);
585575 await replaceRegion($(sel), fresh);
586- fresh.remove(); // release each parsed region as soon as it is consumed
587- }
588- for (const v of values) {
589- const t = document.getElementById(v.id);
590- if (t) { t.value = v.value; t.setSelectionRange(v.start, v.end); }
591576 }
592577 for (const key of ['ref', 'sha', 'paths', 'blob']) {
593578 if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
594579 }
595580 const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
596581 if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
582+ page = null;
597583 panels();
598584 if (tab) {
599585 const oldURL = location.href;
@@ -609,38 +595,40 @@ window.ermiteMR = (() => {
609595 window.ermite?.refreshFiles?.();
610596 watch();
611597 if (focus && active !== document.body) {
612- const same = (e) => e.tagName === focus.tag && (focus.id ? e.id === focus.id :
613- focus.href ? e.getAttribute('href') === focus.href : focus.name && e.getAttribute('name') === focus.name);
614- const el = focus.menu && !focus.id && !focus.name && !focus.href ? document.getElementById(focus.menu)?.querySelector(':scope > summary') : focus.id ? document.getElementById(focus.id) : focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]') : focus.name ? $('[name="' + CSS.escape(focus.name) + '"]') : null;
615- if (el && (same(el) || focus.menu)) el.focus({ preventScroll: true });
598+ const region = focus.region ? $(focus.region) : document;
599+ const menu = focus.menu && document.getElementById(focus.menu);
600+ if (menu) menu.open = true;
601+ const el = focus.file ? $('a[title="' + CSS.escape(focus.file) + '"]', region) :
602+ focus.id ? document.getElementById(focus.id) :
603+ focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]', region) :
604+ focus.name ? $('[name="' + CSS.escape(focus.name) + '"]', region) :
605+ menu?.querySelector(':scope > summary');
606+ el?.focus({ preventScroll: true });
616607 }
617608 restoreAnchor(anchor);
618609 }
619610
620611 function refreshNotice(deferred) {
621- let slot = $('#mr-refresh-status');
622- if (!slot) {
623- slot = document.createElement('div'); slot.id = 'mr-refresh-status'; slot.className = 'live-slot';
624- slot.setAttribute('role', 'status'); slot.setAttribute('aria-live', 'polite');
625- $('#mr-notice')?.after(slot);
626- }
627- slot.className = deferred ? 'live-slot' : 'sr';
612+ const slot = $('#mr-refresh-status');
613+ if (!slot) return;
628614 slot.replaceChildren();
629615 if (!deferred) {
630- const text = document.createElement('span'); text.textContent = 'branch updated'; slot.append(text);
616+ const text = document.createElement('span'); text.className = 'sr'; text.textContent = 'branch updated'; slot.append(text);
631617 return;
632618 }
633619 slot.append('branch updated — refreshing after your comment. ');
634- const a = document.createElement('a'); a.href = location.href; a.textContent = 'refresh now';
635- a.addEventListener('click', (e) => { e.preventDefault(); headRefresh.request(true); });
636- slot.append(a);
620+
637621 }
638622 const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
639- failed: () => { if (hasDraft()) refreshNotice(true); else window.ermiteLive?.reload(); } });
623+ failed: () => window.ermiteLive?.reload() });
640624 function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
641625
642626 function start() {
643627 if (!$('#diffs[data-mr]')) return;
628+ if (window.ermiteLive) window.ermiteLive.canReload = () => {
629+ if (!hasDraft()) return true;
630+ headRefresh.request(); refreshNotice(true); return false;
631+ };
644632 // Global delegation is installed once; replacement regions have their own
645633 // disposable panel/touch hooks below.
646634 if ($('#diffs').dataset.mrStarted) return;
@@ -651,7 +639,7 @@ window.ermiteMR = (() => {
651639 if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
652640 e.preventDefault();
653641 const form = cancel.closest('.cform');
654- for (const t of $$('textarea', form)) t.value = '';
642+ for (const t of $$('textarea', form)) t.value = t.defaultValue;
655643 if (form.contains(document.activeElement)) document.activeElement.blur();
656644 headRefresh.resume();
657645 });
@@ -430,34 +430,33 @@ window.ermiteMR = (() => {
430 // a dirty bit for pushes received during that request. Drafts are checked430 // a dirty bit for pushes received during that request. Drafts are checked
431 // again after the fetch: typing while the network is slow is safe too.431 // again after the fetch: typing while the network is slow is safe too.
432 function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {432 function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
433- let timer, busy = false, pending = false, force = false;433+ let timer, busy = false, pending = false;
434 const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };434 const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
435 async function run() {435 async function run() {
436 if (busy || !pending) return;436 if (busy || !pending) return;
437- if (!force && blocked()) { notice(true); return; }437+ if (blocked()) { notice(true); return; }
438 busy = true;438 busy = true;
439 pending = false;439 pending = false;
440- const manual = force; force = false;
441 try {440 try {
442 const page = await fetchPage();441 const page = await fetchPage();
443- if (!manual && blocked()) { pending = true; notice(true); return; }442+ if (blocked()) { pending = true; notice(true); return; }
444- if (await apply(page, manual) === false) { pending = true; notice(true); }443+ if (await apply(page) === false) { pending = true; notice(true); }
445 else notice(false);444 else notice(false);
446- } catch { failed(); }445+ } catch { if (blocked()) { pending = true; notice(true); } else failed(); }
447 finally {446 finally {
448 busy = false;447 busy = false;
449- if (pending && (force || !blocked())) schedule();448+ if (pending && !blocked()) schedule();
450 }449 }
451 }450 }
452 return {451 return {
453- request(manual = false) { pending = true; force ||= manual; schedule(); },452+ request() { pending = true; schedule(); },
454 resume() { if (pending) schedule(); },453 resume() { if (pending) schedule(); },
455 pending() { return pending || busy; },454 pending() { return pending || busy; },
456 };455 };
457 }456 }
458 457
459 function hasDraft() {458 function hasDraft() {
460- return $$('textarea').some((t) => (t.value.length > 0 && t.value !== t.defaultValue) || document.activeElement === t) ||459+ return $$('textarea').some((t) => (t.value !== t.defaultValue) || document.activeElement === t) ||
461 !!document.activeElement?.closest?.('.cform');460 !!document.activeElement?.closest?.('.cform');
462 }461 }
463 462
@@ -470,19 +469,24 @@ window.ermiteMR = (() => {
470 const bounds = diffs.getBoundingClientRect();469 const bounds = diffs.getBoundingClientRect();
471 const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2)));470 const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2)));
472 // A bounded number of probes, independent of the number of diff lines.471 // A bounded number of probes, independent of the number of diff lines.
473- for (const y of [top + 2, top + 24, top + 64, top + 128]) {472+ let y = top + 2, fallback;
474- if (y >= innerHeight) break;473+ for (let probe = 0; probe < 6 && y < innerHeight; probe++) {
475 const hit = document.elementFromPoint(x, y);474 const hit = document.elementFromPoint(x, y);
476 const file = hit?.closest('details.dfile');475 const file = hit?.closest('details.dfile');
477- if (!file || !diffs.contains(file)) continue;476+ if (!file || !diffs.contains(file)) { y += 32; continue; }
478 const row = hit.closest('tr');477 const row = hit.closest('tr');
479 const link = row && $('.ln a[href*="on="]', row);478 const link = row && $('.ln a[href*="on="]', row);
480 const header = $(':scope > summary', file);479 const header = $(':scope > summary', file);
481- return { file: file.dataset.file,480+ const headerOffset = file.getBoundingClientRect().top;
482- on: link && new URL(link.href, location.href).searchParams.get('on'),481+ fallback = { file: file.dataset.file, offset: headerOffset, headerOffset, y: scrollY };
483- offset: (link ? row : header).getBoundingClientRect().top,482+ if (link) return { ...fallback,
484- headerOffset: header.getBoundingClientRect().top, y: scrollY };483+ on: new URL(link.href, location.href).searchParams.get('on'),
484+ suffix: link.getAttribute('href').split('on=')[1].split('#')[0],
485+ offset: row.getBoundingClientRect().top };
486+ if (file.open && header) y = Math.max(y + 24, header.getBoundingClientRect().bottom + 2);
487+ else return fallback;
485 }488 }
489+ if (fallback) return fallback;
486 return { y: scrollY };490 return { y: scrollY };
487 }491 }
488 492
@@ -490,110 +494,92 @@ window.ermiteMR = (() => {
490 const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);494 const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
491 let el, offset = anchor.offset;495 let el, offset = anchor.offset;
492 if (file && anchor.on) {496 if (file && anchor.on) {
493- // Exact href suffix: no traversal or measurement of every line. URLSearchParams497+ const suffix = anchor.suffix && 'on=' + anchor.suffix + '#' + file.id;
494- // uses the same form encoding as the Go line links (space is '+').
495- const parts = /^(new|old):(\d+):(.+)$/.exec(anchor.on);
496- const path = parts && new URLSearchParams({ path: parts[3] }).toString().slice(5);
497- const suffix = parts && 'on=' + parts[1] + ':' + parts[2] + ':' + path + '#' + file.id;
498 const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view;498 const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view;
499 const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file;499 const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file;
500 if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');500 if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
501 }501 }
502- if (!el && file) { el = $(':scope > summary', file); offset = anchor.headerOffset ?? anchor.offset; }502+ if (!el && file) { el = file; offset = anchor.headerOffset ?? anchor.offset; }
503 if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' });503 if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' });
504 else window.scrollTo({ top: Math.max(0, Math.min(anchor.y, document.documentElement.scrollHeight - innerHeight)), behavior: 'instant' });504 else window.scrollTo({ top: Math.max(0, Math.min(anchor.y, document.documentElement.scrollHeight - innerHeight)), behavior: 'instant' });
505 }505 }
506 506
507- async function fetchPage() {507+ async function fetchPage(url = location.href, mobileRetry = false) {
508- const res = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });508+ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
509 if (!res.ok || res.redirected) throw new Error('MR refresh refused');509 if (!res.ok || res.redirected) throw new Error('MR refresh refused');
510 const page = new DOMParser().parseFromString(await res.text(), 'text/html');510 const page = new DOMParser().parseFromString(await res.text(), 'text/html');
511 if (!$('#diffs[data-mr]', page) || !$('.mr-bar', page) || !$('.hint-live', page)) throw new Error('not an MR page');511 if (!$('#diffs[data-mr]', page) || !$('.mr-bar', page) || !$('.hint-live', page)) throw new Error('not an MR page');
512+ const unified = core.mobileURL(url, $('#diffs', page).dataset.view,
513+ !!window.matchMedia?.('(max-width: 720px)').matches);
514+ if (unified && !mobileRetry) return fetchPage(unified, true);
512 return page;515 return page;
513 }516 }
514 517
515- // Use htmx's swap rather than replaceWith: its cleanup retires the old SSE518+ // htmx 4's public delete swap invokes recursive before:cleanup (including
516- // reader and hx-live bindings, and process activates the inserted controls.519+ // hx-sse's abort/reader.cancel and hx-live's binding disposal). Only an empty
520+ // string is parsed; the fetched nodes themselves are adopted, never serialized.
517 async function replaceRegion(old, fresh) {521 async function replaceRegion(old, fresh) {
518 if (!old || !fresh) throw new Error('missing MR refresh region');522 if (!old || !fresh) throw new Error('missing MR refresh region');
519 for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');523 for (const el of $$('[autofocus]', fresh)) el.removeAttribute('autofocus');
520- await window.htmx.swap({ sourceElement: old, target: old, text: fresh.outerHTML,524+ const marker = document.createComment('MR region');
521- swap: 'outerHTML settle:0 transition:false focusScroll:false', push: false, replace: false });525+ old.before(marker);
526+ await window.htmx.swap({ sourceElement: old, target: old, text: '',
527+ swap: 'delete settle:0 transition:false', push: false, replace: false });
528+ const node = document.adoptNode(fresh);
529+ marker.replaceWith(node);
530+ window.htmx.process(node);
522 }531 }
523 532
524- async function refreshPage(page, manual = false) {533+ async function refreshPage(page) {
525- const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '.rail', '.hint-live'];534+ const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '#mr-comments', '.rail', '.hint-live'];
526 for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');535 for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
527 let anchor = scrollAnchor();536 let anchor = scrollAnchor();
537+ const nextDiffs = $('#diffs', page);
538+ const mobile = !!window.matchMedia?.('(max-width: 720px)').matches;
539+ const chosen = mobile ? 'unified' : ($('#diffs').dataset.diff || $('#diffs').dataset.view);
540+ if (nextDiffs.dataset.diff) nextDiffs.dataset.diff = chosen;
541+ // A one-layout response must use its own contract, never the old page's.
542+ if (mobile && nextDiffs.dataset.view === 'split') throw new Error('mobile needs unified');
528 const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);543 const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
529 const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);544 const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
530 if (placeholder) {545 if (placeholder) {
531- const d = $('#diffs').dataset;546+ const d = nextDiffs.dataset;
532 const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one');547 const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one');
533 const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });548 const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
534 if (!res.ok || res.redirected) throw new Error('anchor window changed');549 if (!res.ok || res.redirected) throw new Error('anchor window changed');
535- const windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');550+ let windowPage = new DOMParser().parseFromString(await res.text(), 'text/html');
536- const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));551+ if (hasDraft()) return false;
537- for (const f of $$('details.dfile', windowPage)) byID.get(f.id)?.replaceWith(f);
538- if (!manual && hasDraft()) return false;
539 anchor = scrollAnchor();552 anchor = scrollAnchor();
553+ const windowFiles = $$('details.dfile', windowPage);
554+ // If the reader moved outside this window, discard it; lazy loading will
555+ // fetch the new position rather than committing an unrelated window.
556+ if (windowFiles.some((f) => f.dataset.file === anchor.file)) {
557+ const byID = new Map($$('#diffs details.dfile', page).map((f) => [f.id, f]));
558+ for (const f of windowFiles) byID.get(f.id)?.replaceWith(f);
559+ }
560+ windowPage = null;
540 }561 }
562+ if (hasDraft()) return false;
541 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));563 const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
542 const folded = $('.r-files')?.hasAttribute('data-folded');564 const folded = $('.r-files')?.hasAttribute('data-folded');
543 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;565 const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
544 const footerOpen = $('#mr-panels')?.open;566 const footerOpen = $('#mr-panels')?.open;
545 const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;567 const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;
546 const active = document.activeElement;568 const active = document.activeElement;
547- const focus = active && { menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };569+ const focus = active && { file: active.closest?.('.files a')?.getAttribute('title'), region: regions.find((sel) => $(sel)?.contains(active)), menu: active.closest?.('details.menu')?.id, id: active.id, tag: active.tagName, name: active.getAttribute('name'), href: active.getAttribute('href') };
548- // Manual refresh keeps even an active draft whose line disappeared. It
549- // stays an ordinary form, under an explicit previous-diff label.
550- const drafts = [...new Set($$('#diffs textarea').filter((t) => t.value !== t.defaultValue || t === active || t.closest('form')?.contains(active)).map((t) => t.closest('form')).filter(Boolean))];
551- let draftID = 0;
552- for (const form of drafts) {
553- for (const t of $$('textarea', form)) {
554- if (!t.id) t.id = 'mr-preserved-draft-' + (++draftID);
555- if (t === active) focus.id = t.id;
556- }
557- const id = $('textarea', form)?.id;
558- const replacement = id && $$('textarea', page).find((t) => t.id === id)?.closest('.cform');
559- const copy = form.cloneNode(true);
560- const originalFields = $$('textarea, input, select', form);
561- $$('textarea, input, select', copy).forEach((field, i) => {
562- const original = originalFields[i];
563- if (field.tagName === 'TEXTAREA') field.textContent = original.defaultValue;
564- else if (field.tagName === 'INPUT') { field.setAttribute('value', original.value); field.toggleAttribute('checked', original.checked); }
565- });
566- if (replacement) replacement.replaceWith(copy);
567- else {
568- const box = page.createElement('div');
569- const label = page.createElement('p'); label.className = 'notice';
570- label.textContent = 'Unsent comment on the previous diff (the line may have moved).';
571- box.append(label, copy); $('#diffs', page).prepend(box);
572- }
573- }
574- // Serializing markup does not retain textarea.value or selection. Restore
575- // values after the htmx swap, without ever clearing the source drafts.
576- const values = drafts.flatMap((f) => $$('textarea', f).map((t) => ({ id: t.id, value: t.value, start: t.selectionStart, end: t.selectionEnd })));
577 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);570 for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file);
578 ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();571 ++diffGeneration; ++revealGen; moved = false; loading.clear(); lazyIO?.disconnect();
579- // Keep the chosen client-side layout when the URL was not pinned.
580- const layout = $('#diffs')?.dataset;
581- if (layout?.diff && $('#diffs', page).dataset.diff) $('#diffs', page).dataset.diff = layout.diff;
582 $('#diffs', page).dataset.mrStarted = 'true';572 $('#diffs', page).dataset.mrStarted = 'true';
583 for (const sel of regions) {573 for (const sel of regions) {
584 const fresh = $(sel, page);574 const fresh = $(sel, page);
585 await replaceRegion($(sel), fresh);575 await replaceRegion($(sel), fresh);
586- fresh.remove(); // release each parsed region as soon as it is consumed
587- }
588- for (const v of values) {
589- const t = document.getElementById(v.id);
590- if (t) { t.value = v.value; t.setSelectionRange(v.start, v.end); }
591 }576 }
592 for (const key of ['ref', 'sha', 'paths', 'blob']) {577 for (const key of ['ref', 'sha', 'paths', 'blob']) {
593 if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];578 if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
594 }579 }
595 const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);580 const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
596 if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;581 if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
582+ page = null;
597 panels();583 panels();
598 if (tab) {584 if (tab) {
599 const oldURL = location.href;585 const oldURL = location.href;
@@ -609,38 +595,40 @@ window.ermiteMR = (() => {
609 window.ermite?.refreshFiles?.();595 window.ermite?.refreshFiles?.();
610 watch();596 watch();
611 if (focus && active !== document.body) {597 if (focus && active !== document.body) {
612- const same = (e) => e.tagName === focus.tag && (focus.id ? e.id === focus.id :598+ const region = focus.region ? $(focus.region) : document;
613- focus.href ? e.getAttribute('href') === focus.href : focus.name && e.getAttribute('name') === focus.name);599+ const menu = focus.menu && document.getElementById(focus.menu);
614- const el = focus.menu && !focus.id && !focus.name && !focus.href ? document.getElementById(focus.menu)?.querySelector(':scope > summary') : focus.id ? document.getElementById(focus.id) : focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]') : focus.name ? $('[name="' + CSS.escape(focus.name) + '"]') : null;600+ if (menu) menu.open = true;
615- if (el && (same(el) || focus.menu)) el.focus({ preventScroll: true });601+ const el = focus.file ? $('a[title="' + CSS.escape(focus.file) + '"]', region) :
602+ focus.id ? document.getElementById(focus.id) :
603+ focus.href ? $('a[href="' + CSS.escape(focus.href) + '"]', region) :
604+ focus.name ? $('[name="' + CSS.escape(focus.name) + '"]', region) :
605+ menu?.querySelector(':scope > summary');
606+ el?.focus({ preventScroll: true });
616 }607 }
617 restoreAnchor(anchor);608 restoreAnchor(anchor);
618 }609 }
619 610
620 function refreshNotice(deferred) {611 function refreshNotice(deferred) {
621- let slot = $('#mr-refresh-status');612+ const slot = $('#mr-refresh-status');
622- if (!slot) {613+ if (!slot) return;
623- slot = document.createElement('div'); slot.id = 'mr-refresh-status'; slot.className = 'live-slot';
624- slot.setAttribute('role', 'status'); slot.setAttribute('aria-live', 'polite');
625- $('#mr-notice')?.after(slot);
626- }
627- slot.className = deferred ? 'live-slot' : 'sr';
628 slot.replaceChildren();614 slot.replaceChildren();
629 if (!deferred) {615 if (!deferred) {
630- const text = document.createElement('span'); text.textContent = 'branch updated'; slot.append(text);616+ const text = document.createElement('span'); text.className = 'sr'; text.textContent = 'branch updated'; slot.append(text);
631 return;617 return;
632 }618 }
633 slot.append('branch updated — refreshing after your comment. ');619 slot.append('branch updated — refreshing after your comment. ');
634- const a = document.createElement('a'); a.href = location.href; a.textContent = 'refresh now';620+
635- a.addEventListener('click', (e) => { e.preventDefault(); headRefresh.request(true); });
636- slot.append(a);
637 }621 }
638 const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,622 const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
639- failed: () => { if (hasDraft()) refreshNotice(true); else window.ermiteLive?.reload(); } });623+ failed: () => window.ermiteLive?.reload() });
640 function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }624 function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
641 625
642 function start() {626 function start() {
643 if (!$('#diffs[data-mr]')) return;627 if (!$('#diffs[data-mr]')) return;
628+ if (window.ermiteLive) window.ermiteLive.canReload = () => {
629+ if (!hasDraft()) return true;
630+ headRefresh.request(); refreshNotice(true); return false;
631+ };
644 // Global delegation is installed once; replacement regions have their own632 // Global delegation is installed once; replacement regions have their own
645 // disposable panel/touch hooks below.633 // disposable panel/touch hooks below.
646 if ($('#diffs').dataset.mrStarted) return;634 if ($('#diffs').dataset.mrStarted) return;
@@ -651,7 +639,7 @@ window.ermiteMR = (() => {
651 if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;639 if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
652 e.preventDefault();640 e.preventDefault();
653 const form = cancel.closest('.cform');641 const form = cancel.closest('.cform');
654- for (const t of $$('textarea', form)) t.value = '';642+ for (const t of $$('textarea', form)) t.value = t.defaultValue;
655 if (form.contains(document.activeElement)) document.activeElement.blur();643 if (form.contains(document.activeElement)) document.activeElement.blur();
656 headRefresh.resume();644 headRefresh.resume();
657 });645 });

Keyboard shortcuts

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

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

Branches 2

Tags 44

↑↓ moveenter openesc close