aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-06 19:03:22 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-06 19:03:22 +0200
commitab44526a291fa673aa2850d105f6412a70a5341f (patch)
tree5940f18acfc15fc732eb65d90de346920461b8c8 /packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
parent85a2ec47b7ad334208a3dbb091fadccc7631785c (diff)
downloadmeshbay-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/folder-tree.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js256
1 files changed, 256 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
new file mode 100644
index 0000000..1af6573
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
@@ -0,0 +1,256 @@
+import {
+ html, useState, useEffect, useMemo, useCallback, useRef,
+} from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { Icon } from './icon.js';
+
+/**
+ * A modal folder picker over a group's shared directories.
+ *
+ * It replaces the flat `<select>` of depth-indented paths every app settings
+ * pane used to carry. That control was defensible while an app picked one
+ * folder once; with several apps picking several folders each, a list of a few
+ * hundred `Media/Films/Action/1999` strings is not something anyone reads.
+ *
+ * **There is no folder-browsing protocol, and this does not add one.** The
+ * whole tree is derived from paths the client already holds — every entry's
+ * folder and every directory the index reports — so opening this asks the node
+ * nothing. That also means it shows exactly what the group's index contains:
+ * an empty folder the node never indexed is not in here, because as far as the
+ * group is concerned it does not exist.
+ *
+ * Props:
+ * roots — the group's roots ({ name, writable, removable, ejected,
+ * available }), for the badges and the writable rule
+ * dirs — every known directory path, `Media/Films` style
+ * mode — "single" (default) or "multi"
+ * requireWritable— grey out roots that do not accept writes, for a
+ * destination rather than a view (Chat's attachments)
+ * selected — current selection: a string in single mode, an array in
+ * multi
+ * onConfirm(sel) — called with the same shape on OK
+ * onCancel()
+ */
+function FolderTreePicker({
+ roots, dirs, mode = 'single', requireWritable = false,
+ selected, onConfirm, onCancel,
+}) {
+ const multi = mode === 'multi';
+ const initial = useMemo(() => {
+ if (multi) return new Set(selected || []);
+ return new Set(selected ? [selected] : []);
+ }, []); // eslint-disable-line -- the initial selection only, never a reset
+
+ const [picked, setPicked] = useState(initial);
+ const [expanded, setExpanded] = useState(() => new Set());
+ const panelRef = useRef(null);
+
+ // Escape closes, and the panel takes focus so it does — a modal that only
+ // responds to the mouse is one a keyboard user cannot leave.
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onCancel(); };
+ window.addEventListener('keydown', onKey);
+ if (panelRef.current) panelRef.current.focus();
+ return () => window.removeEventListener('keydown', onKey);
+ }, [onCancel]);
+
+ // Every ancestor of every known path, so a folder is reachable even when
+ // only something several levels below it was ever indexed.
+ const nodes = useMemo(() => {
+ const all = new Set();
+ for (const d of (dirs || [])) {
+ if (!d) continue;
+ const parts = d.split('/');
+ for (let i = 1; i <= parts.length; i++) all.add(parts.slice(0, i).join('/'));
+ }
+ // A root with nothing under it is still a choice: pointing an app at a
+ // library that has not been scanned yet is exactly what an operator does
+ // right after adding the directory.
+ for (const r of (roots || [])) all.add(r.name);
+ return all;
+ }, [dirs, roots]);
+
+ const childrenOf = useMemo(() => {
+ const map = new Map();
+ for (const path of nodes) {
+ const cut = path.lastIndexOf('/');
+ const parent = cut === -1 ? '' : path.slice(0, cut);
+ if (!map.has(parent)) map.set(parent, []);
+ map.get(parent).push(path);
+ }
+ for (const list of map.values()) {
+ list.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
+ }
+ return map;
+ }, [nodes]);
+
+ const rootByName = useMemo(
+ () => new Map((roots || []).map((r) => [r.name, r])), [roots]);
+
+ // A path's own root decides whether it can be picked: writability is a
+ // property of the root, and everything under it inherits.
+ const rootOf = useCallback(
+ (path) => rootByName.get(path.split('/')[0]) || null, [rootByName]);
+
+ const selectable = useCallback((path) => {
+ if (!requireWritable) return true;
+ const root = rootOf(path);
+ return Boolean(root && root.writable);
+ }, [requireWritable, rootOf]);
+
+ const toggleExpand = useCallback((path) => {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path); else next.add(path);
+ return next;
+ });
+ }, []);
+
+ const choose = useCallback((path) => {
+ if (!selectable(path)) return;
+ setPicked((prev) => {
+ if (!multi) return new Set(prev.has(path) ? [] : [path]);
+ const next = new Set(prev);
+ if (next.has(path)) next.delete(path); else next.add(path);
+ return next;
+ });
+ }, [multi, selectable]);
+
+ // Everything already chosen is expanded on open, so the selection is
+ // visible rather than folded away inside a collapsed branch.
+ useEffect(() => {
+ const open = new Set();
+ for (const path of initial) {
+ const parts = path.split('/');
+ for (let i = 1; i < parts.length; i++) open.add(parts.slice(0, i).join('/'));
+ }
+ setExpanded(open);
+ }, [initial]);
+
+ const renderNode = (path, depth) => {
+ const kids = childrenOf.get(path) || [];
+ const isOpen = expanded.has(path);
+ const isRoot = depth === 0;
+ const root = isRoot ? rootByName.get(path) : null;
+ const name = isRoot ? path : path.slice(path.lastIndexOf('/') + 1);
+ const can = selectable(path);
+ const chosen = picked.has(path);
+
+ return html`
+ <li key=${path} class="ftp-node">
+ <div class="ftp-row ${chosen ? 'chosen' : ''} ${can ? '' : 'blocked'}"
+ style="padding-left:${depth * 18}px"
+ title=${can ? path : t('folder_tree.read_only_blocked')}>
+ <button class="ftp-twisty" disabled=${!kids.length}
+ aria-label=${isOpen ? t('folder_tree.collapse') : t('folder_tree.expand')}
+ onClick=${() => toggleExpand(path)}>
+ ${kids.length ? (isOpen ? '−' : '+') : ' '}
+ </button>
+ <button class="ftp-label" disabled=${!can} onClick=${() => choose(path)}>
+ <${Icon} name="folder" />
+ <span class="ftp-name">${name}</span>
+ ${isRoot && root && html`
+ <span class="ftp-badge ${root.writable ? 'rw' : 'ro'}">
+ ${root.writable ? t('node.root_rw') : t('node.root_ro')}
+ </span>`}
+ ${isRoot && root && root.ejected && html`
+ <span class="ftp-badge warn">${t('group.root_ejected')}</span>`}
+ ${isRoot && root && !root.ejected && root.available === false && html`
+ <span class="ftp-badge warn">${t('node.unavailable')}</span>`}
+ ${chosen && html`<span class="ftp-check">✓</span>`}
+ </button>
+ </div>
+ ${isOpen && kids.length > 0 && html`
+ <ul class="ftp-children">
+ ${kids.map((child) => renderNode(child, depth + 1))}
+ </ul>
+ `}
+ </li>
+ `;
+ };
+
+ const topLevel = childrenOf.get('') || [];
+ const chosenList = [...picked].sort();
+ const noWritableRoot = requireWritable
+ && !(roots || []).some((r) => r.writable);
+
+ return html`
+ <div class="ftp-backdrop" onClick=${onCancel}>
+ <div class="ftp-panel" tabindex="-1" ref=${panelRef}
+ onClick=${(e) => e.stopPropagation()}>
+ <h3 class="ftp-title">${t(multi ? 'folder_tree.title_multi'
+ : 'folder_tree.title_single')}</h3>
+ ${requireWritable && html`
+ <p class="settings-hint">${
+ noWritableRoot ? t('folder_tree.no_writable_root')
+ : t('folder_tree.writable_only')}</p>`}
+
+ ${topLevel.length === 0 ? html`
+ <p class="settings-hint">${t('folder_tree.empty')}</p>
+ ` : html`
+ <ul class="ftp-tree">${topLevel.map((p) => renderNode(p, 0))}</ul>
+ `}
+
+ <div class="ftp-selection">
+ ${chosenList.length
+ ? chosenList.map((p) => html`<code key=${p} class="ftp-chip">${p}</code>`)
+ : html`<span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ </div>
+
+ <div class="ftp-actions">
+ <button class="btn btn-small" onClick=${onCancel}>
+ ${t('settings.cancel')}
+ </button>
+ ${/* OK is offered with nothing selected on purpose: clearing an
+ app's directories is a real choice, and the only way to make
+ it. */''}
+ <button class="btn btn-small btn-secondary"
+ onClick=${() => onConfirm(multi ? chosenList : (chosenList[0] || ''))}>
+ ${t('folder_tree.confirm')}
+ </button>
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+/**
+ * The button-plus-modal pairing every settings pane wants, so none of them
+ * has to hold `open` state of its own.
+ */
+function FolderPickerField({
+ label, hint, roots, dirs, mode = 'single', requireWritable = false,
+ value, onChange, disabled,
+}) {
+ const [open, setOpen] = useState(false);
+ const multi = mode === 'multi';
+ const chosen = multi ? (value || []) : (value ? [value] : []);
+
+ return html`
+ <div class="settings-row">
+ <label class="settings-label">${label}</label>
+ ${hint && html`<p class="settings-hint">${hint}</p>`}
+ <div class="ftp-field">
+ <div class="ftp-field-value">
+ ${chosen.length
+ ? chosen.map((p) => html`<code key=${p} class="ftp-chip">${p}</code>`)
+ : html`<span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ </div>
+ <button class="btn btn-small btn-secondary" disabled=${disabled}
+ onClick=${() => setOpen(true)}>
+ <${Icon} name="folder" /> ${t('folder_tree.choose')}
+ </button>
+ </div>
+ ${open && html`
+ <${FolderTreePicker}
+ roots=${roots} dirs=${dirs} mode=${mode}
+ requireWritable=${requireWritable}
+ selected=${value}
+ onCancel=${() => setOpen(false)}
+ onConfirm=${(sel) => { setOpen(false); onChange(sel); }} />
+ `}
+ </div>
+ `;
+}
+
+export { FolderTreePicker, FolderPickerField };