2169 lines
69 KiB
JavaScript
2169 lines
69 KiB
JavaScript
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,
|
||
draggedEntry: null,
|
||
pointerDrag: null,
|
||
suppressClickPath: null,
|
||
suppressItemActivationUntil: 0,
|
||
debug: [],
|
||
nextDebugId: 1,
|
||
jobs: new Map(),
|
||
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 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: 760,
|
||
height: 460,
|
||
x: 24 + state.windows.length * 24,
|
||
y: 20 + state.windows.length * 20,
|
||
data: {
|
||
path: initialPath,
|
||
entries: [],
|
||
selectedPath: null,
|
||
selectedPaths: [],
|
||
selectionAnchorPath: null,
|
||
view: 'icons',
|
||
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());
|
||
}
|
||
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 = `
|
||
<strong>${escapeHtml(windowTitle(win))}</strong>
|
||
<div class="window-actions">
|
||
<button class="icon" data-action="maximize" title="Maximieren">${win.maximized ? '↙' : '□'}</button>
|
||
<button class="icon" data-action="close" title="Schließen">×</button>
|
||
</div>
|
||
`;
|
||
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);
|
||
body.addEventListener('scroll', () => {
|
||
if (win.data.lockScroll) {
|
||
body.scrollTop = win.data.lockScrollTop || 0;
|
||
body.scrollLeft = win.data.lockScrollLeft || 0;
|
||
return;
|
||
}
|
||
win.data.scrollTop = body.scrollTop;
|
||
win.data.scrollLeft = body.scrollLeft;
|
||
});
|
||
|
||
el.append(titlebar, body);
|
||
requestAnimationFrame(() => {
|
||
body.scrollTop = win.data.scrollTop || 0;
|
||
body.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.innerHTML = `
|
||
<div class="pathbar">
|
||
<button class="icon up-button" data-action="up" title="Eine Ebene zurueck" aria-label="Eine Ebene zurueck">←</button>
|
||
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
|
||
<button class="icon" data-action="go" title="Öffnen" aria-label="Öffnen">↵</button>
|
||
<button class="icon" data-action="refresh" title="Neu laden" aria-label="Neu laden">↻</button>
|
||
<button class="icon" data-action="upload-files" title="Dateien hochladen" aria-label="Dateien hochladen" ${isReadOnly() ? 'disabled' : ''}>⤴</button>
|
||
<button class="icon" data-action="upload-folder" title="Ordner hochladen" aria-label="Ordner hochladen" ${isReadOnly() ? 'disabled' : ''}>⇪</button>
|
||
<button class="icon danger" data-action="delete-selected" title="Auswahl löschen" aria-label="Auswahl löschen" ${isReadOnly() ? 'disabled' : ''}>×</button>
|
||
<input class="upload-input" data-upload="files" type="file" multiple>
|
||
<input class="upload-input" data-upload="folder" type="file" multiple webkitdirectory directory>
|
||
<div class="view-toggle" aria-label="Ansicht">
|
||
<button class="icon ${data.view !== 'icons' ? 'active' : ''}" data-action="view-list" title="Listenansicht" aria-label="Listenansicht">☰</button>
|
||
<button class="icon ${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht" aria-label="Symbolansicht">▦</button>
|
||
</div>
|
||
</div>
|
||
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
|
||
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
|
||
${data.loading ? '<p class="muted">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
|
||
</div>
|
||
`;
|
||
|
||
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="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));
|
||
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');
|
||
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, body);
|
||
startItemPointerDrag(event, win, entry);
|
||
});
|
||
row.addEventListener('click', (event) => {
|
||
if (shouldSuppressItemActivation()) return;
|
||
selectEntry(win, entry, body, 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 renderFileEntries(entries, view, selectedPaths = new Set()) {
|
||
if (!entries.length) {
|
||
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
|
||
}
|
||
return view === 'icons' ? renderFileIcons(entries, selectedPaths) : renderFileTable(entries, selectedPaths);
|
||
}
|
||
|
||
function renderFileTable(entries, selectedPaths) {
|
||
const rows = entries.map((entry) => `
|
||
<tr class="file-row ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
||
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
|
||
<td>${entry.type}</td>
|
||
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
|
||
<td>${new Date(entry.modifiedAt).toLocaleString()}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
return `
|
||
<table class="file-table">
|
||
<thead><tr><th>Name</th><th>Typ</th><th>Größe</th><th>Geändert</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
function renderFileIcons(entries, selectedPaths) {
|
||
const items = entries.map((entry) => `
|
||
<button type="button" class="file-row icon-item ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
||
<span class="file-icon ${escapeAttr(entry.type)}" aria-label="${escapeAttr(icons[entry.type] || 'Item')}"></span>
|
||
<span class="icon-name">${escapeHtml(entry.name)}</span>
|
||
<span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : entry.type}</span>
|
||
</button>
|
||
`).join('');
|
||
|
||
return `<div class="icon-grid">${items}</div>`;
|
||
}
|
||
|
||
function setExplorerView(win, view) {
|
||
win.data.view = view;
|
||
render();
|
||
}
|
||
|
||
function renderProperties(body) {
|
||
const entry = state.selectedEntry;
|
||
if (!entry) {
|
||
body.innerHTML = '<p class="muted">Keine Datei ausgewählt.</p>';
|
||
return;
|
||
}
|
||
|
||
const sizeLabel = entry.type === 'directory' ? directorySizeLabel(entry) : formatBytes(entry.size);
|
||
body.classList.add('properties');
|
||
body.innerHTML = `
|
||
<dl>
|
||
<dt>Name</dt><dd>${escapeHtml(entry.name)}</dd>
|
||
<dt>Typ</dt><dd>${escapeHtml(entry.type)}</dd>
|
||
<dt>Pfad</dt><dd>${escapeHtml(entry.path)}</dd>
|
||
<dt>Größe</dt><dd>${escapeHtml(sizeLabel)}</dd>
|
||
<dt>Geändert</dt><dd>${new Date(entry.modifiedAt).toLocaleString()}</dd>
|
||
<dt>Besitzer</dt><dd>${escapeHtml(formatPrincipal(entry.owner, entry.ownerId))}</dd>
|
||
<dt>Gruppe</dt><dd>${escapeHtml(formatPrincipal(entry.group, entry.groupId))}</dd>
|
||
<dt>Rechte</dt><dd>${escapeHtml(entry.permissions ? `0${entry.permissions}`.slice(-4) : '-')}</dd>
|
||
</dl>
|
||
`;
|
||
if (entry.type === 'directory') {
|
||
ensureDirectorySize(entry);
|
||
}
|
||
}
|
||
|
||
function renderPreview(body, win) {
|
||
const data = win.data;
|
||
body.classList.add('preview-body');
|
||
if (data.loading) {
|
||
body.innerHTML = '<p class="muted">Lade Vorschau...</p>';
|
||
return;
|
||
}
|
||
if (data.error) {
|
||
body.innerHTML = `<p class="muted">${escapeHtml(data.error)}</p>`;
|
||
return;
|
||
}
|
||
const entry = data.entry;
|
||
const navigation = previewNavigation(data);
|
||
body.innerHTML = `
|
||
<div class="preview-meta">
|
||
<div class="preview-nav">
|
||
<button class="icon" data-action="preview-prev" title="Vorherige Datei" aria-label="Vorherige Datei" ${navigation.previous ? '' : 'disabled'}>←</button>
|
||
<button class="icon" data-action="preview-next" title="Nächste Datei" aria-label="Nächste Datei" ${navigation.next ? '' : 'disabled'}>→</button>
|
||
</div>
|
||
<strong>${escapeHtml(entry?.name || data.name || 'Vorschau')}</strong>
|
||
<span>${escapeHtml(formatBytes(data.size || entry?.size || 0))}</span>
|
||
</div>
|
||
${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 `<article class="preview-rendered">${renderMarkdown(data.content || '')}</article>`;
|
||
}
|
||
if (data.type === 'csv') {
|
||
return renderCsvPreview(data.content || '', data.extension === 'tsv' ? '\t' : ',');
|
||
}
|
||
if (data.type === 'image') {
|
||
return `<div class="preview-media"><img src="${escapeAttr(apiUrl(data.rawUrl))}" alt="${escapeAttr(data.name || 'Vorschau')}"></div>`;
|
||
}
|
||
if (data.type === 'pdf') {
|
||
return `<iframe class="preview-frame" src="${escapeAttr(apiUrl(data.rawUrl))}" title="${escapeAttr(data.name || 'PDF Vorschau')}"></iframe>`;
|
||
}
|
||
if (data.type === 'audio') {
|
||
return `<div class="preview-media"><audio controls src="${escapeAttr(apiUrl(data.rawUrl))}"></audio></div>`;
|
||
}
|
||
if (data.type === 'video') {
|
||
return `<div class="preview-media"><video controls src="${escapeAttr(apiUrl(data.rawUrl))}"></video></div>`;
|
||
}
|
||
return `<pre class="preview-text">${escapeHtml(data.content || '')}</pre>`;
|
||
}
|
||
|
||
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(`<ul>${listItems.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</ul>`);
|
||
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(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
|
||
continue;
|
||
}
|
||
const list = /^\s*[-*]\s+(.*)$/.exec(line);
|
||
if (list) {
|
||
listItems.push(list[1]);
|
||
continue;
|
||
}
|
||
flushList();
|
||
if (!line.trim()) continue;
|
||
blocks.push(`<p>${renderInlineMarkdown(line)}</p>`);
|
||
}
|
||
flushList();
|
||
return blocks.join('');
|
||
}
|
||
|
||
function renderInlineMarkdown(value) {
|
||
return escapeHtml(value)
|
||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||
}
|
||
|
||
function renderCsvPreview(content, delimiter) {
|
||
const rows = parseDelimitedRows(content, delimiter).slice(0, 80);
|
||
if (!rows.length) {
|
||
return '<p class="muted">Keine Tabellenzeilen.</p>';
|
||
}
|
||
return `
|
||
<div class="preview-table-wrap">
|
||
<table class="preview-table">
|
||
<tbody>
|
||
${rows.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 jobMarkup = jobs.length ? jobs.map((job) => `
|
||
<article class="job">
|
||
<strong>${escapeHtml(job.type)} #${escapeHtml(job.id)}</strong>
|
||
<div class="muted">${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}</div>
|
||
<div class="progress"><div style="width:${Number(job.progress) || 0}%"></div></div>
|
||
</article>
|
||
`).join('') : '<p class="muted">Keine Transfers.</p>';
|
||
const debugSection = state.config?.debug ? debugMarkup() : '';
|
||
|
||
return `
|
||
<div class="queue-list">${jobMarkup}</div>
|
||
${debugSection}
|
||
`;
|
||
}
|
||
|
||
function debugMarkup() {
|
||
const debugMarkup = state.debug.length ? state.debug.slice(-12).reverse().map((item) => `
|
||
<article class="debug-entry">
|
||
<strong>#${item.id} ${escapeHtml(item.label)}</strong>
|
||
<time>${escapeHtml(item.time)}</time>
|
||
<pre>${escapeHtml(JSON.stringify(item.data, null, 2))}</pre>
|
||
</article>
|
||
`).join('') : '<p class="muted">Noch keine Debug-Einträge.</p>';
|
||
|
||
return `
|
||
<section class="debug-log" aria-label="Debug-Protokoll">
|
||
<header>
|
||
<strong>Debug</strong>
|
||
<button type="button" data-action="clear-debug">Leeren</button>
|
||
</header>
|
||
${debugMarkup}
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
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() {
|
||
return [...state.jobs.values()].filter((job) => job.type !== 'size' && ['queued', 'running', 'cancel_requested'].includes(job.status)).length;
|
||
}
|
||
|
||
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 = `
|
||
<header class="transfer-panel-header">
|
||
<strong>Transfers</strong>
|
||
<button type="button" class="icon" data-action="close-transfers" title="Schließen">×</button>
|
||
</header>
|
||
<div class="transfer-panel-body">${queueMarkup()}</div>
|
||
`;
|
||
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 = `
|
||
<header class="settings-panel-header">
|
||
<strong>Einstellungen</strong>
|
||
<button type="button" class="icon" data-action="close-settings" title="Schließen">×</button>
|
||
</header>
|
||
<div class="settings-panel-body">
|
||
<label class="setting-row">
|
||
<span>
|
||
<strong>Debug</strong>
|
||
<small>Debug-Protokoll im Transfers-Panel anzeigen</small>
|
||
</span>
|
||
<input type="checkbox" data-setting="debug" ${state.config?.debug ? 'checked' : ''}>
|
||
</label>
|
||
<label class="setting-row">
|
||
<span>
|
||
<strong>Schreibzugriff</strong>
|
||
<small>Upload, Rename, Move und Copy erlauben</small>
|
||
</span>
|
||
<input type="checkbox" data-setting="write" ${state.config && !state.config.readOnly ? 'checked' : ''}>
|
||
</label>
|
||
</div>
|
||
`;
|
||
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;
|
||
reconcileExplorerSelection(win);
|
||
} catch (error) {
|
||
win.data.error = error.message;
|
||
} finally {
|
||
win.data.loading = false;
|
||
render();
|
||
}
|
||
}
|
||
|
||
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);
|
||
for (const [index, item] of uploadItems.entries()) {
|
||
recordDebug('upload.file.request', { path: item.path, size: item.file.size, index: index + 1, total: uploadItems.length });
|
||
const form = new FormData();
|
||
form.append('files', item.file, item.file.name);
|
||
form.append('relativePath', item.path);
|
||
appendCsrf(form);
|
||
await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
|
||
method: 'POST',
|
||
body: form
|
||
});
|
||
recordDebug('upload.file.done', { path: item.path, index: index + 1, total: uploadItems.length });
|
||
}
|
||
await loadExplorer(win, targetPath);
|
||
}
|
||
|
||
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 ? `\n\nDebug #${debug.id}: ${debug.label}` : '';
|
||
alert(`${message}${detail}`);
|
||
}
|
||
|
||
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 = prompt('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 = prompt(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?`;
|
||
if (!confirm(`${label}\n\nDieser Vorgang löscht direkt vom Dateisystem.`)) {
|
||
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 = `
|
||
<form class="permissions-modal" aria-label="Berechtigungen ändern">
|
||
<header>
|
||
<strong>Berechtigungen ändern</strong>
|
||
<button type="button" class="icon" data-action="cancel" title="Schließen" aria-label="Schließen">×</button>
|
||
</header>
|
||
<div class="modal-body">
|
||
<p class="muted">${dialog.entries.length === 1 ? escapeHtml(dialog.entries[0].name) : `${dialog.entries.length} Objekte ausgewählt`}</p>
|
||
<label class="check-row">
|
||
<input type="checkbox" name="changeMode" checked>
|
||
<span>Berechtigungen ändern</span>
|
||
</label>
|
||
${permissionMatrixMarkup(dialog.mode)}
|
||
<label class="field-row">
|
||
<span>Numerischer Wert</span>
|
||
<input name="mode" class="mode-input" inputmode="numeric" pattern="[0-7]{3,4}" value="${escapeAttr(dialog.mode)}" aria-label="Numerischer Wert">
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" name="changeOwner">
|
||
<span>Besitzer ändern</span>
|
||
</label>
|
||
<input name="owner" value="${escapeAttr(dialog.owner)}" aria-label="Besitzer" disabled>
|
||
<label class="check-row">
|
||
<input type="checkbox" name="changeGroup">
|
||
<span>Gruppe ändern</span>
|
||
</label>
|
||
<input name="group" value="${escapeAttr(dialog.group)}" aria-label="Gruppe" disabled>
|
||
<label class="check-row ${dialog.hasDirectory ? '' : 'disabled'}">
|
||
<input type="checkbox" name="recursive" ${dialog.hasDirectory ? '' : 'disabled'}>
|
||
<span>Rekursiv auf Unterordner und Dateien anwenden</span>
|
||
</label>
|
||
</div>
|
||
<footer>
|
||
<button type="button" data-action="cancel">Abbrechen</button>
|
||
<button type="submit" class="primary">Anwenden</button>
|
||
</footer>
|
||
</form>
|
||
`;
|
||
|
||
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 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]) => `
|
||
<fieldset class="permission-scope" data-scope="${scope}">
|
||
<legend>${label}</legend>
|
||
${permissionCheck(scope, 'read', 'Lesen', digit & 4)}
|
||
${permissionCheck(scope, 'write', 'Schreiben', digit & 2)}
|
||
${permissionCheck(scope, 'execute', 'Ausführen', digit & 1)}
|
||
</fieldset>
|
||
`).join('');
|
||
}
|
||
|
||
function permissionCheck(scope, bit, label, checked) {
|
||
return `
|
||
<label>
|
||
<input type="checkbox" data-permission="${scope}.${bit}" ${checked ? 'checked' : ''}>
|
||
<span>${label}</span>
|
||
</label>
|
||
`;
|
||
}
|
||
|
||
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);
|
||
markDropCandidates(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);
|
||
markDropCandidates(drag.paths, false);
|
||
state.pointerDrag = null;
|
||
|
||
if (!drag.active) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
state.suppressClickPath = drag.path;
|
||
state.suppressItemActivationUntil = Date.now() + 450;
|
||
const target = findPointerDropTarget(event);
|
||
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');
|
||
}
|
||
}
|
||
|
||
function findPointerDropTarget(event) {
|
||
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;
|
||
|
||
const drag = state.pointerDrag;
|
||
let row = null;
|
||
for (const candidate of elements) {
|
||
try {
|
||
const candidateRow = candidate.closest?.('.file-row');
|
||
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 };
|
||
}
|
||
|
||
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 markDropCandidates(sourcePaths, active) {
|
||
const source = state.pointerDrag;
|
||
for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) {
|
||
const path = row.dataset.path || '';
|
||
const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
|
||
row.classList.toggle('drop-candidate', active && valid);
|
||
}
|
||
}
|
||
|
||
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}"] .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 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) };
|
||
}
|