detached ffb5f8d

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 @@
6666 edge(last) {
6767 const rows = visibleRows();
6868 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' });
7172 },
7273 open() {
7374 if (!$('dialog[open]')) $('[data-cursor] a')?.click();
@@ -99,17 +100,22 @@
99100 // cursor) and, optionally, the groups to hide when they have no row left
100101 // (data-groups). The cursor goes to the first row left.
101102 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) {
103112 const t = input.value.toLowerCase();
104- const rows = $$(sel);
113+ const rows = $$(input.dataset.filter || input.dataset.pick);
105114 for (const r of rows) r.hidden = !r.textContent.toLowerCase().includes(t);
106115 if (input.dataset.groups) {
107116 for (const g of $$(input.dataset.groups)) g.hidden = !rows.some((r) => !r.hidden && g.contains(r));
108117 }
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;
113119 },
114120 // The "go to" palette.
115121 cmdFilter(input) {
@@ -163,7 +169,11 @@
163169 async permalink() {
164170 const b = body().dataset;
165171 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);
167177 try {
168178 await navigator.clipboard.writeText(location.href);
169179 flash('permalink ' + b.sha.slice(0, 7) + ' copied');
@@ -198,6 +208,7 @@
198208 // "g t" is the key "t": the tabs have one event each.
199209 const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top' };
200210 let pending = 0;
211+ let pendingTimer = 0;
201212 addEventListener('keydown', (e) => {
202213 const typing = e.target.closest('input, textarea, select, [contenteditable]');
203214 // ctrl k / cmd k opens the palette; in a field only cmd k, so that the
@@ -216,7 +227,7 @@
216227 if (name) { e.preventDefault(); ermite.fire(name); }
217228 return;
218229 }
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; }
220231 const name = single[e.key];
221232 if (!name) return;
222233 if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
@@ -243,17 +254,19 @@
243254 if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
244255
245256 // 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.
250261 const files = $$('.dfile');
251262 const links = new Map($$('.files a[href^="#"]').map((a) => [a.hash.slice(1), a]));
252263 if (files.length && links.size && 'IntersectionObserver' in window) {
253- const passed = new Set();
264+ const inBand = new Set();
254265 let current = null;
255266 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];
257270 if (f === current) return;
258271 links.get(current?.id)?.removeAttribute('aria-current');
259272 links.get(f.id)?.setAttribute('aria-current', 'true');
@@ -261,9 +274,8 @@
261274 };
262275 const io = new IntersectionObserver((entries) => {
263276 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);
267279 }
268280 mark();
269281 }, { rootMargin: '0px 0px -67% 0px' });
@@ -296,7 +308,9 @@ window.ermiteLive = (() => {
296308 swap(html) {
297309 const t = document.createElement('template');
298310 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]) {
300314 const old = el.id && document.getElementById(el.id);
301315 if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
302316 if (!old) {
@@ -306,10 +320,18 @@ window.ermiteLive = (() => {
306320 }
307321 if (old.hasAttribute('data-slot')) { old.replaceChildren(...el.childNodes); continue; }
308322 if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
309- if (old.hidden && old.tagName === 'TR') el.hidden = true;
310323 old.replaceWith(el);
311324 window.htmx?.process(el);
312325 }
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);
313335 },
314336 reload() {
315337 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 left100 // 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 the214 // 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 file256 // 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 none257+ // 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 passed258+ // 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 a259+ // 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;

Keyboard shortcuts

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

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

Branches 2

Tags 43

↑↓ moveenter openesc close