summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 15:21:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 15:21:30 +0200
commit3d8c1acf785ff7389a68cc515a5c9324dee8de41 (patch)
tree1b7b8c4bf94592007454057bef84d9ba70de2881
parent9f3445d03f106ee3ebd8b4b1bd546a08d9169af7 (diff)
downloadmeshbay-3d8c1acf785ff7389a68cc515a5c9324dee8de41.tar.gz
feat(hub): right-click menu in the Files tab
The toolbar's actions on the row under the pointer, sharing one action list with the toolbar — which keeps showing what does not apply, disabled, while the menu leaves it out. A count only where more than one item is concerned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--docs/USERGUIDE.md3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js151
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/tests/harness/files_menu_probe.py191
-rw-r--r--packages/meshbay-hub/tests/test_files_context_menu.py60
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py4
15 files changed, 354 insertions, 65 deletions
diff --git a/docs/USERGUIDE.md b/docs/USERGUIDE.md
index 4d87aef..0dd5819 100644
--- a/docs/USERGUIDE.md
+++ b/docs/USERGUIDE.md
@@ -252,6 +252,9 @@ file browser — sort, select, download, preview.
- **Delete** — you can remove a file you uploaded yourself; anything else is
the operator's to remove. A folder has to be empty first, so nothing here can
sweep away files you cannot see.
+- **Right-click a file or folder** for the same actions as the toolbar, listing
+ only the ones that apply to it. On a ticked row the menu acts on everything
+ ticked, like the toolbar does.
### Chat
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 6aabea3..ac978e2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -11,6 +11,7 @@ import {
pipelinedDownload, downloadEntry, downloadDirectory as sharedDownloadDirectory,
} from './file-utils.js';
import { useStickyBand } from './sticky.js';
+import { Menu, useMenu } from './menu.js';
// ── Files ────────────────────────────────────────────────────────────────────
//
@@ -152,6 +153,7 @@ function FilesPanel({
// 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
@@ -572,18 +574,72 @@ function FilesPanel({
}
};
- const onlyFile = selectedFiles.length === 1 && selectedDirs.length === 0
- ? selectedFiles[0] : null;
- const deletableFiles = selectedFiles.filter(
- e => isNodeAdmin || (userId && e.uploader_id === userId));
-
- const run = (fn) => {
- setSelected(new Set());
+ 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 —
@@ -593,63 +649,27 @@ function FilesPanel({
// apply are disabled rather than absent. Buttons appearing and vanishing as
// the selection changed made the bar jump about and gave no clue that an
// action existed at all before something was ticked.
- const action = (icon, label, onClick, opts = {}) => html`
- <button class="tb-icon-btn ${opts.danger ? 'danger' : ''}"
- title=${label} aria-label=${label}
- disabled=${!!opts.disabled} onClick=${onClick}>
- <${Icon} name=${icon} />
+ const actionItems = actionsFor(selectedFiles, selectedDirs, true).map(a => html`
+ <button class="tb-icon-btn ${a.danger ? 'danger' : ''}" key=${a.key}
+ title=${a.label} aria-label=${a.label}
+ disabled=${!!a.disabled} onClick=${a.onSelect}>
+ <${Icon} name=${a.icon} />
</button>
- `;
+ `);
- const canPlay = !!(onlyFile && (onlyFile.type === 'video' || onlyFile.type === 'audio'));
- const canView = !!(onlyFile && onlyFile.type !== 'video' && onlyFile.type !== 'audio'
- && canPreview(onlyFile));
- const deletableCount = deletableFiles.length
- + (operatorPaired ? selectedDirs.length : 0);
- // The operator can always delete; anyone else only ever sees the button if
- // something here is theirs to remove. Hiding it from an uploader would take
- // away a right the protocol grants them (draft-v5 §5.1), not just a control.
- const mayEverDelete = isNodeAdmin
- || (userId && entries.some(e => e.uploader_id === userId));
-
- const actionItems = html`
- ${action('play', t('group.play'),
- () => run(() => onPreview(onlyFile)), { disabled: !canPlay })}
- ${action('eye', t('group.view'),
- () => run(() => onPreview(onlyFile)), { disabled: !canView })}
- ${action('download',
- selectedFiles.length
- ? t('group.download_n', { n: selectedFiles.length })
- : t('group.download'),
- () => run(async () => {
- // Awaited one at a time, and each returns as soon as its transfer is
- // registered — so the transfers still run together. Firing them without
- // awaiting meant every file asked the browser for a save dialog at
- // once, and a browser allows one: the rest were rejected and only the
- // first file ever downloaded.
- for (const e of selectedFiles) await downloadFile(e);
- }), { disabled: selectedFiles.length === 0 })}
- ${!readOnly && action('archive',
- selectedDirs.length
- ? t('group.download_zip_n', { n: selectedDirs.length })
- : t('group.download_zip_n', { n: 0 }),
- () => run(async () => {
- for (const d of selectedDirs) await downloadDirectory(d);
- }), { disabled: selectedDirs.length === 0 })}
- ${mayEverDelete && !readOnly && action('trash',
- deletableCount ? t('group.delete_n', { n: deletableCount }) : t('group.delete'),
- async () => {
- const names = [...deletableFiles.map(e => e.name),
- ...(operatorPaired ? selectedDirs : [])];
- 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 selectedDirs) deleteDirectory(d);
- });
- },
- { danger: true, disabled: status !== 'connected' || deletableCount === 0 })}
- `;
+ // 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`
@@ -782,7 +802,8 @@ function FilesPanel({
const isRemovable = rs && rs.removable;
return html`
<tr class="file-row dir-row${isEjected ? ' root-ejected' : ''}" key=${full}
- onClick=${() => { if (!isEjected) setCurrentPath(full); }}>
+ onClick=${() => { if (!isEjected) setCurrentPath(full); }}
+ onContextMenu=${(ev) => onRowMenu(ev, dirKey(d))}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(dirKey(d))}
onClick=${(ev) => ev.stopPropagation()}
@@ -826,7 +847,8 @@ function FilesPanel({
</tr>
`; })}
${sorted.map(e => html`
- <tr class="file-row" key=${e.id}>
+ <tr class="file-row" key=${e.id}
+ onContextMenu=${(ev) => onRowMenu(ev, e.id)}>
<td class="sel-cell">
<input type="checkbox" checked=${selected.has(e.id)}
onClick=${(ev) => ev.stopPropagation()}
@@ -858,6 +880,7 @@ function FilesPanel({
</tbody>
</table>
`}
+ ${menu && html`<${Menu} ...${menu} onClose=${closeMenu} />`}
${dragging && !readOnly && html`
<div class="drop-overlay ${canDropHere ? '' : 'refused'}">
<div class="drop-overlay-label">
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 e742787..730c75b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -717,6 +717,7 @@ export default {
'group.actions': 'Aktionen ({n})',
'group.download_n': 'Herunterladen ({n})',
'group.download_zip_n': 'Ordner als ZIP herunterladen ({n})',
+ 'group.download_zip_one': 'Ordner als ZIP herunterladen',
'group.delete_n': 'Löschen ({n})',
'group.delete_n_confirm': {
one: '{n} Element löschen? {names}',
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 87e5a4a..daa378b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -833,6 +833,7 @@ export default {
'group.actions': 'Actions ({n})',
'group.download_n': 'Download ({n})',
'group.download_zip_n': 'Download folders as zip ({n})',
+ 'group.download_zip_one': 'Download folder as zip',
'group.delete_n': 'Delete ({n})',
'group.delete_n_confirm': {
one: 'Delete {n} item? {names}',
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 9e4040d..3f8261c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -712,6 +712,7 @@ export default {
'group.actions': 'Acciones ({n})',
'group.download_n': 'Descargar ({n})',
'group.download_zip_n': 'Descargar las carpetas en zip ({n})',
+ 'group.download_zip_one': 'Descargar la carpeta en zip',
'group.delete_n': 'Eliminar ({n})',
'group.delete_n_confirm': {
one: '¿Eliminar {n} elemento? {names}',
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 aaa3e19..7a0209e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -715,6 +715,7 @@ export default {
'group.actions': 'Actions ({n})',
'group.download_n': 'Télécharger ({n})',
'group.download_zip_n': 'Télécharger les dossiers en zip ({n})',
+ 'group.download_zip_one': 'Télécharger le dossier en zip',
'group.delete_n': 'Supprimer ({n})',
'group.delete_n_confirm': {
one: 'Supprimer {n} élément ? {names}',
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 a976e09..5b99839 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -715,6 +715,7 @@ export default {
'group.actions': 'Azioni ({n})',
'group.download_n': 'Scarica ({n})',
'group.download_zip_n': 'Scarica le cartelle in zip ({n})',
+ 'group.download_zip_one': 'Scarica la cartella in zip',
'group.delete_n': 'Elimina ({n})',
'group.delete_n_confirm': {
one: 'Eliminare {n} elemento? {names}',
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 2660e7f..b825412 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -707,6 +707,7 @@ export default {
'group.actions': '操作({n})',
'group.download_n': 'ダウンロード({n})',
'group.download_zip_n': 'フォルダーを zip でダウンロード({n})',
+ 'group.download_zip_one': 'フォルダーを zip でダウンロード',
'group.delete_n': '削除({n})',
'group.delete_n_confirm': {
other: '{n} 件を削除しますか?{names}',
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 81ed678..9b6070b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -716,6 +716,7 @@ export default {
'group.actions': 'Acties ({n})',
'group.download_n': 'Downloaden ({n})',
'group.download_zip_n': 'Mappen als zip downloaden ({n})',
+ 'group.download_zip_one': 'Map als zip downloaden',
'group.delete_n': 'Verwijderen ({n})',
'group.delete_n_confirm': {
one: '{n} item verwijderen? {names}',
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 bd91a07..20e721f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -728,6 +728,7 @@ export default {
'group.actions': 'Działania ({n})',
'group.download_n': 'Pobierz ({n})',
'group.download_zip_n': 'Pobierz foldery jako zip ({n})',
+ 'group.download_zip_one': 'Pobierz folder jako zip',
'group.delete_n': 'Usuń ({n})',
'group.delete_n_confirm': {
one: 'Usunąć {n} element? {names}',
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 490f2f9..5506b60 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
@@ -714,6 +714,7 @@ export default {
'group.actions': 'Ações ({n})',
'group.download_n': 'Baixar ({n})',
'group.download_zip_n': 'Baixar as pastas em zip ({n})',
+ 'group.download_zip_one': 'Baixar a pasta em zip',
'group.delete_n': 'Excluir ({n})',
'group.delete_n_confirm': {
one: 'Excluir {n} item? {names}',
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 8609297..21ccd04 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
@@ -696,6 +696,7 @@ export default {
'group.actions': '操作({n})',
'group.download_n': '下载({n})',
'group.download_zip_n': '将文件夹打包为 zip 下载({n})',
+ 'group.download_zip_one': '将文件夹打包为 zip 下载',
'group.delete_n': '删除({n})',
'group.delete_n_confirm': {
other: '删除 {n} 个项目?{names}',
diff --git a/packages/meshbay-hub/tests/harness/files_menu_probe.py b/packages/meshbay-hub/tests/harness/files_menu_probe.py
new file mode 100644
index 0000000..74c33de
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/files_menu_probe.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+"""
+The Files tab's right-click menu, in a real browser.
+
+Mounts the shipped `FilesPanel` on a made-up index — no node, no transport —
+right-clicks rows the way a reader would, and reads back which actions the
+shared `Menu` offered. The toolbar is read too, because it is built from the
+same list and must keep showing what does not apply, disabled.
+
+ files_menu_probe.py
+
+Prints JSON: one entry per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8758
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<div id="root"></div>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale, setLocale } from '/i18n.js';
+import { FilesPanel } from '/files-app.js';
+
+const LOGS = [];
+addEventListener('error', (e) => LOGS.push('error: ' + (e.message || e)));
+const frame = () => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
+
+// Two loose files in `Root/`: a video this reader uploaded, and a text file
+// somebody else did — so Delete applies to one and not the other.
+const ENTRIES = [
+ { id: 'f-video', name: 'clip.mp4', path: 'Root', size: 10, type: 'video',
+ added_at: 1, uploader_id: 'me' },
+ { id: 'f-text', name: 'notes.txt', path: 'Root', size: 5, type: 'document',
+ added_at: 2, uploader_id: 'someone-else' },
+];
+const noop = () => {};
+
+const labels = () => [...document.querySelectorAll('.ctx-menu .ctx-menu-label')]
+ .map((el) => el.textContent);
+const rowNamed = (name) => [...document.querySelectorAll('tr.file-row')]
+ .find((tr) => tr.querySelector('.file-name')
+ && tr.querySelector('.file-name').textContent.trim().startsWith(name));
+const rightClick = async (el) => {
+ // Close whatever the previous case left open, as a click elsewhere would.
+ document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
+ await frame();
+ const r = el.getBoundingClientRect();
+ const ev = new MouseEvent('contextmenu', { bubbles: true, cancelable: true,
+ clientX: r.left + 20, clientY: r.top + 5 });
+ el.dispatchEvent(ev);
+ await frame();
+ return ev.defaultPrevented;
+};
+
+(async () => {
+ const cases = [];
+ try {
+ // English, whatever the machine running this is set to.
+ setLocale('en');
+ await initLocale();
+ render(html`<${FilesPanel} groupId="g" transportRef=${{ current: null }}
+ gekRef=${{ current: null }} status="connected" entries=${ENTRIES}
+ nodeDirs=${['Root']} nodeRoots=${[{ name: 'Root', writable: false }]}
+ setEntries=${noop} setNodeDirs=${noop} setNodeRoots=${noop} applyIndex=${noop}
+ isNodeAdmin=${false} operatorPaired=${false} userId="me" setError=${noop}
+ onPreview=${noop} />`, document.getElementById('root'));
+ await frame();
+
+ let prevented = await rightClick(rowNamed('Root'));
+ cases.push({ case: 'folder', labels: labels(), prevented });
+
+ rowNamed('Root').click();
+ await frame();
+
+ prevented = await rightClick(rowNamed('clip.mp4'));
+ cases.push({ case: 'own video', labels: labels(), prevented });
+
+ prevented = await rightClick(rowNamed('notes.txt'));
+ cases.push({ case: 'someone else\'s text', labels: labels(), prevented });
+
+ for (const name of ['clip.mp4', 'notes.txt']) {
+ rowNamed(name).querySelector('input[type=checkbox]').click();
+ await frame();
+ }
+ prevented = await rightClick(rowNamed('notes.txt'));
+ cases.push({ case: 'ticked row stands for the selection', labels: labels(), prevented,
+ ticked: document.querySelectorAll('tbody input[type=checkbox]:checked').length });
+
+ cases.push({ case: 'toolbar keeps disabled buttons',
+ buttons: document.querySelectorAll('.tb-actions button').length,
+ disabled: document.querySelectorAll('.tb-actions button:disabled').length });
+
+ parent.postMessage({ cases, logs: LOGS }, '*');
+ } catch (err) {
+ parent.postMessage({ error: String(err && (err.stack || err)), logs: LOGS }, '*');
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+addEventListener('message', (e) => {
+ fetch('/log', { method: 'POST', body: JSON.stringify(e.data) });
+});
+const f = document.createElement('iframe');
+f.src = '/case';
+f.style.cssText = 'width:1100px;height:800px;border:0;display:block';
+document.getElementById('frames').appendChild(f);
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.append(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ self._send(FRAME.encode(), "text/html; charset=utf-8")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ if not RECORDS:
+ print(json.dumps({"error": "no measurement"}), file=sys.stderr)
+ return 1
+ print(json.dumps(RECORDS[0], indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_files_context_menu.py b/packages/meshbay-hub/tests/test_files_context_menu.py
new file mode 100644
index 0000000..cfcbd90
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_files_context_menu.py
@@ -0,0 +1,60 @@
+"""
+Right-click in the Files tab opens the shared `Menu` (`menu.js`) with what can
+be done to that row — the toolbar's actions, from the same list.
+
+The one difference from the toolbar is deliberate: the toolbar keeps an action
+that does not apply, disabled, so it does not jump about as the selection
+changes; a menu has no layout to keep still, so it leaves the action out.
+
+Measured in a browser by `harness/files_menu_probe.py`.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "files_menu_probe.py"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=90)
+ data = json.loads(proc.stdout)
+ assert "error" not in data, f"probe failed: {proc.stdout}{proc.stderr}"
+ assert not data["logs"], data["logs"]
+ return {c["case"]: c for c in data["cases"]}
+
+
+def test_a_folder_offers_the_zip_only(cases):
+ c = cases["folder"]
+ assert c["prevented"]
+ assert c["labels"] == ["Download folder as zip"]
+
+
+def test_a_video_of_ones_own_can_be_played_downloaded_and_deleted(cases):
+ assert cases["own video"]["labels"] == ["Play", "Download", "Delete"]
+
+
+def test_what_does_not_apply_is_left_out_not_greyed(cases):
+ # Someone else's text file: no Play, no zip, and no Delete — the reader
+ # has no right to it, so the entry is absent rather than disabled.
+ assert cases["someone else's text"]["labels"] == ["View", "Download"]
+
+
+def test_a_ticked_row_stands_for_the_whole_selection(cases):
+ c = cases["ticked row stands for the selection"]
+ assert c["ticked"] == 2
+ assert c["labels"] == ["Download (2)", "Delete"]
+
+
+def test_the_toolbar_still_greys_what_does_not_apply(cases):
+ c = cases["toolbar keeps disabled buttons"]
+ assert c["buttons"] == 5
+ assert c["disabled"] == 3
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
index ba042d1..1c36d09 100644
--- a/packages/meshbay-hub/tests/test_spa_ordering.py
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -273,7 +273,9 @@ def test_a_multi_file_download_waits_for_each_picker():
# Anchored on the loop rather than on the markup around it: the toolbar
# moved from a dropdown to icon buttons and took the old wrapper with it,
# while the property under test — one picker at a time — did not change.
- block = app[app.index("for (const e of selectedFiles)"):]
+ # The loop is over `files` since the toolbar and the right-click menu
+ # share one action list.
+ block = app[app.index("for (const e of files)"):]
block = block[:block.index("\n")]
assert "await downloadFile(e)" in block, (
"downloads are fired without awaiting again; only the first will ask "