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 = `
${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