(function () { function joinPath(parent, name) { if (parent === '/' || parent === '') return '/' + name; return parent.replace(/\/+$/, '') + '/' + name; } function encodePath(p) { if (p === '/' || p === '') return '/'; return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/') + '/'; } function fetchFolder(path) { return fetch(encodePath(path) + '?tree=1', { credentials: 'same-origin' }) .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }); } function openTreePicker(opts) { opts = opts || {}; var mode = opts.mode || 'folder'; var initialPath = opts.initialPath || '/'; var allowRoot = opts.allowRoot !== false; var hideFiles = !!opts.hideFiles; var preselect = opts.preselect || null; var container = document.createElement('div'); var treeEl = document.createElement('div'); treeEl.className = 'tree-picker'; var selectedPathEl = document.createElement('div'); selectedPathEl.className = 'tree-selected-path muted'; selectedPathEl.textContent = '\u00a0'; container.appendChild(treeEl); container.appendChild(selectedPathEl); var selected = null; // { path, kind, rowEl } var handle = openModal({ title: opts.title || 'Pick', body: container, confirm: { label: opts.confirmLabel || 'SELECT', initiallyDisabled: true, onConfirm: function () { if (!selected) return; handle.close(); if (opts.onSelect) opts.onSelect(selected.path, selected.kind); } } }); function setSelected(path, kind, rowEl) { if (selected && selected.rowEl) selected.rowEl.classList.remove('is-selected'); selected = { path: path, kind: kind, rowEl: rowEl }; rowEl.classList.add('is-selected'); selectedPathEl.textContent = path; handle.setConfirmDisabled(false); } function isSelectable(kind) { if (mode === 'any') return true; if (mode === 'folder') return kind === 'folder'; if (mode === 'file') return kind === 'file'; return false; } // buildRow returns { rowEl, expand(): Promise }. `expand` is a no-op // for files and idempotent for folders (resolves with the already- // loaded children on repeat calls). function buildRow(parentPath, name, kind) { var row = document.createElement('div'); row.className = 'tree-row'; if (!isSelectable(kind)) row.classList.add('is-disabled'); var chevron = document.createElement('span'); chevron.className = 'tree-chevron'; if (kind === 'folder') { chevron.textContent = '\u25b8'; // ▸ } else { chevron.classList.add('is-leaf'); } row.appendChild(chevron); var marker = document.createElement('span'); marker.className = 'tree-marker'; marker.textContent = kind === 'folder' ? '' : '\u00b7'; row.appendChild(marker); var label = document.createElement('span'); label.className = 'tree-name'; label.textContent = name; row.appendChild(label); var fullPath = joinPath(parentPath, name); var childrenEl = null; var loadPromise = null; var isOpen = false; function expand() { if (kind !== 'folder') return Promise.resolve(null); if (isOpen) return loadPromise || Promise.resolve(childrenEl); if (!childrenEl) { childrenEl = document.createElement('div'); childrenEl.className = 'tree-children'; } row.parentNode.insertBefore(childrenEl, row.nextSibling); chevron.textContent = '\u25be'; // ▾ isOpen = true; if (!loadPromise) { loadPromise = loadInto(fullPath, childrenEl); } return loadPromise; } function collapse() { if (kind !== 'folder' || !isOpen) return; if (childrenEl && childrenEl.parentNode) { childrenEl.parentNode.removeChild(childrenEl); } chevron.textContent = '\u25b8'; isOpen = false; } chevron.addEventListener('click', function (e) { e.stopPropagation(); if (isOpen) collapse(); else expand(); }); row.addEventListener('click', function () { if (isSelectable(kind)) { setSelected(fullPath, kind, row); } else if (kind === 'folder') { if (isOpen) collapse(); else expand(); } }); return { rowEl: row, name: name, kind: kind, childrenEl: function () { return childrenEl; }, expand: expand }; } // loadInto fetches folderPath and populates `target` with rows. Returns // an array of the row objects on success, or [] on failure. function loadInto(folderPath, target) { target.textContent = ''; var loading = document.createElement('div'); loading.className = 'tree-row is-disabled'; loading.textContent = '\u2026'; target.appendChild(loading); return fetchFolder(folderPath).then(function (resp) { target.textContent = ''; var rows = []; (resp.entries || []).forEach(function (e) { if (hideFiles && e.kind !== 'folder') return; var r = buildRow(resp.path, e.name, e.kind); target.appendChild(r.rowEl); rows.push(r); }); if (rows.length === 0) { var empty = document.createElement('div'); empty.className = 'tree-row is-disabled'; empty.textContent = '(empty)'; target.appendChild(empty); } return rows; }).catch(function () { target.textContent = ''; var err = document.createElement('div'); err.className = 'tree-row'; err.textContent = '(failed — tap to retry)'; err.addEventListener('click', function () { loadInto(folderPath, target); }); target.appendChild(err); return []; }); } // Root selection row — visible when allowRoot and mode accepts folders. if (allowRoot && isSelectable('folder')) { var rootRow = document.createElement('div'); rootRow.className = 'tree-row'; var rootChev = document.createElement('span'); rootChev.className = 'tree-chevron is-leaf'; rootRow.appendChild(rootChev); var rootMarker = document.createElement('span'); rootMarker.className = 'tree-marker'; rootRow.appendChild(rootMarker); var rootLabel = document.createElement('span'); rootLabel.className = 'tree-name'; rootLabel.textContent = '/'; rootRow.appendChild(rootLabel); rootRow.addEventListener('click', function () { setSelected('/', 'folder', rootRow); }); treeEl.appendChild(rootRow); } var rootChildren = document.createElement('div'); treeEl.appendChild(rootChildren); // Expand the ancestor chain of initialPath so the user lands in // context. For root, just load root children. var segs = (initialPath || '/').split('/').filter(Boolean); var preselectSegs = preselect ? preselect.split('/').filter(Boolean) : null; loadInto('/', rootChildren).then(function (rows) { return expandChain(rows, segs, '/'); }).then(function () { if (preselectSegs === null) return; if (preselectSegs.length === 0) { // Preselect root itself (if allowed). if (allowRoot && isSelectable('folder')) { var root = treeEl.querySelector('.tree-row'); if (root) setSelected('/', 'folder', root); } return; } selectByPath(preselectSegs); }); // expandChain walks `segments`, looking up each by name in the current // row list, expanding it, and recursing into its children. function expandChain(rows, segments, basePath) { if (segments.length === 0) return Promise.resolve(); var seg = segments[0]; var match = null; for (var i = 0; i < rows.length; i++) { if (rows[i].kind === 'folder' && rows[i].name === seg) { match = rows[i]; break; } } if (!match) return Promise.resolve(); return match.expand().then(function (childRows) { return expandChain(childRows || [], segments.slice(1), joinPath(basePath, seg)); }); } // selectByPath walks the visible tree rows to locate the row matching // `segments` and marks it selected. Assumes its ancestors are already // expanded (expandChain ran first). function selectByPath(segments) { var container = rootChildren; var path = '/'; for (var i = 0; i < segments.length; i++) { var seg = segments[i]; var kids = container.children; var found = null; for (var j = 0; j < kids.length; j++) { var row = kids[j]; if (!row.classList || !row.classList.contains('tree-row')) continue; var nm = row.querySelector('.tree-name'); if (nm && nm.textContent === seg) { found = row; break; } } if (!found) return; path = joinPath(path, seg); if (i === segments.length - 1) { if (isSelectable('folder')) setSelected(path, 'folder', found); try { found.scrollIntoView({ block: 'nearest' }); } catch (e) {} return; } var next = found.nextSibling; if (!next || !next.classList || !next.classList.contains('tree-children')) return; container = next; } } return handle; } window.openTreePicker = openTreePicker; })();