class FileManagerModule { constructor() { this.commands = {}; this.commandsMeta = { 'files': { argType: 'file' }, 'preview': { argType: 'file' }, }; this.terminal = null; this.currentPath = []; this._overlay = null; this._selectedFile = null; this._multiSelect = new Set(); // v4.5.78: multi-select for batch ops this._selectMode = false; // v4.6.35: tap-to-select mode (touch devices) this._itemCount = 0; // items rendered in the current directory/filter this._clipboard = { mode: null, paths: [] }; // 'copy' or 'cut' this._searchFilter = ''; this._rawMode = false; this._inlineHtml = false; } requirements() { return { met: true, missing: [] }; } getHelpInfo() { return { 'files': 'files — Open the file manager overlay with VFS directory listing, preview, encrypt, zip', 'preview': 'preview — Preview a specific file', }; } registerCommands() { this.commands['files'] = () => this._openOverlay(); this.commands['preview'] = (args) => this._previewCommand(args); } _previewCommand(args) { if (!args[0]) return this.terminal.print('Usage: preview ', 'warning'); const path = args.join(' '); this._openOverlay(); this._navigateToPathFromParts(path); const content = this.terminal.fs.readFile(path); if (content === null || content === false) { this._renderPreviewError('File not found: ' + path); return; } this._selectedFile = path; this._renderPreview(path, content); this._highlightSelected(); } _navigateToPathFromParts(path) { const parts = path.split('/').filter(p => p); if (parts.length <= 1) { this.currentPath = []; return; } this.currentPath = parts.slice(0, -1); } _detectType(filePath) { const ext = filePath.split('.').pop().toLowerCase(); if (!ext || ext === filePath) return 'other'; const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico']; const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv']; const audioExts = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']; const codeExts = ['js', 'py', 'css', 'yaml', 'yml', 'toml']; if (ext === 'md') return 'md'; if (imageExts.includes(ext)) return 'image'; if (videoExts.includes(ext)) return 'video'; if (audioExts.includes(ext)) return 'audio'; if (codeExts.includes(ext)) return 'code'; if (ext === 'html') return 'html'; if (ext === 'json') return 'json'; if (ext === 'csv') return 'csv'; if (ext === 'pdf') return 'pdf'; if (ext === 'svg') return 'svg'; if (ext === 'txt') return 'text'; return 'other'; } _renderMarkdown(text) { if (!text) return ''; try { let html = text .replace(/&/g, '&') .replace(//g, '>'); // code blocks first html = html.replace(/```([\s\S]*?)```/g, '
$1
'); // inline code html = html.replace(/`([^`]+)`/g, '$1'); // headers html = html.replace(/^### (.+)$/gm, '

$1

'); html = html.replace(/^## (.+)$/gm, '

$1

'); html = html.replace(/^# (.+)$/gm, '

$1

'); // bold html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // italic html = html.replace(/\*(.+?)\*/g, '$1'); // links — v4.5.67: sanitize URL to prevent javascript: scheme injection html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => { const safeUrl = /^(https?:\/\/|mailto:|\/|#)/.test(url) ? url : '#'; return `${text}`; }); // horizontal rules html = html.replace(/^---$/gm, '
'); // unordered lists html = html.replace(/^[\s]*[-*]\s+(.+)$/gm, '
  • $1
  • '); // paragraphs - double newlines html = html.replace(/\n\n/g, '

    '); html = '

    ' + html + '

    '; return html; } catch (e) { return '
    ' + text.replace(/&/g, '&').replace(//g, '>') + '
    '; } } _getPreviewHtml(path, content) { if (content === null || content === false) { return '
    File not found: ' + this._escapeHtml(path) + '
    '; } const type = this._detectType(path); const ext = path.split('.').pop().toLowerCase(); const fileName = path.split('/').pop() || path; // Handle binary detection for media types if (typeof content !== 'string') { return '
    Cannot preview: non-string content
    '; } const isLikelyBinary = content.length > 0 && !content.startsWith('\n') && !content.startsWith('\r') && !content.startsWith(' ') && !content.startsWith('<') && !content.startsWith('{') && !content.startsWith('[') && content.charCodeAt(0) < 32 && content.charCodeAt(0) !== 9 && content.charCodeAt(0) !== 10 && content.charCodeAt(0) !== 13; const isTextContent = typeof content === 'string' && (content.startsWith('{') || content.startsWith('[') || content.startsWith('<') || content.startsWith('#') || /^[\w\s\n\r\t.,;:!?()\-=+*@#$%^&"'\/\\|`~]/.test(content.substring(0, 100))); if (type === 'md' && isTextContent) { return '
    ' + this._renderMarkdown(content) + '
    '; } if (type === 'html') { // Render HTML in an inline sandbox frame const safeHtml = content .replace(/)<[^<]*)*<\/script>/gi, '') .replace(/on\w+\s*=\s*"[^"]*"/gi, '') .replace(/on\w+\s*=\s*'[^']*'/gi, ''); return '
    ' + '
    '; } if (type === 'text') { const truncated = content.substring(0, 2000); const extra = content.length > 2000 ? '
    (truncated to 2000 chars)
    ' : ''; return '
    ' + this._escapeHtml(truncated) + extra + '
    '; } if (type === 'json') { try { const parsed = JSON.parse(content); const formatted = JSON.stringify(parsed, null, 2); const html = this._syntaxHighlightJson(formatted); return '
    ' + html + '
    '; } catch (e) { return '
    ' + this._escapeHtml(content.substring(0, 5000)) + '
    '; } } if (type === 'code') { const lines = content.split('\n'); const totalLines = lines.length; const maxLines = 150; const displayLines = lines.slice(0, maxLines); let html = ''; displayLines.forEach((line, i) => { const lineNum = i + 1; const escaped = this._escapeHtml(line); html += ''; html += ''; }); html += '
    ' + lineNum + '' + (escaped || ' ') + '
    '; if (totalLines > maxLines) { html += '
    (showing ' + maxLines + ' of ' + totalLines + ' lines)
    '; } return html; } if (type === 'image') { // VFS stores images as base64 strings — detect and render inline const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { const mimeTypes = {jpg:'image/jpeg',jpeg:'image/jpeg',png:'image/png',gif:'image/gif',webp:'image/webp',bmp:'image/bmp',ico:'image/x-icon'}; const mime = mimeTypes[ext] || 'image/png'; return '
    ' + this._escapeHtml(fileName) + '
    '; } return '
    📷 Image — cannot render, file may be stored as binary
    '; } if (type === 'svg') { try { const parser = new DOMParser(); const doc = parser.parseFromString(content, 'text/html'); const scripts = doc.querySelectorAll('script'); scripts.forEach(s => s.remove()); // Remove event handlers from svg elements const allEls = doc.querySelectorAll('*'); allEls.forEach(el => { for (let i = el.attributes.length - 1; i >= 0; i--) { const attr = el.attributes[i].name; if (attr.startsWith('on')) el.removeAttribute(attr); } }); const cleanSvg = doc.body.innerHTML; return '
    ' + cleanSvg + '
    '; } catch (e) { return '
    ' + this._escapeHtml(content.substring(0, 5000)) + '
    '; } } if (type === 'video') { const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { const mimeTypes = {mp4:'video/mp4',webm:'video/webm',avi:'video/x-msvideo',mov:'video/quicktime',mkv:'video/x-matroska'}; const mime = mimeTypes[ext] || 'video/mp4'; return '
    '; } return '
    🎬 Video — cannot render
    '; } if (type === 'audio') { const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { const mimeTypes = {mp3:'audio/mpeg',wav:'audio/wav',ogg:'audio/ogg',flac:'audio/flac',aac:'audio/aac',m4a:'audio/mp4'}; const mime = mimeTypes[ext] || 'audio/mpeg'; return '
    '; } return '
    🎵 Audio — cannot render
    '; } if (type === 'pdf') { const isBase64 = /^[A-Za-z0-9+/=]+$/.test(content.substring(0, 100)); if (isBase64) { return '
    '; } return '
    📄 PDF — cannot render
    '; } if (type === 'csv') { try { const lines = content.split('\n').filter(l => l.trim()); const maxRows = 50; const rows = lines.slice(0, maxRows).map(l => l.split(',').map(c => c.trim().replace(/^"|"$/g, ''))); let html = ''; if (rows.length > 0) { html += ''; rows[0].forEach(cell => { html += ''; }); html += ''; rows.slice(1).forEach(row => { html += ''; row.forEach(cell => { html += ''; }); html += ''; }); html += '
    ' + this._escapeHtml(cell) + '
    ' + this._escapeHtml(cell) + '
    '; if (lines.length > maxRows) { html += '
    (showing ' + maxRows + ' of ' + lines.length + ' rows)
    '; } } return html; } catch (e) { return '
    ' + this._escapeHtml(content.substring(0, 5000)) + '
    '; } } // 'other' — raw text fallback if (typeof content === 'string' && isTextContent) { const lines = content.split('\n'); const maxLines = 80; const displayLines = lines.slice(0, maxLines); const truncated = lines.length > maxLines; let html = '
    ';
                displayLines.forEach((line, i) => {
                    html += this._escapeHtml(line) + '\n';
                });
                html += '
    '; if (truncated) { html += '
    (showing ' + maxLines + ' of ' + lines.length + ' lines)
    '; } return html; } return '
    📁 Preview not available for this file type
    '; } _syntaxHighlightJson(jsonStr) { return jsonStr.replace(/("(?:\\.|[^"\\])*")\s*:/g, '$1:') .replace(/:\s*("(?:\\.|[^"\\])*")/g, ': $1') .replace(/:\s*(\d+\.?\d*)/g, ': $1') .replace(/:\s*(true|false|null)/g, ': $1'); } _escapeHtml(str) { if (typeof str !== 'string') return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } _formatSize(size) { if (!size && size !== 0) return ''; if (size < 1024) return size + ' B'; if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'; return (size / (1024 * 1024)).toFixed(1) + ' MB'; } _createOverlay() { if (this._overlay) return; const overlay = document.createElement('div'); overlay.id = 'filemgr-overlay'; overlay.style.cssText = 'position:fixed;inset:0;z-index:1800;display:flex;flex-direction:column;background:var(--bg-color);color:var(--text-color);font-family:var(--font-primary);font-size:14px;height:100vh;'; // Inject responsive styles for the overlay const styleId = 'filemgr-styles'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` #filemgr-overlay.responsive .filemgr-main { flex-direction: column !important; } #filemgr-overlay.responsive #filemgr-sidebar { width: 100% !important; max-height: 40vh; } #filemgr-overlay.responsive #filemgr-preview { width: 100% !important; } #filemgr-overlay .filemgr-main { display: flex; flex: 1; min-height: 0; } @media (max-width: 600px) { #filemgr-overlay .filemgr-main { flex-direction: column !important; } #filemgr-overlay #filemgr-sidebar { width: 100% !important; max-height: 40vh; border-right: none !important; border-bottom: 1px solid var(--border-color); } #filemgr-overlay #filemgr-preview { width: 100% !important; flex: 1 !important; min-height: 0; } #filemgr-action-bar { flex-wrap: wrap; gap: 6px; } #filemgr-action-bar button { min-width: 44px; min-height: 44px; font-size: 0.9em; } } `; document.head.appendChild(style); } // Toolbar const toolbar = document.createElement('div'); toolbar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;'; const closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 10px;border-radius:4px;font-family:inherit;font-size:1em;min-height:44px;touch-action:manipulation;'; closeBtn.onclick = () => this._closeOverlay(); toolbar.appendChild(closeBtn); const titleSpan = document.createElement('span'); titleSpan.textContent = '📁 File Manager'; titleSpan.style.cssText = 'font-weight:bold;margin-right:8px;'; toolbar.appendChild(titleSpan); // Breadcrumbs const breadcrumbs = document.createElement('div'); breadcrumbs.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:0.9em;'; breadcrumbs.id = 'filemgr-breadcrumbs'; // Root breadcrumb const rootSeg = document.createElement('span'); rootSeg.textContent = '/'; rootSeg.style.cssText = 'color:var(--accent-color);cursor:pointer;padding:2px 4px;border-radius:3px;'; rootSeg.onmouseenter = () => { rootSeg.style.background = 'var(--bg-tertiary)'; }; rootSeg.onmouseleave = () => { rootSeg.style.background = 'transparent'; }; rootSeg.onclick = () => this._navigateTo([]); breadcrumbs.appendChild(rootSeg); toolbar.appendChild(breadcrumbs); // Search input const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.placeholder = '🔍 Filter files...'; searchInput.style.cssText = 'flex:1;min-width:120px;max-width:300px;padding:6px 10px;background:var(--bg-tertiary);border:1px solid var(--border-color);border-radius:4px;color:var(--text-color);font-family:var(--font-primary);font-size:0.9em;outline:none;'; searchInput.oninput = (e) => { this._searchFilter = e.target.value.toLowerCase(); this._refreshFileList(); }; toolbar.appendChild(searchInput); // v4.5.78: Batch operation buttons const batchBar = document.createElement('div'); batchBar.style.cssText = 'display:flex;align-items:center;gap:4px;flex-wrap:wrap;'; const mkBtn = (label, title, action) => { const b = document.createElement('button'); b.textContent = label; b.title = title; b.style.cssText = 'background:none;border:1px solid var(--border-color);color:var(--text-color);cursor:pointer;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:0.85em;min-height:32px;touch-action:manipulation;'; b.onclick = () => action(); return b; }; batchBar.appendChild(mkBtn('📋 Copy', 'Copy selected files', () => this._batchCopy())); batchBar.appendChild(mkBtn('✂️ Cut', 'Cut selected files', () => this._batchCut())); batchBar.appendChild(mkBtn('📥 Paste', 'Paste from clipboard', () => this._batchPaste())); batchBar.appendChild(mkBtn('🗑️ Del', 'Delete selected files', () => this._batchDelete())); batchBar.appendChild(mkBtn('📝 Rename', 'Rename selected file', () => this._batchRename())); batchBar.appendChild(mkBtn('☐ All', 'Toggle select all', () => this._toggleSelectAll())); // v4.6.35: tap-to-select mode — the only usable multi-select path on touch devices const selModeBtn = mkBtn('☑ Select', 'Toggle tap-to-select mode (touch)', () => { this._selectMode = !this._selectMode; selModeBtn.style.background = this._selectMode ? 'var(--accent-color)' : 'none'; selModeBtn.style.color = this._selectMode ? '#fff' : 'var(--text-color)'; this._updateStatus(this._selectMode ? 'Select mode ON — tap rows to select' : 'Select mode OFF'); }); selModeBtn.setAttribute('aria-pressed', 'false'); batchBar.appendChild(selModeBtn); toolbar.appendChild(batchBar); // 2026-10-21: publish a VFS file to the user's WebDAV space was terminal-only. const wdavBtn = document.createElement('button'); wdavBtn.textContent = '☁ WebDAV'; wdavBtn.title = 'Upload the selected/current file to your WebDAV space and get its public link'; wdavBtn.style.cssText = 'background:var(--bg-tertiary);border:1px solid var(--border-color);' + 'color:var(--text-color);cursor:pointer;padding:4px 10px;border-radius:4px;' + 'font-family:inherit;font-size:0.9em;min-height:32px;touch-action:manipulation;'; wdavBtn.onclick = () => this._uploadSelectionToWebdav(); toolbar.appendChild(wdavBtn); overlay.appendChild(toolbar); // Main area — wraps sidebar + preview in a responsive flex container const mainArea = document.createElement('div'); mainArea.id = 'filemgr-main-container'; mainArea.className = 'filemgr-main'; mainArea.style.cssText = 'flex:1;overflow:hidden;display:flex;min-height:0;'; // Sidebar const sidebar = document.createElement('div'); sidebar.id = 'filemgr-sidebar'; sidebar.style.cssText = 'width:25%;min-width:150px;overflow-y:auto;background:var(--bg-secondary);border-right:1px solid var(--border-color);'; mainArea.appendChild(sidebar); // Preview pane const preview = document.createElement('div'); preview.id = 'filemgr-preview'; preview.style.cssText = 'flex:1;overflow-y:auto;padding:0;'; mainArea.appendChild(preview); overlay.appendChild(mainArea); // Action bar const actionBar = document.createElement('div'); actionBar.id = 'filemgr-action-bar'; actionBar.style.cssText = 'display:flex;align-items:center;padding:8px 12px;border-top:1px solid var(--border-color);background:var(--bg-secondary);flex-shrink:0;gap:8px;flex-wrap:wrap;'; const statusSpan = document.createElement('span'); statusSpan.id = 'filemgr-status'; statusSpan.style.cssText = 'flex:1;color:var(--text-muted);font-size:0.85em;'; statusSpan.textContent = 'No file selected'; actionBar.appendChild(statusSpan); const encryptBtn = document.createElement('button'); encryptBtn.textContent = '🔐 Encrypt'; encryptBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--warning-color);color:var(--warning-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; encryptBtn.onclick = () => this._actionEncrypt(); actionBar.appendChild(encryptBtn); const compressBtn = document.createElement('button'); compressBtn.textContent = '📦 Zip'; compressBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--info-color);color:var(--info-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; compressBtn.onclick = () => this._actionCompress(); actionBar.appendChild(compressBtn); const deleteBtn = document.createElement('button'); deleteBtn.textContent = '🗑 Delete'; deleteBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--error-color);color:var(--error-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; deleteBtn.onclick = () => this._actionDelete(); actionBar.appendChild(deleteBtn); const downloadBtn = document.createElement('button'); downloadBtn.textContent = '⬇ Download'; downloadBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; downloadBtn.onclick = () => this._actionDownload(); actionBar.appendChild(downloadBtn); const editBtn = document.createElement('button'); editBtn.textContent = '✏ Edit'; editBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--accent-color);color:var(--accent-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; editBtn.onclick = () => this._actionEdit(); actionBar.appendChild(editBtn); const newFileBtn = document.createElement('button'); newFileBtn.textContent = '📄 New'; newFileBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--success-color);color:var(--success-color);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; newFileBtn.onclick = () => this._actionNewFile(); actionBar.appendChild(newFileBtn); const previewToggleBtn = document.createElement('button'); previewToggleBtn.id = 'filemgr-toggle-raw'; previewToggleBtn.textContent = '📄 Raw'; previewToggleBtn.style.cssText = 'padding:6px 14px;background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-muted);border-radius:4px;cursor:pointer;font-family:inherit;font-size:0.9em;min-height:44px;touch-action:manipulation;'; previewToggleBtn.onclick = () => this._togglePreviewMode(); actionBar.appendChild(previewToggleBtn); overlay.appendChild(actionBar); this._overlay = overlay; return overlay; } _openOverlay() { if (this._overlay) { this._overlay.style.display = 'flex'; this._refreshFileList(); return; } const overlay = this._createOverlay(); document.body.appendChild(overlay); this._navigateTo(this.currentPath); } _closeOverlay() { if (this._overlay) { this._overlay.style.display = 'none'; } this._multiSelect.clear(); this._selectedFile = null; this._selectMode = false; } _clearOverlay() { if (this._overlay && this._overlay.parentNode) { this._overlay.parentNode.removeChild(this._overlay); } this._overlay = null; this._selectedFile = null; this._multiSelect.clear(); this._selectMode = false; this._searchFilter = ''; this._rawMode = false; } _navigateTo(pathParts) { this.currentPath = pathParts || []; this._selectedFile = null; this._multiSelect.clear(); this._searchFilter = ''; const searchInput = this._overlay?.querySelector('input[type="text"]'); if (searchInput) searchInput.value = ''; this._updateBreadcrumbs(); this._refreshFileList(); this._clearPreview(); this._updateStatus('No file selected'); } _updateBreadcrumbs() { const breadcrumbs = document.getElementById('filemgr-breadcrumbs'); if (!breadcrumbs) return; // Keep root segment, remove rest while (breadcrumbs.children.length > 1) { breadcrumbs.removeChild(breadcrumbs.lastChild); } let accumulated = ''; this.currentPath.forEach((part, i) => { const sep = document.createElement('span'); sep.textContent = ' › '; sep.style.cssText = 'color:var(--text-muted);margin:0 2px;'; breadcrumbs.appendChild(sep); const seg = document.createElement('span'); seg.textContent = part; seg.style.cssText = 'cursor:pointer;padding:2px 4px;border-radius:3px;color:var(--text-color);'; if (i === this.currentPath.length - 1) { seg.style.color = 'var(--accent-color)'; seg.style.fontWeight = 'bold'; } seg.onmouseenter = () => { seg.style.background = 'var(--bg-tertiary)'; }; seg.onmouseleave = () => { seg.style.background = 'transparent'; }; const pathUpTo = this.currentPath.slice(0, i + 1); seg.onclick = () => this._navigateTo(pathUpTo); breadcrumbs.appendChild(seg); }); } _refreshFileList() { const sidebar = document.getElementById('filemgr-sidebar'); if (!sidebar) return; // Read directory const pathStr = '/' + this.currentPath.join('/'); const entries = this.terminal.fs.ls(pathStr) || []; // Separate dirs and files const dirs = entries.filter(e => e.type === 'dir'); const files = entries.filter(e => e.type !== 'dir'); // Prune selections that no longer exist here (deleted/moved elsewhere) try { const _live = new Set(); entries.forEach(e => _live.add(pathStr === '/' ? '/' + e.name : pathStr + '/' + e.name)); [...this._multiSelect].forEach(p => { if (!_live.has(p)) this._multiSelect.delete(p); }); } catch (_e) {} // Sort alphabetically dirs.sort((a, b) => a.name.localeCompare(b.name)); files.sort((a, b) => a.name.localeCompare(b.name)); // Apply search filter let filteredDirs = dirs; let filteredFiles = files; if (this._searchFilter) { const q = this._searchFilter; filteredDirs = dirs.filter(e => e.name.toLowerCase().includes(q)); filteredFiles = files.filter(e => e.name.toLowerCase().includes(q)); } const _scrollTop = sidebar.scrollTop; sidebar.innerHTML = ''; if (filteredDirs.length === 0 && filteredFiles.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);'; emptyMsg.textContent = this._searchFilter ? 'No matching files' : '(empty)'; sidebar.appendChild(emptyMsg); this._itemCount = 0; this._updateSelectionStatus(); return; } // Add directory items filteredDirs.forEach(entry => { const item = document.createElement('div'); const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; item.dataset.path = fullPath; item.dataset.type = 'dir'; item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;border-left:3px solid transparent;'; item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; item.onclick = (e) => { if (e.ctrlKey || e.metaKey || this._selectMode) { e.preventDefault(); this._toggleMulti(fullPath); return; } const newPath = [...this.currentPath, entry.name]; this._navigateTo(newPath); }; item.appendChild(this._makeCheckbox(fullPath, entry.name + '/')); const icon = document.createElement('span'); icon.textContent = '📁'; item.appendChild(icon); const nameSpan = document.createElement('span'); nameSpan.textContent = entry.name + '/'; nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; item.appendChild(nameSpan); sidebar.appendChild(item); }); // Add file items filteredFiles.forEach(entry => { const item = document.createElement('div'); const fullPath = pathStr === '/' ? '/' + entry.name : pathStr + '/' + entry.name; const isSelected = this._selectedFile === fullPath; const isMulti = this._multiSelect.has(fullPath); item.dataset.path = fullPath; item.dataset.type = 'file'; item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false'; const bgColor = isMulti ? 'var(--bg-tertiary)' : 'transparent'; item.style.cssText = 'display:flex;align-items:center;padding:6px 10px;cursor:pointer;border-bottom:1px solid var(--border-color);gap:6px;background:' + bgColor + ';border-left:3px solid ' + (isSelected ? 'var(--accent-color)' : 'transparent') + ';'; item.onmouseenter = () => { if (!item.dataset.selected) item.style.background = 'var(--bg-tertiary)'; }; item.onmouseleave = () => { if (!item.dataset.selected) item.style.background = 'transparent'; }; item.onclick = (e) => { // Ctrl/Cmd or Select-mode → toggle in the batch set (no full re-render) if (e.ctrlKey || e.metaKey || this._selectMode) { e.preventDefault(); this._toggleMulti(fullPath); return; } // Plain click → preview AND make it the single selected item this._multiSelect.clear(); this._multiSelect.add(fullPath); this._selectedFile = fullPath; this._highlightSelected(); this._renderPreview(fullPath, this.terminal.fs.readFile(fullPath)); this._updateSelectionStatus(); }; item.appendChild(this._makeCheckbox(fullPath, entry.name)); const icon = document.createElement('span'); icon.textContent = '📄'; item.appendChild(icon); const nameSpan = document.createElement('span'); nameSpan.textContent = entry.name; nameSpan.style.cssText = 'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; item.appendChild(nameSpan); const sizeSpan = document.createElement('span'); sizeSpan.textContent = this._formatSize(entry.size); sizeSpan.style.cssText = 'color:var(--text-muted);font-size:0.8em;white-space:nowrap;margin-left:4px;'; item.appendChild(sizeSpan); sidebar.appendChild(item); }); this._itemCount = filteredDirs.length + filteredFiles.length; sidebar.scrollTop = _scrollTop; this._highlightSelected(); this._updateSelectionStatus(); } _makeCheckbox(path, label) { const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = this._multiSelect.has(path); cb.setAttribute('aria-label', 'Select ' + label); cb.style.cssText = 'width:20px;height:20px;min-width:20px;margin:0 6px 0 0;accent-color:var(--accent-color);cursor:pointer;touch-action:manipulation;'; cb.onclick = (e) => e.stopPropagation(); cb.onchange = () => this._toggleMulti(path); return cb; } _toggleMulti(path) { if (this._multiSelect.has(path)) this._multiSelect.delete(path); else this._multiSelect.add(path); this._highlightSelected(); this._updateSelectionStatus(); } _updateSelectionStatus() { const n = this._multiSelect.size; if (n) { this._updateStatus(n + ' selected'); return; } if (this._selectedFile) { this._updateStatus('Preview: ' + this._selectedFile.split('/').pop()); return; } const total = this._itemCount || 0; this._updateStatus(total + ' item' + (total === 1 ? '' : 's')); } _exists(path) { const fs = this.terminal.fs; try { if (fs.readFile(path) !== null) return true; if (typeof fs.readDir === 'function' && fs.readDir(path) !== null) return true; if (typeof fs.stat === 'function' && fs.stat(path)) return true; } catch (_e) {} return false; } _highlightSelected() { const sidebar = document.getElementById('filemgr-sidebar'); if (!sidebar) return; const items = sidebar.querySelectorAll('[data-path]'); items.forEach(item => { const path = item.dataset.path; const inBatch = this._multiSelect.has(path); const preview = this._selectedFile === path; item.dataset.selected = (inBatch || preview) ? 'true' : 'false'; item.style.background = inBatch ? 'var(--bg-tertiary)' : 'transparent'; item.style.borderLeft = '3px solid ' + (preview ? 'var(--accent-color)' : 'transparent'); const cb = item.querySelector('input[type="checkbox"]'); if (cb) cb.checked = inBatch; }); } // v4.5.78: Batch operations _getSelectedPaths() { return [...this._multiSelect]; } _batchCopy() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'copy', paths: [...paths] }; this._updateStatus('📋 Copied ' + paths.length + ' item(s)'); } _batchCut() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'cut', paths: [...paths] }; this._updateStatus('✂️ Cut ' + paths.length + ' item(s)'); } _batchPaste() { if (!this._clipboard || !this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; } const fs = this.terminal.fs; const dest = this.currentPath.length ? '/' + this.currentPath.join('/') : '/'; let count = 0, skipped = 0; const failed = []; this._clipboard.paths.forEach(src => { const name = src.split('/').pop(); const destPath = dest === '/' ? '/' + name : dest + '/' + name; if (destPath === src || destPath.startsWith(src + '/')) { skipped++; return; } // Directory → existing directory would REPLACE the whole subtree silently; skip it. let srcIsDir = false; try { srcIsDir = typeof fs.readDir === 'function' && fs.readDir(src) !== null; } catch (_e) {} if (this._exists(destPath) && srcIsDir) { skipped++; return; } if (this._exists(destPath) && !confirm('Overwrite ' + destPath + '?')) { skipped++; return; } try { const ok = this._clipboard.mode === 'copy' ? fs.copy(src, destPath) : fs.move(src, destPath); if (ok) count++; else failed.push(src); } catch (_e) { failed.push(src); } }); if (this._clipboard.mode === 'cut') { this._clipboard = failed.length ? { mode: 'cut', paths: failed } : { mode: null, paths: [] }; } this._multiSelect.clear(); this._selectedFile = null; this._refreshFileList(); this._updateStatus('📥 Pasted ' + count + ' item(s)' + (skipped ? ' — ' + skipped + ' skipped' : '') + (failed.length ? ' — ' + failed.length + ' failed' : '')); } _batchDelete() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return false; } if (!confirm('Delete ' + paths.length + ' item(s)?')) return false; const fs = this.terminal.fs; let count = 0, failed = 0; paths.forEach(p => { try { const ok = typeof fs.rm === 'function' ? fs.rm(p) : fs.deleteFile(p); if (ok) count++; else failed++; } catch (_e) { failed++; } }); this._multiSelect.clear(); this._selectedFile = null; this._refreshFileList(); this._updateStatus('🗑️ Deleted ' + count + ' item(s)' + (failed ? ' — ' + failed + ' failed' : '')); return count > 0; } _batchRename() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No item selected'); return; } const fs = this.terminal.fs; if (paths.length === 1) { const path = paths[0]; const oldName = path.split('/').pop(); const newName = (prompt('Rename to:', oldName) || '').trim(); if (!newName || newName === oldName) return; if (newName === '.' || newName === '..' || newName.includes('/') || newName.includes('\\')) { this._updateStatus('❌ Invalid file name'); return; } const dir = path.substring(0, path.lastIndexOf('/')) || '/'; const newPath = dir === '/' ? '/' + newName : dir + '/' + newName; if (this._exists(newPath)) { this._updateStatus('⚠ Destination already exists'); return; } if (!fs.rename(path, newPath)) { this._updateStatus('❌ Rename failed'); return; } this._selectedFile = newPath; this._multiSelect.clear(); this._multiSelect.add(newPath); this._refreshFileList(); this._updateStatus('📝 Renamed to ' + newName); return; } const prefix = (prompt('Rename ' + paths.length + ' items — new prefix:', 'file') || '').trim(); if (!prefix) return; let i = 0, ok = 0, skip = 0; paths.forEach(p => { const ext = (p.match(/\.[^./]+$/) || [''])[0]; const dir = p.substring(0, p.lastIndexOf('/')) || '/'; const dest = (dir === '/' ? '' : dir) + '/' + prefix + (++i) + ext; if (dest === p || this._exists(dest)) { skip++; return; } try { if (fs.rename(p, dest)) ok++; else skip++; } catch (_e) { skip++; } }); this._multiSelect.clear(); this._selectedFile = null; this._refreshFileList(); this._updateStatus('📝 Renamed ' + ok + ' item(s)' + (skip ? ' — ' + skip + ' skipped' : '')); } _toggleSelectAll() { const sidebar = document.getElementById('filemgr-sidebar'); if (!sidebar) return; const paths = [...sidebar.querySelectorAll('[data-path]')].map(i => i.dataset.path); if (!paths.length) { this._updateSelectionStatus(); return; } const all = paths.every(p => this._multiSelect.has(p)); if (all) { paths.forEach(p => this._multiSelect.delete(p)); if (this._selectedFile && paths.includes(this._selectedFile)) this._selectedFile = null; } else { paths.forEach(p => this._multiSelect.add(p)); } this._refreshFileList(); } _renderPreview(path, content) { const preview = document.getElementById('filemgr-preview'); if (!preview) return; // Re-read content on each preview call if (content === undefined) { content = this.terminal.fs.readFile(path); } // For raw mode, show raw text regardless of type if (this._rawMode && typeof content === 'string') { preview.innerHTML = this._previewShell(path, '
    RAW: ' + this._escapeHtml(path) + '
    ' + this._escapeHtml(content.substring(0, 10000)) + '
    '); this._wirePreviewToolbar(path); this._updateStatus('Preview: ' + path); return; } // Check file size for text files const type = this._detectType(path); const textTypes = ['md', 'text', 'json', 'code', 'csv', 'other']; if (textTypes.includes(type) && typeof content === 'string' && content.length > 51200) { content = content.substring(0, 51200); preview.innerHTML = this._previewShell(path, this._getPreviewHtml(path, content) + '
    (truncated — file exceeds 50KB)
    '); this._wirePreviewToolbar(path); this._updateStatus('Preview: ' + path); return; } preview.innerHTML = this._previewShell(path, this._getPreviewHtml(path, content)); this._wirePreviewToolbar(path); this._updateStatus('Preview: ' + path); } // 2026-10-21: preview pane had no fullscreen and no share action. _previewShell(path, innerHtml) { return '
    ' + '' + '' + '
    ' + innerHtml + '
    '; } _previewShareUrl(path) { const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules ? this.terminal.moduleManager.modules.WebDAVModule : null; const space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || ''; const clean = String(path).replace(/^\/+/, ''); return 'https://webx11.duckdns.org/spaces/' + space + '/' + clean.split('/').map(encodeURIComponent).join('/'); } // 2026-10-21: publish VFS file(s) to WebDAV (was terminal-only: webdav-push) async _uploadSelectionToWebdav() { let targets = this._getSelectedPaths ? this._getSelectedPaths() : []; if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : []; if (!targets.length) { this._updateStatus('☁ Select a file first'); return; } const wdav = this.terminal && this.terminal.moduleManager && this.terminal.moduleManager.modules ? this.terminal.moduleManager.modules.WebDAVModule : null; if (!wdav) { this._updateStatus('☁ WebDAV module not loaded'); return; } if (!wdav.config || !wdav.config.url) { try { await wdav._autoBootstrap(); } catch (e) {} } if (!wdav.config || !wdav.config.url) { this._updateStatus('☁ WebDAV not configured — run webdav-config'); return; } for (const p of targets) { const clean = String(p).replace(/^\/+/, ''); try { await this.terminal.security.checkAndRun('webdav-push ' + clean); const shareUrl = this._previewShareUrl(clean); this._updateStatus('☁ Uploaded ' + clean + ' → ' + shareUrl); if (this.terminal.print) this.terminal.print('☁ WebDAV: ' + shareUrl, 'success'); } catch (e) { this._updateStatus('☁ Upload failed: ' + (e && e.message ? e.message : e)); } } } _wirePreviewToolbar(path) { const pane = document.getElementById('filemgr-preview'); if (!pane) return; const fsBtn = pane.querySelector('.fm-btn-fs'); const shBtn = pane.querySelector('.fm-btn-share'); if (fsBtn) fsBtn.onclick = () => { const body = pane.querySelector('.fm-preview-body') || pane; if (document.fullscreenElement) { document.exitFullscreen(); return; } if (body.requestFullscreen) body.requestFullscreen().catch(() => this._updateStatus('⛶ Fullscreen blocked by this browser')); else if (body.webkitRequestFullscreen) body.webkitRequestFullscreen(); else this._updateStatus('⛶ Fullscreen not supported here'); }; if (shBtn) shBtn.onclick = () => { const url = this._previewShareUrl(path); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url) .then(() => this._updateStatus('🔗 Link copied: ' + url)) .catch(() => prompt('Copy share link:', url)); } else { prompt('Copy share link:', url); } }; } _renderPreviewError(msg) { const preview = document.getElementById('filemgr-preview'); if (!preview) return; preview.innerHTML = '
    ' + this._escapeHtml(msg) + '
    '; this._updateStatus('Error'); } _clearPreview() { const preview = document.getElementById('filemgr-preview'); if (preview) preview.innerHTML = ''; } _updateStatus(msg) { const status = document.getElementById('filemgr-status'); if (status) status.textContent = msg; } _actionDelete() { // Delegate to the batch path so checkbox selections are honoured, not just the previewed file. if (!this._getSelectedPaths().length && this._selectedFile) { this._multiSelect.add(this._selectedFile); } // Only clear the preview when something was actually deleted (not on cancel). if (this._batchDelete()) this._clearPreview(); } _actionDownload() { if (!this._selectedFile) { this._updateStatus('No file selected to download'); return; } const content = this.terminal.fs.readFile(this._selectedFile); if (content === null || content === false) { this._updateStatus('❌ File not found'); return; } const fileName = this._selectedFile.split('/').pop() || 'file'; const blob = new Blob([content], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this._updateStatus('⬇ Downloaded: ' + fileName); } _actionEdit() { if (!this._selectedFile) { this._updateStatus('No file selected to edit'); return; } const path = this._selectedFile; // Close file manager first so code editor modal (z-index:1000) is visible above this._closeOverlay(); if (typeof webx11 !== 'undefined' && webx11 && webx11.dispatchCommand) { setTimeout(() => { webx11.dispatchCommand('edit', [path]); }, 100); this._updateStatus('✏ Opened in editor: ' + path); } else { this._updateStatus('❌ Editor not available'); } } _togglePreviewMode() { this._rawMode = !this._rawMode; const btn = document.getElementById('filemgr-toggle-raw'); if (btn) { btn.textContent = this._rawMode ? '📄 Rendered' : '📄 Raw'; btn.style.borderColor = this._rawMode ? 'var(--accent-color)' : 'var(--border-color)'; btn.style.color = this._rawMode ? 'var(--accent-color)' : 'var(--text-muted)'; } if (this._selectedFile) { const content = this.terminal.fs.readFile(this._selectedFile); this._renderPreview(this._selectedFile, content); } } _actionEncrypt() { if (!this._selectedFile) { this._updateStatus('No file selected'); return; } if (this._selectedFile.endsWith('.enc')) { this._updateStatus('⚠ Already encrypted (.enc)'); return; } const passphrase = prompt('Enter passphrase for AES-256-GCM encryption:'); if (!passphrase || passphrase.length < 4) { this._updateStatus('❌ Passphrase too short (min 4 chars)'); return; } // Delegate to cryptogzip module's encrypt command if (typeof webx11 !== 'undefined' && webx11.executeCommand) { webx11.executeCommand('encrypt ' + this._selectedFile + ' ' + passphrase); this._updateStatus('🔐 Encrypted: ' + this._selectedFile + '.enc'); this._refreshFileList(); } } _actionCompress() { if (!this._selectedFile) { this._updateStatus('No file selected'); return; } if (this._selectedFile.endsWith('.gz')) { this._updateStatus('⚠ Already compressed (.gz)'); return; } if (typeof webx11 !== 'undefined' && webx11.executeCommand) { webx11.executeCommand('gzip ' + this._selectedFile); this._updateStatus('📦 Compressed: ' + this._selectedFile + '.gz'); this._refreshFileList(); } } _actionNewFile() { const name = prompt('New file name (e.g. myfile.txt):'); if (!name) return; const pathStr = '/' + this.currentPath.join('/'); const cleanPath = pathStr === '/' ? '/' + name : pathStr + '/' + name; const content = prompt('Enter file content (optional):') || ''; const ok = this.terminal.fs.writeFile(cleanPath, content); if (ok) { this._updateStatus('✅ Created: ' + cleanPath + ' (' + content.length + ' chars)'); this._refreshFileList(); } else { this._updateStatus('❌ Failed to create file'); } } } if (typeof window.webx11 !== 'undefined' && window.webx11) { webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js'); }