import { html, useState, useEffect, useRef, useCallback, } from './vendor/htm-preact.js'; import { t } from './i18n.js'; import { ask } from './ask.js'; import { Icon } from './icon.js'; import { entriesUnder } from './zipstream.js'; import { transfers } from './transfers.js'; import { FILE_ICONS, formatSize, formatDate, canPreview, CHUNK_SIZE, MEMORY_CEILING, pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory, } from './file-utils.js'; import { useStickyBand } from './sticky.js'; import { Menu, useMenu } from './menu.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. // ── Dropping files and folders ─────────────────────────────────────────────── // The node's SAFE_UPLOAD_NAME (roots.py), for files and folder names alike. // Checked before anything is sent, so a dropped folder holding one name the // node refuses is refused whole instead of arriving with holes in it. The node // still decides; `test_files_drop_upload.py` holds the two to the same answers. const UPLOAD_NAME = /^[\p{L}\p{N}][\p{L}\p{N}_ .\-()[\]'’,&+#@]{0,127}(? n.toLowerCase())); const tops = [...new Set(items.map((i) => i.path.split('/')[0]))]; const conflicts = tops.filter((n) => taken.has(n.toLowerCase())).sort(); const invalid = [...new Set(items.flatMap((i) => i.path.split('/')))] .filter((n) => !UPLOAD_NAME.test(n)).sort(); // Every folder, including one only implied by a file's path, parents first: // the node creates one level at a time. const dirs = new Set(); for (const i of items) { const parts = i.path.split('/'); const depth = i.kind === 'dir' ? parts.length : parts.length - 1; for (let d = 1; d <= depth; d++) dirs.add(parts.slice(0, d).join('/')); } const orderedDirs = [...dirs].sort((a, b) => (a.split('/').length - b.split('/').length) || a.localeCompare(b)); return { conflicts, invalid, dirs: orderedDirs, files: items.filter((i) => i.kind === 'file') }; } // The dropped entries, which must be taken during the drop event itself: the // DataTransfer is emptied as soon as the handler returns. function droppedEntries(dataTransfer) { return [...(dataTransfer.items || [])] .filter((it) => it.kind === 'file' && it.webkitGetAsEntry) .map((it) => it.webkitGetAsEntry()) .filter(Boolean); } async function walkEntries(roots) { const out = []; const visit = async (entry, prefix) => { const path = prefix ? `${prefix}/${entry.name}` : entry.name; if (entry.isFile) { const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); out.push({ kind: 'file', path, file }); } else if (entry.isDirectory) { out.push({ kind: 'dir', path }); const reader = entry.createReader(); // readEntries answers in batches (a hundred in Chromium) until it // answers with none. for (;;) { const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); if (!batch.length) break; for (const child of batch) await visit(child, path); } } }; for (const root of roots) await visit(root, ''); return out; } // One ordering for the whole listing. Folders stay above files and are ordered // among themselves by the same column: a folder's size is everything under it, // its date the newest file in it. It has no type, so sorted by type folders // fall back to their names. Rows are `{ name, size, date, type }`; a tie goes // to the name, ascending, so equal sizes do not shuffle between renders. // // Folders used to be `[...dirs].sort()` whatever the column — so reversing the // name sort or sorting by size moved the files and left every folder where it // was, which in a folder of folders is nothing moving at all. function sortRows(rows, key, asc) { const byName = (a, b) => (a.name || '').localeCompare(b.name || ''); return [...rows].sort((a, b) => { let cmp; if (key === 'size') cmp = (a.size || 0) - (b.size || 0); else if (key === 'date') cmp = (a.date || 0) - (b.date || 0); else if (key === 'type') cmp = (a.type || '').localeCompare(b.type || ''); else cmp = byName(a, b); if (!asc) cmp = -cmp; return cmp || byName(a, b); }); } function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, }) { const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); const [sortAsc, setSortAsc] = useState(true); const [filter, setFilter] = useState(''); const [currentPath, setCurrentPath] = useState(''); const [refreshing, setRefreshing] = useState(false); // The toolbar pins below the page's own band and tells the column heads how // far down to pin. Its height is not a constant — it wraps to three rows on // a phone and grows a field while a folder is being named — so it is // measured rather than written down (sticky.js). const toolbarBand = useStickyBand('--toolbar-h'); const { menu, openAt, close: closeMenu } = useMenu(); // Only the cross-group Search page shows this (`showRefresh`): it has no // live node connection pushing index deltas, so its file list really is // a one-shot cached fetch. In a real group the node streams every change // as it happens, so a manual re-fetch would just duplicate that. const doRefresh = useCallback(async () => { if (refreshing || !onRefreshIndex) return; setRefreshing(true); try { await onRefreshIndex(); } finally { setRefreshing(false); } }, [refreshing, onRefreshIndex]); // 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) => { let transport, gek; if (getTransport) { try { const conn = await getTransport(entry); transport = conn.transport; gek = conn.gek; } catch { setError(t('search.unreachable', { n: 1 })); return; } } else { transport = transportRef.current; gek = gekRef.current; } // Never a silent return. A click that produces nothing at all — no // transfer, no icon, no message — is indistinguishable from a broken // button, and it is what a download looks like whenever the WebRTC // connection is not up: on a screen lock, mid-reconnect, or after the node // restarted. Say so instead. if (!transport || !transport.connected) { setError(t('group.download_offline')); return; } await downloadEntry(transfers, transport, gek, entry); }, [getTransport]); // `jobs` are `{ file, dir }`, all under one root. Fed to the transfer store // UPLOAD_BATCH at a time; the Upload button's few files simply all fit in // the first batch. const startUploads = useCallback((jobs, { root }) => { const transport = transportRef.current; if (!jobs.length || !transport || !transport.connected) return; setError(''); let next = 0; let live = 0; let stopped = false; let refreshTimer = null; // The node re-indexes on a filesystem event, so there is nothing to wait // on but the clock. Refreshing means the file appears in the list without // anyone reloading — once for a burst, not once per file of a folder. const refresh = () => { clearTimeout(refreshTimer); refreshTimer = setTimeout(async () => { try { if (transport.connected) applyIndex(await transport.fetchIndex()); } catch { /* next one */ } }, 2500); }; const pump = () => { while (!stopped && live < UPLOAD_BATCH && next < jobs.length) { if (!transport.connected) { stopped = true; return; } const { file, dir } = jobs[next++]; live += 1; const id = transfers.start({ kind: 'upload', name: file.name, total: file.size, transport, // `makeLease`, not `lease`: pausing gives the slot back, so resuming // has to be able to ask for another one, and a transfer handed a lease // it cannot re-create is refused the button rather than offered one // that would drop its slot for good. makeLease: () => transport.openTransfer({ kind: 'upload', bytes: file.size }), // A `File` is seekable and the node remembers how much it holds, so // there is no target tier to consult here — unlike a download. pausable: true, run: async ({ signal, onProgress, lease }) => { await transport.uploadFile(file, { // Bytes the node acknowledged, not bytes read locally. onProgress: (sent) => onProgress(sent, file.size), signal, root, dir, tr: lease && lease.tr, }); }, }); transfers.settled(id).then((finalStatus) => { live -= 1; refresh(); // Deferred, so that "cancel all" — every live transfer ending in the // same tick — is seen as that, and the rest of the folder is not // started behind it. Cancelling one file lets the others go on. setTimeout(() => { if (finalStatus === 'cancelled' && live === 0) stopped = true; pump(); }, 0); }); } }; pump(); }, [applyIndex]); const uploadFile = useCallback((e) => { const files = [...(e.target.files || [])]; e.target.value = ''; // The folder on screen is the destination — not its root, and not a // subdirectory of the node's invention. Somebody dropping a file into the // folder they are looking at expects it to be in that folder. const uploadDir = currentPath; if (!uploadDir) return; const uploadRoot = uploadDir.split('/')[0]; startUploads(files.map((file) => ({ file, dir: uploadDir })), { root: uploadRoot }); }, [startUploads, currentPath]); // `null` while not creating one; a draft string while the field is open. const [newDirName, setNewDirName] = useState(null); const [creatingDir, setCreatingDir] = useState(false); /** * Create a folder in the directory being browsed. * * The name comes from a field in the toolbar rather than `window.prompt`, * which **throws** in Electron — "prompt() is not supported" — and threw * outside this function's try, so clicking the button did nothing at all: * no folder, no error, nothing in the interface to react to. `prompt` is the * one Chromium leaves to the embedder and Electron declines to implement; * `confirm()` and `alert()` open but leave the window unable to type after * them, which is why `ask.js` exists. * * An inline field is better anyway — it can show the refusal next to the * input instead of after the dialog has closed. */ useEffect(() => { setNewDirName(null); }, [currentPath]); const makeDirectory = useCallback(async (rawName) => { const transport = transportRef.current; const name = (rawName || '').trim(); if (!name) return; if (!transport || !transport.connected) { setError(t('group.mkdir_offline')); return; } setCreatingDir(true); try { await transport.createDirectory(currentPath, name); // `list_dirs` walks the filesystem rather than the index, so a folder // with nothing in it is here on this very fetch. const indexMsg = await transport.fetchIndex(); if (indexMsg.entries) setEntries(indexMsg.entries); if (indexMsg.dirs) setNodeDirs(indexMsg.dirs); if (indexMsg.roots) setNodeRoots(indexMsg.roots); setNewDirName(null); } catch (err) { setError(err.message); } finally { setCreatingDir(false); } }, [currentPath]); // The implementation lives in file-utils.js (docs/MESHBAY_DESIGN.md §9.9) so // photos-app.js's own "zip this album" button can call the same code // rather than a second one. const downloadDirectory = useCallback(async (dir) => { const transport = transportRef.current; await sharedDownloadDirectory( transfers, transport, gekRef.current, entries, dir, { setError }); }, [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]); // With text in the field the user is searching, not browsing: match every // file in the whole group, wherever it lives, by name or by the folder it // sits in — and hide the folder rows, there is nothing to walk into. The // old code only ever filtered files whose folder was exactly `currentPath`, // so at the top of a group — where the rows are roots, never loose files — // typing did nothing at all. const q = filter.trim().toLowerCase(); const dirs = new Set(); const filteredEntries = entries.filter(e => { const ePath = e.path || ''; if (q) { return (e.name || '').toLowerCase().includes(q) || ePath.toLowerCase().includes(q); } if (ePath === currentPath) return true; 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 = sortRows( filteredEntries.map((e) => ({ name: e.name, size: e.size, date: e.added_at, type: e.type, entry: e })), sortKey, sortAsc).map((r) => r.entry); // The node's own listing, so an empty folder is visible, plus anything implied // by a file path in case the two ever disagree. Skipped while searching — the // results are a flat list of matches, not a folder view. if (!q) 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); } } // What each folder holds, once — its size and date are sort keys now, not // only the figure in its row. const dirInfo = new Map([...dirs].map((d) => { const inside = entriesUnder(entries, currentPath ? currentPath + '/' + d : d); return [d, { inside, bytes: inside.reduce((n, f) => n + (f.entry.size || 0), 0), newest: inside.reduce((n, f) => Math.max(n, f.entry.added_at || 0), 0), }]; })); const subdirs = sortRows( [...dirs].map((d) => ({ name: d, size: dirInfo.get(d).bytes, date: dirInfo.get(d).newest })), sortKey, sortAsc).map((r) => r.name); // 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); const currentRootName = currentPath ? currentPath.split('/')[0] : ''; const currentRoot = currentRootName ? rootState.get(currentRootName) : null; const currentRootWritable = currentRoot ? Boolean(currentRoot.writable) : 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. // // Otherwise the rule is the same as the Upload button's, and for the same // reason the node gives: "making a directory is not a privileged act — a // member who can add a file can organise where it goes". It used to require // `isNodeAdmin`, which contradicted the node and hid the control from // everyone who could actually use it. const canCreateDir = Boolean(currentPath) && currentRootWritable && !readOnly; // Dropping files and folders in, under the same rule as Upload and New // folder — a folder on screen, in a writable root, in a group. // // Taken on the window rather than on the table: an empty folder has almost no // table to aim at, and a file released a few pixels off a drop zone is opened // by the browser in place of the group. So every file drag over this tab is // caught, and the overlay says where it will land or why it will not. const canDropHere = canCreateDir && status === 'connected'; const [dragging, setDragging] = useState(false); const dropRef = useRef(null); dropRef.current = { canDropHere, drop: async (roots, looseFiles) => { if (!canDropHere) return; const dir = currentPath; const transport = transportRef.current; if (!transport || !transport.connected) { setError(t('group.mkdir_offline')); return; } let items; try { items = roots.length ? await walkEntries(roots) // No entry API: plain files only, which is what such a browser offers. : looseFiles.map((file) => ({ kind: 'file', path: file.name, file })); } catch { setError(t('group.drop_unreadable')); return; } if (!items.length) return; const plan = planDrop(items, namesIn(entries, nodeDirs, dir)); if (plan.conflicts.length) { setError(t('group.drop_conflict', { names: plan.conflicts.join(', ') })); return; } if (plan.invalid.length) { setError(t('group.drop_invalid', { names: plan.invalid.join(', ') })); return; } setError(''); if (plan.dirs.length) { try { for (const d of plan.dirs) { const cut = d.lastIndexOf('/'); await transport.createDirectory(cut < 0 ? dir : `${dir}/${d.slice(0, cut)}`, d.slice(cut + 1)); } } catch (err) { setError(t('group.drop_mkdir_failed', { detail: err.message })); return; } finally { try { applyIndex(await transport.fetchIndex()); } catch { /* the uploads refresh it */ } } } startUploads(plan.files.map((f) => { const cut = f.path.lastIndexOf('/'); return { file: f.file, dir: cut < 0 ? dir : `${dir}/${f.path.slice(0, cut)}` }; }), { root: dir.split('/')[0] }); }, }; useEffect(() => { if (readOnly) return undefined; let depth = 0; const carriesFiles = (e) => [...((e.dataTransfer && e.dataTransfer.types) || [])] .includes('Files'); const onEnter = (e) => { if (!carriesFiles(e)) return; e.preventDefault(); depth += 1; setDragging(true); }; const onOver = (e) => { if (!carriesFiles(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = dropRef.current.canDropHere ? 'copy' : 'none'; }; const onLeave = (e) => { if (!carriesFiles(e)) return; depth = Math.max(0, depth - 1); if (!depth) setDragging(false); }; const onDrop = (e) => { if (!carriesFiles(e)) return; e.preventDefault(); depth = 0; setDragging(false); const roots = droppedEntries(e.dataTransfer); const looseFiles = [...(e.dataTransfer.files || [])]; dropRef.current.drop(roots, looseFiles).catch((err) => setError(err.message)); }; window.addEventListener('dragenter', onEnter); window.addEventListener('dragover', onOver); window.addEventListener('dragleave', onLeave); window.addEventListener('drop', onDrop); return () => { window.removeEventListener('dragenter', onEnter); window.removeEventListener('dragover', onOver); window.removeEventListener('dragleave', onLeave); window.removeEventListener('drop', onDrop); }; }, [readOnly]); 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 allVisibleKeys = [...subdirs.map(d => dirKey(d)), ...sorted.map(e => e.id)]; const allSelected = allVisibleKeys.length > 0 && allVisibleKeys.every(k => selected.has(k)); const someSelected = allVisibleKeys.some(k => selected.has(k)); const toggleAll = () => { if (allSelected) { setSelected(new Set()); } else { setSelected(prev => { const next = new Set(prev); for (const k of allVisibleKeys) next.add(k); return next; }); } }; const run = (fn, clear = true) => { if (clear) setSelected(new Set()); Promise.resolve().then(fn).catch(err => { if (err && err.name !== 'AbortError') setError(err.message); }); }; // 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)); // What can be done to these files and folders — one list for the toolbar, // which acts on the ticked rows and shows the ones that do not apply // disabled, and for the right-click menu, which acts on the row under the // pointer and leaves them out. `clear` is false for the menu opened on an // unticked row: acting on that row must not throw away a selection it was // never part of. const actionsFor = (files, dirs, clear) => { const onlyFile = files.length === 1 && dirs.length === 0 ? files[0] : null; const deletableFiles = files.filter( e => isNodeAdmin || (userId && e.uploader_id === userId)); const deletableCount = deletableFiles.length + (operatorPaired ? dirs.length : 0); const canPlay = !!(onlyFile && (onlyFile.type === 'video' || onlyFile.type === 'audio')); const canView = !!(onlyFile && onlyFile.type !== 'video' && onlyFile.type !== 'audio' && canPreview(onlyFile)); return [ { key: 'play', icon: 'play', label: t('group.play'), disabled: !canPlay, onSelect: () => run(() => onPreview(onlyFile), clear) }, { key: 'view', icon: 'eye', label: t('group.view'), disabled: !canView, onSelect: () => run(() => onPreview(onlyFile), clear) }, { key: 'download', icon: 'download', label: files.length > 1 ? t('group.download_n', { n: files.length }) : t('group.download'), disabled: files.length === 0, onSelect: () => 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 files) await downloadFile(e); }, clear) }, !readOnly && { key: 'archive', icon: 'archive', label: dirs.length === 1 ? t('group.download_zip_one') : t('group.download_zip_n', { n: dirs.length }), disabled: dirs.length === 0, onSelect: () => run(async () => { for (const d of dirs) await downloadDirectory(d); }, clear) }, mayEverDelete && !readOnly && { key: 'delete', icon: 'trash', danger: true, label: deletableCount > 1 ? t('group.delete_n', { n: deletableCount }) : t('group.delete'), disabled: status !== 'connected' || deletableCount === 0, onSelect: async () => { const names = [...deletableFiles.map(e => e.name), ...(operatorPaired ? dirs : [])]; if (!await ask(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 dirs) deleteDirectory(d); }, clear); } }, ].filter(Boolean); }; // 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 actionItems = actionsFor(selectedFiles, selectedDirs, true).map(a => html` `); // Right-click on a row. A ticked row stands for the whole selection, as in // any file manager; an unticked one for itself alone. A menu has no layout // to keep still, so what does not apply is left out rather than greyed — // and with nothing left, the browser's own menu is not taken away. const onRowMenu = (e, key) => { const [files, dirs] = selected.has(key) ? [selectedFiles, selectedDirs] : typeof key === 'string' && key.startsWith('dir:') ? [[], [key.slice(4)]] : [entries.filter(x => x.id === key), []]; const items = actionsFor(files, dirs, selected.has(key)).filter(a => !a.disabled); if (items.length) openAt(e, items); }; 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`
${currentPath && currentRootWritable && html` `} ${/* Icon only: the toolbar already carries a labelled primary action, and a second one beside it competes with it for the width a breadcrumb trail needs. The name lives in the tooltip and in aria-label, so it is not lost to anyone reading with something other than their eyes. */''} ${canCreateDir && newDirName === null && html` `} ${canCreateDir && newDirName !== null && html` setNewDirName(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') makeDirectory(newDirName); if (e.key === 'Escape') setNewDirName(null); }} /> `}
${!readOnly && html``}
${actionItems}
${showGroup && html``} ${currentPath && html` { const parts = currentPath.split('/'); setCurrentPath(parts.slice(0, -1).join('/')); }}> ${showGroup && html``} `} ${subdirs.map(d => { const full = currentPath ? currentPath + '/' + d : d; const { inside, bytes } = dirInfo.get(d); const rs = rootState.get(d); const isEjected = rs && rs.ejected; const isUnavail = unavailableHere.includes(d); const isRemovable = rs && rs.removable; return html` { if (!isEjected) setCurrentPath(full); }} onContextMenu=${(ev) => onRowMenu(ev, dirKey(d))}> ${/* `file-name`, like a file's own name cell. Without it this was a bare ${showGroup && html``} `; })} ${sorted.map(e => html` onRowMenu(ev, e.id)}> ${showGroup && html``} `)} ${sorted.length === 0 && subdirs.length === 0 && html` `}
{ if (el) el.indeterminate = someSelected && !allSelected; }} onChange=${toggleAll} /> toggleSort('name')}> ${t('group.col_name')} ${sortKey === 'name' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('size')}> ${t('group.col_size')} ${sortKey === 'size' ? (sortAsc ? '▲' : '▼') : ''} ${t('search.col_group')} toggleSort('type')}> ${t('group.col_type')} ${sortKey === 'type' ? (sortAsc ? '▲' : '▼') : ''} toggleSort('date')}> ${t('group.col_date')} ${sortKey === 'date' ? (sortAsc ? '▲' : '▼') : ''}
${'\u{1F4C1}'} ..
ev.stopPropagation()} onChange=${() => toggle(dirKey(d))} /> ${isEjected ? '\u{23CF}' : isUnavail ? '\u{26A0}' : '\u{1F4C1}'}, so a folder called `Rage_Against_The_Machine_Discography_1992-2000_FLAC` — one unbreakable word, which is how music libraries are named — set the column's minimum width to the whole string. Measured on a phone: a 527px table in a 390px window, and on Android a page wider than the screen takes every pinned header out of the visible area with it. */''} ${d}${isEjected ? html` ${t('group.root_ejected')} ` : isUnavail ? html` ${t('group.root_unavailable')} ` : ''}${rs && rs.writable && !isEjected ? html` ✎ ` : ''}${isRemovable && isNodeAdmin && operatorPaired ? html` ` : ''} ${inside.length ? formatSize(bytes) : ''}
ev.stopPropagation()} onChange=${() => toggle(e.id)} /> ${FILE_ICONS[e.type] || FILE_ICONS.other} ${canPreview(e) ? html` onPreview(e)}>${e.name}` : e.name } ${q && e.path && html` { setFilter(''); setCurrentPath(e.path); }}>${e.path}`} ${formatSize(e.size)} ${e.groupName || ''} ${e.type} ${formatDate(e.added_at)}
${filter ? t('group.empty_filter') : t('group.empty_dir')}
`} ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`} ${dragging && !readOnly && html`
${canDropHere ? t('group.drop_here', { dir: currentPath }) : t('group.drop_not_here')}
`} `; } // ── 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 () => { // Nothing here streams: a preview is decrypted whole, held as an array of // chunks, and turned into a blob. That is right for a page of text and a // photograph, and it is a dead tab for the things that also reach here — // a scanned PDF, a multi-gigabyte .csv or .log. There was no size test at // all, and the text branch is the sharpest illustration: it decoded the // entire file and then kept 500 000 characters of it. // // Films and music never arrive (group-page.js's onPreview routes video to // the MSE player and audio to the music queue), so this guard is only ever // met by a document somebody clicked without knowing how big it was. It // offers the download instead, which does stream. if (entry.size > MEMORY_CEILING) { setError(t('preview.too_large', { size: formatSize(entry.size), limit: formatSize(MEMORY_CEILING), })); setPhase('error'); return; } 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 };