1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
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 };
|