diff options
Diffstat (limited to 'modules/ext/webdav.js')
| -rw-r--r-- | modules/ext/webdav.js | 515 |
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() { |