fix(web): keep diff TOC, filters and cursor right after jumps and live updates
- the diff TOC tracks files in the band and scans for the file above it after a jump that skips the band - live updates re-apply an active filter and keep the cursor on its row - swap iterates a copy of the fragments - gg/G move the cursor; a repeated g restarts its timer - the permalink replaces the ref segment after the route and says when the page has nothing to pin
- commit
- ffb5f8d35a2975414cd2301f0f5285ca576342ec
- author
- brice · · 2026-10-11 20:49 UTC
- parent
- bde7c01
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +43 -21
M internal/adapters/web/static/js/ermite.js +43 -21
| @@ -66,8 +66,9 @@ | |||
| 66 | 66 | edge(last) { | |
| 67 | 67 | const rows = visibleRows(); | |
| 68 | 68 | const row = last ? rows.at(-1) : rows[0]; | |
| 69 | - | if (row) row.scrollIntoView({ block: 'nearest' }); | |
| 70 | - | else scrollPage(last ? 1e9 : 0); | |
| 69 | + | if (!row) return scrollPage(last ? 1e9 : 0); | |
| 70 | + | setCursor(row, $$('[data-rows] > [data-cursor]')); | |
| 71 | + | row.scrollIntoView({ block: 'nearest' }); | |
| 71 | 72 | }, | |
| 72 | 73 | open() { | |
| 73 | 74 | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | |
| @@ -99,17 +100,22 @@ | |||
| 99 | 100 | // cursor) and, optionally, the groups to hide when they have no row left | |
| 100 | 101 | // (data-groups). The cursor goes to the first row left. | |
| 101 | 102 | filter(input) { | |
| 102 | - | const sel = input.dataset.filter || input.dataset.pick; | |
| 103 | + | const rows = ermite.hide(input); | |
| 104 | + | if (input.dataset.filter) { | |
| 105 | + | const first = rows.find((r) => !r.hidden); | |
| 106 | + | for (const r of rows) r.toggleAttribute('data-cursor', r === first); | |
| 107 | + | } | |
| 108 | + | }, | |
| 109 | + | // hide applies the text of a filter input to its rows and groups, without | |
| 110 | + | // moving the cursor, and returns the rows. | |
| 111 | + | hide(input) { | |
| 103 | 112 | const t = input.value.toLowerCase(); | |
| 104 | - | const rows = $$(sel); | |
| 113 | + | const rows = $$(input.dataset.filter || input.dataset.pick); | |
| 105 | 114 | for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t); | |
| 106 | 115 | if (input.dataset.groups) { | |
| 107 | 116 | for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); | |
| 108 | 117 | } | |
| 109 | - | if (input.dataset.filter) { | |
| 110 | - | const first = rows.find((r) => !r.hidden); | |
| 111 | - | for (const r of rows) r.toggleAttribute('data-cursor', r === first); | |
| 112 | - | } | |
| 118 | + | return rows; | |
| 113 | 119 | }, | |
| 114 | 120 | // The "go to" palette. | |
| 115 | 121 | cmdFilter(input) { | |
| @@ -163,7 +169,11 @@ | |||
| 163 | 169 | async permalink() { | |
| 164 | 170 | const b = body().dataset; | |
| 165 | 171 | if (!b.sha) return flash('no ref to pin on this page'); | |
| 166 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 172 | + | const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| 173 | + | // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>. | |
| 174 | + | const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname; | |
| 175 | + | if (path === location.pathname) return flash('this page has no ref to pin'); | |
| 176 | + | history.replaceState(null, '', path + location.hash); | |
| 167 | 177 | try { | |
| 168 | 178 | await navigator.clipboard.writeText(location.href); | |
| 169 | 179 | flash('permalink ' + b.sha.slice(0, 7) + ' copied'); | |
| @@ -198,6 +208,7 @@ | |||
| 198 | 208 | // "g t" is the key "t": the tabs have one event each. | |
| 199 | 209 | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | |
| 200 | 210 | let pending = 0; | |
| 211 | + | let pendingTimer = 0; | |
| 201 | 212 | addEventListener('keydown', (e) => { | |
| 202 | 213 | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | |
| 203 | 214 | // ctrl k / cmd k opens the palette; in a field only cmd k, so that the | |
| @@ -216,7 +227,7 @@ | |||
| 216 | 227 | if (name) { e.preventDefault(); ermite.fire(name); } | |
| 217 | 228 | return; | |
| 218 | 229 | } | |
| 219 | - | if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; } | |
| 230 | + | if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; } | |
| 220 | 231 | const name = single[e.key]; | |
| 221 | 232 | if (!name) return; | |
| 222 | 233 | if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault(); | |
| @@ -243,17 +254,19 @@ | |||
| 243 | 254 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | |
| 244 | 255 | ||
| 245 | 256 | // 4. The table of contents of a diff marks the file in view: the last file | |
| 246 | - | // whose top is above the first third of the window (the first file when none | |
| 247 | - | // is). One observer watches the files against that band; a file has passed | |
| 248 | - | // the line when it is in the band or wholly above it. It reports only when a | |
| 249 | - | // file enters or leaves the band, so a scroll costs nothing in between. | |
| 257 | + | // in the top third of the window, else the last file whose top is above that | |
| 258 | + | // line (the first file when none is). One observer tracks the files in the | |
| 259 | + | // band; it reports only when a file enters or leaves it, so a scroll costs | |
| 260 | + | // nothing in between, and a jump that skips the band is caught by the scan. | |
| 250 | 261 | const files = $$('.dfile'); | |
| 251 | 262 | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | |
| 252 | 263 | if (files.length && links.size && 'IntersectionObserver' in window) { | |
| 253 | - | const passed = new Set(); | |
| 264 | + | const inBand = new Set(); | |
| 254 | 265 | let current = null; | |
| 255 | 266 | const mark = () => { | |
| 256 | - | const f = files.findLast((x) => passed.has(x)) ?? files[0]; | |
| 267 | + | const f = files.findLast((x) => inBand.has(x)) | |
| 268 | + | ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3) | |
| 269 | + | ?? files[0]; | |
| 257 | 270 | if (f === current) return; | |
| 258 | 271 | links.get(current?.id)?.removeAttribute('aria-current'); | |
| 259 | 272 | links.get(f.id)?.setAttribute('aria-current', 'true'); | |
| @@ -261,9 +274,8 @@ | |||
| 261 | 274 | }; | |
| 262 | 275 | const io = new IntersectionObserver((entries) => { | |
| 263 | 276 | for (const e of entries) { | |
| 264 | - | const line = e.rootBounds ? e.rootBounds.bottom : innerHeight / 3; | |
| 265 | - | if (e.isIntersecting || e.boundingClientRect.top < line) passed.add(e.target); | |
| 266 | - | else passed.delete(e.target); | |
| 277 | + | if (e.isIntersecting) inBand.add(e.target); | |
| 278 | + | else inBand.delete(e.target); | |
| 267 | 279 | } | |
| 268 | 280 | mark(); | |
| 269 | 281 | }, { rootMargin: '0px 0px -67% 0px' }); | |
| @@ -296,7 +308,9 @@ window.ermiteLive = (() => { | |||
| 296 | 308 | swap(html) { | |
| 297 | 309 | const t = document.createElement('template'); | |
| 298 | 310 | t.innerHTML = html; | |
| 299 | - | for (const el of t.content.children) { | |
| 311 | + | const cursor = document.querySelector('[data-rows] > [data-cursor]'); | |
| 312 | + | const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href'); | |
| 313 | + | for (const el of [...t.content.children]) { | |
| 300 | 314 | const old = el.id && document.getElementById(el.id); | |
| 301 | 315 | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | |
| 302 | 316 | if (!old) { | |
| @@ -306,10 +320,18 @@ window.ermiteLive = (() => { | |||
| 306 | 320 | } | |
| 307 | 321 | if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; } | |
| 308 | 322 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | |
| 309 | - | if (old.hidden && old.tagName === 'TR') el.hidden = true; | |
| 310 | 323 | old.replaceWith(el); | |
| 311 | 324 | window.htmx?.process(el); | |
| 312 | 325 | } | |
| 326 | + | // The rows may have changed under an active filter: apply it again and | |
| 327 | + | // keep the cursor on its row while that row is still shown. | |
| 328 | + | for (const input of document.querySelectorAll('input[data-filter]')) { | |
| 329 | + | if (input.value) window.ermite?.hide(input); | |
| 330 | + | } | |
| 331 | + | if (!cursorKey) return; | |
| 332 | + | const rows = [...document.querySelectorAll('[data-rows] > *')]; | |
| 333 | + | const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey)); | |
| 334 | + | if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back); | |
| 313 | 335 | }, | |
| 314 | 336 | reload() { | |
| 315 | 337 | if (reloading) return; | |
| @@ -66,8 +66,9 @@ | |||||
| 66 | edge(last) { | 66 | edge(last) { | ||
| 67 | const rows = visibleRows(); | 67 | const rows = visibleRows(); | ||
| 68 | const row = last ? rows.at(-1) : rows[0]; | 68 | const row = last ? rows.at(-1) : rows[0]; | ||
| 69 | - | if (row) row.scrollIntoView({ block: 'nearest' }); | 69 | + | if (!row) return scrollPage(last ? 1e9 : 0); |
| 70 | - | else scrollPage(last ? 1e9 : 0); | 70 | + | setCursor(row, $$('[data-rows] > [data-cursor]')); |
| 71 | + | row.scrollIntoView({ block: 'nearest' }); | |||
| 71 | }, | 72 | }, | ||
| 72 | open() { | 73 | open() { | ||
| 73 | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | 74 | if (!$('dialog[open]')) $('[data-cursor] a')?.click(); | ||
| @@ -99,17 +100,22 @@ | |||||
| 99 | // cursor) and, optionally, the groups to hide when they have no row left | 100 | // cursor) and, optionally, the groups to hide when they have no row left | ||
| 100 | // (data-groups). The cursor goes to the first row left. | 101 | // (data-groups). The cursor goes to the first row left. | ||
| 101 | filter(input) { | 102 | filter(input) { | ||
| 102 | - | const sel = input.dataset.filter || input.dataset.pick; | 103 | + | const rows = ermite.hide(input); |
| 104 | + | if (input.dataset.filter) { | |||
| 105 | + | const first = rows.find((r) => !r.hidden); | |||
| 106 | + | for (const r of rows) r.toggleAttribute('data-cursor', r === first); | |||
| 107 | + | } | |||
| 108 | + | }, | |||
| 109 | + | // hide applies the text of a filter input to its rows and groups, without | |||
| 110 | + | // moving the cursor, and returns the rows. | |||
| 111 | + | hide(input) { | |||
| 103 | const t = input.value.toLowerCase(); | 112 | const t = input.value.toLowerCase(); | ||
| 104 | - | const rows = $$(sel); | 113 | + | const rows = $$(input.dataset.filter || input.dataset.pick); |
| 105 | for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t); | 114 | for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t); | ||
| 106 | if (input.dataset.groups) { | 115 | if (input.dataset.groups) { | ||
| 107 | for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); | 116 | for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r)); | ||
| 108 | } | 117 | } | ||
| 109 | - | if (input.dataset.filter) { | 118 | + | return rows; |
| 110 | - | const first = rows.find((r) => !r.hidden); | |||
| 111 | - | for (const r of rows) r.toggleAttribute('data-cursor', r === first); | |||
| 112 | - | } | |||
| 113 | }, | 119 | }, | ||
| 114 | // The "go to" palette. | 120 | // The "go to" palette. | ||
| 115 | cmdFilter(input) { | 121 | cmdFilter(input) { | ||
| @@ -163,7 +169,11 @@ | |||||
| 163 | async permalink() { | 169 | async permalink() { | ||
| 164 | const b = body().dataset; | 170 | const b = body().dataset; | ||
| 165 | if (!b.sha) return flash('no ref to pin on this page'); | 171 | if (!b.sha) return flash('no ref to pin on this page'); | ||
| 166 | - | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | 172 | + | const enc = (b.ref || '').split('/').map(encodeURIComponent).join('/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 173 | + | // The ref follows the repository and the route: /<repo>/<tree|blob|commits>/<ref>. | |||
| 174 | + | const path = enc ? location.pathname.replace(new RegExp('^(/[^/]+/[^/]+/)' + enc + '(?=/|$)'), '$1' + b.sha) : location.pathname; | |||
| 175 | + | if (path === location.pathname) return flash('this page has no ref to pin'); | |||
| 176 | + | history.replaceState(null, '', path + location.hash); | |||
| 167 | try { | 177 | try { | ||
| 168 | await navigator.clipboard.writeText(location.href); | 178 | await navigator.clipboard.writeText(location.href); | ||
| 169 | flash('permalink ' + b.sha.slice(0, 7) + ' copied'); | 179 | flash('permalink ' + b.sha.slice(0, 7) + ' copied'); | ||
| @@ -198,6 +208,7 @@ | |||||
| 198 | // "g t" is the key "t": the tabs have one event each. | 208 | // "g t" is the key "t": the tabs have one event each. | ||
| 199 | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | 209 | const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' }; | ||
| 200 | let pending = 0; | 210 | let pending = 0; | ||
| 211 | + | let pendingTimer = 0; | |||
| 201 | addEventListener('keydown', (e) => { | 212 | addEventListener('keydown', (e) => { | ||
| 202 | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | 213 | const typing = e.target.closest('input, textarea, select, [contenteditable]'); | ||
| 203 | // ctrl k / cmd k opens the palette; in a field only cmd k, so that the | 214 | // ctrl k / cmd k opens the palette; in a field only cmd k, so that the | ||
| @@ -216,7 +227,7 @@ | |||||
| 216 | if (name) { e.preventDefault(); ermite.fire(name); } | 227 | if (name) { e.preventDefault(); ermite.fire(name); } | ||
| 217 | return; | 228 | return; | ||
| 218 | } | 229 | } | ||
| 219 | - | if (e.key === 'g') { pending = 1; setTimeout(() => (pending = 0), 1200); return; } | 230 | + | if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; } |
| 220 | const name = single[e.key]; | 231 | const name = single[e.key]; | ||
| 221 | if (!name) return; | 232 | if (!name) return; | ||
| 222 | if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault(); | 233 | if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault(); | ||
| @@ -243,17 +254,19 @@ | |||||
| 243 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | 254 | if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true }); | ||
| 244 | 255 | ||||
| 245 | // 4. The table of contents of a diff marks the file in view: the last file | 256 | // 4. The table of contents of a diff marks the file in view: the last file | ||
| 246 | - | // whose top is above the first third of the window (the first file when none | 257 | + | // in the top third of the window, else the last file whose top is above that |
| 247 | - | // is). One observer watches the files against that band; a file has passed | 258 | + | // line (the first file when none is). One observer tracks the files in the |
| 248 | - | // the line when it is in the band or wholly above it. It reports only when a | 259 | + | // band; it reports only when a file enters or leaves it, so a scroll costs |
| 249 | - | // file enters or leaves the band, so a scroll costs nothing in between. | 260 | + | // nothing in between, and a jump that skips the band is caught by the scan. |
| 250 | const files = $$('.dfile'); | 261 | const files = $$('.dfile'); | ||
| 251 | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | 262 | const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a])); | ||
| 252 | if (files.length && links.size && 'IntersectionObserver' in window) { | 263 | if (files.length && links.size && 'IntersectionObserver' in window) { | ||
| 253 | - | const passed = new Set(); | 264 | + | const inBand = new Set(); |
| 254 | let current = null; | 265 | let current = null; | ||
| 255 | const mark = () => { | 266 | const mark = () => { | ||
| 256 | - | const f = files.findLast((x) => passed.has(x)) ?? files[0]; | 267 | + | const f = files.findLast((x) => inBand.has(x)) |
| 268 | + | ?? files.findLast((x) => x.getBoundingClientRect().top < innerHeight / 3) | |||
| 269 | + | ?? files[0]; | |||
| 257 | if (f === current) return; | 270 | if (f === current) return; | ||
| 258 | links.get(current?.id)?.removeAttribute('aria-current'); | 271 | links.get(current?.id)?.removeAttribute('aria-current'); | ||
| 259 | links.get(f.id)?.setAttribute('aria-current', 'true'); | 272 | links.get(f.id)?.setAttribute('aria-current', 'true'); | ||
| @@ -261,9 +274,8 @@ | |||||
| 261 | }; | 274 | }; | ||
| 262 | const io = new IntersectionObserver((entries) => { | 275 | const io = new IntersectionObserver((entries) => { | ||
| 263 | for (const e of entries) { | 276 | for (const e of entries) { | ||
| 264 | - | const line = e.rootBounds ? e.rootBounds.bottom : innerHeight / 3; | 277 | + | if (e.isIntersecting) inBand.add(e.target); |
| 265 | - | if (e.isIntersecting || e.boundingClientRect.top < line) passed.add(e.target); | 278 | + | else inBand.delete(e.target); |
| 266 | - | else passed.delete(e.target); | |||
| 267 | } | 279 | } | ||
| 268 | mark(); | 280 | mark(); | ||
| 269 | }, { rootMargin: '0px 0px -67% 0px' }); | 281 | }, { rootMargin: '0px 0px -67% 0px' }); | ||
| @@ -296,7 +308,9 @@ window.ermiteLive = (() => { | |||||
| 296 | swap(html) { | 308 | swap(html) { | ||
| 297 | const t = document.createElement('template'); | 309 | const t = document.createElement('template'); | ||
| 298 | t.innerHTML = html; | 310 | t.innerHTML = html; | ||
| 299 | - | for (const el of t.content.children) { | 311 | + | const cursor = document.querySelector('[data-rows] > [data-cursor]'); |
| 312 | + | const cursorKey = cursor?.id || cursor?.querySelector('a')?.getAttribute('href'); | |||
| 313 | + | for (const el of [...t.content.children]) { | |||
| 300 | const old = el.id && document.getElementById(el.id); | 314 | const old = el.id && document.getElementById(el.id); | ||
| 301 | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | 315 | if (el.hasAttribute('data-gone')) { old?.remove(); continue; } | ||
| 302 | if (!old) { | 316 | if (!old) { | ||
| @@ -306,10 +320,18 @@ window.ermiteLive = (() => { | |||||
| 306 | } | 320 | } | ||
| 307 | if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; } | 321 | if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; } | ||
| 308 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | 322 | if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', ''); | ||
| 309 | - | if (old.hidden && old.tagName === 'TR') el.hidden = true; | |||
| 310 | old.replaceWith(el); | 323 | old.replaceWith(el); | ||
| 311 | window.htmx?.process(el); | 324 | window.htmx?.process(el); | ||
| 312 | } | 325 | } | ||
| 326 | + | // The rows may have changed under an active filter: apply it again and | |||
| 327 | + | // keep the cursor on its row while that row is still shown. | |||
| 328 | + | for (const input of document.querySelectorAll('input[data-filter]')) { | |||
| 329 | + | if (input.value) window.ermite?.hide(input); | |||
| 330 | + | } | |||
| 331 | + | if (!cursorKey) return; | |||
| 332 | + | const rows = [...document.querySelectorAll('[data-rows] > *')]; | |||
| 333 | + | const back = rows.find((r) => !r.hidden && (r.id === cursorKey || r.querySelector('a')?.getAttribute('href') === cursorKey)); | |||
| 334 | + | if (back) for (const r of rows) r.toggleAttribute('data-cursor', r === back); | |||
| 313 | }, | 335 | }, | ||
| 314 | reload() { | 336 | reload() { | ||
| 315 | if (reloading) return; | 337 | if (reloading) return; | ||