diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/app.js | 147 |
1 files changed, 141 insertions, 6 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 3ef0da5..995e7c7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -3,6 +3,7 @@ import { createContext, useContext, } from './vendor/htm-preact.js'; import { t, getLocale, setLocale, LOCALES } from './i18n.js'; +import { ZipStream, entriesUnder } from './zipstream.js'; // ── Constants ──────────────────────────────────────────────────────────────── @@ -1158,6 +1159,107 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, } }, [groupId, token, descDraft, onGroupUpdated]); + /** + * Download a directory as a zip, written straight to disk. + * + * An archive of a group directory is routinely tens of gigabytes, so it is + * never held anywhere: each file is fetched chunk by chunk, decrypted, and + * handed to the zip writer, which hands it to the file the browser opened. + * Peak memory is one chunk plus one small record per file. + * + * Without the File System Access API there is nowhere to stream to, and the + * only alternative is to build the whole thing in memory — so that path is + * offered but says what it costs first. + */ + const downloadDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + + const files = entriesUnder(entries, dir); + if (!files.length) { + setError(t('group.zip_empty')); + return; + } + const totalBytes = files.reduce((n, f) => n + (f.entry.size || 0), 0); + const suggested = (dir.split('/').pop() || 'files') + '.zip'; + + let writable = null; + if (window.showSaveFilePicker) { + const handle = await window.showSaveFilePicker({ + suggestedName: suggested, + types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], + }); + writable = await handle.createWritable(); + } else if (!confirm(t('group.zip_no_stream', { + size: formatSize(totalBytes), name: suggested, + }))) { + return; + } + + setDlState({ name: suggested, progress: 0, total: totalBytes }); + const parts = writable ? null : []; + let written = 0; + + try { + const zip = new ZipStream(async (bytes) => { + if (writable) await writable.write(bytes); + else parts.push(bytes.slice()); + }); + + for (const { entry, name } of files) { + await zip.begin(name, entry.size, + new Date((entry.added_at || 0) * 1000)); + // A zero-byte file has no chunk to ask for; the header and an empty + // descriptor are the whole entry. + const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); + if (totalChunks > 0) await pipelinedDownload( + transport, gekRef.current, entry.id, totalChunks, + (bytes) => { + written += bytes; + setDlState(prev => prev && { ...prev, progress: written }); + }, + // pipelinedDownload writes in order, which is what the archive needs. + { write: (plaintext) => zip.write(plaintext) }); + await zip.end(); + } + await zip.finish(); + + if (writable) { + await writable.close(); + } else { + const url = URL.createObjectURL(new Blob(parts, { type: 'application/zip' })); + const a = document.createElement('a'); + a.href = url; + a.download = suggested; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + setDlState(null); + } catch (err) { + setDlState(null); + if (writable) await writable.abort().catch(() => {}); + if (err.name === 'AbortError') return; + setError(t('group.dl_failed', { err: err.message })); + } + }, [entries]); + + const deleteDirectory = useCallback(async (dir) => { + const transport = transportRef.current; + if (!transport || !transport.connected) return; + try { + const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; + const signFn = (sk && window.MeshBayKeys) + ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) + : null; + await transport.deleteDirectory(dir, signFn); + applyIndex(await transport.fetchIndex()); + } catch (err) { + setError(err.message); + } + }, [applyIndex]); + const deleteFile = useCallback(async (entry) => { const transport = transportRef.current; if (!transport || !transport.connected) return; @@ -1383,17 +1485,50 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, </tr> </thead> <tbody> - ${subdirs.map(d => html` - <tr class="file-row dir-row" onClick=${() => - setCurrentPath(currentPath ? currentPath + '/' + d : d)}> + ${subdirs.map(d => { + const full = currentPath ? currentPath + '/' + d : d; + const inside = entriesUnder(entries, full); + const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0); + return html` + <tr class="file-row dir-row" key=${full} onClick=${() => + setCurrentPath(full)}> <td>\u{1F4C1}</td> <td>${d}/</td> - <td></td> + <td class="file-size">${inside.length ? formatSize(bytes) : ''}</td> <td class="td-type"></td> <td class="td-date"></td> - <td></td> + <td class="file-actions-cell"> + <button class="file-menu-btn" onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(menuOpen === 'dir:' + full ? null : 'dir:' + full); + }}>${'\u{22EE}'}</button> + ${menuOpen === 'dir:' + full && html` + <div class="file-menu"> + ${inside.length > 0 && html` + <button onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(null); + downloadDirectory(full); + }}> + <span class="fmi">${'\u{2B07}'}</span> + ${t('group.download_zip', { n: inside.length })} + </button> + `} + ${operatorPaired && status === 'connected' && html` + <button class="danger" onClick=${(ev) => { + ev.stopPropagation(); + setMenuOpen(null); + if (inside.length) { setError(t('group.rmdir_not_empty')); return; } + if (confirm(t('group.rmdir_confirm', { name: d }))) { + deleteDirectory(full); + } + }}><span class="fmi">${'\u{1F5D1}'}</span> ${t('group.rmdir')}</button> + `} + </div> + `} + </td> </tr> - `)} + `; })} ${sorted.map(e => { const canPreview = ['image', 'video', 'document'].includes(e.type) || e.name.match(/\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i); |