- ${data.error ? `
${escapeHtml(data.error)}
` : ''}
- ${data.loading ? '
Lade...
' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
+
+ ${data.treeVisible ? renderFolderTree(win) : ''}
+
+
+ ${data.error ? `
${escapeHtml(data.error)}
` : ''}
+ ${data.loading ? '
Lade...
' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
+
+
`;
@@ -314,11 +332,16 @@ function renderExplorer(body, win) {
body.querySelector('[data-action="go"]').addEventListener('click', () => loadExplorer(win, input.value));
body.querySelector('[data-action="refresh"]').addEventListener('click', () => loadExplorer(win, data.path));
body.querySelector('[data-action="up"]').addEventListener('click', () => loadExplorer(win, parentPath(data.path)));
+ body.querySelector('[data-action="toggle-tree"]').addEventListener('click', () => {
+ data.treeVisible = !data.treeVisible;
+ render();
+ });
body.querySelector('[data-action="view-list"]').addEventListener('click', () => setExplorerView(win, 'list'));
body.querySelector('[data-action="view-icons"]').addEventListener('click', () => setExplorerView(win, 'icons'));
body.querySelector('[data-action="upload-files"]').addEventListener('click', () => body.querySelector('[data-upload="files"]').click());
body.querySelector('[data-action="upload-folder"]').addEventListener('click', () => body.querySelector('[data-upload="folder"]').click());
body.querySelector('[data-action="delete-selected"]').addEventListener('click', () => deleteEntries(win));
+ bindFolderTree(body, win);
for (const inputEl of body.querySelectorAll('.upload-input')) {
inputEl.addEventListener('change', async () => {
try {
@@ -332,6 +355,7 @@ function renderExplorer(body, win) {
}
const dropZone = body.querySelector('.drop-zone');
+ const fileScrollHost = body.querySelector('.explorer-content') || body;
dropZone.addEventListener('dragenter', (event) => {
if (hasDropPayload(event)) {
event.preventDefault();
@@ -372,12 +396,12 @@ function renderExplorer(body, win) {
row.addEventListener('dragstart', (event) => event.preventDefault());
row.addEventListener('selectstart', (event) => event.preventDefault());
row.addEventListener('pointerdown', (event) => {
- lockExplorerScroll(win, body);
+ lockExplorerScroll(win, fileScrollHost);
startItemPointerDrag(event, win, entry);
});
row.addEventListener('click', (event) => {
if (shouldSuppressItemActivation()) return;
- selectEntry(win, entry, body, event);
+ selectEntry(win, entry, fileScrollHost, event);
});
row.addEventListener('dblclick', () => {
if (shouldSuppressItemActivation()) return;
@@ -402,6 +426,71 @@ function renderExplorer(body, win) {
}
}
+function renderFolderTree(win) {
+ const roots = state.config?.roots || [];
+ const branches = roots.map((root) => renderTreeBranch(win, root.path, root.label || root.path, 0)).join('');
+ return `
+
+ `;
+}
+
+function renderTreeBranch(win, path, label, level) {
+ const data = win.data;
+ const expanded = (data.treeExpanded || []).includes(path);
+ const loading = (data.treeLoading || []).includes(path);
+ const children = data.treeChildren?.[path];
+ const active = normalizePath(data.path) === normalizePath(path);
+ const mayExpand = loading || children === undefined || children.length > 0;
+ const childMarkup = expanded && children?.length
+ ? `
${children.map((entry) => renderTreeBranch(win, entry.path, entry.name, level + 1)).join('')}
`
+ : '';
+ return `
+
+
+
+
+
+ ${childMarkup}
+
+ `;
+}
+
+function bindFolderTree(body, win) {
+ body.querySelector('[data-action="close-tree"]')?.addEventListener('click', () => {
+ win.data.treeVisible = false;
+ render();
+ });
+ for (const button of body.querySelectorAll('[data-tree-open]')) {
+ button.addEventListener('click', () => loadExplorer(win, button.dataset.treeOpen));
+ }
+ for (const button of body.querySelectorAll('[data-tree-toggle]')) {
+ button.addEventListener('click', async () => {
+ const path = button.dataset.treeToggle;
+ const expanded = new Set(win.data.treeExpanded || []);
+ if (expanded.has(path)) {
+ expanded.delete(path);
+ win.data.treeExpanded = [...expanded];
+ render();
+ return;
+ }
+ expanded.add(path);
+ win.data.treeExpanded = [...expanded];
+ await loadTreeChildren(win, path);
+ });
+ }
+}
+
function renderFileEntries(entries, view, selectedPaths = new Set()) {
if (!entries.length) {
return '
Dieser Ordner ist leer. Dateien können hier abgelegt werden.
';
@@ -412,8 +501,8 @@ function renderFileEntries(entries, view, selectedPaths = new Set()) {
function renderFileTable(entries, selectedPaths) {
const rows = entries.map((entry) => `
- | ${icons[entry.type] || 'Item'}${escapeHtml(entry.name)} |
- ${entry.type} |
+ ${fileTypeIcon(entry)}${escapeHtml(entry.name)} |
+ ${escapeHtml(typeLabel(entry.type))} |
${entry.type === 'file' ? formatBytes(entry.size) : ''} |
${new Date(entry.modifiedAt).toLocaleString()} |
@@ -430,15 +519,25 @@ function renderFileTable(entries, selectedPaths) {
function renderFileIcons(entries, selectedPaths) {
const items = entries.map((entry) => `
`).join('');
return `
${items}
`;
}
+function fileTypeIcon(entry, large = false) {
+ const name = entry?.type === 'directory' ? 'folder' : entry?.type === 'symlink' ? 'link' : 'file';
+ const label = icons[entry?.type] || 'Item';
+ return `
${iconSvg(name)}`;
+}
+
+function typeLabel(type) {
+ return ({ directory: 'Ordner', file: 'Datei', symlink: 'Verknüpfung', other: 'Element' })[type] || 'Element';
+}
+
function setExplorerView(win, view) {
win.data.view = view;
render();
@@ -916,6 +1015,8 @@ async function loadExplorer(win, targetPath) {
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`));
win.data.path = result.path;
win.data.entries = result.entries;
+ cacheTreeChildren(win, result.path, result.entries);
+ await hydrateTreePath(win, result.path);
reconcileExplorerSelection(win);
} catch (error) {
win.data.error = error.message;
@@ -925,6 +1026,59 @@ async function loadExplorer(win, targetPath) {
}
}
+async function loadTreeChildren(win, path) {
+ if (win.data.treeChildren?.[path] !== undefined) {
+ render();
+ return;
+ }
+ const loading = new Set(win.data.treeLoading || []);
+ loading.add(path);
+ win.data.treeLoading = [...loading];
+ render();
+ try {
+ const result = await api(apiUrl(`list.php?path=${encodeURIComponent(path)}`));
+ cacheTreeChildren(win, result.path, result.entries);
+ } catch (error) {
+ win.data.treeChildren = { ...(win.data.treeChildren || {}), [path]: [] };
+ showError(error);
+ } finally {
+ win.data.treeLoading = (win.data.treeLoading || []).filter((item) => item !== path);
+ render();
+ }
+}
+
+function cacheTreeChildren(win, path, entries) {
+ win.data.treeChildren = {
+ ...(win.data.treeChildren || {}),
+ [path]: (entries || []).filter((entry) => entry.type === 'directory')
+ };
+}
+
+async function hydrateTreePath(win, targetPath) {
+ const root = rootForPath(targetPath);
+ if (!root) return;
+ const relative = normalizePath(targetPath).slice(normalizePath(root.path).length).split('/').filter(Boolean);
+ const paths = [normalizePath(root.path)];
+ for (const part of relative) paths.push(joinPath(paths.at(-1), part));
+ win.data.treeExpanded = [...new Set([...(win.data.treeExpanded || []), ...paths])];
+ for (const path of paths.slice(0, -1)) {
+ if (win.data.treeChildren?.[path] !== undefined) continue;
+ try {
+ const result = await api(apiUrl(`list.php?path=${encodeURIComponent(path)}`));
+ cacheTreeChildren(win, result.path, result.entries);
+ } catch {
+ win.data.treeChildren = { ...(win.data.treeChildren || {}), [path]: [] };
+ }
+ }
+}
+
+function rootForPath(path) {
+ const normalized = normalizePath(path);
+ return (state.config?.roots || [])
+ .filter((root) => normalized === normalizePath(root.path) || isPathInside(normalized, root.path))
+ .sort((a, b) => b.path.length - a.path.length)[0] || null;
+}
+
async function handleDrop(event, win, targetPath) {
const uploadItems = await getDroppedUploadItems(event);
recordDebug('html.drop', {
@@ -2221,6 +2375,9 @@ function clearPointerDropTarget() {
for (const item of workspace.querySelectorAll('.file-row.drop-target')) {
item.classList.remove('drop-target');
}
+ for (const item of workspace.querySelectorAll('.tree-label.drop-target')) {
+ item.classList.remove('drop-target');
+ }
}
function findPointerDropTarget(event) {
@@ -2253,6 +2410,11 @@ function findPointerDropTarget(event) {
let row = null;
for (const candidate of elements) {
try {
+ const treeTarget = candidate.closest?.('.tree-label[data-tree-open]');
+ if (treeTarget && treeTarget.closest?.('.window') === windowEl && isValidPointerTargetPath(treeTarget.dataset.treeOpen, drag)) {
+ row = treeTarget;
+ break;
+ }
const candidateRow = candidate.closest?.('.file-row');
if (!candidateRow || candidateRow.dataset.type !== 'directory') {
continue;
@@ -2273,7 +2435,7 @@ function findPointerDropTarget(event) {
}
}
if (row) {
- return { windowId: win.id, path: row.dataset.path, row };
+ return { windowId: win.id, path: row.dataset.path || row.dataset.treeOpen, row };
}
return { windowId: win.id, path: win.data.path, row: null };
@@ -2295,6 +2457,11 @@ function markDropCandidates(sourcePaths, active) {
const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
row.classList.toggle('drop-candidate', active && valid);
}
+ for (const row of workspace.querySelectorAll('.tree-label[data-tree-open]')) {
+ const path = row.dataset.treeOpen || '';
+ const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
+ row.classList.toggle('drop-candidate', active && valid);
+ }
}
function isValidPointerTargetPath(path, drag = state.pointerDrag) {
@@ -2313,7 +2480,8 @@ function lockExplorerScroll(win, body) {
function restoreExplorerScroll(win, body) {
if (!body) {
- body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
+ body = workspace.querySelector(`.window[data-window-id="${win.id}"] .explorer-content`)
+ || workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
}
if (!body) return;
diff --git a/server/public/index.html b/server/public/index.html
index 2ee1b18..add8bfc 100644
--- a/server/public/index.html
+++ b/server/public/index.html
@@ -14,9 +14,10 @@
Initialisiere...