Files view: the jumped-to record stays selected (v2.6.2)

- 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
This commit is contained in:
catarrh committed 2026-10-17 23:16:33 +03:00
1 parent a0a2251522
commit 76c5647dd4
8 files changed
+98 -19

No files matched your search

+4 -3
View File
@@ -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
+4 -3
View File
@@ -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
+19 -8
View File
@@ -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 `<div class="apdu-entry apdu-gap" data-idx="${idx}">
return `<div class="apdu-entry apdu-gap${selCls}" data-idx="${idx}">
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
<span class="apdu-type">⚠ CAPTURE GAP</span>
<span class="apdu-data">${cmd_description(m)}</span>
@@ -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 `<div class="apdu-entry${pairCls}" data-idx="${idx}">
return `<div class="apdu-entry${pairCls}${selCls}" data-idx="${idx}">
<span class="apdu-time">${fmt_elapsed(m.elapsed)}</span>
<span class="apdu-type ${typeCls}">${grLabel}</span>
<span class="apdu-sw ${sw_class(m)}">${sw_text(m)}</span>
@@ -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) {
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simtrace-analyser-v75';
const CACHE = 'simtrace-analyser-v76';
const PRECACHE = [
'index.html',
+67 -1
View File
@@ -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;/);
});
+1 -1
View File
@@ -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"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version."""
__version__ = '2.6.1'
__version__ = '2.6.2'
+1 -1
View File
@@ -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