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).
This commit is contained in:
1 parent
105efe62da
commit
3878156646
3 files changed
+112
No files matched your search
@@ -1117,6 +1117,9 @@
|
|||||||
<span data-l10n="Sort:">Sort:</span>
|
<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-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="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>
|
||||||
|
<span data-l10n="Filter:">Filter:</span>
|
||||||
|
<select id="pysim-fs-filter" multiple size="4" onchange="pysimFsSetFilter()" class="text-xs bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded px-1 py-0.5 text-gray-700 dark:text-slate-200 max-w-48"></select>
|
||||||
</div>
|
</div>
|
||||||
<button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button>
|
<button id="pysim-fs-probe-btn" data-needs="card" onclick="pysimFsProbeAll()" class="w-full mb-1 px-2 py-0.5 rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Probe all files">Probe all files</button>
|
||||||
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
|
<div id="pysim-fs-probe-status" class="hidden mb-1 text-gray-500 dark:text-slate-400"></div>
|
||||||
@@ -16073,6 +16076,52 @@ function pysimFsSetSort(mode) {
|
|||||||
pysimFsRenderTree();
|
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 = '<option disabled>' + esc(t('(no filters yet)')) + '</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = FS_FILTER_OPTIONS.map(o =>
|
||||||
|
'<option value="' + esc(o.id) + '">' + esc(t(o.label)) + '</option>').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() {
|
async function pysimFsRefresh() {
|
||||||
const detailEl = document.getElementById('pysim-fs-detail');
|
const detailEl = document.getElementById('pysim-fs-detail');
|
||||||
detailEl.classList.add('hidden');
|
detailEl.classList.add('hidden');
|
||||||
@@ -16271,6 +16320,7 @@ function pysimFsRenderNode(node, depth) {
|
|||||||
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
html += '<div class="text-gray-400 dark:text-slate-500">' + pad + ' ' + esc(t('(empty)')) + '</div>';
|
||||||
} else {
|
} else {
|
||||||
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
for (const child of pysimFsSortChildren(node.children, pysimFsSort)) {
|
||||||
|
if (!pysimFsFilterMatch(child)) continue;
|
||||||
html += pysimFsRenderNode(child, depth + 1);
|
html += pysimFsRenderNode(child, depth + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27713,6 +27763,8 @@ const LANG_RU = {
|
|||||||
'No entries yet.': 'Записей пока нет.',
|
'No entries yet.': 'Записей пока нет.',
|
||||||
'Sort:': 'Сортировка:',
|
'Sort:': 'Сортировка:',
|
||||||
'Name': 'Имя',
|
'Name': 'Имя',
|
||||||
|
'Filter:': 'Фильтр:',
|
||||||
|
'(no filters yet)': '(фильтров пока нет)',
|
||||||
'Probe all files': 'Проверить все файлы',
|
'Probe all files': 'Проверить все файлы',
|
||||||
'Feature': 'Функция',
|
'Feature': 'Функция',
|
||||||
'SST': 'SST',
|
'SST': 'SST',
|
||||||
@@ -28745,6 +28797,7 @@ function isViewVisible(id) {
|
|||||||
// Dynamic views build their labels with t() at render time; re-render the
|
// 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.
|
// visible ones after a language switch so they don't stay in the old language.
|
||||||
function refreshDynamicI18n() {
|
function refreshDynamicI18n() {
|
||||||
|
pysimFsFilterRender();
|
||||||
if (isViewVisible('tab-profiler')) {
|
if (isViewVisible('tab-profiler')) {
|
||||||
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
|
if (profilerView === 'list') { profilerRenderList(); profilerRenderSnapshotList(); if (profilerListTab === 'custom') pysimCustomRender(); }
|
||||||
else if (profilerView === 'editor') profilerRenderEditor();
|
else if (profilerView === 'editor') profilerRenderEditor();
|
||||||
@@ -28820,6 +28873,7 @@ document.getElementById('theme-btn').textContent = document.documentElement.clas
|
|||||||
updateConvFields();
|
updateConvFields();
|
||||||
updateSp();
|
updateSp();
|
||||||
uiccStdFilesLoad();
|
uiccStdFilesLoad();
|
||||||
|
pysimFsFilterRender();
|
||||||
updateSpKic();
|
updateSpKic();
|
||||||
updateSpKid();
|
updateSpKid();
|
||||||
chainInit('chain-sim');
|
chainInit('chain-sim');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -22,8 +22,10 @@ function extractFunc(src, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let code = 'var pysimFsSort = "fid";\n';
|
let code = 'var pysimFsSort = "fid";\n';
|
||||||
|
code += 'var pysimFsFilter = new Set();\n';
|
||||||
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
|
||||||
code += extractFunc(html, 'pysimFsNodePath') + '\n';
|
code += extractFunc(html, 'pysimFsNodePath') + '\n';
|
||||||
|
code += extractFunc(html, 'pysimFsFilterMatch') + '\n';
|
||||||
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
|
||||||
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
|
||||||
eval(code);
|
eval(code);
|
||||||
|
|||||||
Reference in new issue
Block a user