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:
1 parent
cc7c23a9c7
commit
4a4232de5c
3 files changed
+95
-6
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user