summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/app.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js147
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);