diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-07 10:35:09 +0200 |
| commit | 2c0903c648e24b4e2adf20492398e8b67d033b49 (patch) | |
| tree | 0435f298010f0f946362f28baebbe88337ca8768 /packages/meshbay-hub/src/meshbay_hub/static/folder-tree.js | |
| parent | 0ed078c92cabab1dab0f70f321562032ea549ce6 (diff) | |
| parent | eeda274d751c537f4ecef3087994a16a9517478f (diff) | |
| download | meshbay-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.js | 298 |
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 }; |