aboutsummaryrefslogtreecommitdiff
path: root/modules/ext/filemgr.js
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-26 14:56:44 +0100
committerAgando <roduccomm@gmail.com>2026-10-26 14:56:44 +0100
commit0e3d019e8b2dcc335f02a277039368e8b7d36b03 (patch)
treefd075c9073b9ca6a344b883fd439c4a85bbe9f65 /modules/ext/filemgr.js
parent5512726541dc7f7e7144695677c3e1d63cba02ad (diff)
feat(newos4): mobile download/share UX — deferred blob revoke, multi-select, Web Share, 44px targets
Executes .hermes/plans/2026-10-24_051600-newos4-mobile-download-share.md (approved via the feedback dashboard; marker autonomy: safe). Front-end only, v4.6.94 -> v4.6.95. T1 modules/ext/filemgr.js — URL.revokeObjectURL ran in the same tick as a.click(); iOS Safari and Android Chrome hand the blob: URL to the download manager asynchronously, so the transfer was aborted while the status line still said "Downloaded". Now revoked after 30 s. T2 download honours the multi-selection via _getSelectedPaths() (falling back to _selectedFile), reports "⬇ Downloaded N files" and "❌ Skipped <path>" instead of a silent partial success. T3 share: navigator.share -> clipboard.writeText -> inline read-only <input> (focused, selected). prompt() is suppressed in iOS standalone PWAs and most Android WebViews, which is why tapping 🔗 did nothing. Unresolvable space now reports "🔗 Share unavailable — no space resolved". T4 both preview toolbar buttons: min-height/min-width 44px, touch-action manipulation, 1em, gap 10px (was 24px/0.85em/6px — the smallest tap targets in the UI). T5 _previewShareUrl falls back _resolveSpaceName -> _detectSpaceName (webdav.js:22) -> page path, and returns null rather than emitting /spaces//<path>. T6 the URL helpers are now module-scope pure functions exported for Node, so modules/test/test-download-url.js tests the shipped code instead of a local copy (4 -> 6 cases). Also fixes a latent bug that made require() impossible: `typeof window.webx11` throws under Node because typeof does not protect a member access on an undefined global. Verified on the served bundle (filemgr.js is concatenated into critical-ext-bundle.js, NOT core-bundle.js — the plan's T7 grep pointed at the wrong file): HTTP 200, 129 867 B, 2x navigator.share, 3x fmBuildShareUrl, deferred revoke present, 11x min-height:44px. Live browser probe on /spaces/newos4/main.html: _previewShareUrl('docs/a.md') -> https://webx11.duckdns.org/spaces/newos4/docs/a.md (no double slash); with navigator.share absent and clipboard blocked, clicking 🔗 produced .fm-share-input containing that URL; button style ends min-height:44px;min-width:44px;touch-action:manipulation; toolbar gap:10px. node modules/test/test-download-url.js -> 6 PASS. lint-no-undef clean (55 files). maintain: PASS version v4.6.95 x3, syntax OK, HTTP 200 /main.html. Also adds admin/docs/session-overview-2026-10-26.md (glm-5.3-compressed session record).
Diffstat (limited to 'modules/ext/filemgr.js')
-rw-r--r--modules/ext/filemgr.js140
1 files changed, 104 insertions, 36 deletions
diff --git a/modules/ext/filemgr.js b/modules/ext/filemgr.js
index 01d1da9..120e07e 100644
--- a/modules/ext/filemgr.js
+++ b/modules/ext/filemgr.js
@@ -1,3 +1,35 @@
+// 2026-10-24: pure URL helpers, require-able so modules/test/test-download-url.js tests the
+// shipped code instead of a copy of it. No DOM, no terminal, no module state.
+function fmDetectSpaceName(pathname) {
+ try {
+ const parts = String(pathname || '').split('/').filter(Boolean);
+ const i = parts.indexOf('spaces');
+ return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
+ } catch (e) { return ''; }
+}
+
+function fmExtractSpaceNameFromUrl(url) {
+ if (!url) return '';
+ try {
+ const parts = new URL(url).pathname.split('/').filter(Boolean);
+ const i = parts.indexOf('spaces');
+ return (i >= 0 && i + 1 < parts.length) ? parts[i + 1] : '';
+ } catch (e) { return ''; }
+}
+
+// Returns null when no space can be resolved — never emits /spaces//<path>.
+function fmBuildShareUrl(space, path) {
+ if (!space) return null;
+ const clean = String(path || '').replace(/^\/+/, '');
+ if (!clean) return null;
+ return 'https://webx11.duckdns.org/spaces/' + space + '/' +
+ clean.split('/').map(encodeURIComponent).join('/');
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { fmDetectSpaceName, fmExtractSpaceNameFromUrl, fmBuildShareUrl };
+}
+
class FileManagerModule {
constructor() {
this.commands = {};
@@ -943,24 +975,25 @@ class FileManagerModule {
// 2026-10-21: preview pane had no fullscreen and no share action.
_previewShell(path, innerHtml) {
- return '<div class="fm-preview-toolbar" style="display:flex;gap:6px;justify-content:flex-end;' +
+ return '<div class="fm-preview-toolbar" style="display:flex;gap:10px;justify-content:flex-end;' +
'padding:6px 8px;border-bottom:1px solid var(--border-color);flex-shrink:0">' +
'<button class="fm-btn-fs" title="Fullscreen preview" aria-label="Fullscreen preview" style="background:var(--bg-tertiary);' +
- 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' +
- 'cursor:pointer;font-size:0.85em;min-height:24px;">⛶</button>' +
+ 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' +
+ 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">⛶</button>' +
'<button class="fm-btn-share" title="Copy share link" aria-label="Copy share link" style="background:var(--bg-tertiary);' +
- 'border:1px solid var(--border-color);color:var(--text-color);padding:2px 8px;border-radius:4px;' +
- 'cursor:pointer;font-size:0.85em;min-height:24px;">🔗</button>' +
+ 'border:1px solid var(--border-color);color:var(--text-color);padding:0 10px;border-radius:4px;' +
+ 'cursor:pointer;font-size:1em;min-height:44px;min-width:44px;touch-action:manipulation;">🔗</button>' +
'</div><div class="fm-preview-body" style="flex:1;min-height:0;overflow:auto">' + innerHtml + '</div>';
}
_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('/');
+ let space = (wdav && wdav._resolveSpaceName && wdav._resolveSpaceName()) || '';
+ // T5: the fallback detector already shipped in modules/ext/webdav.js:22 — use it.
+ if (!space && wdav && wdav._detectSpaceName) space = wdav._detectSpaceName() || '';
+ if (!space) space = fmDetectSpaceName(window.location.pathname);
+ return fmBuildShareUrl(space, path); // null when nothing resolves — caller reports it
}
// 2026-10-21: publish VFS file(s) to WebDAV (was terminal-only: webdav-push)
@@ -1000,13 +1033,40 @@ class FileManagerModule {
};
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);
+ if (!url) { this._updateStatus('🔗 Share unavailable — no space resolved'); return; }
+ // T3: prompt() is suppressed in iOS standalone PWAs and most Android WebViews, so the
+ // last resort is a real input the user can select — never a dialog that cannot appear.
+ const inlineFallback = () => {
+ const bar = pane.querySelector('.fm-preview-toolbar');
+ let inp = pane.querySelector('.fm-share-input');
+ if (!inp && bar) {
+ inp = document.createElement('input');
+ inp.className = 'fm-share-input';
+ inp.readOnly = true;
+ inp.setAttribute('aria-label', 'Share link');
+ inp.style.cssText = 'flex:1;min-width:0;min-height:44px;padding:0 10px;font-size:1em;' +
+ 'border:1px solid var(--border-color);border-radius:4px;' +
+ 'background:var(--bg-primary);color:var(--text-color)';
+ inp.value = url;
+ bar.insertBefore(inp, bar.firstChild);
+ }
+ if (inp) { inp.focus(); inp.select(); }
+ this._updateStatus('🔗 Select and copy');
+ };
+ const copyThen = () => {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(url)
+ .then(() => this._updateStatus('🔗 Link copied: ' + url))
+ .catch(inlineFallback);
+ } else { inlineFallback(); }
+ };
+ if (navigator.share) {
+ navigator.share({ title: 'newOS4 file', url })
+ .then(() => this._updateStatus('🔗 Shared'))
+ .catch(copyThen);
+ return;
}
+ copyThen();
};
}
@@ -1037,26 +1097,31 @@ class FileManagerModule {
}
_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;
+ // T2: honour the multi-selection the way every sibling action does (_getSelectedPaths).
+ let targets = this._getSelectedPaths ? this._getSelectedPaths() : [];
+ if (!targets || !targets.length) targets = this._selectedFile ? [this._selectedFile] : [];
+ if (!targets.length) { this._updateStatus('No file selected to download'); return; }
+ const saved = [], skipped = [];
+ for (const p of targets) {
+ const content = this.terminal.fs.readFile(p);
+ if (content === null || content === false) { skipped.push(p); continue; }
+ const fileName = p.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);
+ // T1: iOS Safari / Android Chrome hand blob: to the download manager asynchronously.
+ // Revoking in the same tick aborts the transfer while the UI still said "Downloaded".
+ setTimeout(() => URL.revokeObjectURL(url), 30000);
+ saved.push(fileName);
}
- 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);
+ if (saved.length > 1) this._updateStatus('⬇ Downloaded ' + saved.length + ' files');
+ else if (saved.length === 1) this._updateStatus('⬇ Downloaded: ' + saved[0]);
+ if (skipped.length) this._updateStatus('❌ Skipped ' + skipped.join(', '));
}
_actionEdit() {
@@ -1145,6 +1210,9 @@ class FileManagerModule {
}
}
-if (typeof window.webx11 !== 'undefined' && window.webx11) {
- webx11.moduleManager.registerModule(new FileManagerModule(), 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js');
+// 2026-10-24: `typeof window.webx11` throws under Node (member access on an undefined global),
+// which would make the module un-require-able by modules/test/test-download-url.js.
+if (typeof window !== 'undefined' && window.webx11 && window.webx11.moduleManager) {
+ window.webx11.moduleManager.registerModule(new FileManagerModule(),
+ 'https://webx11.duckdns.org/spaces/newos4/modules/ext/filemgr.js');
}