diff options
Diffstat (limited to 'modules/ext')
| -rw-r--r-- | modules/ext/webdav.js | 1028 |
1 files changed, 718 insertions, 310 deletions
diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index 464db16..a84eb12 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -32,8 +32,29 @@ class WebDAVModule { return ''; } + _extractSpaceNameFromUrl(url) { + try { + const u = new URL(url); + const parts = u.pathname.split('/').filter(p => p); + const idx = parts.indexOf('spaces'); + if (idx >= 0 && idx + 1 < parts.length) return parts[idx + 1]; + } catch (e) {} + return ''; + } + + _resolveSpaceName() { + // Prefer the configured URL's space, fall back to the page URL + if (this.config && this.config.url) { + const fromUrl = this._extractSpaceNameFromUrl(this.config.url); + if (fromUrl) return fromUrl; + } + return this._spaceName; + } + _detectSessionUser() { try { + const lsUser = localStorage.getItem('webx11_username'); + if (lsUser && lsUser.trim()) return lsUser.trim(); const cookies = document.cookie.split(';').map(c => c.trim()); for (const c of cookies) { if (c.startsWith('webx11_user=')) { @@ -44,12 +65,15 @@ class WebDAVModule { return null; } + _detectSessionToken() { + try { + return localStorage.getItem('webx11_webdav_token') || ''; + } catch (e) { return ''; } + } + _promptForToken(spaceName) { - // Use textarea+execCommand for mobile WebView compatibility const input = window.prompt('Enter your WebDAV token for space "' + spaceName + '":', ''); - if (input && input.trim()) { - return input.trim(); - } + if (input && input.trim()) return input.trim(); return null; } @@ -189,7 +213,8 @@ class WebDAVModule { } async _listDirViaAutoIndex(path) { - if (!this._spaceName) { + const spaceName = this._resolveSpaceName(); + if (!spaceName) { throw new Error('WebDAV: cannot use __list__ without space name'); } // Derive host from configured URL to support different environments @@ -202,7 +227,7 @@ class WebDAVModule { } 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, '/')); + const listUrl = host + '/spaces/' + 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); @@ -216,11 +241,12 @@ class WebDAVModule { _parseAutoindex(html) { const entries = []; // 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; + const linkRegex = /<a href="([^"]+)">([^\u003c]+)<\/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 dateStr = match[3]; const sizeStr = match[4]; if (name === '..' || name === '../' || name === '__list__') continue; const isDir = href.endsWith('/'); @@ -232,13 +258,14 @@ class WebDAVModule { name: name, type: isDir ? 'dir' : 'file', size: size, + mtime: this._parseNginxDate(dateStr), href: href }); } // Fallback: if regex failed, try simpler parse if (entries.length === 0) { // Try broadest: extract all hrefs - const broadRegex = /<a href="([^"]+)"[^>]*>/g; + const broadRegex = /<a href="([^"]+)"[^\u003e]*>/g; const names = []; while ((match = broadRegex.exec(html)) !== null) { const href = match[1]; @@ -253,6 +280,7 @@ class WebDAVModule { name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), type: isDir ? 'dir' : 'file', size: 0, + mtime: null, href: href }); } @@ -268,6 +296,7 @@ class WebDAVModule { name: decodeURIComponent(href.replace(/\/$/, '').split('/').pop() || href), type: isDir ? 'dir' : 'file', size: 0, + mtime: null, href: href }); } @@ -275,7 +304,21 @@ class WebDAVModule { } return entries; } - + + _parseNginxDate(dateStr) { + if (!dateStr) return null; + // nginx default: 02-Aug-2026 14:30 + try { + const months = { 'Jan':0,'Feb':1,'Mar':2,'Apr':3,'May':4,'Jun':5,'Jul':6,'Aug':7,'Sep':8,'Oct':9,'Nov':10,'Dec':11 }; + const m = dateStr.match(/^(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})$/); + if (m) { + const d = new Date(Date.UTC(parseInt(m[3]), months[m[2]], parseInt(m[1]), parseInt(m[4]), parseInt(m[5]))); + if (!isNaN(d.getTime())) return d.toISOString(); + } + } catch (e) {} + return null; + } + _parsePropfind(xml) { const parser = new DOMParser(); const doc = parser.parseFromString(xml, 'text/xml'); @@ -301,31 +344,24 @@ class WebDAVModule { } async _ensureDir(remotePath) { - // Try MKCOL — ignore 405 (already exists) and 409 (parent missing) + // Try MKCOL — ignore 405 (already exists) const response = await this._webdavRequest('MKCOL', remotePath); if (response.ok || response.status === 405 || response.status === 301 || response.status === 302) return true; if (response.status === 409) { - // Parent missing — try creating parent recursively - const parts = remotePath.replace(/\/$/, '').split('/').filter(Boolean); - if (parts.length > 1) { - const parent = parts.slice(0, -1).join('/'); - await this._ensureDir(parent); - // Retry MKCOL - const retry = await this._webdavRequest('MKCOL', remotePath); - if (retry.ok || retry.status === 405) return true; - } - // Try creating path component by component as a fallback + // Parent missing — create each missing component once, bottom-up. + // (Previously this recursed + retried + looped, producing an MKCOL + // storm of 6-8 requests per call that delayed uploads.) + const parts = remotePath.replace(/\/+$/, '').split('/').filter(Boolean); let current = ''; + let created = false; for (const part of parts) { current += '/' + part; try { const attempt = await this._webdavRequest('MKCOL', current); - if (!attempt.ok && attempt.status !== 405) { - // Ignore — it likely exists already or we can't create it - } - } catch (e) { /* ignore */ } + if (attempt.ok || attempt.status === 405) created = true; + } catch (e) { /* ignore — component may exist */ } } - return true; + return created; } return false; } @@ -398,17 +434,44 @@ class WebDAVModule { // --- webdav-config: set WebDAV endpoint and credentials --- this.commands['webdav-config'] = async (args) => { - // No args: auto-detect space name and try to configure from token + const autoUrlFor = (space) => 'https://webx11.duckdns.org/spaces/' + space + '/'; + + // No args: prefer logged-in user's space, fall back to URL space if (!args || args.length === 0) { + const sessionUser = self._detectSessionUser(); + const sessionToken = self._detectSessionToken(); + + if (sessionUser && sessionToken) { + // Auto-mount the logged-in user's own space + const autoUrl = autoUrlFor(sessionUser); + self.config = { + url: autoUrl, + username: sessionUser, + password: sessionToken + }; + await self._saveConfig(); + self._autoConfigured = true; + self.terminal.print('Auto-configured WebDAV for logged-in user: ' + sessionUser, 'success'); + try { + const resp = await self._webdavRequest('OPTIONS', '/'); + self.terminal.print('Connected: ' + resp.status + ' ' + resp.statusText, 'success'); + self.terminal.print('WebDAV configured: ' + self.config.url, 'success'); + } catch (e) { + self.terminal.print('Connection test failed: ' + e.message, 'warning'); + } + return; + } + const spaceName = self._spaceName; if (!spaceName) { - self.terminal.print('Could not detect space name from URL.', 'warning'); + self.terminal.print('Could not detect user or space from URL.', 'warning'); self.terminal.print('Usage: webdav-config <url> [username] [password]', 'info'); return; } - // Auto-configure URL from space name - const autoUrl = 'https://webx11.duckdns.org/spaces/' + spaceName + '/'; + // Not logged in: pre-fill URL/username for this space and ask for token + const sessionUserOther = self._detectSessionUser(); + const autoUrl = autoUrlFor(spaceName); let token = null; // Check SecretVault first @@ -426,32 +489,6 @@ class WebDAVModule { token = localStorage.getItem('webx11_spaces_' + spaceName); } - // If still no token, use session-aware prompting - if (!token) { - const sessionUser = self._detectSessionUser(); - if (sessionUser && sessionUser === spaceName) { - self.terminal.print('👋 Welcome back, ' + sessionUser + '!', 'success'); - self.terminal.print('No saved WebDAV config found. Enter your token:', 'info'); - } else if (sessionUser) { - self.terminal.print('👋 You are logged in as "' + sessionUser + '" — this space is "' + spaceName + '".', 'info'); - self.terminal.print('Enter the WebDAV token for space "' + spaceName + '":', 'info'); - } else { - self.terminal.print('No saved WebDAV config found for space "' + spaceName + '".', 'info'); - self.terminal.print('Enter the WebDAV token (get it from the WebX11 dashboard):', 'info'); - } - self.terminal.print('', 'text-muted'); - self.terminal.print(' webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'success'); - self.terminal.print('', 'text-muted'); - // Pre-configure URL + username so user only needs token - self.config = { - url: autoUrl, - username: spaceName, - password: '' - }; - await self._saveConfig(); - return; - } - if (token) { self.config = { url: autoUrl, @@ -470,18 +507,21 @@ class WebDAVModule { self.terminal.print('Config saved but connection may need CORS or network access.', 'warning'); } } else { - self.terminal.print('No WebDAV token found for space: ' + spaceName, 'warning'); - self.terminal.print('Get your token from the WebX11 dashboard.', 'info'); - self.terminal.print('Auto-setting URL: ' + autoUrl, 'info'); - self.terminal.print('Then configure with: webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'info'); - // At minimum set the URL + if (sessionUserOther) { + self.terminal.print('👋 You are logged in as "' + sessionUserOther + '" — this space is "' + spaceName + '".', 'info'); + } else { + self.terminal.print('Not logged in. Please enter the WebDAV token for space "' + spaceName + '":', 'info'); + } + self.terminal.print('', 'text-muted'); + self.terminal.print(' webdav-config ' + autoUrl + ' ' + spaceName + ' <your-token>', 'success'); + self.terminal.print('', 'text-muted'); + // Pre-configure URL + username so user only needs token self.config = { url: autoUrl, username: spaceName, password: '' }; await self._saveConfig(); - self._autoConfigured = true; } return; } @@ -793,7 +833,7 @@ class WebDAVModule { // --- webdav-browser: modern visual file browser --- this.commands['webdav-browser'] = async (args) => { - const startPath = args[0] || self._browsingPath || '/'; + const startPath = (args && args[0]) || self._browsingPath || '/'; await self.openWebdavBrowser(startPath); }; @@ -860,6 +900,50 @@ class WebDAVModule { /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */ + _getFileIcon(name) { + if (!name) return '📄'; + const ext = name.split('.').pop().toLowerCase(); + const icons = { + html: '🌐', htm: '🌐', css: '🎨', js: '📜', json: '📋', yaml: '📋', yml: '📋', + ts: '📘', py: '🐍', sh: '🔧', md: '📝', txt: '📄', + png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️', + mp4: '🎬', webm: '🎬', mp3: '🎵', wav: '🎵', ogg: '🎵', + pdf: '📕', doc: '📘', docx: '📘', xls: '📗', xlsx: '📗', + zip: '📦', tar: '📦', gz: '📦', rar: '📦', '7z': '📦', + php: '🌐', sql: '🗃️', db: '🗃️', log: '📋' + }; + return icons[ext] || '📄'; + } + + _formatDate(iso) { + if (!iso) return ''; + try { + const d = new Date(iso); + return d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + } catch (e) { return iso; } + } + + _escapeHtml(text) { + return (text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + } + + async _createRemoteFile(remotePath) { + try { + const parentDir = remotePath.split('/').slice(0, -1).join('/') || '/'; + await this._ensureDir(parentDir); + const resp = await this._webdavRequest('PUT', remotePath, { + body: '', + headers: { 'Content-Type': 'text/plain' } + }); + return resp.ok || resp.status === 201 || resp.status === 204; + } catch (e) { + this.terminal.print('Create file error: ' + e.message, 'error'); + return false; + } + } + + /* ── Modern WebDAV File-Tree Browser with Bulk Ops ── */ + async openWebdavBrowser(path) { if (!this.config || !this.config.url) { if (this.terminal) this.terminal.print('WebDAV not configured. Use webdav-config first.', 'warning'); @@ -868,89 +952,360 @@ class WebDAVModule { this._browsingPath = path; const self = this; this._selected = {}; + this._browserSort = this._browserSort || { key: 'name', dir: 'asc' }; + this._browserFilter = this._browserFilter || ''; // Remove existing modal if any const existing = document.getElementById('webdav-browser-modal'); if (existing) existing.remove(); // Fetch listing - let entries = []; + let allEntries = []; let errorMsg = ''; try { - entries = await this._listDir(path); + allEntries = await this._listDir(path); } catch (e) { 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); - }); + // Hide dotfiles + allEntries = allEntries.filter(function(e) { return !e.name.startsWith('.'); }); + + if (errorMsg) { + const errOverlay = document.createElement('div'); + errOverlay.id = 'webdav-browser-modal'; + errOverlay.className = 'modal-overlay'; + errOverlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:0;'; + errOverlay.onclick = function(e) { if (e.target === this) this.remove(); }; + const errContent = document.createElement('div'); + errContent.className = 'modal-content'; + errContent.style.cssText = 'width:100vw; height:100vh; max-width:none; max-height:none; display:flex; flex-direction:column; overflow:hidden; border-radius:0;'; + const errDiv = document.createElement('div'); + errDiv.style.cssText = 'color:var(--error-color); padding:40px; text-align:center; font-size:1.05em;'; + errDiv.textContent = '❌ ' + errorMsg; + const errFooter = document.createElement('div'); + errFooter.className = 'modal-footer'; + errFooter.style.cssText = 'display:flex; justify-content:center; align-items:center; gap:8px; padding:12px; border-top:1px solid var(--border);'; + const backBtn = document.createElement('button'); + backBtn.className = 'btn'; + backBtn.textContent = '⬅ Back'; + backBtn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; + backBtn.onclick = function() { + const parentPath = path.replace(/\/[^\/]*\/?$/, '') || '/'; + self.openWebdavBrowser(parentPath); + }; + const closeBtn = document.createElement('button'); + closeBtn.className = 'btn'; + closeBtn.textContent = 'Close'; + closeBtn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; + closeBtn.onclick = function() { self.closeWebdavBrowser(); }; + errFooter.appendChild(backBtn); + errFooter.appendChild(closeBtn); + errContent.appendChild(errDiv); + errContent.appendChild(errFooter); + errOverlay.appendChild(errContent); + document.body.appendChild(errOverlay); + this.terminal.print('❌ WebDAV browser error: ' + errorMsg, 'error'); + return; + } + + // Sort: dirs first, then by current sort key + function sortEntries(entries) { + const sortKey = self._browserSort.key; + const dir = self._browserSort.dir; + return entries.slice().sort(function(a, b) { + if (a.type !== b.type) return a.type === 'dir' ? -1 : 1; + let cmp = 0; + if (sortKey === 'name') cmp = a.name.localeCompare(b.name); + else if (sortKey === 'size') cmp = (a.size || 0) - (b.size || 0); + else if (sortKey === 'date') cmp = new Date(a.mtime || 0) - new Date(b.mtime || 0); + if (cmp === 0) cmp = a.name.localeCompare(b.name); + return dir === 'asc' ? cmp : -cmp; + }); + } + + function filterEntries(entries) { + const f = (self._browserFilter || '').toLowerCase(); + if (!f) return entries; + return entries.filter(function(e) { return e.name.toLowerCase().indexOf(f) !== -1; }); + } + + function getEntries() { + return sortEntries(filterEntries(allEntries)); + } // Build modal const overlay = document.createElement('div'); overlay.id = 'webdav-browser-modal'; overlay.className = 'modal-overlay'; - overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:999;'; - overlay.onclick = function(e) { if (e.target === this) self.closeWebdavBrowser(); }; + overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:12px;'; + overlay.style.cssText = 'display:flex; justify-content:center; align-items:center; z-index:9999; padding:0;'; const content = document.createElement('div'); content.className = 'modal-content'; - content.style.cssText = 'max-width:750px; max-height:85vh; width:94%;'; + content.style.cssText = 'width:100vw; height:100vh; max-width:none; max-height:none; display:flex; flex-direction:column; overflow:hidden; border-radius:0;'; // Header const header = document.createElement('div'); header.className = 'modal-header'; - 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>'; + header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:14px; flex-wrap:wrap; padding:12px 14px;'; + header.innerHTML = '<span style="font-size:1.2em;">📁 WebDAV File Browser</span>' + + '<span style="display:flex;gap:8px;align-items:center">' + + '<span id="sel-count" style="font-size:0.9em;color:var(--text-muted)"></span>' + + '<button id="wdav-minimize" class="btn btn-sm" title="Minimize to floating button" style="font-size:0.85em;padding:3px 8px;">—</button>' + + '<button id="wdav-share" class="btn btn-sm" title="Share folder URL" style="font-size:0.85em;padding:3px 8px;">🔗</button>' + + '<span style="cursor:pointer;font-size:1.1em;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.remove();var f=document.getElementById(\'wdav-floating-restore\');if(f)f.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:0; overflow-y:auto; flex-grow:1;'; + // Toolbar - 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 { - // 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(); + const toolbar = document.createElement('div'); + toolbar.style.cssText = 'display:flex; gap:10px; align-items:center; padding:10px 14px; border-bottom:1px solid var(--border); flex-wrap:wrap; background:var(--bg-tertiary);'; + + // Breadcrumb + const breadDiv = document.createElement('div'); + breadDiv.id = 'wdav-breadcrumb'; + breadDiv.style.cssText = 'font-size:0.95em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:160px;'; + function renderBreadcrumb() { + const cleanPath = path.replace(/\/+$/, '') || '/'; + const parts = cleanPath.split('/').filter(Boolean); + let html = '📁 <span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'/\');return false;">~\u003c/span>'; + let cumulative = ''; + for (let i = 0; i < parts.length; i++) { + cumulative += '/' + parts[i]; + html += ' <span style="color:var(--text-muted);font-size:0.85em;">▸</span> '; + html += '<span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'' + cumulative.replace(/'/g, "\\'") + '/\');return false;">' + parts[i] + '</span>'; + } + breadDiv.innerHTML = html; + } + renderBreadcrumb(); + toolbar.appendChild(breadDiv); + + // Search + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.placeholder = '🔍 Search...'; + searchInput.value = self._browserFilter; + searchInput.style.cssText = 'background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:0.95em;min-width:160px;flex:1;'; + searchInput.oninput = function() { + self._browserFilter = this.value; + renderList(); + }; + toolbar.appendChild(searchInput); + + // Sort buttons + function makeSortBtn(key, label) { + const btn = document.createElement('button'); + btn.className = 'btn btn-sm'; + btn.style.cssText = 'font-size:0.85em; padding:5px 10px;'; + function update() { + const active = self._browserSort.key === key; + const arrow = active ? (self._browserSort.dir === 'asc' ? ' ▾' : ' ▴') : ''; + btn.textContent = label + arrow; + btn.style.background = active ? 'var(--accent)' : 'var(--bg-secondary)'; + btn.style.color = active ? '#000' : 'var(--text-primary)'; + } + btn.onclick = function() { + if (self._browserSort.key === key) { + self._browserSort.dir = self._browserSort.dir === 'asc' ? 'desc' : 'asc'; + } else { + self._browserSort.key = key; + self._browserSort.dir = 'asc'; + } + updateSortBtns(); + renderList(); }; - selectAllRow.appendChild(selAllCheck); - selectAllRow.appendChild(document.createTextNode('Select All (' + entries.length + ' items)')); - body.appendChild(selectAllRow); + btn.update = update; + return btn; + } + const sortName = makeSortBtn('name', 'Name'); + const sortDate = makeSortBtn('date', 'Date'); + const sortSize = makeSortBtn('size', 'Size'); + toolbar.appendChild(sortName); + toolbar.appendChild(sortDate); + toolbar.appendChild(sortSize); + + function updateSortBtns() { + sortName.update(); + sortDate.update(); + sortSize.update(); + } + updateSortBtns(); + + // View toggle: list vs tree (future) — placeholder for layout parity with dashboard + const listViewBtn = document.createElement('button'); + listViewBtn.className = 'btn btn-sm'; + listViewBtn.textContent = '📋'; + listViewBtn.title = 'List view'; + listViewBtn.style.cssText = 'font-size:0.85em; padding:5px 10px;'; + toolbar.appendChild(listViewBtn); + + content.appendChild(toolbar); + + // Main body: file list + preview side-by-side on wide screens + const mainBody = document.createElement('div'); + mainBody.style.cssText = 'display:flex; flex:1; min-height:0; overflow:hidden;'; + + const filePane = document.createElement('div'); + filePane.style.cssText = 'flex:1; min-width:0; min-height:0; overflow-y:auto; overflow-x:hidden;'; + + const listContainer = document.createElement('div'); + listContainer.id = 'wdav-list-container'; + + // Select-all row + const selectAllRow = document.createElement('div'); + selectAllRow.style.cssText = 'display:flex;align-items:center;padding:8px 14px;border-bottom:1px solid var(--border);font-size:0.9em;color:var(--text-muted);gap:10px;position:sticky;top:0;background:var(--bg-tertiary);z-index:2;'; + const selAllCheck = document.createElement('input'); + selAllCheck.type = 'checkbox'; + selAllCheck.style.cssText = 'accent-color:var(--success-color);width:18px;height:18px;cursor:pointer;'; + selAllCheck.onchange = function() { + const checked = this.checked; + getEntries().forEach(function(e) { self._selected[e.name] = checked; }); + renderList(); + self._updateSelectionCount(); + }; + selectAllRow.appendChild(selAllCheck); + const selAllLabel = document.createElement('span'); + selAllLabel.id = 'wdav-select-all-label'; + selAllLabel.textContent = 'Select All'; + selectAllRow.appendChild(selAllLabel); + listContainer.appendChild(selectAllRow); + + const fileListDiv = document.createElement('div'); + fileListDiv.id = 'wdav-file-list'; + listContainer.appendChild(fileListDiv); + filePane.appendChild(listContainer); + mainBody.appendChild(filePane); + content.appendChild(mainBody); + + // Bulk action bar + const bulkBar = document.createElement('div'); + bulkBar.id = 'wdav-bulk-bar'; + bulkBar.style.cssText = 'display:none;padding:10px 14px;border-top:1px solid var(--success-color);background:var(--bg-tertiary);gap:8px;align-items:center;flex-wrap:wrap;'; + bulkBar.innerHTML = '<span id="bulk-count" style="font-size:0.95em;color:var(--text-color);flex:1;">0 selected</span>' + + '<button class="btn" id="bulk-delete" style="font-size:0.9em;padding:5px 12px;">🗑 Delete</button>' + + '<button class="btn" id="bulk-move" style="font-size:0.9em;padding:5px 12px;">📦 Move</button>' + + '<button class="btn" id="bulk-download" style="font-size:0.9em;padding:5px 12px;">⬇ Download</button>'; + content.appendChild(bulkBar); + + // Footer buttons + const footer = document.createElement('div'); + footer.className = 'modal-footer'; + footer.style.cssText = 'display:flex; justify-content:flex-start; align-items:center; gap:8px; flex-wrap:wrap; padding:10px 14px; border-top:1px solid var(--border);'; + + function makeFootBtn(label, title, onClick) { + const btn = document.createElement('button'); + btn.className = 'btn'; + btn.textContent = label; + btn.title = title; + btn.style.cssText = 'font-size:0.9em; padding:6px 12px;'; + btn.onclick = onClick; + return btn; + } + + footer.appendChild(makeFootBtn('📁+ New Folder', 'Create directory', function() { + const 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'); + }); + } + })); + + footer.appendChild(makeFootBtn('📄 New File', 'Create empty file', function() { + const name = prompt('File name:'); + if (name) { + self._createRemoteFile((path.replace(/\/+$/, '') || '') + '/' + name).then(function(ok) { + if (ok) { self.terminal.print('Created: ' + name, 'success'); self.openWebdavBrowser(path); } + else self.terminal.print('Failed to create file', 'error'); + }); + } + })); + + footer.appendChild(makeFootBtn('⬆ Upload', 'Upload file', function() { self.uploadToWebdav(path); })); + footer.appendChild(makeFootBtn('🔄 Refresh', 'Refresh listing', function() { self.openWebdavBrowser(path); })); + footer.appendChild(makeFootBtn('Close', 'Close browser', function() { self.closeWebdavBrowser(); })); + + content.appendChild(footer); + + overlay.appendChild(content); + document.body.appendChild(overlay); + overlay.__nav = function(p) { self.openWebdavBrowser(p); }; + + // Minimize: collapse modal into a floating restore button + document.getElementById('wdav-minimize').onclick = function() { + overlay.style.display = 'none'; + var fl = document.getElementById('wdav-floating-restore'); + if (!fl) { + fl = document.createElement('div'); + fl.id = 'wdav-floating-restore'; + fl.textContent = '📁'; + fl.title = 'Restore WebDAV browser (' + path + ')'; + fl.style.cssText = 'position:fixed; right:16px; bottom:16px; z-index:10002; width:52px; height:52px; border-radius:50%; background:var(--accent,#4a9eff); color:#000; display:flex; align-items:center; justify-content:center; font-size:22px; cursor:pointer; box-shadow:0 4px 14px rgba(0,0,0,0.5);'; + fl.onclick = function() { + overlay.style.display = 'flex'; + fl.remove(); + self.openWebdavBrowser(path); + }; + document.body.appendChild(fl); + } + if (window.webx11 && window.webx11.inputField) window.webx11.inputField.focus(); + }; + + // Share: copy direct folder URL to clipboard + document.getElementById('wdav-share').onclick = function() { + const spaceName = self._resolveSpaceName(); + const cleanPath = (path || '/').replace(/\/+$/, '') + '/'; + const shareUrl = 'https://webx11.duckdns.org/spaces/' + (spaceName || '') + cleanPath; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(shareUrl).then(function() { + self.terminal.print('🔗 Shared URL copied: ' + shareUrl, 'success'); + }, function() { + self.terminal.print('🔗 Share URL: ' + shareUrl, 'info'); + }); + } else { + self.terminal.print('🔗 Share URL: ' + shareUrl, 'info'); + } + }; + + function renderList() { + const entries = getEntries(); + fileListDiv.innerHTML = ''; + selAllLabel.textContent = 'Select All (' + entries.length + ' items)'; + + // Parent ".." row + if (path !== '/' && path !== '') { + const parentPath = path.replace(/\/[^\/]*\/?$/, '') || '/'; + const parentRow = document.createElement('div'); + parentRow.style.cssText = 'display:flex;align-items:center;padding:9px 14px;border-bottom:1px solid var(--border);cursor:pointer;min-height:44px;gap:10px;'; + parentRow.onmouseover = function() { this.style.background = 'var(--bg-tertiary)'; }; + parentRow.onmouseout = function() { this.style.background = ''; }; + parentRow.innerHTML = '<span style="flex-shrink:0;font-size:1.35em;">📁</span><span style="flex:1;font-size:1em;">..</span>'; + parentRow.onclick = function() { self.openWebdavBrowser(parentPath); }; + fileListDiv.appendChild(parentRow); + } + + if (entries.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = 'text-align:center;padding:50px;color:var(--text-secondary);font-size:1.05em;'; + empty.textContent = 'Empty directory'; + fileListDiv.appendChild(empty); + return; + } - // File rows - const listContainer = document.createElement('div'); - let rowIndex = 0; entries.forEach(function(entry) { - var row = document.createElement('div'); + const 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.style.cssText = 'display:flex;align-items:center;padding:9px 14px;border-bottom:1px solid var(--border);cursor:default;min-height:44px;gap:10px;'; 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'); + const 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.style.cssText = 'accent-color:var(--success-color);width:18px;height:18px;cursor:pointer;flex-shrink:0;'; + cb.checked = !!self._selected[entry.name]; cb.onchange = function(e) { self._selected[entry.name] = this.checked; self._updateSelectionCount(); @@ -958,28 +1313,36 @@ class WebDAVModule { }; row.appendChild(cb); - // Icon - var iconSpan = document.createElement('span'); - iconSpan.textContent = entry.type === 'dir' ? '📁' : '📄'; - iconSpan.style.cssText = 'flex-shrink:0;font-size:1.1em;'; + const iconSpan = document.createElement('span'); + iconSpan.textContent = entry.type === 'dir' ? '📁' : self._getFileIcon(entry.name); + iconSpan.style.cssText = 'flex-shrink:0;font-size:1.35em;'; - // Filename (editable on dblclick) - var nameSpan = document.createElement('span'); + const nameSpan = document.createElement('span'); 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.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:1em;cursor:pointer;'; nameSpan.title = entry.name; + + const metaSpan = document.createElement('span'); + metaSpan.style.cssText = 'font-size:0.8em;color:var(--text-muted);flex-shrink:0;min-width:130px;text-align:right;display:flex;flex-direction:column;'; + if (entry.type === 'file') { + metaSpan.innerHTML = '<span>' + self._formatSize(entry.size) + '</span>' + (entry.mtime ? '<span>' + self._formatDate(entry.mtime) + '</span>' : ''); + } else if (entry.mtime) { + metaSpan.innerHTML = '<span>' + self._formatDate(entry.mtime) + '</span>'; + } + + // Inline rename on double click nameSpan.ondblclick = function(e) { e.stopPropagation(); - var input = document.createElement('input'); + const 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.style.cssText = 'width:100%;background:var(--bg-secondary);border:1px solid var(--success-color);color:var(--text-color);padding:3px 8px;border-radius:3px;font-size:0.95em;'; input.onblur = function() { - var newName = this.value.trim(); + const newName = this.value.trim(); if (newName && newName !== entry.name) { - var parentPath = path.replace(/\/+$/, '') || ''; - var oldFull = parentPath + '/' + entry.name; - var newFull = parentPath + '/' + newName; + const parentPath = path.replace(/\/+$/, '') || ''; + const oldFull = parentPath + '/' + entry.name; + const newFull = parentPath + '/' + newName; self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { @@ -1003,27 +1366,53 @@ class WebDAVModule { input.focus(); input.select(); }; - // Click = navigate dir / preview file + + // Click = navigate dir if (entry.type === 'dir') { row.onclick = function(e) { - if (e.target.type === 'checkbox') return; - self.openWebdavBrowser(entry.href); + if (e.target.type === 'checkbox' || e.target.closest('.wdav-preview-btn')) return; + // nginx autoindex hrefs are relative to the CURRENT dir — resolve against path + let target = entry.href; + if (target && !target.startsWith('/')) { + target = (path.replace(/\/+$/, '') || '') + '/' + target; + } + self.openWebdavBrowser(target); }; } else { row.onclick = function(e) { - if (e.target.type === 'checkbox') return; + if (e.target.type === 'checkbox' || e.target.closest('.wdav-preview-btn')) return; self.previewWebdavFile(entry); }; } - // Size - var sizeSpan = document.createElement('span'); - 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); - } + // Preview button next to file + const previewBtn = document.createElement('button'); + previewBtn.className = 'btn btn-sm wdav-preview-btn'; + previewBtn.textContent = '👁'; + previewBtn.title = 'Preview ' + entry.name; + previewBtn.style.cssText = 'font-size:0.85em; padding:3px 8px; flex-shrink:0; display:' + (entry.type === 'file' ? 'inline-block' : 'none') + ';'; + previewBtn.onclick = function(e) { + e.stopPropagation(); + self.previewWebdavFile(entry); + }; + + // Open in new tab button next to file + const newTabBtn = document.createElement('button'); + newTabBtn.className = 'btn btn-sm wdav-newtab-btn'; + newTabBtn.textContent = '⛶'; + newTabBtn.title = 'Open ' + entry.name + ' in new tab'; + newTabBtn.style.cssText = 'font-size:0.85em; padding:3px 8px; flex-shrink:0; display:' + (entry.type === 'file' ? 'inline-block' : 'none') + ';'; + newTabBtn.onclick = function(e) { + e.stopPropagation(); + self.previewInNewTab(entry); + }; + + // Double-click downloads files like dashboard + row.ondblclick = function(e) { + if (entry.type !== 'file' || e.target.type === 'checkbox' || e.target.tagName === 'INPUT' || e.target.closest('.wdav-preview-btn')) return; + self.downloadFromWebdav(entry); + }; - // Context menu on right-click row.oncontextmenu = function(e) { e.preventDefault(); self._showContextMenu(e, entry, path); @@ -1031,66 +1420,47 @@ class WebDAVModule { row.appendChild(iconSpan); row.appendChild(nameSpan); - row.appendChild(sizeSpan); - listContainer.appendChild(row); + row.appendChild(previewBtn); + row.appendChild(newTabBtn); + row.appendChild(metaSpan); + fileListDiv.appendChild(row); }); - 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'; + // Keyboard navigation + filePane.tabIndex = -1; + filePane.style.outline = 'none'; + let focusIdx = 0; + filePane.onkeydown = function(e) { + const rows = fileListDiv.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(); + const 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() { filePane.focus(); }, 100); // 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]; }); + const 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; + const 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'); @@ -1099,14 +1469,14 @@ class WebDAVModule { 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]; }); + const 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:', '/'); + let 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; + const oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; + const 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'); @@ -1116,88 +1486,29 @@ class WebDAVModule { 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]; }); + const 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; }); + const entry = allEntries.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'; - footer.style.cssText = 'display:flex; justify-content:space-between; align-items:center; gap:6px; flex-wrap:wrap; padding:8px 10px;'; - - // Breadcrumb - var breadDiv = document.createElement('div'); - breadDiv.style.cssText = 'font-size:0.8em; color:var(--info-color); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; min-width:0;'; - var cleanPath = path.replace(/\/+$/, '') || '/'; - var parts = cleanPath.split('/').filter(Boolean); - var breadHtml = '📁 <span style="cursor:pointer;text-decoration:underline;" onclick="var m=document.getElementById(\'webdav-browser-modal\');if(m)m.__nav(\'/\');return false;">/</span>'; - var cumulative = ''; - 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>'; - } - breadDiv.innerHTML = breadHtml; - 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'); - }); - } + self._updateSelectionCount = function() { + const 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'; }; - var uploadBtn = document.createElement('button'); - uploadBtn.className = 'btn'; - uploadBtn.textContent = '⬆ Upload'; - uploadBtn.onclick = function() { self.uploadToWebdav(path); }; - - var refreshBtn = document.createElement('button'); - refreshBtn.className = 'btn'; - refreshBtn.textContent = '🔄'; - refreshBtn.title = 'Refresh'; - refreshBtn.onclick = function() { self.openWebdavBrowser(path); }; - - var closeBtn = document.createElement('button'); - closeBtn.className = 'btn'; - closeBtn.textContent = 'Close'; - closeBtn.onclick = function() { self.closeWebdavBrowser(); }; - - btnGroup.appendChild(mkdirBtn); - btnGroup.appendChild(uploadBtn); - btnGroup.appendChild(refreshBtn); - btnGroup.appendChild(closeBtn); - - footer.appendChild(breadDiv); - footer.appendChild(btnGroup); - content.appendChild(footer); - - overlay.appendChild(content); - document.body.appendChild(overlay); - + renderList(); this.terminal.print('📂 Opened WebDAV file browser: ' + path, 'info'); } + _showContextMenu(e, entry, currentPath) { var existing = document.getElementById('wdav-context-menu'); if (existing) existing.remove(); @@ -1216,7 +1527,13 @@ class WebDAVModule { 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: '📂 Open', fn: function() { + var target = entry.href; + if (target && !target.startsWith('/')) { + target = (currentPath.replace(/\/+$/, '') || '') + '/' + target; + } + this.openWebdavBrowser(target); + }.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)); @@ -1269,6 +1586,27 @@ class WebDAVModule { this.terminal.print('WebDAV browser closed', 'text-muted'); } + previewInNewTab(entry) { + // Build direct URL to the file within the configured space + const spaceName = this._resolveSpaceName(); + let base = ''; + if (this.config && this.config.url) { + try { base = new URL(this.config.url).origin; } catch (e) {} + } + base = base || 'https://webx11.duckdns.org'; + let rel = entry.href || entry.name; + // href from autoindex is relative to the current directory — prepend browsing path + if (rel && !rel.startsWith('/')) { + const dir = (this._browsingPath || '/').replace(/\/+$/, '') + '/'; + rel = dir.replace(/^\/+/, '') + rel; + } + rel = rel.replace(/^\/+/, ''); + const url = base + '/spaces/' + (spaceName || '') + '/' + rel; + const w = window.open(url, '_blank'); + if (!w) this.terminal.print('Popup blocked — open manually: ' + url, 'warning'); + return url; + } + async previewWebdavFile(entry) { try { var basePath = (this._browsingPath || '/').replace(/\/+$/, ''); @@ -1279,13 +1617,24 @@ class WebDAVModule { var isImage = entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/i); var size = parseInt(resp.headers.get('content-length') || '0'); + // Ensure shared preview modal exists (defined in terminal.js) + var previewModal = document.getElementById('preview-modal'); + var previewTitle = document.getElementById('preview-title'); + var previewIframe = document.getElementById('preview-iframe'); + if (!previewModal || !previewTitle || !previewIframe) { + this.terminal.print('Preview modal not available.', 'error'); + return; + } + // Always render above the WebDAV browser modal (z-index 9999) + previewModal.style.zIndex = '10001'; + if (size > 512000 && !isHtml && !isImage) { - // Large non-html file — offer download instead - this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — double-click to download instead of preview.', 'warning'); + // Large non-html file — print truncated text to terminal instead + this.terminal.print(entry.name + ' is ' + this._formatSize(size) + ' — previewing first 2000 chars.', 'warning'); var textPreview = await resp.text(); - this.terminal.print('--- ' + entry.name + ' (first 2000 chars) ---'); - this.terminal.print(textPreview.substring(0, 2000)); - if (textPreview.length > 2000) this.terminal.print('...(truncated ' + this._formatSize(size) + ' total)'); + previewTitle.textContent = '📄 ' + entry.name; + previewIframe.srcdoc = '<pre style="white-space:pre-wrap;word-break:break-word;font-family:monospace;font-size:0.85em;color:var(--text-primary);padding:12px;margin:0;">' + this._escapeHtml(textPreview.substring(0, 2000)) + (textPreview.length > 2000 ? '\n...(truncated ' + this._formatSize(size) + ' total)' : '') + '</pre>'; + previewModal.style.display = 'flex'; return; } @@ -1296,68 +1645,127 @@ class WebDAVModule { reader.onloadend = function() { resolve(reader.result); }; reader.readAsDataURL(blob); }); - // Use preview modal - var previewTitle = document.getElementById('preview-title'); - var previewIframe = document.getElementById('preview-iframe'); - if (previewTitle && previewIframe) { - previewTitle.textContent = '🖼 ' + entry.name; - previewIframe.srcdoc = '<img src="' + dataUrl + '" style="max-width:100%; max-height:100%; display:block; margin:auto; background:var(--bg-color);">'; - document.getElementById('preview-modal').style.display = 'flex'; - return; - } + previewTitle.textContent = '🖼 ' + entry.name; + previewIframe.srcdoc = '<img src="' + dataUrl + '" style="max-width:100%; max-height:100%; display:block; margin:auto; background:var(--bg-color);">'; + previewModal.style.display = 'flex'; + return; } if (isHtml) { var content = await resp.text(); - var previewTitle = document.getElementById('preview-title'); - var previewIframe = document.getElementById('preview-iframe'); - if (previewTitle && previewIframe) { - previewTitle.textContent = '📄 ' + entry.name; - previewIframe.srcdoc = content; - document.getElementById('preview-modal').style.display = 'flex'; - return; - } + previewTitle.textContent = '📄 ' + entry.name; + previewIframe.srcdoc = content; + previewModal.style.display = 'flex'; + return; } - // Text file — print to terminal + // Text file — show in preview modal var content = await resp.text(); - this.terminal.print('--- ' + entry.name + ' (' + this._formatSize(content.length) + ') ---', 'info'); - this.terminal.print(content); - this.terminal.print('--- End: ' + entry.name + ' ---', 'text-muted'); + previewTitle.textContent = '📄 ' + entry.name + ' (' + this._formatSize(content.length) + ')'; + previewIframe.srcdoc = '<pre style="white-space:pre-wrap;word-break:break-word;font-family:monospace;font-size:0.85em;color:var(--text-primary);padding:12px;margin:0;">' + this._escapeHtml(content) + '</pre>'; + previewModal.style.display = 'flex'; } catch (e) { this.terminal.print('Preview error: ' + e.message, 'error'); } } uploadToWebdav(currentPath) { - var self = this; - var input = document.createElement('input'); + const self = this; + const input = document.createElement('input'); input.type = 'file'; input.onchange = async function(e) { - var file = e.target.files[0]; + const file = e.target.files[0]; if (!file) return; - var reader = new FileReader(); - reader.onload = async function(evt) { - var basePath = currentPath.replace(/\/+$/, ''); - var remotePath = basePath + '/' + file.name; - var parentDir = remotePath.split('/').slice(0, -1).join('/'); - try { - await self._ensureDir(parentDir); - var resp = await self._webdavRequest('PUT', remotePath, { - body: evt.target.result, - headers: { 'Content-Type': 'application/octet-stream' } - }); - if (resp.ok || resp.status === 201 || resp.status === 204) { - self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success'); - self.openWebdavBrowser(currentPath); - } else { - self.terminal.print('Upload failed: HTTP ' + resp.status, 'error'); - } - } catch (e) { - self.terminal.print('Upload error: ' + e.message, 'error'); - } - }; - reader.readAsText(file); + const basePath = currentPath.replace(/\/+$/, ''); + const remotePath = basePath + '/' + file.name; + const parentDir = remotePath.split('/').slice(0, -1).join('/') || '/'; + + // Progress UI — floating bar, recreated if closed + let prog = document.getElementById('wdav-upload-progress'); + if (!prog) { + prog = document.createElement('div'); + prog.id = 'wdav-upload-progress'; + prog.style.cssText = 'position:fixed; left:50%; bottom:24px; transform:translateX(-50%); z-index:10003; width:400px; max-width:92vw; background:var(--bg-tertiary,#141414); border:1px solid var(--border,#333); border-radius:8px; padding:10px 12px; box-shadow:0 4px 16px rgba(0,0,0,.5); font-size:0.85em;'; + prog.innerHTML = '<div style="display:flex;justify-content:space-between;margin-bottom:6px;gap:10px;"><span id="wdav-upload-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><span id="wdav-upload-pct" style="flex-shrink:0;">0%</span></div>' + + '<div style="height:8px;background:var(--bg-secondary,#222);border-radius:4px;overflow:hidden;"><div id="wdav-upload-bar" style="height:100%;width:0%;background:var(--accent,#4a9eff);transition:width .15s;"></div></div>' + + '<div style="display:flex;justify-content:space-between;margin-top:6px;color:var(--text-muted);"><span id="wdav-upload-speed"></span><span id="wdav-upload-size"></span></div>'; + document.body.appendChild(prog); + } else { + prog.style.display = 'block'; + } + const bar = document.getElementById('wdav-upload-bar'); + const pctEl = document.getElementById('wdav-upload-pct'); + const speedEl = document.getElementById('wdav-upload-speed'); + const sizeEl = document.getElementById('wdav-upload-size'); + document.getElementById('wdav-upload-name').textContent = file.name; + bar.style.width = '0%'; + pctEl.textContent = '0%'; + speedEl.textContent = ''; + sizeEl.textContent = self._formatSize(file.size); + + const startTime = Date.now(); + let lastLoaded = 0, lastTime = startTime; + + try { + // NOTE: no _ensureDir here — currentPath is the directory being + // browsed, so it already exists. Skipping it avoids the MKCOL + // request storm that delayed uploads (esp. on mobile) and + // caused client aborts (HTTP 499). + + // Read the file into memory BEFORE opening the connection. + // Android Chrome's lazy File streaming (xhr.send(file)) stalls + // for larger files picked from the file manager (content://), + // leaving the PUT half-open until nginx 408s or the browser + // aborts (499). A pre-read ArrayBuffer uploads reliably. + const buf = await new Promise(function(resolve, reject) { + const r = new FileReader(); + r.onloadend = function() { + if (r.error) reject(r.error); + else resolve(r.result); + }; + r.readAsArrayBuffer(file); + }); + + // XHR PUT so we get upload progress events (fetch has none) + await new Promise(function(resolve, reject) { + const baseUrl = self.config.url.replace(/\/+$/, ''); + const urlPath = remotePath.startsWith('/') ? remotePath : '/' + remotePath; + const xhr = new XMLHttpRequest(); + xhr.open('PUT', baseUrl + urlPath, true); + const auth = self._getAuthHeader(); + if (auth.Authorization) xhr.setRequestHeader('Authorization', auth.Authorization); + xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); + xhr.upload.onprogress = function(ev) { + if (!ev.lengthComputable) return; + const pct = Math.round(ev.loaded / ev.total * 100); + bar.style.width = pct + '%'; + pctEl.textContent = pct + '%'; + const now = Date.now(); + const dt = (now - lastTime) / 1000; + if (dt >= 0.5) { + speedEl.textContent = self._formatSize((ev.loaded - lastLoaded) / dt) + '/s'; + lastLoaded = ev.loaded; + lastTime = now; + } + }; + xhr.onload = function() { + if (xhr.status >= 200 && xhr.status < 300) resolve(); + else reject(new Error('HTTP ' + xhr.status)); + }; + xhr.onerror = function() { reject(new Error('Network error')); }; + xhr.send(buf); // pre-read buffer — reliable on mobile + }); + + bar.style.width = '100%'; + pctEl.textContent = '100%'; + const elapsed = Math.max((Date.now() - startTime) / 1000, 0.001); + speedEl.textContent = self._formatSize(file.size / elapsed) + '/s'; + self.terminal.print('⬆ Uploaded: ' + file.name + ' (' + self._formatSize(file.size) + ')', 'success'); + setTimeout(function() { prog.style.display = 'none'; self.openWebdavBrowser(currentPath); }, 700); + } catch (err) { + self.terminal.print('Upload failed: ' + err.message, 'error'); + prog.style.display = 'none'; + } }; input.click(); } |