From 7d2f9872332ee826343d7f4069d1092511b6fd73 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 8 Sep 2026 15:11:11 -0400 Subject: [PATCH 1/5] feat(plugins): add search, filter and sort to Installed Plugins, on a shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Installed Plugins grid had no way to narrow it down: no search, no way to see only what's enabled, disabled, or out of date. On a rig with a couple dozen plugins that means scrolling the whole grid to find one. The two sections below it already solved this, twice, independently — the Plugin Store and Starlark Apps carried a copy-paste fork of the same ~600 lines (filter state, apply-filters-and-sort, page renderer, pagination strip, active-filter badge, listener wiring). Rather than add a third copy, this extracts the shared machinery and builds the new toolbar on it. New: web_interface/static/v3/js/plugins/list_filter.js — ListFilter.create() owns debounced search, filter axes, sort, the active-filter count, Clear, and optional pagination/persistence. Callers keep their own card markup via a `render` callback. Three control types cover every axis the page uses: pills (new), select (store category, starlark author) and cycle (the tri-state All -> Installed -> Not Installed button). Installed Plugins gets a compact toolbar: search box, one-click All / Enabled / Disabled / Updates pills, and a sort dropdown (A-Z, Z-A, updates first, recently updated, category). Filters reset on load, so you never come back to a mysteriously short list. No new CSS — this is the first consumer of the .filter-pill rules already sitting unused in app.css. renderInstalledPlugins() is split so it still publishes canonical state while renderInstalledCards() draws only the visible subset; the filtered list is never assigned to window.installedPlugins, which the toggle handler, isStorePluginInstalled(), runUpdateAllPlugins() and the Alpine config tabs all read as their source of truth. Toggling a plugin while filtered pins its card so it doesn't vanish from under the cursor. The Store and Starlark migrations are behaviour-preserving: same element ids, same localStorage keys (storeSort/storePerPage, starlarkSort/starlarkPerPage), same tri-state button markup, same pagination. Verified by differential tests that run the old and new implementations side by side against identical fixtures and compare every observable after each interaction. The only visible change is the pagination attribute (data-store-page/data-starlark-page -> data-list-page), which nothing outside its own click handler referenced. Net -156 lines in plugins_manager.js while adding a feature. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../static/v3/js/plugins/list_filter.js | 450 ++++++++ web_interface/static/v3/plugins_manager.js | 1020 +++++++---------- web_interface/templates/v3/base.html | 4 +- .../templates/v3/partials/plugins.html | 52 + 4 files changed, 937 insertions(+), 589 deletions(-) create mode 100644 web_interface/static/v3/js/plugins/list_filter.js diff --git a/web_interface/static/v3/js/plugins/list_filter.js b/web_interface/static/v3/js/plugins/list_filter.js new file mode 100644 index 00000000..b1fab69f --- /dev/null +++ b/web_interface/static/v3/js/plugins/list_filter.js @@ -0,0 +1,450 @@ +/** + * ListFilter — shared search / filter / sort controller for the card-grid + * sections of the Plugin Manager. + * + * The Installed Plugins, Plugin Store and Starlark Apps sections all need the + * same machinery: debounced text search over a few fields, a handful of filter + * axes, a sort dropdown, an active-filter count with a Clear button, and a + * re-render. This owns that machinery; the caller keeps ownership of its own + * card markup via the `render` callback. + * + * Usage: + * + * const ctl = ListFilter.create({ + * getItems: () => window.installedPlugins || [], + * render: (visible, total) => renderCards(visible, total), + * search: { el: 'my-search', fields: ['name', 'id', 'tags'] }, + * sort: { el: 'my-sort', default: 'a-z', comparators: { 'a-z': fn } }, + * controls: [{ type: 'pills', el: '#my-pills', attr: 'data-my-filter', + * key: 'filter', default: 'all', test: (item, v) => true }], + * clearEl: 'my-clear', + * }); + * ctl.bind(); // idempotent — safe to call after every HTMX partial swap + * ctl.apply(); // filter + sort + render + * + * Optional `pagination` slices the result set and renders page controls; the + * `render` callback then receives just the current page. Optional `persist` + * takes read/write callbacks so the caller — not this helper — owns its + * storage keys. + * + * Element references are DOM ids, except `controls[].el` which is a CSS + * selector for the pill container. + */ +const ListFilter = (function () { + 'use strict'; + + function debounce(fn, wait) { + let timer = null; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), wait); + }; + } + + function byId(id) { + return id ? document.getElementById(id) : null; + } + + // Filter axes compare against their default to decide "is this axis active", + // so null/undefined/'' must not be conflated with a real selection. + function sameValue(a, b) { + if (a === b) return true; + if (a === null || a === undefined) return b === null || b === undefined; + return false; + } + + // Build the lowercased search haystack. Array fields (e.g. tags) are + // flattened in, matching the existing store/starlark search behaviour. + function haystack(item, fields) { + const parts = []; + (fields || []).forEach(field => { + const value = item ? item[field] : null; + if (Array.isArray(value)) { + value.forEach(v => { if (v) parts.push(String(v)); }); + } else if (value) { + parts.push(String(value)); + } + }); + return parts.join(' ').toLowerCase(); + } + + function create(config) { + const cfg = config || {}; + const searchCfg = cfg.search || null; + const sortCfg = cfg.sort || null; + const controls = Array.isArray(cfg.controls) ? cfg.controls : []; + const pageCfg = cfg.pagination || null; + const persistCfg = cfg.persist || null; + const idOf = typeof cfg.idOf === 'function' ? cfg.idOf : (item => item && item.id); + + // Defaults double as the "inactive" value for each axis. + const defaults = {}; + if (searchCfg) defaults.search = ''; + if (sortCfg) defaults.sort = sortCfg.default !== undefined ? sortCfg.default : 'a-z'; + controls.forEach(c => { + defaults[c.key] = c.default !== undefined ? c.default : null; + }); + + const state = Object.assign({}, defaults); + + // page/perPage sit outside `defaults` on purpose: Clear Filters returns + // to page 1 but must NOT reset a per-page size the user chose. + if (pageCfg) { + state.page = 1; + state.perPage = pageCfg.defaultPerPage || 12; + } + + // Seed persisted values. The caller supplies read()/write() so storage + // keys stay where they always were. + if (persistCfg && typeof persistCfg.read === 'function') { + const saved = persistCfg.read() || {}; + if (sortCfg && saved.sort !== undefined && saved.sort !== null) state.sort = saved.sort; + if (pageCfg && saved.perPage) state.perPage = saved.perPage; + } + + function persist() { + if (persistCfg && typeof persistCfg.write === 'function') persistCfg.write(state); + } + + // Ids that stay visible even when they no longer match the active + // filters. Populated by the caller when the user acts on a card (e.g. + // toggling a plugin off while filtering by Enabled) so the card they + // just clicked doesn't vanish underneath the cursor. Cleared as soon as + // the user touches the toolbar. + const sticky = new Set(); + + function activeCount() { + let n = 0; + if (searchCfg && state.search) n++; + if (sortCfg && !sameValue(state.sort, defaults.sort)) n++; + controls.forEach(c => { + if (!sameValue(state[c.key], defaults[c.key])) n++; + }); + return n; + } + + function matches(item) { + if (searchCfg && state.search) { + if (!haystack(item, searchCfg.fields).includes(state.search.toLowerCase())) { + return false; + } + } + for (let i = 0; i < controls.length; i++) { + const c = controls[i]; + const value = state[c.key]; + if (sameValue(value, defaults[c.key])) continue; // axis inactive + if (typeof c.test === 'function' && !c.test(item, value)) return false; + } + return true; + } + + function compute() { + const all = (typeof cfg.getItems === 'function' ? cfg.getItems() : null) || []; + const total = all.length; + const list = all.filter(item => { + if (sticky.size > 0 && sticky.has(idOf(item))) return true; + return matches(item); + }); + + if (sortCfg && sortCfg.comparators) { + // An unrecognised sort key falls back to the default comparator, + // matching the switch-with-default the store code used. + const cmp = sortCfg.comparators[state.sort] || sortCfg.comparators[defaults.sort]; + if (typeof cmp === 'function') list.sort(cmp); + } + return { list: list, total: total }; + } + + // Reflect current state back onto the controls, so programmatic changes + // and a fresh partial swap both land on a correctly-lit toolbar. + function syncControls() { + if (searchCfg) { + // The toolbar markup is rebuilt on every HTMX partial swap while + // this controller (and its state) survives — put the text back. + const el = byId(searchCfg.el); + if (el && el.value !== state.search) el.value = state.search; + } + if (sortCfg) { + const el = byId(sortCfg.el); + if (el && el.value !== state.sort) el.value = state.sort; + } + if (pageCfg && pageCfg.perPageEl) { + const el = byId(pageCfg.perPageEl); + if (el && el.value !== String(state.perPage)) el.value = String(state.perPage); + } + controls.forEach(c => { + const value = state[c.key]; + if (c.type === 'pills') { + const container = document.querySelector(c.el); + if (!container) return; + container.querySelectorAll('[' + c.attr + ']').forEach(btn => { + const on = btn.getAttribute(c.attr) === String(value); + btn.setAttribute('data-active', on ? 'true' : 'false'); + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + } else if (c.type === 'select') { + const el = byId(c.el); + if (el && el.value !== (value === null ? '' : value)) { + el.value = value === null ? '' : value; + } + } else if (c.type === 'cycle') { + const btn = byId(c.el); + // The caller renders cycle buttons so each section keeps its + // own label/icon/class treatment. + if (btn && typeof c.render === 'function') c.render(btn, value); + } + }); + } + + function updateChrome(list, total) { + const n = activeCount(); + + const countEl = byId(cfg.countEl); + if (countEl && typeof cfg.countFormat === 'function') { + countEl.textContent = cfg.countFormat(list.length, total, n > 0); + } + + const activeEl = byId(cfg.activeCountEl); + if (activeEl) { + activeEl.classList.toggle('hidden', n === 0); + activeEl.textContent = n + ' filter' + (n !== 1 ? 's' : '') + ' active'; + } + + const clearEl = byId(cfg.clearEl); + if (clearEl) clearEl.classList.toggle('hidden', n === 0); + + if (searchCfg && searchCfg.clearEl) { + const searchClear = byId(searchCfg.clearEl); + if (searchClear) searchClear.classList.toggle('hidden', !state.search); + } + + syncControls(); + + if (typeof cfg.onChrome === 'function') cfg.onChrome(state, list, total); + } + + // Page-number strip with leading/trailing ellipsis, matching the markup + // the plugin store has always produced. + function renderPagination(containerId, totalPages, currentPage) { + const container = byId(containerId); + if (!container) return; + + if (totalPages <= 1) { container.innerHTML = ''; return; } + + const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; + const activeClass = 'bg-blue-600 text-white border-blue-600'; + const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; + const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; + + let html = ''; + html += ``; + + const pages = []; + pages.push(1); + if (currentPage > 3) pages.push('...'); + for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { + pages.push(i); + } + if (currentPage < totalPages - 2) pages.push('...'); + if (totalPages > 1) pages.push(totalPages); + + pages.forEach(p => { + if (p === '...') { + html += ``; + } else { + html += ``; + } + }); + + html += ``; + + container.innerHTML = html; + + container.querySelectorAll('[data-list-page]').forEach(btn => { + btn.addEventListener('click', function () { + const p = parseInt(this.getAttribute('data-list-page')); + if (p >= 1 && p <= totalPages && p !== currentPage) { + state.page = p; + // Page moves re-slice only; filters and sort are unchanged. + apply(true); + const grid = byId(pageCfg && pageCfg.scrollToEl); + if (grid && typeof grid.scrollIntoView === 'function') { + grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }); + }); + } + + function apply(skipPageReset) { + const result = compute(); + + if (!pageCfg) { + updateChrome(result.list, result.total); + if (typeof cfg.render === 'function') cfg.render(result.list, result.total); + return result; + } + + if (!skipPageReset) state.page = 1; + + const total = result.list.length; + const totalPages = Math.max(1, Math.ceil(total / state.perPage)); + if (state.page > totalPages) state.page = totalPages; + + const start = (state.page - 1) * state.perPage; + const end = Math.min(start + state.perPage, total); + const pageItems = result.list.slice(start, end); + + const info = total > 0 + ? (typeof pageCfg.infoFormat === 'function' + ? pageCfg.infoFormat(start + 1, end, total) + : `Showing ${start + 1}\u2013${end} of ${total}`) + : (pageCfg.emptyText || 'No results match your filters'); + [pageCfg.infoEl, pageCfg.infoBottomEl].forEach(id => { + const el = byId(id); + if (el) el.textContent = info; + }); + + renderPagination(pageCfg.topEl, totalPages, state.page); + renderPagination(pageCfg.bottomEl, totalPages, state.page); + + updateChrome(result.list, result.total); + if (typeof cfg.render === 'function') cfg.render(pageItems, result.total); + return result; + } + + function setSearch(value) { + state.search = (value || '').trim(); + sticky.clear(); + apply(); + } + + function reset() { + // Only the filter axes reset; a chosen page size is a preference, + // not a filter, so it survives Clear Filters. + Object.assign(state, defaults); + if (pageCfg) state.page = 1; + sticky.clear(); + if (searchCfg) { + const el = byId(searchCfg.el); + if (el) el.value = ''; + } + persist(); + syncControls(); + apply(); + } + + function bind() { + if (searchCfg) { + const input = byId(searchCfg.el); + if (input && !input._listFilterInit) { + input._listFilterInit = true; + const run = debounce(() => setSearch(input.value), searchCfg.debounceMs || 300); + input.addEventListener('input', run); + input.addEventListener('keydown', e => { + if (e.key === 'Escape') { + input.value = ''; + setSearch(''); + } + }); + } + const searchClear = searchCfg.clearEl ? byId(searchCfg.clearEl) : null; + if (searchClear && !searchClear._listFilterInit) { + searchClear._listFilterInit = true; + searchClear.addEventListener('click', () => { + const el = byId(searchCfg.el); + if (el) el.value = ''; + setSearch(''); + }); + } + } + + if (sortCfg) { + const el = byId(sortCfg.el); + if (el && !el._listFilterInit) { + el._listFilterInit = true; + el.addEventListener('change', function () { + state.sort = this.value; + sticky.clear(); + persist(); + apply(); + }); + } + } + + controls.forEach(c => { + if (c.type === 'pills') { + const container = document.querySelector(c.el); + if (!container || container._listFilterInit) return; + container._listFilterInit = true; + // Delegated, so the pills survive any markup re-render. + container.addEventListener('click', event => { + const btn = event.target.closest('[' + c.attr + ']'); + if (!btn || !container.contains(btn)) return; + state[c.key] = btn.getAttribute(c.attr); + sticky.clear(); + apply(); + }); + } else if (c.type === 'select') { + const el = byId(c.el); + if (!el || el._listFilterInit) return; + el._listFilterInit = true; + el.addEventListener('change', function () { + state[c.key] = this.value; + sticky.clear(); + apply(); + }); + } else if (c.type === 'cycle') { + const btn = byId(c.el); + if (!btn || btn._listFilterInit) return; + btn._listFilterInit = true; + const values = Array.isArray(c.values) ? c.values : [null]; + btn.addEventListener('click', () => { + const at = values.findIndex(v => sameValue(v, state[c.key])); + state[c.key] = values[(at + 1) % values.length]; + sticky.clear(); + apply(); + }); + } + }); + + if (pageCfg && pageCfg.perPageEl) { + const el = byId(pageCfg.perPageEl); + if (el && !el._listFilterInit) { + el._listFilterInit = true; + el.addEventListener('change', function () { + state.perPage = parseInt(this.value) || (pageCfg.defaultPerPage || 12); + persist(); + apply(); + }); + } + } + + const clearEl = byId(cfg.clearEl); + if (clearEl && !clearEl._listFilterInit) { + clearEl._listFilterInit = true; + clearEl.addEventListener('click', reset); + } + } + + return { + state: state, + sticky: sticky, + activeCount: activeCount, + bind: bind, + apply: apply, + reset: reset, + setSearch: setSearch, + syncControls: syncControls, + }; + } + + return { create: create }; +})(); + +// Export +if (typeof module !== 'undefined' && module.exports) { + module.exports = ListFilter; +} else { + window.ListFilter = ListFilter; +} diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 80a924fa..995f4987 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -884,40 +884,11 @@ window.currentPluginConfig = null; let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads let cacheTimestamp = null; const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds - let storeFilteredList = []; function storeCacheExpired() { return !cacheTimestamp || (Date.now() - cacheTimestamp >= CACHE_DURATION); } - // ── Plugin Store Filter State ─────────────────────────────────────────── - const storeFilterState = { - sort: safeLocalStorage.getItem('storeSort') || 'a-z', - filterCategory: '', - filterInstalled: null, // null=all, true=installed, false=not-installed - searchQuery: '', - page: 1, - perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || 12, - persist() { - safeLocalStorage.setItem('storeSort', this.sort); - safeLocalStorage.setItem('storePerPage', this.perPage); - }, - reset() { - this.sort = 'a-z'; - this.filterCategory = ''; - this.filterInstalled = null; - this.searchQuery = ''; - this.page = 1; - }, - activeCount() { - let n = 0; - if (this.searchQuery) n++; - if (this.filterInstalled !== null) n++; - if (this.filterCategory) n++; - if (this.sort !== 'a-z') n++; - return n; - } - }; let onDemandStatusInterval = null; let currentOnDemandPluginId = null; let hasLoadedOnDemandStatus = false; @@ -1069,11 +1040,8 @@ window.initPluginsPage = function() { restartBtn.replaceWith(restartBtn.cloneNode(true)); document.getElementById('restart-display-btn').addEventListener('click', restartDisplay); } - // Restore persisted store sort/perPage - const storeSortEl = document.getElementById('store-sort'); - if (storeSortEl) storeSortEl.value = storeFilterState.sort; - const storePpEl = document.getElementById('store-per-page'); - if (storePpEl) storePpEl.value = storeFilterState.perPage; + // Persisted store sort/perPage are restored by the controller's syncControls(). + setupInstalledFilterListeners(); setupStoreFilterListeners(); if (closeOnDemandModalBtn) { @@ -1328,13 +1296,9 @@ function loadInstalledPlugins(forceRefresh = false) { }); } + // Also refreshes the '#installed-count' text via the filter controller. renderInstalledPlugins(installedPlugins); - // Update count - const countEl = document.getElementById('installed-count'); - if (countEl) { - countEl.textContent = installedPlugins.length + ' installed'; - } return installedPlugins; } else { const errorMsg = 'Failed to load installed plugins: ' + data.message; @@ -1369,6 +1333,142 @@ function refreshInstalledPlugins() { window.pluginManager.loadInstalledPlugins = loadInstalledPlugins; // Note: searchPluginStore will be exposed after its definition (see below) +// ── Installed Plugins: search / filter / sort ─────────────────────────── +// Sort comparators for the installed-plugins toolbar. Kept beside the render +// code they drive rather than inside the ListFilter helper, which stays +// section-agnostic. +function installedSortName(plugin) { + return String((plugin && (plugin.name || plugin.id)) || '').toLowerCase(); +} + +// last_updated is the plugin's local git commit date (date_iso from +// _get_local_git_info, see api_v3.py). It can be absent or unparseable. +function installedUpdatedAt(plugin) { + const raw = plugin ? plugin.last_updated : null; + if (!raw) return null; + const t = Date.parse(raw); + return Number.isNaN(t) ? null : t; +} + +const INSTALLED_COMPARATORS = { + 'a-z': (a, b) => installedSortName(a).localeCompare(installedSortName(b)), + 'z-a': (a, b) => installedSortName(b).localeCompare(installedSortName(a)), + 'status': (a, b) => { + const rank = p => (p.update_available ? 0 : (p.enabled ? 1 : 2)); + const diff = rank(a) - rank(b); + return diff !== 0 ? diff : installedSortName(a).localeCompare(installedSortName(b)); + }, + 'recent': (a, b) => { + const ta = installedUpdatedAt(a); + const tb = installedUpdatedAt(b); + // Plugins with no usable timestamp sort to the end, never to the top. + if (ta === null && tb === null) return installedSortName(a).localeCompare(installedSortName(b)); + if (ta === null) return 1; + if (tb === null) return -1; + return tb - ta; + }, + 'category': (a, b) => { + const catCmp = String(a.category || '').localeCompare(String(b.category || '')); + return catCmp !== 0 ? catCmp : installedSortName(a).localeCompare(installedSortName(b)); + }, +}; + +let _installedFilter = null; + +// Created lazily so a script load-order problem degrades to "no filtering" +// instead of throwing while this file is still being evaluated. +function getInstalledFilter() { + if (_installedFilter) return _installedFilter; + if (!window.ListFilter || typeof window.ListFilter.create !== 'function') { + console.warn('[PLUGINS] ListFilter helper unavailable — installed plugin filters disabled'); + return null; + } + _installedFilter = window.ListFilter.create({ + // Always read the canonical list, never a captured snapshot. + getItems: () => window.installedPlugins || installedPlugins || [], + render: renderInstalledCards, + search: { + el: 'installed-search', + clearEl: 'installed-search-clear', + fields: ['name', 'id', 'description', 'author', 'category', 'tags'], + debounceMs: 200, + }, + sort: { + el: 'installed-sort', + default: 'a-z', + comparators: INSTALLED_COMPARATORS, + }, + controls: [{ + type: 'pills', + el: '#installed-filter-pills', + attr: 'data-installed-filter', + key: 'status', + default: 'all', + test: (plugin, value) => { + if (value === 'enabled') return Boolean(plugin.enabled); + if (value === 'disabled') return !plugin.enabled; + // update_available is computed server-side (api_v3.py) — never + // recompute it from version strings here. + if (value === 'updates') return Boolean(plugin.update_available); + return true; + }, + }], + clearEl: 'installed-clear-filters', + countEl: 'installed-count', + countFormat: (shown, total, filtered) => + filtered ? `${shown} of ${total} shown` : `${total} installed`, + onChrome: updateInstalledUpdatesBadge, + }); + return _installedFilter; +} + +// The Updates pill counts against the *full* list, not the filtered view, so +// the badge keeps telling you how much work is outstanding while you browse. +function updateInstalledUpdatesBadge() { + const all = window.installedPlugins || installedPlugins || []; + const n = all.filter(p => p && p.update_available).length; + + const badge = document.getElementById('installed-updates-count'); + if (badge) { + badge.textContent = String(n); + badge.classList.toggle('hidden', n === 0); + } + + const pill = document.querySelector('#installed-filter-pills [data-installed-filter="updates"]'); + if (pill) { + pill.classList.toggle('opacity-50', n === 0); + pill.title = n === 0 + ? 'No plugins have updates available' + : `Show only the ${n} plugin${n !== 1 ? 's' : ''} with a newer version available`; + } +} + +function applyInstalledFiltersAndRender() { + const ctl = getInstalledFilter(); + if (ctl) { + ctl.apply(); + return; + } + // Fallback: render everything, so the section still works without the helper. + const all = window.installedPlugins || installedPlugins || []; + renderInstalledCards(all, all.length); + const countEl = document.getElementById('installed-count'); + if (countEl) countEl.textContent = all.length + ' installed'; +} + +function setupInstalledFilterListeners() { + const ctl = getInstalledFilter(); + if (!ctl) return; + // Both are idempotent — the plugins partial is HTMX-swapped, so this runs + // again on every return to the tab. + ctl.bind(); + ctl.syncControls(); +} + +// Publishes the canonical installed-plugin list, then renders through the +// active filters. Everything that reads window.installedPlugins (the toggle +// handler, isStorePluginInstalled, runUpdateAllPlugins, the Alpine config tabs) +// depends on this receiving the FULL list — never a filtered subset. function renderInstalledPlugins(plugins) { const container = document.getElementById('installed-plugins-grid'); if (!container) { @@ -1399,11 +1499,25 @@ function renderInstalledPlugins(plugins) { } } + applyInstalledFiltersAndRender(); +} + +// Renders the card grid only. `plugins` is the visible (filtered) subset and +// `total` the full installed count — this must NOT touch window.installedPlugins. +function renderInstalledCards(plugins, total) { + const container = document.getElementById('installed-plugins-grid'); + if (!container) return; + + const totalCount = (typeof total === 'number') ? total : plugins.length; + // Remove skeleton cards before rendering real content container.querySelectorAll('.installed-skeleton').forEach(el => el.remove()); + // Attached before the early returns so the empty state's Clear button works. + setupInstalledEventDelegation(); + if (plugins.length === 0) { - container.innerHTML = ` + container.innerHTML = totalCount === 0 ? `
@@ -1411,6 +1525,18 @@ function renderInstalledPlugins(plugins) {

No plugins installed

Install plugins from the store to get started

+ ` : ` +
+
+ +
+

No plugins match your filters

+

${totalCount} plugin${totalCount !== 1 ? 's' : ''} installed, none matching the current search or filters.

+ +
`; return; } @@ -1519,39 +1645,34 @@ function renderInstalledPlugins(plugins) {
`; }).join(''); +} - // Set up event delegation for plugin action buttons (fallback if onclick doesn't work) - // Only set up once per container to avoid redundant listeners - const setupEventDelegation = () => { - const container = document.getElementById('installed-plugins-grid'); - if (!container) { - pluginLog('[RENDER] installed-plugins-grid not found for event delegation'); - return; - } - - // Skip if already set up (guard against multiple calls) - if (container._eventDelegationSetup) { - pluginLog('[RENDER] Event delegation already set up, skipping'); - return; - } - - // Mark as set up - container._eventDelegationSetup = true; - container._pluginActionHandler = handlePluginAction; +// Set up event delegation for plugin action buttons (fallback if onclick doesn't work) +// Only set up once per container to avoid redundant listeners, which is what +// makes it safe to rebuild the grid's innerHTML on every keystroke. +function setupInstalledEventDelegation() { + const container = document.getElementById('installed-plugins-grid'); + if (!container) { + pluginLog('[RENDER] installed-plugins-grid not found for event delegation'); + return; + } - // Add listeners for both click and change events - container.addEventListener('click', handlePluginAction, true); - container.addEventListener('change', handlePluginAction, true); - pluginLog('[RENDER] Event delegation set up for installed-plugins-grid'); - }; + // Skip if already set up (guard against multiple calls) + if (container._eventDelegationSetup) { + return; + } - // Set up immediately - setupEventDelegation(); + // Mark as set up + container._eventDelegationSetup = true; + container._pluginActionHandler = handlePluginAction; - // Also retry after a short delay to ensure it's attached even if container wasn't ready - setTimeout(setupEventDelegation, 100); + // Add listeners for both click and change events + container.addEventListener('click', handlePluginAction, true); + container.addEventListener('change', handlePluginAction, true); + pluginLog('[RENDER] Event delegation set up for installed-plugins-grid'); } + function handlePluginAction(event) { // Check for both button and input (for toggle) const button = event.target.closest('button[data-action]') || event.target.closest('input[data-action]'); @@ -1560,6 +1681,15 @@ function handlePluginAction(event) { const action = button.getAttribute('data-action'); const pluginId = button.getAttribute('data-plugin-id'); + // Grid-level actions have no plugin id (the empty state's Clear button). + if (action === 'clear-installed-filters') { + event.preventDefault(); + event.stopPropagation(); + const ctl = getInstalledFilter(); + if (ctl) ctl.reset(); + return; + } + if (!pluginId) return; event.preventDefault(); @@ -1587,6 +1717,13 @@ function handlePluginAction(event) { switch(action) { case 'toggle': + // Toggling under an Enabled/Disabled filter would otherwise make the + // card vanish the moment the server confirms. Pin it until the user + // next touches the toolbar. + { + const ctl = getInstalledFilter(); + if (ctl) ctl.sticky.add(pluginId); + } // Get the current enabled state from plugin data (source of truth) // rather than from the checkbox DOM which might be out of sync const plugin = (window.installedPlugins || []).find(p => p.id === pluginId); @@ -3494,249 +3631,130 @@ function isStorePluginInstalled(pluginIdOrPlugin) { return installed.some(p => p.id === storeId || (pathDerivedId && p.id === pathDerivedId)); } -function applyStoreFiltersAndSort(skipPageReset) { - if (!pluginStoreCache) return; - const st = storeFilterState; - - let list = pluginStoreCache.slice(); - - // Text search - if (st.searchQuery) { - const q = st.searchQuery.toLowerCase(); - list = list.filter(plugin => { - const hay = [ - plugin.name, plugin.description, plugin.author, - plugin.id, plugin.category, - ...(plugin.tags || []) - ].filter(Boolean).join(' ').toLowerCase(); - return hay.includes(q); - }); - } - - // Category filter - if (st.filterCategory) { - const cat = st.filterCategory.toLowerCase(); - list = list.filter(plugin => (plugin.category || '').toLowerCase() === cat); - } - - // Installed filter - if (st.filterInstalled === true) { - list = list.filter(plugin => isStorePluginInstalled(plugin)); - } else if (st.filterInstalled === false) { - list = list.filter(plugin => !isStorePluginInstalled(plugin)); - } - - // Sort - list.sort((a, b) => { - const nameA = (a.name || a.id || '').toLowerCase(); - const nameB = (b.name || b.id || '').toLowerCase(); - switch (st.sort) { - case 'z-a': return nameB.localeCompare(nameA); - case 'category': { - const catCmp = (a.category || '').localeCompare(b.category || ''); - return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB); - } - case 'author': { - const authCmp = (a.author || '').localeCompare(b.author || ''); - return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB); - } - case 'newest': { - const dateA = a.last_updated ? new Date(a.last_updated).getTime() : 0; - const dateB = b.last_updated ? new Date(b.last_updated).getTime() : 0; - return dateB - dateA; // newest first - } - default: return nameA.localeCompare(nameB); - } - }); - - storeFilteredList = list; - if (!skipPageReset) st.page = 1; - - renderStorePage(); - updateStoreFilterUI(); -} +// ── Plugin Store: search / filter / sort ──────────────────────────────── +// Behaviour, element ids and localStorage keys are unchanged from the +// hand-rolled version this replaces — only the machinery is now shared. +const STORE_COMPARATORS = { + 'a-z': (a, b) => storeSortName(a).localeCompare(storeSortName(b)), + 'z-a': (a, b) => storeSortName(b).localeCompare(storeSortName(a)), + 'category': (a, b) => { + const catCmp = (a.category || '').localeCompare(b.category || ''); + return catCmp !== 0 ? catCmp : storeSortName(a).localeCompare(storeSortName(b)); + }, + 'author': (a, b) => { + const authCmp = (a.author || '').localeCompare(b.author || ''); + return authCmp !== 0 ? authCmp : storeSortName(a).localeCompare(storeSortName(b)); + }, + 'newest': (a, b) => { + // Missing dates count as epoch 0, so they land last under a descending + // sort — same as before. + const dateA = a.last_updated ? new Date(a.last_updated).getTime() : 0; + const dateB = b.last_updated ? new Date(b.last_updated).getTime() : 0; + return dateB - dateA; // newest first + }, +}; -function renderStorePage() { - const st = storeFilterState; - const total = storeFilteredList.length; - const totalPages = Math.max(1, Math.ceil(total / st.perPage)); - if (st.page > totalPages) st.page = totalPages; - - const start = (st.page - 1) * st.perPage; - const end = Math.min(start + st.perPage, total); - const pagePlugins = storeFilteredList.slice(start, end); - - // Results info - const info = total > 0 - ? `Showing ${start + 1}\u2013${end} of ${total} plugins` - : 'No plugins match your filters'; - const infoEl = document.getElementById('store-results-info'); - const infoElBot = document.getElementById('store-results-info-bottom'); - if (infoEl) infoEl.textContent = info; - if (infoElBot) infoElBot.textContent = info; - - // Pagination - renderStorePagination('store-pagination-top', totalPages, st.page); - renderStorePagination('store-pagination-bottom', totalPages, st.page); - - // Grid - renderPluginStore(pagePlugins); +function storeSortName(plugin) { + return (plugin.name || plugin.id || '').toLowerCase(); } -function renderStorePagination(containerId, totalPages, currentPage) { - const container = document.getElementById(containerId); - if (!container) return; - - if (totalPages <= 1) { container.innerHTML = ''; return; } - - const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; - const activeClass = 'bg-blue-600 text-white border-blue-600'; - const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; - const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; +let _storeFilter = null; - let html = ''; - html += ``; - - const pages = []; - pages.push(1); - if (currentPage > 3) pages.push('...'); - for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { - pages.push(i); +function getStoreFilter() { + if (_storeFilter) return _storeFilter; + if (!window.ListFilter || typeof window.ListFilter.create !== 'function') { + console.warn('[PLUGINS] ListFilter helper unavailable — store filters disabled'); + return null; } - if (currentPage < totalPages - 2) pages.push('...'); - if (totalPages > 1) pages.push(totalPages); - - pages.forEach(p => { - if (p === '...') { - html += ``; - } else { - html += ``; - } - }); - - html += ``; - - container.innerHTML = html; - - container.querySelectorAll('[data-store-page]').forEach(btn => { - btn.addEventListener('click', function() { - const p = parseInt(this.getAttribute('data-store-page')); - if (p >= 1 && p <= totalPages && p !== currentPage) { - storeFilterState.page = p; - renderStorePage(); - const grid = document.getElementById('plugin-store-grid'); - if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }); + _storeFilter = window.ListFilter.create({ + getItems: () => pluginStoreCache || [], + render: renderPluginStore, + search: { + el: 'plugin-search', + fields: ['name', 'description', 'author', 'id', 'category', 'tags'], + debounceMs: 300, + }, + sort: { + el: 'store-sort', + default: 'a-z', + comparators: STORE_COMPARATORS, + }, + controls: [ + { + type: 'select', + el: 'plugin-category', + key: 'filterCategory', + default: '', + test: (plugin, value) => (plugin.category || '').toLowerCase() === value.toLowerCase(), + }, + { + // Cycles All → Installed → Not Installed → All. + type: 'cycle', + el: 'store-filter-installed', + key: 'filterInstalled', + default: null, + values: [null, true, false], + test: (plugin, value) => (value === true ? isStorePluginInstalled(plugin) : !isStorePluginInstalled(plugin)), + render: (btn, value) => { + if (value === true) { + btn.innerHTML = 'Installed'; + btn.classList.add('border-green-400', 'bg-green-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); + } else if (value === false) { + btn.innerHTML = 'Not Installed'; + btn.classList.add('border-red-400', 'bg-red-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); + } else { + btn.innerHTML = 'All'; + btn.classList.add('border-gray-300', 'bg-white'); + btn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); + } + }, + }, + ], + pagination: { + perPageEl: 'store-per-page', + defaultPerPage: 12, + topEl: 'store-pagination-top', + bottomEl: 'store-pagination-bottom', + infoEl: 'store-results-info', + infoBottomEl: 'store-results-info-bottom', + scrollToEl: 'plugin-store-grid', + infoFormat: (start, end, total) => `Showing ${start}\u2013${end} of ${total} plugins`, + emptyText: 'No plugins match your filters', + }, + persist: { + read: () => ({ + sort: safeLocalStorage.getItem('storeSort') || undefined, + perPage: parseInt(safeLocalStorage.getItem('storePerPage')) || undefined, + }), + write: (state) => { + safeLocalStorage.setItem('storeSort', state.sort); + safeLocalStorage.setItem('storePerPage', state.perPage); + }, + }, + clearEl: 'store-clear-filters', + activeCountEl: 'store-active-filters', }); + return _storeFilter; } -function updateStoreFilterUI() { - const st = storeFilterState; - const count = st.activeCount(); - - const badge = document.getElementById('store-active-filters'); - const clearBtn = document.getElementById('store-clear-filters'); - if (badge) { - badge.classList.toggle('hidden', count === 0); - badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active'; - } - if (clearBtn) clearBtn.classList.toggle('hidden', count === 0); - - const instBtn = document.getElementById('store-filter-installed'); - if (instBtn) { - if (st.filterInstalled === true) { - instBtn.innerHTML = 'Installed'; - instBtn.classList.add('border-green-400', 'bg-green-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); - } else if (st.filterInstalled === false) { - instBtn.innerHTML = 'Not Installed'; - instBtn.classList.add('border-red-400', 'bg-red-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); - } else { - instBtn.innerHTML = 'All'; - instBtn.classList.add('border-gray-300', 'bg-white'); - instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); - } +function applyStoreFiltersAndSort(skipPageReset) { + if (!pluginStoreCache) return; + const ctl = getStoreFilter(); + if (ctl) { + ctl.apply(skipPageReset); + return; } + // Fallback: no helper, render the cache unfiltered rather than nothing. + renderPluginStore(pluginStoreCache); } function setupStoreFilterListeners() { - // Search with debounce - const searchEl = document.getElementById('plugin-search'); - if (searchEl && !searchEl._storeFilterInit) { - searchEl._storeFilterInit = true; - let debounce = null; - searchEl.addEventListener('input', function() { - clearTimeout(debounce); - debounce = setTimeout(() => { - storeFilterState.searchQuery = this.value.trim(); - applyStoreFiltersAndSort(); - }, 300); - }); - } - - // Category dropdown - const catEl = document.getElementById('plugin-category'); - if (catEl && !catEl._storeFilterInit) { - catEl._storeFilterInit = true; - catEl.addEventListener('change', function() { - storeFilterState.filterCategory = this.value; - applyStoreFiltersAndSort(); - }); - } - - // Sort dropdown - const sortEl = document.getElementById('store-sort'); - if (sortEl && !sortEl._storeFilterInit) { - sortEl._storeFilterInit = true; - sortEl.addEventListener('change', function() { - storeFilterState.sort = this.value; - storeFilterState.persist(); - applyStoreFiltersAndSort(); - }); - } - - // Installed toggle (cycle: all → installed → not-installed → all) - const instBtn = document.getElementById('store-filter-installed'); - if (instBtn && !instBtn._storeFilterInit) { - instBtn._storeFilterInit = true; - instBtn.addEventListener('click', function() { - const st = storeFilterState; - if (st.filterInstalled === null) st.filterInstalled = true; - else if (st.filterInstalled === true) st.filterInstalled = false; - else st.filterInstalled = null; - applyStoreFiltersAndSort(); - }); - } - - // Clear filters - const clearBtn = document.getElementById('store-clear-filters'); - if (clearBtn && !clearBtn._storeFilterInit) { - clearBtn._storeFilterInit = true; - clearBtn.addEventListener('click', function() { - storeFilterState.reset(); - const searchEl = document.getElementById('plugin-search'); - if (searchEl) searchEl.value = ''; - const catEl = document.getElementById('plugin-category'); - if (catEl) catEl.value = ''; - const sortEl = document.getElementById('store-sort'); - if (sortEl) sortEl.value = 'a-z'; - storeFilterState.persist(); - applyStoreFiltersAndSort(); - }); - } - - // Per-page selector - const ppEl = document.getElementById('store-per-page'); - if (ppEl && !ppEl._storeFilterInit) { - ppEl._storeFilterInit = true; - ppEl.addEventListener('change', function() { - storeFilterState.perPage = parseInt(this.value) || 12; - storeFilterState.persist(); - applyStoreFiltersAndSort(); - }); - } + const ctl = getStoreFilter(); + if (!ctl) return; + ctl.bind(); + ctl.syncControls(); } // Expose searchPluginStore on window.pluginManager for Alpine.js integration @@ -5608,41 +5626,8 @@ document.addEventListener('htmx:afterSettle', function() { let starlarkSectionVisible = false; let starlarkFullCache = null; // All apps from server - let starlarkFilteredList = []; // After filters applied let starlarkDataLoaded = false; - // ── Filter State ──────────────────────────────────────────────────────── - const starlarkFilterState = { - sort: safeLocalStorage.getItem('starlarkSort') || 'a-z', - filterInstalled: null, // null=all, true=installed, false=not-installed - filterAuthor: '', - filterCategory: '', - searchQuery: '', - page: 1, - perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || 24, - persist() { - safeLocalStorage.setItem('starlarkSort', this.sort); - safeLocalStorage.setItem('starlarkPerPage', this.perPage); - }, - reset() { - this.sort = 'a-z'; - this.filterInstalled = null; - this.filterAuthor = ''; - this.filterCategory = ''; - this.searchQuery = ''; - this.page = 1; - }, - activeCount() { - let n = 0; - if (this.searchQuery) n++; - if (this.filterInstalled !== null) n++; - if (this.filterAuthor) n++; - if (this.filterCategory) n++; - if (this.sort !== 'a-z') n++; - return n; - } - }; - // ── Helpers ───────────────────────────────────────────────────────────── function escapeHtml(str) { if (!str) return ''; @@ -5681,12 +5666,7 @@ document.addEventListener('htmx:afterSettle', function() { }); } - // Restore persisted sort/perPage - const sortEl = document.getElementById('starlark-sort'); - if (sortEl) sortEl.value = starlarkFilterState.sort; - const ppEl = document.getElementById('starlark-per-page'); - if (ppEl) ppEl.value = starlarkFilterState.perPage; - + // Persisted sort/perPage are restored by the controller's syncControls(). setupStarlarkFilterListeners(); const uploadBtn = document.getElementById('starlark-upload-btn'); @@ -5787,147 +5767,126 @@ document.addEventListener('htmx:afterSettle', function() { } // ── Apply Filters + Sort ──────────────────────────────────────────────── - function applyStarlarkFiltersAndSort(skipPageReset) { - if (!starlarkFullCache) return; - const st = starlarkFilterState; - - let list = starlarkFullCache.slice(); - - // Text search - if (st.searchQuery) { - const q = st.searchQuery.toLowerCase(); - list = list.filter(app => { - const hay = [app.name, app.summary, app.desc, app.author, app.id, app.category] - .filter(Boolean).join(' ').toLowerCase(); - return hay.includes(q); - }); - } + // ── Filter / Sort / Paginate ──────────────────────────────────────────── + // Same behaviour, element ids and localStorage keys as the hand-rolled + // version this replaces; the machinery is now shared with the store and + // the installed-plugins list. + function starlarkSortName(app) { + return (app.name || app.id || '').toLowerCase(); + } + + const STARLARK_COMPARATORS = { + 'a-z': (a, b) => starlarkSortName(a).localeCompare(starlarkSortName(b)), + 'z-a': (a, b) => starlarkSortName(b).localeCompare(starlarkSortName(a)), + 'category': (a, b) => { + const catCmp = (a.category || '').localeCompare(b.category || ''); + return catCmp !== 0 ? catCmp : starlarkSortName(a).localeCompare(starlarkSortName(b)); + }, + 'author': (a, b) => { + const authCmp = (a.author || '').localeCompare(b.author || ''); + return authCmp !== 0 ? authCmp : starlarkSortName(a).localeCompare(starlarkSortName(b)); + }, + }; - // Category filter - if (st.filterCategory) { - const cat = st.filterCategory.toLowerCase(); - list = list.filter(app => (app.category || '').toLowerCase() === cat); + function renderInstalledCycleButton(btn, value) { + if (value === true) { + btn.innerHTML = 'Installed'; + btn.classList.add('border-green-400', 'bg-green-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); + } else if (value === false) { + btn.innerHTML = 'Not Installed'; + btn.classList.add('border-red-400', 'bg-red-50'); + btn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); + } else { + btn.innerHTML = 'All'; + btn.classList.add('border-gray-300', 'bg-white'); + btn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); } + } - // Author filter - if (st.filterAuthor) { - list = list.filter(app => app.author === st.filterAuthor); - } + let _starlarkFilter = null; - // Installed filter - if (st.filterInstalled === true) { - list = list.filter(app => isStarlarkInstalled(app.id)); - } else if (st.filterInstalled === false) { - list = list.filter(app => !isStarlarkInstalled(app.id)); + function getStarlarkFilter() { + if (_starlarkFilter) return _starlarkFilter; + if (!window.ListFilter || typeof window.ListFilter.create !== 'function') { + console.warn('[STARLARK] ListFilter helper unavailable — filters disabled'); + return null; } - - // Sort - list.sort((a, b) => { - const nameA = (a.name || a.id || '').toLowerCase(); - const nameB = (b.name || b.id || '').toLowerCase(); - switch (st.sort) { - case 'z-a': return nameB.localeCompare(nameA); - case 'category': { - const catCmp = (a.category || '').localeCompare(b.category || ''); - return catCmp !== 0 ? catCmp : nameA.localeCompare(nameB); - } - case 'author': { - const authCmp = (a.author || '').localeCompare(b.author || ''); - return authCmp !== 0 ? authCmp : nameA.localeCompare(nameB); - } - default: return nameA.localeCompare(nameB); // a-z - } + _starlarkFilter = window.ListFilter.create({ + getItems: () => starlarkFullCache || [], + render: (pageApps) => renderStarlarkApps(pageApps, document.getElementById('starlark-apps-grid')), + search: { + el: 'starlark-search', + fields: ['name', 'summary', 'desc', 'author', 'id', 'category'], + debounceMs: 300, + }, + sort: { + el: 'starlark-sort', + default: 'a-z', + comparators: STARLARK_COMPARATORS, + }, + controls: [ + { + type: 'select', + el: 'starlark-category', + key: 'filterCategory', + default: '', + test: (app, value) => (app.category || '').toLowerCase() === value.toLowerCase(), + }, + { + // Author matching is exact/case-sensitive — the options come + // straight from the server's author list. + type: 'select', + el: 'starlark-filter-author', + key: 'filterAuthor', + default: '', + test: (app, value) => app.author === value, + }, + { + type: 'cycle', + el: 'starlark-filter-installed', + key: 'filterInstalled', + default: null, + values: [null, true, false], + test: (app, value) => (value === true ? isStarlarkInstalled(app.id) : !isStarlarkInstalled(app.id)), + render: renderInstalledCycleButton, + }, + ], + pagination: { + perPageEl: 'starlark-per-page', + defaultPerPage: 24, + topEl: 'starlark-pagination-top', + bottomEl: 'starlark-pagination-bottom', + infoEl: 'starlark-results-info', + infoBottomEl: 'starlark-results-info-bottom', + scrollToEl: 'starlark-apps-grid', + infoFormat: (start, end, total) => `Showing ${start}\u2013${end} of ${total} apps`, + emptyText: 'No apps match your filters', + }, + persist: { + read: () => ({ + sort: safeLocalStorage.getItem('starlarkSort') || undefined, + perPage: parseInt(safeLocalStorage.getItem('starlarkPerPage')) || undefined, + }), + write: (state) => { + safeLocalStorage.setItem('starlarkSort', state.sort); + safeLocalStorage.setItem('starlarkPerPage', state.perPage); + }, + }, + clearEl: 'starlark-clear-filters', + activeCountEl: 'starlark-active-filters', }); - - starlarkFilteredList = list; - if (!skipPageReset) st.page = 1; - - renderStarlarkPage(); - updateStarlarkFilterUI(); + return _starlarkFilter; } - // ── Render Current Page ───────────────────────────────────────────────── - function renderStarlarkPage() { - const st = starlarkFilterState; - const total = starlarkFilteredList.length; - const totalPages = Math.max(1, Math.ceil(total / st.perPage)); - if (st.page > totalPages) st.page = totalPages; - - const start = (st.page - 1) * st.perPage; - const end = Math.min(start + st.perPage, total); - const pageApps = starlarkFilteredList.slice(start, end); - - // Results info - const info = total > 0 - ? `Showing ${start + 1}\u2013${end} of ${total} apps` - : 'No apps match your filters'; - const infoEl = document.getElementById('starlark-results-info'); - const infoElBot = document.getElementById('starlark-results-info-bottom'); - if (infoEl) infoEl.textContent = info; - if (infoElBot) infoElBot.textContent = info; - - // Pagination - renderStarlarkPagination('starlark-pagination-top', totalPages, st.page); - renderStarlarkPagination('starlark-pagination-bottom', totalPages, st.page); - - // Grid - const grid = document.getElementById('starlark-apps-grid'); - renderStarlarkApps(pageApps, grid); - } - - // ── Pagination Controls ───────────────────────────────────────────────── - function renderStarlarkPagination(containerId, totalPages, currentPage) { - const container = document.getElementById(containerId); - if (!container) return; - - if (totalPages <= 1) { container.innerHTML = ''; return; } - - const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; - const activeClass = 'bg-blue-600 text-white border-blue-600'; - const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; - const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; - - let html = ''; - - // Prev - html += ``; - - // Page numbers with ellipsis - const pages = []; - pages.push(1); - if (currentPage > 3) pages.push('...'); - for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { - pages.push(i); + function applyStarlarkFiltersAndSort(skipPageReset) { + if (!starlarkFullCache) return; + const ctl = getStarlarkFilter(); + if (ctl) { + ctl.apply(skipPageReset); + return; } - if (currentPage < totalPages - 2) pages.push('...'); - if (totalPages > 1) pages.push(totalPages); - - pages.forEach(p => { - if (p === '...') { - html += ``; - } else { - html += ``; - } - }); - - // Next - html += ``; - - container.innerHTML = html; - - // Event delegation for page buttons - container.querySelectorAll('[data-starlark-page]').forEach(btn => { - btn.addEventListener('click', function() { - const p = parseInt(this.getAttribute('data-starlark-page')); - if (p >= 1 && p <= totalPages && p !== currentPage) { - starlarkFilterState.page = p; - renderStarlarkPage(); - // Scroll to top of grid - const grid = document.getElementById('starlark-apps-grid'); - if (grid) grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }); - }); + renderStarlarkApps(starlarkFullCache, document.getElementById('starlark-apps-grid')); } // ── Card Rendering ────────────────────────────────────────────────────── @@ -5994,127 +5953,12 @@ document.addEventListener('htmx:afterSettle', function() { } // ── Filter UI Updates ─────────────────────────────────────────────────── - function updateStarlarkFilterUI() { - const st = starlarkFilterState; - const count = st.activeCount(); - - const badge = document.getElementById('starlark-active-filters'); - const clearBtn = document.getElementById('starlark-clear-filters'); - if (badge) { - badge.classList.toggle('hidden', count === 0); - badge.textContent = count + ' filter' + (count !== 1 ? 's' : '') + ' active'; - } - if (clearBtn) clearBtn.classList.toggle('hidden', count === 0); - - // Update installed toggle button text - const instBtn = document.getElementById('starlark-filter-installed'); - if (instBtn) { - if (st.filterInstalled === true) { - instBtn.innerHTML = 'Installed'; - instBtn.classList.add('border-green-400', 'bg-green-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-red-400', 'bg-red-50'); - } else if (st.filterInstalled === false) { - instBtn.innerHTML = 'Not Installed'; - instBtn.classList.add('border-red-400', 'bg-red-50'); - instBtn.classList.remove('border-gray-300', 'bg-white', 'border-green-400', 'bg-green-50'); - } else { - instBtn.innerHTML = 'All'; - instBtn.classList.add('border-gray-300', 'bg-white'); - instBtn.classList.remove('border-green-400', 'bg-green-50', 'border-red-400', 'bg-red-50'); - } - } - } - // ── Event Listeners ───────────────────────────────────────────────────── function setupStarlarkFilterListeners() { - // Search with debounce - const searchEl = document.getElementById('starlark-search'); - if (searchEl && !searchEl._starlarkInit) { - searchEl._starlarkInit = true; - let debounce = null; - searchEl.addEventListener('input', function() { - clearTimeout(debounce); - debounce = setTimeout(() => { - starlarkFilterState.searchQuery = this.value.trim(); - applyStarlarkFiltersAndSort(); - }, 300); - }); - } - - // Category dropdown - const catEl = document.getElementById('starlark-category'); - if (catEl && !catEl._starlarkInit) { - catEl._starlarkInit = true; - catEl.addEventListener('change', function() { - starlarkFilterState.filterCategory = this.value; - applyStarlarkFiltersAndSort(); - }); - } - - // Sort dropdown - const sortEl = document.getElementById('starlark-sort'); - if (sortEl && !sortEl._starlarkInit) { - sortEl._starlarkInit = true; - sortEl.addEventListener('change', function() { - starlarkFilterState.sort = this.value; - starlarkFilterState.persist(); - applyStarlarkFiltersAndSort(); - }); - } - - // Author dropdown - const authEl = document.getElementById('starlark-filter-author'); - if (authEl && !authEl._starlarkInit) { - authEl._starlarkInit = true; - authEl.addEventListener('change', function() { - starlarkFilterState.filterAuthor = this.value; - applyStarlarkFiltersAndSort(); - }); - } - - // Installed toggle (cycle: all → installed → not-installed → all) - const instBtn = document.getElementById('starlark-filter-installed'); - if (instBtn && !instBtn._starlarkInit) { - instBtn._starlarkInit = true; - instBtn.addEventListener('click', function() { - const st = starlarkFilterState; - if (st.filterInstalled === null) st.filterInstalled = true; - else if (st.filterInstalled === true) st.filterInstalled = false; - else st.filterInstalled = null; - applyStarlarkFiltersAndSort(); - }); - } - - // Clear filters - const clearBtn = document.getElementById('starlark-clear-filters'); - if (clearBtn && !clearBtn._starlarkInit) { - clearBtn._starlarkInit = true; - clearBtn.addEventListener('click', function() { - starlarkFilterState.reset(); - // Reset UI elements - const searchEl = document.getElementById('starlark-search'); - if (searchEl) searchEl.value = ''; - const catEl = document.getElementById('starlark-category'); - if (catEl) catEl.value = ''; - const sortEl = document.getElementById('starlark-sort'); - if (sortEl) sortEl.value = 'a-z'; - const authEl = document.getElementById('starlark-filter-author'); - if (authEl) authEl.value = ''; - starlarkFilterState.persist(); - applyStarlarkFiltersAndSort(); - }); - } - - // Per-page selector - const ppEl = document.getElementById('starlark-per-page'); - if (ppEl && !ppEl._starlarkInit) { - ppEl._starlarkInit = true; - ppEl.addEventListener('change', function() { - starlarkFilterState.perPage = parseInt(this.value) || 24; - starlarkFilterState.persist(); - applyStarlarkFiltersAndSort(); - }); - } + const ctl = getStarlarkFilter(); + if (!ctl) return; + ctl.bind(); + ctl.syncControls(); } // ── Install / Upload / Pixlet ─────────────────────────────────────────── diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index 053724b4..87cd0a34 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -974,7 +974,9 @@

- + + + diff --git a/web_interface/templates/v3/partials/plugins.html b/web_interface/templates/v3/partials/plugins.html index ba7f0c88..d62a8cbc 100644 --- a/web_interface/templates/v3/partials/plugins.html +++ b/web_interface/templates/v3/partials/plugins.html @@ -29,6 +29,58 @@

Installed Plugins

0 installed + + +
+ +
+ + + +
+ + +
+ + + + +
+ + + + +
+ + + +
+
From 9ab9791e77935e40a81ae31e8888b999203658d1 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 8 Sep 2026 16:35:37 -0400 Subject: [PATCH 2/5] fix(plugins): keep raw search text, and stop the store search refetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings from CodeRabbit on #540. Do not write the trimmed search value back into the input. setSearch() trimmed before storing, and syncControls() then copied that trimmed value back over what the user had typed. Pausing longer than the debounce after typing a space deleted the space (and reset the caret), making multi-word terms effectively untypable. The raw text is now kept alongside the trimmed one: filtering and activeCount() still use the trimmed value, while the input keeps exactly what was typed. Remove the legacy #plugin-search / #plugin-category listeners in initializePlugins(). They bound searchPluginStore as the event handler, so the DOM event arrived as its `fetchCommitInfo` argument — always truthy, which skipped the cached-filter fast path and refetched /api/v3/plugins/store/list with commit info on every keystroke burst and category change. The store's ListFilter controller already filters the cached list, which is what those two controls should do. This double-binding predates this PR (the old code guarded with _listenerSetup and _storeFilterInit, two different flags, so both sets stayed live); it is fixed here because the refactor owns that wiring now. Both fixes are covered by tests that fail without them: the trailing-space regressions in the installed-plugins DOM suite, and a new whole-file jsdom test that counts fetches while typing (1 request at init, 0 thereafter; previously 1 -> 2 -> 3 -> 5). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../static/v3/js/plugins/list_filter.js | 14 +++++++++++--- web_interface/static/v3/plugins_manager.js | 18 ++++++------------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/web_interface/static/v3/js/plugins/list_filter.js b/web_interface/static/v3/js/plugins/list_filter.js index b1fab69f..72c3941f 100644 --- a/web_interface/static/v3/js/plugins/list_filter.js +++ b/web_interface/static/v3/js/plugins/list_filter.js @@ -79,7 +79,10 @@ const ListFilter = (function () { // Defaults double as the "inactive" value for each axis. const defaults = {}; - if (searchCfg) defaults.search = ''; + if (searchCfg) { + defaults.search = ''; // trimmed — what filtering and activeCount use + defaults.searchRaw = ''; // exactly what the user typed — what the input shows + } if (sortCfg) defaults.sort = sortCfg.default !== undefined ? sortCfg.default : 'a-z'; controls.forEach(c => { defaults[c.key] = c.default !== undefined ? c.default : null; @@ -162,7 +165,8 @@ const ListFilter = (function () { // The toolbar markup is rebuilt on every HTMX partial swap while // this controller (and its state) survives — put the text back. const el = byId(searchCfg.el); - if (el && el.value !== state.search) el.value = state.search; + const text = state.searchRaw !== undefined ? state.searchRaw : state.search; + if (el && el.value !== text) el.value = text; } if (sortCfg) { const el = byId(sortCfg.el); @@ -314,7 +318,11 @@ const ListFilter = (function () { } function setSearch(value) { - state.search = (value || '').trim(); + // Keep the raw text so syncControls can put it back verbatim. Writing + // the trimmed value into the input would eat a trailing space (and + // reset the caret) mid-word, which makes multi-word terms untypable. + state.searchRaw = value || ''; + state.search = state.searchRaw.trim(); sticky.clear(); apply(); } diff --git a/web_interface/static/v3/plugins_manager.js b/web_interface/static/v3/plugins_manager.js index 995f4987..a16e66f6 100644 --- a/web_interface/static/v3/plugins_manager.js +++ b/web_interface/static/v3/plugins_manager.js @@ -1180,18 +1180,12 @@ function initializePlugins() { } }); - // Setup search functionality (with guard against duplicate listeners) - const searchInput = document.getElementById('plugin-search'); - const categorySelect = document.getElementById('plugin-category'); - - if (searchInput && !searchInput._listenerSetup) { - searchInput._listenerSetup = true; - searchInput.addEventListener('input', debounce(searchPluginStore, 300)); - } - if (categorySelect && !categorySelect._listenerSetup) { - categorySelect._listenerSetup = true; - categorySelect.addEventListener('change', searchPluginStore); - } + // #plugin-search and #plugin-category are wired by the store's ListFilter + // controller (setupStoreFilterListeners). They used to ALSO be bound here to + // searchPluginStore; because that binding passed the DOM event as the + // `fetchCommitInfo` argument, every keystroke and category change skipped the + // cached-filter fast path and refetched /api/v3/plugins/store/list with commit + // info. Filtering the cached list is the controller's job — leave it to it. // Setup GitHub installation handlers debugLog('[initializePlugins] About to call setupGitHubInstallHandlers...'); From 009c4fb2f7bd0cdd85c35cb04fc78b1188bd67a5 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 8 Sep 2026 16:49:05 -0400 Subject: [PATCH 3/5] fix(plugins): build pagination via DOM APIs, drop computed member access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the five Codacy security findings, all in list_filter.js. Pagination no longer assembles an HTML string (3 findings: 2 critical + 1 high, "unsafe assignment to innerHTML"). The interpolated values were only page integers and local class constants, so there was no injection path, but concatenating markup into innerHTML is the pattern the scanners flag and createElement is no less clear. Each button now also owns its click listener directly instead of the container being re-queried afterwards, and the strip is cleared with textContent = '' rather than by assigning empty markup. No innerHTML assignment remains in the file. haystack() now walks Object.entries(item) and keeps the configured fields, instead of reading item[field] per field ("generic object injection sink"). Field order no longer drives the haystack order, which is irrelevant to the substring test. matches() iterates controls with for...of instead of an index ("variable assigned to object injection sink"). The rendered pagination is unchanged: same buttons, labels, page numbers, disabled states and classes. The old-vs-new differential tests now compare pagination structurally (tag, text, page, disabled, sorted class list) rather than as an HTML string, since building nodes legitimately serialises differently — «/» as characters rather than «/», disabled="" rather than a bare attribute. That comparison is stronger than the string one it replaces, and the real-DOM suite still drives the actual page buttons. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../static/v3/js/plugins/list_filter.js | 98 ++++++++++++------- 1 file changed, 61 insertions(+), 37 deletions(-) diff --git a/web_interface/static/v3/js/plugins/list_filter.js b/web_interface/static/v3/js/plugins/list_filter.js index 72c3941f..2050f23a 100644 --- a/web_interface/static/v3/js/plugins/list_filter.js +++ b/web_interface/static/v3/js/plugins/list_filter.js @@ -55,10 +55,16 @@ const ListFilter = (function () { // Build the lowercased search haystack. Array fields (e.g. tags) are // flattened in, matching the existing store/starlark search behaviour. + // Walks the item's own entries and keeps the wanted ones, rather than reading + // item[field] for each configured field. Same haystack (order is irrelevant + // to the substring test), minus the computed member access that static + // analysers flag as an object-injection sink. function haystack(item, fields) { + if (!item) return ''; + const wanted = new Set(fields || []); const parts = []; - (fields || []).forEach(field => { - const value = item ? item[field] : null; + Object.entries(item).forEach(([key, value]) => { + if (!wanted.has(key)) return; if (Array.isArray(value)) { value.forEach(v => { if (v) parts.push(String(v)); }); } else if (value) { @@ -132,8 +138,7 @@ const ListFilter = (function () { return false; } } - for (let i = 0; i < controls.length; i++) { - const c = controls[i]; + for (const c of controls) { const value = state[c.key]; if (sameValue(value, defaults[c.key])) continue; // axis inactive if (typeof c.test === 'function' && !c.test(item, value)) return false; @@ -227,21 +232,54 @@ const ListFilter = (function () { if (typeof cfg.onChrome === 'function') cfg.onChrome(state, list, total); } - // Page-number strip with leading/trailing ellipsis, matching the markup - // the plugin store has always produced. + // Page-number strip with leading/trailing ellipsis, producing the same + // controls the plugin store has always rendered. + // + // Built with createElement rather than by concatenating an HTML string. + // Nothing interpolated here is user-controlled — only page integers and + // these class constants — but assembling markup into innerHTML is the + // pattern static analysers flag as an XSS sink, and building nodes is no + // less clear. It also lets each button own its listener directly instead + // of re-querying the container afterwards. + const PAGE_BTN_CLASS = 'px-3 py-1 text-sm rounded-md border transition-colors'; + const PAGE_ACTIVE_CLASS = 'bg-blue-600 text-white border-blue-600'; + const PAGE_NORMAL_CLASS = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; + const PAGE_DISABLED_CLASS = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; + function renderPagination(containerId, totalPages, currentPage) { const container = byId(containerId); if (!container) return; - if (totalPages <= 1) { container.innerHTML = ''; return; } - - const btnClass = 'px-3 py-1 text-sm rounded-md border transition-colors'; - const activeClass = 'bg-blue-600 text-white border-blue-600'; - const normalClass = 'bg-white text-gray-700 border-gray-300 hover:bg-gray-100 cursor-pointer'; - const disabledClass = 'bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed'; - - let html = ''; - html += ``; + // textContent = '' drops the previous strip without parsing markup. + container.textContent = ''; + if (totalPages <= 1) return; + + const goTo = target => { + if (target >= 1 && target <= totalPages && target !== currentPage) { + state.page = target; + // Page moves re-slice only; filters and sort are unchanged. + apply(true); + const grid = byId(pageCfg && pageCfg.scrollToEl); + if (grid && typeof grid.scrollIntoView === 'function') { + grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }; + + const addPageButton = (label, target, variant) => { + const btn = document.createElement('button'); + btn.className = PAGE_BTN_CLASS + ' ' + ( + variant === 'active' ? PAGE_ACTIVE_CLASS + : variant === 'disabled' ? PAGE_DISABLED_CLASS + : PAGE_NORMAL_CLASS); + btn.setAttribute('data-list-page', String(target)); + if (variant === 'disabled') btn.disabled = true; + btn.textContent = label; + btn.addEventListener('click', () => goTo(target)); + container.appendChild(btn); + }; + + addPageButton('\u00ab', currentPage - 1, currentPage <= 1 ? 'disabled' : 'normal'); const pages = []; pages.push(1); @@ -252,32 +290,18 @@ const ListFilter = (function () { if (currentPage < totalPages - 2) pages.push('...'); if (totalPages > 1) pages.push(totalPages); - pages.forEach(p => { - if (p === '...') { - html += ``; + pages.forEach(entry => { + if (entry === '...') { + const gap = document.createElement('span'); + gap.className = 'px-2 py-1 text-sm text-gray-400'; + gap.textContent = '\u2026'; + container.appendChild(gap); } else { - html += ``; + addPageButton(String(entry), entry, entry === currentPage ? 'active' : 'normal'); } }); - html += ``; - - container.innerHTML = html; - - container.querySelectorAll('[data-list-page]').forEach(btn => { - btn.addEventListener('click', function () { - const p = parseInt(this.getAttribute('data-list-page')); - if (p >= 1 && p <= totalPages && p !== currentPage) { - state.page = p; - // Page moves re-slice only; filters and sort are unchanged. - apply(true); - const grid = byId(pageCfg && pageCfg.scrollToEl); - if (grid && typeof grid.scrollIntoView === 'function') { - grid.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - } - }); - }); + addPageButton('\u00bb', currentPage + 1, currentPage >= totalPages ? 'disabled' : 'normal'); } function apply(skipPageReset) { From fba27bf1aea4e8fd012b593a8566f86fe19749ca Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 8 Sep 2026 17:43:36 -0400 Subject: [PATCH 4/5] fix(plugins): keep configured field order when building the search haystack The previous commit swapped item[field] for Object.entries(item) to clear a static-analysis object-injection warning, and in doing so changed the order of the haystack: entries follow the object's own key insertion order, not the configured `fields` order. Since the values are concatenated, that order decides which values end up adjacent, so a multi-word query spanning a field boundary matched differently. For store fields [name, description, author, id, ...] and API objects keyed {id, name, description, author, ...}, "bob plugin-01" matched before and stopped matching after. That contradicted the behaviour-preservation claim for the store and starlark migrations, and the differential tests missed it because every fixture query was a single word. Values now come out of a Map built from Object.entries, iterated in `fields` order: the original haystack is restored, and there is still no computed member access for the analyser to flag. Regression coverage for the ordering itself, at both levels: - unit: phrases spanning name->id and category->tags, plus the reverse (object-key) order asserted NOT to match - differential: the same class of query compared old-vs-new, with a guard that the phrase actually matches something so a mutual zero-result cannot pass vacuously Verified both fail without this fix (3 unit, 2 differential) and pass with it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .../static/v3/js/plugins/list_filter.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/web_interface/static/v3/js/plugins/list_filter.js b/web_interface/static/v3/js/plugins/list_filter.js index 2050f23a..6a8773ac 100644 --- a/web_interface/static/v3/js/plugins/list_filter.js +++ b/web_interface/static/v3/js/plugins/list_filter.js @@ -55,16 +55,18 @@ const ListFilter = (function () { // Build the lowercased search haystack. Array fields (e.g. tags) are // flattened in, matching the existing store/starlark search behaviour. - // Walks the item's own entries and keeps the wanted ones, rather than reading - // item[field] for each configured field. Same haystack (order is irrelevant - // to the substring test), minus the computed member access that static - // analysers flag as an object-injection sink. + // + // Values are read out of a Map rather than via item[field], which keeps + // static analysers from flagging a computed member access as an + // object-injection sink. Iteration follows `fields`, NOT the object's own + // key order: the fields are concatenated, so their order decides which + // values end up adjacent, and a multi-word query can span a field boundary. function haystack(item, fields) { if (!item) return ''; - const wanted = new Set(fields || []); + const values = new Map(Object.entries(item)); const parts = []; - Object.entries(item).forEach(([key, value]) => { - if (!wanted.has(key)) return; + (fields || []).forEach(field => { + const value = values.get(field); if (Array.isArray(value)) { value.forEach(v => { if (v) parts.push(String(v)); }); } else if (value) { From 8564a1d1d9cf5db5083cc7ee3c42bfb5ce9ca64d Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Tue, 8 Sep 2026 19:55:01 -0400 Subject: [PATCH 5/5] test(web): add JS suites for ListFilter and the plugin-manager grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No JS toolchain exists in this repo, so these are plain node scripts with no framework: each prints ok/FAIL lines and exits non-zero. `node test/js/run_all.js` runs everything, skipping the DOM suites (rather than failing) when jsdom is absent or nothing is listening, so it stays useful in a bare checkout. unit/test_list_filter.js ListFilter search/filter/sort/count/sticky, driven through the installed-plugins config eval'd verbatim out of plugins_manager.js so the test cannot drift from the real configuration unit/test_render_cards.js renderInstalledCards markup, both empty states, and escaping of hostile plugin metadata dom/test_installed_dom.js the toolbar in a real DOM, including the HTMX partial re-swap and a getComputedStyle check that .filter-pill[data-active] matches what we emit dom/test_store_dom.js store pagination, per-page, category, tri-state Installed button, persistence across a re-boot dom/test_no_double_fetch.js loads the whole plugins_manager.js and counts requests, so a keystroke cannot refetch the store The DOM suites deliberately fetch the partial and the plugin data from a running web interface instead of using fixtures, so a renamed element id or a changed payload shape fails them loudly. Point them at a rig with a full plugin set when it matters (BASE=http://host:5000); a dev box with two plugins installed passes while exercising very little. Several assertions exist to stop specific bugs recurring: trailing spaces surviving the search debounce, a query spanning two adjacent search fields (haystack field order is load-bearing), and window.installedPlugins staying at full length while the grid is filtered. Others guard against passing vacuously — counting only non-skeleton cards, and checking a search phrase matches something before comparing two result sets. The old-vs-new differential suites that verified the store and starlark migrations are not included: they compared against the pre-refactor code, which now exists only in git history. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- .gitignore | 4 + test/js/README.md | 65 +++++++ test/js/dom/test_installed_dom.js | 279 +++++++++++++++++++++++++++ test/js/dom/test_no_double_fetch.js | 132 +++++++++++++ test/js/dom/test_store_dom.js | 221 +++++++++++++++++++++ test/js/package.json | 12 ++ test/js/run_all.js | 57 ++++++ test/js/unit/test_list_filter.js | 288 ++++++++++++++++++++++++++++ test/js/unit/test_render_cards.js | 109 +++++++++++ 9 files changed, 1167 insertions(+) create mode 100644 test/js/README.md create mode 100644 test/js/dom/test_installed_dom.js create mode 100644 test/js/dom/test_no_double_fetch.js create mode 100644 test/js/dom/test_store_dom.js create mode 100644 test/js/package.json create mode 100755 test/js/run_all.js create mode 100644 test/js/unit/test_list_filter.js create mode 100644 test/js/unit/test_render_cards.js diff --git a/.gitignore b/.gitignore index 3c3c6a0b..fdfaa90b 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ config/backups/ # Starlark apps runtime storage (installed .star files and cached renders) /starlark-apps/ skin_renders/ + +# JS test deps (test/js) +node_modules/ +package-lock.json diff --git a/test/js/README.md b/test/js/README.md new file mode 100644 index 00000000..dd91132c --- /dev/null +++ b/test/js/README.md @@ -0,0 +1,65 @@ +# Web-interface JS tests + +Covers `web_interface/static/v3/js/plugins/list_filter.js` (the shared +search/filter/sort controller) and the plugin-manager grids that use it: +Installed Plugins, the Plugin Store, and Starlark Apps. + +There is no JS toolchain in this repo, so these are plain node scripts with no +test framework. Each prints `ok`/`FAIL` lines and exits non-zero on failure. + +## Running + +```bash +cd test/js +npm install # jsdom, for the DOM suites only +node run_all.js +``` + +The unit suites need nothing but node. The DOM suites additionally need a +running web interface, because they test against the **real** server-rendered +HTML and the **real** API rather than fixtures: + +```bash +# in another shell, from the repo root +EMULATOR=true python3 web_interface/app.py # http://localhost:5000 + +# or point the suites at a device +BASE=http://10.0.10.169:5000 node run_all.js +``` + +`run_all.js` skips the DOM suites (rather than failing) when jsdom is missing or +nothing is listening, so it stays useful in a bare checkout. + +## The suites + +| Suite | Needs a server | Covers | +|---|---|---| +| `unit/test_list_filter.js` | no | `ListFilter` search/filter/sort/count/sticky, and the installed-plugins config **extracted verbatim** from `plugins_manager.js` so the test can't drift from it | +| `unit/test_render_cards.js` | no | `renderInstalledCards` markup, both empty states, and HTML-escaping of hostile plugin metadata | +| `dom/test_installed_dom.js` | yes | The toolbar in a real DOM: pill/search/sort interaction, the HTMX partial re-swap, and a `getComputedStyle` check that `.filter-pill[data-active]` really matches the emitted markup | +| `dom/test_store_dom.js` | yes | Store pagination, per-page, category, tri-state Installed button, and persistence across a re-boot, against the live registry | +| `dom/test_no_double_fetch.js` | yes | Loads the **whole** `plugins_manager.js` and counts requests: typing in the store search must filter the cached list, not refetch `/api/v3/plugins/store/list` | + +Point the DOM suites at a rig with a full plugin set when it matters — a dev box +with two plugins installed will pass while exercising very little. + +## Notes for whoever changes this next + +- The suites read the shipped files off disk and, for the DOM ones, the partial + from the running server. They do not keep their own copy of the markup, so + renaming an element id will fail them loudly rather than silently pass. +- `unit/test_list_filter.js` `eval`s a slice of `plugins_manager.js` located by + the text `function installedSortName(plugin)`. If that function is renamed, + fix the slice markers rather than pasting a copy of the config into the test. +- A few assertions exist specifically to stop earlier bugs coming back: + trailing spaces surviving the search debounce; a multi-word query that spans + two adjacent search fields (field order in the haystack is load-bearing); + `window.installedPlugins` staying at full length while the grid is filtered. +- Watch for assertions that can pass vacuously. Several here deliberately guard + against it — e.g. counting only non-skeleton cards, and asserting a search + phrase matches something before comparing two results. + +The old-vs-new differential suites used to verify that the store and Starlark +migrations were behaviour-preserving are not included: they compared against the +pre-refactor implementation, which now only exists in git history. See PR #540 +if that comparison ever needs redoing. diff --git a/test/js/dom/test_installed_dom.js b/test/js/dom/test_installed_dom.js new file mode 100644 index 00000000..5fbee8c6 --- /dev/null +++ b/test/js/dom/test_installed_dom.js @@ -0,0 +1,279 @@ +// Integration test in a REAL DOM (jsdom): +// - HTML comes from the running server's /partials/plugins (real template output) +// - list_filter.js is loaded as a real script +// - plugin data comes from the running server's real API +// - interactions are real dispatched DOM events on the real pill/select nodes +// This exercises HTML parsing, attribute reflection, event bubbling and +// delegation for real — none of which the hand-rolled shim could vouch for. +const fs = require('fs'); +const http = require('http'); +const { JSDOM, VirtualConsole } = require('jsdom'); + +const path = require('path'); +const V3 = path.resolve(__dirname, '../../../web_interface/static/v3'); +const BASE = process.env.BASE || 'http://localhost:5000'; + +function get(path) { + return new Promise((res, rej) => { + http.get(BASE + path, r => { let d = ''; r.on('data', c => d += c); r.on('end', () => res(d)); }) + .on('error', rej); + }); +} + +(async () => { + const partial = await get('/partials/plugins'); + const installed = JSON.parse(await get('/api/v3/plugins/installed')).data.plugins; + + // Collected so an uncaught error inside a handler fails the run instead of + // silently vanishing. + const jsErrors = []; + const vc = new VirtualConsole(); + vc.on('jsdomError', e => jsErrors.push(String(e.message || e))); + vc.on('error', (...a) => jsErrors.push('console.error: ' + a.join(' '))); + + const dom = new JSDOM( + `
${partial}
`, + { runScripts: 'dangerously', virtualConsole: vc, url: BASE + '/' }); + + const { window } = dom; + const { document } = window; + + // Minimal ambient globals the extracted block expects from plugins_manager.js. + window.pluginLog = () => {}; + window.debugLog = () => {}; + window.PLUGIN_DEBUG = false; + window.installedPlugins = installed; + window.escapeHtml = function (text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }; + + // Load the helper as a real ', + version: '1.0.0', category: 'x', description: 'nope', enabled: true, tags: ['t'], +}], 1); +const evil = container.innerHTML; +ok('no raw bad/.test(evil)); +ok('no raw from description', !/nope<\/b>/.test(evil)); +ok('no raw from tags', !/t<\/i>/.test(evil)); +ok('escaped entities present instead', /</.test(evil)); + +console.log(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail ? 1 : 0);