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) 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 child so the tooltip works + // across browsers (Firefox ignores title on an svg element). + let safeUrl = dataUrl.replaceAll("&", "&").replaceAll('"', """); + let safeTitle = title.replaceAll("&", "&").replaceAll("<", "<").replaceAll('"', """); + 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 + ''; + } + + 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 '' + decodedName + hubCopyIcon + ''; + } + } + return decodedName; + } let fileUrl = uiState.userUrl + cgiEncode(row.fullPath); - let copyIcon = ''; + let copyIcon = copyLinkIconSvg("Copy file URL to clipboard", fileUrl); return '' + decodedName + '' + copyIcon + ''; } }, { 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) {