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:
1 parent
64433f9739
commit
08a6446bc8
8 files changed
+114
-12
No files matched your search
@@ -203,7 +203,9 @@ The Trace tab's side panel has a view selector (*Map* / *Stats* / **Files**;
|
||||
a drop-down, since three tab captions no longer fit). The Files view is a
|
||||
trace-wide timeline of file operations — every **Read**, **Update** and
|
||||
**Changed on card** row shows the same file name as the Map view, and clicking
|
||||
a row jumps to the APDU in the trace. Rows that carry a recorded change (a
|
||||
a row jumps straight to the APDU in the trace (the APDU list keeps the
|
||||
reader's position: new messages pin the view to the bottom only while the
|
||||
reader is already there, so a jump is never pulled away by the live poll). Rows that carry a recorded change (a
|
||||
card-side change, or an update that wrote or first observed bytes) also offer
|
||||
**Details**: a modal showing the full file path (`MF/EF_ICCID (2FE2)`,
|
||||
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — the DF
|
||||
|
||||
+3
-1
@@ -207,7 +207,9 @@ Application Layer* — два отдельных поля выбора, имен
|
||||
**Files** — выпадающий список, три подписи-вкладки уже не помещались). Вид
|
||||
Files — это таймлайн файловых операций по всей трассе: строки **Read**,
|
||||
**Update** и **Changed on card** показывают те же имена файлов, что и вид Map,
|
||||
а щелчок по строке переходит к APDU в трассе. У строк с записанным изменением
|
||||
а щелчок по строке переходит прямо к APDU в трассе (список APDU сохраняет
|
||||
позицию читателя: новые сообщения прижимают вид к низу, только пока читатель
|
||||
уже внизу, поэтому переход не срывается живым опросом). У строк с записанным изменением
|
||||
(изменение со стороны карты либо запись, изменившая или впервые показавшая
|
||||
байты) есть кнопка **Details**: окно показывает полный путь файла (`MF/EF_ICCID (2FE2)`,
|
||||
`ADF/EF_IMSI (6F07)`, `DF_GSM_ACCESS/EF_KcGPRS (4F52)`, `AID …/4F09` — DF
|
||||
|
||||
+21
-6
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simtrace-analyser-v72';
|
||||
const CACHE = 'simtrace-analyser-v73';
|
||||
|
||||
const PRECACHE = [
|
||||
'index.html',
|
||||
|
||||
@@ -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\);/);
|
||||
});
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
|
||||
|
||||
[project]
|
||||
name = "simtrace-analyser"
|
||||
version = "2.6.0"
|
||||
version = "2.6.1"
|
||||
description = "SIMtrace Analyser — APDU trace analyser for GSMTAP/PCAP captures (bundles the simtrace2-pysniff utility)"
|
||||
readme = {file = "README.md", content-type = "text/markdown"}
|
||||
license = {text = "GPL-2.0-or-later"}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Single source of truth for the SIMtrace Analyser version."""
|
||||
|
||||
__version__ = '2.6.0'
|
||||
__version__ = '2.6.1'
|
||||
@@ -224,7 +224,7 @@ class TestImportEndpoint(unittest.TestCase):
|
||||
class TestVersion(unittest.TestCase):
|
||||
def test_package_version(self):
|
||||
from simtrace2_pysniff import __version__
|
||||
self.assertEqual(__version__, '2.6.0')
|
||||
self.assertEqual(__version__, '2.6.1')
|
||||
|
||||
def test_status_includes_version(self):
|
||||
import os
|
||||
|
||||
Reference in new issue
Block a user