file manager: expand/collapse all + auto-expand on filter

- "Expand all" / "Collapse all" links in the sort row: expand-all walks
  the directory tree loading children on demand (skipping DFs the probe
  found absent), collapse-all resets every directory.
- Selecting any filter but "All" auto-expands the tree so the matched
  files are visible (pysimFsAutoExpandForFilter, wired into the two
  filter setters).
- i18n EN/RU for the two links.

Frontend 1163 tests green (+2).  (Version/sw bump still deferred.)
This commit is contained in:
catarrh committed 2026-10-26 15:05:49 +03:00
1 parent cc7c23a9c7
commit 4a4232de5c
3 files changed
+95 -6

No files matched your search

+43
View File
@@ -1117,6 +1117,9 @@
<span data-l10n="Sort:">Sort:</span>
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-blue-600 text-white" data-fs-sort="fid" onclick="pysimFsSetSort('fid')">FID</span>
<span class="pysim-fs-sort-pill px-2 py-0.5 rounded cursor-pointer bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300" data-fs-sort="name" onclick="pysimFsSetSort('name')" data-l10n="Name">Name</span>
<span class="mx-1 text-gray-400 dark:text-slate-600">·</span>
<button type="button" onclick="pysimFsExpandAll()" class="text-xs text-blue-600 dark:text-blue-400 hover:underline" data-l10n="Expand all">Expand all</button>
<button type="button" onclick="pysimFsCollapseAll()" class="text-xs text-blue-600 dark:text-blue-400 hover:underline" data-l10n="Collapse all">Collapse all</button>
</div>
<div class="flex items-center gap-1 mb-1 text-gray-500 dark:text-slate-400">
<span data-l10n="Filter:">Filter:</span>
@@ -16339,6 +16342,7 @@ function pysimFsFilterSetEntry(entry, on) {
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
pysimFsFilterRender();
pysimFsRenderTree();
pysimFsAutoExpandForFilter();
}
// The cross-reference inverted to service number -> feature name, per file
@@ -16382,6 +16386,7 @@ function pysimFsSetFilterToService(table, n) {
localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
pysimFsFilterRender();
pysimFsRenderTree();
pysimFsAutoExpandForFilter();
}
// Whether a file node passes the active filter. An empty selection and
@@ -16684,6 +16689,42 @@ async function pysimFsToggleDir(path) {
if (node.expanded) pysimRefresh();
}
// Expand every directory that exists (skipping ones the probe already found
// absent), loading children on demand. Also the auto-action after a filter
// narrows to a specific category, so the matched files are visible.
async function pysimFsExpandAll() {
const stack = [pysimFsTreeRoot];
while (stack.length) {
const node = stack.pop();
if (!node || !node.isDir) continue;
if (node.exists === false) continue;
if (!node.children) {
await pysimFsLoadChildren(node);
if (node.exists === false) continue;
}
node.expanded = true;
for (const c of (node.children || [])) if (c.isDir) stack.push(c);
}
pysimFsRenderTree();
}
function pysimFsCollapseAll() {
const stack = [pysimFsTreeRoot];
while (stack.length) {
const node = stack.pop();
if (!node || !node.isDir) continue;
node.expanded = false;
for (const c of (node.children || [])) if (c.isDir) stack.push(c);
}
pysimFsRenderTree();
}
// A filter that narrows to a specific category expands the tree so the
// matched files are shown; "All"/empty leaves the tree as-is.
function pysimFsAutoExpandForFilter() {
if (pysimFsFilter.size > 0 && !pysimFsFilter.has(FS_FILTER_ALL)) pysimFsExpandAll();
}
let pysimFsSelected = null;
function pysimFsInfoHtml(sel, name, opts) {
@@ -28074,6 +28115,8 @@ const LANG_RU = {
'CB-related': 'Связанное с CB',
'Custom files': 'Пользовательские файлы',
'Selected: {n}': 'Выбрано: {n}',
'Expand all': 'Развернуть все',
'Collapse all': 'Свернуть все',
'Probe all files': 'Проверить все файлы',
'Feature': 'Функция',
'SST': 'SST',
+46 -3
View File
@@ -6,7 +6,7 @@ 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 re = new RegExp('(?:async\\s+)?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;
@@ -59,6 +59,8 @@ let _fsFilterPathsKey = null;
let pysimCustomFiles = [];
const _store = {};
let _treeRenders = 0;
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
let pysimFsLoadChildren = async node => { if (!node.children) node.children = []; };
globalThis.localStorage = {
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
@@ -121,7 +123,8 @@ for (const f of ['fsFilterServiceOptions', 'fsFilterIndexBuild', 'fsFilterIndex'
'fsFilterPathsKeyOf', 'pysimFsFilterPathsInvalidate', 'pysimFsFilterMatchingPaths',
'pysimFsFilterMatch', 'pysimFsFilterShow', 'pysimFsFilterLabel',
'pysimFsFilterSummary', 'pysimFsFilterRender', 'pysimFsFilterToggle',
'pysimFsFilterSetEntry', 'pysimFsFilterRestore', 'pysimFsNodePath']) {
'pysimFsFilterSetEntry', 'pysimFsFilterRestore', 'pysimFsNodePath',
'pysimFsExpandAll', 'pysimFsCollapseAll', 'pysimFsAutoExpandForFilter']) {
code += extractFunc(html, f) + '\n';
}
for (const c of ['FS_FILTER_GROUPS', 'FS_FILTER_GROUP_ORDER', 'SERVICES_FS_MAP']) code += extractConst(html, c);
@@ -454,6 +457,46 @@ test('the button turns blue when a filter is active and back for all', () => {
assert.strictEqual(dom.btn.classes.has('text-gray-700'), true);
});
test('expand all and collapse all walk the directory tree, skipping absent DFs', async () => {
const telecom = { name: 'DF.TELECOM', fid: '7F10', isDir: true, exists: true, expanded: false,
parent: null, children: [
{ name: 'DF.PHONEBOOK', fid: '5F3A', isDir: true, exists: true, expanded: false, parent: null, children: null },
] };
const gsm = { name: 'DF.GSM', fid: '7F20', isDir: true, exists: false, expanded: false, parent: null, children: null };
pysimFsTreeRoot = { name: 'MF', fid: '3F00', isDir: true, exists: true, expanded: true, parent: null,
children: [gsm, telecom] };
let loads = 0;
pysimFsLoadChildren = async node => { if (!node.children) { node.children = []; } loads++; };
try {
_treeRenders = 0;
await pysimFsExpandAll();
assert.strictEqual(pysimFsTreeRoot.expanded, true);
assert.strictEqual(gsm.expanded, false, 'an absent DF is not expanded');
assert.strictEqual(telecom.expanded, true);
assert.strictEqual(telecom.children[0].expanded, true);
assert.ok(loads > 0, 'lazy children are loaded on demand');
assert.strictEqual(_treeRenders, 1);
pysimFsCollapseAll();
assert.strictEqual(pysimFsTreeRoot.expanded, false);
assert.strictEqual(telecom.expanded, false);
assert.strictEqual(telecom.children[0].expanded, false);
} finally {
pysimFsTreeRoot = null;
}
});
test('auto-expand fires only for a non-"all" filter', () => {
// expand-all (with a null tree) still re-renders once; pin the branch
pysimFsFilter = new Set(['all']);
_treeRenders = 0;
pysimFsAutoExpandForFilter();
assert.strictEqual(_treeRenders, 0, '"all" does not expand');
pysimFsFilter = new Set(['identifiers']);
pysimFsAutoExpandForFilter();
assert.strictEqual(_treeRenders, 1, 'a category expands the tree');
});
test('the menu marks the saved selection', () => {
pysimFsFilter = new Set(['svc:Short Message Storage (SMS)', 'identifiers']);
dom.menu = fakeMenu();
@@ -510,7 +553,7 @@ test('the checkbox rows keep "all" exclusive and persist the selection', () => {
pysimFsFilterSetEntry('sms', true);
assert.deepStrictEqual([...pysimFsFilter], ['sms']);
assert.strictEqual(_store.simple_fs_filter, '["sms"]');
assert.strictEqual(_treeRenders, 2);
assert.strictEqual(_treeRenders, 3); // render + the auto-expand's render
assert.strictEqual(box('sms').checked, true);
assert.strictEqual(box('all').checked, false);
assert.strictEqual(dom.btn.textContent, 'SMS-related');
+6 -3
View File
@@ -51,6 +51,7 @@ let _fsFilterPathsKey = null;
const _store = {};
let _renders = 0;
let _treeRenders = 0;
let pysimFsTreeRoot = null; // expand-all walks this; null = nothing to expand
globalThis.localStorage = {
getItem: k => (Object.prototype.hasOwnProperty.call(_store, k) ? _store[k] : null),
@@ -64,10 +65,12 @@ globalThis.pysimFsRenderTree = () => { _treeRenders++; };
let code = '';
for (const f of ['servicesFeatureByNumber', 'pysimFsFilterBtnHtml', 'pysimFsSetFilterToService',
'pysimFsFilterPathsInvalidate']) {
'pysimFsFilterPathsInvalidate', 'pysimFsExpandAll', 'pysimFsCollapseAll',
'pysimFsAutoExpandForFilter']) {
code += extractFunc(html, f) + '\n';
}
for (const c of ['SERVICES_CROSS_REF', 'SERVICES_FS_MAP']) code += extractConst(html, c);
code += "var FS_FILTER_ALL = 'all';\n";
eval(code);
test('servicesFeatureByNumber inverts the cross-reference per file table', () => {
@@ -107,12 +110,12 @@ test('pysimFsSetFilterToService narrows the tree to that service', () => {
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
assert.strictEqual(_store.simple_fs_filter, JSON.stringify(['svc:Fixed Dialling Numbers (FDN)']));
assert.strictEqual(_renders, 1);
assert.strictEqual(_treeRenders, 1);
assert.strictEqual(_treeRenders, 2); // render + the auto-expand's render
// a capability-only service changes nothing
pysimFsSetFilterToService('ust', 99);
assert.deepStrictEqual([...pysimFsFilter], ['svc:Fixed Dialling Numbers (FDN)']);
assert.strictEqual(_renders, 1);
assert.strictEqual(_treeRenders, 1);
assert.strictEqual(_treeRenders, 2);
});
test('the four service-table fields carry their table, EF.ACC carries none', () => {