292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
(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();
|
|
}
|
|
});
|
|
|
|
if (kind === 'folder') {
|
|
row.addEventListener('dblclick', function (e) {
|
|
e.preventDefault();
|
|
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;
|
|
})();
|