b91db8547a0bb5431b4246b5faaf5696f73b2115
chmalee
  Thu Jul 16 11:25:35 2026 -0700
hubSpace: add Hub Upload menu link, selection banner, and shareable hub links, refs #37705

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index 2c2d84bd049..7623d5c12ec 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -1491,30 +1491,80 @@
 
     function viewHubInGenomeBrowser(hubName) {
         // connect the whole hub in hgTracks, without pack'ing any specific track
         if (typeof uiState.userUrl === "undefined" || uiState.userUrl.length === 0) {
             return;
         }
         let dirRow = uiState.filesHash[hubName];
         if (!dirRow) return;
         let hubUrl = uiState.userUrl + cgiEncode(hubTxtPathForHub(hubName));
         let dbParam = isAssemblyHub(hubName) ? "genome" : "db";
         let genome = dirRow.genome || findHubGenome(hubName) || "";
         let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid() + "&" + dbParam + "=" + genome + "&hubUrl=" + encodeURIComponent(hubUrl);
         window.location.assign(url);
     }
 
+    function hubHasHubTxt(hubName) {
+        // true if the hub directory has a hub.txt file recorded in hubSpace
+        let dir = uiState.filesHash[hubName];
+        if (dir && dir.children) {
+            for (let child of dir.children) {
+                if (child.fileType === "hub.txt") return true;
+            }
+        }
+        return false;
+    }
+
+    function hubShareLink(hubName) {
+        // build an absolute, shareable hgTracks link that connects this hub and
+        // nothing else. No hgsid so the recipient uses their own session.
+        if (typeof uiState.userUrl === "undefined" || uiState.userUrl.length === 0) {
+            return null;
+        }
+        let dirRow = uiState.filesHash[hubName];
+        if (!dirRow) return null;
+        let hubUrl = uiState.userUrl + cgiEncode(hubTxtPathForHub(hubName));
+        let dbParam = isAssemblyHub(hubName) ? "genome" : "db";
+        let genome = dirRow.genome || findHubGenome(hubName) || "";
+        return window.location.origin + "/cgi-bin/hgTracks?" + dbParam + "=" + genome +
+            "&hubUrl=" + encodeURIComponent(hubUrl);
+    }
+
+    function copyLinkIconSvg(title, dataUrl) {
+        // clipboard icon that the table click handler copies from its data-url.
+        // The title is both an attribute and a <title> child so the tooltip works
+        // across browsers (Firefox ignores title on an svg element).
+        let safeUrl = dataUrl.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
+        let safeTitle = title.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
+        return '<svg class="copyLinkIcon" title="' + safeTitle + '" data-url="' + safeUrl + '" style="margin-left: 6px; cursor: pointer; vertical-align:baseline; width:0.8em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><title>' + safeTitle + '</title><path d="M502.6 70.63l-61.25-61.25C435.4 3.371 427.2 0 418.7 0H255.1c-35.35 0-64 28.66-64 64l.0195 256C192 355.4 220.7 384 256 384h192c35.2 0 64-28.8 64-64V93.25C512 84.77 508.6 76.63 502.6 70.63zM464 320c0 8.836-7.164 16-16 16H255.1c-8.838 0-16-7.164-16-16L239.1 64.13c0-8.836 7.164-16 16-16h128L384 96c0 17.67 14.33 32 32 32h47.1V320zM272 448c0 8.836-7.164 16-16 16H63.1c-8.838 0-16-7.164-16-16L47.98 192.1c0-8.836 7.164-16 16-16H160V128H63.99c-35.35 0-64 28.65-64 64l.0098 256C.002 483.3 28.66 512 64 512h192c35.2 0 64-28.8 64-64v-32h-47.1L272 448z"/></svg>';
+    }
+
+    function copyHubLinkFromBanner(ev) {
+        // copy the shareable hub link stashed on the button's data-url
+        ev.stopPropagation();
+        let btn = ev.currentTarget;
+        let url = btn.getAttribute("data-url");
+        if (!url) return;
+        navigator.clipboard.writeText(url).then(function() {
+            let orig = btn.textContent;
+            btn.textContent = "Copied";
+            setTimeout(function() { btn.textContent = orig; }, 1500);
+        }, function() {
+            alert("Failed to copy link: " + url);
+        });
+    }
+
     function showHubBanner(hubName) {
         let banner = document.getElementById("hubBanner");
         let nameSpan = document.getElementById("hubBannerName");
         if (!banner || !nameSpan) return;
         nameSpan.textContent = hubName;
         banner.style.display = "";
     }
 
     function hideHubBanner() {
         let banner = document.getElementById("hubBanner");
         if (banner) banner.style.display = "none";
     }
 
     // helper object so we don't need to use an AbortController to update
     // the data this function is using
@@ -1614,57 +1664,88 @@
         cart.setCgiAndUrl(fileListEndpoint);
         _.forEach(data, (d) => {
             cartData.deleteFile.fileList.push({
                 fileName: d.fileName,
                 fileType: d.fileType,
                 parentDir: d.parentDir,
                 genome: d.genome,
                 fullPath: d.fullPath,
             });
         });
         cart.send(cartData, deleteFileSuccess);
         cart.flush();
     }
 
     function updateSelectedFileDiv(data, isFolderSelect = false) {
-        // update the div that shows how many files are selected
+        // update the div that shows how many files are selected, both below the
+        // table and in a banner above it
         let numSelected = data !== null ? data.length : 0;
+        // the above-table banner is only used in the top level view, not inside a hub
+        let atTopLevel = !uiState.currentHub;
         let infoDiv = document.getElementById("selectedFileInfo");
         let span = document.getElementById("numberSelectedFiles");
         let spanParentDiv = span.parentElement;
+        let banner = document.getElementById("selectedFileBanner");
+        let bannerSpan = document.getElementById("numberSelectedFilesBanner");
         if (numSelected > 0) {
+            let label;
             if (isFolderSelect || span.textContent.endsWith("hub") || span.textContent.endsWith("hubs")) {
-                span.textContent = `${numSelected} ${numSelected > 1 ? "hubs" : "hub"}`;
+                label = `${numSelected} ${numSelected > 1 ? "hubs" : "hub"}`;
             } else {
-                span.textContent = `${numSelected} ${numSelected > 1 ? "files" : "file"}`;
+                label = `${numSelected} ${numSelected > 1 ? "files" : "file"}`;
             }
+            span.textContent = label;
+            bannerSpan.textContent = label;
             // (re) set up the handlers for the selected file info div:
             let viewBtn = document.getElementById("viewSelectedFiles");
             viewBtn.addEventListener("click", viewAllInGenomeBrowser);
             viewBtn.textContent = "View selected";
             let deleteBtn = document.getElementById("deleteSelectedFiles");
             deleteBtn.style.display = "inline-block";
             deleteBtn.addEventListener("click", deleteFileList);
             deleteBtn.textContent = "Delete selected";
+            // mirror the controls in the banner above the table
+            let bannerViewBtn = document.getElementById("viewSelectedFilesBanner");
+            bannerViewBtn.addEventListener("click", viewAllInGenomeBrowser);
+            bannerViewBtn.textContent = "View selected";
+            let bannerDeleteBtn = document.getElementById("deleteSelectedFilesBanner");
+            bannerDeleteBtn.addEventListener("click", deleteFileList);
+            bannerDeleteBtn.textContent = "Delete selected";
+            // when exactly one hub is selected, offer a shareable connect link
+            let copyBtn = document.getElementById("copyHubLinkBanner");
+            let singleHub = (data.length === 1 && data[0].fileType === "dir" &&
+                !data[0].parentDir && hubHasHubTxt(data[0].fullPath)) ? data[0].fullPath : null;
+            let singleHubLink = singleHub ? hubShareLink(singleHub) : null;
+            if (singleHubLink) {
+                copyBtn.textContent = "Copy link to hub";
+                copyBtn.setAttribute("data-url", singleHubLink);
+                copyBtn.addEventListener("click", copyHubLinkFromBanner);
+                copyBtn.style.display = "inline-block";
+            } else {
+                copyBtn.style.display = "none";
+            }
         } else {
             span.textContent = "";
+            bannerSpan.textContent = "";
+            document.getElementById("copyHubLinkBanner").style.display = "none";
         }
 
         // set the visibility of the placeholder text and info text
         spanParentDiv.style.display = numSelected === 0 ? "none": "block";
         let placeholder = document.getElementById("placeHolderInfo");
         placeholder.style.display = numSelected === 0 ? "block" : "none";
+        banner.style.display = (numSelected === 0 || !atTopLevel) ? "none" : "";
     }
 
     function handleCheckboxSelect(evtype, table, selectedRow) {
         // depending on the state of the checkbox, we will be adding information
         // to the div, or removing information. We also potentially checked/unchecked
         // all of the checkboxes if the selectAll box was clicked.
 
         // The data variable will hold all the information we want to keep visible in the info div
         let data = [];
         // The selectedData global holds the actual information needed for the view/delete buttons
         // to work, so data plus any child rows
         selectedData = {};
         // Track only the rows the user directly selected (not children)
         directlySelected = {};
 
@@ -2110,38 +2191,49 @@
             },
             {
                 orderable: false, targets: 1,
                 data: "action", title: "",
                 render: function(data, type, row) {
                     if (type === "display") {
                         return dataTablePrintAction(row);
                     }
                     return '';
                 }
             },
             {
                 targets: 2,
                 render: function(data, type, row, meta) {
                     let decodedName = decodeURIComponent(data);
-                    if (type !== "display" || row.fileType === "dir") {
+                    if (type !== "display") {
                         return decodedName;
                     }
                     if (typeof uiState.userUrl === "undefined" || uiState.userUrl.length === 0) {
                         return decodedName;
                     }
+                    if (row.fileType === "dir") {
+                        // top-level hubs get an icon that copies a shareable connect link
+                        if (!row.parentDir && hubHasHubTxt(row.fullPath)) {
+                            let hubLink = hubShareLink(row.fullPath);
+                            if (hubLink) {
+                                let hubCopyIcon = copyLinkIconSvg("Copy a shareable link that connects this hub", hubLink);
+                                return '<span style="white-space:nowrap">' + decodedName + hubCopyIcon + '</span>';
+                            }
+                        }
+                        return decodedName;
+                    }
                     let fileUrl = uiState.userUrl + cgiEncode(row.fullPath);
-                    let copyIcon = '<svg class="copyLinkIcon" title="Copy file URL to clipboard" data-url="' + fileUrl + '" style="margin-left: 6px; cursor: pointer; vertical-align:baseline; width:0.8em" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M502.6 70.63l-61.25-61.25C435.4 3.371 427.2 0 418.7 0H255.1c-35.35 0-64 28.66-64 64l.0195 256C192 355.4 220.7 384 256 384h192c35.2 0 64-28.8 64-64V93.25C512 84.77 508.6 76.63 502.6 70.63zM464 320c0 8.836-7.164 16-16 16H255.1c-8.838 0-16-7.164-16-16L239.1 64.13c0-8.836 7.164-16 16-16h128L384 96c0 17.67 14.33 32 32 32h47.1V320zM272 448c0 8.836-7.164 16-16 16H63.1c-8.838 0-16-7.164-16-16L47.98 192.1c0-8.836 7.164-16 16-16H160V128H63.99c-35.35 0-64 28.65-64 64l.0098 256C.002 483.3 28.66 512 64 512h192c35.2 0 64-28.8 64-64v-32h-47.1L272 448z"/></svg>';
+                    let copyIcon = copyLinkIconSvg("Copy file URL to clipboard", fileUrl);
                     return '<span style="white-space:nowrap"><a class="fileLink" href="' + fileUrl + '" target="_blank" rel="noopener">' + decodedName + '</a>' + copyIcon + '</span>';
                 }
             },
             {
                 targets: 3,
                 render: function(data, type, row, meta) {
                     if (type === "display") {
                          return dataTablePrintSize(data, type, row, meta);
                     }
                     return data;
                 }
             },
             {
                 targets: 5,
                 render: function(data, type, row) {