From ab44526a291fa673aa2850d105f6412a70a5341f Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 6 Sep 2026 19:03:22 +0200 Subject: feat(client): Phase 2 — per-app settings panes, folder tree, multi-directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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` +
  • +
    + + +
    + ${isOpen && kids.length > 0 && html` + + `} +
  • + `; + }; + + const topLevel = childrenOf.get('') || []; + const chosenList = [...picked].sort(); + const noWritableRoot = requireWritable + && !(roots || []).some((r) => r.writable); + + return html` +
    +
    e.stopPropagation()}> +

    ${t(multi ? 'folder_tree.title_multi' + : 'folder_tree.title_single')}

    + ${requireWritable && html` +

    ${ + noWritableRoot ? t('folder_tree.no_writable_root') + : t('folder_tree.writable_only')}

    `} + + ${topLevel.length === 0 ? html` +

    ${t('folder_tree.empty')}

    + ` : html` +
      ${topLevel.map((p) => renderNode(p, 0))}
    + `} + +
    + ${chosenList.length + ? chosenList.map((p) => html`${p}`) + : html`${t('folder_tree.nothing_selected')}`} +
    + +
    + + ${/* OK is offered with nothing selected on purpose: clearing an + app's directories is a real choice, and the only way to make + it. */''} + +
    +
    +
    + `; +} + +/** + * 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` +
    + + ${hint && html`

    ${hint}

    `} +
    +
    + ${chosen.length + ? chosen.map((p) => html`${p}`) + : html`${t('folder_tree.nothing_selected')}`} +
    + +
    + ${open && html` + <${FolderTreePicker} + roots=${roots} dirs=${dirs} mode=${mode} + requireWritable=${requireWritable} + selected=${value} + onCancel=${() => setOpen(false)} + onConfirm=${(sel) => { setOpen(false); onChange(sel); }} /> + `} +
    + `; +} + +export { FolderTreePicker, FolderPickerField }; -- cgit v1.2.3