diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 15:21:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 15:21:30 +0200 |
| commit | 3d8c1acf785ff7389a68cc515a5c9324dee8de41 (patch) | |
| tree | 1b7b8c4bf94592007454057bef84d9ba70de2881 /packages/meshbay-hub | |
| parent | 9f3445d03f106ee3ebd8b4b1bd546a08d9169af7 (diff) | |
| download | meshbay-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>
Diffstat (limited to 'packages/meshbay-hub')
14 files changed, 351 insertions, 65 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js index 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 " |