404d5bb6d8c0418d5f06535ef470e36c35d2a237
chmalee
  Thu Apr 16 15:57:56 2026 -0700
Add assembly hub support to hubSpace.

Users can upload a .2bit to create an assembly hub, optionally alongside
their own *.hub.txt (prefix names like araTha1.hub.txt are recognized)
and sibling track files. Uploads run in parallel; hub.txt mutations are
serialized per-hub via flock so arrival order does not matter.

- hubSpace table gains a hubType column ('trackHub' or 'assemblyHub');
ON DUPLICATE KEY UPDATE excludes it so a re-upload cannot revert an
upgraded hub.
- writeHubText can now emit an assembly stanza derived from the 2bit;
upgradeHubTxtForAssembly promotes an existing plain hub.txt in place
when a 2bit arrives after tracks.
- pre-finish decides synthesize vs upgrade vs leave-alone from server
state (existing rows, hub.txt on disk) plus a single client flag
(batchHasHubTxt); client-supplied hubType is no longer trusted.
- Client UI adds 2bit as a file type, locks the genome field when the
hub is authoritative (drilled-in or batch hub.txt), defaults new
uploads to an existing assembly hub at top level, and routes
hgTracks URLs through 'genome=' vs 'db=' by hubType.
- Fix pre-existing nested-path bug in hubPathFromParentDir
(*firstSlash = 0).

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

diff --git src/hg/js/hgMyData.js src/hg/js/hgMyData.js
index 9dd31e0fa85..d08d91f1d34 100644
--- src/hg/js/hgMyData.js
+++ src/hg/js/hgMyData.js
@@ -151,48 +151,98 @@
                     onChange: e => {
                         onChange(e.target.value);
                         file.meta.fileType = hubCreate.detectFileType(e.target.value);
                         file.meta.name = e.target.value;
                     },
                     required: required,
                     form: form,
                     }
                 );
             },
         },
         {
             id: 'genome',
             name: 'Genome',
             render: ({value, onChange}, h) => {
+                // 2bit files name a new assembly hub (editable). Other files
+                // with genomeLocked are pinned by a hub-defining sibling or
+                // the hub they were drilled into.
+                let isTwoBit = file.meta.fileType === "2bit";
+                let isLocked = !!file.meta.genomeLocked;
+                if (isTwoBit || isLocked) {
+                    let editable2bit = isTwoBit && !isLocked;
+                    let label;
+                    if (editable2bit) {
+                        label = "Genome name for your assembly hub:";
+                    } else if (isTwoBit) {
+                        label = "Genome (set by hub.txt in this batch):";
+                    } else {
+                        label = "Genome (set by the assembly hub):";
+                    }
+                    return h('div', {
+                            class: "uppy-Dashboard-FileCard-label",
+                            style: "display: inline-block; width: 78%"
+                            },
+                        label,
+                        h('input', {
+                            id: `${file.meta.name}AsmHubInput`,
+                            type: 'text',
+                            class: "uppy-u-reset uppy-c-textInput uppy-Dashboard-FileCard-input",
+                            style: "margin-left: 5px",
+                            value: file.meta.genome || "",
+                            disabled: !editable2bit,
+                            onChange: e => {
+                                let v = hubCreate.sanitizeGenomeName(e.target.value);
+                                onChange(v);
+                                file.meta.genome = v;
+                                file.meta.genomeLabel = v;
+                            }
+                        })
+                    );
+                }
                 // keep these as a variable so we can init the autocompleteCat
                 // code only after the elements have actually been rendered
                 // there are multiple rendering passes and only eventually
                 // 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 => {
                             onChange(e.target.value);
                             file.meta.genome = e.target.value;
                             file.meta.genomeLabel = e.target.selectedOptions[0].label;
+                            // If the user picked one of their own assembly hubs,
+                            // flip hubType and align parentDir so the upload
+                            // targets that existing hub rather than creating a
+                            // new stub. If they picked a UCSC db, flip back
+                            // and reset parentDir to a fresh default so they
+                            // don't accidentally upload into an assembly hub.
+                            let hub = hubCreate.assemblyHubByGenome(e.target.value);
+                            if (hub) {
+                                file.meta.hubType = "assemblyHub";
+                                file.meta.parentDir = hub.fileName;
+                            } else {
+                                file.meta.hubType = "trackHub";
+                                file.meta.parentDir = hubCreate.uiState.hubNameDefault;
+                            }
                         }
                         },
                         hubCreate.makeGenomeSelectOptions(file.meta.genome, file.meta.genomeLabel).map( (genomeObj) => {
                             return h('option', {
                                 value: genomeObj.value,
                                 label: genomeObj.label,
                                 selected: file.meta.genome !== null ? genomeObj.value === file.meta.genome : genomeObj.value === hubCreate.defaultDb()
                             });
                         })
                     ),
                     h('p', {
                         class: "uppy-Dashboard-FileCard-label",
                         style: "display: block; width: 78%",
                         }, "or search for your genome:"),
                     // third div child
@@ -233,83 +283,94 @@
     doneButtonHandler: function() {
         uppy.clear();
     },
 };
 
 // make our Uppy instance:
 const uppy = new Uppy.Uppy({
     debug: true,
     allowMultipleUploadBatches: false,
     onBeforeUpload: (files) => {
         // set all the fileTypes and genomes from their selects
         let doUpload = true;
         let thisQuota = 0;
         let filesToOverwrite = []; // collect files that will overwrite existing ones
 
-        // Check if any file is a hub.txt - if so, we need to upload it first
-        // and switch to sequential uploads to prevent race conditions
-        let hasHubTxt = Object.values(files).some(f => f.meta.fileType === "hub.txt");
-        if (hasHubTxt) {
-            // Set TUS plugin to sequential uploads (limit: 1)
-            const tusPlugin = uppy.getPlugin('Tus');
-            if (tusPlugin) {
-                tusPlugin.setOptions({ limit: 1 });
-            }
-            // Reorder files so hub.txt comes first (JS objects maintain insertion order)
-            let hubTxtFiles = {};
-            let otherFiles = {};
-            for (let [key, file] of Object.entries(files)) {
-                if (file.meta.fileType === "hub.txt") {
-                    hubTxtFiles[key] = file;
-                } else {
-                    otherFiles[key] = file;
+        // If a 2bit is in the batch, propagate its genome/hubType to siblings.
+        // Fall back to sanitized filename because setFileMeta is async from
+        // our point of view - captured meta may not be populated yet.
+        let batchTwoBit = Object.values(files).find(looksLikeTwoBit);
+        if (batchTwoBit) {
+            let asmGenome = batchTwoBit.meta.genome || hubCreate.sanitizeGenomeName(batchTwoBit.name);
+            for (let f of Object.values(files)) {
+                f.meta.genome = asmGenome;
+                f.meta.genomeLabel = asmGenome;
+                f.meta.hubType = "assemblyHub";
+                // fileType may also be stale; recompute from filename if missing
+                if (!f.meta.fileType) {
+                    f.meta.fileType = hubCreate.detectFileType(f.name);
                 }
             }
-            files = Object.assign({}, hubTxtFiles, otherFiles);
+        }
+
+        // Tag every file so pre-finish knows a user hub.txt is coming in
+        // the same batch and can skip synthesizing its own.
+        let hasHubTxt = Object.values(files).some(looksLikeHubTxt);
+        for (let f of Object.values(files)) {
+            f.meta.batchHasHubTxt = hasHubTxt ? "true" : "false";
         }
 
         for (let [key, file] of Object.entries(files)) {
             let fileNameMatch = file.meta.name.match(fileNameRegex);
             let parentDirMatch = file.meta.parentDir.match(parentDirRegex);
             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);
                 doUpload = false;
                 continue;
             }
             if (!parentDirMatch || parentDirMatch[0] !== file.meta.parentDir) {
                 uppy.info(`Error: Hub name has special characters, please rename hub: ${file.meta.parentDir} for file: ${file.meta.name} to only include alpha-numeric characters, period, or underscore.`, 'error', 5000);
                 doUpload = false;
                 continue;
             }
             if (!file.meta.genome) {
                 uppy.info(`Error: No genome selected for file ${file.meta.name}!`, 'error', 5000);
                 doUpload = false;
                 continue;
             }
             if  (!file.meta.fileType) {
                 uppy.info(`Error: File type not supported, file: ${file.meta.name}!`, 'error', 5000);
                 doUpload = false;
                 continue;
             }
             // check if this hub already exists and the genome is different from what was
             // just selected, if so, make the user create a new hub
             if (file.meta.parentDir in hubCreate.uiState.filesHash && hubCreate.uiState.filesHash[file.meta.parentDir].genome !== file.meta.genome) {
-                genome = hubCreate.uiState.filesHash[file.meta.parentDir].genome;
-                uppy.info(`Error: the hub ${file.meta.parentDir} already exists and is for genome "${genome}". Please select the correct genome, a different hub or make a new hub.`);
+                let existing = hubCreate.uiState.filesHash[file.meta.parentDir];
+                // If the existing hub is an assembly hub, adopt its genome
+                // automatically rather than erroring - the UI hid the picker
+                // for this case, so the mismatch is just stale metadata.
+                if (existing.hubType === "assemblyHub") {
+                    file.meta.genome = existing.genome;
+                    file.meta.genomeLabel = existing.genome;
+                    file.meta.hubType = "assemblyHub";
+                } else {
+                    uppy.info(`Error: the hub ${file.meta.parentDir} already exists and is for genome "${existing.genome}". Please select the correct genome, a different hub or make a new hub.`);
                     doUpload = false;
                     continue;
                 }
+            }
             // check if the user is uploading a file that already exists in this hub
             if (file.meta.parentDir in hubCreate.uiState.filesHash) {
                 let hubFiles = hubCreate.uiState.filesHash[file.meta.parentDir].children;
                 for (let j = 0; j < hubFiles.length; j++) {
                     if (hubFiles[j].fileName === file.meta.name) {
                         filesToOverwrite.push(file);
                         break;
                     }
                 }
             }
 
             // Set metadata directly on the file object since we're returning a modified files object
             // (using setFileMeta would be overwritten when we return the files object)
             file.meta.fileName = file.meta.name;
             file.meta.fileSize = file.size;
@@ -319,35 +380,110 @@
         }
         // If any files will overwrite existing ones, show a single confirmation dialog
         if (filesToOverwrite.length > 0) {
             let fileNames = filesToOverwrite.map(f => f.meta.name).join("\n  ");
             if (!confirm(`The following file(s) already exist and will be overwritten:\n  ${fileNames}\n\nContinue?`)) {
                 doUpload = false;
             } else {
                 // Set metadata flag to allow overwrite on backend for each file
                 filesToOverwrite.forEach(f => f.meta.allowOverwrite = "true");
             }
         }
         if (thisQuota + hubCreate.uiState.userQuota > hubCreate.uiState.maxQuota) {
             uppy.info(`Error: this file batch exceeds your quota. Please delete some files to make space or email genome-www@soe.ucsc.edu if you feel you need more space.`);
             doUpload = false;
         }
-        // Return the (possibly reordered) files object to proceed, or false to cancel
         return doUpload ? files : false;
     },
 });
 
+function looksLikeTwoBit(f) {
+    return (f.name || "").toLowerCase().endsWith(".2bit");
+}
+
+function looksLikeHubTxt(f) {
+    // Accept exact "hub.txt" or any "*.hub.txt" (e.g. "araTha1.hub.txt").
+    let n = (f.name || "").toLowerCase();
+    return n === "hub.txt" || n.endsWith(".hub.txt");
+}
+
+function propagateAssemblyHubMeta(uppyInstance) {
+    // When a batch contains a 2bit (and/or an assembly-hub hub.txt), mirror the
+    // custom genome name onto every file sharing that parentDir and mark every
+    // file hubType=assemblyHub. hub.txt wins over the 2bit's default.
+    //
+    // We detect the hub-defining files by filename rather than by meta.fileType,
+    // because setFileMeta updates Uppy's state immutably - file objects captured
+    // from getFiles() earlier in this event may still carry old meta.
+    let files = uppyInstance.getFiles();
+    let twoBit = files.find(looksLikeTwoBit);
+    let hubTxt = files.find(looksLikeHubTxt);
+    if (!twoBit && !hubTxt) return;
+
+    function applyGenomeToSiblings(genome, alsoLockHubDefiners) {
+        // Set genome/hubType on every file in the batch. Non-hub-defining
+        // files (i.e. the sibling tracks) are always locked to this genome so
+        // the user can't drift them. The hub-defining files (2bit, hub.txt)
+        // are locked only when alsoLockHubDefiners is true - used by the
+        // hub.txt path to pin the 2bit's editable field too.
+        if (!genome) return;
+        // All files in this batch belong to one new hub, so they must share
+        // one parentDir. Take it from the hub-defining file - its parentDir
+        // came from getDefaultHubName(), while a track that was added first
+        // may have been pointed at an existing assembly hub.
+        let hubDefiner = hubTxt || twoBit;
+        let syncParentDir = hubDefiner && hubDefiner.meta && hubDefiner.meta.parentDir;
+        for (let f of uppyInstance.getFiles()) {
+            let isHubDefining = looksLikeTwoBit(f) || looksLikeHubTxt(f);
+            let meta = {
+                genome: genome,
+                genomeLabel: genome,
+                hubType: "assemblyHub",
+                genomeLocked: !isHubDefining || alsoLockHubDefiners,
+            };
+            if (syncParentDir) meta.parentDir = syncParentDir;
+            uppyInstance.setFileMeta(f.id, meta);
+        }
+    }
+
+    if (hubTxt) {
+        hubCreate.readFileAsText(hubTxt.data).then((text) => {
+            let parsed = hubCreate.parseHubTxt(text);
+            // hub.txt is authoritative: lock the genome field so the user
+            // can't edit it and drift the stored db away from what hub.txt
+            // says. The user can always edit the hub.txt itself if they
+            // want a different name.
+            if (parsed.isAssemblyHub && parsed.genome) {
+                applyGenomeToSiblings(parsed.genome, true);
+                uppyInstance.info(`Using genome "${parsed.genome}" from hub.txt`, "info", 4000);
+            } else if (parsed.genome && twoBit) {
+                let twoBitGenome = twoBit.meta.genome || hubCreate.sanitizeGenomeName(twoBit.name);
+                if (parsed.genome !== twoBitGenome) {
+                    applyGenomeToSiblings(parsed.genome, true);
+                    uppyInstance.info(`Using genome "${parsed.genome}" from hub.txt (overrides 2bit default)`, "warning", 5000);
+                }
+            }
+        }).catch((err) => {
+            console.warn("Could not read hub.txt for genome detection:", err);
+        });
+        return;
+    }
+
+    let asmGenome = twoBit.meta.genome || hubCreate.sanitizeGenomeName(twoBit.name);
+    applyGenomeToSiblings(asmGenome, false);
+}
+
 // create a custom uppy plugin to batch change the type and db fields
 class BatchChangePlugin extends Uppy.BasePlugin {
     constructor(uppy, opts) {
         super(uppy, opts);
         this.id = "BatchChangePlugin";
         this.type = "progressindicator";
         this.opts = opts;
     }
 
     createOptsForSelect(select, opts) {
         opts.forEach( (opt) => {
             let option = document.createElement("option");
             option.value = opt.value;
             option.label = opt.label;
             option.id = opt.id;
@@ -370,146 +506,254 @@
                 this.createOptsForSelect(dbSelect, dbOpts);
                 fileDiv.appendChild(dbSelect);
             }
         }
     }
 
     removeBatchSelectsFromDashboard() {
         let batchSelectDiv = document.getElementById("batch-selector-div");
         if (batchSelectDiv) {
             batchSelectDiv.remove();
         }
     }
 
     addBatchSelectsToDashboard() {
         if (!document.getElementById("batch-selector-div")) {
+            // If the batch contains a 2bit, the UCSC genome picker makes no
+            // sense - show the custom genome name read-only instead. Detect by
+            // filename rather than meta.hubType because setFileMeta updates
+            // Uppy state immutably and the meta may not be visible on file
+            // objects captured from getFiles() earlier in this event.
+            let assemblyHubGenome = null;
+            for (let f of this.uppy.getFiles()) {
+                if (looksLikeTwoBit(f)) {
+                    assemblyHubGenome = f.meta.genome || hubCreate.sanitizeGenomeName(f.name);
+                    break;
+                }
+            }
+
             let batchSelectDiv = document.createElement("div");
             batchSelectDiv.id = "batch-selector-div";
             batchSelectDiv.style.display = "grid";
             batchSelectDiv.style.width = "80%";
             // the grid syntax is 2 columns, 3 rows
             batchSelectDiv.style.gridTemplateColumns = "max-content minmax(0, 200px) max-content 1fr min-content";
             batchSelectDiv.style.gridTemplateRows = "repest(3, auto)";
             batchSelectDiv.style.margin = "10px auto"; // centers this div
             batchSelectDiv.style.fontSize = "14px";
             batchSelectDiv.style.gap = "8px";
             if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
                 batchSelectDiv.style.color = "#eaeaea";
             }
 
             // first just explanatory text:
             let batchSelectText = document.createElement("div");
             batchSelectText.textContent = "Change options for all files:";
             // syntax here is rowStart / columnStart / rowEnd / columnEnd
             batchSelectText.style.gridArea = "1 / 1 / 1 / 2";
 
-            // the batch change db select
-            let batchDbSelect = document.createElement("select");
+            let batchDbLabel = document.createElement("label");
+            batchDbLabel.textContent = "Genome";
+            batchDbLabel.style.gridArea = "2 / 1 / 2 / 1";
+
+            let batchDbSelect = null;
+            let batchDbGenomeSearchBar = null;
+            let batchDbGenomeSearchButton = null;
+            let batchDbSearchBarLabel = null;
+
+            if (assemblyHubGenome) {
+                // Assembly hub: show the custom genome name as a locked text
+                // field, no UCSC picker or search.
+                let locked = document.createElement("input");
+                locked.type = "text";
+                locked.id = "batchAsmHubGenome";
+                locked.value = assemblyHubGenome;
+                locked.disabled = true;
+                locked.classList.add("uppy-u-reset", "uppy-c-textInput");
+                locked.style.gridArea = "2 / 2 / 2 / 2";
+                locked.style.margin = "2px";
+                batchDbLabel.for = "batchAsmHubGenome";
+
+                let note = document.createElement("div");
+                note.textContent = "(assembly hub - genome is set by the 2bit in this batch)";
+                note.style.gridArea = "2 / 3 / 2 / 5";
+                note.style.margin = "auto 0";
+                note.style.fontStyle = "italic";
+
+                batchSelectDiv.appendChild(batchSelectText);
+                batchSelectDiv.appendChild(batchDbLabel);
+                batchSelectDiv.appendChild(locked);
+                batchSelectDiv.appendChild(note);
+            } else {
+                // Track hub: the usual UCSC picker + autocomplete.
+                batchDbSelect = document.createElement("select");
                 this.createOptsForSelect(batchDbSelect, hubCreate.makeGenomeSelectOptions());
                 batchDbSelect.id = "batchDbSelect";
                 batchDbSelect.style.gridArea = "2 / 2 / 2 / 2";
                 batchDbSelect.style.margin = "2px";
-            let batchDbLabel = document.createElement("label");
-            batchDbLabel.textContent = "Genome";
                 batchDbLabel.for = "batchDbSelect";
-            batchDbLabel.style.gridArea = "2 / 1 / 2 / 1";
 
-            // the search bar for db selection
-            let batchDbSearchBarLabel= document.createElement("label");
+                batchDbSearchBarLabel = document.createElement("label");
                 batchDbSearchBarLabel.textContent = "or search for your genome:";
                 batchDbSearchBarLabel.style.gridArea = "2 / 3 /2 / 3";
                 batchDbSearchBarLabel.style.margin = "auto";
 
-            let batchDbGenomeSearchBar = document.createElement("input");
+                batchDbGenomeSearchBar = document.createElement("input");
                 batchDbGenomeSearchBar.classList.add("uppy-u-reset", "uppy-c-textInput");
                 batchDbGenomeSearchBar.type = "text";
                 batchDbGenomeSearchBar.id = "batchDbSearchBar";
                 batchDbGenomeSearchBar.style.gridArea = "2 / 4 / 2 / 4";
-            let batchDbGenomeSearchButton = document.createElement("input");
+                batchDbGenomeSearchButton = document.createElement("input");
                 batchDbGenomeSearchButton.type = "button";
                 batchDbGenomeSearchButton.value = "search";
                 batchDbGenomeSearchButton.id = "batchDbSearchBarButton";
                 batchDbGenomeSearchButton.style.gridArea = "2 / 5 / 2 / 5";
 
-            // the batch change hub name
+                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);
+                    for (let [key, file] of Object.entries(files)) {
+                        let meta = {
+                            genome: val,
+                            genomeLabel: label,
+                            hubType: hub ? "assemblyHub" : "trackHub",
+                            parentDir: hub ? hub.fileName : hubCreate.uiState.hubNameDefault,
+                        };
+                        this.uppy.setFileMeta(file.id, meta);
+                    }
+                });
+
+                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";
 
             let batchParentDirInput = document.createElement("input");
             batchParentDirInput.id = "batchParentDir";
             batchParentDirInput.value = hubCreate.getDefaultHubName();
             batchParentDirInput.style.gridArea = "3 / 2 / 3 / 2";
             batchParentDirInput.style.margin= "1px 1px auto";
             batchParentDirInput.classList.add("uppy-u-reset", "uppy-c-textInput");
 
-            // add event handlers to change metadata, use an arrow function
-            // because otherwise 'this' keyword will be the element instead of
-            // our class
-            batchDbSelect.addEventListener("change", (ev) => {
-                let files = this.uppy.getFiles();
-                let val = ev.target.value;
-                for (let [key, file] of Object.entries(files)) {
-                    this.uppy.setFileMeta(file.id, {genome: val});
-                    this.uppy.setFileMeta(file.id, {genomeLabel: ev.target.selectedOptions[0].label});
-                }
-            });
             batchParentDirInput.addEventListener("change", (ev) => {
                 let files = this.uppy.getFiles();
                 let val = ev.target.value;
                 for (let [key, file] of Object.entries(files)) {
                     this.uppy.setFileMeta(file.id, {parentDir: val});
                 }
             });
 
-
-            batchSelectDiv.appendChild(batchSelectText);
-            batchSelectDiv.appendChild(batchDbLabel);
-            batchSelectDiv.appendChild(batchDbSelect);
-            batchSelectDiv.appendChild(batchDbSearchBarLabel);
-            batchSelectDiv.appendChild(batchDbGenomeSearchBar);
-            batchSelectDiv.appendChild(batchDbGenomeSearchButton);
             batchSelectDiv.appendChild(batchParentDirLabel);
             batchSelectDiv.appendChild(batchParentDirInput);
 
             // append the batch changes to the bottom of the file list, for some reason
             // I can't append to the actual Dashboard-files, it must be getting emptied
             // and re-rendered or something
             let uppyFilesDiv = document.querySelector(".uppy-Dashboard-progressindicators");
             if (uppyFilesDiv) {
                 uppyFilesDiv.insertBefore(batchSelectDiv, uppyFilesDiv.firstChild);
             }
 
-            // everything has to exist already for autocompleteCat to initialize
+            // autocomplete only applies in the track-hub path
+            if (batchDbSelect && batchDbGenomeSearchBar && batchDbGenomeSearchButton) {
                 let justInitted = initAutocompleteForInput(batchDbGenomeSearchBar.id, batchDbSelect);
                 if (justInitted) {
-                // only do this once per batch setup
                     batchDbGenomeSearchButton.addEventListener("click", (e) => {
-                    let inp = document.getElementById(batchDbSearchBar.id).value;
+                        let inp = document.getElementById(batchDbGenomeSearchBar.id).value;
                         let selector = "[id='"+batchDbGenomeSearchBar.id+"']";
                         $(selector).autocompleteCat("search", inp);
                     });
                 }
             }
         }
+    }
 
     install() {
         this.uppy.on("file-added", (file) => {
             // add default meta data for genome and fileType
-            console.log("file-added");
-            this.uppy.setFileMeta(file.id, {"genome": hubCreate.defaultDb(), "fileType": hubCreate.detectFileType(file.name), "parentDir": hubCreate.getDefaultHubName()});
+            let ftype = hubCreate.detectFileType(file.name);
+            let defaultMeta = {
+                "genome": hubCreate.defaultDb(),
+                "fileType": ftype,
+                "parentDir": hubCreate.getDefaultHubName(),
+                "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.
+            let drilledIntoAsmHub = false;
+            if (hubCreate.uiState.currentHub &&
+                hubCreate.uiState.currentHub === 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,
+                    });
+                    drilledIntoAsmHub = true;
+                }
+            }
+
+            // Top-level with no drilled-in hub: if the user already has an
+            // assembly hub, default this file to target it so they don't have
+            // to re-enter the genome + hub name. The user can still switch via
+            // the dropdown. Skip this when the file itself is hub-defining
+            // (2bit or hub.txt) or when any file in the batch is - in that
+            // case the batch is creating a *new* hub, not adding to an
+            // existing one, so the defaults should not point at the old hub.
+            let fileIsHubDefining = ftype === "2bit" || ftype === "hub.txt";
+            let batchHasHubDefining = this.uppy.getFiles().some(f =>
+                looksLikeTwoBit(f) || looksLikeHubTxt(f));
+            if (!drilledIntoAsmHub && !fileIsHubDefining && !batchHasHubDefining) {
+                let firstHub = hubCreate.firstAssemblyHub();
+                if (firstHub) {
+                    this.uppy.setFileMeta(file.id, {
+                        genome: firstHub.genome,
+                        genomeLabel: firstHub.genome,
+                        parentDir: firstHub.fileName,
+                        hubType: "assemblyHub",
+                        // NOT genomeLocked - user may want a different hub/genome
+                    });
+                }
+            }
+
+            // 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).
+            propagateAssemblyHubMeta(this.uppy);
+
             if (this.uppy.getFiles().length > 1) {
                 this.addBatchSelectsToDashboard();
             } else {
                 // only open the file editor when there is one file
                 const dash = uppy.getPlugin("Dashboard");
                 dash.toggleFileCard(true, file.id);
             }
         });
         this.uppy.on("file-removed", (file) => {
             // remove the batch change selects if now <2 files present
             if (this.uppy.getFiles().length < 2) {
                 this.removeBatchSelectsFromDashboard();
             }
         });
 
@@ -578,37 +822,104 @@
         "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;
     }
 
+    function sanitizeGenomeName(name) {
+        // Strip .2bit, replace non-alphanumeric/_/- with _, drop hub_ prefix.
+        // Returns empty string if nothing usable is left.
+        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) {
+        // Return the fullPath of the hub.txt file inside hubName as recorded in
+        // hubSpace, falling back to "hubName/hub.txt" if there's no row yet.
+        // The user may have uploaded their own "araTha1.hub.txt" - use its
+        // actual filename rather than assuming "hub.txt".
+        let dir = uiState.filesHash[hubName];
+        if (dir && dir.children) {
+            for (let child of dir.children) {
+                if (child.fileType === "hub.txt") return child.fullPath;
+            }
+        }
+        return hubName + "/hub.txt";
+    }
+
+    function assemblyHubByGenome(genome) {
+        // Return the dir row of the user's assembly hub whose genome matches,
+        // or null. If genome is falsy, return the first assembly hub found.
+        for (let fullPath in uiState.filesHash) {
+            let fd = uiState.filesHash[fullPath];
+            if (fd.fileType !== "dir" || fd.hubType !== "assemblyHub" || !fd.genome) continue;
+            if (!genome || fd.genome === genome) return fd;
+        }
+        return null;
+    }
+
+    function firstAssemblyHub() { return assemblyHubByGenome(null); }
+    function genomeIsAssemblyHub(genome) { return !!genome && !!assemblyHubByGenome(genome); }
+
+    function parseHubTxt(text) {
+        // Very small hub.txt parser: extracts `genome` and `twoBitPath` lines.
+        // Returns {genome, twoBitPath, isAssemblyHub} (values may be null/false).
+        let ret = {genome: null, twoBitPath: null, isAssemblyHub: false};
+        if (!text) return ret;
+        let lines = text.split(/\r?\n/);
+        for (let line of lines) {
+            let trimmed = line.replace(/^\s+/, "");
+            if (trimmed.startsWith("genome ") && !ret.genome) {
+                ret.genome = trimmed.substring(7).trim();
+            } else if (trimmed.startsWith("twoBitPath ")) {
+                ret.twoBitPath = trimmed.substring(11).trim();
+                ret.isAssemblyHub = true;
+            }
+        }
+        return ret;
+    }
+
+    function readFileAsText(fileObj) {
+        // Return a Promise resolving to the file contents as text.
+        return new Promise((resolve, reject) => {
+            let reader = new FileReader();
+            reader.onload = () => resolve(reader.result);
+            reader.onerror = () => reject(reader.error);
+            reader.readAsText(fileObj);
+        });
+    }
+
     function detectFileType(fileName) {
         let fileLower = fileName.toLowerCase();
         for (let fileType in extensionMap) {
             for (let ext of extensionMap[fileType]) {
                 if (fileLower.endsWith(ext)) {
                     return fileType;
                 }
             }
         }
         //we could alert here but instead just explicitly set the value to null
         //and let the backend reject it instead, forcing the user to rename their
         //file
         //alert(`file extension for ${fileName} not found, please explicitly select it`);
         return null;
     }
@@ -630,109 +941,136 @@
         // as an additional option
         let ret = [];
         let cartChoice = {};
         cartChoice.id = cartDb;
         cartChoice.label = cartDb;
         cartChoice.value = cartDb.split(" ").slice(-1)[0];
         if (cartChoice.value.startsWith("hub_")) {
             cartChoice.label = cartDb.split(" ").slice(0,-1).join(" "); // take off the actual db value
         }
         cartChoice.selected = value && label ? false: true;
         defaultGenomeChoices[cartChoice.label] = cartChoice;
 
         // next time around our value/label pair will be a default. this time around we
         // want it selected because it was explicitly asked for, but it may not be next time
         ret = Object.values(defaultGenomeChoices);
-        if (value && label && !(label in defaultGenomeChoices)) {
+
+        // Include the user's uploaded assembly hubs as options. One entry per
+        // assembly hub (dedupe by genome name), taken from the dir row in
+        // filesHash. This lets users picking a dropdown genome target a hub
+        // they already created.
+        let seenAsmHub = {};
+        for (let fullPath in uiState.filesHash) {
+            let fd = uiState.filesHash[fullPath];
+            if (fd.fileType === "dir" && fd.hubType === "assemblyHub" &&
+                fd.genome && !seenAsmHub[fd.genome]) {
+                seenAsmHub[fd.genome] = true;
+                ret.push({
+                    value: fd.genome,
+                    label: `${fd.genome} (your assembly hub)`,
+                });
+            }
+        }
+
+        // Cache the value/label pair so it's a default next time - but skip
+        // assembly-hub genomes, those are added by the loop above with the
+        // "(your assembly hub)" suffix and would otherwise show up twice.
+        if (value && label && !(label in defaultGenomeChoices) &&
+            !genomeIsAssemblyHub(value)) {
             defaultGenomeChoices[label] = {value: value, label: label, selected: true};
         }
         return ret;
     }
 
     function makeTypeSelectOptions() {
         let ret = [];
         let autoChoice = {};
         autoChoice.label = "Auto-detect from extension";
         autoChoice.value = "Auto-detect from extension";
         autoChoice.selected = true;
         ret.push(autoChoice);
         let choices = ["bigBed", "bam", "vcf", "vcf (bgzip or gzip compressed)", "bigWig", "hic", "cram", "bigBarChart", "bigGenePred", "bigMaf", "bigInteract", "bigPsl", "bigChain"];
         choices.forEach( (e) =>  {
             let choice = {};
             choice.id = e;
             choice.label = e;
             choice.value = e;
             ret.push(choice);
         });
         return ret;
     }
 
-    function viewInGenomeBrowser(fname, ftype, genome, hubName) {
+    function viewInGenomeBrowser(fname, ftype, genome, hubName, hubType) {
         // redirect to hgTracks with this track open in the hub
         if (typeof uiState.userUrl !== "undefined" && uiState.userUrl.length > 0) {
             if (ftype 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?
-                let hubUrl = uiState.userUrl + cgiEncode(hubName) + "/hub.txt";
-                let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid() + "&db=" + genome + "&hubUrl=" + encodeURIComponent(hubUrl) + "&" + trackHubFixName(fname) + "=pack";
+                let hubUrl = uiState.userUrl + cgiEncode(hubTxtPathForHub(hubName));
+                // Assembly hubs use the user-defined genome name, which isn't a
+                // UCSC db - hgTracks needs 'genome=' (resolves via the hub)
+                // rather than 'db=' (looks up a native assembly).
+                let dbParam = hubType === "assemblyHub" ? "genome" : "db";
+                let url = "../cgi-bin/hgTracks?hgsid=" + getHgsid() + "&" + dbParam + "=" + genome + "&hubUrl=" + encodeURIComponent(hubUrl) + "&" + trackHubFixName(fname) + "=pack";
                 window.location.assign(url);
                 return false;
             }
         }
     }
 
     function trackHubFixName(trackName) {
         // replace everything but alphanumeric and underscore with underscore
         return encodeURIComponent(trackName.replaceAll(fileNameFixRegex, "_"));
     }
 
     // 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) => {
                 if (!genome) {
                     genome = d.genome;
-                    url += "&db=" + genome;
+                    // Assembly hubs use 'genome=' (hub-resolved name), native
+                    // UCSC assemblies use 'db='.
+                    let dbParam = d.hubType === "assemblyHub" ? "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)) {
                         // 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
-                        url += "&hubUrl=" + encodeURIComponent(uiState.userUrl + cgiEncode(d.parentDir));
-                        if (d.parentDir.endsWith("/")) {
-                            url += "hub.txt";
-                        } else {
-                            url += "/hub.txt";
-                        }
+                        // 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)));
                     }
                     hubsAdded[d.parentDir] = 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);
@@ -1061,38 +1399,38 @@
             folderIcon.style.height = "24px";
             folderIcon.classList.add("folderIcon");
             folderIcon.addEventListener("click", function(e) {
                 e.stopPropagation();
                 console.log("folder click");
                 let table = $("#filesTable").DataTable();
                 let trow = $(e.target).closest("tr");
                 let row = table.row(trow);
                 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 in extensionMap) {
+            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.type = 'button';
                 viewBtn.addEventListener("click", function(e) {
                     e.stopPropagation();
-                    viewInGenomeBrowser(rowData.fileName, rowData.fileType, rowData.genome, rowData.parentDir);
+                    viewInGenomeBrowser(rowData.fileName, rowData.fileType, rowData.genome, rowData.parentDir, 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();
@@ -1434,66 +1772,78 @@
             withCredentials: true,
             retryDelays: null,
             removeFingerprintOnSuccess: true, // clean up localStorage after successful upload
         };
 
         uppy.use(Uppy.Tus, tusOptions);
         uppy.use(BatchChangePlugin, {target: Uppy.Dashboard});
         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, parentDirObj;
+            let hubType = metadata.hubType || "trackHub";
             newReqObj = {
                 "fileName": cgiEncode(metadata.fileName),
                 "fileSize": metadata.fileSize,
                 "fileType": metadata.fileType,
                 "genome": metadata.genome,
                 "parentDir": cgiEncode(metadata.parentDir),
                 "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:
-            if (metadata.fileType !== "hub.txt") {
-                // if the user uploaded a hub.txt don't make a second fake object for it
+            // 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 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(metadata.parentDir),
                     "fullPath": cgiEncode(metadata.parentDir) + "/hub.txt",
+                    "hubType": hubType,
                 };
             }
             parentDirObj = {
                 "uploadTime": nowFormatted,
                 "lastModified": dFormatted,
                 "fileName": cgiEncode(metadata.parentDir),
                 "fileSize": 0,
                 "fileType": "dir",
                 "genome": metadata.genome,
                 "parentDir": "",
                 "fullPath": cgiEncode(metadata.parentDir),
+                "hubType": hubType,
             };
             // package the three objects together as one "hub" and display it
             let hub = [parentDirObj, newReqObj];
             if (hubTxtObj) {
                 hub.push(hubTxtObj);
             }
             addNewUploadedHubToTable(hub);
             updateQuota(metadata.fileSize);
         });
         uppy.on('complete', (result) => {
             history.replaceState(uiState, "", document.location.href);
             console.log("replace history with uiState");
         });
         inited = true;
     }
@@ -1544,17 +1894,23 @@
             } else if (!inited && isLoggedIn) {
                 cart.send({ getHubSpaceUIState: {}}, handleRefreshState, handleErrorState);
                 cart.flush();
             } else {
                 showExistingFiles([]);
             }
         }
     }
 
     return { init: init,
              uiState: uiState,
              defaultDb: defaultDb,
              makeGenomeSelectOptions: makeGenomeSelectOptions,
              getDefaultHubName: getDefaultHubName,
              detectFileType: detectFileType,
+             sanitizeGenomeName: sanitizeGenomeName,
+             readFileAsText: readFileAsText,
+             parseHubTxt: parseHubTxt,
+             firstAssemblyHub: firstAssemblyHub,
+             genomeIsAssemblyHub: genomeIsAssemblyHub,
+             assemblyHubByGenome: assemblyHubByGenome,
            };
 }());