From 38781566466f39673e99c6214077324f616b393e 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 13:03:22 +0300 Subject: [PATCH] file manager: scaffold the tree filter (multiselect) Add a Filter multiselect next to the sort pills in the file tree toolbar. The option list is large and undecided, so FS_FILTER_OPTIONS is empty and the per-node match is a no-op for now; the mechanism is wired end to end: - FS_FILTER_OPTIONS const (empty), pysimFsFilter state persisted to simple_fs_filter, pysimFsSetFilter / pysimFsFilterMatch / pysimFsFilterRender, and the render loop consults pysimFsFilterMatch. - The dropdown re-renders on language switch; "Filter:" and "(no filters yet)" are translated (EN/RU). Frontend 1132 tests green (6 new scaffold pins). --- frontend/index.html | 54 ++++++++++++++++++++++++++++++ frontend/tests/fs_filter.test.js | 56 ++++++++++++++++++++++++++++++++ frontend/tests/fs_render.test.js | 2 ++ 3 files changed, 112 insertions(+) create mode 100644 frontend/tests/fs_filter.test.js diff --git a/frontend/index.html b/frontend/index.html index 18ffa68..7b3013d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1117,6 +1117,9 @@ Sort: FID Name + · + Filter: + @@ -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);