From 08a6446bc81bec0b9c4a686641a614a03ebcedc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 17 Oct 2026 23:01:40 +0300 Subject: [PATCH] Files view: the row jump no longer scrolls to the end of the trace (v2.6.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 4 +- README_RUS.md | 4 +- frontend/index.html | 27 +++++++--- frontend/sw.js | 2 +- frontend/tests/files_jump.test.js | 83 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- simtrace_analyser/version.py | 2 +- tests/test_pcap_import.py | 2 +- 8 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 frontend/tests/files_jump.test.js diff --git a/README.md b/README.md index 2f77e85..dff4528 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_RUS.md b/README_RUS.md index 3b3308d..7fb401c 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 58fe7e0..1e38013 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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) { `; }).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 = `
${t('Click an APDU to see details')}
`; updateCaptureButton(); startPolling(); @@ -2937,7 +2952,7 @@ async function loadSession(id) { STATE.mapExpanded.clear(); switchTab('trace'); updateCaptureButton(); - renderMessages(); + renderMessages(false, true); el('apdu-detail').innerHTML = `
${t('Click an APDU to see details')}
`; 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) { diff --git a/frontend/sw.js b/frontend/sw.js index 0a0396a..5bfa91e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v72'; +const CACHE = 'simtrace-analyser-v73'; const PRECACHE = [ 'index.html', diff --git a/frontend/tests/files_jump.test.js b/frontend/tests/files_jump.test.js new file mode 100644 index 0000000..7dcadf1 --- /dev/null +++ b/frontend/tests/files_jump.test.js @@ -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\);/); +}); diff --git a/pyproject.toml b/pyproject.toml index 5789ae6..64c12d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"} diff --git a/simtrace_analyser/version.py b/simtrace_analyser/version.py index 6c5b69c..8815113 100644 --- a/simtrace_analyser/version.py +++ b/simtrace_analyser/version.py @@ -1,3 +1,3 @@ """Single source of truth for the SIMtrace Analyser version.""" -__version__ = '2.6.0' +__version__ = '2.6.1' diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 126085a..875a48a 100644 --- a/tests/test_pcap_import.py +++ b/tests/test_pcap_import.py @@ -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