From 979254cf303af562574e6a6a2c86416fe2d1e7e8 Mon Sep 17 00:00:00 2001 From: Agando Date: Mon, 26 Oct 2026 14:56:44 +0100 Subject: feat(newos4): mobile download/share UX — deferred blob revoke, multi-select, Web Share, 44px targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " instead of a silent partial success. T3 share: navigator.share -> clipboard.writeText -> inline read-only (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//. 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). --- modules/ext/filemgr.js | 140 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 104 insertions(+), 36 deletions(-) (limited to 'modules/ext/filemgr.js') 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//. +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 '
' + '' + + '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;">⛶' + '' + + '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;">🔗' + '
' + 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('/'); + 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'); } -- cgit v1.2.3