diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-15 22:55:25 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-15 22:55:25 +0200 |
| commit | 1f84c047914cf21df1a1de196d990d193539523d (patch) | |
| tree | a818f18127af96349b6f3da8c53212c324672b60 /packages/meshbay-hub/src/meshbay_hub/static/files-app.js | |
| parent | 459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (diff) | |
| download | meshbay-1f84c047914cf21df1a1de196d990d193539523d.tar.gz | |
feat(hub): drop files and folders onto Files to upload them
Into the folder on screen, under the Upload button's rule. A name already
there, or one the node would refuse, cancels the whole drop with a message.
Folders are recreated level by level; files go out a few at a time. The
in-flight upload guard is keyed by folder and name.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/files-app.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/files-app.js | 298 |
1 files changed, 264 insertions, 34 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 638bc76..518c917 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -24,6 +24,94 @@ import { useStickyBand } from './sticky.js'; // 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}(?<![ .])$/u; + +// How many dropped files are handed to the transfer store at once. The node +// queues at most 32 transfers per member (transfers.py) and refuses past that, +// so a folder of a few hundred files is fed in as earlier ones finish. +const UPLOAD_BATCH = 8; + +// The names already in folder `dir`, files and folders both — from the index +// and the node's own directory listing, so an empty folder counts. +function namesIn(entries, nodeDirs, dir) { + const names = new Set(); + const prefix = dir + '/'; + for (const e of entries) { + const p = e.path || ''; + if (p === dir) names.add(e.name); + else if (p.startsWith(prefix)) names.add(p.slice(prefix.length).split('/')[0]); + } + for (const d of nodeDirs) { + if (d.startsWith(prefix)) names.add(d.slice(prefix.length).split('/')[0]); + } + return [...names]; +} + +// What a drop would do, decided before any of it is done. `items` are +// `{ kind: 'file'|'dir', path }` relative to the folder dropped into. +// +// A name already there refuses the whole drop. Left to the node, a colliding +// file is stored under a free name ("x (1).jpg") and a colliding folder is +// refused half-way through, after the folders before it were made — neither is +// what anyone dropping a file expects. Compared without case, because on +// NTFS and exFAT roots the two *are* the same name. +function planDrop(items, existing) { + const taken = new Set(existing.map((n) => 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; +} + function FilesPanel({ groupId, transportRef, gekRef, status, entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, @@ -84,49 +172,81 @@ function FilesPanel({ 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 = ''; - const transport = transportRef.current; - if (!files.length || !transport || !transport.connected) return; // 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]; - setError(''); - - for (const file of files) { - 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: uploadRoot, - dir: uploadDir, - tr: lease && lease.tr, - }); - // 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, currentPath]); + 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); @@ -288,6 +408,109 @@ function FilesPanel({ // 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 @@ -605,6 +828,13 @@ function FilesPanel({ </tbody> </table> `} + ${dragging && !readOnly && html` + <div class="drop-overlay ${canDropHere ? '' : 'refused'}"> + <div class="drop-overlay-label"> + ${canDropHere ? t('group.drop_here', { dir: currentPath }) : t('group.drop_not_here')} + </div> + </div> + `} `; } |