From 4a4232de5c3d875cf0ffb806bb0ef2d671fe522f 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: Mon, 26 Oct 2026 15:05:49 +0300 Subject: [PATCH] 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.) --- frontend/index.html | 43 ++++++++++++++++++++++ frontend/tests/fs_filter.test.js | 49 ++++++++++++++++++++++++-- frontend/tests/fs_service_link.test.js | 9 +++-- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index e11aef1..b7a4bec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1117,6 +1117,9 @@ Sort: FID Name + · + +
Filter: @@ -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', diff --git a/frontend/tests/fs_filter.test.js b/frontend/tests/fs_filter.test.js index 2a447f5..b6bc490 100644 --- a/frontend/tests/fs_filter.test.js +++ b/frontend/tests/fs_filter.test.js @@ -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'); diff --git a/frontend/tests/fs_service_link.test.js b/frontend/tests/fs_service_link.test.js index b1e51ba..47393ad 100644 --- a/frontend/tests/fs_service_link.test.js +++ b/frontend/tests/fs_service_link.test.js @@ -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', () => {