c3788110e8d865774fff8197cf5b47350472ba64
chmalee
  Tue Aug 4 11:26:55 2026 -0700
Fix hubSpace hook error handling and nested hub path handling, refs #37964

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

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index bdc7f01bdf4..cd292b3775f 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -911,32 +911,32 @@
                 "genome": hubCreate.defaultDb(),
                 "fileType": ftype,
                 "parentDir": defaultParentDir,
                 "hubType": "trackHub",
             };
             if (ftype === "2bit") {
                 // This file defines an assembly hub. Default the genome to the
                 // sanitized filename stem; the user can edit it in the file card.
                 defaultMeta.genome = hubCreate.sanitizeGenomeName(file.name);
                 defaultMeta.genomeLabel = defaultMeta.genome;
                 defaultMeta.hubType = "assemblyHub";
             }
             this.uppy.setFileMeta(file.id, defaultMeta);
 
             // When drilled into an assembly hub, inherit and lock its genome.
-            if (hubCreate.uiState.currentHub &&
-                hubCreate.uiState.currentHub === defaultMeta.parentDir) {
+            let openDir = hubCreate.uiState.currentHubPath || hubCreate.uiState.currentHub;
+            if (openDir && openDir === defaultMeta.parentDir) {
                 let existing = hubCreate.uiState.filesHash[defaultMeta.parentDir];
                 if (existing && existing.hubType === "assemblyHub") {
                     this.uppy.setFileMeta(file.id, {
                         genome: existing.genome,
                         genomeLabel: existing.genome,
                         hubType: "assemblyHub",
                         genomeLocked: true,
                     });
                 }
             }
 
             // If a 2bit is in the batch, every sibling file in the same parentDir
             // adopts its genome and gets hubType=assemblyHub. Also handle hub.txt:
             // parse it client-side and, if it declares an assembly hub, mirror
             // those values onto every file (hub.txt wins).
@@ -1012,60 +1012,72 @@
             }
         });
     }
     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: "",
         currentHub: "", // if the user has a hub dir open, set the name here and use it as the default
                         // hub name when uploading a new file with the dir open, otherwise hubNameDefault
+        currentHubPath: "", // full path of the open dir, so we can tell which hub it belongs to
+                            // when it is a subdirectory like myHub/hg38
         isLoggedIn: "",
         maxQuota: 0,
         userQuota: 0,
         userFiles: {}, // same as uiData.userFiles on page load
         filesHash: {}, // for each file, userFiles.fullPath is the key, and then the userFiles.fileList data as the value, with an extra key for the child fullPaths if the file is a directory
     };
 
     let extensionMap = {
         "bigBed": [".bb", ".bigbed"],
         "bam": [".bam"],
         "vcf": [".vcf"],
         "vcfTabix": [".vcf.gz", "vcf.bgz"],
         "bigWig": [".bw", ".bigwig"],
         "hic": [".hic"],
         "cram": [".cram"],
         "bigBarChart": [".bigbarchart"],
         "bigGenePred": [".bgp", ".biggenepred"],
         "bigMaf": [".bigmaf"],
         "bigInteract": [".biginteract"],
         "bigPsl": [".bigpsl"],
         "bigChain": [".bigchain"],
         "bamIndex": [".bam.bai", ".bai"],
         "tabixIndex": [".vcf.gz.tbi", "vcf.bgz.tbi"],
         "hub.txt": ["hub.txt"],
         "2bit": [".2bit"],
         "text": [".txt", ".text"],
     };
 
     function getDefaultHubName() {
-        return uiState.currentHub.length > 0 ? uiState.currentHub : uiState.hubNameDefault;
+        // 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() {
+        // 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;
+        return path ? path.split("/")[0] : "";
     }
 
     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 hubTxtPathForHub(hubName) {
@@ -1847,59 +1859,61 @@
         // clear any fixed searches so we can apply a new one
         let currSearches = table.search.fixed().toArray();
         currSearches.forEach((name) => table.search.fixed(name, null));
     }
 
     function dataTableShowTopLevel(table) {
         // show all the "root" files, which are files (probably mostly directories)
         // with no parentDir
         clearSearch(table);
         // deselect any selected rows like Finder et al when moving into/upto a directory
         table.rows({selected: true}).deselect();
         table.search.fixed("showRoot", function(searchStr, rowData, rowIx) {
             return !rowData.parentDir;
         });
         uiState.currentHub = "";
+        uiState.currentHubPath = "";
         hideHubBanner();
         updateSelectedFileDiv(null);
     }
 
     function dataTableShowDir(table, dirName, dirFullPath) {
         // show the directory and all immediate children of the directory
         clearSearch(table);
         // deselect any selected rows like Finder et al when moving into/upto a directory
         table.rows({selected: true}).deselect();
         // Callers must call table.draw() after this so filter + order changes
         // from showDir/customOrder render in a single redraw.
         table.search.fixed("oneHub", function(searchStr, rowData, rowIx) {
             // calculate the fullPath of this rows parentDir in case the dirName passed
             // to this function has the same name as a parentDir further up in the
             // listing. For example, consider a test/test/tmp.txt layout, where "test"
             // is the parentDir of tmp.txt and the test subdirectory
             let parentDirFull = rowData.fullPath.split("/").slice(0,-1).join("/");
             if (rowData.parentDir === dirName && parentDirFull === dirFullPath) {
                 return true;
             } else if (rowData.fullPath === dirFullPath) {
                 // also return the directory itself
                 return true;
             } else {
                 return false;
             }
         });
         uiState.currentHub = dirName;
+        uiState.currentHubPath = dirFullPath;
         dataTableCreateBreadcrumb(table, dirName, dirFullPath);
-        showHubBanner(dirName);
+        showHubBanner(hubRootForCurrentDir());
         updateSelectedFileDiv(null);
     }
 
     // when we move into a new directory, we remove the row from the table
     // and add it's html into the header, keep the row object around so
     // we can add it back in later
     let oldRowData = null;
     function dataTableCustomOrder(table, dirData) {
         // figure out the order the rows of the table should be in
         // if dirData is null, sort on  uploadTime first
         // if dirData exists, that is the first row, followed by everything else
         // in uploadTime order
         if (!dirData) {
             // make sure the old row can show up again in the table
             let thead = document.querySelector(".dt-scroll-headInner > table:nth-child(1) > thead:nth-child(1)");
@@ -2149,32 +2163,40 @@
                 let allRows = table.rows().indexes();
                 for (let j = 0; j < allRows.length; j++) {
                     let rowData = table.row(allRows[j]).data();
                     if (rowData.fullPath === obj.fullPath) {
                         table.row(allRows[j]).invalidate();
                         break;
                     }
                 }
             }
         }
 
         // show all the new rows we just added, note the double draw, we need
         // to have the new rows rendered to do the order because the order
         // will copy the actual DOM node
         parseFileListIntoHash(uiState.fileList);
-        dataTableShowDir(table, hubDirData.fileName, hubDirData.fullPath);
-        dataTableCustomOrder(table, hubDirData);
+        // stay in the directory the user has open, the upload may have gone into a
+        // subdirectory of the hub and would not be listed at the hub level. Both calls
+        // have to name the same directory, or the row moved into the header and the row
+        // dropped from the table are different ones
+        let showDirData = hubDirData;
+        if (uiState.currentHubPath && uiState.currentHubPath in uiState.filesHash) {
+            showDirData = uiState.filesHash[uiState.currentHubPath];
+        }
+        dataTableShowDir(table, showDirData.fileName, showDirData.fullPath);
+        dataTableCustomOrder(table, showDirData);
         table.draw();
     }
 
     function doRowSelect(evtype, table, indexes) {
         let selectedRow = table.row(indexes);
         let rowTr = selectedRow.node();
         if (rowTr) {
             handleCheckboxSelect(evtype, table, selectedRow);
         }
     }
 
     function indentActionButton(rowTr, rowData) {
         let numIndents = "0px"; //data.parentDir !== "" ? data.fullPath.split('/').length - 1: 0;
         if (rowData.fileType !== "dir") {
             numIndents = "10px";
@@ -2375,31 +2397,31 @@
         });
         if (uiState.isLoggedIn) {
             table.buttons(".uploadButton").enable();
             document.getElementById("rootBreadcrumb").addEventListener("click", function(e) {
                 dataTableShowTopLevel(table);
                 dataTableCustomOrder(table);
                 dataTableEmptyBreadcrumb(table);
                 table.draw();
             });
         } else {
             table.buttons(".uploadButton").disable();
         }
         let hubBannerBtn = document.getElementById("hubBannerViewBtn");
         if (hubBannerBtn) {
             hubBannerBtn.addEventListener("click", function(e) {
-                viewHubInGenomeBrowser(uiState.currentHub);
+                viewHubInGenomeBrowser(hubRootForCurrentDir());
             });
         }
         let hubBannerCopyBtn = document.getElementById("hubBannerCopyBtn");
         if (hubBannerCopyBtn) {
             hubBannerCopyBtn.addEventListener("click", copyHubLinkFromBanner);
         }
         table.on("select", function(e, dt, type, indexes) {
             indexes.forEach(function(i) {
                 doRowSelect(e.type, dt, i);
             });
         });
         table.on("deselect", function(e, dt, type, indexes) {
             indexes.forEach(function(i) {
                 doRowSelect(e.type, dt, i);
             });
@@ -2500,64 +2522,66 @@
                 if (dash) dash.toggleFileCard(true, file.id);
             }
         });
         uppy.on('upload-success', (file, response) => {
             const metadata = file.meta;
             const d = new Date(metadata.lastModified);
             const pad = (num) => String(num).padStart(2, '0');
             const dFormatted = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
             const now = new Date(Date.now());
             const nowFormatted = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
             let newReqObj, hubTxtObj;
             let hubType = metadata.hubType || "trackHub";
             // Multi-segment parentDir (split-hub uploads) needs per-segment rows.
             let parentSegments = metadata.parentDir.split("/");
             let parentLeaf = parentSegments[parentSegments.length - 1];
+            // the hub.txt lives at the hub, not in the subdirectory the file went into
+            let hubRoot = parentSegments[0];
             newReqObj = {
                 "fileName": cgiEncode(metadata.fileName),
                 "fileSize": metadata.fileSize,
                 "fileType": metadata.fileType,
                 "genome": metadata.genome,
                 "parentDir": cgiEncode(parentLeaf),
                 "lastModified": dFormatted,
                 "uploadTime": nowFormatted,
                 "fullPath": cgiEncode(metadata.parentDir) + "/" + cgiEncode(metadata.fileName),
                 "hubType": hubType,
             };
             // from what I can tell, any response we would create in the pre-finish hook
             // is completely ignored for some reason, so we have to fake the other files
             // we would have created with this one file and add them to the table if they
             // weren't already there:
             // Only fabricate a hub.txt row when the backend actually synthesized
             // one. Skip if the user supplied their own *.hub.txt (either already
             // in filesHash from a prior upload, or coming in this same batch -
             // upload-success order is arbitrary so the hub.txt row may not be
             // in filesHash yet when a sibling's upload-success fires).
-            let dirHash = uiState.filesHash[cgiEncode(metadata.parentDir)];
+            let dirHash = uiState.filesHash[cgiEncode(hubRoot)];
             let hubTxtExists = !!(dirHash && dirHash.children &&
                 dirHash.children.some(c => c.fileType === "hub.txt"));
             let batchHasHubTxt = metadata.batchHasHubTxt === "true";
             if (metadata.fileType !== "hub.txt" && !hubTxtExists && !batchHasHubTxt) {
                 hubTxtObj = {
                     "uploadTime": nowFormatted,
                     "lastModified": dFormatted,
                     "fileName": "hub.txt",
                     "fileSize": 0,
                     "fileType": "hub.txt",
                     "genome": metadata.genome,
-                    "parentDir": cgiEncode(parentLeaf),
-                    "fullPath": cgiEncode(metadata.parentDir) + "/hub.txt",
+                    "parentDir": cgiEncode(hubRoot),
+                    "fullPath": cgiEncode(hubRoot) + "/hub.txt",
                     "hubType": hubType,
                 };
             }
             // One dir row per path segment; leaf-only db, matching makeParentDirRows().
             // For split hubs the hub-root dir stays empty regardless of layout.
             let isSplitHub = metadata.batchSplitHub === "true";
             let dirRows = [];
             for (let i = 0; i < parentSegments.length; i++) {
                 let dirFullPath = parentSegments.slice(0, i + 1)
                                                 .map(cgiEncode).join("/");
                 let dirParent = i > 0 ? cgiEncode(parentSegments[i - 1]) : "";
                 let isLeaf = (i === parentSegments.length - 1);
                 let dirDb;
                 if (isSplitHub) {
                     dirDb = (isLeaf && parentSegments.length > 1) ? metadata.genome : "";