From 76c5647dd4a634a387cfbe012f7d2cecc8cf92b8 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:16:33 +0300 Subject: [PATCH] Files view: the jumped-to record stays selected (v2.6.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - focusMessage()/scrollToType() highlighted the target and removed the class after 1500 ms, so a Files-row (or map/category) jump looked selected for a moment and then lost it; on top of that any re-render (the 500 ms live poll, a filter/search change) rebuilt the list and dropped the highlight — even for a clicked entry - the selection is now persistent app state (STATE.selectedIdx): the render marks the entry (gap rows included), focusMessage records the index before rendering, showDetail records it for plain clicks, scrollToType persists it too (clearing the previous highlight), and a new session/capture resets it so a stale index cannot mark an unrelated APDU - tests: a stub-driven focusMessage case (state + detail updated, no render and no timer), the filter-clearing case, and source pins for renderMessages, focusMessage, scrollToType, showDetail, loadSession and startCapture; 527 Python + 58 Node tests - README/README_RUS: the jump leaves the record selected; PWA_VERSION/ pyproject/version.py 2.6.2, SW cache simtrace-analyser-v76 --- README.md | 7 ++-- README_RUS.md | 7 ++-- frontend/index.html | 27 ++++++++---- frontend/sw.js | 2 +- frontend/tests/files_jump.test.js | 68 ++++++++++++++++++++++++++++++- pyproject.toml | 2 +- simtrace_analyser/version.py | 2 +- tests/test_pcap_import.py | 2 +- 8 files changed, 98 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index dff4528..43c50d4 100644 --- a/README.md +++ b/README.md @@ -203,9 +203,10 @@ 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 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 +a row jumps straight to the APDU in the trace and leaves it highlighted +until another one is focused (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 or un-highlighted 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 7fb401c..a910e38 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -207,9 +207,10 @@ Application Layer* — два отдельных поля выбора, имен **Files** — выпадающий список, три подписи-вкладки уже не помещались). Вид Files — это таймлайн файловых операций по всей трассе: строки **Read**, **Update** и **Changed on card** показывают те же имена файлов, что и вид Map, -а щелчок по строке переходит прямо к APDU в трассе (список 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 bf1460d..a643c31 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -505,6 +505,7 @@ const STATE = { sessionStarted: null, livePoller: null, messages: [], + selectedIdx: null, // the focused APDU entry (persists across renders) typeCounts: [], traceMap: [], sideView: 'map', @@ -1754,9 +1755,13 @@ function atrPpsRows(decoded) { } function showDetail(msg, entry) { - // Highlight clicked entry + // Highlight the focused entry; the state keeps it marked across renders. document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected')); - if (entry) entry.classList.add('selected'); + if (entry) { + entry.classList.add('selected'); + const idx = Number(entry.dataset.idx); + if (!Number.isNaN(idx)) STATE.selectedIdx = idx; + } const d = el('apdu-detail'); const decoded = msg.decoded; @@ -2333,9 +2338,10 @@ function scrollToType(typeKey) { } } if (targetEl) { + STATE.selectedIdx = parseInt(targetEl.dataset.idx); targetEl.scrollIntoView({ block: 'center', behavior: 'smooth' }); + document.querySelectorAll('.apdu-entry.selected').forEach(e => e.classList.remove('selected')); targetEl.classList.add('selected'); - setTimeout(() => targetEl.classList.remove('selected'), 1500); } } @@ -2345,12 +2351,13 @@ function focusMessage(idx) { const needRender = filterEl.value !== '' || searchEl.value !== ''; filterEl.value = ''; searchEl.value = ''; + // The focused entry stays selected until another one is focused; the + // state lets a re-render (the live poll included) keep it marked. + STATE.selectedIdx = idx; if (needRender) renderMessages(true); const entry = el('apdu-list').querySelector(`.apdu-entry[data-idx="${idx}"]`); if (!entry) return; entry.scrollIntoView({ block: 'center', behavior: 'smooth' }); - entry.classList.add('selected'); - setTimeout(() => entry.classList.remove('selected'), 1500); showDetail(STATE.messages[idx], entry); } @@ -2432,9 +2439,11 @@ function renderMessages(skipScroll, forceStick) { return; } + const selectedIdx = STATE.selectedIdx; list.innerHTML = filtered.map(({ m, idx }) => { + const selCls = idx === selectedIdx ? ' selected' : ''; if (m.type === 'gap') { - return `
+ return `
${fmt_elapsed(m.elapsed)} ⚠ CAPTURE GAP ${cmd_description(m)} @@ -2454,7 +2463,7 @@ function renderMessages(skipScroll, forceStick) { m.decoded.ins_name === 'GET RESPONSE' && m.decoded.response_for; const pairCls = (isGR || isOriginalCommand(idx)) ? ' apdu-pair' : ''; const grLabel = isGR ? '↳ ' + label : label; - return `
+ return `
${fmt_elapsed(m.elapsed)} ${grLabel} ${sw_text(m)} @@ -2561,6 +2570,7 @@ async function startCapture() { STATE.currentSessionId = data.session_id; STATE.sessionStarted = data.started; STATE.messages = []; + STATE.selectedIdx = null; _lastAfter = 0; STATE.mapExpanded.clear(); buildTypeCounts(); @@ -2943,6 +2953,7 @@ async function loadSession(id) { STATE.filesData = null; STATE.filesSessionId = null; STATE.filesImages = {}; + STATE.selectedIdx = null; STATE.sessionStarted = data.session.started; STATE.captureActive = false; STATE.currentSessionName = data.session.name || t('Session #') + id; @@ -3205,7 +3216,7 @@ function onFilterChange() { } // ──────────────────── Versioning ──────────────────── -const PWA_VERSION = '2.6.1'; +const PWA_VERSION = '2.6.2'; const MIN_SERVER_VERSION = '2.3.0'; function parseVersion(v) { diff --git a/frontend/sw.js b/frontend/sw.js index cfdc831..b8f2b8e 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simtrace-analyser-v75'; +const CACHE = 'simtrace-analyser-v76'; const PRECACHE = [ 'index.html', diff --git a/frontend/tests/files_jump.test.js b/frontend/tests/files_jump.test.js index 7dcadf1..c94d9c4 100644 --- a/frontend/tests/files_jump.test.js +++ b/frontend/tests/files_jump.test.js @@ -13,6 +13,8 @@ let STATE = {}; let API = {}; let renders = []; let focused = []; +let el = () => ({}); +let showDetail = () => {}; function renderMessages(skipScroll, forceStick) { renders.push([skipScroll, forceStick]); } @@ -21,8 +23,11 @@ function focusMessage(idx) { } eval( extractFunc(src, 'jumpToMessage') + '\n' + - extractFunc(src, 'shouldStickToBottom') + extractFunc(src, 'shouldStickToBottom') + '\n' ); +// The real focusMessage, under an alias: the stub above stays in place for +// the jumpToMessage tests. +eval('var realFocusMessage = ' + extractFunc(src, 'focusMessage') + ';'); test('jumpToMessage pages, renders without stick-to-bottom, then focuses', async () => { renders = []; @@ -81,3 +86,64 @@ 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\);/); }); + +// ── the focused record stays selected ────────────────────────────────── + +test('focusMessage marks the record persistently and updates the detail', () => { + renders = []; + const classes = new Set(); + const entry = { + dataset: { idx: '1' }, + classList: { add: c => classes.add(c), remove: c => classes.delete(c) }, + scrollIntoView: () => {}, + }; + STATE = { messages: [{ id: 'a' }, { id: 'b' }], selectedIdx: 0 }; + el = () => ({ value: '', querySelector: () => entry }); + let detailed = null; + showDetail = (msg, e) => { detailed = [msg, e]; }; + realFocusMessage(1); + assert.equal(STATE.selectedIdx, 1); + assert.deepEqual(detailed, [{ id: 'b' }, entry]); + // No render is needed when no filter is active — and no flash timeout + // is scheduled: the highlight is owned by showDetail/the state. + assert.deepEqual(renders, []); +}); + +test('focusMessage clears a filter before focusing', () => { + renders = []; + const entry = { + dataset: { idx: '3' }, + classList: { add: () => {}, remove: () => {} }, + scrollIntoView: () => {}, + }; + STATE = { messages: {}, selectedIdx: null }; + el = () => ({ value: 'x', querySelector: () => entry }); + showDetail = () => {}; + realFocusMessage(3); + assert.deepEqual(renders, [[true, undefined]]); + assert.equal(STATE.selectedIdx, 3); +}); + +test('the selection persists across renders (source pins)', () => { + const render = extractFunc(src, 'renderMessages'); + assert.match(render, /const selectedIdx = STATE\.selectedIdx;/); + assert.match(render, /idx === selectedIdx \? ' selected' : ''/); + assert.match(render, /apdu-entry apdu-gap\$\{selCls\}/); + assert.match(render, /apdu-entry\$\{pairCls\}\$\{selCls\}/); + + const focus = extractFunc(src, 'focusMessage'); + assert.match(focus, /STATE\.selectedIdx = idx;/); + assert.doesNotMatch(focus, /setTimeout/); + + const type = extractFunc(src, 'scrollToType'); + assert.match(type, /STATE\.selectedIdx = parseInt\(targetEl\.dataset\.idx\);/); + assert.doesNotMatch(type, /setTimeout/); + + const detail = extractFunc(src, 'showDetail'); + assert.match(detail, /entry\.classList\.add\('selected'\)/); + assert.match(detail, /STATE\.selectedIdx = idx;/); + + // A new session/capture must not keep a stale index selected. + assert.match(extractFunc(src, 'loadSession'), /STATE\.selectedIdx = null;/); + assert.match(extractFunc(src, 'startCapture'), /STATE\.selectedIdx = null;/); +}); diff --git a/pyproject.toml b/pyproject.toml index 64c12d9..98abda8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend" [project] name = "simtrace-analyser" -version = "2.6.1" +version = "2.6.2" 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 8815113..4a4cf79 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.1' +__version__ = '2.6.2' diff --git a/tests/test_pcap_import.py b/tests/test_pcap_import.py index 875a48a..ec30456 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.1') + self.assertEqual(__version__, '2.6.2') def test_status_includes_version(self): import os