aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-18 16:42:34 +0200
committerAgando <roduccomm@gmail.com>2026-10-18 16:42:34 +0200
commitcdf391ed5817f4b15d8312955ad38e31992e062b (patch)
tree81698d9607d0793783617121ddf1461625e43ea0
parent3e1a1d27a15fbf0673ef327f39595339737848c1 (diff)
newOS4 v4.6.35: file manager multi-select + safe paste/rename/delete (Part B)
-rw-r--r--main.html6
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/ext/critical-ext-bundle.js241
-rw-r--r--modules/ext/filemgr.js241
4 files changed, 324 insertions, 166 deletions
diff --git a/main.html b/main.html
index 607e1f0..37b2a01 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.34"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.35"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.34"></script>
+<script src="modules/core/module-loader.js?v=v4.6.35"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.34"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.35"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
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() {