import { html, useState, useEffect, useRef, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { Icon } from './icon.js'; import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; import { FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, _openDownloadTarget, _saveBlob, pipelinedDownload, downloadEntry, } from './file-utils.js'; // ── Files ──────────────────────────────────────────────────────────────────── // // The group's file browser: toolbar (upload, mkdir, filter, select), the // table itself, and every file/directory operation. `entries`/`nodeDirs`/ // `nodeRoots` are owned by the group shell (group-page.js) — Chat needs the // same index for its image attachments — and handed down here read-only // alongside `applyIndex`/the raw setters to write back through, the same // shape ChatPanel already takes for `onRefreshIndex`. // // `onPreview` opens a file in the shell's video/preview modal rather than // this component owning that state itself, again because more than one tab // (Chat's attachments) can trigger it. function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, mayUpload, userId, setError, onPreview, }) { const [selecting, setSelecting] = useState(false); const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); const [filter, setFilter] = useState(''); const [currentPath, setCurrentPath] = useState(''); // A directory from the group just left rarely exists in the one just // entered (e.g. "outputs" in one group, absent in another) — Files would // otherwise show that stale path and list nothing. useEffect(() => { setCurrentPath(''); setSelected(new Set()); setFilter(''); }, [groupId]); const downloadFile = useCallback(async (entry) => { const transport = transportRef.current; if (!transport || !transport.connected) return; // Both of these have to happen inside the click: a browser grants a file // picker, and re-grants a folder, only from a user gesture — so this stays // a click handler all the way down into downloadEntry's own picker call. await downloadEntry(transfers, transport, gekRef.current, entry); }, []); const uploadFile = useCallback((e) => { const files = [...(e.target.files || [])]; e.target.value = ''; const transport = transportRef.current; if (!files.length || !transport || !transport.connected) return; setError(''); for (const file of files) { transfers.start({ kind: 'upload', name: file.name, total: file.size, transport, run: async ({ signal, onProgress }) => { await transport.uploadFile(file, { // Bytes the node acknowledged, not bytes read locally. onProgress: (sent) => onProgress(sent, file.size), signal, }); // The node re-indexes on a filesystem event, so there is nothing to // wait on but the clock. Refreshing here means the file appears in // the list without anyone reloading. await new Promise(r => setTimeout(r, 2500)); if (transport.connected) applyIndex(await transport.fetchIndex()); }, }); } }, [applyIndex]); const makeDirectory = useCallback(async () => { const transport = transportRef.current; if (!transport || !transport.connected) return; const name = prompt(t('group.mkdir_prompt')); if (!name || !name.trim()) return; try { await transport.createDirectory(currentPath, name.trim()); const indexMsg = await transport.fetchIndex(); if (indexMsg.entries) setEntries(indexMsg.entries); if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); if (indexMsg.roots) setNodeRoots(indexMsg.roots); } catch (err) { setError(err.message); } }, [currentPath]); /** * 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'; // totalBytes decides how this is delivered, but it is not the archive's // size — headers and the central directory come on top — so it is not // announced as a Content-Length that the download would then miss. const target = await _openDownloadTarget(suggested, totalBytes, { types: [{ description: 'ZIP archive', accept: { 'application/zip': ['.zip'] } }], }, 0); if (target === false) return; if (!target && !confirm(t('group.zip_no_stream', { size: formatSize(totalBytes), name: suggested, }))) { return; } const gek = gekRef.current; const zipOpenRef = { url: null }; transfers.start({ kind: 'download', name: (target && target.name) || suggested, total: totalBytes, transport, open: target ? (target.open || null) : () => { if (zipOpenRef.url) window.open(zipOpenRef.url, '_blank'); }, run: async ({ signal, onProgress }) => { const writable = target ? target.writable : null; 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, gek, entry.id, totalChunks, (bytes) => { written += bytes; onProgress(written, totalBytes); }, // pipelinedDownload writes in order, which the archive needs. { write: (plaintext) => zip.write(plaintext) }, signal); await zip.end(); } await zip.finish(); if (writable) await writable.close(); else { const blob = new Blob(parts, { type: 'application/zip' }); _saveBlob(blob, suggested); zipOpenRef.url = URL.createObjectURL(blob); } } catch (err) { if (writable) await writable.abort().catch(() => {}); throw err; } }, }); }, [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; try { // Signs an explicit transcript built by transport.js, not opaque bytes from // the node — see MeshBayCrypto.adminTranscript and finding H5. // Signed with the identity this node pinned for us — the only one it // will accept, and the only one we hold here. const sk = transport.sessionKeys && transport.sessionKeys.skEdB64; const signFn = (sk && window.MeshBayKeys) ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript) : null; await transport.deleteFile(entry.id, signFn); applyIndex(await transport.fetchIndex()); } catch (err) { setError(err.message); } }, [applyIndex]); const toggleSort = useCallback((key) => { setSortAsc(prev => sortKey === key ? !prev : true); setSortKey(key); }, [sortKey]); const dirs = new Set(); const filteredEntries = entries.filter(e => { const ePath = e.path || ''; if (ePath === currentPath) { return !filter || e.name.toLowerCase().includes(filter.toLowerCase()); } if (!currentPath && ePath) { dirs.add(ePath.split('/')[0]); } else if (currentPath && ePath.startsWith(currentPath + '/')) { const rest = ePath.slice(currentPath.length + 1); dirs.add(rest.split('/')[0]); } return false; }); const sorted = [...filteredEntries].sort((a, b) => { let cmp = 0; if (sortKey === 'name') cmp = a.name.localeCompare(b.name); else if (sortKey === 'size') cmp = a.size - b.size; else if (sortKey === 'type') cmp = a.type.localeCompare(b.type); else if (sortKey === 'date') cmp = a.added_at - b.added_at; return sortAsc ? cmp : -cmp; }); // The node's own listing, so an empty folder is visible, plus anything implied // by a file path in case the two ever disagree. for (const d of nodeDirs) { if (!currentPath && !d.includes('/')) dirs.add(d); else if (currentPath && d.startsWith(currentPath + '/')) { const rest = d.slice(currentPath.length + 1); if (!rest.includes('/')) dirs.add(rest); } } const subdirs = [...dirs].sort(); // At the top of a group the folders on screen ARE the roots, so their state // belongs there. Deeper in, everything shown lives inside one readable root // and there is nothing to flag. const rootState = new Map(nodeRoots.map(r => [r.name, r])); const unavailableHere = currentPath ? [] : subdirs.filter(d => rootState.get(d) && rootState.get(d).available === false); // A member cannot create a folder at the top of a group: that level is the // set of roots, which is the operator's configuration and not a directory on // anyone's disk. The node refuses it, so offering it would only produce an // error nobody can act on. const canCreateDir = Boolean(currentPath) && isNodeAdmin; const breadcrumbs = currentPath ? currentPath.split('/') : []; // Selection is keyed globally — file ids, and 'dir:' plus a full path — so // walking into another folder keeps what was already ticked. const dirKey = (name) => 'dir:' + (currentPath ? currentPath + '/' + name : name); const selectedFiles = entries.filter(e => selected.has(e.id)); const selectedDirs = [...selected] .filter(k => typeof k === 'string' && k.startsWith('dir:')) .map(k => k.slice(4)); const toggle = (key) => setSelected(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0 ? selectedFiles[0] : null; const deletableFiles = selectedFiles.filter( e => isNodeAdmin || (userId && e.uploader_id === userId)); const run = (fn) => { setSelecting(false); setSelected(new Set()); Promise.resolve().then(fn).catch(err => { if (err && err.name !== 'AbortError') setError(err.message); }); }; // Icon only, with the name in the tooltip: these sit in a toolbar that is // already narrow, and every one of them is a verb the icon carries on its // own. `title` gives the hover text and `aria-label` the accessible name — // an icon button with neither is unusable with a screen reader. // // Every action is rendered as soon as Select is on, and the ones that do not // apply are disabled rather than absent. Buttons appearing and vanishing as // the selection changed made the bar jump about and gave no clue that an // action existed at all before something was ticked. const action = (icon, label, onClick, opts = {}) => html` `; const canPlay = !!(onlyFile && onlyFile.type === 'video'); const canView = !!(onlyFile && onlyFile.type !== 'video' && canPreview(onlyFile)); const deletableCount = deletableFiles.length + (operatorPaired ? selectedDirs.length : 0); // The operator can always delete; anyone else only ever sees the button if // something here is theirs to remove. Hiding it from an uploader would take // away a right the protocol grants them (draft-v5 §5.1), not just a control. const mayEverDelete = isNodeAdmin || (userId && entries.some(e => e.uploader_id === userId)); const actionItems = html` ${action('play', t('group.play'), () => run(() => onPreview(onlyFile)), { disabled: !canPlay })} ${action('eye', t('group.view'), () => run(() => onPreview(onlyFile)), { disabled: !canView })} ${action('download', selectedFiles.length ? t('group.download_n', { n: selectedFiles.length }) : t('group.download'), () => run(async () => { // Awaited one at a time, and each returns as soon as its transfer is // registered — so the transfers still run together. Firing them without // awaiting meant every file asked the browser for a save dialog at // once, and a browser allows one: the rest were rejected and only the // first file ever downloaded. for (const e of selectedFiles) await downloadFile(e); }), { disabled: selectedFiles.length === 0 })} ${action('archive', selectedDirs.length ? t('group.download_zip_n', { n: selectedDirs.length }) : t('group.download_zip_n', { n: 0 }), () => run(async () => { for (const d of selectedDirs) await downloadDirectory(d); }), { disabled: selectedDirs.length === 0 })} ${mayEverDelete && action('trash', deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), () => { const names = [...deletableFiles.map(e => e.name), ...(operatorPaired ? selectedDirs : [])]; if (!confirm(t('group.delete_n_confirm', { n: names.length, names: names.join(', ') }))) return; run(() => { for (const e of deletableFiles) deleteFile(e); if (operatorPaired) for (const d of selectedDirs) deleteDirectory(d); }); }, { danger: true, disabled: status !== 'connected' || deletableCount === 0 })} `; return html` ${(status === 'discovering' || status === 'connecting' || status === 'fetching') && html`

${' '}${t('status.connecting_short')}

`} ${status === 'offline' && html`

${t('group.offline_title')} ${t('group.offline_hint')}

`} ${status === 'connected' && html`
${mayUpload && html` `} ${canCreateDir && html` `}
${selecting && html`
${actionItems}
`}
${selecting && html``} ${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` selecting ? toggle(dirKey(d)) : setCurrentPath(full)}> ${selecting && html` `} `; })} ${sorted.map(e => html` selecting && toggle(e.id)}> ${selecting && html` `} `)} ${sorted.length === 0 && subdirs.length === 0 && html` `}
toggleSort('name')}> ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('size')}> ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('type')}> ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('date')}> ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
ev.stopPropagation()} onChange=${() => toggle(dirKey(d))} /> ${unavailableHere.includes(d) ? '\u{26A0}' : '\u{1F4C1}'} ${d}${unavailableHere.includes(d) ? html` ${t('group.root_unavailable')} ` : ''} ${inside.length ? formatSize(bytes) : ''}
ev.stopPropagation()} onChange=${() => toggle(e.id)} /> ${FILE_ICONS[e.type] || FILE_ICONS.other} ${!selecting && canPreview(e) ? html` onPreview(e)}>${e.name}` : e.name } ${formatSize(e.size)} ${e.type} ${formatDate(e.added_at)}
${filter ? t('group.empty_filter') : t('group.empty_dir')}
`} `; } // ── File Preview (text, images) ───────────────────────────────────────── const TEXT_EXTS = /\.(txt|md|json|csv|log|xml|yaml|yml|ini|conf|py|js|html|css|sh|c|h|java|rs|go|rb|toml)$/i; const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [content, setContent] = useState(null); const [error, setError] = useState(''); const [downloading, setDownloading] = useState(false); const blobUrlRef = useRef(null); useEffect(() => { let cancelled = false; const load = async () => { const transport = transportRef.current; if (!transport || !transport.connected) { setError(t('video.err_transport')); setPhase('error'); return; } try { const totalChunks = Math.ceil(entry.size / CHUNK_SIZE); let downloaded = 0; const chunks = await pipelinedDownload( transport, gekRef.current, entry.id, totalChunks, (bytes) => { downloaded += bytes; setProgress(downloaded / entry.size); }, ); if (cancelled) return; if (/\.pdf$/i.test(entry.name)) { // Decrypted here and shown from a blob: URL — the bytes never leave // the page, and the browser's own viewer renders them. const blob = new Blob(chunks, { type: 'application/pdf' }); blobUrlRef.current = URL.createObjectURL(blob); setContent({ type: 'pdf' }); } else if (entry.name.match(IMAGE_EXTS)) { const ext = entry.name.split('.').pop().toLowerCase(); const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; const blob = new Blob(chunks, { type: mime }); blobUrlRef.current = URL.createObjectURL(blob); setContent({ type: 'image' }); } else { const decoder = new TextDecoder('utf-8', { fatal: false }); const text = chunks.map(c => decoder.decode(c, { stream: true })).join(''); setContent({ type: 'text', text: text.slice(0, 500000) }); } setPhase('ready'); } catch (err) { if (!cancelled) { setError(err.message); setPhase('error'); } } }; load(); return () => { cancelled = true; }; }, [entry]); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); useEffect(() => { return () => { if (blobUrlRef.current) { URL.revokeObjectURL(blobUrlRef.current); blobUrlRef.current = null; } }; }, []); return html`
{ if (e.target.classList.contains('video-overlay')) onClose(); }}>
${entry.name} (${formatSize(entry.size)}) ${onDownload && html` `}
${phase === 'loading' && html`
${t('video.loading', { name: entry.name })}
`} ${phase === 'ready' && content?.type === 'pdf' && html`

${t('preview.pdf_fallback')}

`} ${phase === 'ready' && content?.type === 'image' && html`
${entry.name}
`} ${phase === 'ready' && content?.type === 'text' && html`
${content.text}
`} ${phase === 'error' && html`
${error}
`}
`; } export { FilesPanel, FilePreview };