diff --git a/build-plugin.sh b/build-plugin.sh index 373ce6a..66b5372 100755 --- a/build-plugin.sh +++ b/build-plugin.sh @@ -2,7 +2,7 @@ set -eu PLUGIN="u-navigator" -VERSION="2026.07.14.r016" +VERSION="2026.07.14.r017" PKG_DIR="packages" WORK_DIR=".plugin-build" PKG_NAME="${PLUGIN}-${VERSION}.tgz" diff --git a/packages/u-navigator-2026.07.14.r017.tgz b/packages/u-navigator-2026.07.14.r017.tgz new file mode 100644 index 0000000..dffd0c1 Binary files /dev/null and b/packages/u-navigator-2026.07.14.r017.tgz differ diff --git a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php index 1e8e10a..7a035d4 100644 --- a/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php +++ b/plugin-root/usr/local/emhttp/plugins/u-navigator/api/preview.php @@ -15,6 +15,68 @@ if ($type === null) { unav_error(415, 'Preview is not available for this file type'); } +$editable = in_array($type, ['text', 'markdown', 'csv'], true); +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + unav_assert_writable(); + if (!$editable) { + unav_error(415, 'Dieser Dateityp kann nicht bearbeitet werden'); + } + $data = unav_request_data(); + if (!array_key_exists('content', $data)) { + unav_error(400, 'Missing file content'); + } + $content = (string)$data['content']; + if (strlen($content) > UNAV_TEXT_PREVIEW_MAX_BYTES) { + unav_error(413, 'Die bearbeitete Datei ist größer als 1 MB'); + } + if (strpos($content, "\0") !== false) { + unav_error(415, 'Binärdaten können nicht im Texteditor gespeichert werden'); + } + $expectedRevision = trim((string)($data['revision'] ?? '')); + if ($expectedRevision === '') { + unav_error(400, 'Missing file revision'); + } + $handle = fopen($resolved['path'], 'r+b'); + if ($handle === false || !flock($handle, LOCK_EX)) { + if (is_resource($handle)) { + fclose($handle); + } + unav_error(500, 'Could not lock file for editing'); + } + rewind($handle); + $current = stream_get_contents($handle); + if ($current === false) { + flock($handle, LOCK_UN); + fclose($handle); + unav_error(500, 'Could not read file before saving'); + } + if (!hash_equals($expectedRevision, hash('sha256', $current))) { + flock($handle, LOCK_UN); + fclose($handle); + unav_error(409, 'Die Datei wurde außerhalb von U-Navigator geändert. Bitte vor dem Speichern neu laden.'); + } + rewind($handle); + if (!ftruncate($handle, 0) || !unav_write_all($handle, $content) || !fflush($handle)) { + flock($handle, LOCK_UN); + fclose($handle); + unav_error(500, 'Could not save file'); + } + flock($handle, LOCK_UN); + fclose($handle); + clearstatcache(true, $resolved['path']); + unav_json([ + 'path' => $resolved['path'], + 'size' => strlen($content), + 'modifiedAt' => gmdate('c', filemtime($resolved['path']) ?: time()), + 'revision' => hash('sha256', $content), + 'content' => $content, + ]); +} + +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + unav_error(405, 'Method not allowed'); +} + $size = filesize($resolved['path']); if ($size === false) { unav_error(500, 'Could not read file size'); @@ -35,6 +97,7 @@ $payload = [ 'size' => $size, 'extension' => $extension, 'type' => $type, + 'editable' => $editable, 'mime' => $mime, 'rawUrl' => 'preview.php?raw=1&path=' . rawurlencode($resolved['path']), ]; @@ -51,10 +114,24 @@ if (in_array($type, ['text', 'markdown', 'csv'], true)) { unav_error(415, 'Preview is not available for binary files'); } $payload['content'] = $content; + $payload['revision'] = hash('sha256', $content); } unav_json($payload); +function unav_write_all($handle, string $content): bool { + $length = strlen($content); + $offset = 0; + while ($offset < $length) { + $written = fwrite($handle, substr($content, $offset)); + if ($written === false || $written === 0) { + return false; + } + $offset += $written; + } + return true; +} + function unav_preview_type(string $extension, string $name): ?string { $base = strtolower($name); if (in_array($base, ['dockerfile', 'makefile'], true)) { diff --git a/server/public/app.js b/server/public/app.js index 1643bfd..99f67c7 100644 --- a/server/public/app.js +++ b/server/public/app.js @@ -68,6 +68,7 @@ const uiIcons = { move: '', preview: '', refresh: '', + save: '', shield: '', tree: '', upload: '' @@ -170,6 +171,10 @@ function openPreview(entry, sourceWindowId = state.focusedId) { existing.data.entry = entry; existing.data.sourceWindowId = sourceWindowId; existing.data.content = ''; + existing.data.editable = false; + existing.data.editing = false; + existing.data.draft = ''; + existing.data.revision = ''; existing.data.loading = true; existing.data.error = null; focusWindow(existing); @@ -187,6 +192,10 @@ function openPreview(entry, sourceWindowId = state.focusedId) { entry, sourceWindowId, content: '', + editable: false, + editing: false, + draft: '', + revision: '', loading: true, error: null } @@ -268,8 +277,17 @@ function renderWindow(win) { focusWindow(win); render(); }); - titlebar.querySelector('[data-action="close"]').addEventListener('click', (event) => { + titlebar.querySelector('[data-action="close"]').addEventListener('click', async (event) => { event.stopPropagation(); + if (win.kind === 'preview' && win.data.editing && win.data.draft !== win.data.content) { + const discard = await showConfirmDialog( + 'Ungespeicherte Änderungen', + 'Die Änderungen an dieser Datei wurden noch nicht gespeichert.', + '', + { confirmLabel: 'Verwerfen', danger: true } + ); + if (!discard) return; + } state.windows = state.windows.filter((item) => item.id !== win.id); state.focusedId = state.windows.at(-1)?.id || null; render(); @@ -635,22 +653,54 @@ function renderPreview(body, win) { } const entry = data.entry; const navigation = previewNavigation(data); + const canEdit = data.editable && !isReadOnly(); body.innerHTML = `
- - + +
${escapeHtml(entry?.name || data.name || 'Vorschau')} - ${escapeHtml(formatBytes(data.size || entry?.size || 0))} +
+ ${escapeHtml(formatBytes(data.size || entry?.size || 0))} + ${canEdit && !data.editing ? `` : ''} + ${data.editing ? `` : ''} +
${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)); + body.querySelector('[data-action="preview-edit"]')?.addEventListener('click', () => { + data.editing = true; + data.draft = data.content || ''; + render(); + }); + body.querySelector('[data-action="preview-cancel"]')?.addEventListener('click', () => { + data.editing = false; + data.draft = ''; + render(); + }); + const editor = body.querySelector('.preview-editor'); + if (editor) { + editor.addEventListener('input', () => { + data.draft = editor.value; + }); + editor.addEventListener('keydown', (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault(); + savePreview(win, editor.value); + } + }); + requestAnimationFrame(() => editor.focus()); + } + body.querySelector('[data-action="preview-save"]')?.addEventListener('click', () => savePreview(win, editor?.value ?? data.draft ?? '')); } function renderPreviewContent(data) { + if (data.editing) { + return ``; + } if (data.type === 'markdown') { return `
${renderMarkdown(data.content || '')}
`; } @@ -702,6 +752,10 @@ async function loadPreview(win, entry) { win.data.extension = result.extension; win.data.rawUrl = result.rawUrl; win.data.content = result.content; + win.data.editable = Boolean(result.editable); + win.data.editing = false; + win.data.draft = ''; + win.data.revision = result.revision || ''; } catch (error) { win.data.error = error.message; } finally { @@ -710,6 +764,43 @@ async function loadPreview(win, entry) { } } +async function savePreview(win, content) { + if (win.data.saving || !win.data.editable || isReadOnly()) return; + win.data.saving = true; + render(); + const body = new URLSearchParams(); + body.set('content', content); + body.set('revision', win.data.revision || ''); + appendCsrf(body); + try { + const result = await api(apiUrl(`preview.php?path=${encodeURIComponent(win.data.entry.path)}`), { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body + }); + win.data.content = result.content; + win.data.draft = ''; + win.data.editing = false; + win.data.revision = result.revision; + win.data.size = result.size; + win.data.entry = { ...win.data.entry, size: result.size, modifiedAt: result.modifiedAt }; + const source = state.windows.find((item) => item.id === win.data.sourceWindowId && item.kind === 'explorer'); + const sourceEntry = source?.data.entries?.find((item) => item.path === result.path); + if (sourceEntry) { + sourceEntry.size = result.size; + sourceEntry.modifiedAt = result.modifiedAt; + } + if (state.selectedEntry?.path === result.path) { + state.selectedEntry = win.data.entry; + } + } catch (error) { + showError(error); + } finally { + win.data.saving = false; + render(); + } +} + function renderMarkdown(content) { const blocks = []; let listItems = []; @@ -2644,7 +2735,7 @@ function windowTitle(win) { return `Explorer · ${win.data.path || ''}`; } if (win.kind === 'preview') { - return `Vorschau · ${win.data.entry?.name || ''}`; + return `${win.data.editing ? 'Editor' : 'Vorschau'} · ${win.data.entry?.name || ''}`; } return win.title; } diff --git a/server/public/styles.css b/server/public/styles.css index 9603363..3667c9e 100644 --- a/server/public/styles.css +++ b/server/public/styles.css @@ -1264,6 +1264,27 @@ padding-bottom: 6px; } +.u-nav .preview-actions { + align-items: center; + display: flex; + gap: 6px; + justify-content: flex-end; + white-space: nowrap; +} + +.u-nav .preview-actions button { + align-items: center; + display: inline-flex; + font-size: 12px; + gap: 5px; + min-height: 28px; +} + +.u-nav .preview-actions .unav-icon { + height: 14px; + width: 14px; +} + .u-nav .preview-nav { display: inline-flex; gap: 4px; @@ -1295,6 +1316,27 @@ white-space: pre; } +.u-nav .preview-editor { + background: var(--surface-2); + border: 1px solid var(--accent); + border-radius: 7px; + color: var(--text); + font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + margin: 0; + min-height: 0; + outline: 0; + overflow: auto; + padding: 10px; + resize: none; + tab-size: 2; + white-space: pre; + width: 100%; +} + +.u-nav .preview-editor:focus { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent); +} + .u-nav .preview-rendered, .u-nav .preview-table-wrap, .u-nav .preview-media, diff --git a/u-navigator.plg b/u-navigator.plg index 2f67859..6c07fcd 100644 --- a/u-navigator.plg +++ b/u-navigator.plg @@ -1,7 +1,7 @@ - + @@ -24,7 +24,7 @@ https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package; -8c943cbf6a929bfb8e3249c901fb3869 +3fa2ea5659712c219ce073b7a16410c5