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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 128 | 128 | anchor's window is fetched too if needed, and other windows remain lazy. | |
| 129 | 129 | ||
| 130 | 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. | |
| 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 small | 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 | 131 | + | “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. Submitting | 133 | + | 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) { | |||
| 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 | + | } | |
| @@ -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 | |||
| 304 | 304 | // Deterministic timers: no sleeps, and each test restores every mocked global. | |
| 305 | 305 | function clock() { | |
| 306 | 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; }; | |
| 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; }; | |
| 309 | 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 | 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 | 336 | apply: () => applied++, notice() {}, failed: () => assert.fail('unexpected failure') }); | |
| 337 | 337 | try { | |
| 338 | 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 | 342 | r.request(); await c.tick(); assert.equal(fetched, 1, 'one fetch in flight'); | |
| 341 | 343 | resolve('one'); await first; assert.equal(applied, 1); assert.equal(c.size(), 1); | |
| 342 | 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 | 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 | 350 | const c = clock(); let blocked = false, applied = 0, resolve; const notices = []; | |
| 349 | 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 | 353 | notice: (v) => notices.push(v), failed: () => assert.fail('unexpected failure') }); | |
| 352 | 354 | try { | |
| 353 | 355 | r.request(); const first = c.tick(); blocked = true; resolve('page'); await first; | |
| 354 | 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 | 359 | assert.equal(applied, 1); assert.equal(notices.at(-1), false); | |
| 357 | 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 | 389 | let lineTop = 160, headerTop = -200, found = true; const calls = []; | |
| 387 | 390 | const header = { getBoundingClientRect: () => rect(headerTop) }; | |
| 388 | 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 }; | |
| 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 }; | |
| 391 | 394 | const hit = { closest: (s) => s === 'tr' ? row : file }; | |
| 392 | 395 | const diffs = { dataset: { diff: 'unified' }, getBoundingClientRect: () => rect(120), contains: () => true }; | |
| 393 | 396 | globalThis.document = { | |
| @@ -408,3 +411,45 @@ test('viewport probe finds a stable path/side/line and restores its offset witho | |||
| 408 | 411 | assert.deepEqual(calls.pop(), ['to', { top: 9200, behavior: 'instant' }]); | |
| 409 | 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 | + | }); | |
| @@ -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) { | |||
| 13 | 13 | @Shell(l, mrListRail(p)) { | |
| 14 | 14 | if p.Live { | |
| 15 | 15 | @LiveSlot("mr-notice") | |
| 16 | + | <div id="mr-refresh-status" role="status" aria-live="polite"></div> | |
| 16 | 17 | } | |
| 17 | 18 | <div class="filter"> | |
| 18 | 19 | <label for="mr-filter" class="sr">filter</label> | |
| @@ -157,6 +158,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 157 | 158 | <p class="d-err" role="alert">{ p.MergeError }</p> | |
| 158 | 159 | } | |
| 159 | 160 | @LiveSlot("mr-notice") | |
| 161 | + | <div id="mr-refresh-status" role="status" aria-live="polite"></div> | |
| 160 | 162 | <div id="mr-range" hidden?={ p.Since == nil && p.SinceNote == "" }> | |
| 161 | 163 | if p.Since != nil { | |
| 162 | 164 | <nav class="since" aria-label="Diff range"> | |
| @@ -212,6 +214,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 212 | 214 | <p class="note">no changed files.</p> | |
| 213 | 215 | } | |
| 214 | 216 | </div> | |
| 217 | + | <div id="mr-comments"> | |
| 215 | 218 | <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(p.NUnresolved) } open / { itoa(p.NComments) }</span></h2> | |
| 216 | 219 | for _, c := range p.Loose { | |
| 217 | 220 | @commentView(c, "") | |
| @@ -225,6 +228,7 @@ templ MergeRequest(l Layout, p MRPage) { | |||
| 225 | 228 | if p.GeneralForm != nil { | |
| 226 | 229 | @commentForm(*p.GeneralForm, "") | |
| 227 | 230 | } | |
| 231 | + | </div> | |
| 228 | 232 | } | |
| 229 | 233 | } | |
| 230 | 234 | ||
| @@ -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 = (() => { | |||
| 965 | 965 | reload() { | |
| 966 | 966 | if (reloading) return; | |
| 967 | 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 | 973 | // error handles htmx:sse:error (detail.status is the status of a refused | |
| 971 | 974 | // 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 refused | 973 | // 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 by | 974 | // 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 = (() => { | |||
| 430 | 430 | // a dirty bit for pushes received during that request. Drafts are checked | |
| 431 | 431 | // again after the fetch: typing while the network is slow is safe too. | |
| 432 | 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 | 434 | const schedule = () => { clearTimeout(timer); timer = setTimeout(run, delay); }; | |
| 435 | 435 | async function run() { | |
| 436 | 436 | if (busy || !pending) return; | |
| 437 | - | if (!force && blocked()) { notice(true); return; } | |
| 437 | + | if (blocked()) { notice(true); return; } | |
| 438 | 438 | busy = true; | |
| 439 | 439 | pending = false; | |
| 440 | - | const manual = force; force = false; | |
| 441 | 440 | try { | |
| 442 | 441 | 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); } | |
| 445 | 444 | else notice(false); | |
| 446 | - | } catch { failed(); } | |
| 445 | + | } catch { if (blocked()) { pending = true; notice(true); } else failed(); } | |
| 447 | 446 | finally { | |
| 448 | 447 | busy = false; | |
| 449 | - | if (pending && (force || !blocked())) schedule(); | |
| 448 | + | if (pending && !blocked()) schedule(); | |
| 450 | 449 | } | |
| 451 | 450 | } | |
| 452 | 451 | return { | |
| 453 | - | request(manual = false) { pending = true; force ||= manual; schedule(); }, | |
| 452 | + | request() { pending = true; schedule(); }, | |
| 454 | 453 | resume() { if (pending) schedule(); }, | |
| 455 | 454 | pending() { return pending || busy; }, | |
| 456 | 455 | }; | |
| 457 | 456 | } | |
| 458 | 457 | ||
| 459 | 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 | 460 | !!document.activeElement?.closest?.('.cform'); | |
| 462 | 461 | } | |
| 463 | 462 | ||
| @@ -470,19 +469,24 @@ window.ermiteMR = (() => { | |||
| 470 | 469 | const bounds = diffs.getBoundingClientRect(); | |
| 471 | 470 | const x = Math.max(1, Math.min(innerWidth - 1, bounds.left + Math.min(100, bounds.width / 2))); | |
| 472 | 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]) { | |
| 474 | - | if (y >= innerHeight) break; | |
| 472 | + | let y = top + 2, fallback; | |
| 473 | + | for (let probe = 0; probe < 6 && y < innerHeight; probe++) { | |
| 475 | 474 | const hit = document.elementFromPoint(x, y); | |
| 476 | 475 | const file = hit?.closest('details.dfile'); | |
| 477 | - | if (!file || !diffs.contains(file)) continue; | |
| 476 | + | if (!file || !diffs.contains(file)) { y += 32; continue; } | |
| 478 | 477 | const row = hit.closest('tr'); | |
| 479 | 478 | const link = row && $('.ln a[href*="on="]', row); | |
| 480 | 479 | 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; | |
| 485 | 488 | } | |
| 489 | + | if (fallback) return fallback; | |
| 486 | 490 | return { y: scrollY }; | |
| 487 | 491 | } | |
| 488 | 492 | ||
| @@ -490,110 +494,92 @@ window.ermiteMR = (() => { | |||
| 490 | 494 | const file = $$('#diffs details.dfile').find((f) => f.dataset.file === anchor.file); | |
| 491 | 495 | let el, offset = anchor.offset; | |
| 492 | 496 | 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; | |
| 498 | 498 | const layout = $('#diffs').dataset.diff || $('#diffs').dataset.view; | |
| 499 | 499 | const table = $(layout === 'split' && !window.matchMedia?.('(max-width: 720px)').matches ? '.dt.spl' : '.dt.uni', file) || file; | |
| 500 | 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 | 503 | if (el) window.scrollBy({ top: el.getBoundingClientRect().top - offset, behavior: 'instant' }); | |
| 504 | 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() { | |
| 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' } }); | |
| 509 | 509 | if (!res.ok || res.redirected) throw new Error('MR refresh refused'); | |
| 510 | 510 | const page = new DOMParser().parseFromString(await res.text(), 'text/html'); | |
| 511 | 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 | 515 | return page; | |
| 513 | 516 | } | |
| 514 | 517 | ||
| 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. | |
| 517 | 521 | async function replaceRegion(old, fresh) { | |
| 518 | 522 | if (!old || !fresh) throw new Error('missing MR refresh region'); | |
| 519 | 523 | 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); | |
| 522 | 531 | } | |
| 523 | 532 | ||
| 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']; | |
| 526 | 535 | for (const sel of regions) if (!$(sel) || !$(sel, page)) throw new Error('missing MR region'); | |
| 527 | 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 | 543 | const anchorFile = $$('#diffs details.dfile', page).find((f) => f.dataset.file === anchor.file); | |
| 529 | 544 | const placeholder = anchorFile && $('.dlazy[data-src]', anchorFile); | |
| 530 | 545 | if (placeholder) { | |
| 531 | - | const d = $('#diffs').dataset; | |
| 546 | + | const d = nextDiffs.dataset; | |
| 532 | 547 | const url = placeholder.dataset.src + '&view=' + encodeURIComponent(d.diff || d.view || 'unified') + '&layouts=' + (d.diff ? 'both' : 'one'); | |
| 533 | 548 | const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'text/html' } }); | |
| 534 | 549 | 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; | |
| 539 | 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 | 563 | const files = new Map($$('#diffs details.dfile').map((f) => [f.dataset.file, f.open])); | |
| 542 | 564 | const folded = $('.r-files')?.hasAttribute('data-folded'); | |
| 543 | 565 | const railY = $('.rail')?.scrollTop, filesY = $('.r-files .files')?.scrollTop; | |
| 544 | 566 | const footerOpen = $('#mr-panels')?.open; | |
| 545 | 567 | const tab = $('[data-mr-tabs] [aria-selected="true"]')?.id; | |
| 546 | 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') }; | |
| 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') }; | |
| 577 | 570 | for (const f of $$('#diffs details.dfile', page)) if (files.has(f.dataset.file)) f.open = files.get(f.dataset.file); | |
| 578 | 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 | 572 | $('#diffs', page).dataset.mrStarted = 'true'; | |
| 583 | 573 | for (const sel of regions) { | |
| 584 | 574 | const fresh = $(sel, page); | |
| 585 | 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 | 577 | for (const key of ['ref', 'sha', 'paths', 'blob']) { | |
| 593 | 578 | if (page.body.dataset[key] !== undefined) document.body.dataset[key] = page.body.dataset[key]; | |
| 594 | 579 | } | |
| 595 | 580 | const oldSHA = $('.hdr .ref .sha'), newSHA = $('.hdr .ref .sha', page); | |
| 596 | 581 | if (oldSHA && newSHA) oldSHA.textContent = newSHA.textContent; | |
| 582 | + | page = null; | |
| 597 | 583 | panels(); | |
| 598 | 584 | if (tab) { | |
| 599 | 585 | const oldURL = location.href; | |
| @@ -609,38 +595,40 @@ window.ermiteMR = (() => { | |||
| 609 | 595 | window.ermite?.refreshFiles?.(); | |
| 610 | 596 | watch(); | |
| 611 | 597 | 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 }); | |
| 616 | 607 | } | |
| 617 | 608 | restoreAnchor(anchor); | |
| 618 | 609 | } | |
| 619 | 610 | ||
| 620 | 611 | 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; | |
| 628 | 614 | slot.replaceChildren(); | |
| 629 | 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 | 617 | return; | |
| 632 | 618 | } | |
| 633 | 619 | 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 | + | ||
| 637 | 621 | } | |
| 638 | 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 | 624 | function headChanged() { if ($('#diffs[data-mr]')) headRefresh.request(); } | |
| 641 | 625 | ||
| 642 | 626 | function start() { | |
| 643 | 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 | 632 | // Global delegation is installed once; replacement regions have their own | |
| 645 | 633 | // disposable panel/touch hooks below. | |
| 646 | 634 | if ($('#diffs').dataset.mrStarted) return; | |
| @@ -651,7 +639,7 @@ window.ermiteMR = (() => { | |||
| 651 | 639 | if (!cancel || !headRefresh.pending() || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; | |
| 652 | 640 | e.preventDefault(); | |
| 653 | 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 | 643 | if (form.contains(document.activeElement)) document.activeElement.blur(); | |
| 656 | 644 | headRefresh.resume(); | |
| 657 | 645 | }); | |
| @@ -430,34 +430,33 @@ window.ermiteMR = (() => { | |||||
| 430 | // a dirty bit for pushes received during that request. Drafts are checked | 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. | 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. URLSearchParams | 497 | + | 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 SSE | 518 | + | // 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 own | 632 | // 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 | }); | ||