detached e97c08e

feat(web): refresh the MR view in place when its source branch moves

A push to the source branch sends an mr-head event instead of the reload
banner. The page fetches itself, swaps the head-dependent regions, keeps
the reader's line, folds, tab and focus, and waits for unsent comments.
commit
e97c08ee5c24b3c1e3d6931238d9bd485833c889
author
brice · · 2026-10-20 05:43 UTC
parent
b46e8c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +899 -450
M docs/merge-requests.md +14 -0
@@ -118,3 +118,17 @@ The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
118118 are read-only information in the More (⋯) menu, available to every viewer.
119119 Signing-key information is also there when a merge box offers signed modes;
120120 merge actions remain restricted to their existing permissions.
121+
122+## Live source-branch updates
123+
124+A push to the source branch refreshes the web review in place: the diff, files
125+rail, commits/checks footer, diff range and merge controls follow the new head.
126+The review keeps its visible file/line, folded files and selected footer tab.
127+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.
129+
130+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.
@@ -118,3 +118,17 @@ The rail keeps Files (including its `g f` shortcut) and Verdicts. Merge settings
118 are read-only information in the More (⋯) menu, available to every viewer.118 are read-only information in the More (⋯) menu, available to every viewer.
119 Signing-key information is also there when a merge box offers signed modes;119 Signing-key information is also there when a merge box offers signed modes;
120 merge actions remain restricted to their existing permissions.120 merge actions remain restricted to their existing permissions.
121+
122+## Live source-branch updates
123+
124+A push to the source branch refreshes the web review in place: the diff, files
125+rail, commits/checks footer, diff range and merge controls follow the new head.
126+The review keeps its visible file/line, folded files and selected footer tab.
127+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.
129+
130+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.
M internal/adapters/web/events.go +5 -1
@@ -31,7 +31,7 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=summary|refs
3232 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3333 // GET /-/events?topic=repo:<name>&view=mrs
34-// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch>
34+// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&head=<sha>&target=<target branch>
3535 // GET /-/events?topic=repo:<name>&view=issues
3636 // GET /-/events?topic=repo:<name>&view=issue&n=<number>
3737 // GET /-/events?topic=repo:<name>&view=actions
@@ -60,6 +60,10 @@ import (
6060 // the badge of the merge box (a slot like mr-notice), when the
6161 // mergeability of the request shown settles or a push moves its
6262 // source or target
63+// mr-head data: <div id="mr-head"></div>; the source branch moved.
64+// ermiteMR fetches the current page and refreshes its head-dependent
65+// regions in place, preserving the reader's position and drafts.
66+// The replacement stream uses the new head for CI filtering.
6367 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
6468 // something changed, with the link that reloads the page
6569 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
@@ -31,7 +31,7 @@ import (
31 // GET /-/events?topic=repo:<name>&view=summary|refs31 // GET /-/events?topic=repo:<name>&view=summary|refs
32 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>32 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
33 // GET /-/events?topic=repo:<name>&view=mrs33 // GET /-/events?topic=repo:<name>&view=mrs
34-// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&target=<target branch>34+// GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>&head=<sha>&target=<target branch>
35 // GET /-/events?topic=repo:<name>&view=issues35 // GET /-/events?topic=repo:<name>&view=issues
36 // GET /-/events?topic=repo:<name>&view=issue&n=<number>36 // GET /-/events?topic=repo:<name>&view=issue&n=<number>
37 // GET /-/events?topic=repo:<name>&view=actions37 // GET /-/events?topic=repo:<name>&view=actions
@@ -60,6 +60,10 @@ import (
60 // the badge of the merge box (a slot like mr-notice), when the60 // the badge of the merge box (a slot like mr-notice), when the
61 // mergeability of the request shown settles or a push moves its61 // mergeability of the request shown settles or a push moves its
62 // source or target62 // source or target
63+// mr-head data: <div id="mr-head"></div>; the source branch moved.
64+// ermiteMR fetches the current page and refreshes its head-dependent
65+// regions in place, preserving the reader's position and drafts.
66+// The replacement stream uses the new head for CI filtering.
63 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:67 // mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
64 // something changed, with the link that reloads the page68 // something changed, with the link that reloads the page
65 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),69 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
M internal/adapters/web/events_test.go +24 -0
@@ -478,3 +478,27 @@ func TestCommitsBannerWithoutMergeRequests(t *testing.T) {
478478 require.Len(t, got, 1)
479479 assert.Contains(t, got[0].html, "the branch moved — reload")
480480 }
481+
482+func TestMRPushEventsKeepMergeability(t *testing.T) {
483+ t.Parallel()
484+ svc := mocks.NewMergeRequestService(t)
485+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
486+ Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Twice()
487+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
488+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
489+ for _, ref := range []string{"feature", "main"} {
490+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + ref}}})
491+ require.NoError(t, err)
492+ if ref == "feature" {
493+ require.Len(t, fs, 2)
494+ assert.Equal(t, "mr-head", fs[0].event)
495+ assert.Equal(t, "event: mr-head\nid: b-1\ndata: <div id=\"mr-head\"></div>\n\n", formatEvent("b-1", fs[0].event, fs[0].html, true))
496+ } else {
497+ require.Len(t, fs, 1)
498+ }
499+ assert.Equal(t, "mr-mergeability", fs[len(fs)-1].event)
500+ for _, f := range fs {
501+ assert.NotEqual(t, "mr-notice", f.event)
502+ }
503+ }
504+}
@@ -478,3 +478,27 @@ func TestCommitsBannerWithoutMergeRequests(t *testing.T) {
478 require.Len(t, got, 1)478 require.Len(t, got, 1)
479 assert.Contains(t, got[0].html, "the branch moved — reload")479 assert.Contains(t, got[0].html, "the branch moved — reload")
480 }480 }
481+
482+func TestMRPushEventsKeepMergeability(t *testing.T) {
483+ t.Parallel()
484+ svc := mocks.NewMergeRequestService(t)
485+ svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
486+ Return(domain.Mergeability{State: domain.MergeabilityUpToDate}, nil).Twice()
487+ l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
488+ ctx := context.WithValue(t.Context(), signedInKey{}, struct{}{})
489+ for _, ref := range []string{"feature", "main"} {
490+ fs, err := l.mrPushed(ctx, domain.PushEvent{Updates: []domain.RefUpdate{{Ref: "refs/heads/" + ref}}})
491+ require.NoError(t, err)
492+ if ref == "feature" {
493+ require.Len(t, fs, 2)
494+ assert.Equal(t, "mr-head", fs[0].event)
495+ assert.Equal(t, "event: mr-head\nid: b-1\ndata: <div id=\"mr-head\"></div>\n\n", formatEvent("b-1", fs[0].event, fs[0].html, true))
496+ } else {
497+ require.Len(t, fs, 1)
498+ }
499+ assert.Equal(t, "mr-mergeability", fs[len(fs)-1].event)
500+ for _, f := range fs {
501+ assert.NotEqual(t, "mr-notice", f.event)
502+ }
503+ }
504+}
M internal/adapters/web/fragments.go +2 -7
@@ -474,8 +474,7 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
474474 // mrPushed announces a push to the source branch of the merge request shown,
475475 // and has the badge follow a push to its source or its target.
476476 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
477- n, err := strconv.ParseInt(l.n, 10, 64)
478- if err != nil {
477+ if _, err := strconv.ParseInt(l.n, 10, 64); err != nil {
479478 return nil, nil
480479 }
481480 var out []fragment
@@ -483,11 +482,7 @@ func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragme
483482 for _, u := range p.Updates {
484483 switch {
485484 case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
486- notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
487- if err != nil {
488- return nil, err
489- }
490- out = append(out, notice...)
485+ out = append(out, fragment{event: "mr-head", html: `<div id="mr-head"></div>`})
491486 moved = true
492487 case l.target != "" && u.Ref == "refs/heads/"+l.target:
493488 moved = true
@@ -474,8 +474,7 @@ func (l *liveStream) issueNotice(ctx context.Context, number int64, text string)
474 // mrPushed announces a push to the source branch of the merge request shown,474 // mrPushed announces a push to the source branch of the merge request shown,
475 // and has the badge follow a push to its source or its target.475 // and has the badge follow a push to its source or its target.
476 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {476 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
477- n, err := strconv.ParseInt(l.n, 10, 64)477+ if _, err := strconv.ParseInt(l.n, 10, 64); err != nil {
478- if err != nil {
479 return nil, nil478 return nil, nil
480 }479 }
481 var out []fragment480 var out []fragment
@@ -483,11 +482,7 @@ func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragme
483 for _, u := range p.Updates {482 for _, u := range p.Updates {
484 switch {483 switch {
485 case l.ref != "" && u.Ref == "refs/heads/"+l.ref:484 case l.ref != "" && u.Ref == "refs/heads/"+l.ref:
486- notice, err := l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))485+ out = append(out, fragment{event: "mr-head", html: `<div id="mr-head"></div>`})
487- if err != nil {
488- return nil, err
489- }
490- out = append(out, notice...)
491 moved = true486 moved = true
492 case l.target != "" && u.Ref == "refs/heads/"+l.target:487 case l.target != "" && u.Ref == "refs/heads/"+l.target:
493 moved = true488 moved = true
M internal/adapters/web/fragments.templ +1 -0
@@ -103,6 +103,7 @@ templ Live(l Layout) {
103103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104104 htmx:sse:before:connection -> ermiteLive.gate(event);
105105 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106+ mr-head -> ermiteMR.headChanged();
106107 gap -> ermiteLive.reload()"
107108 ></span>
108109 }
@@ -103,6 +103,7 @@ templ Live(l Layout) {
103 htmx:sse:error -> ermiteLive.error(event.detail.status);103 htmx:sse:error -> ermiteLive.error(event.detail.status);
104 htmx:sse:before:connection -> ermiteLive.gate(event);104 htmx:sse:before:connection -> ermiteLive.gate(event);
105 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);105 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
106+ mr-head -> ermiteMR.headChanged();
106 gap -> ermiteLive.reload()"107 gap -> ermiteLive.reload()"
107 ></span>108 ></span>
108 }109 }
M internal/adapters/web/fragments_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/fragments_test.go +22 -0
@@ -7,6 +7,8 @@ import (
77 "strings"
88 "testing"
99
10+ "bnema.dev/ermite/internal/domain"
11+
1012 "github.com/a-h/templ"
1113 "github.com/stretchr/testify/assert"
1214 "github.com/stretchr/testify/require"
@@ -77,3 +79,23 @@ func TestDiffControlsPassDataThroughAttributes(t *testing.T) {
7779 assert.Contains(t, link, `this.dataset.view`)
7880 assert.NotContains(t, link, "=== 'split'")
7981 }
82+
83+// The push signal is intentionally constant-sized: diff work belongs to the
84+// subsequent windowed page request, not to each subscriber's event handler.
85+func TestMRPushedHeadSignal(t *testing.T) {
86+ t.Parallel()
87+ l := liveStream{s: &Server{}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
88+ for _, tt := range []struct {
89+ ref string
90+ want int
91+ }{{"refs/heads/feature", 1}, {"refs/heads/main", 0}, {"refs/heads/other", 0}} {
92+ fs, err := l.mrPushed(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: tt.ref}}})
93+ require.NoError(t, err)
94+ require.Len(t, fs, tt.want)
95+ if tt.want > 0 {
96+ assert.Equal(t, "mr-head", fs[0].event)
97+ assert.Equal(t, `<div id="mr-head"></div>`, fs[0].html)
98+ assert.NotContains(t, fs[0].html, "the branch changed")
99+ }
100+ }
101+}
@@ -7,6 +7,8 @@ import (
7 "strings"7 "strings"
8 "testing"8 "testing"
9 9
10+ "bnema.dev/ermite/internal/domain"
11+
10 "github.com/a-h/templ"12 "github.com/a-h/templ"
11 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
@@ -77,3 +79,23 @@ func TestDiffControlsPassDataThroughAttributes(t *testing.T) {
77 assert.Contains(t, link, `this.dataset.view`)79 assert.Contains(t, link, `this.dataset.view`)
78 assert.NotContains(t, link, "=== 'split'")80 assert.NotContains(t, link, "=== 'split'")
79 }81 }
82+
83+// The push signal is intentionally constant-sized: diff work belongs to the
84+// subsequent windowed page request, not to each subscriber's event handler.
85+func TestMRPushedHeadSignal(t *testing.T) {
86+ t.Parallel()
87+ l := liveStream{s: &Server{}, repo: "demo", view: "mr", n: "1", ref: "feature", target: "main"}
88+ for _, tt := range []struct {
89+ ref string
90+ want int
91+ }{{"refs/heads/feature", 1}, {"refs/heads/main", 0}, {"refs/heads/other", 0}} {
92+ fs, err := l.mrPushed(t.Context(), domain.PushEvent{Updates: []domain.RefUpdate{{Ref: tt.ref}}})
93+ require.NoError(t, err)
94+ require.Len(t, fs, tt.want)
95+ if tt.want > 0 {
96+ assert.Equal(t, "mr-head", fs[0].event)
97+ assert.Equal(t, `<div id="mr-head"></div>`, fs[0].html)
98+ assert.NotContains(t, fs[0].html, "the branch changed")
99+ }
100+ }
101+}
M internal/adapters/web/jstest/mr.test.mjs +108 -0
@@ -300,3 +300,111 @@ test('touch control: lazy links enable toolbar; ordinary taps pass through; rang
300300 }
301301 }
302302 });
303+
304+// Deterministic timers: no sleeps, and each test restores every mocked global.
305+function clock() {
306+ 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; };
309+ globalThis.clearTimeout = (id) => tasks.delete(id);
310+ return { async tick() { const batch = [...tasks.values()]; tasks.clear(); for (const fn of batch) await fn(); },
311+ size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };
312+}
313+
314+test('head refresh defers a real textarea draft, resumes after emptying and blurring', async () => {
315+ const saved = globalThis.document, c = clock();
316+ const text = { value: 'unsent', defaultValue: '' };
317+ let fetched = 0, applied = 0; const notices = [];
318+ globalThis.document = { activeElement: text, querySelectorAll: () => [text] };
319+ const r = window.ermiteMR.refresher({ blocked: window.ermiteMR.hasDraft,
320+ fetchPage: async () => { fetched++; return 'page'; }, apply: () => applied++,
321+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected fallback') });
322+ try {
323+ r.request(); await c.tick();
324+ assert.equal(fetched, 0); assert.deepEqual(notices, [true]);
325+ text.value = ''; r.resume(); await c.tick();
326+ assert.equal(fetched, 0, 'focus still protects an empty draft');
327+ document.activeElement = null; r.resume(); await c.tick();
328+ assert.equal(fetched, 1); assert.equal(applied, 1); assert.equal(r.pending(), false);
329+ } finally { globalThis.document = saved; c.restore(); }
330+});
331+
332+test('head refresh debounces bursts, serializes requests, and reruns after an in-flight push', async () => {
333+ const c = clock(); let fetched = 0, applied = 0, resolve;
334+ const r = window.ermiteMR.refresher({ blocked: () => false,
335+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); },
336+ apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });
337+ try {
338+ r.request(); r.request(); r.request(); assert.equal(c.size(), 1);
339+ const first = c.tick(); assert.equal(fetched, 1);
340+ 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);
342+ const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;
343+ assert.equal(applied, 2); assert.equal(c.size(), 0);
344+ } finally { c.restore(); }
345+});
346+
347+test('typing during fetch defers the swap; manual refresh bypasses deferral', async () => {
348+ const c = clock(); let blocked = false, applied = 0, resolve; const notices = [];
349+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
350+ fetchPage: () => new Promise((r) => { resolve = r; }), apply: (_page, manual) => { applied++; assert.equal(manual, true); },
351+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
352+ try {
353+ r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
354+ assert.equal(applied, 0); assert.deepEqual(notices, [true]);
355+ r.request(true); const second = c.tick(); resolve('new page'); await second;
356+ assert.equal(applied, 1); assert.equal(notices.at(-1), false);
357+ } finally { c.restore(); }
358+});
359+
360+test('fetch failures and non-MR responses take the fallback, never apply', async () => {
361+ const saved = { fetch: globalThis.fetch, location: globalThis.location, DOMParser: globalThis.DOMParser };
362+ const c = clock(); let fallback = 0;
363+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1?view=split&from=25' };
364+ const r = window.ermiteMR.refresher({ blocked: () => false, fetchPage: window.ermiteMR.fetchPage,
365+ apply: () => assert.fail('must not swap'), notice() {}, failed: () => fallback++ });
366+ try {
367+ for (const result of ['network', { ok: false }, { ok: true, redirected: true }, { ok: true, text: async () => '<html>login</html>' }]) {
368+ globalThis.fetch = async (url, opts) => {
369+ assert.equal(url, location.href); assert.equal(opts.credentials, 'same-origin');
370+ if (result === 'network') throw new Error('offline'); return result;
371+ };
372+ globalThis.DOMParser = class { parseFromString() { return { querySelector: () => null }; } };
373+ r.request(); await c.tick();
374+ }
375+ assert.equal(fallback, 4);
376+ } finally {
377+ for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; }
378+ c.restore();
379+ }
380+});
381+
382+test('viewport probe finds a stable path/side/line and restores its offset without line scans', () => {
383+ const names = ['document', 'location', 'innerHeight', 'innerWidth', 'scrollY', 'scrollBy', 'scrollTo', 'CSS', 'matchMedia'];
384+ const saved = Object.fromEntries(names.map((k) => [k, globalThis[k]]));
385+ const rect = (top, bottom = top + 20) => ({ top, bottom, left: 0, width: 900 });
386+ let lineTop = 160, headerTop = -200, found = true; const calls = [];
387+ const header = { getBoundingClientRect: () => rect(headerTop) };
388+ 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 };
391+ const hit = { closest: (s) => s === 'tr' ? row : file };
392+ const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };
393+ globalThis.document = {
394+ querySelector: (s) => s === '#diffs' ? diffs : s === '.hdr' ? { getBoundingClientRect: () => rect(0, 100) } : null,
395+ querySelectorAll: (s) => { assert.equal(s, '#diffs details.dfile', 'only files, never every line'); return found === 'gone' ? [] : [file]; },
396+ elementFromPoint: () => hit, documentElement: { scrollHeight: 10000 },
397+ };
398+ Object.assign(globalThis, { location: { href: link.href }, innerHeight: 800, innerWidth: 1200, scrollY: 2500,
399+ scrollBy: (arg) => calls.push(['by', arg]), scrollTo: (arg) => calls.push(['to', arg]), CSS: { escape: (s) => s }, matchMedia: () => ({ matches: false }) });
400+ try {
401+ const a = window.ermiteMR.scrollAnchor();
402+ assert.equal(a.file, 'a.go'); assert.equal(a.on, 'new:42:a.go'); assert.equal(a.offset, 160);
403+ file.id = 'f3'; lineTop = 260; window.ermiteMR.restoreAnchor(a);
404+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
405+ found = false; headerTop = -100; window.ermiteMR.restoreAnchor(a);
406+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
407+ found = 'gone'; window.ermiteMR.restoreAnchor({ ...a, y: 20000 });
408+ 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; } }
410+});
@@ -300,3 +300,111 @@ test('touch control: lazy links enable toolbar; ordinary taps pass through; rang
300 }300 }
301 }301 }
302 });302 });
303+
304+// Deterministic timers: no sleeps, and each test restores every mocked global.
305+function clock() {
306+ 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; };
309+ globalThis.clearTimeout = (id) => tasks.delete(id);
310+ return { async tick() { const batch = [...tasks.values()]; tasks.clear(); for (const fn of batch) await fn(); },
311+ size: () => tasks.size, restore() { Object.assign(globalThis, saved); } };
312+}
313+
314+test('head refresh defers a real textarea draft, resumes after emptying and blurring', async () => {
315+ const saved = globalThis.document, c = clock();
316+ const text = { value: 'unsent', defaultValue: '' };
317+ let fetched = 0, applied = 0; const notices = [];
318+ globalThis.document = { activeElement: text, querySelectorAll: () => [text] };
319+ const r = window.ermiteMR.refresher({ blocked: window.ermiteMR.hasDraft,
320+ fetchPage: async () => { fetched++; return 'page'; }, apply: () => applied++,
321+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected fallback') });
322+ try {
323+ r.request(); await c.tick();
324+ assert.equal(fetched, 0); assert.deepEqual(notices, [true]);
325+ text.value = ''; r.resume(); await c.tick();
326+ assert.equal(fetched, 0, 'focus still protects an empty draft');
327+ document.activeElement = null; r.resume(); await c.tick();
328+ assert.equal(fetched, 1); assert.equal(applied, 1); assert.equal(r.pending(), false);
329+ } finally { globalThis.document = saved; c.restore(); }
330+});
331+
332+test('head refresh debounces bursts, serializes requests, and reruns after an in-flight push', async () => {
333+ const c = clock(); let fetched = 0, applied = 0, resolve;
334+ const r = window.ermiteMR.refresher({ blocked: () => false,
335+ fetchPage: () => { fetched++; return new Promise((r) => { resolve = r; }); },
336+ apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') });
337+ try {
338+ r.request(); r.request(); r.request(); assert.equal(c.size(), 1);
339+ const first = c.tick(); assert.equal(fetched, 1);
340+ 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);
342+ const second = c.tick(); assert.equal(fetched, 2); resolve('two'); await second;
343+ assert.equal(applied, 2); assert.equal(c.size(), 0);
344+ } finally { c.restore(); }
345+});
346+
347+test('typing during fetch defers the swap; manual refresh bypasses deferral', async () => {
348+ const c = clock(); let blocked = false, applied = 0, resolve; const notices = [];
349+ const r = window.ermiteMR.refresher({ blocked: () => blocked,
350+ fetchPage: () => new Promise((r) => { resolve = r; }), apply: (_page, manual) => { applied++; assert.equal(manual, true); },
351+ notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') });
352+ try {
353+ r.request(); const first = c.tick(); blocked = true; resolve('page'); await first;
354+ assert.equal(applied, 0); assert.deepEqual(notices, [true]);
355+ r.request(true); const second = c.tick(); resolve('new page'); await second;
356+ assert.equal(applied, 1); assert.equal(notices.at(-1), false);
357+ } finally { c.restore(); }
358+});
359+
360+test('fetch failures and non-MR responses take the fallback, never apply', async () => {
361+ const saved = { fetch: globalThis.fetch, location: globalThis.location, DOMParser: globalThis.DOMParser };
362+ const c = clock(); let fallback = 0;
363+ globalThis.location = { href: 'https://forge.test/demo/merge-requests/1?view=split&from=25' };
364+ const r = window.ermiteMR.refresher({ blocked: () => false, fetchPage: window.ermiteMR.fetchPage,
365+ apply: () => assert.fail('must not swap'), notice() {}, failed: () => fallback++ });
366+ try {
367+ for (const result of ['network', { ok: false }, { ok: true, redirected: true }, { ok: true, text: async () => '<html>login</html>' }]) {
368+ globalThis.fetch = async (url, opts) => {
369+ assert.equal(url, location.href); assert.equal(opts.credentials, 'same-origin');
370+ if (result === 'network') throw new Error('offline'); return result;
371+ };
372+ globalThis.DOMParser = class { parseFromString() { return { querySelector: () => null }; } };
373+ r.request(); await c.tick();
374+ }
375+ assert.equal(fallback, 4);
376+ } finally {
377+ for (const [k,v] of Object.entries(saved)) { if (v === undefined) delete globalThis[k]; else globalThis[k] = v; }
378+ c.restore();
379+ }
380+});
381+
382+test('viewport probe finds a stable path/side/line and restores its offset without line scans', () => {
383+ const names = ['document', 'location', 'innerHeight', 'innerWidth', 'scrollY', 'scrollBy', 'scrollTo', 'CSS', 'matchMedia'];
384+ const saved = Object.fromEntries(names.map((k) => [k, globalThis[k]]));
385+ const rect = (top, bottom = top + 20) => ({ top, bottom, left: 0, width: 900 });
386+ let lineTop = 160, headerTop = -200, found = true; const calls = [];
387+ const header = { getBoundingClientRect: () => rect(headerTop) };
388+ 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 };
391+ const hit = { closest: (s) => s === 'tr' ? row : file };
392+ const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true };
393+ globalThis.document = {
394+ querySelector: (s) => s === '#diffs' ? diffs : s === '.hdr' ? { getBoundingClientRect: () => rect(0, 100) } : null,
395+ querySelectorAll: (s) => { assert.equal(s, '#diffs details.dfile', 'only files, never every line'); return found === 'gone' ? [] : [file]; },
396+ elementFromPoint: () => hit, documentElement: { scrollHeight: 10000 },
397+ };
398+ Object.assign(globalThis, { location: { href: link.href }, innerHeight: 800, innerWidth: 1200, scrollY: 2500,
399+ scrollBy: (arg) => calls.push(['by', arg]), scrollTo: (arg) => calls.push(['to', arg]), CSS: { escape: (s) => s }, matchMedia: () => ({ matches: false }) });
400+ try {
401+ const a = window.ermiteMR.scrollAnchor();
402+ assert.equal(a.file, 'a.go'); assert.equal(a.on, 'new:42:a.go'); assert.equal(a.offset, 160);
403+ file.id = 'f3'; lineTop = 260; window.ermiteMR.restoreAnchor(a);
404+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
405+ found = false; headerTop = -100; window.ermiteMR.restoreAnchor(a);
406+ assert.deepEqual(calls.pop(), ['by', { top: 100, behavior: 'instant' }]);
407+ found = 'gone'; window.ermiteMR.restoreAnchor({ ...a, y: 20000 });
408+ 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; } }
410+});
M internal/adapters/web/mr.templ +32 -28
@@ -157,35 +157,39 @@ templ MergeRequest(l Layout, p MRPage) {
157157 <p class="d-err" role="alert">{ p.MergeError }</p>
158158 }
159159 @LiveSlot("mr-notice")
160- if p.Since != nil {
161- <nav class="since" aria-label="Diff range">
162- <a
163- href={ templ.SafeURL(p.Since.AllHref) }
164- if !p.Since.Current {
165- aria-current="page"
166- }
167- >all changes <span class="mute">vs { p.Target }</span></a>
168- <a
169- href={ templ.SafeURL(p.Since.SinceHref) }
170- if p.Since.Current {
171- aria-current="page"
172- }
173- >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
174- </nav>
175- }
176- if p.SinceNote != "" {
177- <p class="notice">{ p.SinceNote }</p>
178- }
160+ <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>
161+ if p.Since != nil {
162+ <nav class="since" aria-label="Diff range">
163+ <a
164+ href={ templ.SafeURL(p.Since.AllHref) }
165+ if !p.Since.Current {
166+ aria-current="page"
167+ }
168+ >all changes <span class="mute">vs { p.Target }</span></a>
169+ <a
170+ href={ templ.SafeURL(p.Since.SinceHref) }
171+ if p.Since.Current {
172+ aria-current="page"
173+ }
174+ >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
175+ </nav>
176+ }
177+ if p.SinceNote != "" {
178+ <p class="notice">{ p.SinceNote }</p>
179+ }
180+ </div>
179181 @mrBar(p)
180- if p.Invisible {
181- <p class="notice">
182- this diff holds invisible characters (direction controls, zero-width characters). They are shown as
183- <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
184- </p>
185- }
186- if p.Note != "" {
187- <p class="notice">{ p.Note }</p>
188- }
182+ <div id="mr-diff-notices" hidden?={ !p.Invisible && p.Note == "" }>
183+ if p.Invisible {
184+ <p class="notice">
185+ this diff holds invisible characters (direction controls, zero-width characters). They are shown as
186+ <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
187+ </p>
188+ }
189+ if p.Note != "" {
190+ <p class="notice">{ p.Note }</p>
191+ }
192+ </div>
189193 <div
190194 id="diffs"
191195 data-mr
@@ -157,35 +157,39 @@ templ MergeRequest(l Layout, p MRPage) {
157 <p class="d-err" role="alert">{ p.MergeError }</p>157 <p class="d-err" role="alert">{ p.MergeError }</p>
158 }158 }
159 @LiveSlot("mr-notice")159 @LiveSlot("mr-notice")
160- if p.Since != nil {160+ <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }>
161- <nav class="since" aria-label="Diff range">161+ if p.Since != nil {
162- <a162+ <nav class="since" aria-label="Diff range">
163- href={ templ.SafeURL(p.Since.AllHref) }163+ <a
164- if !p.Since.Current {164+ href={ templ.SafeURL(p.Since.AllHref) }
165- aria-current="page"165+ if !p.Since.Current {
166- }166+ aria-current="page"
167- >all changes <span class="mute">vs { p.Target }</span></a>167+ }
168- <a168+ >all changes <span class="mute">vs { p.Target }</span></a>
169- href={ templ.SafeURL(p.Since.SinceHref) }169+ <a
170- if p.Since.Current {170+ href={ templ.SafeURL(p.Since.SinceHref) }
171- aria-current="page"171+ if p.Since.Current {
172- }172+ aria-current="page"
173- >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>173+ }
174- </nav>174+ >since your last read <span class="mute">{ p.Since.ReadShort } → { p.Since.HeadShort }</span></a>
175- }175+ </nav>
176- if p.SinceNote != "" {176+ }
177- <p class="notice">{ p.SinceNote }</p>177+ if p.SinceNote != "" {
178- }178+ <p class="notice">{ p.SinceNote }</p>
179+ }
180+ </div>
179 @mrBar(p)181 @mrBar(p)
180- if p.Invisible {182+ <div id="mr-diff-notices" hidden?={ !p.Invisible && p.Note == "" }>
181- <p class="notice">183+ if p.Invisible {
182- this diff holds invisible characters (direction controls, zero-width characters). They are shown as184+ <p class="notice">
183- <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.185+ this diff holds invisible characters (direction controls, zero-width characters). They are shown as
184- </p>186+ <span class="inv">&lt;U+XXXX&gt;</span> markers, never applied.
185- }187+ </p>
186- if p.Note != "" {188+ }
187- <p class="notice">{ p.Note }</p>189+ if p.Note != "" {
188- }190+ <p class="notice">{ p.Note }</p>
191+ }
192+ </div>
189 <div193 <div
190 id="diffs"194 id="diffs"
191 data-mr195 data-mr
M internal/adapters/web/mr_templ.go +414 -402 generated file: collapsed
M internal/adapters/web/static/css/80-live.css +5 -0
@@ -8,5 +8,10 @@
88 padding: var(--s3) var(--s4); font-weight: 700;
99 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);
1010 }
11+ /* A deferred MR refresh must not insert height above a long diff. */
12+ #mr-refresh-status:has(a) {
13+ position: fixed; bottom: var(--s4); left: var(--s4); z-index: 24;
14+ max-width: min(42rem, calc(100vw - 2 * var(--s4))); font-size: var(--fs-s);
15+ }
1116 .live-slot a { color: var(--ink-hi); }
1217 }
@@ -8,5 +8,10 @@
8 padding: var(--s3) var(--s4); font-weight: 700;8 padding: var(--s3) var(--s4); font-weight: 700;
9 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);9 background: var(--bg-surface); border: 1px solid var(--border-strong); border-left: 3px solid var(--teal); border-radius: var(--r-s);
10 }10 }
11+ /* A deferred MR refresh must not insert height above a long diff. */
12+ #mr-refresh-status:has(a) {
13+ position: fixed; bottom: var(--s4); left: var(--s4); z-index: 24;
14+ max-width: min(42rem, calc(100vw - 2 * var(--s4))); font-size: var(--fs-s);
15+ }
11 .live-slot a { color: var(--ink-hi); }16 .live-slot a { color: var(--ink-hi); }
12 }17 }
M internal/adapters/web/static/js/ermite.js +9 -1
@@ -731,13 +731,21 @@
731731 mark();
732732 }, { rootMargin: '0px 0px -67% 0px' });
733733 for (const f of files) io.observe(f);
734+ // A refreshed MR replaced the rail and the windowed diff. Rebuild once,
735+ // not once per file (which would make a large file list quadratic).
736+ ermite.refreshFiles = () => {
737+ io.disconnect(); inBand.clear(); current = null;
738+ files = $$('.dfile'); links.clear();
739+ for (const a of $$('.files a[href^="#"]')) links.set(a.hash.slice(1), a);
740+ for (const f of files) io.observe(f);
741+ };
734742 // A window of files of a merge request came in (mr.js): its files replace
735743 // placeholders, which the observer and the list knew by element.
736744 ermite.watchFile = (f, old) => {
737745 if (old) { io.unobserve(old); inBand.delete(old); }
738746 files = $$('.dfile');
739747 for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
740- if (current && !current.isConnected) current = document.getElementById(current.id);
748+ if (current && !current.isConnected) current = null;
741749 io.observe(f);
742750 };
743751 }
@@ -731,13 +731,21 @@
731 mark();731 mark();
732 }, { rootMargin: '0px 0px -67% 0px' });732 }, { rootMargin: '0px 0px -67% 0px' });
733 for (const f of files) io.observe(f);733 for (const f of files) io.observe(f);
734+ // A refreshed MR replaced the rail and the windowed diff. Rebuild once,
735+ // not once per file (which would make a large file list quadratic).
736+ ermite.refreshFiles = () => {
737+ io.disconnect(); inBand.clear(); current = null;
738+ files = $$('.dfile'); links.clear();
739+ for (const a of $$('.files a[href^="#"]')) links.set(a.hash.slice(1), a);
740+ for (const f of files) io.observe(f);
741+ };
734 // A window of files of a merge request came in (mr.js): its files replace742 // A window of files of a merge request came in (mr.js): its files replace
735 // placeholders, which the observer and the list knew by element.743 // placeholders, which the observer and the list knew by element.
736 ermite.watchFile = (f, old) => {744 ermite.watchFile = (f, old) => {
737 if (old) { io.unobserve(old); inBand.delete(old); }745 if (old) { io.unobserve(old); inBand.delete(old); }
738 files = $$('.dfile');746 files = $$('.dfile');
739 for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);747 for (const x of [...inBand]) if (!x.isConnected) inBand.delete(x);
740- if (current && !current.isConnected) current = document.getElementById(current.id);748+ if (current && !current.isConnected) current = null;
741 io.observe(f);749 io.observe(f);
742 };750 };
743 }751 }
M internal/adapters/web/static/js/mr.js +254 -8
@@ -84,11 +84,13 @@ window.ermiteMR = (() => {
8484 let refreshTouchComments = () => {};
8585 let moved = false;
8686 let lazyIO = null; // the observer of the placeholders (watch)
87+ let diffGeneration = 0;
8788 let revealGen = 0; // the last reveal wins the scroll
8889
8990 // load fetches the window of a placeholder and swaps its files in. The
9091 // promise resolves once they are in the page (or the page said to reload).
9192 function load(ph) {
93+ const generation = diffGeneration;
9294 const src = ph?.dataset.src;
9395 if (!src || moved) return Promise.resolve();
9496 if (loading.has(src)) return loading.get(src);
@@ -102,12 +104,14 @@ window.ermiteMR = (() => {
102104 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
103105 .then(async (res) => {
104106 const html = await res.text();
107+ if (generation !== diffGeneration) return;
105108 if (res.status === 409) return stale(html);
106109 if (!res.ok) throw new Error('loading files: ' + res.status);
107110 // A window of the old diff after the page went stale stays out.
108111 if (!moved) swap(html);
109112 })
110113 .catch((err) => {
114+ if (generation !== diffGeneration) return;
111115 loading.delete(src);
112116 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
113117 window.ermite?.flash?.(err.message, 4000);
@@ -195,6 +199,7 @@ window.ermiteMR = (() => {
195199 // that come in are marked in the list of files (ermite.js marks the one in
196200 // view).
197201 function watch() {
202+ lazyIO?.disconnect();
198203 if (!('IntersectionObserver' in window)) {
199204 for (const p of $$('.dlazy[data-src]')) load(p);
200205 return;
@@ -309,7 +314,9 @@ window.ermiteMR = (() => {
309314 }
310315
311316 // Enhance the ordinary anchor navigation only when scripts are available.
317+ let disposePanels = () => {};
312318 function panels() {
319+ disposePanels();
313320 const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');
314321 if (!footer || !nav) return;
315322 const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
@@ -342,20 +349,30 @@ window.ermiteMR = (() => {
342349 footer.open = true; select(index);
343350 }
344351 // A repeated click on the same hash must also reopen a folded footer.
345- document.addEventListener('click', (e) => {
352+ const checksClick = (e) => {
346353 if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }
347- });
354+ };
355+ document.addEventListener('click', checksClick);
356+ let observer;
348357 const summary = $('#mr-ck');
349358 if (summary) {
350359 const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; };
351- new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true });
360+ observer = new MutationObserver(sync);
361+ observer.observe(summary.parentElement, { childList: true, subtree: true, characterData: true });
352362 sync();
353363 }
354364 select(0); hash();
355365 addEventListener('hashchange', hash);
366+ disposePanels = () => {
367+ document.removeEventListener?.('click', checksClick);
368+ window.removeEventListener?.('hashchange', hash);
369+ observer?.disconnect?.();
370+ };
356371 }
357372
358- function touchComments() {
373+ let disposeTouch = () => {};
374+ function touchComments(scroll = true) {
375+ disposeTouch();
359376 const diffs = $('#diffs');
360377 const bar = document.createElement('div');
361378 bar.className = 'mr-touch-range';
@@ -392,23 +409,252 @@ window.ermiteMR = (() => {
392409 marker.setAttribute('data-range-start', '');
393410 status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
394411 });
395- touch?.addEventListener('change', () => {
412+ const changed = () => {
396413 if (touch.matches) return;
397414 enabled = false;
398415 clear();
399416 button.setAttribute('aria-pressed', 'false');
400417 status.textContent = '';
401- });
418+ };
419+ touch?.addEventListener('change', changed);
420+ disposeTouch = () => { touch?.removeEventListener?.('change', changed); bar.remove?.(); };
402421 // The server opens the line form. Scroll after layout without stealing
403422 // focus or forcing a mobile keyboard to appear.
404- if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
423+ if (scroll && new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
405424 const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
406425 form?.scrollIntoView({ block: 'center' });
407426 });
408427 }
409428
429+ // A refresh scheduler has one request in flight, a trailing debounce, and
430+ // 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.
432+ function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
433+ let timer, busy = false, pending = false, force = false;
434+ const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
435+ async function run() {
436+ if (busy || !pending) return;
437+ if (!force && blocked()) { notice(true); return; }
438+ busy = true;
439+ pending = false;
440+ const manual = force; force = false;
441+ try {
442+ const page = await fetchPage();
443+ if (!manual && blocked()) { pending = true; notice(true); return; }
444+ if (await apply(page, manual) === false) { pending = true; notice(true); }
445+ else notice(false);
446+ } catch { failed(); }
447+ finally {
448+ busy = false;
449+ if (pending && (force || !blocked())) schedule();
450+ }
451+ }
452+ return {
453+ request(manual = false) { pending = true; force ||= manual; schedule(); },
454+ resume() { if (pending) schedule(); },
455+ pending() { return pending || busy; },
456+ };
457+ }
458+
459+ function hasDraft() {
460+ return $$('textarea').some((t) => (t.value.length > 0 && t.value !== t.defaultValue) || document.activeElement === t) ||
461+ !!document.activeElement?.closest?.('.cform');
462+ }
463+
464+ // Path + side + line is stable when files are inserted before this one;
465+ // fN is only a positional anchor. Hidden unified/split rows are ignored.
466+ function scrollAnchor() {
467+ const top = Math.max(0, $('.hdr')?.getBoundingClientRect().bottom || 0,
468+ $('.mr-bar')?.getBoundingClientRect().bottom || 0);
469+ const diffs = $('#diffs');
470+ const bounds = diffs.getBoundingClientRect();
471+ 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.
473+ for (const y of [top + 2, top + 24, top + 64, top + 128]) {
474+ if (y >= innerHeight) break;
475+ const hit = document.elementFromPoint(x, y);
476+ const file = hit?.closest('details.dfile');
477+ if (!file || !diffs.contains(file)) continue;
478+ const row = hit.closest('tr');
479+ const link = row && $('.ln a[href*="on="]', row);
480+ 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 };
485+ }
486+ return { y: scrollY };
487+ }
488+
489+ function restoreAnchor(anchor) {
490+ const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
491+ let el, offset = anchor.offset;
492+ 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;
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;
500+ if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
501+ }
502+ if (!el && file) { el = $(':scope > summary', file); offset = anchor.headerOffset ?? anchor.offset; }
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' });
505+ }
506+
507+ async function fetchPage() {
508+ const res = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
509+ if (!res.ok || res.redirected) throw new Error('MR refresh refused');
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');
512+ return page;
513+ }
514+
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.
517+ async function replaceRegion(old, fresh) {
518+ if (!old || !fresh) throw new Error('missing MR refresh region');
519+ 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 });
522+ }
523+
524+ async function refreshPage(page, manual = false) {
525+ const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '.rail', '.hint-live'];
526+ for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
527+ let anchor = scrollAnchor();
528+ const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
529+ const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
530+ if (placeholder) {
531+ const d = $('#diffs').dataset;
532+ 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' } });
534+ 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;
539+ anchor = scrollAnchor();
540+ }
541+ const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
542+ const folded = $('.r-files')?.hasAttribute('data-folded');
543+ const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
544+ const footerOpen = $('#mr-panels')?.open;
545+ const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;
546+ 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 })));
577+ 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();
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';
583+ for (const sel of regions) {
584+ const fresh = $(sel, page);
585+ 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+ }
592+ for (const key of ['ref', 'sha', 'paths', 'blob']) {
593+ if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
594+ }
595+ const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
596+ if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
597+ panels();
598+ if (tab) {
599+ const oldURL = location.href;
600+ document.getElementById(tab)?.click();
601+ if (location.href !== oldURL) history.replaceState(history.state, '', oldURL);
602+ }
603+ if ($('#mr-panels')) $('#mr-panels').open = footerOpen;
604+ foldFiles(!!folded);
605+ if ($('.rail')) $('.rail').scrollTop = railY;
606+ if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;
607+ touchComments(false);
608+ $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
609+ window.ermite?.refreshFiles?.();
610+ watch();
611+ 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 });
616+ }
617+ restoreAnchor(anchor);
618+ }
619+
620+ 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';
628+ slot.replaceChildren();
629+ if (!deferred) {
630+ const text = document.createElement('span'); text.textContent = 'branch updated'; slot.append(text);
631+ return;
632+ }
633+ 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);
637+ }
638+ const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
639+ failed: () => { if (hasDraft()) refreshNotice(true); else window.ermiteLive?.reload(); } });
640+ function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
641+
410642 function start() {
411643 if (!$('#diffs[data-mr]')) return;
644+ // Global delegation is installed once; replacement regions have their own
645+ // disposable panel/touch hooks below.
646+ if ($('#diffs').dataset.mrStarted) return;
647+ $('#diffs').dataset.mrStarted = 'true';
648+ for (const type of ['input', 'focusout', 'reset', 'submit']) document.addEventListener?.(type, () => headRefresh.resume());
649+ document.addEventListener?.('click', (e) => {
650+ const cancel = e.target.closest?.('.cform .f-btns a');
651+ if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
652+ e.preventDefault();
653+ const form = cancel.closest('.cform');
654+ for (const t of $$('textarea', form)) t.value = '';
655+ if (form.contains(document.activeElement)) document.activeElement.blur();
656+ headRefresh.resume();
657+ });
412658 panels();
413659 // Large diffs may contain only the split table. Ask the existing server
414660 // route for unified rather than writing the saved session preference.
@@ -457,7 +703,7 @@ window.ermiteMR = (() => {
457703 });
458704 }
459705
460- return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
706+ return { core, refresher, hasDraft, fetchPage, scrollAnchor, restoreAnchor, headChanged, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
461707 })();
462708
463709 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
@@ -84,11 +84,13 @@ window.ermiteMR = (() => {
84 let refreshTouchComments = () => {};84 let refreshTouchComments = () => {};
85 let moved = false;85 let moved = false;
86 let lazyIO = null; // the observer of the placeholders (watch)86 let lazyIO = null; // the observer of the placeholders (watch)
87+ let diffGeneration = 0;
87 let revealGen = 0; // the last reveal wins the scroll88 let revealGen = 0; // the last reveal wins the scroll
88 89
89 // load fetches the window of a placeholder and swaps its files in. The90 // load fetches the window of a placeholder and swaps its files in. The
90 // promise resolves once they are in the page (or the page said to reload).91 // promise resolves once they are in the page (or the page said to reload).
91 function load(ph) {92 function load(ph) {
93+ const generation = diffGeneration;
92 const src = ph?.dataset.src;94 const src = ph?.dataset.src;
93 if (!src || moved) return Promise.resolve();95 if (!src || moved) return Promise.resolve();
94 if (loading.has(src)) return loading.get(src);96 if (loading.has(src)) return loading.get(src);
@@ -102,12 +104,14 @@ window.ermiteMR = (() => {
102 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })104 const p = fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' })
103 .then(async (res) => {105 .then(async (res) => {
104 const html = await res.text();106 const html = await res.text();
107+ if (generation !== diffGeneration) return;
105 if (res.status === 409) return stale(html);108 if (res.status === 409) return stale(html);
106 if (!res.ok) throw new Error('loading files: ' + res.status);109 if (!res.ok) throw new Error('loading files: ' + res.status);
107 // A window of the old diff after the page went stale stays out.110 // A window of the old diff after the page went stale stays out.
108 if (!moved) swap(html);111 if (!moved) swap(html);
109 })112 })
110 .catch((err) => {113 .catch((err) => {
114+ if (generation !== diffGeneration) return;
111 loading.delete(src);115 loading.delete(src);
112 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');116 for (const p of $$('.dlazy[data-loading]')) if (p.dataset.src === src) p.removeAttribute('data-loading');
113 window.ermite?.flash?.(err.message, 4000);117 window.ermite?.flash?.(err.message, 4000);
@@ -195,6 +199,7 @@ window.ermiteMR = (() => {
195 // that come in are marked in the list of files (ermite.js marks the one in199 // that come in are marked in the list of files (ermite.js marks the one in
196 // view).200 // view).
197 function watch() {201 function watch() {
202+ lazyIO?.disconnect();
198 if (!('IntersectionObserver' in window)) {203 if (!('IntersectionObserver' in window)) {
199 for (const p of $$('.dlazy[data-src]')) load(p);204 for (const p of $$('.dlazy[data-src]')) load(p);
200 return;205 return;
@@ -309,7 +314,9 @@ window.ermiteMR = (() => {
309 }314 }
310 315
311 // Enhance the ordinary anchor navigation only when scripts are available.316 // Enhance the ordinary anchor navigation only when scripts are available.
317+ let disposePanels = () => {};
312 function panels() {318 function panels() {
319+ disposePanels();
313 const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');320 const footer = $('#mr-panels'), nav = $('[data-mr-tabs]');
314 if (!footer || !nav) return;321 if (!footer || !nav) return;
315 const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);322 const tabs = $$('a', nav), panels = $$('[data-mr-panel]', footer);
@@ -342,20 +349,30 @@ window.ermiteMR = (() => {
342 footer.open = true; select(index);349 footer.open = true; select(index);
343 }350 }
344 // A repeated click on the same hash must also reopen a folded footer.351 // A repeated click on the same hash must also reopen a folded footer.
345- document.addEventListener('click', (e) => {352+ const checksClick = (e) => {
346 if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }353 if (e.target.closest?.('#mr-ck')) { footer.open = true; select(1); }
347- });354+ };
355+ document.addEventListener('click', checksClick);
356+ let observer;
348 const summary = $('#mr-ck');357 const summary = $('#mr-ck');
349 if (summary) {358 if (summary) {
350 const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; };359 const sync = () => { $('[data-checks-label]', nav).textContent = $('#mr-ck')?.title || $('#mr-ck')?.textContent.trim() || 'no checks'; };
351- new MutationObserver(sync).observe(summary.parentElement, { childList: true, subtree: true, characterData: true });360+ observer = new MutationObserver(sync);
361+ observer.observe(summary.parentElement, { childList: true, subtree: true, characterData: true });
352 sync();362 sync();
353 }363 }
354 select(0); hash();364 select(0); hash();
355 addEventListener('hashchange', hash);365 addEventListener('hashchange', hash);
366+ disposePanels = () => {
367+ document.removeEventListener?.('click', checksClick);
368+ window.removeEventListener?.('hashchange', hash);
369+ observer?.disconnect?.();
370+ };
356 }371 }
357 372
358- function touchComments() {373+ let disposeTouch = () => {};
374+ function touchComments(scroll = true) {
375+ disposeTouch();
359 const diffs = $('#diffs');376 const diffs = $('#diffs');
360 const bar = document.createElement('div');377 const bar = document.createElement('div');
361 bar.className = 'mr-touch-range';378 bar.className = 'mr-touch-range';
@@ -392,23 +409,252 @@ window.ermiteMR = (() => {
392 marker.setAttribute('data-range-start', '');409 marker.setAttribute('data-range-start', '');
393 status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;410 status.textContent = `Range starts at line ${link.textContent.trim()}. Tap the last line number (same file and side).`;
394 });411 });
395- touch?.addEventListener('change', () => {412+ const changed = () => {
396 if (touch.matches) return;413 if (touch.matches) return;
397 enabled = false;414 enabled = false;
398 clear();415 clear();
399 button.setAttribute('aria-pressed', 'false');416 button.setAttribute('aria-pressed', 'false');
400 status.textContent = '';417 status.textContent = '';
401- });418+ };
419+ touch?.addEventListener('change', changed);
420+ disposeTouch = () => { touch?.removeEventListener?.('change', changed); bar.remove?.(); };
402 // The server opens the line form. Scroll after layout without stealing421 // The server opens the line form. Scroll after layout without stealing
403 // focus or forcing a mobile keyboard to appear.422 // focus or forcing a mobile keyboard to appear.
404- if (new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {423+ if (scroll && new URL(location.href).searchParams.has('on')) requestAnimationFrame(() => {
405 const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);424 const form = $$('.fnote .cform', diffs).find((f) => f.getClientRects().length);
406 form?.scrollIntoView({ block: 'center' });425 form?.scrollIntoView({ block: 'center' });
407 });426 });
408 }427 }
409 428
429+ // A refresh scheduler has one request in flight, a trailing debounce, and
430+ // 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.
432+ function refresher({ blocked, fetchPage, apply, notice, failed, delay = 300 }) {
433+ let timer, busy = false, pending = false, force = false;
434+ const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); };
435+ async function run() {
436+ if (busy || !pending) return;
437+ if (!force && blocked()) { notice(true); return; }
438+ busy = true;
439+ pending = false;
440+ const manual = force; force = false;
441+ try {
442+ const page = await fetchPage();
443+ if (!manual && blocked()) { pending = true; notice(true); return; }
444+ if (await apply(page, manual) === false) { pending = true; notice(true); }
445+ else notice(false);
446+ } catch { failed(); }
447+ finally {
448+ busy = false;
449+ if (pending && (force || !blocked())) schedule();
450+ }
451+ }
452+ return {
453+ request(manual = false) { pending = true; force ||= manual; schedule(); },
454+ resume() { if (pending) schedule(); },
455+ pending() { return pending || busy; },
456+ };
457+ }
458+
459+ function hasDraft() {
460+ return $$('textarea').some((t) => (t.value.length > 0 && t.value !== t.defaultValue) || document.activeElement === t) ||
461+ !!document.activeElement?.closest?.('.cform');
462+ }
463+
464+ // Path + side + line is stable when files are inserted before this one;
465+ // fN is only a positional anchor. Hidden unified/split rows are ignored.
466+ function scrollAnchor() {
467+ const top = Math.max(0, $('.hdr')?.getBoundingClientRect().bottom || 0,
468+ $('.mr-bar')?.getBoundingClientRect().bottom || 0);
469+ const diffs = $('#diffs');
470+ const bounds = diffs.getBoundingClientRect();
471+ 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.
473+ for (const y of [top + 2, top + 24, top + 64, top + 128]) {
474+ if (y >= innerHeight) break;
475+ const hit = document.elementFromPoint(x, y);
476+ const file = hit?.closest('details.dfile');
477+ if (!file || !diffs.contains(file)) continue;
478+ const row = hit.closest('tr');
479+ const link = row && $('.ln a[href*="on="]', row);
480+ 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 };
485+ }
486+ return { y: scrollY };
487+ }
488+
489+ function restoreAnchor(anchor) {
490+ const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file);
491+ let el, offset = anchor.offset;
492+ 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;
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;
500+ if (suffix) el = $('a[href$="' + CSS.escape(suffix) + '"]', table)?.closest('tr');
501+ }
502+ if (!el && file) { el = $(':scope > summary', file); offset = anchor.headerOffset ?? anchor.offset; }
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' });
505+ }
506+
507+ async function fetchPage() {
508+ const res = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
509+ if (!res.ok || res.redirected) throw new Error('MR refresh refused');
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');
512+ return page;
513+ }
514+
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.
517+ async function replaceRegion(old, fresh) {
518+ if (!old || !fresh) throw new Error('missing MR refresh region');
519+ 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 });
522+ }
523+
524+ async function refreshPage(page, manual = false) {
525+ const regions = ['.mr-footer', '#mr-range', '.mr-bar', '#mr-diff-notices', '#diffs', '.rail', '.hint-live'];
526+ for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region');
527+ let anchor = scrollAnchor();
528+ const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file);
529+ const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile);
530+ if (placeholder) {
531+ const d = $('#diffs').dataset;
532+ 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' } });
534+ 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;
539+ anchor = scrollAnchor();
540+ }
541+ const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open]));
542+ const folded = $('.r-files')?.hasAttribute('data-folded');
543+ const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop;
544+ const footerOpen = $('#mr-panels')?.open;
545+ const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id;
546+ 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 })));
577+ 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();
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';
583+ for (const sel of regions) {
584+ const fresh = $(sel, page);
585+ 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+ }
592+ for (const key of ['ref', 'sha', 'paths', 'blob']) {
593+ if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key];
594+ }
595+ const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page);
596+ if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent;
597+ panels();
598+ if (tab) {
599+ const oldURL = location.href;
600+ document.getElementById(tab)?.click();
601+ if (location.href !== oldURL) history.replaceState(history.state, '', oldURL);
602+ }
603+ if ($('#mr-panels')) $('#mr-panels').open = footerOpen;
604+ foldFiles(!!folded);
605+ if ($('.rail')) $('.rail').scrollTop = railY;
606+ if ($('.r-files .files')) $('.r-files .files').scrollTop = filesY;
607+ touchComments(false);
608+ $('#diffs').dataset.mobile = String(!!window.matchMedia?.('(max-width: 720px)').matches);
609+ window.ermite?.refreshFiles?.();
610+ watch();
611+ 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 });
616+ }
617+ restoreAnchor(anchor);
618+ }
619+
620+ 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';
628+ slot.replaceChildren();
629+ if (!deferred) {
630+ const text = document.createElement('span'); text.textContent = 'branch updated'; slot.append(text);
631+ return;
632+ }
633+ 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);
637+ }
638+ const headRefresh = refresher({ blocked: hasDraft, fetchPage, apply: refreshPage, notice: refreshNotice,
639+ failed: () => { if (hasDraft()) refreshNotice(true); else window.ermiteLive?.reload(); } });
640+ function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); }
641+
410 function start() {642 function start() {
411 if (!$('#diffs[data-mr]')) return;643 if (!$('#diffs[data-mr]')) return;
644+ // Global delegation is installed once; replacement regions have their own
645+ // disposable panel/touch hooks below.
646+ if ($('#diffs').dataset.mrStarted) return;
647+ $('#diffs').dataset.mrStarted = 'true';
648+ for (const type of ['input', 'focusout', 'reset', 'submit']) document.addEventListener?.(type, () => headRefresh.resume());
649+ document.addEventListener?.('click', (e) => {
650+ const cancel = e.target.closest?.('.cform .f-btns a');
651+ if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
652+ e.preventDefault();
653+ const form = cancel.closest('.cform');
654+ for (const t of $$('textarea', form)) t.value = '';
655+ if (form.contains(document.activeElement)) document.activeElement.blur();
656+ headRefresh.resume();
657+ });
412 panels();658 panels();
413 // Large diffs may contain only the split table. Ask the existing server659 // Large diffs may contain only the split table. Ask the existing server
414 // route for unified rather than writing the saved session preference.660 // route for unified rather than writing the saved session preference.
@@ -457,7 +703,7 @@ window.ermiteMR = (() => {
457 });703 });
458 }704 }
459 705
460- return { core, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };706+ return { core, refresher, hasDraft, fetchPage, scrollAnchor, restoreAnchor, headChanged, panels, start, load, reveal, openMerge, menuKey, markRead, focusFiles, stepFiles, leaveFiles, foldFiles, openFile };
461 })();707 })();
462 708
463 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());709 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => window.ermiteMR.start());
M internal/app/testdata/web/commits-root.html +1 -0
@@ -973,6 +973,7 @@
973973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974974 htmx:sse:before:connection -> ermiteLive.gate(event);
975975 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976+ mr-head -> ermiteMR.headChanged();
976977 gap -> ermiteLive.reload()">
977978 </span>
978979 </div>
@@ -973,6 +973,7 @@
973 htmx:sse:error -> ermiteLive.error(event.detail.status);973 htmx:sse:error -> ermiteLive.error(event.detail.status);
974 htmx:sse:before:connection -> ermiteLive.gate(event);974 htmx:sse:before:connection -> ermiteLive.gate(event);
975 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);975 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
976+ mr-head -> ermiteMR.headChanged();
976 gap -> ermiteLive.reload()">977 gap -> ermiteLive.reload()">
977 </span>978 </span>
978 </div>979 </div>
M internal/app/testdata/web/commits-small.html +1 -0
@@ -318,6 +318,7 @@
318318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319319 htmx:sse:before:connection -> ermiteLive.gate(event);
320320 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321+ mr-head -> ermiteMR.headChanged();
321322 gap -> ermiteLive.reload()">
322323 </span>
323324 </div>
@@ -318,6 +318,7 @@
318 htmx:sse:error -> ermiteLive.error(event.detail.status);318 htmx:sse:error -> ermiteLive.error(event.detail.status);
319 htmx:sse:before:connection -> ermiteLive.gate(event);319 htmx:sse:before:connection -> ermiteLive.gate(event);
320 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);320 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
321+ mr-head -> ermiteMR.headChanged();
321 gap -> ermiteLive.reload()">322 gap -> ermiteLive.reload()">
322 </span>323 </span>
323 </div>324 </div>
M internal/app/testdata/web/home.html +1 -0
@@ -194,6 +194,7 @@
194194 htmx:sse:error -> ermiteLive.error(event.detail.status);
195195 htmx:sse:before:connection -> ermiteLive.gate(event);
196196 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
197+ mr-head -> ermiteMR.headChanged();
197198 gap -> ermiteLive.reload()">
198199 </span>
199200 </div>
@@ -194,6 +194,7 @@
194 htmx:sse:error -> ermiteLive.error(event.detail.status);194 htmx:sse:error -> ermiteLive.error(event.detail.status);
195 htmx:sse:before:connection -> ermiteLive.gate(event);195 htmx:sse:before:connection -> ermiteLive.gate(event);
196 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);196 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
197+ mr-head -> ermiteMR.headChanged();
197 gap -> ermiteLive.reload()">198 gap -> ermiteLive.reload()">
198 </span>199 </span>
199 </div>200 </div>
M internal/app/testdata/web/refs.html +1 -0
@@ -200,6 +200,7 @@
200200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201201 htmx:sse:before:connection -> ermiteLive.gate(event);
202202 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203+ mr-head -> ermiteMR.headChanged();
203204 gap -> ermiteLive.reload()">
204205 </span>
205206 </div>
@@ -200,6 +200,7 @@
200 htmx:sse:error -> ermiteLive.error(event.detail.status);200 htmx:sse:error -> ermiteLive.error(event.detail.status);
201 htmx:sse:before:connection -> ermiteLive.gate(event);201 htmx:sse:before:connection -> ermiteLive.gate(event);
202 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);202 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
203+ mr-head -> ermiteMR.headChanged();
203 gap -> ermiteLive.reload()">204 gap -> ermiteLive.reload()">
204 </span>205 </span>
205 </div>206 </div>
M internal/app/testdata/web/summary.html +1 -0
@@ -226,6 +226,7 @@
226226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227227 htmx:sse:before:connection -> ermiteLive.gate(event);
228228 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229+ mr-head -> ermiteMR.headChanged();
229230 gap -> ermiteLive.reload()">
230231 </span>
231232 </div>
@@ -226,6 +226,7 @@
226 htmx:sse:error -> ermiteLive.error(event.detail.status);226 htmx:sse:error -> ermiteLive.error(event.detail.status);
227 htmx:sse:before:connection -> ermiteLive.gate(event);227 htmx:sse:before:connection -> ermiteLive.gate(event);
228 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);228 home-list, repo-last-push, refs-list, commits-head, mr-notice, mr-mergeability, ci-run, ci-job, inbox -> ermiteLive.swap(event.detail.data);
229+ mr-head -> ermiteMR.headChanged();
229 gap -> ermiteLive.reload()">230 gap -> ermiteLive.reload()">
230 </span>231 </span>
231 </div>232 </div>
M internal/app/web_mr_test.go +3 -2
@@ -595,8 +595,9 @@ func TestWebMergeRequestEvents(t *testing.T) {
595595
596596 r.writeFile(r.wt, "more.txt", []byte("more\n"))
597597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
598- ev = nextNamed(t, one, "mr-notice")
599- assert.Contains(t, ev.data, "the branch changed")
598+ ev = nextNamed(t, one, "mr-head")
599+ assert.Equal(t, `<div id="mr-head"></div>`, ev.data)
600+ assert.NotContains(t, ev.data, "the branch changed")
600601
601602 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
602603 assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")
@@ -595,8 +595,9 @@ func TestWebMergeRequestEvents(t *testing.T) {
595 595
596 r.writeFile(r.wt, "more.txt", []byte("more\n"))596 r.writeFile(r.wt, "more.txt", []byte("more\n"))
597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")597 r.mustGit(r.wt, "push", "-q", r.url, "feat/search")
598- ev = nextNamed(t, one, "mr-notice")598+ ev = nextNamed(t, one, "mr-head")
599- assert.Contains(t, ev.data, "the branch changed")599+ assert.Equal(t, `<div id="mr-head"></div>`, ev.data)
600+ assert.NotContains(t, ev.data, "the branch changed")
600 601
601 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)602 r.cli(t, adminToken, "mr", "close", "proj", "1").ok(t)
602 assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")603 assert.Contains(t, nextNamed(t, one, "mr-notice").data, "was closed")

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 42

↑↓ moveenter openesc close