Skip to content

Commit 4a0d87c

Browse files
authored
feat(ui): make the collections QAM navigable and declutter its panel
First of two PRs for #1539. Restructures the collections QAM so it stays usable as the virtual-collection list grows. - **Search** — a fuzzy name filter over the collection list (a shared `fuzzyMatch` util). - **Render cap** — the list paints at most 50 rows plus a "N more — refine your search" hint, so a large virtual list never strains the renderer. - **Per-type filter** — on the Virtual sub-tab, an All / Franchise / IGDB Collection control narrows by type. - **Enable All / Disable All act on the current filter** — a new `save_collections_sync` batch callable toggles exactly the matched collections; enabling a whole kind (no filter) asks for confirmation first. - **Declutter** — the "Show collection games in platform groups" toggle moves to Settings → Library, leaving the Collections panel for browsing and selection. The collection naming/provenance toggle (`collection_naming_mode`) is the second PR. Part of #1539.
1 parent 46287bd commit 4a0d87c

18 files changed

Lines changed: 1056 additions & 163 deletions

docs/architecture/backend-architecture.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,15 @@ key while preserving every enabled id, so a previously-enabled franchise collect
531531
required. (The historical v2 → v3 split still produces the `franchise` bucket; the v10 → v11 step renames it afterwards,
532532
so that frozen step is untouched.)
533533

534+
**Batch collection enable — `save_collections_sync` for a filtered subset (#1539).** The Collections tab adds a name
535+
search and (on Virtual) a per-type filter, and its **Enable All / Disable All** act on the current filter. When the view
536+
is a bounded subset (a search or per-type filter is active) the frontend calls `save_collections_sync` with the matched
537+
ids, the kind, and the enabled flag — a single settings write that stamps every id into the `kind` bucket, so the whole
538+
kind is never touched. An unknown kind or a non-list id argument is rejected with the canonical failure shape; an empty
539+
id list is a success no-op. The **unfiltered** whole-kind case still uses `set_all_collections_sync` (which re-fetches
540+
the kind from the server) so a large id list never crosses the WebSocket bridge; the frontend gates that whole-kind call
541+
behind a confirm. Both live on `LibraryFetcher`.
542+
534543
**Collection owner-scope filter — "Own" is a sync scope, not just a display filter (#1532).** RomM's collection list
535544
endpoints return the signed-in user's own collections plus every other user's _public_ collection. The QAM
536545
`Show

docs/user-guide/syncing-your-library.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -225,10 +225,19 @@ The **Collections** page splits collections into three sub-tabs plus a dedicated
225225
- **Virtual** sub-tab — auto-generated groupings RomM derives from IGDB metadata: both IGDB **franchise** groupings and
226226
IGDB **collection** (series) groupings. Each row is labelled with its type (Franchise / IGDB Collection).
227227

228-
Each sub-tab shows its visible count in the section header (e.g. `MY COLLECTIONS (4)`) and lets you toggle individual
229-
collections, or use the paired **Enable All** / **Disable All** buttons to bulk-toggle just that sub-tab. The global
230-
**Show collection games in platform groups** toggle controls whether games pulled in via a collection also get added to
231-
their platform's Steam group.
228+
Each sub-tab shows its match count in the section header (e.g. `MY COLLECTIONS (4)`) and lets you toggle individual
229+
collections. A **search box** above the list filters the current sub-tab by name — most useful on **Virtual**, which can
230+
run to hundreds of entries. On the **Virtual** sub-tab a segmented **All / Franchise / IGDB Collection** control narrows
231+
the list to one virtual type. The list itself is capped for performance: when more collections match than fit, the first
232+
rows render and a `… more — refine your search` hint appears — type in the search box to bring the rest into view.
233+
234+
The paired **Enable All** / **Disable All** buttons act on the **current filter**: with a search or the Virtual per-type
235+
filter active, they toggle exactly the matching collections; with no filter they toggle the whole sub-tab and ask for
236+
confirmation first, since that can be a large number.
237+
238+
The **Show collection games in platform groups** setting — whether games pulled in via a collection also get added to
239+
their platform's Steam group — now lives on the **Settings** page under **Library**, alongside the preferred-region
240+
preference. It applies to every sync, so it sits with the other set-and-forget preferences rather than on this tab.
232241

233242
#### Own / All
234243

@@ -241,7 +250,7 @@ On a **shared RomM server** the collection list includes every other user's _pub
241250
sync, even if one was enabled earlier — switching back to **All** brings your earlier choices back, since the scope
242251
filters over your enable state rather than changing it.
243252

244-
**Virtual collections always sync** under either setting: they are auto-generated groupings that belong to no one, so
253+
**Virtual collections always appear** under either setting: they are auto-generated groupings that have no owner, so
245254
"Own" never hides them. The filter only becomes active once the plugin knows your account identity, which it learns the
246255
first time you sign in (existing sign-ins pick it up on the next connection check). Until then, **Own** behaves exactly
247256
like **All** — it never hides a collection it can't yet attribute.

main.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,10 @@ async def get_collections(self):
332332
async def save_collection_sync(self, collection_id, kind, enabled):
333333
return self._sync_service.save_collection_sync(collection_id, kind, enabled)
334334

335+
@migration_blocked
336+
async def save_collections_sync(self, collection_ids, kind, enabled):
337+
return self._sync_service.save_collections_sync(collection_ids, kind, enabled)
338+
335339
@migration_blocked
336340
async def set_all_collections_sync(self, enabled, scope=None):
337341
return await self._sync_service.set_all_collections_sync(enabled, scope)

py_modules/services/library/fetcher.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,31 @@ def save_collection_sync(self, collection_id, kind, enabled):
348348
self._settings_persister.save_settings()
349349
return {"success": True}
350350

351+
def save_collections_sync(self, collection_ids, kind, enabled):
352+
"""Batch-stamp a bounded set of collection ids into one kind's bucket.
353+
354+
The frontend uses this for the filtered-subset Enable/Disable All (a
355+
search or per-type filter is active) so the whole-kind
356+
``set_all_collections_sync`` — which re-fetches every collection from the
357+
server — stays reserved for the unfiltered case. One settings write
358+
stamps every id in ``collection_ids`` to ``enabled`` in the ``kind``
359+
bucket. An unknown kind or a non-list id argument is rejected with the
360+
canonical failure shape; an empty id list is a success no-op (nothing to
361+
stamp, no write).
362+
"""
363+
if kind not in ("user", "smart", "virtual"):
364+
return {"success": False, "reason": "invalid_kind", "message": f"Invalid collection kind: {kind}"}
365+
if not isinstance(collection_ids, list):
366+
return {"success": False, "reason": "invalid_ids", "message": "collection_ids must be a list"}
367+
if not collection_ids:
368+
return {"success": True}
369+
buckets = self._get_enabled_collections_buckets()
370+
for cid in collection_ids:
371+
buckets[kind][str(cid)] = bool(enabled)
372+
self._settings["enabled_collections"] = buckets
373+
self._settings_persister.save_settings()
374+
return {"success": True}
375+
351376
async def set_all_collections_sync(self, enabled, scope=None):
352377
enabled = bool(enabled)
353378
if scope not in (None, "user", "smart", "virtual"):

py_modules/services/library/service.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,9 @@ async def get_collections(self):
290290
def save_collection_sync(self, collection_id, kind, enabled):
291291
return self._fetcher.save_collection_sync(collection_id, kind, enabled)
292292

293+
def save_collections_sync(self, collection_ids, kind, enabled):
294+
return self._fetcher.save_collections_sync(collection_ids, kind, enabled)
295+
293296
async def set_all_collections_sync(self, enabled, scope=None):
294297
return await self._fetcher.set_all_collections_sync(enabled, scope)
295298

src/api/backend.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,13 @@ export const getCollections = callable<
161161
export const saveCollectionSync = callable<[string, CollectionKind, boolean], { success: boolean; message?: string }>(
162162
"save_collection_sync",
163163
);
164+
// Batch stamp for a bounded, filtered subset of collections (search / per-type
165+
// filter active). The whole-kind Enable/Disable All keeps setAllCollectionsSync
166+
// so a huge id list never crosses the wire.
167+
export const saveCollectionsSync = callable<
168+
[string[], CollectionKind, boolean],
169+
{ success: boolean; reason?: string; message?: string }
170+
>("save_collections_sync");
164171
export const setAllCollectionsSync = callable<
165172
[boolean, "user" | "smart" | "virtual" | null],
166173
{ success: boolean; message?: string }

src/components/DangerZone.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { clearPlatformCollection, clearAllRomMCollections } from "../utils/colle
3636
import { formatUninstallStatus } from "../utils/formatters";
3737
import type { RegistryPlatform } from "../types";
3838
import { detach } from "../utils/detach";
39+
import { fuzzyMatch } from "../utils/fuzzyMatch";
3940

4041
const DEFAULT_WHITELIST_PATTERNS: string[] = [
4142
"retrodeck",
@@ -55,17 +56,6 @@ const DEFAULT_WHITELIST_PATTERNS: string[] = [
5556
"nonsteamlaunchers",
5657
];
5758

58-
// Fuzzy match: each character of the query must appear in order in the target (like fzf)
59-
const fuzzyMatch = (query: string, target: string): boolean => {
60-
const q = query.toLowerCase();
61-
const t = target.toLowerCase();
62-
let qi = 0;
63-
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
64-
if (t[ti] === q[qi]) qi++;
65-
}
66-
return qi === q.length;
67-
};
68-
6959
interface NonSteamApp {
7060
appId: number;
7161
name: string;

0 commit comments

Comments
 (0)