diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:03:22 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 19:03:22 +0200 |
| commit | ab44526a291fa673aa2850d105f6412a70a5341f (patch) | |
| tree | 5940f18acfc15fc732eb65d90de346920461b8c8 /packages/meshbay-hub/src/meshbay_hub/static/search-page.js | |
| parent | 85a2ec47b7ad334208a3dbb091fadccc7631785c (diff) | |
| download | meshbay-ab44526a291fa673aa2850d105f6412a70a5341f.tar.gz | |
feat(client): Phase 2 — per-app settings panes, folder tree, multi-directory
Each app's settings were inlined in `group-settings.js` — TMDB, MusicBrainz,
and one folder picker per app, each with its own draft state and save handler
saying the same thing about a different key. They are one file per app now,
reached through the `apps.js` registry, and the page that renders them names no
application at all: adding one is a registry entry and a settings file.
The line between the two is what makes that true. What every app has — folders
— the page does generically, through one `saveDirectories` bound to the app.
What one app alone has, its pane does itself with the transport it is handed.
An app that only needs directories touches neither `group-settings.js` nor
`group-page.js`, which is `test_app_settings_plugin.py`'s subject.
`settings-ui.js` exists because a pane importing the page that renders it is a
cycle, and ES modules answer that with a temporal-dead-zone ReferenceError at
first render — a component that silently does not appear, the fault already
recorded in CLAUDE.md about hook ordering.
The flat depth-indented `<select>` of every folder in the library becomes a
modal tree. It asks the node for nothing: the tree is derived from paths the
client already holds, so it shows exactly what the group's index contains and
adds no folder-browsing protocol. For Chat's attachment folder — the one
directory that is written to rather than read — read-only roots are greyed
out, so the node's refusal arrives before the operator picks rather than when
somebody sends a file.
Videos and Music take a list of folders. A library on two drives could not be
described before; the only recourse was pointing the app at a parent containing
both, which pulls in everything else under it. The scalar shapes survive on the
wire alone, for a node speaking MNP 1.0, and the client reads them as a
one-element list.
Two things the tests caught that I would not have:
`test_asset_versioning` — six new modules were missing from `_ASSETS`. Reached
through the registry rather than imported by name, they are exactly the files
nothing else would notice changing, and a stale one is served from cache with
no version bump.
And `node --check foo.js` does **not** reliably report a module syntax error:
it accepted `${/* ... */''}` — htm template syntax pasted into a plain object
literal — and reported success. A `.mjs` copy forces the module parser and
reports it. The suite had no syntax check at all, which is how that reached a
file; `test_spa_syntax.py` does it for every module now, and pins that the
loose path is not what it uses.
Suite: 12 failures, all pre-existing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011pvMdvLBG92jyhvD5pD6us
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/search-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/search-page.js | 61 |
1 files changed, 41 insertions, 20 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js index 6e4e5b9..4ed3df1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/search-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/search-page.js @@ -147,10 +147,12 @@ async function fetchGroupIndex(groupId, token, bundleKey, username, userId) { clearTimeout(timer); const indexMsg = await transport.fetchIndex(); + // Plural, with the old scalars as the fallback for a node still speaking + // MNP 1.0 — the same reading group-page.js does on its own handshake. const roots = { - videoRoot: ack.video_root || '', - audioRoot: ack.audio_root || '', - photoRoots: ack.photo_roots || [], + video: ack.video_directories || (ack.video_root ? [ack.video_root] : []), + music: ack.music_directories || (ack.audio_root ? [ack.audio_root] : []), + photo: ack.photo_directories || ack.photo_roots || [], }; // Which of the reader's groups sit on their own node — the tie-breaker // when the same file is announced by several of them @@ -201,10 +203,29 @@ async function fetchAllIndexes(groups, token, username, userId, onProgress, onBa // -- SearchPage --------------------------------------------------------------- -function underRoot(entry, root) { - if (!root) return false; +function underRoot(entry, directories) { + const dirs = directories || []; + if (!dirs.length) return false; const p = entry.path || ''; - return p === root || p.startsWith(root + '/'); + return dirs.some((d) => p === d || p.startsWith(d + '/')); +} + +/** + * An app's directories out of a cached group, in either shape. + * + * The cache lives in IndexedDB and outlives a deploy, so a reader opening + * Search after this ships still has entries written by the previous version — + * `{videoRoot: 'X'}` where this now writes `{video: ['X']}`. Reading only the + * new shape would empty their Videos results with no explanation and no way + * to tell it from "nothing matched". + */ +function cachedDirs(roots, appKey, legacyKey) { + if (!roots) return []; + const fresh = roots[appKey]; + if (Array.isArray(fresh)) return fresh; + const legacy = roots[legacyKey]; + if (Array.isArray(legacy)) return legacy; + return legacy ? [legacy] : []; } // -- Merging the same file announced by several groups ------------------------ @@ -226,7 +247,7 @@ function underRoot(entry, root) { // `mergeUnitEntries` folds lists that share a key, so the two copies become // one unit without this having to group them first. function videoUnits(entries) { - const { movies, shows } = groupVideoEntries(entries, SEARCH_VIDEO_ROOT); + const { movies, shows } = groupVideoEntries(entries, [SEARCH_VIDEO_ROOT]); return [ ...movies.map((e) => ({ key: `movie:${e.id}`, entries: [e] })), ...shows.map((s) => ({ key: `show:${s.title}`, entries: s.episodes })), @@ -238,7 +259,7 @@ function videoUnits(entries) { // object, so the key only has to name it stably — hence `foldKey` over the // display strings, which are whichever spelling arrived first. function musicUnits(entries) { - const { tracks, albums } = groupMusicEntries(entries, SEARCH_AUDIO_ROOT); + const { tracks, albums } = groupMusicEntries(entries, [SEARCH_AUDIO_ROOT]); return [ ...albums.map((a) => ({ key: `album:${foldKey(a.artist)}/${foldKey(a.album)}`, @@ -497,12 +518,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) const videoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { - const root = data.roots.videoRoot; - if (!root) continue; + const dirs = cachedDirs(data.roots, 'video', 'videoRoot'); + if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'video') continue; - if (!underRoot(e, root)) continue; + if (!underRoot(e, dirs)) continue; if (q && !matchesQuery(e)) continue; result.push({ ...e, @@ -523,12 +544,12 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) const musicEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { - const root = data.roots.audioRoot; - if (!root) continue; + const dirs = cachedDirs(data.roots, 'music', 'audioRoot'); + if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'audio') continue; - if (!underRoot(e, root)) continue; + if (!underRoot(e, dirs)) continue; if (q && !matchesQuery(e)) continue; result.push({ ...e, @@ -549,13 +570,13 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) const photoEntries = useMemo(() => { const result = []; for (const [groupId, data] of indexedGroups) { - const roots = data.roots.photoRoots; - if (!roots || !roots.length) continue; + const dirs = cachedDirs(data.roots, 'photo', 'photoRoots'); + if (!dirs.length) continue; const conn = groupConns.current.get(groupId); for (const e of data.entries) { if (e.type !== 'image') continue; const p = e.path || ''; - if (!roots.some((r) => p === r || p.startsWith(r + '/'))) continue; + if (!dirs.some((d) => p === d || p.startsWith(d + '/'))) continue; if (q && !matchesQuery(e)) continue; result.push({ ...e, @@ -725,7 +746,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) status="connected" entries=${videoEntries} onPreview=${onPreview} - videoRoot=${SEARCH_VIDEO_ROOT} + videoDirectories=${[SEARCH_VIDEO_ROOT]} tmdbConfig=${{ enabled: true }} isNodeAdmin=${false} onNeedConn=${connectGroup} @@ -739,7 +760,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) gekRef=${defaultGRef} status="connected" entries=${musicEntries} - audioRoot=${SEARCH_AUDIO_ROOT} + musicDirectories=${[SEARCH_AUDIO_ROOT]} musicbrainzConfig=${{ enabled: true }} onPlayQueue=${handleMusicPlay} hideFilter=${true} /> @@ -752,7 +773,7 @@ function SearchPage({ token, username, userId, groups, onPlayQueue, userPrefs }) gekRef=${defaultGRef} status="connected" entries=${photoEntries} - photoRoots=${SEARCH_PHOTO_ROOTS} + photoDirectories=${SEARCH_PHOTO_ROOTS} setError=${noop} hideFilter=${true} readOnly=${true} /> |