c8c6f609f1998f92db7e4bbb329e252b50f8edd9
chmalee
  Thu Aug 6 11:42:01 2026 -0700
hubspace: fixing bugs from nightly code review and what Gerardo noted in #37964-note 12

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

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index 8513d758532..13bbe361a37 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -319,31 +319,49 @@
                 // do the elements actually make it into the DOM
                 let ret = h('div', {
                         class: "uppy-Dashboard-FileCard-label",
                         style: "display: inline-block; width: 78%"
                         },
                     // first child of div
                     "Select from popular assemblies:",
                     // second div child
                     h('select', {
                         id: `${file.meta.name}DbSelect`,
                         style: "margin-left: 5px",
                         onChange: e => {
                             let val = e.target.value;
                             let label = e.target.selectedOptions[0].label;
                             let hub = hubCreate.assemblyHubByGenome(val);
-                            let newParentDir = hub ? hub.fileName : hubCreate.uiState.hubNameDefault;
+                            // Keep a hub name the user typed or that came from the
+                            // folder they opened. A genome from one of their assembly
+                            // hubs still moves the file, that hub is the only place
+                            // the genome exists.
+                            // Read the box rather than file.meta, which the file card
+                            // only writes when the card is saved
+                            let pdInput = document.getElementById("uppy-Dashboard-FileCard-input-parentDir");
+                            let currentParentDir = (pdInput ? pdInput.value :
+                                    ((file.meta && file.meta.parentDir) || "")).trim();
+                            let userNamedHub = currentParentDir &&
+                                    currentParentDir !== hubCreate.uiState.hubNameDefault;
+                            let newParentDir;
+                            if (hub) {
+                                newParentDir = hub.fullPath;
+                            } else if (userNamedHub) {
+                                newParentDir = currentParentDir;
+                            } else {
+                                newParentDir = hubCreate.uiState.hubNameDefault;
+                            }
                             // we call onChange here, which will do an onChange with a potentially
                             // stale metadata if the user has also edited parentDir. later we will
                             // fix that up and use the genome name as the recommended parentDir
                             // or a pre-existing hub if one exists
                             onChange(val);
                             file.meta.genome = val;
                             file.meta.genomeLabel = label;
                             file.meta.hubType = hub ? "assemblyHub" : "trackHub";
                             file.meta.parentDir = newParentDir;
                             // Sync the Hub Name field in a later tick. In this
                             // tick its onChange would spread the same stale
                             // state as the genome onChange above and revert
                             // genome; deferring lets genome flush first.
                             setTimeout(function() {
                                 let pd = document.getElementById("uppy-Dashboard-FileCard-input-parentDir");
@@ -446,31 +464,34 @@
             return false;
         }
 
         // If a 2bit is in the batch, propagate its genome/hubType to siblings.
         let batchTwoBit = twoBitsInBatch[0];
         if (batchTwoBit && !isSplitHub) {
             let asmGenome = batchTwoBit.meta.genome;
             if (!asmGenome) {
                 uppy.info(`Error: Genome name is required for ` +
                           `${batchTwoBit.name}. Open the file card and enter ` +
                           `a name for your assembly.`, "error", 5000);
                 return false;
             }
             // Every file in the batch takes its hub root from the hub-defining
             // file, which is the hub.txt when the user supplied one. Editing the
-            // hub name on a file card changes only that file's meta
+            // hub name on a file card changes only that file's meta.
+            // One 2bit means one hub for the whole batch, so a file whose hub
+            // name says otherwise is moved into the 2bit's hub on purpose. Files
+            // headed for a different hub belong in their own batch
             let hubDefiner = Object.values(files).find(looksLikeHubTxt) || batchTwoBit;
             let asmHubRoot = (hubDefiner.meta.parentDir || "").trim().split("/")[0];
             for (let f of Object.values(files)) {
                 f.meta.genome = asmGenome;
                 f.meta.genomeLabel = asmGenome;
                 f.meta.hubType = "assemblyHub";
                 if (asmHubRoot) {
                     // swap the first segment only, a folder drop keeps its subdirectory
                     let segments = (f.meta.parentDir || "").split("/");
                     if (segments.length > 1) {
                         f.meta.parentDir = asmHubRoot + "/" + segments.slice(1).join("/");
                     } else {
                         f.meta.parentDir = asmHubRoot;
                     }
                 }
@@ -985,51 +1006,71 @@
             batchDbGenomeSearchBar.classList.add("uppy-u-reset", "uppy-c-textInput");
             batchDbGenomeSearchBar.type = "text";
             batchDbGenomeSearchBar.id = "batchDbSearchBar";
             batchDbGenomeSearchBar.style.gridArea = "2 / 4 / 2 / 4";
             batchDbGenomeSearchButton = document.createElement("input");
             batchDbGenomeSearchButton.type = "button";
             batchDbGenomeSearchButton.value = "search";
             batchDbGenomeSearchButton.id = "batchDbSearchBarButton";
             batchDbGenomeSearchButton.style.gridArea = "2 / 5 / 2 / 5";
 
             batchDbSelect.addEventListener("change", (ev) => {
                 let files = this.uppy.getFiles();
                 let val = ev.target.value;
                 let label = ev.target.selectedOptions[0].label;
                 let hub = hubCreate.assemblyHubByGenome(val);
-                let newRoot = hub ? hub.fileName : hubCreate.uiState.hubNameDefault;
+                // Keep the hub name the user typed or that came from the folder
+                // they opened, only an untouched default gets replaced. A genome
+                // from one of their assembly hubs still moves the files, that hub
+                // is the only place the genome exists
+                let nameInput = document.getElementById("batchParentDir");
+                let currentRoot = (nameInput ? nameInput.value : "").trim();
+                let keepName = currentRoot && (userSetBatchHubName ||
+                        currentRoot !== hubCreate.uiState.hubNameDefault);
+                let newRoot;
+                if (hub) {
+                    newRoot = hub.fullPath;
+                } else if (keepName) {
+                    newRoot = currentRoot;
+                } else {
+                    newRoot = hubCreate.uiState.hubNameDefault;
+                }
                 for (let [key, file] of Object.entries(files)) {
                     // Keep the file's subdirectory under whatever root the
                     // batch genome change implies; only the root segment
                     // moves.
                     let oldParent = (file.meta && file.meta.parentDir) || "";
                     let segments = oldParent.split("/");
                     let newParent;
                     if (segments.length > 1) {
                         newParent = newRoot + "/" + segments.slice(1).join("/");
                     } else {
                         newParent = newRoot;
                     }
                     let meta = {
                         genome: val,
                         genomeLabel: label,
                         hubType: hub ? "assemblyHub" : "trackHub",
                         parentDir: newParent,
                     };
                     this.uppy.setFileMeta(file.id, meta);
                 }
+                // show where the files actually went. Assigning the value fires no
+                // change event, so this does not count as the user naming the hub
+                if (nameInput) {
+                    nameInput.value = newRoot;
+                }
             });
 
             batchSelectDiv.appendChild(batchSelectText);
             batchSelectDiv.appendChild(batchDbLabel);
             batchSelectDiv.appendChild(batchDbSelect);
             batchSelectDiv.appendChild(batchDbSearchBarLabel);
             batchSelectDiv.appendChild(batchDbGenomeSearchBar);
             batchSelectDiv.appendChild(batchDbGenomeSearchButton);
         }
 
         // the batch change hub name (shown in both modes)
         let batchParentDirLabel = document.createElement("label");
         batchParentDirLabel.textContent = "Hub Name";
         batchParentDirLabel.for = "batchParentDir";
         batchParentDirLabel.style.gridArea = "3 / 1 / 3 / 1";
@@ -1192,33 +1233,38 @@
                 propagateAssemblyHubMeta(this.uppy);
             }
             // The last 2bit leaving takes the assembly hub with it, so let the
             // siblings it stamped go back to being ordinary track files. A hub.txt
             // still in the batch defines the hub on its own, so leave those alone
             if (looksLikeTwoBit(file) &&
                     !this.uppy.getFiles().some(looksLikeTwoBit) &&
                     !this.uppy.getFiles().some(looksLikeHubTxt)) {
                 for (let f of this.uppy.getFiles()) {
                     // a file headed into an existing assembly hub keeps its lock,
                     // that came from the destination and not from the 2bit
                     let dest = hubCreate.uiState.filesHash[f.meta && f.meta.parentDir];
                     if (dest && dest.hubType === "assemblyHub") {
                         continue;
                     }
+                    // the genome was the 2bit's assembly name, which means
+                    // nothing without the 2bit. Clear it so the upload check
+                    // makes the user pick a real genome
                     this.uppy.setFileMeta(f.id, {
                         hubType: "trackHub",
                         genomeLocked: false,
+                        genome: "",
+                        genomeLabel: "",
                     });
                 }
             }
             if (this.uppy.getFiles().length > 1) {
                 // rebuilds only if the batch changed shape, see the signature check
                 this.addBatchSelectsToDashboard();
             }
         });
 
         this.uppy.on("dashboard:modal-open", () => {
             // check if there were already files chosen from before:
             if (this.uppy.getFiles().length > 1) {
                 this.addBatchSelectsToDashboard();
             }
             if (this.uppy.getFiles().length < 2) {
@@ -1241,30 +1287,50 @@
 
         this.uppy.on("dashboard:file-edit-complete", (file) => {
             // check the filename and hubname metadata and warn the user
             // to edit them if they are wrong. unfortunately I cannot
             // figure out how to force the file card to re-toggle
             // and jump back into the editor from here
             if (file) {
                 let fileNameMatch = file.meta.name.match(fileNameRegex);
                 if (!fileNameMatch || fileNameMatch[0] !== file.meta.name) {
                     uppy.info(`Error: File name has special characters, please rename file: '${file.meta.name}' to only include alpha-numeric characters, period, or underscore.`, 'error', 5000);
                 }
                 if (!isValidParentDir(normalizeParentDir(file))) {
                     uppy.info(`Error: Hub path '${file.meta.parentDir}' must be alpha-numeric / period / underscore segments separated by '/'.`, 'error', 5000);
                 }
             }
+            // Renaming the assembly on the 2bit's card leaves its siblings on the
+            // old name, which reads as two genomes in one hub. Restamp them from
+            // the 2bit first, the way adding a file does, and say so since the
+            // user only edited the one card
+            if (file && looksLikeTwoBit(file)) {
+                let asmGenome = file.meta.genome || hubCreate.sanitizeGenomeName(file.name);
+                let renamed = this.uppy.getFiles().filter(
+                    f => f.id !== file.id && f.meta && f.meta.genome !== asmGenome);
+                if (asmGenome && renamed.length) {
+                    let lead;
+                    if (renamed.length === 1) {
+                        lead = "The other file in this batch now uses";
+                    } else {
+                        lead = `The other ${renamed.length} files in this batch now use`;
+                    }
+                    uppy.info(`${lead} the genome "${asmGenome}", since every file ` +
+                              `in the batch goes into this one assembly hub.`, "info", 5000);
+                }
+                propagateAssemblyHubMeta(this.uppy);
+            }
             // a hub name edited on a file card has to reach the batch box too
             refreshBatchHubNameInput(this.uppy);
             warnOnMixedGenomes(this.uppy);
         });
     }
     uninstall() {
         // not really used because we aren't ever uninstalling the uppy instance
         this.uppy.off("file-added");
     }
 }
 
 var hubCreate = (function() {
     let uiState = { // our object for keeping track of the current UI and what to do
         userUrl: "", // the web accesible path where the uploads are stored for this user
         hubNameDefault: "",
@@ -1295,37 +1361,42 @@
         "bigChain": [".bigchain"],
         "bamIndex": [".bam.bai", ".bai"],
         "tabixIndex": [".vcf.gz.tbi", "vcf.bgz.tbi"],
         "hub.txt": ["hub.txt"],
         "2bit": [".2bit"],
         "text": [".txt", ".text"],
     };
 
     function getDefaultHubName() {
         // with a directory open, new files default into that directory, which for a
         // subdirectory is the whole path like myHub/hg38
         let openDir = uiState.currentHubPath || uiState.currentHub;
         return openDir.length > 0 ? openDir : uiState.hubNameDefault;
     }
 
-    function hubRootForCurrentDir() {
+    function hubRootFromPath(path) {
         // the hub is the first path segment: hub.txt and the hub's own row live there
-        // even when the user has drilled down into a subdirectory of it
-        let path = uiState.currentHubPath || uiState.currentHub;
+        // even for a file down in a subdirectory of the hub. Matches
+        // hubRootFromParentDir in hg/lib/userdata.c
         return path ? path.split("/")[0] : "";
     }
 
+    function hubRootForCurrentDir() {
+        // the hub of the directory the user has open
+        return hubRootFromPath(uiState.currentHubPath || uiState.currentHub);
+    }
+
     function sanitizeGenomeName(name) {
         // Strip .2bit, replace non-alphanumeric/_/-/. with _, drop hub_ prefix.
         // Returns empty string if nothing usable is left.
         // The allowed character class [A-Za-z0-9._-] must match the
         // server-side check in src/hg/hgHubConnect/hooks/pre-finish.c.
         if (!name) return "";
         let stem = name.replace(/\.2bit$/i, "");
         stem = stem.replace(/[^A-Za-z0-9._-]/g, "_");
         stem = stem.replace(/^hub_/, "");
         return stem;
     }
 
     function sanitizeHubName(name) {
         // Turn the hub.txt 'hub' line into a name usable as a hubSpace directory.
         // The allowed characters are the ones isValidParentDir accepts in one path
@@ -1865,94 +1936,96 @@
 
     // helper object so we don't need to use an AbortController to update
     // the data this function is using
     let selectedData = {};
     // track which items the user directly selected (vs children of selected directories)
     let directlySelected = {};
     function viewAllInGenomeBrowser(ev) {
         // redirect to hgTracks with these tracks/hubs open
         let data = selectedData;
         if (typeof uiState.userUrl !== "undefined" && uiState.userUrl.length > 0) {
             let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid();
             let genome; // may be multiple genomes in list, just redirect to the first one
                         // TODO: this should probably raise an alert to click through
             let hubsAdded = {};
             _.forEach(data, (d) => {
+                let hubRoot = hubRootFromPath(d.fullPath);
                 if (!genome) {
                     // Hub-level rows carry empty db; fall back via the subtree.
                     genome = d.genome;
-                    let hubRoot = (d.fileType === "dir") ? d.fullPath : d.parentDir;
                     if (!genome && hubRoot) {
                         genome = findHubGenome(hubRoot);
                     }
                     if (genome) {
                         let isAsm = (d.hubType === "assemblyHub") ||
                                     (hubRoot && isAssemblyHub(hubRoot));
                         let dbParam = isAsm ? "genome" : "db";
                         url += "&" + dbParam + "=" + genome;
                     }
                 }
                 if (d.fileType === "hub.txt") {
                     url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(d.fullPath));
                 }
                 else if (d.fileType in extensionMap) {
                     // TODO: tusd should return this location in it's response after
                     // uploading a file and then we can look it up somehow, the cgi can
                     // write the links directly into the html directly for prev uploaded files maybe?
-                    if (!(d.parentDir in hubsAdded)) {
+                    if (!(hubRoot in hubsAdded)) {
                         // NOTE: hubUrls get added regardless of whether they are on this assembly
                         // or not, because multiple genomes may have been requested. If this user
                         // switches to another genome we want this hub to be connected already
                         // Resolve the actual hub.txt filename - user may have
                         // uploaded "<prefix>.hub.txt" rather than literal hub.txt.
-                        let hubDir = d.parentDir.replace(/\/$/, "");
-                        url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(hubTxtPathForHub(hubDir)));
+                        url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(hubTxtPathForHub(hubRoot)));
                     }
-                    hubsAdded[d.parentDir] = true;
+                    hubsAdded[hubRoot] = true;
                     if (d.genome == genome) {
                         // turn the track on if its for this db
                         url += "&" + trackHubFixName(d.fileName) + "=pack";
                     }
                 }
             });
             window.location.assign(url);
             return false;
         }
     }
 
     function deleteFileSuccess(jqXhr, textStatus) {
         deleteFileFromTable(jqXhr.deletedList);
         updateSelectedFileDiv(null);
     }
 
     function deleteFileList(ev) {
         // same as deleteFile() but acts on the selectedData variable
         let data = selectedData;
         // Block deletion of an assembly hub's defining 2bit unless the whole hub
         // is also in this batch. Removing the 2bit alone leaves hub.txt with a
         // twoBitPath pointing at a missing file and the surviving rows still
         // flagged hubType=assemblyHub. The user must delete the entire hub
         // instead, or replace the 2bit by uploading a new one with the same name.
         let selectedValues = Object.values(data);
         let selectedHubDirs = new Set(
             selectedValues.filter(x => x.fileType === "dir").map(x => x.fullPath));
         let blockedTwoBits = [];
         for (let d of selectedValues) {
             if (d.fileType !== "2bit") continue;
-            let hub = uiState.filesHash[d.parentDir];
+            // hubType lives on the hub's own row, which for a 2bit in a
+            // subdirectory is not the directory holding it
+            let hubRoot = hubRootFromPath(d.fullPath);
+            let hub = uiState.filesHash[hubRoot];
             if (!hub || hub.hubType !== "assemblyHub") continue;
-            if (!selectedHubDirs.has(d.parentDir)) blockedTwoBits.push(d);
+            if (!selectedHubDirs.has(hubRoot)) blockedTwoBits.push(d);
         }
         if (blockedTwoBits.length > 0) {
             let names = blockedTwoBits.map(d => d.fullPath).join("\n  ");
             alert(`Cannot delete the following 2bit file(s) because they are part of ` +
                   `an assembly hub:\n  ${names}\n\nDelete the whole hub instead, ` +
                   `or replace the 2bit by uploading a new one with the same name.`);
             return;
         }
         // Only warn about hub.txt deletion if the user directly selected the hub.txt file,
         // not if it's being deleted as part of selecting a whole hub/directory
         let hasDirectlySelectedHubTxt = Object.values(directlySelected).some(d => d.fileType === "hub.txt");
         if (hasDirectlySelectedHubTxt) {
             if (!confirm("Warning: Deleting a hub.txt file will remove your hub and its shareable URL. Are you sure?")) {
                 return;
             }
@@ -2331,31 +2404,31 @@
                 dataTableShowDir(table, rowData.fileName, rowData.fullPath);
                 dataTableCustomOrder(table, rowData);
                 table.draw();
             });
             return folderIcon;
         } else {
             // only offer the button if this is a track file
             if (rowData.fileType !== "hub.txt" && rowData.fileType !== "text" && rowData.fileType !== "tabixIndex" && rowData.fileType !== "bamIndex" && rowData.fileType !== "2bit" && rowData.fileType in extensionMap) {
                 let container = document.createElement("div");
                 let viewBtn = document.createElement("button");
                 viewBtn.textContent = "View in Genome Browser";
                 viewBtn.style.whiteSpace = "nowrap";
                 viewBtn.type = 'button';
                 viewBtn.addEventListener("click", function(e) {
                     e.stopPropagation();
-                    viewInGenomeBrowser(rowData.fileName, rowData.fileType, rowData.genome, rowData.parentDir, rowData.hubType);
+                    viewInGenomeBrowser(rowData.fileName, rowData.fileType, rowData.genome, hubRootFromPath(rowData.fullPath), rowData.hubType);
                 });
                 container.appendChild(viewBtn);
                 return container;
             } else {
                 return null;
             }
         }
     }
 
     function deleteFileFromTable(pathList) {
         // req is an object with properties of an uploaded file, make a new row
         // for it in the filesTable
         let table = $("#filesTable").DataTable();
         let rows = table.rows((idx, data) => pathList.includes(data.fullPath));
         rows.remove().draw();