const workspace = document.querySelector('#workspace');
const statusText = document.querySelector('#statusText');
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,
draggedEntry: null,
pointerDrag: null,
suppressClickPath: null,
debug: [],
nextDebugId: 1,
jobs: new Map(),
jobRefresh: new Map(),
nextWindowId: 1,
zIndex: 20
};
const icons = {
directory: 'Folder',
file: 'File',
symlink: 'Link',
other: 'Item'
};
document.querySelector('#newExplorerButton').addEventListener('click', () => openExplorer());
document.querySelector('#newPropertiesButton').addEventListener('click', () => openProperties());
document.querySelector('#newQueueButton').addEventListener('click', () => openQueue());
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) });
});
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');
}
statusText.textContent = `${state.config.roots.length} Root, ${state.config.readOnly ? 'Read-only' : 'Schreibzugriff aktiv'} · ${APP_VERSION}`;
openExplorer(state.config.roots[0].path);
openQueue();
} 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,
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 openQueue() {
return createWindow('Transfers', 'queue', {
width: 440,
height: 340,
x: 250,
y: 130,
data: {}
});
}
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());
}
}
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 === 'queue') renderQueue(body);
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')}
`;
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'));
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', () => selectEntry(win, entry, body));
row.addEventListener('dblclick', () => {
if (entry.type === 'directory') {
loadExplorer(win, entry.path);
}
});
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
selectEntry(win, entry);
showContextMenu(event, win, entry);
});
}
}
function renderFileEntries(entries, view) {
if (!entries.length) {
return 'Dieser Ordner ist leer. Dateien können hier abgelegt werden.
';
}
return view === 'icons' ? renderFileIcons(entries) : renderFileTable(entries);
}
function renderFileTable(entries) {
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 `
| Name | Typ | Größe | Geändert |
${rows}
`;
}
function renderFileIcons(entries) {
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;
}
body.classList.add('properties');
body.innerHTML = `
- Name
- ${escapeHtml(entry.name)}
- Typ
- ${escapeHtml(entry.type)}
- Pfad
- ${escapeHtml(entry.path)}
- Größe
- ${formatBytes(entry.size)}
- 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) : '-')}
`;
}
function renderQueue(body) {
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 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.
';
body.innerHTML = `
${jobMarkup}
`;
body.querySelector('[data-action="clear-debug"]')?.addEventListener('click', () => {
state.debug = [];
render();
});
}
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;
} catch (error) {
win.data.error = error.message;
} finally {
win.data.loading = false;
render();
}
}
async function handleDrop(event, win, targetPath) {
recordDebug('html.drop', {
targetPath,
types: dataTransferTypes(event),
files: getDroppedFiles(event).map((file) => ({ name: file.name, size: file.size }))
});
if (state.config.readOnly) {
showError(new Error('Read-only mode ist aktiv.'));
return;
}
const files = getDroppedFiles(event);
if (files.length) {
const form = new FormData();
for (const file of files) {
form.append('files', file, file.name);
}
appendCsrf(form);
await fetchChecked(apiUrl(`upload.php?path=${encodeURIComponent(targetPath)}`), {
method: 'POST',
body: form
});
await loadExplorer(win, targetPath);
}
}
async function createJob(type, source, destination, refreshWindowIds = []) {
recordDebug('job.create.request', { type, source, destination, refreshWindowIds });
const body = new URLSearchParams();
body.set('source', source);
body.set('destination', destination);
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
});
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;
}
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) {
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) {
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}`, []);
}
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)]);
}
actions.push(['Eigenschaften', () => showPropertiesForEntry(win, entry)]);
actions.push(['Umbenennen', () => renameEntry(win, entry)]);
actions.push(['Kopieren nach...', () => promptTransfer(win, entry, 'copy')]);
actions.push(['Verschieben nach...', () => promptTransfer(win, entry, 'move')]);
} else {
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);
render();
if (['queued', 'running', 'cancel_requested'].includes(job.status)) {
setTimeout(() => pollJob(id), 700);
} else {
await refreshAfterJob(job);
}
} catch (error) {
recordDebug('job.poll.error', { id, error: serializeError(error) });
showError(error);
}
}
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) {
if (state.suppressClickPath === entry.path) {
state.suppressClickPath = null;
restoreExplorerScroll(win, scrollBody);
return;
}
win.data.selectedPath = entry.path;
state.selectedEntry = entry;
focusWindow(win);
updateSelectedRows();
updatePropertiesWindows();
restoreExplorerScroll(win, scrollBody);
}
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 row of workspace.querySelectorAll('.file-row')) {
row.classList.toggle('selected', row.dataset.path === state.selectedEntry?.path);
}
}
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;
recordDebug('pointer.start', {
windowId: win.id,
path: entry.path,
type: entry.type,
x: event.clientX,
y: event.clientY
});
state.pointerDrag = {
sourceWindowId: win.id,
path: entry.path,
name: entry.name,
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');
markDraggingRow(drag.path, 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;
markDraggingRow(drag.path, false);
state.pointerDrag = null;
if (!drag.active) {
return;
}
event.preventDefault();
state.suppressClickPath = drag.path;
const target = findPointerDropTarget(event);
recordDebug('pointer.finish', {
source: drag.path,
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 destination = `${target.path.replace(/\/$/, '')}/${drag.name}`;
if (destination === drag.path) {
throw new Error('Quelle und Ziel sind identisch.');
}
await createJob(event.altKey ? 'copy' : 'move', drag.path, destination, [drag.sourceWindowId, target.windowId]);
} catch (error) {
showError(error);
}
}
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;
try {
element = document.elementFromPoint(event.clientX, event.clientY);
} catch (error) {
recordDebug('elementFromPoint.error', {
x: event.clientX,
y: event.clientY,
error: serializeError(error)
});
return null;
}
let windowEl;
try {
windowEl = element?.closest?.('.window');
} catch (error) {
recordDebug('closest.window.error', { error: serializeError(error), element: element?.tagName });
return null;
}
if (!windowEl) return null;
const win = state.windows.find((item) => item.id === windowEl.dataset.windowId);
if (!win || win.kind !== 'explorer') return null;
let row;
try {
row = element.closest?.('.file-row');
} catch (error) {
recordDebug('closest.row.error', { error: serializeError(error), element: element?.tagName });
}
if (row?.dataset.type === 'directory') {
return { windowId: win.id, path: row.dataset.path, row };
}
return { windowId: win.id, path: win.data.path, row: null };
}
function markDraggingRow(path, dragging) {
for (const row of workspace.querySelectorAll('.file-row')) {
if (row.dataset.path === path) {
row.classList.toggle('dragging', dragging);
}
}
}
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 || ''}`;
}
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 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 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 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 = {}) {
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);
}
updateQueueWindows();
return item;
}
function updateQueueWindows() {
for (const win of state.windows.filter((item) => item.kind === 'queue')) {
const body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
if (body) {
renderQueue(body);
}
}
}
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) };
}