From 1f84c047914cf21df1a1de196d990d193539523d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 15 Sep 2026 22:55:25 +0200 Subject: 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 --- docs/MESHBAY_DESIGN.md | 12 + .../src/meshbay_hub/static/files-app.js | 298 ++++++++++++++++++--- .../src/meshbay_hub/static/locales/de.js | 6 + .../src/meshbay_hub/static/locales/en.js | 6 + .../src/meshbay_hub/static/locales/es.js | 6 + .../src/meshbay_hub/static/locales/fr.js | 6 + .../src/meshbay_hub/static/locales/it.js | 6 + .../src/meshbay_hub/static/locales/ja.js | 6 + .../src/meshbay_hub/static/locales/nl.js | 6 + .../src/meshbay_hub/static/locales/pl.js | 6 + .../src/meshbay_hub/static/locales/pt-BR.js | 6 + .../src/meshbay_hub/static/locales/zh-CN.js | 6 + .../meshbay-hub/src/meshbay_hub/static/style.css | 32 +++ .../src/meshbay_hub/static/transfers.js | 6 + .../src/meshbay_hub/static/transport.js | 12 +- .../meshbay-hub/tests/test_files_drop_upload.py | 105 ++++++++ .../meshbay-hub/tests/test_transport_contracts.py | 9 +- 17 files changed, 492 insertions(+), 42 deletions(-) create mode 100644 packages/meshbay-hub/tests/test_files_drop_upload.py diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md index 6b48300..2b6d1ac 100644 --- a/docs/MESHBAY_DESIGN.md +++ b/docs/MESHBAY_DESIGN.md @@ -1318,6 +1318,18 @@ whether to draw the Upload button and the chat paperclip, and changes are broadc to everyone connected. None of that is the control: a member on an old tab, or one speaking MNP directly, is refused by the node. +**Files can be dropped onto the Files tab**, files and folders alike, under the +Upload button's rule (a folder on screen, in a writable root, in a group — not in +Search). A drop is decided whole before anything is sent: **a name already in the +folder refuses it**, compared without case, because the node's no-overwrite rule +would otherwise store a colliding file under a free name nobody asked for and +refuse a colliding folder half-way through; and a name outside the filename +allowlist refuses it too, from a client copy of the rule that a test holds to the +node's answers. A folder is rebuilt one `dir_create` at a time, parents first, and +its files are fed to the transfer store a few at a time so the member's queue +(§5.5) never reaches its cap. None of this is a control — the node still enforces +every rule above; it is what keeps a drop from ending in a partial copy. + In a group with **no** writable root the interface says so plainly rather than picking one — a fallback that chooses whatever comes first only moves the failure to send time, where the person has already chosen the file. 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}(? 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({ `} + ${dragging && !readOnly && html` +
+
+ ${canDropHere ? t('group.drop_here', { dir: currentPath }) : t('group.drop_not_here')} +
+
+ `} `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index c705588..19004ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -187,6 +187,12 @@ export default { 'group.offline_hint': 'Die Dateien erscheinen, sobald sich ein Node verbindet, der ' + 'diese Gruppe hostet.', 'group.upload': 'Hochladen', + 'group.drop_here': 'Zum Hochladen nach {dir} ablegen', + 'group.drop_not_here': 'Dateien können nur in einem beschreibbaren Ordner abgelegt werden.', + 'group.drop_conflict': 'Hochladen abgebrochen — bereits in diesem Ordner: {names}. Bitte zuerst umbenennen oder entfernen.', + 'group.drop_invalid': 'Hochladen abgebrochen — diese Namen kann der Knoten nicht speichern: {names}', + 'group.drop_unreadable': 'Hochladen abgebrochen — die abgelegten Elemente konnten nicht gelesen werden.', + 'group.drop_mkdir_failed': 'Hochladen abgebrochen — ein Ordner konnte nicht erstellt werden ({detail}).', 'group.mkdir': 'Neuer Ordner', 'group.mkdir_prompt': 'Name des neuen Ordners', 'group.mkdir_offline': 'Nicht mit dem Node verbunden.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 4f0fff2..8084cd0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -187,6 +187,12 @@ export default { 'group.offline_title': 'No nodes are currently online for this group.', 'group.offline_hint': 'Files will appear when a node hosting this group connects.', 'group.upload': 'Upload', + 'group.drop_here': 'Drop to upload into {dir}', + 'group.drop_not_here': 'Files can only be dropped into a folder you can write to.', + 'group.drop_conflict': 'Upload cancelled — already in this folder: {names}. Rename or remove it first.', + 'group.drop_invalid': 'Upload cancelled — these names cannot be stored on the node: {names}', + 'group.drop_unreadable': 'Upload cancelled — the dropped items could not be read.', + 'group.drop_mkdir_failed': 'Upload cancelled — a folder could not be created ({detail}).', 'group.mkdir': 'New folder', 'group.mkdir_prompt': 'New folder name', 'group.mkdir_offline': 'Not connected to the node.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index fa0b0b2..589f49d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -185,6 +185,12 @@ export default { 'group.offline_hint': 'Los archivos aparecerán cuando se conecte un node que aloje ' + 'este grupo.', 'group.upload': 'Subir', + 'group.drop_here': 'Suelta para subir a {dir}', + 'group.drop_not_here': 'Solo se pueden soltar archivos en una carpeta con permiso de escritura.', + 'group.drop_conflict': 'Subida cancelada — ya existe en esta carpeta: {names}. Cámbiale el nombre o elimínalo primero.', + 'group.drop_invalid': 'Subida cancelada — el nodo no puede guardar estos nombres: {names}', + 'group.drop_unreadable': 'Subida cancelada — no se pudieron leer los elementos soltados.', + 'group.drop_mkdir_failed': 'Subida cancelada — no se pudo crear una carpeta ({detail}).', 'group.mkdir': 'Nueva carpeta', 'group.mkdir_prompt': 'Nombre de la nueva carpeta', 'group.mkdir_offline': 'Sin conexión con el nodo.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index b4145ce..8409c6d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -186,6 +186,12 @@ export default { 'group.offline_hint': 'Les fichiers apparaîtront dès qu’un node hébergeant ce ' + 'groupe se connectera.', 'group.upload': 'Envoyer', + 'group.drop_here': 'Déposez pour envoyer dans {dir}', + 'group.drop_not_here': 'On ne peut déposer des fichiers que dans un dossier accessible en écriture.', + 'group.drop_conflict': 'Envoi annulé — déjà présent dans ce dossier : {names}. Renommez-le ou supprimez-le d\'abord.', + 'group.drop_invalid': 'Envoi annulé — ces noms ne peuvent pas être enregistrés sur le nœud : {names}', + 'group.drop_unreadable': 'Envoi annulé — les éléments déposés n\'ont pas pu être lus.', + 'group.drop_mkdir_failed': 'Envoi annulé — un dossier n\'a pas pu être créé ({detail}).', 'group.mkdir': 'Nouveau dossier', 'group.mkdir_prompt': 'Nom du nouveau dossier', 'group.mkdir_offline': 'Non connecté au nœud.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 673f0cc..c501639 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -186,6 +186,12 @@ export default { 'group.offline_hint': 'I file compariranno quando si collegherà un node che ospita ' + 'questo gruppo.', 'group.upload': 'Carica', + 'group.drop_here': 'Rilascia per caricare in {dir}', + 'group.drop_not_here': 'I file si possono rilasciare solo in una cartella scrivibile.', + 'group.drop_conflict': 'Caricamento annullato — già presente in questa cartella: {names}. Rinominalo o rimuovilo prima.', + 'group.drop_invalid': 'Caricamento annullato — il nodo non può salvare questi nomi: {names}', + 'group.drop_unreadable': 'Caricamento annullato — impossibile leggere gli elementi rilasciati.', + 'group.drop_mkdir_failed': 'Caricamento annullato — impossibile creare una cartella ({detail}).', 'group.mkdir': 'Nuova cartella', 'group.mkdir_prompt': 'Nome della nuova cartella', 'group.mkdir_offline': 'Non connesso al nodo.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index c36f2c9..85b9f31 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -184,6 +184,12 @@ export default { 'group.offline_hint': 'このグループをホストしている node が接続すると、' + 'ファイルが表示されます。', 'group.upload': 'アップロード', + 'group.drop_here': 'ドロップして {dir} にアップロード', + 'group.drop_not_here': 'ファイルをドロップできるのは書き込み可能なフォルダーだけです。', + 'group.drop_conflict': 'アップロードを中止しました — このフォルダーに既に存在します: {names}。先に名前を変更するか削除してください。', + 'group.drop_invalid': 'アップロードを中止しました — ノードに保存できない名前があります: {names}', + 'group.drop_unreadable': 'アップロードを中止しました — ドロップされた項目を読み取れませんでした。', + 'group.drop_mkdir_failed': 'アップロードを中止しました — フォルダーを作成できませんでした ({detail})。', 'group.mkdir': '新しいフォルダー', 'group.mkdir_prompt': '新しいフォルダー名', 'group.mkdir_offline': 'ノードに接続していません。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 180c1bb..68e3a7d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -187,6 +187,12 @@ export default { 'group.offline_hint': 'De bestanden verschijnen zodra er een node verbinding maakt ' + 'die deze groep host.', 'group.upload': 'Uploaden', + 'group.drop_here': 'Loslaten om te uploaden naar {dir}', + 'group.drop_not_here': 'Bestanden kunnen alleen in een beschrijfbare map worden losgelaten.', + 'group.drop_conflict': 'Upload geannuleerd — staat al in deze map: {names}. Hernoem of verwijder het eerst.', + 'group.drop_invalid': 'Upload geannuleerd — deze namen kan de node niet opslaan: {names}', + 'group.drop_unreadable': 'Upload geannuleerd — de losgelaten items konden niet worden gelezen.', + 'group.drop_mkdir_failed': 'Upload geannuleerd — een map kon niet worden aangemaakt ({detail}).', 'group.mkdir': 'Nieuwe map', 'group.mkdir_prompt': 'Naam van de nieuwe map', 'group.mkdir_offline': 'Niet verbonden met de node.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 180cdbe..f7db14e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -190,6 +190,12 @@ export default { 'group.offline_title': 'Obecnie żaden node nie jest dostępny dla tej grupy.', 'group.offline_hint': 'Pliki pojawią się, gdy połączy się node hostujący tę grupę.', 'group.upload': 'Wyślij', + 'group.drop_here': 'Upuść, aby wysłać do {dir}', + 'group.drop_not_here': 'Pliki można upuszczać tylko do folderu z prawem zapisu.', + 'group.drop_conflict': 'Wysyłanie anulowane — już jest w tym folderze: {names}. Najpierw zmień nazwę lub usuń.', + 'group.drop_invalid': 'Wysyłanie anulowane — węzeł nie może zapisać tych nazw: {names}', + 'group.drop_unreadable': 'Wysyłanie anulowane — nie udało się odczytać upuszczonych elementów.', + 'group.drop_mkdir_failed': 'Wysyłanie anulowane — nie udało się utworzyć folderu ({detail}).', 'group.mkdir': 'Nowy folder', 'group.mkdir_prompt': 'Nazwa nowego folderu', 'group.mkdir_offline': 'Brak połączenia z węzłem.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 2ae26b2..3a6f55f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -187,6 +187,12 @@ export default { 'group.offline_hint': 'Os arquivos aparecerão quando um node que hospeda este grupo ' + 'se conectar.', 'group.upload': 'Enviar', + 'group.drop_here': 'Solte para enviar para {dir}', + 'group.drop_not_here': 'Só é possível soltar arquivos em uma pasta com permissão de escrita.', + 'group.drop_conflict': 'Envio cancelado — já existe nesta pasta: {names}. Renomeie ou remova primeiro.', + 'group.drop_invalid': 'Envio cancelado — o nó não pode armazenar estes nomes: {names}', + 'group.drop_unreadable': 'Envio cancelado — não foi possível ler os itens soltos.', + 'group.drop_mkdir_failed': 'Envio cancelado — não foi possível criar uma pasta ({detail}).', 'group.mkdir': 'Nova pasta', 'group.mkdir_prompt': 'Nome da nova pasta', 'group.mkdir_offline': 'Sem conexão com o nó.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 6c0ec37..cb84105 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -183,6 +183,12 @@ export default { 'group.offline_title': '当前没有为此群组提供服务的 node 在线。', 'group.offline_hint': '当托管此群组的 node 连接后,文件就会出现。', 'group.upload': '上传', + 'group.drop_here': '拖放以上传到 {dir}', + 'group.drop_not_here': '只能将文件拖放到可写入的文件夹中。', + 'group.drop_conflict': '上传已取消 — 此文件夹中已存在:{names}。请先重命名或删除。', + 'group.drop_invalid': '上传已取消 — 节点无法保存这些名称:{names}', + 'group.drop_unreadable': '上传已取消 — 无法读取拖放的项目。', + 'group.drop_mkdir_failed': '上传已取消 — 无法创建文件夹({detail})。', 'group.mkdir': '新建文件夹', 'group.mkdir_prompt': '新文件夹名称', 'group.mkdir_offline': '未连接到节点。', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 049db0f..f46e5c2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -3624,6 +3624,38 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } gap: 6px; flex-shrink: 0; } + +/* Dropping files onto Files (files-app.js). Fixed and click-through: the drag + events belong to the window, and nothing pinned may move while it shows. */ +.drop-overlay { + position: fixed; + inset: 0; + z-index: 900; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: color-mix(in srgb, var(--accent) 10%, transparent); + outline: 3px dashed var(--accent); + outline-offset: -12px; +} +.drop-overlay.refused { + background: color-mix(in srgb, var(--text-dim) 10%, transparent); + outline-color: var(--border); +} +.drop-overlay-label { + max-width: min(90vw, 480px); + padding: 10px 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-surface); + color: var(--text); + text-align: center; + word-break: break-word; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + .tb-pager-range { font-size: 0.83em; color: var(--text-dim); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js index 524b7f3..9a1455a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transfers.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transfers.js @@ -290,6 +290,12 @@ export class TransferStore { return item.id; } + /** Resolves with a transfer's final status once it has ended, whatever the ending. */ + settled(id) { + const item = this._items.find((it) => it.id === id); + return item ? item.promise.then(() => item.status) : Promise.resolve('gone'); + } + _watchLease(item) { item.lease._onState = (lease) => { if (item.status !== 'queued' && item.status !== 'running') return; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 1f05b8d..77bf522 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -2329,15 +2329,17 @@ class MeshBayTransport { async uploadFile(file, { chunkSize, onProgress, signal, root, dir, tr = '' } = {}) { // The same file twice at once would confuse the node, which keys its own - // upload state by name — and would race for the same destination. The guard - // is by name for that reason, even though the map below is keyed by id. - if (this._inFlightUploads.has(file.name)) { + // upload state by folder and name — and would race for the same + // destination. The guard uses the same key: by name alone, a dropped folder + // holding a `cover.jpg` in two albums failed the second one for nothing. + const inFlightKey = `${dir || ''}/${file.name}`; + if (this._inFlightUploads.has(inFlightKey)) { throw new Error(`${file.name} is already being uploaded`); } if (!this._gekRaw) throw new Error('This group has no key on this device'); const C = window.MeshBayCrypto; const groupId = (this._connectArgs && this._connectArgs.groupId) || ''; - this._inFlightUploads.add(file.name); + this._inFlightUploads.add(inFlightKey); const uploadId = _hex(crypto.getRandomValues(new Uint8Array(16))); const size = chunkSize || UPLOAD_CHUNK_SIZE; const total = Math.max(1, Math.ceil(file.size / size)); @@ -2487,7 +2489,7 @@ class MeshBayTransport { } } finally { this._uploaders.delete(uploadId); - this._inFlightUploads.delete(file.name); + this._inFlightUploads.delete(inFlightKey); } return stored || {}; } diff --git a/packages/meshbay-hub/tests/test_files_drop_upload.py b/packages/meshbay-hub/tests/test_files_drop_upload.py new file mode 100644 index 0000000..5dfaf23 --- /dev/null +++ b/packages/meshbay-hub/tests/test_files_drop_upload.py @@ -0,0 +1,105 @@ +""" +Dropping files and folders onto Files (`files-app.js`). + +What a drop does is decided before anything is sent, and both decisions are +tested here by running the real functions out of the source: + + * a name already in the folder refuses the whole drop — left to the node, a + colliding file is quietly stored as "x (1).jpg" and a colliding folder is + refused after the ones before it were made; + * a name the node would refuse refuses it too, which only works while the + client's copy of the node's filename rule gives the node's answers. That is + a second copy of a rule, so it is checked against the first one here. +""" + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +FILES_APP = STATIC / "files-app.js" + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not FILES_APP.exists(), + reason="node or the SPA sources are not available") + + +@pytest.fixture(scope="module") +def source(): + text = FILES_APP.read_text(encoding="utf-8") + const = re.search(r"^const UPLOAD_NAME = .*;$", text, re.M) + funcs = [re.search(rf"^function {name}\(.*?^\}}", text, re.M | re.S) + for name in ("namesIn", "planDrop")] + assert const and all(funcs), "files-app.js no longer has what this test reads" + return "\n".join([const.group(0)] + [m.group(0) for m in funcs]) + + +def _run(tmp_path, source, expr): + script = tmp_path / "case.js" + script.write_text(f"{source}\nconsole.log(JSON.stringify({expr}));", encoding="utf-8") + out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True) + return json.loads(out.stdout) + + +NAMES = [ + "photo.jpg", "Été 2024", "東京", "track (1).flac", "[live] set", "a_b-c", + "rock & roll", "l’été", "x+y#z@w", "9lives", + ".DS_Store", "_private", "-dash", " space", "trailing ", "trailing.", + "a/b", "a\\b", "semi;colon", "x" * 128, "x" * 129, "", "tab\tname", +] + + +def test_the_client_refuses_exactly_the_names_the_node_refuses(tmp_path, source): + roots = pytest.importorskip("meshbay_node.roots") + node = [bool(roots.SAFE_UPLOAD_NAME.match(n)) for n in NAMES] + client = _run(tmp_path, source, f"{json.dumps(NAMES)}.map((n) => UPLOAD_NAME.test(n))") + differ = [n for n, a, b in zip(NAMES, node, client) if a != b] + assert not differ, f"client and node disagree on: {differ}" + + +def test_names_in_a_folder_count_files_folders_and_empty_folders(tmp_path, source): + entries = [{"path": "music", "name": "a.mp3"}, + {"path": "music/Album", "name": "t.flac"}, + {"path": "musicals", "name": "not-here.txt"}] + got = _run(tmp_path, source, + f"namesIn({json.dumps(entries)}, ['music/Empty', 'music/Album/cd1'], 'music').sort()") + assert got == ["Album", "Empty", "a.mp3"] + + +def _plan(tmp_path, source, items, existing): + return _run(tmp_path, source, f"planDrop({json.dumps(items)}, {json.dumps(existing)})") + + +def test_a_name_already_there_is_a_conflict_whatever_its_case(tmp_path, source): + items = [{"kind": "file", "path": "Photo.JPG"}, + {"kind": "dir", "path": "Album"}, {"kind": "file", "path": "Album/x.jpg"}, + {"kind": "file", "path": "new.txt"}] + plan = _plan(tmp_path, source, items, ["photo.jpg", "album", "other"]) + assert plan["conflicts"] == ["Album", "Photo.JPG"] + + +def test_only_the_top_level_can_conflict(tmp_path, source): + items = [{"kind": "file", "path": "Album/photo.jpg"}] + plan = _plan(tmp_path, source, items, ["photo.jpg"]) + assert plan["conflicts"] == [] + + +def test_a_name_the_node_would_refuse_is_reported_wherever_it_is(tmp_path, source): + items = [{"kind": "dir", "path": "Album"}, + {"kind": "file", "path": "Album/.DS_Store"}, + {"kind": "file", "path": "Album/ok.jpg"}] + plan = _plan(tmp_path, source, items, []) + assert plan["invalid"] == [".DS_Store"] + + +def test_folders_are_created_parents_first_including_implied_ones(tmp_path, source): + items = [{"kind": "file", "path": "A/B/C/deep.txt"}, + {"kind": "dir", "path": "A/Empty"}, + {"kind": "file", "path": "top.txt"}] + plan = _plan(tmp_path, source, items, []) + assert plan["dirs"] == ["A", "A/B", "A/Empty", "A/B/C"] + assert [f["path"] for f in plan["files"]] == ["A/B/C/deep.txt", "top.txt"] diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py index d13cfd4..c10f183 100644 --- a/packages/meshbay-hub/tests/test_transport_contracts.py +++ b/packages/meshbay-hub/tests/test_transport_contracts.py @@ -332,9 +332,12 @@ def test_uploads_are_tracked_per_upload(transport): """Acks interleave when two files are in flight.""" assert "this._uploaders = new Map()" in transport assert "this._uploaders.set(uploadId" in transport - # And the "already being uploaded" guard still speaks in filenames, because - # that is what the caller passed and what it would recognise in the error. - assert "this._inFlightUploads.has(file.name)" in transport + # The "already being uploaded" guard is keyed by folder and name, as the + # node's own upload state is: a dropped folder can hold two files of one name. + assert "const inFlightKey = `${dir || ''}/${file.name}`;" in transport + assert "this._inFlightUploads.has(inFlightKey)" in transport + # And its error still speaks in the filename the caller would recognise. + assert "`${file.name} is already being uploaded`" in transport def test_the_upload_itself_is_sealed(transport): -- cgit v1.2.3