const workspace = document.querySelector('#workspace');
const appShell = document.querySelector('.u-nav');
const statusText = document.querySelector('#statusText');
const transferButton = document.querySelector('#newQueueButton');
const settingsButton = document.querySelector('#settingsButton');
const API_BASE = window.UNAV_API_BASE || '/api';
const APP_VERSION = window.UNAV_VERSION || 'dev';
const state = {
config: null,
windows: [],
focusedId: null,
selectedEntry: null,
contextMenu: null,
permissionsDialog: null,
uploadConflictDialog: null,
uiDialog: null,
draggedEntry: null,
pointerDrag: null,
suppressClickPath: null,
suppressItemActivationUntil: 0,
debug: [],
nextDebugId: 1,
jobs: new Map(),
uploadTransfers: new Map(),
nextUploadTransferId: 1,
jobRefresh: new Map(),
sizeJobs: new Map(),
sizeResults: new Map(),
transferPanelVisible: false,
transferPanelClosing: false,
transferAutoHideTimer: null,
settingsPanelVisible: false,
typeAhead: {
key: '',
windowId: null,
at: 0
},
nextWindowId: 1,
zIndex: 20
};
const icons = {
directory: 'Folder',
file: 'File',
symlink: 'Link',
other: 'Item'
};
const uiIcons = {
arrowLeft: '',
arrowRight: '',
chevronDown: '',
chevronRight: '',
close: '',
delete: '',
enter: '',
file: '',
folder: '',
folderUpload: '',
grid: '',
list: '',
link: '',
maximize: '',
minimize: '',
refresh: '',
tree: '',
upload: ''
};
const previewExtensions = new Set(['txt', 'log', 'md', 'rd', 'rst', 'yaml', 'yml', 'json', 'xml', 'csv', 'tsv', 'ini', 'cfg', 'conf', 'env', 'toml', 'sql', 'nfo', 'srt', 'sub', 'vtt', 'm3u', 'm3u8', 'pls', 'sh', 'bash', 'zsh', 'php', 'js', 'ts', 'tsx', 'jsx', 'css', 'html', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'bat', 'ps1', 'properties', 'service', 'timer', 'mount', 'rules', 'cron', 'plg', 'page', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp3', 'flac', 'm4a', 'ogg', 'wav', 'mp4', 'mkv', 'webm', 'mov']);
const previewFilenames = new Set(['dockerfile', 'makefile']);
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
transferButton.addEventListener('click', () => toggleTransferPanel());
settingsButton.addEventListener('click', () => toggleSettingsPanel());
window.addEventListener('error', (event) => {
recordDebug('window.error', {
message: event.message,
source: event.filename,
line: event.lineno,
column: event.colno,
error: serializeError(event.error)
});
});
window.addEventListener('unhandledrejection', (event) => {
recordDebug('window.unhandledrejection', { reason: serializeError(event.reason) });
});
document.addEventListener('keydown', handleTypeAheadKey);
init();
async function init() {
try {
state.config = await api(apiUrl('config.php'));
if (!state.config.roots?.length) {
throw new Error('No U-Navigator roots configured');
}
updateStatusText();
openExplorer(state.config.roots[0].path);
} catch (error) {
statusText.textContent = error.message;
}
}
function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
if (!initialPath) {
statusText.textContent = 'Kein Root-Pfad konfiguriert';
return null;
}
const win = createWindow('Explorer', 'explorer', {
width: 900,
height: 540,
x: 24 + state.windows.length * 24,
y: 20 + state.windows.length * 20,
data: {
path: initialPath,
entries: [],
selectedPath: null,
selectedPaths: [],
selectionAnchorPath: null,
view: 'icons',
treeVisible: true,
treeWidth: 220,
treeExpanded: [],
treeChildren: {},
treeLoading: [],
loading: false,
error: null
}
});
loadExplorer(win, initialPath);
return win;
}
function openProperties(entry = state.selectedEntry) {
if (entry) {
state.selectedEntry = entry;
}
const existing = state.windows.find((item) => item.kind === 'properties');
if (existing) {
focusWindow(existing);
render();
return existing;
}
return createWindow('Eigenschaften', 'properties', {
width: 420,
height: 320,
x: 160,
y: 90,
data: {}
});
}
function openPreview(entry, sourceWindowId = state.focusedId) {
if (!entry || !isPreviewable(entry)) {
showError(new Error('Für diesen Dateityp ist keine Vorschau verfügbar.'));
return null;
}
const existing = state.windows.find((item) => item.kind === 'preview');
if (existing) {
existing.data.entry = entry;
existing.data.sourceWindowId = sourceWindowId;
existing.data.content = '';
existing.data.loading = true;
existing.data.error = null;
focusWindow(existing);
render();
loadPreview(existing, entry);
return existing;
}
const win = createWindow('Vorschau', 'preview', {
width: 620,
height: 460,
x: 190,
y: 110,
data: {
entry,
sourceWindowId,
content: '',
loading: true,
error: null
}
});
loadPreview(win, entry);
return win;
}
function openQueue() {
showTransferPanel();
return null;
}
function createWindow(title, kind, options) {
const id = `win-${state.nextWindowId++}`;
const win = {
id,
kind,
title,
x: options.x,
y: options.y,
width: options.width,
height: options.height,
maximized: false,
zIndex: ++state.zIndex,
data: options.data || {}
};
state.windows.push(win);
state.focusedId = id;
render();
return win;
}
function render() {
workspace.innerHTML = '';
for (const win of state.windows) {
workspace.appendChild(renderWindow(win));
}
if (state.contextMenu) {
workspace.appendChild(renderContextMenu());
}
if (state.permissionsDialog) {
workspace.appendChild(renderPermissionsDialog());
}
if (state.uploadConflictDialog) {
workspace.appendChild(renderUploadConflictDialog());
}
if (state.uiDialog) {
workspace.appendChild(renderUiDialog());
}
renderTransferPanel();
renderSettingsPanel();
}
function renderWindow(win) {
const el = document.createElement('section');
el.className = `window ${state.focusedId === win.id ? 'focused' : ''} ${win.maximized ? 'maximized' : ''}`;
el.style.left = `${win.maximized ? 0 : win.x}px`;
el.style.top = `${win.maximized ? 0 : win.y}px`;
el.style.width = `${win.maximized ? workspace.clientWidth : win.width}px`;
el.style.height = `${win.maximized ? workspace.clientHeight : win.height}px`;
el.style.zIndex = win.zIndex;
el.dataset.windowId = win.id;
el.addEventListener('pointerdown', () => focusWindow(win));
const titlebar = document.createElement('div');
titlebar.className = 'titlebar';
titlebar.innerHTML = `
${escapeHtml(windowTitle(win))}
`;
titlebar.addEventListener('pointerdown', (event) => startDragWindow(event, win));
titlebar.querySelector('[data-action="maximize"]').addEventListener('click', (event) => {
event.stopPropagation();
win.maximized = !win.maximized;
focusWindow(win);
render();
});
titlebar.querySelector('[data-action="close"]').addEventListener('click', (event) => {
event.stopPropagation();
state.windows = state.windows.filter((item) => item.id !== win.id);
state.focusedId = state.windows.at(-1)?.id || null;
render();
});
const body = document.createElement('div');
body.className = 'window-body';
if (win.kind === 'explorer') renderExplorer(body, win);
if (win.kind === 'properties') renderProperties(body);
if (win.kind === 'preview') renderPreview(body, win);
const scrollHost = body.querySelector('.explorer-content') || body;
scrollHost.addEventListener('scroll', () => {
if (win.data.lockScroll) {
scrollHost.scrollTop = win.data.lockScrollTop || 0;
scrollHost.scrollLeft = win.data.lockScrollLeft || 0;
return;
}
win.data.scrollTop = scrollHost.scrollTop;
win.data.scrollLeft = scrollHost.scrollLeft;
});
el.append(titlebar, body);
requestAnimationFrame(() => {
scrollHost.scrollTop = win.data.scrollTop || 0;
scrollHost.scrollLeft = win.data.scrollLeft || 0;
});
if (!win.maximized) {
const resize = document.createElement('div');
resize.className = 'resize-handle';
resize.addEventListener('pointerdown', (event) => startResizeWindow(event, win));
el.appendChild(resize);
}
return el;
}
function renderExplorer(body, win) {
const data = win.data;
body.classList.add('explorer-body');
body.innerHTML = `
${data.treeVisible ? renderFolderTree(win) : ''}
${data.treeVisible ? '
' : ''}
${data.error ? `
${escapeHtml(data.error)}
` : ''}
${data.loading ? '
Lade...
' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
`;
const input = body.querySelector('input');
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);
body.querySelector('[data-action="resize-tree"]')?.addEventListener('pointerdown', (event) => startTreeResize(event, win, body));
for (const inputEl of body.querySelectorAll('.upload-input')) {
inputEl.addEventListener('change', async () => {
try {
await uploadFileList(win, [...inputEl.files], data.path);
} catch (error) {
showError(error);
} finally {
inputEl.value = '';
}
});
}
const dropZone = body.querySelector('.drop-zone');
const fileScrollHost = body.querySelector('.explorer-content') || body;
dropZone.addEventListener('dragenter', (event) => {
if (hasDropPayload(event)) {
event.preventDefault();
data.dragOver = true;
dropZone.classList.add('drag-over');
}
});
dropZone.addEventListener('dragover', (event) => {
if (hasDropPayload(event)) {
event.preventDefault();
setDropEffect(event, 'copy');
data.dragOver = true;
dropZone.classList.add('drag-over');
}
});
dropZone.addEventListener('dragleave', () => {
data.dragOver = false;
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', async (event) => {
event.preventDefault();
data.dragOver = false;
dropZone.classList.remove('drag-over');
try {
await handleDrop(event, win, data.path);
} catch (error) {
showError(error);
}
});
dropZone.addEventListener('contextmenu', (event) => {
event.preventDefault();
showContextMenu(event, win, null);
});
for (const row of body.querySelectorAll('.file-row')) {
const entry = data.entries.find((item) => item.path === row.dataset.path);
row.draggable = false;
row.addEventListener('dragstart', (event) => event.preventDefault());
row.addEventListener('selectstart', (event) => event.preventDefault());
row.addEventListener('pointerdown', (event) => {
lockExplorerScroll(win, fileScrollHost);
startItemPointerDrag(event, win, entry);
});
row.addEventListener('click', (event) => {
if (shouldSuppressItemActivation()) return;
selectEntry(win, entry, fileScrollHost, event);
});
row.addEventListener('dblclick', () => {
if (shouldSuppressItemActivation()) return;
if (entry.type === 'directory') {
loadExplorer(win, entry.path);
} else if (isPreviewable(entry)) {
openPreview(entry, win.id);
}
});
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
if (selectedPathSet(win).has(entry.path)) {
state.selectedEntry = entry;
focusWindow(win);
updatePropertiesWindows();
} else {
selectEntry(win, entry, null, event);
}
showContextMenu(event, win, entry);
});
}
}
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 startTreeResize(event, win, body) {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const handle = event.currentTarget;
const layout = body.querySelector('.explorer-layout');
const startX = event.clientX;
const startWidth = Number(win.data.treeWidth) || body.querySelector('.folder-tree')?.getBoundingClientRect().width || 220;
const pointerId = event.pointerId;
handle.classList.add('active');
try {
handle.setPointerCapture(pointerId);
} catch {}
const move = (moveEvent) => {
const maxWidth = Math.max(180, Math.min(480, body.clientWidth - 240));
win.data.treeWidth = Math.round(clamp(startWidth + moveEvent.clientX - startX, 150, maxWidth));
layout?.style.setProperty('--tree-width', `${win.data.treeWidth}px`);
};
const up = () => {
handle.classList.remove('active');
handle.removeEventListener('pointermove', move);
handle.removeEventListener('pointerup', up);
handle.removeEventListener('pointercancel', up);
try {
handle.releasePointerCapture(pointerId);
} catch {}
};
handle.addEventListener('pointermove', move);
handle.addEventListener('pointerup', up);
handle.addEventListener('pointercancel', up);
}
function renderFileEntries(entries, view, selectedPaths = new Set()) {
if (!entries.length) {
return 'Dieser Ordner ist leer. Dateien können hier abgelegt werden.
';
}
return view === 'icons' ? renderFileIcons(entries, selectedPaths) : renderFileTable(entries, selectedPaths);
}
function renderFileTable(entries, selectedPaths) {
const rows = entries.map((entry) => `
| ${fileTypeIcon(entry)}${escapeHtml(entry.name)} |
${escapeHtml(typeLabel(entry.type))} |
${entry.type === 'file' ? formatBytes(entry.size) : ''} |
${new Date(entry.modifiedAt).toLocaleString()} |
`).join('');
return `
| Name | Typ | Größe | Geändert |
${rows}
`;
}
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();
}
function renderProperties(body) {
const entry = state.selectedEntry;
if (!entry) {
body.innerHTML = 'Keine Datei ausgewählt.
';
return;
}
const sizeLabel = entry.type === 'directory' ? directorySizeLabel(entry) : formatBytes(entry.size);
body.classList.add('properties');
body.innerHTML = `
- Name
- ${escapeHtml(entry.name)}
- Typ
- ${escapeHtml(entry.type)}
- Pfad
- ${escapeHtml(entry.path)}
- Größe
- ${escapeHtml(sizeLabel)}
- Geändert
- ${new Date(entry.modifiedAt).toLocaleString()}
- Besitzer
- ${escapeHtml(formatPrincipal(entry.owner, entry.ownerId))}
- Gruppe
- ${escapeHtml(formatPrincipal(entry.group, entry.groupId))}
- Rechte
- ${escapeHtml(entry.permissions ? `0${entry.permissions}`.slice(-4) : '-')}
`;
if (entry.type === 'directory') {
ensureDirectorySize(entry);
}
}
function renderPreview(body, win) {
const data = win.data;
body.classList.add('preview-body');
if (data.loading) {
body.innerHTML = 'Lade Vorschau...
';
return;
}
if (data.error) {
body.innerHTML = `${escapeHtml(data.error)}
`;
return;
}
const entry = data.entry;
const navigation = previewNavigation(data);
body.innerHTML = `
${renderPreviewContent(data)}
`;
body.querySelector('[data-action="preview-prev"]')?.addEventListener('click', () => openPreview(navigation.previous, data.sourceWindowId));
body.querySelector('[data-action="preview-next"]')?.addEventListener('click', () => openPreview(navigation.next, data.sourceWindowId));
}
function renderPreviewContent(data) {
if (data.type === 'markdown') {
return `${renderMarkdown(data.content || '')}`;
}
if (data.type === 'csv') {
return renderCsvPreview(data.content || '', data.extension === 'tsv' ? '\t' : ',');
}
if (data.type === 'image') {
return ``;
}
if (data.type === 'pdf') {
return ``;
}
if (data.type === 'audio') {
return ``;
}
if (data.type === 'video') {
return ``;
}
return `${escapeHtml(data.content || '')}`;
}
function previewNavigation(data) {
const source = state.windows.find((win) => win.id === data.sourceWindowId && win.kind === 'explorer');
const entries = (source?.data.entries || []).filter(isPreviewable);
if (entries.length < 2) {
return { previous: null, next: null };
}
const index = entries.findIndex((entry) => entry.path === data.entry?.path);
if (index < 0) {
return { previous: null, next: null };
}
return {
previous: entries[(index - 1 + entries.length) % entries.length],
next: entries[(index + 1) % entries.length]
};
}
async function loadPreview(win, entry) {
win.data.loading = true;
win.data.error = null;
render();
try {
const result = await api(apiUrl(`preview.php?path=${encodeURIComponent(entry.path)}`));
win.data.entry = entry;
win.data.sourceWindowId = win.data.sourceWindowId || state.focusedId;
win.data.name = result.name;
win.data.size = result.size;
win.data.type = result.type;
win.data.extension = result.extension;
win.data.rawUrl = result.rawUrl;
win.data.content = result.content;
} catch (error) {
win.data.error = error.message;
} finally {
win.data.loading = false;
render();
}
}
function renderMarkdown(content) {
const blocks = [];
let listItems = [];
const flushList = () => {
if (listItems.length) {
blocks.push(`${listItems.map((item) => `- ${renderInlineMarkdown(item)}
`).join('')}
`);
listItems = [];
}
};
for (const line of String(content).split(/\r?\n/)) {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
flushList();
const level = heading[1].length;
blocks.push(`${renderInlineMarkdown(heading[2])}`);
continue;
}
const list = /^\s*[-*]\s+(.*)$/.exec(line);
if (list) {
listItems.push(list[1]);
continue;
}
flushList();
if (!line.trim()) continue;
blocks.push(`${renderInlineMarkdown(line)}
`);
}
flushList();
return blocks.join('');
}
function renderInlineMarkdown(value) {
return escapeHtml(value)
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1');
}
function renderCsvPreview(content, delimiter) {
const rows = parseDelimitedRows(content, delimiter).slice(0, 80);
if (!rows.length) {
return 'Keine Tabellenzeilen.
';
}
return `
${rows.map((row) => `${row.map((cell) => `| ${escapeHtml(cell)} | `).join('')}
`).join('')}
`;
}
function parseDelimitedRows(content, delimiter) {
const rows = [];
let row = [];
let cell = '';
let quoted = false;
const text = String(content || '');
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char === '"') {
if (quoted && text[index + 1] === '"') {
cell += '"';
index += 1;
} else {
quoted = !quoted;
}
continue;
}
if (!quoted && char === delimiter) {
row.push(cell);
cell = '';
continue;
}
if (!quoted && (char === '\n' || char === '\r')) {
if (char === '\r' && text[index + 1] === '\n') index += 1;
row.push(cell);
rows.push(row);
row = [];
cell = '';
continue;
}
cell += char;
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
return rows;
}
function renderQueue(body) {
body.innerHTML = queueMarkup();
bindQueueActions(body);
}
function queueMarkup() {
const jobs = [...state.jobs.values()].reverse();
const uploads = [...state.uploadTransfers.values()].reverse();
const transferItems = [
...uploads.map(uploadTransferMarkup),
...jobs.map((job) => `
${escapeHtml(job.type)} #${escapeHtml(job.id)}
${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}
`)
];
const jobMarkup = transferItems.length ? transferItems.join('') : 'Keine Transfers.
';
const debugSection = state.config?.debug ? debugMarkup() : '';
return `
${jobMarkup}
${debugSection}
`;
}
function uploadTransferMarkup(upload) {
const progress = Math.max(0, Math.min(100, Number(upload.progress) || 0));
const fileText = `${upload.completedFiles + upload.skippedFiles}/${upload.totalFiles} Dateien`;
const byteText = `${formatBytes(upload.uploadedBytes)} / ${formatBytes(upload.totalBytes)}`;
const detail = upload.currentFile ? ` · ${upload.currentFile}` : '';
const skipped = upload.skippedFiles ? ` · ${upload.skippedFiles} übersprungen` : '';
const overwritten = upload.overwrittenFiles ? ` · ${upload.overwrittenFiles} überschrieben` : '';
return `
Upload #${escapeHtml(upload.id)}
${escapeHtml(upload.status)} · ${escapeHtml(fileText)} · ${escapeHtml(byteText)}${escapeHtml(detail)}${escapeHtml(skipped)}${escapeHtml(overwritten)}
`;
}
function debugMarkup() {
const debugMarkup = state.debug.length ? state.debug.slice(-12).reverse().map((item) => `
#${item.id} ${escapeHtml(item.label)}
${escapeHtml(JSON.stringify(item.data, null, 2))}
`).join('') : 'Noch keine Debug-Einträge.
';
return `
`;
}
function bindQueueActions(root) {
root.querySelector('[data-action="clear-debug"]')?.addEventListener('click', () => {
state.debug = [];
updateTransferPanel();
});
}
function toggleTransferPanel() {
if (state.transferPanelVisible) {
hideTransferPanel();
} else {
showTransferPanel();
}
}
function showTransferPanel() {
if (state.transferAutoHideTimer) {
clearTimeout(state.transferAutoHideTimer);
state.transferAutoHideTimer = null;
}
state.transferPanelVisible = true;
state.transferPanelClosing = false;
renderTransferPanel();
}
function hideTransferPanel() {
if (!state.transferPanelVisible && !state.transferPanelClosing) {
return;
}
if (state.transferAutoHideTimer) {
clearTimeout(state.transferAutoHideTimer);
state.transferAutoHideTimer = null;
}
state.transferPanelVisible = false;
state.transferPanelClosing = true;
renderTransferPanel();
setTimeout(() => {
if (!state.transferPanelVisible) {
state.transferPanelClosing = false;
renderTransferPanel();
}
}, 220);
}
function scheduleTransferAutoHide() {
if (!state.transferPanelVisible || activeTransferCount() > 0 || state.transferAutoHideTimer) {
return;
}
state.transferAutoHideTimer = setTimeout(() => {
state.transferAutoHideTimer = null;
if (activeTransferCount() === 0) {
hideTransferPanel();
}
}, 2200);
}
function activeTransferCount() {
const activeJobs = [...state.jobs.values()].filter((job) => job.type !== 'size' && ['queued', 'running', 'cancel_requested'].includes(job.status)).length;
const activeUploads = [...state.uploadTransfers.values()].filter((upload) => ['queued', 'running'].includes(upload.status)).length;
return activeJobs + activeUploads;
}
function renderTransferPanel() {
const existing = appShell.querySelector('.transfer-panel');
if (!state.transferPanelVisible && !state.transferPanelClosing) {
existing?.remove();
transferButton.classList.remove('active');
transferButton.setAttribute('aria-expanded', 'false');
return;
}
const panel = existing || document.createElement('aside');
panel.className = `transfer-panel ${state.transferPanelClosing ? 'closing' : 'open'}`;
panel.setAttribute('aria-label', 'Transfers');
panel.innerHTML = `
${queueMarkup()}
`;
if (!existing) {
appShell.appendChild(panel);
}
const buttonRect = transferButton.getBoundingClientRect();
const shellRect = appShell.getBoundingClientRect();
const panelWidth = Math.min(520, Math.max(360, shellRect.width - 32));
const right = Math.max(16, shellRect.right - buttonRect.right);
panel.style.width = `${panelWidth}px`;
panel.style.right = `${right}px`;
panel.style.top = `${buttonRect.bottom - shellRect.top + 8}px`;
panel.querySelector('[data-action="close-transfers"]').addEventListener('click', () => hideTransferPanel());
bindQueueActions(panel);
transferButton.classList.add('active');
transferButton.setAttribute('aria-expanded', 'true');
}
function toggleSettingsPanel() {
state.settingsPanelVisible = !state.settingsPanelVisible;
renderSettingsPanel();
}
function renderSettingsPanel() {
const existing = appShell.querySelector('.settings-panel');
if (!state.settingsPanelVisible) {
existing?.remove();
settingsButton.classList.remove('active');
settingsButton.setAttribute('aria-expanded', 'false');
return;
}
const panel = existing || document.createElement('aside');
panel.className = 'settings-panel';
panel.setAttribute('aria-label', 'Einstellungen');
panel.innerHTML = `
`;
if (!existing) {
appShell.appendChild(panel);
}
const buttonRect = settingsButton.getBoundingClientRect();
const shellRect = appShell.getBoundingClientRect();
panel.style.right = `${Math.max(16, shellRect.right - buttonRect.right)}px`;
panel.style.top = `${buttonRect.bottom - shellRect.top + 8}px`;
panel.querySelector('[data-action="close-settings"]').addEventListener('click', () => {
state.settingsPanelVisible = false;
renderSettingsPanel();
});
panel.querySelector('[data-setting="debug"]').addEventListener('change', (event) => {
saveSettings({ debug: event.currentTarget.checked });
});
panel.querySelector('[data-setting="write"]').addEventListener('change', (event) => {
saveSettings({ readOnly: !event.currentTarget.checked });
});
settingsButton.classList.add('active');
settingsButton.setAttribute('aria-expanded', 'true');
}
async function saveSettings(changes) {
const body = new URLSearchParams();
const readOnly = changes.readOnly ?? state.config.readOnly;
const debug = changes.debug ?? state.config.debug;
body.set('readOnly', readOnly ? '1' : '0');
body.set('debug', debug ? '1' : '0');
appendCsrf(body);
try {
state.config = await api(apiUrl('config.php'), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
updateStatusText();
if (!state.config.debug) {
state.debug = [];
}
render();
} catch (error) {
showError(error);
}
}
function updateStatusText() {
if (!state.config) return;
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'} · Debug ${state.config.debug ? 'aktiv' : 'aus'} · ${APP_VERSION}`;
}
function isReadOnly() {
return Boolean(state.config?.readOnly);
}
async function loadExplorer(win, targetPath) {
win.data.loading = true;
win.data.error = null;
win.data.path = targetPath;
render();
try {
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;
} finally {
win.data.loading = false;
render();
}
}
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', {
targetPath,
types: dataTransferTypes(event),
files: uploadItems.map((item) => ({ name: item.path, size: item.file.size }))
});
if (state.config.readOnly) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
if (uploadItems.length) {
await uploadFileList(win, uploadItems, targetPath);
}
}
async function uploadFileList(win, files, targetPath) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
if (!files.length) return;
const uploadItems = normalizeUploadItems(files);
const transfer = createUploadTransfer(targetPath, uploadItems);
let overwriteAll = false;
for (const [index, item] of uploadItems.entries()) {
updateUploadTransfer(transfer, {
status: 'running',
currentFile: item.path,
currentIndex: index + 1
});
recordDebug('upload.file.request', { path: item.path, size: item.file.size, index: index + 1, total: uploadItems.length });
try {
await uploadFileChunked(targetPath, item.file, item.path, false, (bytes) => {
addUploadProgress(transfer, bytes);
});
updateUploadTransfer(transfer, {
completedFiles: transfer.completedFiles + 1,
currentFile: item.path,
progress: uploadProgressPercent(transfer)
});
recordDebug('upload.file.done', { path: item.path, index: index + 1, total: uploadItems.length, overwrite: false });
} catch (error) {
if (error.status !== 409) {
if (isLocalFileReadError(error)) {
updateUploadTransfer(transfer, {
skippedFiles: transfer.skippedFiles + 1,
skippedBytes: transfer.skippedBytes + Number(item.file.size || 0),
currentFile: item.path,
progress: uploadProgressPercent(transfer)
}, true);
recordDebug('upload.file.unreadable', {
path: item.path,
index: index + 1,
total: uploadItems.length,
error: serializeError(error)
});
continue;
}
updateUploadTransfer(transfer, {
status: 'failed',
message: error.message || 'Upload fehlgeschlagen',
currentFile: item.path,
progress: uploadProgressPercent(transfer)
}, true);
throw error;
}
const conflictAction = overwriteAll ? 'overwrite' : await askUploadConflict({
path: item.path,
targetPath,
index: index + 1,
total: uploadItems.length
});
if (conflictAction === 'overwrite-all') {
overwriteAll = true;
}
const overwrite = overwriteAll || conflictAction === 'overwrite';
recordDebug('upload.overwrite.prompt', { path: item.path, action: conflictAction, overwrite });
if (!overwrite) {
updateUploadTransfer(transfer, {
skippedFiles: transfer.skippedFiles + 1,
skippedBytes: transfer.skippedBytes + Number(item.file.size || 0),
currentFile: item.path,
progress: uploadProgressPercent(transfer)
});
recordDebug('upload.file.skipped', { path: item.path, index: index + 1, total: uploadItems.length });
continue;
}
await uploadFileChunked(targetPath, item.file, item.path, true, (bytes) => {
addUploadProgress(transfer, bytes);
});
updateUploadTransfer(transfer, {
completedFiles: transfer.completedFiles + 1,
overwrittenFiles: transfer.overwrittenFiles + 1,
currentFile: item.path,
progress: uploadProgressPercent(transfer)
});
recordDebug('upload.file.done', { path: item.path, index: index + 1, total: uploadItems.length, overwrite: true });
}
}
updateUploadTransfer(transfer, {
status: 'completed',
message: 'Abgeschlossen',
currentFile: '',
progress: 100
}, true);
scheduleTransferAutoHide();
await loadExplorer(win, targetPath);
}
async function uploadFileChunked(targetPath, file, relativePath, overwrite = false, onChunkUploaded = null) {
const chunkSize = 256 * 1024;
const totalChunks = Math.max(1, Math.ceil(file.size / chunkSize));
let finalPayload = null;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
const start = chunkIndex * chunkSize;
const chunk = file.slice(start, Math.min(file.size, start + chunkSize));
const data = arrayBufferToBase64(await chunk.arrayBuffer());
const body = new URLSearchParams();
body.set('uploadMode', 'chunk');
body.set('relativePath', relativePath);
body.set('chunkIndex', String(chunkIndex));
body.set('totalChunks', String(totalChunks));
body.set('data', data);
if (overwrite) {
body.set('overwrite', '1');
}
appendCsrf(body);
const response = await fetch(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
const text = await response.text();
recordDebug('upload.chunk.response', {
path: relativePath,
chunk: chunkIndex + 1,
totalChunks,
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type'),
body: text.slice(0, 1000)
});
if (!response.ok) {
const error = new Error(text || `${response.status} ${response.statusText}`);
error.status = response.status;
error.path = relativePath;
throw error;
}
onChunkUploaded?.(chunk.size);
try {
finalPayload = JSON.parse(text);
} catch (error) {
throw new Error(`Upload lieferte keine JSON-Antwort: ${text.slice(0, 200) || response.headers.get('content-type') || 'leer'}`);
}
}
if (!finalPayload?.uploaded?.length) {
throw new Error('Upload wurde vom Server nicht bestätigt.');
}
}
function createUploadTransfer(targetPath, uploadItems) {
const transfer = {
id: `upload-${state.nextUploadTransferId++}`,
status: 'queued',
message: 'Wartet',
targetPath,
totalFiles: uploadItems.length,
completedFiles: 0,
skippedFiles: 0,
overwrittenFiles: 0,
totalBytes: uploadItems.reduce((sum, item) => sum + Number(item.file?.size || 0), 0),
uploadedBytes: 0,
skippedBytes: 0,
currentFile: '',
currentIndex: 0,
progress: 0,
updatedAt: Date.now(),
lastRenderAt: 0
};
state.uploadTransfers.set(transfer.id, transfer);
showTransferPanel();
updateTransferPanel();
return transfer;
}
function updateUploadTransfer(transfer, patch, force = false) {
Object.assign(transfer, patch, {
message: patch.message ?? `${transfer.currentIndex || 0}/${transfer.totalFiles}`,
updatedAt: Date.now()
});
const now = Date.now();
if (force || now - transfer.lastRenderAt > 180) {
transfer.lastRenderAt = now;
updateTransferPanel();
}
}
function addUploadProgress(transfer, bytes) {
transfer.uploadedBytes += Number(bytes || 0);
updateUploadTransfer(transfer, {
progress: uploadProgressPercent(transfer)
});
}
function uploadProgressPercent(transfer) {
if (transfer.totalBytes > 0) {
return ((transfer.uploadedBytes + transfer.skippedBytes) / transfer.totalBytes) * 100;
}
if (transfer.totalFiles > 0) {
return ((transfer.completedFiles + transfer.skippedFiles) / transfer.totalFiles) * 100;
}
return 0;
}
function isLocalFileReadError(error) {
return error?.name === 'NotReadableError'
|| error?.name === 'NotFoundError'
|| error?.name === 'SecurityError';
}
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return btoa(binary);
}
function normalizeUploadItems(files) {
return files.map((item) => {
if (item?.file && item?.path) {
return item;
}
const file = item;
return {
file,
path: file.webkitRelativePath || file.name
};
}).filter((item) => item.file && item.path);
}
async function createJob(type, source, destination = null, refreshWindowIds = [], extra = {}) {
if (['move', 'copy', 'permissions'].includes(type) && isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return null;
}
recordDebug('job.create.request', { type, source, destination, refreshWindowIds, extra });
const body = new URLSearchParams();
body.set('source', source);
if (destination !== null) {
body.set('destination', destination);
}
for (const [key, value] of Object.entries(extra)) {
if (value !== null && value !== undefined) {
body.set(key, String(value));
}
}
appendCsrf(body);
const job = await api(apiUrl(`job.php?action=${encodeURIComponent(type)}`), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
recordDebug('job.create.response', job);
state.jobs.set(job.id, job);
state.jobRefresh.set(job.id, {
windowIds: [...new Set(refreshWindowIds)].filter(Boolean),
done: false
});
showTransferPanel();
pollJob(job.id);
updateTransferPanel();
return job;
}
async function createSizeJob(entry) {
recordDebug('size.create.request', { source: entry.path });
const body = new URLSearchParams();
body.set('source', entry.path);
appendCsrf(body);
const job = await api(apiUrl('job.php?action=size'), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body
});
recordDebug('size.create.response', job);
state.jobs.set(job.id, job);
state.sizeJobs.set(entry.path, job.id);
pollJob(job.id);
render();
return job;
}
async function reloadExplorerWindows(ids) {
const uniqueIds = new Set(ids);
const targets = state.windows.filter((win) => win.kind === 'explorer' && (uniqueIds.has(win.id) || !uniqueIds.size));
await Promise.all(targets.map((win) => loadExplorer(win, win.data.path)));
}
function hasDropPayload(event) {
const types = dataTransferTypes(event);
return types.includes('Files') || getDroppedFiles(event).length > 0;
}
async function getDroppedUploadItems(event) {
const entries = getDroppedEntries(event);
if (entries.length) {
const items = [];
for (const entry of entries) {
items.push(...await readDroppedEntry(entry, ''));
}
if (items.length) {
return items;
}
}
return getDroppedFiles(event).filter((file) => file.size > 0 || file.type || file.name.includes('.')).map((file) => ({
file,
path: file.webkitRelativePath || file.name
}));
}
function getDroppedEntries(event) {
try {
return [...(event.dataTransfer?.items || [])]
.map((item) => item.webkitGetAsEntry?.())
.filter(Boolean);
} catch (error) {
recordDebug('dataTransfer.entries.error', serializeError(error));
return [];
}
}
async function readDroppedEntry(entry, parentPath) {
const path = parentPath ? `${parentPath}/${entry.name}` : entry.name;
if (entry.isFile) {
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
return [{ file, path }];
}
if (!entry.isDirectory) {
return [];
}
const reader = entry.createReader();
const children = await readAllDirectoryEntries(reader);
const results = [];
for (const child of children) {
results.push(...await readDroppedEntry(child, path));
}
return results;
}
async function readAllDirectoryEntries(reader) {
const entries = [];
while (true) {
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
if (!batch.length) {
return entries;
}
entries.push(...batch);
}
}
function showError(error) {
const debug = recordDebug('error.shown', serializeError(error));
const message = error?.message || String(error);
const detail = debug ? `Debug #${debug.id}: ${debug.label}` : '';
showMessageDialog('Fehler', message, detail).catch(() => {});
}
function getDroppedFiles(event) {
try {
return [...(event.dataTransfer?.files || [])];
} catch {
return [];
}
}
function dataTransferTypes(event) {
try {
return [...(event.dataTransfer?.types || [])];
} catch (error) {
recordDebug('dataTransfer.types.error', serializeError(error));
return [];
}
}
function setEffectAllowed(event) {
try {
event.dataTransfer.effectAllowed = 'copyMove';
} catch (error) {
recordDebug('dataTransfer.effectAllowed.error', serializeError(error));
}
}
function setDropEffect(event, effect) {
try {
event.dataTransfer.dropEffect = effect;
} catch {
recordDebug('dataTransfer.dropEffect.error', { effect });
}
}
async function renameEntry(win, entry) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const nextName = await showPromptDialog('Umbenennen', 'Neuer Name', entry.name);
if (!nextName || nextName === entry.name) {
return;
}
if (nextName.includes('/')) {
showError(new Error('Der Name darf keinen Slash enthalten.'));
return;
}
await createJob('move', entry.path, `${dirname(entry.path)}/${nextName}`, [win.id]);
}
async function promptTransfer(win, entry, type) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const targetDir = await showPromptDialog(
type === 'copy' ? 'Kopieren' : 'Verschieben',
type === 'copy' ? 'Kopieren nach Ordner' : 'Verschieben nach Ordner',
win.data.path
);
if (!targetDir) {
return;
}
await createJob(type, entry.path, `${targetDir.replace(/\/$/, '')}/${entry.name}`, []);
}
async function deleteEntries(win, entry = null) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const entries = entry && selectedPathSet(win).has(entry.path) ? selectedEntries(win) : (entry ? [entry] : selectedEntries(win));
if (!entries.length) {
showError(new Error('Keine Datei ausgewählt.'));
return;
}
const label = entries.length === 1 ? `"${entries[0].name}" wirklich löschen?` : `${entries.length} Objekte wirklich löschen?`;
const confirmed = await showConfirmDialog('Löschen bestätigen', label, 'Dieser Vorgang löscht direkt vom Dateisystem.', {
confirmLabel: 'Löschen',
danger: true
});
if (!confirmed) {
return;
}
for (const item of entries) {
await createJob('delete', item.path, null, [win.id]);
}
}
function openPermissionsDialog(win, entry) {
if (isReadOnly()) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const entries = selectedPathSet(win).has(entry.path) ? selectedEntries(win) : [entry];
state.permissionsDialog = {
winId: win.id,
entries,
mode: normalizeMode(entry.permissions || '0755'),
owner: entry.owner || '',
group: entry.group || '',
recursive: false,
hasDirectory: entries.some((item) => item.type === 'directory')
};
render();
}
function downloadEntries(win, entry = null) {
const entries = entry && selectedPathSet(win).has(entry.path) ? selectedEntries(win) : (entry ? [entry] : [{
path: win.data.path,
name: basename(win.data.path),
type: 'directory'
}]);
const paths = entries.map((item) => item.path);
const query = paths.length === 1
? `path=${encodeURIComponent(paths[0])}`
: `paths=${encodeURIComponent(JSON.stringify(paths))}`;
recordDebug('download.start', { paths });
const link = document.createElement('a');
link.href = apiUrl(`download.php?${query}`);
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
}
function renderPermissionsDialog() {
const dialog = state.permissionsDialog;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
`;
const form = overlay.querySelector('form');
form.addEventListener('click', (event) => event.stopPropagation());
overlay.addEventListener('click', () => closePermissionsDialog());
overlay.querySelectorAll('[data-action="cancel"]').forEach((button) => {
button.addEventListener('click', () => closePermissionsDialog());
});
for (const checkboxName of ['changeMode', 'changeOwner', 'changeGroup']) {
const checkbox = form.elements[checkboxName];
const inputName = checkboxName.replace('change', '').toLowerCase();
const input = form.elements[inputName];
checkbox.addEventListener('change', () => {
input.disabled = !checkbox.checked;
if (!input.disabled) input.focus();
});
}
bindPermissionMatrix(form);
form.addEventListener('submit', (event) => {
event.preventDefault();
applyPermissionsDialog(form).catch(showError);
});
return overlay;
}
function askUploadConflict(details) {
return new Promise((resolve) => {
state.uploadConflictDialog = { ...details, resolve };
render();
});
}
function renderUploadConflictDialog() {
const dialog = state.uploadConflictDialog;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = `
`;
const form = overlay.querySelector('form');
form.addEventListener('click', (event) => event.stopPropagation());
overlay.addEventListener('click', () => closeUploadConflictDialog('skip'));
overlay.querySelectorAll('[data-action="skip"]').forEach((button) => {
button.addEventListener('click', () => closeUploadConflictDialog('skip'));
});
overlay.querySelector('[data-action="overwrite"]').addEventListener('click', () => closeUploadConflictDialog('overwrite'));
form.addEventListener('submit', (event) => {
event.preventDefault();
closeUploadConflictDialog('overwrite-all');
});
setTimeout(() => {
form.querySelector('.primary')?.focus();
}, 0);
return overlay;
}
function closeUploadConflictDialog(action) {
const dialog = state.uploadConflictDialog;
if (!dialog) return;
state.uploadConflictDialog = null;
dialog.resolve(action);
render();
}
function showMessageDialog(title, message, detail = '') {
return showUiDialog({
type: 'message',
title,
message,
detail,
confirmLabel: 'OK'
});
}
function showConfirmDialog(title, message, detail = '', options = {}) {
return showUiDialog({
type: 'confirm',
title,
message,
detail,
confirmLabel: options.confirmLabel || 'OK',
cancelLabel: options.cancelLabel || 'Abbrechen',
danger: Boolean(options.danger)
});
}
function showPromptDialog(title, label, value = '') {
return showUiDialog({
type: 'prompt',
title,
message: label,
value,
confirmLabel: 'OK',
cancelLabel: 'Abbrechen'
});
}
function showUiDialog(options) {
return new Promise((resolve) => {
state.uiDialog = { ...options, resolve };
render();
});
}
function renderUiDialog() {
const dialog = state.uiDialog;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
const isPrompt = dialog.type === 'prompt';
const isConfirm = dialog.type === 'confirm';
overlay.innerHTML = `
`;
const form = overlay.querySelector('form');
form.addEventListener('click', (event) => event.stopPropagation());
overlay.addEventListener('click', () => closeUiDialog(cancelDialogValue(dialog)));
overlay.querySelectorAll('[data-action="cancel"]').forEach((button) => {
button.addEventListener('click', () => closeUiDialog(cancelDialogValue(dialog)));
});
form.addEventListener('submit', (event) => {
event.preventDefault();
if (isPrompt) {
closeUiDialog(form.elements.value.value);
return;
}
closeUiDialog(isConfirm ? true : undefined);
});
setTimeout(() => {
const focusTarget = isPrompt ? form.elements.value : form.querySelector('.primary');
focusTarget?.focus();
if (isPrompt) {
focusTarget.select();
}
}, 0);
return overlay;
}
function cancelDialogValue(dialog) {
if (dialog.type === 'confirm') return false;
if (dialog.type === 'prompt') return null;
return undefined;
}
function closeUiDialog(value) {
const dialog = state.uiDialog;
if (!dialog) return;
state.uiDialog = null;
dialog.resolve(value);
render();
}
function permissionMatrixMarkup(mode) {
const digits = modeToDigits(mode);
const rows = [
['owner', 'Besitzer-Berechtigungen', digits[0]],
['group', 'Gruppen-Berechtigungen', digits[1]],
['public', 'Öffentliche Berechtigungen', digits[2]]
];
return rows.map(([scope, label, digit]) => `
`).join('');
}
function permissionCheck(scope, bit, label, checked) {
return `
`;
}
function bindPermissionMatrix(form) {
const changeMode = form.elements.changeMode;
const modeInput = form.elements.mode;
const permissionInputs = [...form.querySelectorAll('[data-permission]')];
const setModeDisabled = () => {
modeInput.disabled = !changeMode.checked;
for (const input of permissionInputs) {
input.disabled = !changeMode.checked;
}
};
changeMode.addEventListener('change', setModeDisabled);
for (const input of permissionInputs) {
input.addEventListener('change', () => {
changeMode.checked = true;
setModeDisabled();
modeInput.value = modeFromPermissionInputs(form);
});
}
modeInput.addEventListener('input', () => {
changeMode.checked = true;
setModeDisabled();
syncPermissionInputsFromMode(form, modeInput.value);
});
setModeDisabled();
}
function modeFromPermissionInputs(form) {
const scopes = ['owner', 'group', 'public'];
const values = scopes.map((scope) => {
let value = 0;
if (form.querySelector(`[data-permission="${scope}.read"]`)?.checked) value += 4;
if (form.querySelector(`[data-permission="${scope}.write"]`)?.checked) value += 2;
if (form.querySelector(`[data-permission="${scope}.execute"]`)?.checked) value += 1;
return String(value);
});
return values.join('');
}
function syncPermissionInputsFromMode(form, mode) {
if (!/^[0-7]{3,4}$/.test(mode)) return;
const digits = modeToDigits(mode);
for (const [index, scope] of ['owner', 'group', 'public'].entries()) {
const digit = digits[index];
const map = { read: 4, write: 2, execute: 1 };
for (const [bit, value] of Object.entries(map)) {
const input = form.querySelector(`[data-permission="${scope}.${bit}"]`);
if (input) input.checked = Boolean(digit & value);
}
}
}
function normalizeMode(mode) {
const value = String(mode || '');
if (!/^[0-7]{3,4}$/.test(value)) return '755';
return value.length === 4 ? value.slice(1) : value;
}
function modeToDigits(mode) {
const normalized = normalizeMode(mode);
return normalized.split('').slice(-3).map((digit) => Number(digit));
}
function closePermissionsDialog() {
state.permissionsDialog = null;
render();
}
async function applyPermissionsDialog(form) {
const dialog = state.permissionsDialog;
if (!dialog) return;
const options = {
mode: form.elements.changeMode.checked ? form.elements.mode.value.trim() : '',
owner: form.elements.changeOwner.checked ? form.elements.owner.value.trim() : '',
group: form.elements.changeGroup.checked ? form.elements.group.value.trim() : '',
recursive: form.elements.recursive.checked ? '1' : '0'
};
if (!options.mode && !options.owner && !options.group) {
showError(new Error('Keine Änderung angegeben.'));
return;
}
if (options.mode && !/^[0-7]{3,4}$/.test(options.mode)) {
showError(new Error('Berechtigungen müssen aus 3 oder 4 Oktalziffern bestehen.'));
return;
}
const win = state.windows.find((item) => item.id === dialog.winId);
closePermissionsDialog();
for (const item of dialog.entries) {
await createJob('permissions', item.path, null, [win?.id], options);
}
}
function showPropertiesForEntry(win, entry) {
selectEntry(win, entry);
openProperties(entry);
}
function showContextMenu(event, win, entry) {
const rect = workspace.getBoundingClientRect();
state.contextMenu = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
winId: win.id,
entryPath: entry?.path || null
};
render();
}
function renderContextMenu() {
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.left = `${state.contextMenu.x}px`;
menu.style.top = `${state.contextMenu.y}px`;
const win = state.windows.find((item) => item.id === state.contextMenu.winId);
const entry = win?.data.entries?.find((item) => item.path === state.contextMenu.entryPath) || null;
const actions = [];
if (entry) {
if (entry.type === 'directory') {
actions.push(['Öffnen', () => loadExplorer(win, entry.path)]);
} else if (isPreviewable(entry)) {
actions.push(['Vorschau', () => openPreview(entry, win.id)]);
}
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
actions.push(['Herunterladen', () => downloadEntries(win, entry)]);
if (!isReadOnly()) {
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
actions.push(['Berechtigungen...', () => openPermissionsDialog(win, entry)]);
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
actions.push(['Löschen', () => deleteEntries(win, entry)]);
}
} else {
actions.push(['Aktuellen Ordner herunterladen', () => downloadEntries(win, null)]);
actions.push(['Neu laden', () => loadExplorer(win, win.data.path)]);
}
for (const [label, action] of actions) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', async () => {
state.contextMenu = null;
render();
try {
await action();
} catch (error) {
showError(error);
}
});
menu.appendChild(button);
}
setTimeout(() => {
const close = () => {
state.contextMenu = null;
render();
document.removeEventListener('click', close);
document.removeEventListener('keydown', keyClose);
};
const keyClose = (event) => {
if (event.key === 'Escape') close();
};
document.addEventListener('click', close);
document.addEventListener('keydown', keyClose);
}, 0);
return menu;
}
async function pollJob(id) {
try {
const job = await api(apiUrl(`job.php?id=${encodeURIComponent(id)}`));
state.jobs.set(id, job);
updateTransferPanel();
if (['queued', 'running', 'cancel_requested'].includes(job.status)) {
setTimeout(() => pollJob(id), 700);
} else if (job.type === 'size') {
finishSizeJob(job);
} else {
await refreshAfterJob(job);
scheduleTransferAutoHide();
}
} catch (error) {
recordDebug('job.poll.error', { id, error: serializeError(error) });
showError(error);
}
}
function ensureDirectorySize(entry) {
if (state.sizeResults.has(entry.path) || state.sizeJobs.has(entry.path)) {
return;
}
state.sizeJobs.set(entry.path, 'pending');
createSizeJob(entry).catch((error) => {
state.sizeJobs.delete(entry.path);
showError(error);
});
}
function finishSizeJob(job) {
state.sizeJobs.delete(job.source);
if (job.status === 'completed' && job.result) {
state.sizeResults.set(job.source, job.result);
}
recordDebug('size.finish', { id: job.id, source: job.source, status: job.status, result: job.result || null });
updatePropertiesWindows();
}
async function refreshAfterJob(job) {
const refresh = state.jobRefresh.get(job.id);
if (refresh?.done) {
return;
}
if (refresh) {
refresh.done = true;
}
const windowIds = refresh?.windowIds || [];
recordDebug('job.refresh', { id: job.id, status: job.status, windowIds });
await reloadExplorerWindows(windowIds);
if (refresh) {
state.jobRefresh.delete(job.id);
}
}
function selectEntry(win, entry, scrollBody = null, event = null) {
if (state.suppressClickPath === entry.path) {
state.suppressClickPath = null;
restoreExplorerScroll(win, scrollBody);
return;
}
applyExplorerSelection(win, entry, event);
state.selectedEntry = entry;
focusWindow(win);
updateSelectedRows();
updatePropertiesWindows();
restoreExplorerScroll(win, scrollBody);
}
function applyExplorerSelection(win, entry, event = null) {
const entries = win.data.entries || [];
const current = selectedPathSet(win);
const toggle = Boolean(event?.ctrlKey || event?.metaKey);
const range = Boolean(event?.shiftKey && win.data.selectionAnchorPath);
if (range) {
const anchorIndex = entries.findIndex((item) => item.path === win.data.selectionAnchorPath);
const entryIndex = entries.findIndex((item) => item.path === entry.path);
if (anchorIndex >= 0 && entryIndex >= 0) {
const [start, end] = anchorIndex < entryIndex ? [anchorIndex, entryIndex] : [entryIndex, anchorIndex];
const paths = entries.slice(start, end + 1).map((item) => item.path);
win.data.selectedPaths = toggle ? [...new Set([...current, ...paths])] : paths;
} else {
win.data.selectedPaths = [entry.path];
win.data.selectionAnchorPath = entry.path;
}
} else if (toggle) {
if (current.has(entry.path)) {
current.delete(entry.path);
} else {
current.add(entry.path);
}
win.data.selectedPaths = [...current];
win.data.selectionAnchorPath = entry.path;
} else {
win.data.selectedPaths = [entry.path];
win.data.selectionAnchorPath = entry.path;
}
win.data.selectedPath = entry.path;
if (!win.data.selectedPaths.length) {
win.data.selectedPath = null;
win.data.selectionAnchorPath = null;
}
}
function reconcileExplorerSelection(win) {
const available = new Set((win.data.entries || []).map((entry) => entry.path));
win.data.selectedPaths = (win.data.selectedPaths || []).filter((path) => available.has(path));
if (!available.has(win.data.selectedPath)) {
win.data.selectedPath = win.data.selectedPaths[0] || null;
}
if (!available.has(win.data.selectionAnchorPath)) {
win.data.selectionAnchorPath = win.data.selectedPath;
}
if (state.selectedEntry && !available.has(state.selectedEntry.path) && state.focusedId === win.id) {
state.selectedEntry = null;
}
}
function selectedPathSet(win) {
return new Set(win?.data?.selectedPaths || (win?.data?.selectedPath ? [win.data.selectedPath] : []));
}
function selectedEntries(win) {
const paths = selectedPathSet(win);
return (win?.data?.entries || []).filter((entry) => paths.has(entry.path));
}
function shouldSuppressItemActivation() {
if (Date.now() <= state.suppressItemActivationUntil) {
recordDebug('item.activation.suppressed', { until: state.suppressItemActivationUntil });
return true;
}
return false;
}
function focusWindow(win) {
state.focusedId = win.id;
win.zIndex = ++state.zIndex;
for (const windowEl of workspace.querySelectorAll('.window')) {
windowEl.classList.toggle('focused', windowEl.dataset.windowId === win.id);
}
const current = workspace.querySelector(`.window[data-window-id="${win.id}"]`);
if (current) {
current.style.zIndex = win.zIndex;
}
}
function updateSelectedRows() {
for (const win of state.windows.filter((item) => item.kind === 'explorer')) {
const selected = selectedPathSet(win);
for (const row of workspace.querySelectorAll(`.window[data-window-id="${win.id}"] .file-row`)) {
row.classList.toggle('selected', selected.has(row.dataset.path));
}
}
}
function handleTypeAheadKey(event) {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
if (state.contextMenu || state.settingsPanelVisible) return;
if (isTypingTarget(document.activeElement)) return;
if (event.key.length !== 1) return;
const key = event.key.toLocaleLowerCase();
if (!/^[\p{L}\p{N}]$/u.test(key)) return;
const win = state.windows.find((item) => item.id === state.focusedId && item.kind === 'explorer');
if (!win || !win.data.entries?.length) return;
const now = Date.now();
const repeated = state.typeAhead.windowId === win.id && state.typeAhead.key === key && now - state.typeAhead.at < 1000;
const entries = win.data.entries;
const selectedIndex = entries.findIndex((entry) => entry.path === win.data.selectedPath);
const startIndex = repeated ? selectedIndex + 1 : Math.max(0, selectedIndex + 1);
const match = findTypeAheadMatch(entries, key, startIndex);
if (!match) return;
event.preventDefault();
state.typeAhead = { key, windowId: win.id, at: now };
win.data.selectedPath = match.path;
win.data.selectedPaths = [match.path];
win.data.selectionAnchorPath = match.path;
state.selectedEntry = match;
focusWindow(win);
updateSelectedRows();
updatePropertiesWindows();
scrollEntryIntoView(win, match.path);
recordDebug('typeahead.match', { key, path: match.path });
}
function findTypeAheadMatch(entries, key, startIndex) {
for (let offset = 0; offset < entries.length; offset += 1) {
const index = (startIndex + offset) % entries.length;
const entry = entries[index];
if (entry.name?.toLocaleLowerCase().startsWith(key)) {
return entry;
}
}
return null;
}
function scrollEntryIntoView(win, path) {
const row = workspace.querySelector(`.window[data-window-id="${win.id}"] .file-row[data-path="${cssEscape(path)}"]`);
row?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function isTypingTarget(element) {
if (!element) return false;
const tag = element.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable;
}
function updatePropertiesWindows() {
for (const win of state.windows.filter((item) => item.kind === 'properties')) {
const body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
if (body) {
renderProperties(body);
}
}
}
function startItemPointerDrag(event, win, entry) {
if (event.button !== 0 || !entry) return;
const items = dragItemsForEntry(win, entry);
recordDebug('pointer.start', {
windowId: win.id,
path: entry.path,
type: entry.type,
count: items.length,
x: event.clientX,
y: event.clientY
});
state.pointerDrag = {
sourceWindowId: win.id,
path: entry.path,
name: entry.name,
type: entry.type,
items,
paths: items.map((item) => item.path),
startX: event.clientX,
startY: event.clientY,
active: false,
targetWindowId: null,
targetPath: null
};
const move = (moveEvent) => updateItemPointerDrag(moveEvent);
const up = (upEvent) => finishItemPointerDrag(upEvent, move, up);
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up, { once: true });
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {}
}
function updateItemPointerDrag(event) {
const drag = state.pointerDrag;
if (!drag) return;
const distance = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY);
if (!drag.active && distance < 6) {
return;
}
drag.active = true;
event.preventDefault();
document.body.classList.add('u-nav-dragging');
markDraggingRows(drag.paths, true);
updatePointerDropTarget(event);
}
async function finishItemPointerDrag(event, move, up) {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const drag = state.pointerDrag;
clearPointerDropTarget();
document.body.classList.remove('u-nav-dragging');
if (!drag) return;
markDraggingRows(drag.paths, false);
if (!drag.active) {
state.pointerDrag = null;
return;
}
event.preventDefault();
state.suppressClickPath = drag.path;
state.suppressItemActivationUntil = Date.now() + 450;
const target = findPointerDropTarget(event, drag);
state.pointerDrag = null;
recordDebug('pointer.finish', {
source: drag.path,
sources: drag.paths,
target: target ? { windowId: target.windowId, path: target.path, rowPath: target.row?.dataset?.path || null } : null,
copy: event.altKey,
x: event.clientX,
y: event.clientY
});
if (!target) {
return;
}
try {
const jobs = drag.items.map((item) => ({
item,
destination: joinPath(target.path, item.name)
}));
recordDebug('pointer.destination', {
source: drag.path,
sources: drag.paths,
targetPath: target.path,
destinations: jobs.map((job) => job.destination),
targetIsRow: Boolean(target.row),
copy: event.altKey
});
validatePointerDropJobs(jobs, target.path);
for (const job of jobs) {
await createJob(event.altKey ? 'copy' : 'move', job.item.path, job.destination, [drag.sourceWindowId, target.windowId]);
}
} catch (error) {
showError(error);
}
}
function dragItemsForEntry(win, entry) {
const selected = selectedPathSet(win);
if (selected.has(entry.path)) {
const entries = selectedEntries(win);
if (entries.length) return entries;
}
return [entry];
}
function validatePointerDropJobs(jobs, targetPath) {
const destinations = new Set();
for (const { item, destination } of jobs) {
if (destinations.has(destination)) {
throw new Error('Mehrere Quellen würden am Ziel denselben Namen verwenden.');
}
destinations.add(destination);
if (destination === item.path || targetPath === item.path) {
throw new Error('Quelle und Ziel sind identisch.');
}
if (item.type === 'directory' && isPathInside(targetPath, item.path)) {
throw new Error('Ein Ordner kann nicht in sich selbst verschoben werden.');
}
}
}
function updatePointerDropTarget(event) {
clearPointerDropTarget();
const target = findPointerDropTarget(event);
if (!target) return;
state.pointerDrag.targetWindowId = target.windowId;
state.pointerDrag.targetPath = target.path;
const windowEl = workspace.querySelector(`.window[data-window-id="${target.windowId}"]`);
windowEl?.querySelector('.drop-zone')?.classList.add('drag-over');
if (target.row) {
target.row.classList.add('drop-target');
}
}
function clearPointerDropTarget() {
for (const item of workspace.querySelectorAll('.drop-zone.drag-over')) {
item.classList.remove('drag-over');
}
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, drag = state.pointerDrag) {
let element;
let elements = [];
try {
element = document.elementFromPoint(event.clientX, event.clientY);
elements = document.elementsFromPoint ? document.elementsFromPoint(event.clientX, event.clientY) : [element];
} catch (error) {
recordDebug('elementFromPoint.error', {
x: event.clientX,
y: event.clientY,
error: serializeError(error)
});
return null;
}
let windowEl;
try {
windowEl = element?.closest?.('.window');
} catch (error) {
recordDebug('closest.window.error', { error: serializeError(error), element: element?.tagName });
return null;
}
if (!windowEl) return null;
const win = state.windows.find((item) => item.id === windowEl.dataset.windowId);
if (!win || win.kind !== 'explorer') return null;
let row = null;
let overEntry = false;
for (const candidate of elements) {
try {
const treeTarget = candidate.closest?.('.tree-label[data-tree-open]');
if (treeTarget && treeTarget.closest?.('.window') === windowEl) {
overEntry = true;
}
if (treeTarget && treeTarget.closest?.('.window') === windowEl && isValidPointerTargetPath(treeTarget.dataset.treeOpen, drag)) {
row = treeTarget;
break;
}
const candidateRow = candidate.closest?.('.file-row');
if (candidateRow && candidateRow.closest?.('.window') === windowEl) {
overEntry = true;
}
if (!candidateRow || candidateRow.dataset.type !== 'directory') {
continue;
}
if (drag?.paths?.includes(candidateRow.dataset.path)) {
continue;
}
if (!isValidPointerTargetPath(candidateRow.dataset.path, drag)) {
continue;
}
if (candidateRow.closest?.('.window') !== windowEl) {
continue;
}
row = candidateRow;
break;
} catch (error) {
recordDebug('closest.row.error', { error: serializeError(error), element: candidate?.tagName });
}
}
if (row) {
return { windowId: win.id, path: row.dataset.path || row.dataset.treeOpen, row };
}
if (overEntry) {
return null;
}
return { windowId: win.id, path: win.data.path, row: null };
}
function markDraggingRows(paths, dragging) {
const pathSet = new Set(paths || []);
for (const row of workspace.querySelectorAll('.file-row')) {
if (pathSet.has(row.dataset.path)) {
row.classList.toggle('dragging', dragging);
}
}
}
function isValidPointerTargetPath(path, drag = state.pointerDrag) {
if (!drag) return false;
return (drag.items || []).every((item) => {
if (path === item.path) return false;
return !(item.type === 'directory' && isPathInside(path, item.path));
});
}
function lockExplorerScroll(win, body) {
win.data.lockScroll = true;
win.data.lockScrollTop = body.scrollTop;
win.data.lockScrollLeft = body.scrollLeft;
}
function restoreExplorerScroll(win, body) {
if (!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;
const top = win.data.lockScrollTop ?? win.data.scrollTop ?? 0;
const left = win.data.lockScrollLeft ?? win.data.scrollLeft ?? 0;
const restore = () => {
body.scrollTop = top;
body.scrollLeft = left;
};
restore();
requestAnimationFrame(restore);
setTimeout(restore, 0);
setTimeout(() => {
restore();
win.data.lockScroll = false;
win.data.scrollTop = body.scrollTop;
win.data.scrollLeft = body.scrollLeft;
}, 120);
}
function startDragWindow(event, win) {
if (event.target.closest('button') || win.maximized) return;
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const originX = win.x;
const originY = win.y;
const pointerId = event.pointerId;
event.currentTarget.setPointerCapture(pointerId);
const move = (moveEvent) => {
win.x = clamp(originX + moveEvent.clientX - startX, 0, Math.max(0, workspace.clientWidth - 120));
win.y = clamp(originY + moveEvent.clientY - startY, 0, Math.max(0, workspace.clientHeight - 80));
render();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
function startResizeWindow(event, win) {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startY = event.clientY;
const originWidth = win.width;
const originHeight = win.height;
const move = (moveEvent) => {
win.width = Math.max(320, originWidth + moveEvent.clientX - startX);
win.height = Math.max(280, originHeight + moveEvent.clientY - startY);
render();
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
async function api(url, options) {
const response = await fetchChecked(url, options);
const text = await response.text();
try {
return JSON.parse(text);
} catch (error) {
recordDebug('api.json.error', {
url,
status: response.status,
contentType: response.headers.get('content-type'),
body: text.slice(0, 2000),
error: serializeError(error)
});
throw error;
}
}
async function fetchChecked(url, options) {
recordDebug('fetch.request', { url, method: options?.method || 'GET' });
const response = await fetch(url, options);
recordDebug('fetch.response', {
url,
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type')
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
const text = await response.text();
try {
message = JSON.parse(text).error || message;
} catch {}
recordDebug('fetch.error.body', { url, status: response.status, body: text.slice(0, 2000) });
throw new Error(message);
}
return response;
}
function windowTitle(win) {
if (win.kind === 'explorer') {
return `Explorer · ${win.data.path || ''}`;
}
if (win.kind === 'preview') {
return `Vorschau · ${win.data.entry?.name || ''}`;
}
return win.title;
}
function parentPath(value) {
const parts = String(value || '').split('/').filter(Boolean);
if (parts.length <= 1) return value;
return `/${parts.slice(0, -1).join('/')}`;
}
function dirname(value) {
const parts = String(value || '').split('/').filter(Boolean);
if (parts.length <= 1) return '/';
return `/${parts.slice(0, -1).join('/')}`;
}
function basename(value) {
const parts = String(value || '').split('/').filter(Boolean);
return parts[parts.length - 1] || '';
}
function extensionOf(name) {
const value = String(name || '');
const index = value.lastIndexOf('.');
return index >= 0 ? value.slice(index + 1).toLowerCase() : '';
}
function isPreviewable(entry) {
if (entry?.type !== 'file') return false;
const name = String(entry.name || entry.path || '').toLowerCase();
return previewFilenames.has(name) || previewExtensions.has(extensionOf(name));
}
function joinPath(parent, name) {
return `${String(parent || '').replace(/\/+$/, '')}/${String(name || '').replace(/^\/+/, '')}`;
}
function normalizePath(value) {
const parts = String(value || '').split('/').filter(Boolean);
return `/${parts.join('/')}`;
}
function isPathInside(path, parent) {
const normalizedPath = normalizePath(path);
const normalizedParent = normalizePath(parent);
return normalizedPath.startsWith(`${normalizedParent.replace(/\/$/, '')}/`);
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`;
return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function directorySizeLabel(entry) {
const result = state.sizeResults.get(entry.path);
if (!result) {
return state.sizeJobs.has(entry.path) ? 'Berechne...' : 'Noch nicht berechnet';
}
const files = Number(result.files || 0);
const directories = Math.max(0, Number(result.directories || 0) - 1);
return `${formatBytes(result.size)} · ${files} Dateien · ${directories} Ordner`;
}
function formatPrincipal(name, id) {
if (name == null && id == null) return '-';
if (name == null || String(name) === String(id)) return String(id);
if (id == null) return String(name);
return `${name} (${id})`;
}
function iconSvg(name) {
const paths = uiIcons[name] || uiIcons.close;
return ``;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
})[char]);
}
function escapeAttr(value) {
return escapeHtml(value);
}
function cssEscape(value) {
if (window.CSS?.escape) {
return window.CSS.escape(String(value));
}
return String(value).replace(/["\\]/g, '\\$&');
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function apiUrl(path) {
return `${API_BASE.replace(/\/$/, '')}/${path.replace(/^\//, '')}`;
}
function appendCsrf(body) {
const token = getCsrfToken();
if (!token) {
recordDebug('csrf.missing', {});
return;
}
body.append('csrf_token', token);
recordDebug('csrf.attached', { length: token.length });
}
function getCsrfToken() {
return window.csrf_token
|| document.querySelector('input[name="csrf_token"]')?.value
|| document.querySelector('meta[name="csrf_token"]')?.content
|| document.querySelector('meta[name="csrf-token"]')?.content
|| '';
}
function recordDebug(label, data = {}) {
if (!state.config?.debug) {
return null;
}
const item = {
id: state.nextDebugId++,
time: new Date().toLocaleTimeString(),
label,
data
};
state.debug.push(item);
if (state.debug.length > 80) {
state.debug.splice(0, state.debug.length - 80);
}
updateTransferPanel();
return item;
}
function updateTransferPanel() {
renderTransferPanel();
}
function serializeError(error) {
if (!error) return null;
if (error instanceof Error || typeof error === 'object') {
return {
name: error.name,
message: error.message || String(error),
stack: error.stack
};
}
return { message: String(error) };
}