Add multi-select drag and drop
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
PLUGIN="u-navigator"
|
PLUGIN="u-navigator"
|
||||||
VERSION="2026.06.23.r036"
|
VERSION="2026.06.23.r037"
|
||||||
PKG_DIR="packages"
|
PKG_DIR="packages"
|
||||||
WORK_DIR=".plugin-build"
|
WORK_DIR=".plugin-build"
|
||||||
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
PKG_NAME="${PLUGIN}-${VERSION}.tgz"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+140
-32
@@ -90,6 +90,8 @@ function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
|
|||||||
path: initialPath,
|
path: initialPath,
|
||||||
entries: [],
|
entries: [],
|
||||||
selectedPath: null,
|
selectedPath: null,
|
||||||
|
selectedPaths: [],
|
||||||
|
selectionAnchorPath: null,
|
||||||
view: 'icons',
|
view: 'icons',
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null
|
error: null
|
||||||
@@ -271,7 +273,7 @@ function renderExplorer(body, win) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
|
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
|
||||||
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
|
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
|
||||||
${data.loading ? '<p class="muted">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list')}
|
${data.loading ? '<p class="muted">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -326,9 +328,9 @@ function renderExplorer(body, win) {
|
|||||||
lockExplorerScroll(win, body);
|
lockExplorerScroll(win, body);
|
||||||
startItemPointerDrag(event, win, entry);
|
startItemPointerDrag(event, win, entry);
|
||||||
});
|
});
|
||||||
row.addEventListener('click', () => {
|
row.addEventListener('click', (event) => {
|
||||||
if (shouldSuppressItemActivation()) return;
|
if (shouldSuppressItemActivation()) return;
|
||||||
selectEntry(win, entry, body);
|
selectEntry(win, entry, body, event);
|
||||||
});
|
});
|
||||||
row.addEventListener('dblclick', () => {
|
row.addEventListener('dblclick', () => {
|
||||||
if (shouldSuppressItemActivation()) return;
|
if (shouldSuppressItemActivation()) return;
|
||||||
@@ -341,22 +343,22 @@ function renderExplorer(body, win) {
|
|||||||
row.addEventListener('contextmenu', (event) => {
|
row.addEventListener('contextmenu', (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
selectEntry(win, entry);
|
selectEntry(win, entry, null, event);
|
||||||
showContextMenu(event, win, entry);
|
showContextMenu(event, win, entry);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFileEntries(entries, view) {
|
function renderFileEntries(entries, view, selectedPaths = new Set()) {
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
|
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
|
||||||
}
|
}
|
||||||
return view === 'icons' ? renderFileIcons(entries) : renderFileTable(entries);
|
return view === 'icons' ? renderFileIcons(entries, selectedPaths) : renderFileTable(entries, selectedPaths);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFileTable(entries) {
|
function renderFileTable(entries, selectedPaths) {
|
||||||
const rows = entries.map((entry) => `
|
const rows = entries.map((entry) => `
|
||||||
<tr class="file-row ${entry.path === state.selectedEntry?.path ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
<tr class="file-row ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
||||||
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
|
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
|
||||||
<td>${entry.type}</td>
|
<td>${entry.type}</td>
|
||||||
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
|
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
|
||||||
@@ -372,9 +374,9 @@ function renderFileTable(entries) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFileIcons(entries) {
|
function renderFileIcons(entries, selectedPaths) {
|
||||||
const items = entries.map((entry) => `
|
const items = entries.map((entry) => `
|
||||||
<button type="button" class="file-row icon-item ${entry.path === state.selectedEntry?.path ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
<button type="button" class="file-row icon-item ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
||||||
<span class="file-icon ${escapeAttr(entry.type)}" aria-label="${escapeAttr(icons[entry.type] || 'Item')}"></span>
|
<span class="file-icon ${escapeAttr(entry.type)}" aria-label="${escapeAttr(icons[entry.type] || 'Item')}"></span>
|
||||||
<span class="icon-name">${escapeHtml(entry.name)}</span>
|
<span class="icon-name">${escapeHtml(entry.name)}</span>
|
||||||
<span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : entry.type}</span>
|
<span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : entry.type}</span>
|
||||||
@@ -838,6 +840,7 @@ async function loadExplorer(win, targetPath) {
|
|||||||
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`));
|
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`));
|
||||||
win.data.path = result.path;
|
win.data.path = result.path;
|
||||||
win.data.entries = result.entries;
|
win.data.entries = result.entries;
|
||||||
|
reconcileExplorerSelection(win);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
win.data.error = error.message;
|
win.data.error = error.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1126,13 +1129,13 @@ async function refreshAfterJob(job) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectEntry(win, entry, scrollBody = null) {
|
function selectEntry(win, entry, scrollBody = null, event = null) {
|
||||||
if (state.suppressClickPath === entry.path) {
|
if (state.suppressClickPath === entry.path) {
|
||||||
state.suppressClickPath = null;
|
state.suppressClickPath = null;
|
||||||
restoreExplorerScroll(win, scrollBody);
|
restoreExplorerScroll(win, scrollBody);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
win.data.selectedPath = entry.path;
|
applyExplorerSelection(win, entry, event);
|
||||||
state.selectedEntry = entry;
|
state.selectedEntry = entry;
|
||||||
focusWindow(win);
|
focusWindow(win);
|
||||||
updateSelectedRows();
|
updateSelectedRows();
|
||||||
@@ -1140,6 +1143,66 @@ function selectEntry(win, entry, scrollBody = null) {
|
|||||||
restoreExplorerScroll(win, scrollBody);
|
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() {
|
function shouldSuppressItemActivation() {
|
||||||
if (Date.now() <= state.suppressItemActivationUntil) {
|
if (Date.now() <= state.suppressItemActivationUntil) {
|
||||||
recordDebug('item.activation.suppressed', { until: state.suppressItemActivationUntil });
|
recordDebug('item.activation.suppressed', { until: state.suppressItemActivationUntil });
|
||||||
@@ -1161,8 +1224,11 @@ function focusWindow(win) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectedRows() {
|
function updateSelectedRows() {
|
||||||
for (const row of workspace.querySelectorAll('.file-row')) {
|
for (const win of state.windows.filter((item) => item.kind === 'explorer')) {
|
||||||
row.classList.toggle('selected', row.dataset.path === state.selectedEntry?.path);
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,6 +1255,8 @@ function handleTypeAheadKey(event) {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
state.typeAhead = { key, windowId: win.id, at: now };
|
state.typeAhead = { key, windowId: win.id, at: now };
|
||||||
win.data.selectedPath = match.path;
|
win.data.selectedPath = match.path;
|
||||||
|
win.data.selectedPaths = [match.path];
|
||||||
|
win.data.selectionAnchorPath = match.path;
|
||||||
state.selectedEntry = match;
|
state.selectedEntry = match;
|
||||||
focusWindow(win);
|
focusWindow(win);
|
||||||
updateSelectedRows();
|
updateSelectedRows();
|
||||||
@@ -1231,10 +1299,12 @@ function updatePropertiesWindows() {
|
|||||||
function startItemPointerDrag(event, win, entry) {
|
function startItemPointerDrag(event, win, entry) {
|
||||||
if (event.button !== 0 || !entry) return;
|
if (event.button !== 0 || !entry) return;
|
||||||
|
|
||||||
|
const items = dragItemsForEntry(win, entry);
|
||||||
recordDebug('pointer.start', {
|
recordDebug('pointer.start', {
|
||||||
windowId: win.id,
|
windowId: win.id,
|
||||||
path: entry.path,
|
path: entry.path,
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
|
count: items.length,
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY
|
y: event.clientY
|
||||||
});
|
});
|
||||||
@@ -1243,6 +1313,8 @@ function startItemPointerDrag(event, win, entry) {
|
|||||||
path: entry.path,
|
path: entry.path,
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
type: entry.type,
|
type: entry.type,
|
||||||
|
items,
|
||||||
|
paths: items.map((item) => item.path),
|
||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
active: false,
|
active: false,
|
||||||
@@ -1271,8 +1343,8 @@ function updateItemPointerDrag(event) {
|
|||||||
drag.active = true;
|
drag.active = true;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
document.body.classList.add('u-nav-dragging');
|
document.body.classList.add('u-nav-dragging');
|
||||||
markDraggingRow(drag.path, true);
|
markDraggingRows(drag.paths, true);
|
||||||
markDropCandidates(drag.path, true);
|
markDropCandidates(drag.paths, true);
|
||||||
updatePointerDropTarget(event);
|
updatePointerDropTarget(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,8 +1357,8 @@ async function finishItemPointerDrag(event, move, up) {
|
|||||||
document.body.classList.remove('u-nav-dragging');
|
document.body.classList.remove('u-nav-dragging');
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
|
|
||||||
markDraggingRow(drag.path, false);
|
markDraggingRows(drag.paths, false);
|
||||||
markDropCandidates(drag.path, false);
|
markDropCandidates(drag.paths, false);
|
||||||
state.pointerDrag = null;
|
state.pointerDrag = null;
|
||||||
|
|
||||||
if (!drag.active) {
|
if (!drag.active) {
|
||||||
@@ -1299,6 +1371,7 @@ async function finishItemPointerDrag(event, move, up) {
|
|||||||
const target = findPointerDropTarget(event);
|
const target = findPointerDropTarget(event);
|
||||||
recordDebug('pointer.finish', {
|
recordDebug('pointer.finish', {
|
||||||
source: drag.path,
|
source: drag.path,
|
||||||
|
sources: drag.paths,
|
||||||
target: target ? { windowId: target.windowId, path: target.path, rowPath: target.row?.dataset?.path || null } : null,
|
target: target ? { windowId: target.windowId, path: target.path, rowPath: target.row?.dataset?.path || null } : null,
|
||||||
copy: event.altKey,
|
copy: event.altKey,
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
@@ -1309,26 +1382,52 @@ async function finishItemPointerDrag(event, move, up) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const destination = joinPath(target.path, drag.name);
|
const jobs = drag.items.map((item) => ({
|
||||||
|
item,
|
||||||
|
destination: joinPath(target.path, item.name)
|
||||||
|
}));
|
||||||
recordDebug('pointer.destination', {
|
recordDebug('pointer.destination', {
|
||||||
source: drag.path,
|
source: drag.path,
|
||||||
|
sources: drag.paths,
|
||||||
targetPath: target.path,
|
targetPath: target.path,
|
||||||
destination,
|
destinations: jobs.map((job) => job.destination),
|
||||||
targetIsRow: Boolean(target.row),
|
targetIsRow: Boolean(target.row),
|
||||||
copy: event.altKey
|
copy: event.altKey
|
||||||
});
|
});
|
||||||
if (destination === drag.path || target.path === drag.path) {
|
validatePointerDropJobs(jobs, target.path);
|
||||||
throw new Error('Quelle und Ziel sind identisch.');
|
for (const job of jobs) {
|
||||||
|
await createJob(event.altKey ? 'copy' : 'move', job.item.path, job.destination, [drag.sourceWindowId, target.windowId]);
|
||||||
}
|
}
|
||||||
if (drag.type === 'directory' && isPathInside(target.path, drag.path)) {
|
|
||||||
throw new Error('Ein Ordner kann nicht in sich selbst verschoben werden.');
|
|
||||||
}
|
|
||||||
await createJob(event.altKey ? 'copy' : 'move', drag.path, destination, [drag.sourceWindowId, target.windowId]);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(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) {
|
function updatePointerDropTarget(event) {
|
||||||
clearPointerDropTarget();
|
clearPointerDropTarget();
|
||||||
const target = findPointerDropTarget(event);
|
const target = findPointerDropTarget(event);
|
||||||
@@ -1386,10 +1485,10 @@ function findPointerDropTarget(event) {
|
|||||||
if (!candidateRow || candidateRow.dataset.type !== 'directory') {
|
if (!candidateRow || candidateRow.dataset.type !== 'directory') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (candidateRow.dataset.path === drag?.path) {
|
if (drag?.paths?.includes(candidateRow.dataset.path)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (drag?.type === 'directory' && candidateRow.dataset.path.startsWith(`${drag.path.replace(/\/$/, '')}/`)) {
|
if (!isValidPointerTargetPath(candidateRow.dataset.path, drag)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (candidateRow.closest?.('.window') !== windowEl) {
|
if (candidateRow.closest?.('.window') !== windowEl) {
|
||||||
@@ -1408,23 +1507,32 @@ function findPointerDropTarget(event) {
|
|||||||
return { windowId: win.id, path: win.data.path, row: null };
|
return { windowId: win.id, path: win.data.path, row: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDraggingRow(path, dragging) {
|
function markDraggingRows(paths, dragging) {
|
||||||
|
const pathSet = new Set(paths || []);
|
||||||
for (const row of workspace.querySelectorAll('.file-row')) {
|
for (const row of workspace.querySelectorAll('.file-row')) {
|
||||||
if (row.dataset.path === path) {
|
if (pathSet.has(row.dataset.path)) {
|
||||||
row.classList.toggle('dragging', dragging);
|
row.classList.toggle('dragging', dragging);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDropCandidates(sourcePath, active) {
|
function markDropCandidates(sourcePaths, active) {
|
||||||
const source = state.pointerDrag;
|
const source = state.pointerDrag;
|
||||||
for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) {
|
for (const row of workspace.querySelectorAll('.file-row[data-type="directory"]')) {
|
||||||
const path = row.dataset.path || '';
|
const path = row.dataset.path || '';
|
||||||
const valid = path !== sourcePath && !(source?.type === 'directory' && path.startsWith(`${sourcePath.replace(/\/$/, '')}/`));
|
const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
|
||||||
row.classList.toggle('drop-candidate', active && valid);
|
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) {
|
function lockExplorerScroll(win, body) {
|
||||||
win.data.lockScroll = true;
|
win.data.lockScroll = true;
|
||||||
win.data.lockScrollTop = body.scrollTop;
|
win.data.lockScrollTop = body.scrollTop;
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "u-navigator">
|
<!ENTITY name "u-navigator">
|
||||||
<!ENTITY author "Michael Roll">
|
<!ENTITY author "Michael Roll">
|
||||||
<!ENTITY version "2026.06.23.r036">
|
<!ENTITY version "2026.06.23.r037">
|
||||||
<!ENTITY package "&name;-&version;.tgz">
|
<!ENTITY package "&name;-&version;.tgz">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/u-navigator.plg">
|
||||||
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
<!ENTITY support "https://git.casaderoll.de/michael/Unraid-Navigator">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/&package;">
|
<FILE Name="/boot/config/plugins/&name;/&package;">
|
||||||
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
<URL>https://git.casaderoll.de/michael/Unraid-Navigator/raw/branch/main/packages/&package;</URL>
|
||||||
<MD5>5ddb77ea5e8b2d4d9ca48060a002c8c3</MD5>
|
<MD5>6ae4016e68bf99a8cb0c64714b8cb647</MD5>
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Run="/bin/bash">
|
<FILE Run="/bin/bash">
|
||||||
|
|||||||
Reference in New Issue
Block a user