Files view: the row jump no longer scrolls to the end of the trace (v2.6.1)

- jumpToMessage() rendered the APDU list through the unqualified
  renderMessages(), whose tail pins the view to the bottom (the live-capture
  stick), and only then let focusMessage() smooth-scroll back up to the
  clicked record — the visible jump-to-end-then-back.  It now renders with
  skipScroll, so the only scroll is the focus itself
- the content-follow became conditional: shouldStickToBottom() pins the view
  to the newest messages only while the reader is already at the bottom
  (40 px slack), so the 500 ms live poll no longer pulls away a view the
  reader scrolled to — a Files/map jump is no longer yanked down by the next
  batch.  A freshly opened session or a new capture still lands at the
  bottom (renderMessages(false, true))
- tests (node): the jump's paging/render(true)/focus sequence with stubs, the
  no-session no-op, the stick decision (at bottom / within slack / scrolled
  up / explicit jump / forced / first render) and source pins for
  renderMessages, jumpToMessage, loadSession and startCapture; 527 Python +
  53 Node tests
- README/README_RUS note the follow behaviour; PWA_VERSION/pyproject/
  version.py 2.6.1, SW cache simtrace-analyser-v73
This commit is contained in:
catarrh committed 2026-10-17 23:01:40 +03:00
1 parent 64433f9739
commit 08a6446bc8
8 files changed
+114 -12

No files matched your search

+21 -6
View File
@@ -2290,7 +2290,9 @@ async function jumpToMessage(idx) {
if (!data.messages || !data.messages.length) break;
STATE.messages.push(...data.messages);
}
renderMessages();
// Render without the stick-to-bottom: focusMessage() scrolls to the
// record (the render only materializes its element).
renderMessages(true);
focusMessage(idx);
} catch (e) { /* keep the current view */ }
}
@@ -2395,8 +2397,21 @@ function renderTraceEmptyState() {
updateStatus();
}
function renderMessages(skipScroll) {
// Content-follow decision for the APDU list: pin the view to the newest
// messages only when the reader is already at the bottom. An explicit jump
// (*skipScroll*) never sticks, and a freshly loaded session/capture
// (*forceStick*) always starts at the bottom.
function shouldStickToBottom(list, skipScroll, forceStick) {
if (forceStick) return true;
if (skipScroll) return false;
return list.scrollTop + list.clientHeight >= list.scrollHeight - 40;
}
function renderMessages(skipScroll, forceStick) {
const list = el('apdu-list');
// Read the geometry before the DOM is touched: adding messages grows
// scrollHeight, which would hide the fact that the reader was at the end.
const stick = shouldStickToBottom(list, skipScroll, forceStick);
const searchQ = el('search-input').value.toLowerCase();
const filterType = el('filter-select').value;
@@ -2449,7 +2464,7 @@ function renderMessages(skipScroll) {
</div>`;
}).join('');
if (!skipScroll) list.scrollTop = list.scrollHeight;
if (stick) list.scrollTop = list.scrollHeight;
updateStatus();
}
@@ -2552,7 +2567,7 @@ async function startCapture() {
buildTypeCounts();
buildTraceMap();
renderSidePanel();
renderMessages();
renderMessages(false, true);
el('apdu-detail').innerHTML = `<div class="detail-empty" data-l10n="Click an APDU to see details">${t('Click an APDU to see details')}</div>`;
updateCaptureButton();
startPolling();
@@ -2937,7 +2952,7 @@ async function loadSession(id) {
STATE.mapExpanded.clear();
switchTab('trace');
updateCaptureButton();
renderMessages();
renderMessages(false, true);
el('apdu-detail').innerHTML = `<div class="detail-empty" data-l10n="Click an APDU to see details">${t('Click an APDU to see details')}</div>`;
el('session-name-display').style.display = 'inline';
el('session-name-display').textContent = t('Session: ') + (data.session.name || '#' + id);
@@ -3191,7 +3206,7 @@ function onFilterChange() {
}
// ──────────────────── Versioning ────────────────────
const PWA_VERSION = '2.6.0';
const PWA_VERSION = '2.6.1';
const MIN_SERVER_VERSION = '2.3.0';
function parseVersion(v) {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v72';
const CACHE = 'simtrace-analyser-v73';
const PRECACHE = [
'index.html',
+83
View File
@@ -0,0 +1,83 @@
// The Files-view row jump and the APDU list's content-follow decision:
// clicking a timeline row must scroll straight to its APDU — it used to
// render the list with the stick-to-bottom (jump to the end of the trace)
// and then smooth-scroll back up to the record.
const { test } = require('node:test');
const assert = require('node:assert');
const { loadSrc, extractFunc } = require('./helpers.js');
const src = loadSrc();
let STATE = {};
let API = {};
let renders = [];
let focused = [];
function renderMessages(skipScroll, forceStick) {
renders.push([skipScroll, forceStick]);
}
function focusMessage(idx) {
focused.push(idx);
}
eval(
extractFunc(src, 'jumpToMessage') + '\n' +
extractFunc(src, 'shouldStickToBottom')
);
test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => {
renders = [];
focused = [];
STATE = { sessionViewId: 7, messages: [{}, {}] };
API = {
getSession: async (sid, from) => ({
messages: from === 2 ? [{}, {}, {}] : [],
}),
};
await jumpToMessage(3);
assert.equal(STATE.messages.length, 5);
assert.deepEqual(renders, [[true, undefined]]);
assert.deepEqual(focused, [3]);
});
test('jumpToMessage no-ops without a session view', async () => {
renders = [];
focused = [];
STATE = { sessionViewId: null, messages: [] };
API = { getSession: async () => { throw new Error('must not fetch'); } };
await jumpToMessage(3);
assert.deepEqual(renders, []);
assert.deepEqual(focused, []);
});
test('shouldStickToBottom: only a reader at the bottom follows new content', () => {
const list = (scrollTop, clientHeight, scrollHeight) => ({ scrollTop, clientHeight, scrollHeight });
// At the bottom / within the slack → follow.
assert.equal(shouldStickToBottom(list(500, 100, 600), false, false), true);
assert.equal(shouldStickToBottom(list(560, 100, 600), false, false), true);
// Scrolled up (e.g. after a Files/map jump) → leave the view alone.
assert.equal(shouldStickToBottom(list(200, 100, 600), false, false), false);
// An explicit jump never sticks; a new session/capture always does.
assert.equal(shouldStickToBottom(list(200, 100, 600), true, false), false);
assert.equal(shouldStickToBottom(list(200, 100, 600), false, true), true);
// First render: the empty list has no geometry yet.
assert.equal(shouldStickToBottom(list(0, 0, 0), false, false), true);
});
test('renderMessages scrolls through shouldStickToBottom', () => {
const body = extractFunc(src, 'renderMessages');
assert.match(body, /function renderMessages\(skipScroll, forceStick\)/);
assert.match(body, /const stick = shouldStickToBottom\(list, skipScroll, forceStick\);/);
assert.match(body, /if \(stick\) list\.scrollTop = list\.scrollHeight;/);
assert.doesNotMatch(body, /if \(!skipScroll\) list\.scrollTop/);
});
test('jumpToMessage renders with skipScroll (source pin)', () => {
const body = extractFunc(src, 'jumpToMessage');
assert.match(body, /renderMessages\(true\);/);
assert.match(body, /focusMessage\(idx\);/);
});
test('a new session/capture lands at the bottom (source pins)', () => {
assert.match(extractFunc(src, 'loadSession'), /renderMessages\(false, true\);/);
assert.match(extractFunc(src, 'startCapture'), /renderMessages\(false, true\);/);
});