Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
next root-level module can't ship broken.

### Added
- **Search box on the v3 Plugins pedalboard page.** Live, as-you-type filtering by name/id/description; non-matching pedals hide and matches compact/re-flow, boards with zero matches hide, and clearing the query restores everything (including saved drag positions).
- **Perf harness now measures 2D-highway frame time (R3c gate).** `scripts/perf-baseline.mjs` gains a `--song` mode that reports per-frame draw-cost p50/p95/p99 (draw-tagged via `highway.addDrawHook`), the metric that gates the `highway.js` split. Maintainer/CI-only; baseline recorded in `docs/perf-baseline.md`.
- **`routers/` — extracting `server.py`'s route layer, cheapest-first (R3).** Each PR moves a cohesive route group into a `fastapi.APIRouter` under `lib/routers/`, mounted with `app.include_router(...)` at its original site (FastAPI matches in registration order; the full route table stays byte-identical). Bodies are verbatim — only the decorator receiver (`@app` → `@router`) and singleton reads (`meta_db` → `appstate.meta_db`, resolved at call time) change. So far: `audio_effects` (5), `artist_aliases` (5), `loops` (3), `playlists` (12 + covers), `ws_highway` (the 902-line highway chart WebSocket), `chart` (split/unsplit/work/fileinfo — unblocked by the DLC-path substrate), `library_extras`, `wanted`, `shop`, `progression`, `profile`, `stats` (the `/api/stats/{path}` catch-all stays registered last so it can't shadow `/recent` `/best` `/top`), `version` (`/api/version`; VERSION-file lookup adjusted for the router subdir depth), `art` (the `/api/song/{f}/art*` serve/cover-search/candidates/upload/url + `/api/art/{f}/override` routes; the shared `_song_pack_art_exists`/`_art_override_paths`/`_art_safe_name` helpers stay in `server.py` for the song/delete routes and are reached through the `appstate` seam, the CAA/release transport as `enrichment.X`), and `settings` (`GET`/`POST /api/settings`, `/reset`, and the two-phase atomic export/import bundle `/api/settings/export|import`; the shared `_default_settings` builder stays in `server.py` and is reached through the `appstate` seam), and `song` (upload/delete + the metadata write-back, user-meta, overrides, gap-fill, and per-song info routes; the scan/ingest helpers stay in `server.py` and are reached through new `appstate` seams — `kick_scan`, `invalidate_song_caches`, `stat_for_cache`, and a `scan_status()` getter — the `get_song_info` catch-all mounts after the art routes so it can't shadow them), and `library` + collections (the provider list/art/sync endpoints, the library query surface, and collection CRUD → `lib/routers/library.py`; the `LibraryProviderRegistry`/`LocalLibraryProvider`/`SmartCollectionProvider` classes + shared query/collection helpers move to `lib/library_registry.py`, and the registry instance + local provider ride the `appstate` seam — server.py still constructs the singleton and exposes `register_library_provider`/`unregister_library_provider` to plugins via `plugin_context` unchanged), and the `enrichment` route handlers (`/api/enrichment/*`: status, kick/cancel, per-song state, the Match-Review queue, and AcoustID identify → `lib/routers/enrichment.py`; the engine already lives in `lib/enrichment.py` and is reached as `enrichment.X`), and `media` (the file-serving routes — song audio `/audio/{f}`, the local-audio-path resolver `/api/audio-local-path`, and raw sloppak-member serving `/api/sloppak/{f}/file/{rel}` → `lib/routers/media.py`; the cache/static path seams were already in `appstate`), and `artist` (the artist page + external-links payload `/api/artist/{name}/page|links|links/refresh` → `lib/routers/artist.py`; MB link enrichment reached as `enrichment.X`), and `diagnostics` (`/api/diagnostics/export|preview|hardware`; the plugins-root lookup adjusted for the router subdir depth, `_running_version` reached through the `appstate` seam, pure payload-cap helpers re-exported for the `server._diag_*` tests), and `tunings` (`/api/tunings`; the pure `config.json` reader moved to `lib/appconfig.py`, the tuning-provider registry read through the `appstate` seam so plugin-contributed tunings still merge). The DLC library-path resolution (`_get_dlc_dir`, pure `_resolve_dlc_path`) moved to `lib/dlc_paths.py`, reading paths through the seam; `config_dir`/`dlc_dir`/`dlc_dir_env` now ride the `appstate` seam (env-derived, so the pop-and-reimport fixtures reconfigure it for free), and the shared request-field sanitizer `_clean_str` moved to `lib/reqfields.py`. The next cut is picked by a dependency-closure scan that ranks groups by how many `monkeypatch.setattr(server, …)` targets they'd drag along.
- **`routers/` — the first extracted route module (R3).** The five audio-effects mapping
Expand Down
2 changes: 1 addition & 1 deletion static/tailwind.min.css

Large diffs are not rendered by default.

60 changes: 56 additions & 4 deletions static/v3/plugins-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,18 @@
return (p && CURATED[p.id]) || deriveFromType(p) || 'other';
}

// Live search: substring match (case-insensitive) across name/id/description.
// Pure — unit-tested.
function pluginMatches(p, q) {
if (!q) return true;
var hay = ((p.name || '') + ' ' + (p.id || '') + ' ' + (p.description || '')).toLowerCase();
return hay.indexOf(q.toLowerCase().trim()) !== -1;
}

// Kept across render() re-runs (screen:changed, Reset) so the search box
// doesn't silently lose its value on a full DOM rebuild.
var searchQuery = '';

function thumbUrl(p) {
if (p && typeof p.icon === 'string' && p.icon) {
var rel = p.icon.replace(/^assets\//, '');
Expand Down Expand Up @@ -329,19 +341,44 @@
var saved = layout && layout[cat];
if (!saved || typeof saved !== 'object' || Array.isArray(saved)) saved = Object.create(null);
var maxBottom = 0;
var vis = 0; // visible-only index, so hidden (filtered-out) pedals don't leave gaps in the flow
for (var i = 0; i < pedals.length; i++) {
var el = pedals[i];
if (el.classList.contains('hidden')) continue;
var id = el.getAttribute('data-id');
el.style.width = d.w + 'px';
el.style.height = d.h + 'px';
var pos = saved[id] ? clampToBoard(saved[id], boardW, d.w) : defaultSlot(i, boardW);
var pos = saved[id] ? clampToBoard(saved[id], boardW, d.w) : defaultSlot(vis, boardW);
el.style.left = pos.x + 'px';
el.style.top = pos.y + 'px';
maxBottom = Math.max(maxBottom, pos.y + d.h);
vis++;
}
boardEl.style.minHeight = (maxBottom + PAD) + 'px';
}

// Hide non-matching pedals, hide boards with zero matches, and re-flow the
// rest. A null layout (while searching) makes matches compact into flow
// order instead of keeping sparse saved positions; the real layout (query
// cleared) restores saved drag positions exactly.
function applyFilter(root) {
var q = searchQuery.trim();
var layout = loadLayout();
root.querySelectorAll('.v3-board-section').forEach(function (sec) {
var board = sec.querySelector('.v3-pedalboard');
if (!board) return;
var any = false;
board.querySelectorAll('.v3-pedal').forEach(function (el) {
var hit = !q || pluginMatches(el._plugin || { id: el.getAttribute('data-id') }, q);
el.classList.toggle('hidden', !hit);
if (hit) any = true;
});
sec.classList.toggle('hidden', !any);
if (any) layoutBoard(board, sec.getAttribute('data-category'), q ? null : layout);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (window.v3PedalCables) window.v3PedalCables.refresh();
}

var DRAG_THRESHOLD = 5;

function wirePedalDrag(boardEl, cat) {
Expand Down Expand Up @@ -451,6 +488,9 @@
'<div class="v3-pedalboards-wrap px-6 md:px-8 pb-10">' +
'<div class="flex items-center justify-between gap-3 mb-6 flex-wrap">' +
'<span class="text-lg font-medium text-fb-good">' + active + ' active</span>' +
'<input id="v3-plugin-search" type="search" placeholder="Search plugins…" ' +
'class="flex-1 min-w-[200px] max-w-md text-sm bg-fb-card/60 border border-fb-border/50 rounded-md px-3 py-1.5 text-fb-text placeholder:text-fb-textDim outline-none focus:border-fb-primary/60" ' +
'value="' + esc(searchQuery) + '" aria-label="Search plugins">' +
'<div class="flex items-center gap-2">' +
'<button id="v3-pedal-reset" class="text-sm bg-fb-card/60 hover:bg-fb-card border border-fb-border/50 text-fb-textDim hover:text-fb-text px-3 py-1.5 rounded-md" title="Reset pedal positions and re-roll skins">Reset</button>' +
(inspectorPresent
Expand Down Expand Up @@ -505,7 +545,7 @@
// has a real width.
if (!nowCollapsed) {
var board = sec.querySelector('.v3-pedalboard');
if (board) layoutBoard(board, cat, loadLayout());
if (board) layoutBoard(board, cat, searchQuery.trim() ? null : loadLayout());
}
if (window.v3PedalCables) window.v3PedalCables.refresh();
});
Expand Down Expand Up @@ -541,13 +581,23 @@
window.showScreen('plugin-capability_inspector');
}
});

// Live search — no debounce (matches songs.js playlist-search precedent):
// dozens of in-memory items, DOM class toggles only, not a per-frame path.
var search = root.querySelector('#v3-plugin-search');
if (search) search.addEventListener('input', function (ev) {
searchQuery = ev.target.value;
applyFilter(root);
});
// Re-apply on every render() so the filter survives screen re-entry / Reset.
if (searchQuery) applyFilter(root);
}

window.v3PluginsPage = {
render: render,
// Pure helpers exposed for unit tests.
_test: {
categoryOf: categoryOf, thumbUrl: thumbUrl, settingsTarget: settingsTarget,
categoryOf: categoryOf, thumbUrl: thumbUrl, settingsTarget: settingsTarget, pluginMatches: pluginMatches,
clampToBoard: clampToBoard, defaultSlot: defaultSlot,
loadLayout: loadLayout, saveLayout: saveLayout,
frameFor: frameFor, pickFrame: pickFrame, frameUrl: frameUrl,
Expand All @@ -565,7 +615,9 @@
function relayoutAll() {
var root = document.getElementById('v3-plugins');
if (!root || !root.classList.contains('active')) return;
var layout = loadLayout();
// While searching, keep the compact flow (null layout) instead of the
// sparse saved positions, so a resize mid-search doesn't reopen gaps.
var layout = searchQuery.trim() ? null : loadLayout();
root.querySelectorAll('.v3-pedalboard').forEach(function (boardEl) {
layoutBoard(boardEl, boardEl.getAttribute('data-category'), layout);
});
Expand Down
23 changes: 23 additions & 0 deletions tests/js/plugins_page.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,26 @@ test('DRAG_THRESHOLD is a small positive pixel budget', () => {
assert.equal(typeof t.DRAG_THRESHOLD, 'number');
assert.ok(t.DRAG_THRESHOLD > 0 && t.DRAG_THRESHOLD < 20);
});

test('pluginMatches: case-insensitive substring across name/id/description', () => {
const { t } = loadPage();
const p = { id: 'nam_tone', name: 'NAM Tone', description: 'Amp modeling via neural nets' };
assert.ok(t.pluginMatches(p, 'nam'), 'matches name');
assert.ok(t.pluginMatches(p, 'NAM_TONE'), 'matches id, case-insensitive');
assert.ok(t.pluginMatches(p, 'neural'), 'matches description');
assert.ok(!t.pluginMatches(p, 'flappy'), 'no match');
});

test('pluginMatches: empty/whitespace query matches everything', () => {
const { t } = loadPage();
const p = { id: 'x', name: 'X' };
assert.ok(t.pluginMatches(p, ''));
assert.ok(t.pluginMatches(p, ' '));
assert.ok(t.pluginMatches(p, null));
});

test('pluginMatches: plugin with no name/description does not crash', () => {
const { t } = loadPage();
assert.ok(t.pluginMatches({ id: 'bare_plugin' }, 'bare'));
assert.ok(!t.pluginMatches({ id: 'bare_plugin' }, 'nope'));
});
Loading