aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/webdav.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/ext/webdav.js')
-rw-r--r--modules/ext/webdav.js515
1 files changed, 425 insertions, 90 deletions
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js
index b4dfa6f..eea513b 100644
--- a/modules/ext/webdav.js
+++ b/modules/ext/webdav.js
@@ -46,8 +46,11 @@ class WebDAVModule {
'webdav-backup': 'Backup entire VFS to WebDAV as a dated JSON snapshot',
'webdav-restore': 'Restore VFS from a WebDAV backup file',
'webdav-backup-list': 'List available backup files on WebDAV',
- 'webdav-explorer': 'webdav-explorer [path] — Interactive remote file browser',
- 'webdav-browser': 'webdav-browser [path] — Visual WebDAV file explorer modal',
+ 'webdav-browser': 'webdav-browser [path] — Modern WebDAV file-tree browser with bulk operations',
+ 'webdav-rm': 'webdav-rm <path> — Delete a remote file or directory',
+ 'webdav-mkdir': 'webdav-mkdir <path> — Create a remote directory',
+ 'webdav-rename': 'webdav-rename <old> <new> — Rename or move a remote file/dir',
+ 'webdav-move': 'webdav-move <old> <new> — Alias for webdav-rename',
};
}
@@ -127,7 +130,11 @@ class WebDAVModule {
}
async _listDir(path) {
- // Try PROPFIND first (works with WebDAV servers), fallback to nginx autoindex
+ // For WebX11 spaces, skip PROPFIND (nginx returns 302) and use __list__ directly
+ if (this._spaceName) {
+ return this._listDirViaAutoIndex(path);
+ }
+ // Non-nginx: try PROPFIND first, fallback to autoindex
try {
const response = await this._webdavRequest('PROPFIND', path, {
headers: { 'Depth': '1' }
@@ -137,15 +144,24 @@ class WebDAVModule {
return this._parsePropfind(text);
}
} catch (e) { /* PROPFIND failed, try autoindex */ }
- // Fallback: use nginx __list__ endpoint
return this._listDirViaAutoIndex(path);
}
async _listDirViaAutoIndex(path) {
- const baseUrl = this.config.url.replace(/\/+$/, '') + '/';
- const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/';
- // Use __list__ for directory listing
- const listUrl = baseUrl.replace(/\/spaces\/([^/]+)\//, '/spaces/$1/__list__/') + urlPath.replace(/^\//, '');
+ if (!this._spaceName) {
+ throw new Error('WebDAV: cannot use __list__ without space name');
+ }
+ // Derive host from configured URL to support different environments
+ let host = 'https://webx11.duckdns.org';
+ if (this.config && this.config.url) {
+ try {
+ const u = new URL(this.config.url);
+ host = u.origin;
+ } catch (e) {}
+ }
+ const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : '/';
+ // Avoid double slash when path starts with /
+ const listUrl = host + '/spaces/' + this._spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/'));
try {
const resp = await fetch(listUrl, { method: 'GET' });
if (!resp.ok) throw new Error('Autoindex failed: HTTP ' + resp.status);
@@ -158,15 +174,14 @@ class WebDAVModule {
_parseAutoindex(html) {
const entries = [];
- // Parse nginx autoindex HTML format
- const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+(\d{2}-[A-Za-z]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g;
+ // Parse nginx autoindex HTML format — flexible regex that doesn't depend on date locale
+ const linkRegex = /<a href="([^"]+)">([^<]+)<\/a>\s+([\w]{2}-[\w]{3}-\d{4}\s+\d{2}:\d{2})\s+([\d\w.-]+)/g;
let match;
while ((match = linkRegex.exec(html)) !== null) {
const href = match[1];
const name = match[2].replace(/\/$/, '');
const sizeStr = match[4];
- if (name === '..' || name === '../') continue;
- if (name === '__list__') continue;
+ if (name === '..' || name === '../' || name === '__list__') continue;
const isDir = href.endsWith('/');
let size = 0;
if (!isDir && sizeStr !== '-') {
@@ -181,22 +196,40 @@ class WebDAVModule {
}
// Fallback: if regex failed, try simpler parse
if (entries.length === 0) {
- const simpleRegex = /<a href="([^"]+)">/g;
+ // Try broadest: extract all hrefs
+ const broadRegex = /<a href="([^"]+)"[^>]*>/g;
const names = [];
- while ((match = simpleRegex.exec(html)) !== null) {
+ while ((match = broadRegex.exec(html)) !== null) {
const href = match[1];
if (href === '../' || href === '/' || href === './') continue;
if (href.startsWith('?')) continue;
names.push(href);
}
- for (const href of names) {
- const isDir = href.endsWith('/');
- entries.push({
- name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href),
- type: isDir ? 'dir' : 'file',
- size: 0,
- href: href
- });
+ if (names.length > 0) {
+ for (const href of names) {
+ const isDir = href.endsWith('/');
+ entries.push({
+ name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href),
+ type: isDir ? 'dir' : 'file',
+ size: 0,
+ href: href
+ });
+ }
+ } else {
+ // Last resort: match any <a href="...">
+ const lastRegex = /<a href="([^"]+)"/g;
+ while ((match = lastRegex.exec(html)) !== null) {
+ const href = match[1];
+ if (href === '../' || href === '/' || href === './') continue;
+ if (href.startsWith('?')) continue;
+ const isDir = href.endsWith('/');
+ entries.push({
+ name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href),
+ type: isDir ? 'dir' : 'file',
+ size: 0,
+ href: href
+ });
+ }
}
}
return entries;
@@ -529,16 +562,27 @@ class WebDAVModule {
try {
await self._ensureDir('backups');
// Build structured export: root + currentPath + aliases + moduleRegistry
- const exportData = {
- type: 'webx11-backup',
- version: 2,
- savedAt: new Date().toISOString(),
- root: self.terminal.fs.root,
- currentPath: self.terminal.fs.currentPath,
- aliases: self.terminal.moduleManager.aliases || {},
- moduleRegistry: self.terminal.moduleManager.moduleRegistry || []
- };
+ let exportData;
+ try {
+ exportData = {
+ type: 'webx11-backup',
+ version: 2,
+ savedAt: new Date().toISOString(),
+ root: self.terminal.fs.root,
+ currentPath: self.terminal.fs.currentPath,
+ aliases: self.terminal.moduleManager.aliases || {},
+ moduleRegistry: self.terminal.moduleManager.moduleRegistry || []
+ };
+ } catch (e) {
+ self.terminal.print('Failed to read VFS: ' + e.message, 'error');
+ return;
+ }
const serialized = JSON.stringify(exportData, null, 2);
+ // Size check: abort if >5MB
+ if (serialized.length > 5 * 1024 * 1024) {
+ self.terminal.print('Backup too large: ' + self._formatSize(serialized.length) + ' (>5MB limit). Try reducing VFS size first.', 'error');
+ return;
+ }
// Gzip payload
let payload = serialized;
let isGzipped = false;
@@ -550,7 +594,9 @@ class WebDAVModule {
const buf = await resp.arrayBuffer();
payload = new Uint8Array(buf);
isGzipped = true;
- } catch (e) { /* fallback to plain */ }
+ } catch (e) { self.terminal.print('Gzip unavailable, falling back to plain JSON', 'info'); }
+ } else {
+ self.terminal.print('CompressionStream not supported, saving as plain JSON', 'info');
}
// Encrypt if requested
if (encryptPass) {
@@ -671,37 +717,74 @@ class WebDAVModule {
}
};
- // --- webdav-browser: visual modal file browser ---
+ // --- webdav-browser: modern visual file browser ---
this.commands['webdav-browser'] = async (args) => {
const startPath = args[0] || self._browsingPath || '/';
await self.openWebdavBrowser(startPath);
};
- // --- webdav-explorer: interactive file browser via WebDAV ---
- this.commands['webdav-explorer'] = async (args) => {
- const startPath = args[0] || '/';
+ // --- webdav-rm: delete remote file/dir ---
+ this.commands['webdav-rm'] = async (args) => {
+ if (!args[0]) {
+ self.terminal.print('Usage: webdav-rm <remote-path>', 'warning');
+ return;
+ }
try {
- const entries = await self._listDir(startPath);
- self.terminal.print('📁 WebDAV: ' + startPath, 'info');
- if (entries.length === 0) {
- self.terminal.print('(empty)', 'info');
- return;
+ const ok = await self._deleteFile(args[0]);
+ if (ok) {
+ self.terminal.print('Deleted: ' + args[0], 'success');
+ } else {
+ self.terminal.print('Delete failed: resource may not exist', 'error');
}
- entries.forEach(e => {
- const icon = e.type === 'dir' ? '📁' : '📄';
- const size = e.type === 'file' ? ' (' + self._formatSize(e.size) + ')' : '/';
- self.terminal.print(icon + ' ' + e.name + (e.type === 'dir' ? '/' : '') + size);
+ } catch (e) {
+ self.terminal.print('Delete error: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-mkdir: create remote directory ---
+ this.commands['webdav-mkdir'] = async (args) => {
+ if (!args[0]) {
+ self.terminal.print('Usage: webdav-mkdir <remote-path>', 'warning');
+ return;
+ }
+ try {
+ const ok = await self._ensureDir(args[0]);
+ if (ok) {
+ self.terminal.print('Created directory: ' + args[0], 'success');
+ } else {
+ self.terminal.print('Failed to create directory', 'error');
+ }
+ } catch (e) {
+ self.terminal.print('Mkdir error: ' + e.message, 'error');
+ }
+ };
+
+ // --- webdav-rename: rename/move remote file or dir (MOVE method) ---
+ this.commands['webdav-rename'] = async (args) => {
+ if (!args[0] || !args[1]) {
+ self.terminal.print('Usage: webdav-rename <old-path> <new-path>', 'warning');
+ self.terminal.print('Renames or moves a file/directory using WebDAV MOVE', 'info');
+ return;
+ }
+ try {
+ const resp = await self._webdavRequest('MOVE', args[0], {
+ headers: { 'Destination': args[1] }
});
- self.terminal.print('---');
- self.terminal.print('↳ Navigate: webdav-explorer <dir> | Pull: webdav-pull <file>', 'text-muted');
- self.terminal.print(entries.length + ' item(s)', 'text-muted');
+ if (resp.ok || resp.status === 201 || resp.status === 204) {
+ self.terminal.print('Renamed: ' + args[0] + ' → ' + args[1], 'success');
+ } else {
+ self.terminal.print('Rename failed: HTTP ' + resp.status, 'error');
+ }
} catch (e) {
- self.terminal.print('WebDAV error: ' + e.message, 'error');
+ self.terminal.print('Rename error: ' + e.message, 'error');
}
};
+
+ // --- webdav-move: alias for webdav-rename ---
+ this.commands['webdav-move'] = this.commands['webdav-rename'];
}
- /* ── Visual WebDAV Browser Modal ── */
+ /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */
async openWebdavBrowser(path) {
if (!this.config || !this.config.url) {
@@ -710,6 +793,7 @@ class WebDAVModule {
}
this._browsingPath = path;
const self = this;
+ this._selected = {};
// Remove existing modal if any
const existing = document.getElementById('webdav-browser-modal');
@@ -724,6 +808,12 @@ class WebDAVModule {
errorMsg = e.message;
}
+ // Sort: dirs first, then files, alphabetically
+ entries.sort(function(a, b) {
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+
// Build modal
const overlay = document.createElement('div');
overlay.id = 'webdav-browser-modal';
@@ -733,73 +823,237 @@ class WebDAVModule {
const content = document.createElement('div');
content.className = 'modal-content';
- content.style.cssText = 'max-width:650px; max-height:80vh; width:92%;';
+ content.style.cssText = 'max-width:750px; max-height:85vh; width:94%;';
// Header
const header = document.createElement('div');
header.className = 'modal-header';
- header.innerHTML = '<span>📁 WebDAV Browser</span><span style="cursor:pointer" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.remove();var t=window.webx11;if(t&&t.inputField)t.inputField.focus();">✕</span>';
+ header.innerHTML = '<span>📁 WebDAV File Tree</span>' +
+ '<span style="display:flex;gap:6px;align-items:center">' +
+ '<span id="sel-count" style="font-size:0.8em;color:var(--text-muted)"></span>' +
+ '<span style="cursor:pointer" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.remove();var t=window.webx11;if(t&&t.inputField)t.inputField.focus();">✕</span></span>';
content.appendChild(header);
// Body
const body = document.createElement('div');
body.className = 'modal-body';
- body.style.cssText = 'padding:8px; overflow-y:auto; flex-grow:1;';
+ body.style.cssText = 'padding:0; overflow-y:auto; flex-grow:1;';
if (errorMsg) {
const errDiv = document.createElement('div');
errDiv.style.cssText = 'color:var(--error-color); padding:16px; text-align:center;';
errDiv.textContent = '❌ ' + errorMsg;
body.appendChild(errDiv);
- } else if (entries.length === 0) {
- const emptyDiv = document.createElement('div');
- emptyDiv.className = 'text-center';
- emptyDiv.style.cssText = 'padding:20px; color:var(--text-muted);';
- emptyDiv.textContent = '(empty) — upload files using the button below';
- body.appendChild(emptyDiv);
} else {
- // Sort: dirs first, then files, alphabetically
- entries.sort(function(a, b) {
- if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
- return a.name.localeCompare(b.name);
- });
+ // Select-all row
+ const selectAllRow = document.createElement('div');
+ selectAllRow.style.cssText = 'display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid #222;font-size:0.8em;color:var(--text-muted);gap:8px;';
+ const selAllCheck = document.createElement('input');
+ selAllCheck.type = 'checkbox';
+ selAllCheck.style.cssText = 'accent-color:var(--success-color);width:16px;height:16px;cursor:pointer;';
+ selAllCheck.onchange = function() {
+ const checked = this.checked;
+ entries.forEach(function(e) { self._selected[e.name] = checked; });
+ document.querySelectorAll('.wdav-checkbox').forEach(function(c) { c.checked = checked; });
+ self._updateSelectionCount();
+ };
+ selectAllRow.appendChild(selAllCheck);
+ selectAllRow.appendChild(document.createTextNode('Select All (' + entries.length + ' items)'));
+ body.appendChild(selectAllRow);
- var frag = document.createDocumentFragment();
+ // File rows
+ const listContainer = document.createElement('div');
+ let rowIndex = 0;
entries.forEach(function(entry) {
- var item = document.createElement('div');
- item.className = entry.type === 'dir' ? 'file-item dir' : 'file-item';
- item.style.cssText = 'display:flex; justify-content:space-between; align-items:center; padding:8px; border-bottom:1px solid #111; cursor:pointer; min-height:36px;';
- item.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; };
- item.onmouseout = function() { this.style.background = ''; };
+ var row = document.createElement('div');
+ row.className = 'wdav-row';
+ row.style.cssText = 'display:flex;align-items:center;padding:7px 10px;border-bottom:1px solid #1a1a1a;cursor:default;min-height:38px;gap:8px;';
+ row.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; };
+ row.onmouseout = function() { this.style.background = ''; };
+ row.dataset.index = rowIndex++;
+
+ // Checkbox
+ var cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.className = 'wdav-checkbox';
+ cb.style.cssText = 'accent-color:var(--success-color);width:15px;height:15px;cursor:pointer;flex-shrink:0;';
+ cb.onchange = function(e) {
+ self._selected[entry.name] = this.checked;
+ self._updateSelectionCount();
+ e.stopPropagation();
+ };
+ row.appendChild(cb);
+ // Icon
+ var iconSpan = document.createElement('span');
+ iconSpan.textContent = entry.type === 'dir' ? '📁' : '📄';
+ iconSpan.style.cssText = 'flex-shrink:0;font-size:1.1em;';
+
+ // Filename (editable on dblclick)
var nameSpan = document.createElement('span');
- var icon = entry.type === 'dir' ? '📁' : '📄';
- nameSpan.textContent = icon + ' ' + entry.name + (entry.type === 'dir' ? '/' : '');
+ nameSpan.textContent = entry.name + (entry.type === 'dir' ? '/' : '');
+ nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.9em;cursor:pointer;';
+ nameSpan.title = entry.name;
+ nameSpan.ondblclick = function(e) {
+ e.stopPropagation();
+ var input = document.createElement('input');
+ input.type = 'text';
+ input.value = entry.name;
+ input.style.cssText = 'width:100%;background:#1a1a1a;border:1px solid var(--success-color);color:var(--text-color);padding:2px 6px;border-radius:3px;font-size:0.9em;';
+ input.onblur = function() {
+ var newName = this.value.trim();
+ if (newName && newName !== entry.name) {
+ var parentPath = path.replace(/\/+$/, '') || '';
+ var oldFull = parentPath + '/' + entry.name;
+ var newFull = parentPath + '/' + newName;
+ self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } })
+ .then(function(r) {
+ if (r.ok || r.status === 201 || r.status === 204) {
+ self.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success');
+ self.openWebdavBrowser(path);
+ } else {
+ self.terminal.print('Rename failed: HTTP ' + r.status, 'error');
+ nameSpan.textContent = entry.name;
+ }
+ });
+ } else {
+ nameSpan.textContent = entry.name;
+ }
+ };
+ input.onkeydown = function(ev) {
+ if (ev.key === 'Enter') this.blur();
+ if (ev.key === 'Escape') { nameSpan.textContent = entry.name; }
+ };
+ nameSpan.textContent = '';
+ nameSpan.appendChild(input);
+ input.focus();
+ input.select();
+ };
+ // Click = navigate dir / preview file
+ if (entry.type === 'dir') {
+ row.onclick = function(e) {
+ if (e.target.type === 'checkbox') return;
+ self.openWebdavBrowser(entry.href);
+ };
+ } else {
+ row.onclick = function(e) {
+ if (e.target.type === 'checkbox') return;
+ self.previewWebdavFile(entry);
+ };
+ }
+ // Size
var sizeSpan = document.createElement('span');
- sizeSpan.style.cssText = 'font-size:0.8em; color:var(--text-muted);';
+ sizeSpan.style.cssText = 'font-size:0.75em;color:var(--text-muted);flex-shrink:0;min-width:60px;text-align:right;';
if (entry.type === 'file') {
sizeSpan.textContent = self._formatSize(entry.size);
}
- item.appendChild(nameSpan);
- item.appendChild(sizeSpan);
+ // Context menu on right-click
+ row.oncontextmenu = function(e) {
+ e.preventDefault();
+ self._showContextMenu(e, entry, path);
+ };
- if (entry.type === 'dir') {
- item.onclick = function() { self.openWebdavBrowser(entry.href); };
- } else {
- item.onclick = function() { self.previewWebdavFile(entry); };
- // Right-click or context menu could download — but for simplicity, long tap or regular click previews
- // Double-click to download
- item.ondblclick = function() { self.downloadFromWebdav(entry); };
- }
- frag.appendChild(item);
+ row.appendChild(iconSpan);
+ row.appendChild(nameSpan);
+ row.appendChild(sizeSpan);
+ listContainer.appendChild(row);
});
- body.appendChild(frag);
+ body.appendChild(listContainer);
+
+ // Keyboard navigation
+ body.tabIndex = -1;
+ body.style.outline = 'none';
+ var focusIdx = 0;
+ body.onkeydown = function(e) {
+ var rows = listContainer.querySelectorAll('.wdav-row');
+ if (e.key === 'ArrowDown' || e.key === 'j') {
+ e.preventDefault();
+ focusIdx = Math.min(focusIdx + 1, rows.length - 1);
+ rows[focusIdx].scrollIntoView({ block: 'nearest' });
+ rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; });
+ } else if (e.key === 'ArrowUp' || e.key === 'k') {
+ e.preventDefault();
+ focusIdx = Math.max(focusIdx - 1, 0);
+ rows[focusIdx].scrollIntoView({ block: 'nearest' });
+ rows.forEach(function(r, i) { r.style.borderLeft = i === focusIdx ? '2px solid var(--success-color)' : ''; });
+ } else if (e.key === ' ') {
+ e.preventDefault();
+ var cb = rows[focusIdx]?.querySelector('.wdav-checkbox');
+ if (cb) { cb.checked = !cb.checked; cb.onchange({ stopPropagation: function(){} }); }
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ rows[focusIdx]?.click();
+ }
+ };
+ setTimeout(function() { body.focus(); }, 100);
}
content.appendChild(body);
+ // Bulk action bar
+ const bulkBar = document.createElement('div');
+ bulkBar.id = 'wdav-bulk-bar';
+ bulkBar.style.cssText = 'display:none;padding:8px 10px;border-top:1px solid var(--success-color);background:var(--bg-tertiary);gap:6px;align-items:center;flex-wrap:wrap;';
+ bulkBar.innerHTML = '<span id="bulk-count" style="font-size:0.85em;color:var(--text-color);flex:1;">0 selected</span>' +
+ '<button class="btn" id="bulk-delete" style="font-size:0.8em;padding:3px 10px;">🗑 Delete</button>' +
+ '<button class="btn" id="bulk-move" style="font-size:0.8em;padding:3px 10px;">📦 Move</button>' +
+ '<button class="btn" id="bulk-download" style="font-size:0.8em;padding:3px 10px;">⬇ Download</button>';
+ self._updateSelectionCount = function() {
+ var count = Object.values(self._selected).filter(Boolean).length;
+ var el = document.getElementById('sel-count');
+ if (el) el.textContent = count > 0 ? count + ' selected' : '';
+ var bar = document.getElementById('wdav-bulk-bar');
+ var cnt = document.getElementById('bulk-count');
+ if (bar) bar.style.display = count > 0 ? 'flex' : 'none';
+ if (cnt) cnt.textContent = count + ' selected';
+ };
+
+ // Wire bulk actions
+ setTimeout(function() {
+ document.getElementById('bulk-delete')?.addEventListener('click', function() {
+ var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; });
+ if (!names.length || !confirm('Delete ' + names.length + ' item(s) from ' + path + '?')) return;
+ names.forEach(function(name) {
+ var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name;
+ self._deleteFile(fullPath).then(function(ok) {
+ if (ok) self.terminal.print('Deleted: ' + name, 'success');
+ else self.terminal.print('Delete failed: ' + name, 'error');
+ });
+ });
+ setTimeout(function() { self.openWebdavBrowser(path); }, 500);
+ });
+ document.getElementById('bulk-move')?.addEventListener('click', function() {
+ var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; });
+ if (!names.length) return;
+ var target = prompt('Move ' + names.length + ' item(s) to directory:', '/');
+ if (!target) return;
+ target = target.replace(/\/+$/, '');
+ names.forEach(function(name) {
+ var oldFull = (path.replace(/\/+$/, '') || '') + '/' + name;
+ var newFull = target + '/' + name;
+ self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } })
+ .then(function(r) {
+ if (r.ok || r.status === 201 || r.status === 204) self.terminal.print('Moved: ' + name, 'success');
+ else self.terminal.print('Move failed: ' + name + ' (HTTP ' + r.status + ')', 'error');
+ });
+ });
+ setTimeout(function() { self.openWebdavBrowser(path); }, 500);
+ });
+ document.getElementById('bulk-download')?.addEventListener('click', function() {
+ var names = Object.entries(self._selected).filter(function(e) { return e[1]; }).map(function(e) { return e[0]; });
+ names.forEach(function(name) {
+ var fullPath = (path.replace(/\/+$/, '') || '') + '/' + name;
+ // Find entry from entries
+ var entry = entries.find(function(e) { return e.name === name; });
+ if (entry && entry.type === 'file') self.downloadFromWebdav(entry);
+ });
+ });
+ }, 50);
+
+ content.appendChild(bulkBar);
+
// Footer with breadcrumb + buttons
const footer = document.createElement('div');
footer.className = 'modal-footer';
@@ -815,17 +1069,30 @@ class WebDAVModule {
for (var i = 0; i < parts.length; i++) {
cumulative += '/' + parts[i];
var seg = parts[i];
+ breadHtml += ' <span style="color:var(--text-muted);font-size:0.7em;">▸</span> ';
breadHtml += '<span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'' + cumulative.replace(/'/g, "\\'") + '/\');return false;">' + seg + '</span>';
- if (i < parts.length - 1) breadHtml += ' ▸ ';
}
breadDiv.innerHTML = breadHtml;
- // Attach nav function to modal element for breadcrumb clicks
overlay.__nav = function(p) { self.openWebdavBrowser(p); };
// Button group
var btnGroup = document.createElement('div');
btnGroup.style.cssText = 'display:flex; gap:4px; flex-shrink:0;';
+ var mkdirBtn = document.createElement('button');
+ mkdirBtn.className = 'btn';
+ mkdirBtn.textContent = '📁+';
+ mkdirBtn.title = 'New Directory';
+ mkdirBtn.onclick = function() {
+ var name = prompt('Directory name:');
+ if (name) {
+ self._ensureDir((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) {
+ if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); }
+ else self.terminal.print('Failed to create directory', 'error');
+ });
+ }
+ };
+
var uploadBtn = document.createElement('button');
uploadBtn.className = 'btn';
uploadBtn.textContent = '⬆ Upload';
@@ -842,6 +1109,7 @@ class WebDAVModule {
closeBtn.textContent = 'Close';
closeBtn.onclick = function() { self.closeWebdavBrowser(); };
+ btnGroup.appendChild(mkdirBtn);
btnGroup.appendChild(uploadBtn);
btnGroup.appendChild(refreshBtn);
btnGroup.appendChild(closeBtn);
@@ -853,7 +1121,71 @@ class WebDAVModule {
overlay.appendChild(content);
document.body.appendChild(overlay);
- this.terminal.print('📂 Opened WebDAV browser: ' + path, 'info');
+ this.terminal.print('📂 Opened WebDAV file browser: ' + path, 'info');
+ }
+
+ _showContextMenu(e, entry, currentPath) {
+ var existing = document.getElementById('wdav-context-menu');
+ if (existing) existing.remove();
+
+ var menu = document.createElement('div');
+ menu.id = 'wdav-context-menu';
+ menu.style.cssText = 'position:fixed;left:' + e.clientX + 'px;top:' + e.clientY + 'px;background:#111;border:1px solid #333;border-radius:6px;padding:4px 0;z-index:1001;min-width:160px;box-shadow:0 4px 12px rgba(0,0,0,0.5);';
+ menu.onclick = function() { this.remove(); };
+
+ var items = [];
+ if (entry.type === 'file') {
+ items.push({ label: '📄 Preview', fn: function() { this.previewWebdavFile(entry); }.bind(this) });
+ items.push({ label: '⬇ Download', fn: function() { this.downloadFromWebdav(entry); }.bind(this) });
+ items.push({ label: '📋 Copy Path', fn: function() {
+ var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name;
+ navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this));
+ }.bind(this) });
+ } else {
+ items.push({ label: '📂 Open', fn: function() { this.openWebdavBrowser(entry.href); }.bind(this) });
+ items.push({ label: '📋 Copy Path', fn: function() {
+ var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name + '/';
+ navigator.clipboard.writeText(full).then(function() { this.terminal.print('Path copied: ' + full, 'success'); }.bind(this));
+ }.bind(this) });
+ }
+ items.push({ label: '✏️ Rename', fn: function() {
+ var newName = prompt('Rename "' + entry.name + '" to:');
+ if (newName && newName !== entry.name) {
+ var oldFull = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name;
+ var newFull = (currentPath.replace(/\/+$/, '') || '') + '/' + newName;
+ this._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }).then(function(r) {
+ if (r.ok || r.status === 201 || r.status === 204) {
+ this.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success');
+ this.openWebdavBrowser(currentPath);
+ } else { this.terminal.print('Rename failed: HTTP ' + r.status, 'error'); }
+ }.bind(this));
+ }
+ }.bind(this) });
+ items.push({ label: '🗑 Delete', fn: function() {
+ if (!confirm('Delete ' + entry.name + '?')) return;
+ var full = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name;
+ this._deleteFile(full).then(function(ok) {
+ if (ok) { this.terminal.print('Deleted: ' + entry.name, 'success'); this.openWebdavBrowser(currentPath); }
+ else { this.terminal.print('Delete failed', 'error'); }
+ }.bind(this));
+ }.bind(this) });
+
+ items.forEach(function(item) {
+ var div = document.createElement('div');
+ div.textContent = item.label;
+ div.style.cssText = 'padding:6px 14px;cursor:pointer;font-size:0.85em;';
+ div.onmouseover = function() { this.style.background = '#222'; };
+ div.onmouseout = function() { this.style.background = ''; };
+ div.onclick = function(e) { e.stopPropagation(); menu.remove(); item.fn(); };
+ menu.appendChild(div);
+ });
+
+ document.body.appendChild(menu);
+ // Close on click outside
+ var closeMenu = function(ev) {
+ if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', closeMenu); }
+ };
+ setTimeout(function() { document.addEventListener('click', closeMenu); }, 10);
}
closeWebdavBrowser() {
@@ -949,7 +1281,10 @@ class WebDAVModule {
async downloadFromWebdav(entry) {
try {
- var resp = await this._webdavRequest('GET', entry.href);
+ // Construct path from current browsing path + entry name (avoid autoindex href fragility)
+ var basePath = (this._browsingPath || '/').replace(/\/+$/, '');
+ var fullPath = basePath + '/' + entry.name;
+ var resp = await this._webdavRequest('GET', fullPath);
if (!resp.ok) { this.terminal.print('Download failed: HTTP ' + resp.status, 'error'); return; }
var content = await resp.text();
var blob = new Blob([content], { type: 'application/octet-stream' });
@@ -957,7 +1292,7 @@ class WebDAVModule {
var a = document.createElement('a');
a.href = url;
a.download = entry.name;
- a.style.display = 'none';
+ a.style.cssText = 'display:none;';
document.body.appendChild(a);
a.click();
setTimeout(function() {