aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ext/webdav.js1028
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+ }
+
+ 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();
}