aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-15 22:55:25 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-15 22:55:25 +0200
commit1f84c047914cf21df1a1de196d990d193539523d (patch)
treea818f18127af96349b6f3da8c53212c324672b60
parent459fc93e98f23e326c2fa77fe86ba74c2bae77f0 (diff)
downloadmeshbay-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>
-rw-r--r--docs/MESHBAY_DESIGN.md12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js298
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css32
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transfers.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js12
-rw-r--r--packages/meshbay-hub/tests/test_files_drop_upload.py105
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py9
17 files changed, 492 insertions, 42 deletions
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}(?<![ .])$/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>
+ `}
`;
}
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):