From 1d94a92936abb3d37a8f9bfb36c8850246fee15d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 5 Oct 2026 12:23:18 +0200 Subject: feat: copy a file's or folder's #/name@owner link from Files, Music, Photos and Search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Copy link" puts the address group-link.js resolves on the clipboard, on the hub's origin rather than the page's, so a link copied in the desktop application is not app://meshbay. Files offers it for one row, from the right-click menu or the toolbar with one row ticked (a phone's way in); Music on one track's menu, whose dots a phone has; Photos on a right-clicked tile and in the lightbox's bar. The video player and the file preview carry a link button next to Download. Applications get a `linkFor(entry | folderPath)` prop (MESHBAY_DESIGN.md §9.2) and offer the action only when it names a link. The group page builds it from the hub's row; Search from each result's own group and its path before the merged views prefixed it, and names no link for a folder of the merged tree, which a group name alone does not identify. harness/copy_link_probe.py mounts the three applications in Chrome and reads what reached the clipboard. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-hub/src/meshbay_hub/static/files-app.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/files-app.js') 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 978090c..b3672c9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js @@ -13,6 +13,7 @@ import { import { useStickyBand } from './sticky.js'; import { Menu, useMenu } from './menu.js'; import { askReport } from './report.js'; +import { copyLink } from './copy-link.js'; // ── Files ──────────────────────────────────────────────────────────────────── // @@ -142,7 +143,7 @@ function FilesPanel({ entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex, isNodeAdmin, operatorPaired, userId, setError, onPreview, showGroup, readOnly, getTransport, onRefreshIndex, showRefresh, onReport, - openDirectory, + openDirectory, linkFor, }) { const [selected, setSelected] = useState(() => new Set()); const [sortKey, setSortKey] = useState('name'); @@ -623,6 +624,14 @@ function FilesPanel({ onSelect: () => run(async () => { for (const d of dirs) await downloadDirectory(d); }, clear) }, + // One file or one folder, ticked (the toolbar, which is also how a phone + // gets at it) or under the pointer. Absent rather than disabled where + // the view can name no link at all (`linkFor` returns null). + linkFor && { key: 'link', icon: 'link', label: t('link.copy'), + disabled: files.length + dirs.length !== 1 + || !linkFor(files.length ? files[0] : dirs[0]), + onSelect: () => run( + () => copyLink(linkFor(files.length ? files[0] : dirs[0])), 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, @@ -905,7 +914,7 @@ function FilesPanel({ const IMAGE_EXTS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)$/i; -function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { +function FilePreview({ entry, transportRef, gekRef, onClose, onDownload, onCopyLink }) { const [phase, setPhase] = useState('loading'); const [progress, setProgress] = useState(0); const [content, setContent] = useState(null); @@ -1000,6 +1009,11 @@ function FilePreview({ entry, transportRef, gekRef, onClose, onDownload }) { }}>
${entry.name} (${formatSize(entry.size)}) + ${onCopyLink && html` + + `} ${onDownload && html`