@@ -16073,6 +16076,52 @@ function pysimFsSetSort(mode) {
pysimFsRenderTree();
}
+// The tree filter: a multiselect of file criteria, applied when the tree
+// renders. The option list is large (dozens of items) and not decided yet —
+// populate FS_FILTER_OPTIONS later; the mechanism (state, persistence,
+// per-node match, render wiring) is scaffolded here.
+const FS_FILTER_OPTIONS = [
+ // { id: 'decoded-editor', label: 'Has a decoded editor' },
+ // { id: 'present', label: 'Present on card' },
+ // ... to be decided
+];
+
+let pysimFsFilter = new Set();
+try {
+ const saved = JSON.parse(localStorage.getItem('simple_fs_filter') || '[]');
+ if (Array.isArray(saved)) pysimFsFilter = new Set(saved);
+} catch (e) { /* ignore */ }
+
+function pysimFsFilterRender() {
+ const el = document.getElementById('pysim-fs-filter');
+ if (!el) return;
+ if (!FS_FILTER_OPTIONS.length) {
+ el.innerHTML = '';
+ return;
+ }
+ el.innerHTML = FS_FILTER_OPTIONS.map(o =>
+ '').join('');
+ el.querySelectorAll('option').forEach(o => { o.selected = pysimFsFilter.has(o.value); });
+}
+
+function pysimFsSetFilter() {
+ const el = document.getElementById('pysim-fs-filter');
+ if (!el) return;
+ pysimFsFilter = new Set(Array.from(el.selectedOptions).map(o => o.value));
+ localStorage.setItem('simple_fs_filter', JSON.stringify(Array.from(pysimFsFilter)));
+ pysimFsRenderTree();
+}
+
+// Whether a node passes the active filter. With no filter active the tree
+// shows everything. When FS_FILTER_OPTIONS is populated, this maps each
+// active id to a per-node test (kind, name/fid, presence, decoded-editor
+// availability, ...). Note for later: a directory should stay visible when
+// it contains any matching descendant, not only when it matches itself.
+function pysimFsFilterMatch(node) {
+ if (!pysimFsFilter.size) return true;
+ return true;
+}
+
async function pysimFsRefresh() {
const detailEl = document.getElementById('pysim-fs-detail');
detailEl.classList.add('hidden');
@@ -16271,6 +16320,7 @@ function pysimFsRenderNode(node, depth) {
html += '
' + pad + ' ' + esc(t('(empty)')) + '
';
} else {
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
+ if (!pysimFsFilterMatch(child)) continue;
html += pysimFsRenderNode(child, depth + 1);
}
}
@@ -27713,6 +27763,8 @@ const LANG_RU = {
'No entries yet.': 'Записей пока нет.',
'Sort:': 'Сортировка:',
'Name': 'Имя',
+ 'Filter:': 'Фильтр:',
+ '(no filters yet)': '(фильтров пока нет)',
'Probe all files': 'Проверить все файлы',
'Feature': 'Функция',
'SST': 'SST',
@@ -28745,6 +28797,7 @@ function isViewVisible(id) {
// Dynamic views build their labels with t() at render time; re-render the
// visible ones after a language switch so they don't stay in the old language.
function refreshDynamicI18n() {
+ pysimFsFilterRender();
if (isViewVisible('tab-profiler')) {
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
else if (profilerView === 'editor') profilerRenderEditor();
@@ -28820,6 +28873,7 @@ document.getElementById('theme-btn').textContent = document.documentElement.clas
updateConvFields();
updateSp();
uiccStdFilesLoad();
+pysimFsFilterRender();
updateSpKic();
updateSpKid();
chainInit('chain-sim');
diff --git a/frontend/tests/fs_filter.test.js b/frontend/tests/fs_filter.test.js
new file mode 100644
index 0000000..e10ea81
--- /dev/null
+++ b/frontend/tests/fs_filter.test.js
@@ -0,0 +1,56 @@
+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);
+}
+
+// module state the extracted match function closes over
+let pysimFsFilter = new Set();
+eval(extractFunc(html, 'pysimFsFilterMatch'));
+
+test('the file tree has a multiselect filter next to the sort pills', () => {
+ assert.match(html, /id="pysim-fs-filter"[^>]*multiple[^>]*onchange="pysimFsSetFilter\(\)"/);
+ assert.match(html, /data-l10n="Filter:"/);
+});
+
+test('the filter options list is scaffolded and empty for now', () => {
+ assert.match(html, /const FS_FILTER_OPTIONS = \[/);
+});
+
+test('the render consults the filter when walking children', () => {
+ assert.match(html, /pysimFsFilterMatch\(child\)/);
+});
+
+test('the filter state is persisted under simple_fs_filter', () => {
+ const fn = extractFunc(html, 'pysimFsSetFilter');
+ assert.ok(fn.indexOf("localStorage.setItem('simple_fs_filter'") > 0, fn);
+ assert.ok(fn.indexOf('pysimFsRenderTree()') > 0, fn);
+});
+
+test('the dropdown is re-rendered on language switch', () => {
+ const fn = extractFunc(html, 'refreshDynamicI18n');
+ assert.ok(fn.indexOf('pysimFsFilterRender()') > 0, fn);
+});
+
+test('an empty filter matches everything (scaffold no-op until criteria land)', () => {
+ pysimFsFilter = new Set();
+ assert.strictEqual(pysimFsFilterMatch({ name: 'EF.ADN', fid: '6F3A' }), true);
+ assert.strictEqual(pysimFsFilterMatch({ name: 'DF.GSM', fid: '7F20', isDir: true }), true);
+});
diff --git a/frontend/tests/fs_render.test.js b/frontend/tests/fs_render.test.js
index 8d3ed5c..2e0350e 100644
--- a/frontend/tests/fs_render.test.js
+++ b/frontend/tests/fs_render.test.js
@@ -22,8 +22,10 @@ function extractFunc(src, name) {
}
let code = 'var pysimFsSort = "fid";\n';
+code += 'var pysimFsFilter = new Set();\n';
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
code += extractFunc(html, 'pysimFsNodePath') + '\n';
+code += extractFunc(html, 'pysimFsFilterMatch') + '\n';
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);