@@ -5849,6 +5854,44 @@ function pysimCustomInject(node) {
}
}
+let pysimFsSort = localStorage.getItem('otaman_fs_sort') === 'name' ? 'name' : 'fid';
+
+function pysimFsSortChildren(children, mode) {
+ return (children || []).slice().sort((a, b) => {
+ if (!!a.isDir !== !!b.isDir) return a.isDir ? -1 : 1;
+ const af = (a.fid || '').toUpperCase();
+ const bf = (b.fid || '').toUpperCase();
+ const an = (a.name || '').toUpperCase();
+ const bn = (b.name || '').toUpperCase();
+ let r;
+ if (mode === 'name') {
+ const ak = an || af;
+ const bk = bn || bf;
+ r = ak < bk ? -1 : ak > bk ? 1 : 0;
+ if (r === 0) r = af < bf ? -1 : af > bf ? 1 : 0;
+ } else {
+ r = af < bf ? -1 : af > bf ? 1 : 0;
+ if (r === 0) r = an < bn ? -1 : an > bn ? 1 : 0;
+ }
+ return r;
+ });
+}
+
+function pysimFsSetSort(mode) {
+ pysimFsSort = mode === 'name' ? 'name' : 'fid';
+ localStorage.setItem('otaman_fs_sort', pysimFsSort);
+ document.querySelectorAll('.pysim-fs-sort-pill').forEach(p => {
+ const active = p.dataset.fsSort === pysimFsSort;
+ p.classList.toggle('bg-blue-600', active);
+ p.classList.toggle('text-white', active);
+ p.classList.toggle('bg-gray-200', !active);
+ p.classList.toggle('dark:bg-slate-700', !active);
+ p.classList.toggle('text-gray-700', !active);
+ p.classList.toggle('dark:text-slate-300', !active);
+ });
+ pysimFsRenderTree();
+}
+
async function pysimFsRefresh() {
const detailEl = document.getElementById('pysim-fs-detail');
detailEl.classList.add('hidden');
@@ -5946,7 +5989,7 @@ function pysimFsRenderNode(node, depth) {
}
html += '
';
if (isDir && node.children && node.expanded) {
- for (const child of node.children) {
+ for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
html += pysimFsRenderNode(child, depth + 1);
}
}
@@ -9050,6 +9093,8 @@ const LANG_RU = {
'Verify vs pySim': 'Проверить в pySim',
'Send to Card': 'Отправить на карту',
'Phone simulator': 'Симулятор телефона',
+ 'Sort:': 'Сортировка:',
+ 'Name': 'Имя',
'No server connection': 'Нет соединения с сервером',
'Server connected, no card equipped': 'Сервер подключён, карта не подключена',
'Card equipped': 'Карта подключена',
@@ -9269,6 +9314,7 @@ updateHelpLink();
cardsLoad();
pysimStartAvailabilityProbe();
+pysimFsSetSort(pysimFsSort);
if (localStorage.getItem('theme') === 'dark' ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
diff --git a/frontend/sw.js b/frontend/sw.js
index 00ab40f..80cf7b0 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v108';
+const CACHE = 'otaman-v109';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/fs_sort.test.js b/frontend/tests/fs_sort.test.js
new file mode 100644
index 0000000..c95ed4d
--- /dev/null
+++ b/frontend/tests/fs_sort.test.js
@@ -0,0 +1,66 @@
+const { test } = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
+
+function extractFunc(src, name) {
+ const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
+ const m = re.exec(src);
+ if (!m) throw new Error('function ' + name + ' not found');
+ let i = m.index + m[0].length - 1;
+ let depth = 0;
+ for (; i < src.length; i++) {
+ if (src[i] === '{') depth++;
+ else if (src[i] === '}') {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ return src.slice(m.index, i + 1);
+}
+
+eval(extractFunc(html, 'pysimFsSortChildren'));
+
+const f = (fid, name) => ({ fid, name, isDir: false });
+const d = (fid, name) => ({ fid, name, isDir: true });
+
+test('DFs sort before EFs in both modes', () => {
+ const children = [f('6F07', 'EF.IMSI'), d('7F20', 'DF.GSM'), f('2FE2', 'EF.ICCID'), d('7F10', 'DF.TELECOM')];
+ assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['7F10', '7F20', '2FE2', '6F07']);
+ assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['DF.GSM', 'DF.TELECOM', 'EF.ICCID', 'EF.IMSI']);
+});
+
+test('FID mode sorts EFs numerically by FID string', () => {
+ const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'EF.ICCID'), f('6F07', 'EF.IMSI')];
+ assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07', '6F3A']);
+});
+
+test('name mode sorts case-insensitively', () => {
+ const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'ef.iccid'), f('6F07', 'EF.IMSI')];
+ assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['EF.ADN', 'ef.iccid', 'EF.IMSI']);
+});
+
+test('missing name falls back to the FID as the sort key', () => {
+ const children = [f('2FE2', null), f('6F07', 'EF.IMSI'), f('6F3A', '')];
+ // keys: '2FE2', 'EF.IMSI', '6F3A' -> '2FE2' < '6F3A' < 'EF.IMSI'
+ assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F3A', '6F07']);
+});
+
+test('custom entries use their isDir flag for the DF priority', () => {
+ const children = [f('6F3A', 'EF.ADN'), d('7F20', 'DF.GSM')];
+ assert.strictEqual(pysimFsSortChildren(children, 'fid')[0].name, 'DF.GSM');
+});
+
+test('equal keys keep a deterministic tie-break by the other field', () => {
+ const children = [f('6F07', 'EF.SAME'), f('2FE2', 'EF.SAME')];
+ assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F07']);
+ assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07']);
+});
+
+test('does not mutate the input array', () => {
+ const children = [f('6F3A', 'B'), f('2FE2', 'A')];
+ pysimFsSortChildren(children, 'fid');
+ assert.deepStrictEqual(children.map(x => x.fid), ['6F3A', '2FE2']);
+});
diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js
index 6d8e5ac..93e4b76 100644
--- a/frontend/tests/html.test.js
+++ b/frontend/tests/html.test.js
@@ -48,3 +48,9 @@ test('header state indicator and profiler custom-files tab', () => {
assert.ok(html.includes('data-list-tab="custom"'));
assert.ok(!html.includes('data-pysim-sub="custom"'));
});
+
+test('file manager has FID / Name sort pills', () => {
+ assert.match(html, /data-fs-sort="fid" onclick="pysimFsSetSort\('fid'\)"/);
+ assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
+ assert.ok(html.includes('pysim-fs-sort-pill'));
+});