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/folder-tree.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/folder-tree.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js | 256 |
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 }; |