diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/ext/critical-ext-bundle.js | 241 | ||||
| -rw-r--r-- | modules/ext/filemgr.js | 241 |
3 files changed, 321 insertions, 163 deletions
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 2bfc0ec..3a85417 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.34'; + const LATEST_VER = 'v4.6.35'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/modules/ext/critical-ext-bundle.js b/modules/ext/critical-ext-bundle.js index 8773f47..504dfa8 100644 --- a/modules/ext/critical-ext-bundle.js +++ b/modules/ext/critical-ext-bundle.js @@ -942,6 +942,8 @@ class FileManagerModule { 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; @@ -1359,6 +1361,15 @@ class FileManagerModule { 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); overlay.appendChild(toolbar); @@ -1460,6 +1471,9 @@ class FileManagerModule { if (this._overlay) { this._overlay.style.display = 'none'; } + this._multiSelect.clear(); + this._selectedFile = null; + this._selectMode = false; } _clearOverlay() { @@ -1468,6 +1482,8 @@ class FileManagerModule { } this._overlay = null; this._selectedFile = null; + this._multiSelect.clear(); + this._selectMode = false; this._searchFilter = ''; this._rawMode = false; } @@ -1475,6 +1491,7 @@ class FileManagerModule { _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 = ''; @@ -1527,6 +1544,13 @@ class FileManagerModule { 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)); @@ -1540,6 +1564,7 @@ class FileManagerModule { filteredFiles = files.filter(e => e.name.toLowerCase().includes(q)); } + const _scrollTop = sidebar.scrollTop; sidebar.innerHTML = ''; if (filteredDirs.length === 0 && filteredFiles.length === 0) { @@ -1547,7 +1572,8 @@ class FileManagerModule { emptyMsg.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);'; emptyMsg.textContent = this._searchFilter ? 'No matching files' : '(empty)'; sidebar.appendChild(emptyMsg); - this._updateStatus('0 files'); + this._itemCount = 0; + this._updateSelectionStatus(); return; } @@ -1557,14 +1583,17 @@ class FileManagerModule { 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;'; + 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 = () => { + 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); @@ -1586,27 +1615,24 @@ class FileManagerModule { item.dataset.path = fullPath; item.dataset.type = 'file'; item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false'; - const bgColor = (isSelected || 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 + ';'; - if (isSelected || isMulti) { - item.style.borderLeft = '3px solid var(--accent-color)'; - } + 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) => { - // v4.5.78: Ctrl+click or checkbox for multi-select - if (e.ctrlKey || e.metaKey) { - if (this._multiSelect.has(fullPath)) this._multiSelect.delete(fullPath); - else this._multiSelect.add(fullPath); - this._refreshFileList(); - return; - } + // 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(); - const content = this.terminal.fs.readFile(fullPath); - this._renderPreview(fullPath, content); + 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); @@ -1624,34 +1650,73 @@ class FileManagerModule { sidebar.appendChild(item); }); - const totalCount = entries.length; - this._updateStatus(totalCount + ' item' + (totalCount !== 1 ? 's' : '')); + 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-type="file"]'); + const items = sidebar.querySelectorAll('[data-path]'); items.forEach(item => { - const selected = item.dataset.path === this._selectedFile || this._multiSelect.has(item.dataset.path); - item.dataset.selected = selected ? 'true' : 'false'; - item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent'; - item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent'; + 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() { - const paths = [...this._multiSelect]; - if (this._selectedFile && !paths.includes(this._selectedFile)) paths.push(this._selectedFile); - return paths; + return [...this._multiSelect]; } _batchCopy() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'copy', paths: [...paths] }; - this._multiSelect.clear(); this._updateStatus('📋 Copied ' + paths.length + ' item(s)'); } @@ -1659,82 +1724,104 @@ class FileManagerModule { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'cut', paths: [...paths] }; - this._multiSelect.clear(); this._updateStatus('✂️ Cut ' + paths.length + ' item(s)'); } _batchPaste() { - if (!this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; } - const dest = '/' + this.currentPath.join('/'); - let count = 0; + 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; } + if (this._exists(destPath) && !confirm('Overwrite ' + destPath + '?')) { skipped++; return; } try { - if (this._clipboard.mode === 'copy') { - const content = this.terminal.fs.readFile(src); - if (content !== null) { this.terminal.fs.writeFile(destPath, content); count++; } - } else if (this._clipboard.mode === 'cut') { - const content = this.terminal.fs.readFile(src); - if (content !== null) { - this.terminal.fs.writeFile(destPath, content); - this.terminal.fs.deleteFile(src); - count++; - } - } - } catch (e) { /* skip errors */ } + 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 = { mode: null, paths: [] }; + 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)'); + 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; } - if (!confirm('Delete ' + paths.length + ' file(s)?')) return; - let count = 0; + if (!confirm('Delete ' + paths.length + ' item(s)?')) return; + const fs = this.terminal.fs; + let count = 0, failed = 0; paths.forEach(p => { - try { this.terminal.fs.deleteFile(p); count++; } catch (e) { /* skip */ } + 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 + ' file(s)'); + this._updateStatus('🗑️ Deleted ' + count + ' item(s)' + (failed ? ' — ' + failed + ' failed' : '')); } _batchRename() { - const path = this._selectedFile || (this._multiSelect.size === 1 ? [...this._multiSelect][0] : null); - if (!path) { this._updateStatus('Select one file to rename'); return; } - const oldName = path.split('/').pop(); - const newName = prompt('Rename to:', oldName); - if (!newName || newName === oldName) return; - const dir = path.substring(0, path.lastIndexOf('/')) || '/'; - const newPath = dir === '/' ? '/' + newName : dir + '/' + newName; - try { - const content = this.terminal.fs.readFile(path); - if (content === null) { this._updateStatus('Cannot read file'); return; } - this.terminal.fs.writeFile(newPath, content); - this.terminal.fs.deleteFile(path); + 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); - } catch (e) { this._updateStatus('Rename failed: ' + e.message); } + 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 fileItems = sidebar.querySelectorAll('[data-type="file"]'); - if (this._multiSelect.size >= fileItems.length) { - this._multiSelect.clear(); + 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 { - fileItems.forEach(item => this._multiSelect.add(item.dataset.path)); + paths.forEach(p => this._multiSelect.add(p)); } this._refreshFileList(); - this._updateStatus(this._multiSelect.size + ' selected'); } _renderPreview(path, content) { @@ -1789,20 +1876,12 @@ class FileManagerModule { } _actionDelete() { - if (!this._selectedFile) { - this._updateStatus('No file selected to delete'); - return; - } - if (!confirm('Delete ' + this._selectedFile + '?')) return; - const ok = this.terminal.fs.deleteFile(this._selectedFile); - if (ok) { - this._selectedFile = null; - this._refreshFileList(); - this._clearPreview(); - this._updateStatus('✅ Deleted'); - } else { - this._updateStatus('❌ Failed to delete'); + // 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); } + this._batchDelete(); + this._clearPreview(); } _actionDownload() { diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js index acbb963..28c62ce 100644 --- a/modules/ext/filemgr.js +++ b/modules/ext/filemgr.js @@ -9,6 +9,8 @@ class FileManagerModule { 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; @@ -426,6 +428,15 @@ class FileManagerModule { 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); overlay.appendChild(toolbar); @@ -527,6 +538,9 @@ class FileManagerModule { if (this._overlay) { this._overlay.style.display = 'none'; } + this._multiSelect.clear(); + this._selectedFile = null; + this._selectMode = false; } _clearOverlay() { @@ -535,6 +549,8 @@ class FileManagerModule { } this._overlay = null; this._selectedFile = null; + this._multiSelect.clear(); + this._selectMode = false; this._searchFilter = ''; this._rawMode = false; } @@ -542,6 +558,7 @@ class FileManagerModule { _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 = ''; @@ -594,6 +611,13 @@ class FileManagerModule { 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)); @@ -607,6 +631,7 @@ class FileManagerModule { filteredFiles = files.filter(e => e.name.toLowerCase().includes(q)); } + const _scrollTop = sidebar.scrollTop; sidebar.innerHTML = ''; if (filteredDirs.length === 0 && filteredFiles.length === 0) { @@ -614,7 +639,8 @@ class FileManagerModule { emptyMsg.style.cssText = 'padding:20px;text-align:center;color:var(--text-muted);'; emptyMsg.textContent = this._searchFilter ? 'No matching files' : '(empty)'; sidebar.appendChild(emptyMsg); - this._updateStatus('0 files'); + this._itemCount = 0; + this._updateSelectionStatus(); return; } @@ -624,14 +650,17 @@ class FileManagerModule { 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;'; + 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 = () => { + 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); @@ -653,27 +682,24 @@ class FileManagerModule { item.dataset.path = fullPath; item.dataset.type = 'file'; item.dataset.selected = (isSelected || isMulti) ? 'true' : 'false'; - const bgColor = (isSelected || 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 + ';'; - if (isSelected || isMulti) { - item.style.borderLeft = '3px solid var(--accent-color)'; - } + 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) => { - // v4.5.78: Ctrl+click or checkbox for multi-select - if (e.ctrlKey || e.metaKey) { - if (this._multiSelect.has(fullPath)) this._multiSelect.delete(fullPath); - else this._multiSelect.add(fullPath); - this._refreshFileList(); - return; - } + // 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(); - const content = this.terminal.fs.readFile(fullPath); - this._renderPreview(fullPath, content); + 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); @@ -691,34 +717,73 @@ class FileManagerModule { sidebar.appendChild(item); }); - const totalCount = entries.length; - this._updateStatus(totalCount + ' item' + (totalCount !== 1 ? 's' : '')); + 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-type="file"]'); + const items = sidebar.querySelectorAll('[data-path]'); items.forEach(item => { - const selected = item.dataset.path === this._selectedFile || this._multiSelect.has(item.dataset.path); - item.dataset.selected = selected ? 'true' : 'false'; - item.style.background = selected ? 'var(--bg-tertiary)' : 'transparent'; - item.style.borderLeft = selected ? '3px solid var(--accent-color)' : '3px solid transparent'; + 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() { - const paths = [...this._multiSelect]; - if (this._selectedFile && !paths.includes(this._selectedFile)) paths.push(this._selectedFile); - return paths; + return [...this._multiSelect]; } _batchCopy() { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'copy', paths: [...paths] }; - this._multiSelect.clear(); this._updateStatus('📋 Copied ' + paths.length + ' item(s)'); } @@ -726,82 +791,104 @@ class FileManagerModule { const paths = this._getSelectedPaths(); if (!paths.length) { this._updateStatus('No files selected'); return; } this._clipboard = { mode: 'cut', paths: [...paths] }; - this._multiSelect.clear(); this._updateStatus('✂️ Cut ' + paths.length + ' item(s)'); } _batchPaste() { - if (!this._clipboard.paths.length) { this._updateStatus('Clipboard empty'); return; } - const dest = '/' + this.currentPath.join('/'); - let count = 0; + 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; } + if (this._exists(destPath) && !confirm('Overwrite ' + destPath + '?')) { skipped++; return; } try { - if (this._clipboard.mode === 'copy') { - const content = this.terminal.fs.readFile(src); - if (content !== null) { this.terminal.fs.writeFile(destPath, content); count++; } - } else if (this._clipboard.mode === 'cut') { - const content = this.terminal.fs.readFile(src); - if (content !== null) { - this.terminal.fs.writeFile(destPath, content); - this.terminal.fs.deleteFile(src); - count++; - } - } - } catch (e) { /* skip errors */ } + 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 = { mode: null, paths: [] }; + 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)'); + 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; } - if (!confirm('Delete ' + paths.length + ' file(s)?')) return; - let count = 0; + if (!confirm('Delete ' + paths.length + ' item(s)?')) return; + const fs = this.terminal.fs; + let count = 0, failed = 0; paths.forEach(p => { - try { this.terminal.fs.deleteFile(p); count++; } catch (e) { /* skip */ } + 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 + ' file(s)'); + this._updateStatus('🗑️ Deleted ' + count + ' item(s)' + (failed ? ' — ' + failed + ' failed' : '')); } _batchRename() { - const path = this._selectedFile || (this._multiSelect.size === 1 ? [...this._multiSelect][0] : null); - if (!path) { this._updateStatus('Select one file to rename'); return; } - const oldName = path.split('/').pop(); - const newName = prompt('Rename to:', oldName); - if (!newName || newName === oldName) return; - const dir = path.substring(0, path.lastIndexOf('/')) || '/'; - const newPath = dir === '/' ? '/' + newName : dir + '/' + newName; - try { - const content = this.terminal.fs.readFile(path); - if (content === null) { this._updateStatus('Cannot read file'); return; } - this.terminal.fs.writeFile(newPath, content); - this.terminal.fs.deleteFile(path); + 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); - } catch (e) { this._updateStatus('Rename failed: ' + e.message); } + 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 fileItems = sidebar.querySelectorAll('[data-type="file"]'); - if (this._multiSelect.size >= fileItems.length) { - this._multiSelect.clear(); + 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 { - fileItems.forEach(item => this._multiSelect.add(item.dataset.path)); + paths.forEach(p => this._multiSelect.add(p)); } this._refreshFileList(); - this._updateStatus(this._multiSelect.size + ' selected'); } _renderPreview(path, content) { @@ -856,20 +943,12 @@ class FileManagerModule { } _actionDelete() { - if (!this._selectedFile) { - this._updateStatus('No file selected to delete'); - return; - } - if (!confirm('Delete ' + this._selectedFile + '?')) return; - const ok = this.terminal.fs.deleteFile(this._selectedFile); - if (ok) { - this._selectedFile = null; - this._refreshFileList(); - this._clearPreview(); - this._updateStatus('✅ Deleted'); - } else { - this._updateStatus('❌ Failed to delete'); + // 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); } + this._batchDelete(); + this._clearPreview(); } _actionDownload() { |