diff --git a/build-plugin.sh b/build-plugin.sh
index c96a11b..bda7840 100755
--- a/build-plugin.sh
+++ b/build-plugin.sh
@@ -2,7 +2,7 @@
set -eu
PLUGIN="u-navigator"
-VERSION="2026.06.23.r001"
+VERSION="2026.06.23.r002"
PKG_DIR="packages"
WORK_DIR=".plugin-build"
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
diff --git a/packages/u-navigator-2026.06.23.r001.tgz b/packages/u-navigator-2026.06.23.r001.tgz
deleted file mode 100644
index 8bb9ee6..0000000
Binary files a/packages/u-navigator-2026.06.23.r001.tgz and /dev/null differ
diff --git a/packages/u-navigator-2026.06.23.r002.tgz b/packages/u-navigator-2026.06.23.r002.tgz
new file mode 100644
index 0000000..50fcc6b
Binary files /dev/null and b/packages/u-navigator-2026.06.23.r002.tgz differ
diff --git a/server/public/app.js b/server/public/app.js
index 725cc0d..71bf1c1 100644
--- a/server/public/app.js
+++ b/server/public/app.js
@@ -8,6 +8,8 @@ const state = {
selectedEntry: null,
contextMenu: null,
draggedEntry: null,
+ pointerDrag: null,
+ suppressClickPath: null,
jobs: new Map(),
nextWindowId: 1,
zIndex: 20
@@ -233,41 +235,16 @@ function renderExplorer(body, win) {
for (const row of body.querySelectorAll('.file-row')) {
const entry = data.entries.find((item) => item.path === row.dataset.path);
- row.addEventListener('pointerdown', () => lockExplorerScroll(win, body));
+ 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('dragstart', (event) => {
- state.draggedEntry = { path: entry.path, name: entry.name, sourceWindowId: win.id };
- setEffectAllowed(event);
- row.classList.add('dragging');
- });
- row.addEventListener('dragend', () => {
- row.classList.remove('dragging');
- setTimeout(() => {
- state.draggedEntry = null;
- }, 0);
- });
- row.addEventListener('dragover', (event) => {
- if (entry.type === 'directory') {
- event.preventDefault();
- event.stopPropagation();
- setDropEffect(event, event.altKey ? 'copy' : 'move');
- }
- });
- row.addEventListener('drop', async (event) => {
- if (entry.type !== 'directory') return;
- event.preventDefault();
- event.stopPropagation();
- try {
- await handleDrop(event, win, entry.path);
- } catch (error) {
- showError(error);
- }
- });
row.addEventListener('contextmenu', (event) => {
event.preventDefault();
event.stopPropagation();
@@ -283,7 +260,7 @@ function renderFileTable(entries) {
}
const rows = entries.map((entry) => `
-
+
| ${icons[entry.type] || 'Item'}${escapeHtml(entry.name)} |
${entry.type} |
${entry.type === 'file' ? formatBytes(entry.size) : ''} |
@@ -528,6 +505,11 @@ async function pollJob(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);
@@ -563,6 +545,121 @@ function updatePropertiesWindows() {
}
}
+function startItemPointerDrag(event, win, entry) {
+ if (event.button !== 0) return;
+
+ 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 });
+}
+
+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;
+ }
+
+ state.suppressClickPath = drag.path;
+ const target = findPointerDropTarget(event);
+ 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);
+ await reloadExplorerWindows([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) {
+ const element = document.elementFromPoint(event.clientX, event.clientY);
+ const windowEl = element?.closest?.('.window');
+ if (!windowEl) return null;
+
+ const win = state.windows.find((item) => item.id === windowEl.dataset.windowId);
+ if (!win || win.kind !== 'explorer') return null;
+
+ const row = element.closest?.('.file-row');
+ 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[data-path="${cssEscape(path)}"]`)) {
+ row.classList.toggle('dragging', dragging);
+ }
+}
+
function lockExplorerScroll(win, body) {
win.data.lockScroll = true;
win.data.lockScrollTop = body.scrollTop;
@@ -695,6 +792,13 @@ function escapeAttr(value) {
return escapeHtml(value);
}
+function cssEscape(value) {
+ if (window.CSS?.escape) {
+ return CSS.escape(String(value));
+ }
+ return String(value).replace(/["\\]/g, '\\$&');
+}
+
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
diff --git a/server/public/styles.css b/server/public/styles.css
index 6031804..635ed91 100644
--- a/server/public/styles.css
+++ b/server/public/styles.css
@@ -229,16 +229,30 @@
.u-nav .file-row:hover,
.u-nav .file-row.selected,
-.u-nav .file-row.dragging {
+.u-nav .file-row.dragging,
+.u-nav .file-row.drop-target {
background: var(--surface-2);
}
.u-nav .file-row:hover td,
.u-nav .file-row.selected td,
-.u-nav .file-row.dragging td {
+.u-nav .file-row.dragging td,
+.u-nav .file-row.drop-target td {
background: var(--surface-2) !important;
}
+.u-nav .file-row {
+ user-select: none;
+}
+
+.u-nav .file-row.dragging {
+ opacity: 0.55;
+}
+
+.u-nav-dragging {
+ cursor: grabbing;
+}
+
.u-nav .name-cell {
align-items: center;
display: flex;
diff --git a/u-navigator.plg b/u-navigator.plg
index 2a1ba12..f4d4fb4 100644
--- a/u-navigator.plg
+++ b/u-navigator.plg
@@ -1,7 +1,7 @@
-
+
]>
@@ -14,7 +14,7 @@
https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;
-54c2deddb4643c26d51b86aca896b52d
+0a9e56a6a4ddf7648388b25a3dedb264