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-07 10:35:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 10:35:09 +0200
commit2c0903c648e24b4e2adf20492398e8b67d033b49 (patch)
tree0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
parent0ed078c92cabab1dab0f70f321562032ea549ce6 (diff)
parenteeda274d751c537f4ecef3087994a16a9517478f (diff)
downloadmeshbay-2c0903c648e24b4e2adf20492398e8b67d033b49.tar.gz
Merge branch 'refactor/groups-phase1'
Groups refactor, phases 1-3. The root model replaces the old `upload` flag and group-wide `member_upload` with per-root `writable`/`removable`/`ejected`, carried by a `RootSet` that both front doors — the loopback API and signed MNP — reach through the same `ops` functions. MNP goes to 1.1, additively: the roots table now rides on `index_delta`, so a root added, removed, ejected or plugged reaches every connected client instead of only whoever reloaded. The group UI becomes a plugin architecture: an application is a registry entry in `apps.js` plus its own files, with directories stored generically by `ops.set_app_directories` under whatever the app is called. A reference application, hidden behind `?dev=1`, is what makes that claim testable — adding it is what found the two places still naming apps by hand. 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.js298
1 files changed, 298 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..6e6dfad
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js
@@ -0,0 +1,298 @@
+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 chosen folders, as a table, plus the button that opens the picker.
+ *
+ * Not a `.settings-row`: that class is `display:flex; justify-content:
+ * space-between`, so a label, a hint and a value laid out inside one end up
+ * spread across a single line in whatever order they were written — which is
+ * how the first version of this read as three unrelated fragments per app.
+ *
+ * A table rather than a row of chips because these are lists now. Videos and
+ * Music can hold several folders, Photos routinely does, and a wrapped run of
+ * chips gives no column to scan and nowhere to put a per-row control. One
+ * folder per line, removable where it sits, in the same shape as the shared
+ * directories table above it — the operator is looking at two lists of
+ * directories on one page and they should read alike.
+ */
+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] : []);
+
+ const removeAt = (path) => {
+ if (!multi) { onChange(''); return; }
+ onChange(chosen.filter((p) => p !== path));
+ };
+
+ return html`
+ <div class="folder-field">
+ <div class="folder-field-head">
+ <h4 class="folder-field-label">${label}</h4>
+ ${hint && html`<p class="settings-hint">${hint}</p>`}
+ </div>
+
+ ${chosen.length > 0 && html`
+ <table class="shared-dirs-tbl folder-field-tbl">
+ <tbody>
+ ${chosen.map((path) => html`
+ <tr key=${path}>
+ <td class="sdt-col-dir">
+ <span class="sdt-dir-name">
+ <${Icon} name="folder" />
+ ${path}
+ </span>
+ </td>
+ <td class="sdt-col-actions">
+ <button class="sdt-action-btn sdt-action-danger"
+ disabled=${disabled}
+ title=${t('folder_tree.remove')}
+ onClick=${() => removeAt(path)}>\u{2715}</button>
+ </td>
+ </tr>
+ `)}
+ </tbody>
+ </table>
+ `}
+
+ <div class="folder-field-actions">
+ ${chosen.length === 0 && html`
+ <span class="settings-hint">${t('folder_tree.nothing_selected')}</span>`}
+ <button class="btn btn-small btn-secondary" disabled=${disabled}
+ onClick=${() => setOpen(true)}>
+ <${Icon} name="folder" />
+ ${' '}${chosen.length && multi ? t('folder_tree.add')
+ : 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 };