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 = ` ${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); 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 = `
${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="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 '

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) => ` ${icons[entry.type] || 'Item'}${escapeHtml(entry.name)} ${entry.type} ${entry.type === 'file' ? formatBytes(entry.size) : ''} ${new Date(entry.modifiedAt).toLocaleString()} `).join(''); return ` ${rows}
NameTypGrößeGeändert
`; } function renderFileIcons(entries, selectedPaths) { const items = entries.map((entry) => ` `).join(''); return `
${items}
`; } 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 = `
${escapeHtml(entry?.name || data.name || 'Vorschau')} ${escapeHtml(formatBytes(data.size || entry?.size || 0))}
${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 `
${escapeAttr(data.name || 'Vorschau')}
`; } 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 = []; } }; 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) => ``).join('')}`).join('')}
${escapeHtml(cell)}
`; } 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) => `
${escapeHtml(job.type)} #${escapeHtml(job.id)}
${escapeHtml(job.status)} · ${escapeHtml(job.message || '')}
`).join('') : '

Keine Transfers.

'; const debugSection = state.config?.debug ? debugMarkup() : ''; return `
${jobMarkup}
${debugSection} `; } 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 `
Debug
${debugMarkup}
`; } 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 = `
Transfers
${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 = `
Einstellungen
`; 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 params = new URLSearchParams({ path: targetPath, relativePath: item.path }); const token = getCsrfToken(); if (token) { params.set('csrf_token', token); recordDebug('csrf.attached', { length: token.length }); } else { recordDebug('csrf.missing', {}); } const response = await fetch(apiUrl(`upload.php?${params.toString()}`), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: item.file }); const text = await response.text(); recordDebug('upload.file.response', { path: item.path, status: response.status, ok: response.ok, contentType: response.headers.get('content-type'), body: text.slice(0, 1000) }); if (!response.ok) { throw new Error(text || `${response.status} ${response.statusText}`); } let payload; try { payload = JSON.parse(text); } catch (error) { throw new Error(`Upload lieferte keine JSON-Antwort: ${text.slice(0, 200) || response.headers.get('content-type') || 'leer'}`); } if (!payload.uploaded?.length) { throw new Error('Upload wurde vom Server nicht bestätigt.'); } 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 = `
Berechtigungen ändern
`; 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]) => `
${label} ${permissionCheck(scope, 'read', 'Lesen', digit & 4)} ${permissionCheck(scope, 'write', 'Schreiben', digit & 2)} ${permissionCheck(scope, 'execute', 'Ausführen', digit & 1)}
`).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); 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) }; }