520322178ed1029edc43b6b998023ba84d083301
jrobinso
  Fri Sep 19 21:36:28 2025 -0700
Better error handling (hopefully) on page refresh.

diff --git src/hg/js/igvFileHelper.js src/hg/js/igvFileHelper.js
index 3b230f51099..e924a60937c 100644
--- src/hg/js/igvFileHelper.js
+++ src/hg/js/igvFileHelper.js
@@ -47,31 +47,32 @@
             return this.file.slice(start, end);
         }
 
         async text() {
             this.checkFile();
             return this.file.text();
         }
 
         async arrayBuffer() {
             this.checkFile();
             return this.file.arrayBuffer();
         }
 
         checkFile() {
             if (!this.file) {
-                    throw new Error(`Connection to file ${this.name} is not available.  Please re-select the file.`);
+                throw new Error(`Connection to file ${this.name} is not available.  Please re-select the file` +
+                    ' in the IGV File Manager window.');
             }
         }
     }
 
 
     /**
      * Initialize igv.js, creating an igvBrowser if a session is found in local storage.   The existence of a
      * global "igvBrowser" variable indicates igv.js has already been initialized.
      *
      * @returns {Promise<void>}
      */
     async function initIgvUcsc() {
 
         if (window.igvBrowser) {
             //console.log("igvBrowser already exists");
@@ -85,46 +86,46 @@
 
         if (sessionString) {
 
             const igvSession = JSON.parse(igv.uncompressSession(`blob:${sessionString}`));
 
             // Reconnect any file-based tracks to the actual File objects.
             if (igvSession.tracks) {
 
                 const failed = await restoreTrackConfigurations(igvSession.tracks);
 
                 if (failed.length > 0) {
 
                     const sendRestoreRequest = () => channel.postMessage({type: "restoreFiles", files: failed});
 
                     if (filePicker && !filePicker.closed) {
+                        // Filepicker is set.  Its not clear how this happens, or if its even possible.
                         sendRestoreRequest();
                         return;
                     } else if (await pingFilePicker()) {
 
                         // A file picker is open, but (apparently) doesn't have references to the requested files.
                         // Send a request to restore the files, which will bring the file picker to the front and
-                            // prompt the user to select the files.
+                        // prompt the user to select the files. -- Prompt provided by igv.js error handler for now.
+                        // showDialog(`Connections to ${failed.length} file(s) in the IGV session could not be ` +
+                        //    `restored.  Please re-select the files in the IGV File Manager window.`);
                         sendRestoreRequest();
 
                     } else {
-
                         // Open a file picker and prompt user to select files to restore the connections.
                         filePicker = openFilePicker();
-                            filePicker.onload = () => {
-                                channel.postMessage({type: "restoreFiles", files: failed});
-                            };
+                        filePicker.onload = () => sendRestoreRequest();
                     }
                 }
             }
             await createIGVBrowser(igvSession);
         }
     }
 
     /**
      * Given a list of files, return a list of track configurations.  Each configuration contains a url (MockFile) and
      * optionally an indexURL (MockFile).
      *
      * NOTE: igv uses the "url" and "indexURL" fiels for local files as well as URLs.
      *
      * @param files
      * @returns {*[]}
@@ -238,56 +239,50 @@
         return failed;
     }
 
     /**
      * Attempt to restore a File object given its id by sending a "getFile" message on the BroadcastChannel and waiting for
      * a "file" message in response.  If no response is received within 1/2 a second undefined is returned.
      *
      * NOTE: the 1/2 second timeout is arbitrary, and could be reduced to improve responsiveness.
      *
      * @param id
      * @returns {Promise<unknown>}
      */
     async function restoreFile(id) {
         return new Promise((resolve) => {
 
-                const previousOnMessage = channel.onmessage;
-                const timeoutId = setTimeout(() => {
-                    cleanup();
-                    console.error(`Timeout waiting for file with id: ${id}`);
-                    resolve(undefined);
-                }, 500);
-
-                function cleanup() {
-                    channel.onmessage = previousOnMessage;
-                    clearTimeout(timeoutId);
-                }
-
-                channel.onmessage = function (event) {
-                    try {
+            const fileListener = (event) => {
                 const msg = event.data;
-                        if (msg.type === 'file') {
+                if (msg.type === 'file' && msg.id === id) {
                     cleanup();
                     resolve(msg.data);
                 }
-                    } catch (error) {
+            };
+
+            const timeout = setTimeout(() => {
                 cleanup();
-                        console.error(error);
+                console.error(`Timeout waiting for file with id: ${id}`);
                 resolve(undefined);
+            }, 500);
+
+            function cleanup() {
+                channel.removeEventListener('message', fileListener);
+                clearTimeout(timeout);
             }
-                };
 
+            channel.addEventListener('message', fileListener);
             channel.postMessage({type: 'getFile', id});
         });
     }
 
 
     /**
      *  Update the igv.js browser to reflect a change in the UCSC browser start position.  This is called
      *  when the user drags a UCSC track image to a new position.  It is intended for small changes, and
      *  works by shifting the igv.js predrawn track image.  This will not work for large position
      *  changes, or for changes in resolution.
      *
      * @param newPortalStart
      */
     function updateIgvStartPosition(newPortalStart) {
         // TODO -- this is hacky, add new function to igv.js
@@ -340,204 +335,279 @@
      * @param intervalMs
      */
     function startSessionAutoSave(intervalMs = 1000) {
         if (sessionAutoSaveTimer !== null) return; // already running
         sessionAutoSaveTimer = setInterval(updateSessionStorage, intervalMs);
     }
 
     function stopSessionAutoSave() {
         if (sessionAutoSaveTimer !== null) {
             clearInterval(sessionAutoSaveTimer);
             sessionAutoSaveTimer = null;
         }
     }
 
 
-// Detect a page refresh (visibility change to hidden) and save the session to local storage.  This is meant to
-// simulate  UCSC browser session handling.
-// XX TODO - not enough time for sending an HTTP request to update cart - need a better system!
-// document.onvisibilitychange = () => {
-//     if (document.visibilityState === "hidden") {
-//         if (igvBrowser) {
-//             //setCartVar("igvState", igvSession, null, false);
-//             //const igvSession = igvBrowser.compressedSession();
-//             //localStorage.setItem("igvSession", igvSession);
-//         }
-//     }
-// };
+    window.addEventListener("DOMContentLoaded", async () => {
 
         // The "Add IGV track" button handler.  The button opens the file picker window, unless
         // it is already open in which case it brings that window to the front.  Tracks are added
         // from the filePicker page by selecting track files.
-        window.addEventListener("DOMContentLoaded", async () => {
         document.getElementById('hgtIgv').addEventListener('click', async function (e) {
             e.preventDefault(); // our
             if (filePicker && !filePicker.closed) {
                 filePicker.focus();
                 return;
             } else {
-
-                    // A filePicker might be open from a previous instance of this page.  We can detect this by sending
-                    // a message on the channel and waiting briefly for a response, but we cannot get a reference to the window
-                    // so we ask the user to bring it to the front.
-
+                // A file picker may be open from a previous session. First ping it to see if it is still there,
+                // if it responds the user should be alerted from that window, no need to open a new window.
                 const responded = await pingFilePicker();
-                    if (responded) {
-                        alert("File picker is already open. Please switch to that window.");
-                    } else {
-                        // No filePicker found, open a new one.
+                if (!responded) {
                     filePicker = openFilePicker();
                 }
             }
         });
+
+        initializeDialog();
+
     });
 
+    // Initialize a jQuery UI dialog used for user messages.
+    function initializeDialog() {
+
+        // Inject a hidden dive for an alert dialog.  We use this to report errors.
+        const alertDialog = document.createElement('div');
+        alertDialog.id = 'igvAlertDialog';
+        alertDialog.title = 'Alert!';
+        alertDialog.style.display = 'none';
+        document.body.appendChild(alertDialog);
+
+        $("#igvAlertDialog").dialog({
+            autoOpen: false,
+            modal: true,
+            position: {my: "center", at: "center", of: $("#imgTbl")}
+        });
+    }
+
 
     /**
      * Send a "ping" message to the file picker window and wait up to 100 msec for a "pong" response.  Used to
      * determine if a file picker window is already open.
      * @param channel
      * @returns {Promise<unknown>}
      */
     async function pingFilePicker() {
-            const waitForResponse = new Promise((resolve) => {
-                const originalOnMessage = channel.onmessage;
-                channel.onmessage = (event) => {
+        return new Promise((resolve) => {
+
+            const pongListener = (event) => {
                 if (event.data && event.data.type === "pong") {
-                        channel.onmessage = originalOnMessage;
+                    clearTimeout(timeout);
+                    channel.removeEventListener('message', pongListener);
                     resolve(true);
                 }
             };
-                setTimeout(() => {
-                    channel.onmessage = originalOnMessage;
+
+            const timeout = setTimeout(() => {
+                channel.removeEventListener('message', pongListener);
                 resolve(false);
             }, 100);
-            });
 
+            channel.addEventListener('message', pongListener);
             channel.postMessage({type: "ping"});
-
-            const responded = await waitForResponse;
-            return responded;
+        });
     }
 
 
+    /**
+     * Open the file picker window.  If the window is already open it *should* be brought to the front.
+     * @returns {WindowProxy}
+     */
     function openFilePicker() {
-            return window.open('../admin/filePicker.html', 'filePicker' + Date.now(), 'width=600,height=1000');
+        filePicker = window.open('../admin/filePicker.html', 'igvFilePicker', 'width=600,height=1000');
+        if (!filePicker) {
+            showDialog("Unable to open file picker window.  Please disable your popup blocker for this site and try again.");
+        }
+        return filePicker;
     }
 
     /**
      * Update the track names in the left hand column of the IGV row in the image table.
      */
     function updateTrackNames() {
 
-            document.getElementById('igv_namediv').innerHTML = ""; // Clear any existing content
 
         // Add track names to the left hand column.
         if (!igvBrowser) return;
 
-            const allTracks = igvBrowser.findTracks(t => t.type);
+        const gearIconSize = 10;
+        const leftSidebar = document.getElementById('igv_leftsidebar');
+        const nameDiv = document.getElementById('igv_namediv');
+
+        // Clear any existing content
+        nameDiv.innerHTML = "";
+        leftSidebar.innerHTML = "";
+
+        const allTracks = igvBrowser.findTracks(t => t.type);  // ignore tracks with no type
         let top = 0;
         for (let track of allTracks) {
 
-                if ('sequence' !== track.type) {
-                    const labelContainer = document.createElement('div');
-                    labelContainer.style.position = 'absolute'; // Use relative positioning
-                    labelContainer.style.top = `${top}px`; // Position the element at the current value of "top"
-                    labelContainer.style.left = '0';
-                    labelContainer.style.right = '0';       // span full width of the enclosing td via igv_namediv
-                    labelContainer.style.width = '100%';    // optional, for clarity
-
             const gearDiv = document.createElement('div');
             gearDiv.style.position = 'absolute';
-                    gearDiv.style.left = '5px';
-                    gearDiv.style.width = '15px';
-                    gearDiv.style.height = '15px';
-                    gearDiv.style.maxWidth = '15px';
-                    gearDiv.style.maxHeight = '15px';
+            gearDiv.style.top = `${top + 5}px`; // Slightly offset from the top of the track
+            gearDiv.style.left = '2px';
+            gearDiv.style.width = `${gearIconSize}px`;
+            gearDiv.style.height = `${gearIconSize}px`;
+            gearDiv.style.maxWidth = `${gearIconSize}px`;
+            gearDiv.style.maxHeight = `${gearIconSize}px`;
             gearDiv.style.overflow = 'hidden';
             gearDiv.style.cursor = 'pointer';
 
-                    const cog = igv.createIcon('cog', 'grey');
-                    // Ensure the underlying SVG is 15x15
+            const cog = igv.createIcon('cog', 'black');
             const svg = cog.tagName && cog.tagName.toLowerCase() === 'svg' ? cog : cog.querySelector('svg');
-                    if (svg) {
-                        svg.setAttribute('width', '15');
-                        svg.setAttribute('height', '15');
-                        svg.style.width = '15px';
-                        svg.style.height = '15px';
-                    }
+            svg.setAttribute('width', gearIconSize.toString());
+            svg.setAttribute('height', gearIconSize.toString());
+            svg.style.width = `${gearIconSize}px`;
+            svg.style.height = `${gearIconSize}px`;
             gearDiv.appendChild(cog);
             gearDiv.setAttribute('data-track-id', track.id); // Set the track ID attribute
-                    labelContainer.appendChild(gearDiv);
+            document.getElementById('igv_leftsidebar').appendChild(gearDiv);
+
+
             gearDiv.addEventListener("click", (e) => {
                 e.stopPropagation();
                 e.preventDefault();
                 const trackId = e.currentTarget.getAttribute('data-track-id');
                 const matchingTracks = igvBrowser.findTracks(t => t.id === trackId);
                 if (matchingTracks.length > 0) {
                     const trackView = matchingTracks[0].trackView;
                     trackView.trackGearPopup.presentMenuList(
                         trackView,
                         igvBrowser.menuUtils.trackMenuItemList(trackView),
                         igvBrowser.config);
                 }
             });
 
             // Move the track gear popup location, and hide its true parent.  This is a bit hacky, but
             // moving the gear popover to a new parent has undesired side effects.
             const popover = track.trackView.trackGearPopup.popover;
             if (popover) {
                 popover.parentElement.style.width = '0px';  // don't use display=none, that breaks the popup
                 popover.parentElement.style.height = '0px';
                 popover.style.left = "-100px";
             }
-
+            if ('sequence' !== track.type) {
                 const trackLabelDiv = document.createElement('div');
-                    trackLabelDiv.textContent = track.name; // Set the track name as the label
-                    trackLabelDiv.style.right = '5px'; // Set a fixed width for the label div
-                    trackLabelDiv.style.textAlign = 'right'; // Right-justify the text
-                    labelContainer.appendChild(trackLabelDiv);
-
-                    document.getElementById('igv_namediv').appendChild(labelContainer);
+                trackLabelDiv.title = (typeof track.url === 'string') ? track.url : track.name;
+                trackLabelDiv.innerHTML = track.name.replaceAll(".", " ");
+                trackLabelDiv.style.position = 'absolute';
+                trackLabelDiv.style.fontSize = '12px';
+                trackLabelDiv.style.color = '#333';
+                trackLabelDiv.style.top = `${top + 10}px`;
+                trackLabelDiv.style.left = '5px';
+                trackLabelDiv.style.right = '5px';
+                trackLabelDiv.style.height = `{track.trackView.viewports[0].viewportElement.clientHeight}px`;
+                trackLabelDiv.style.textAlign = 'left';
+                trackLabelDiv.style.overflow = 'hidden';
+
+                nameDiv.appendChild(trackLabelDiv);
             }
             top += track.trackView.viewports[0].viewportElement.clientHeight; // Adjust top for the next element
         }
     }
 
     /**
      * Insert a new row into the image table for the IGV browser.  The igv browser object is a super-track of
      * sorts, containing all igv.js tracks in the session.  In the future we might want to allocate a row
      * for each igv.js track, but this will require some refactoring of igv.js.
      *
      * @returns {HTMLTableRowElement}
      */
+
     function insertIGVRow() {
         const imgTbl = document.getElementById('imgTbl');
         const tbody = imgTbl.querySelector('tbody');
         const igvRow = document.createElement('tr');
         igvRow.id = "tr_igv";
-            igvRow.innerHTML = `
-                <td style="background: grey">
-                    <div style="width:13px"></div>
-                </td>
-                <td style="position: relative">
-                    <div id="igv_namediv" style="position:absolute; top:0; bottom:0; left:0; right:0;"></div>
-                </td>
-                <td>
-                    <div id="igv_div" style="width: auto"></div>
-                </td>
-            `;
+        igvRow.classList.add('imgOrd', 'trDraggable');
+        igvRow.style.position = 'relative'; // For positioning the overlay
+
+        // First cell for drag handle and left sidebar
+        const td1 = document.createElement('td');
+        td1.className = 'dragHandle';
+        td1.style.position = 'relative';
+        igvRow.appendChild(td1);
+
+        const leftSidebarDiv = document.createElement('div');
+        leftSidebarDiv.id = 'igv_leftsidebar';
+        leftSidebarDiv.style.position = "absolute";
+        leftSidebarDiv.style.top = "0px";
+        leftSidebarDiv.style.bottom = "0px";
+        leftSidebarDiv.style.left = "0px";
+        leftSidebarDiv.style.right = "0px";
+        leftSidebarDiv.style.background = "rgb(193,193,193)";
+        td1.appendChild(leftSidebarDiv);
+
+        // Second cell for track names
+        const td2 = document.createElement('td');
+        td2.style.position = 'relative';
+        igvRow.appendChild(td2);
+
+        const nameDiv = document.createElement('div');
+        nameDiv.id = 'igv_namediv';
+        nameDiv.style.position = 'absolute';
+        nameDiv.style.top = '0';
+        nameDiv.style.bottom = '0';
+        nameDiv.style.left = '0';
+        nameDiv.style.right = '0';
+        td2.appendChild(nameDiv);
+
+        // Third cell for the IGV browser div
+        const td3 = document.createElement('td');
+        igvRow.appendChild(td3);
+
+        const igvDiv = document.createElement('div');
+        igvDiv.id = 'igv_div';
+        igvDiv.style.width = 'auto';
+        td3.appendChild(igvDiv);
+
+        // Create a transparent overlay for the entire row --currently disabled
+        // const overlay = document.createElement('div');
+        // overlay.style.position = 'absolute';
+        // overlay.style.top = '0';
+        // overlay.style.left = '0';
+        // overlay.style.width = '100%';
+        // overlay.style.height = '100%';
+        // overlay.style.backgroundColor = 'rgba(75, 255, 75, 0.2)';
+        // overlay.style.display = 'none'; // Initially hidden
+        // overlay.style.pointerEvents = 'none'; // Allow clicks to pass through
+        // igvRow.appendChild(overlay);
+        //
+        // // Show/hide overlay on mouseover/mouseout of the left sidebar
+        // leftSidebarDiv.addEventListener('mouseenter', () => {
+        //     overlay.style.display = 'block';
+        // });
+        // leftSidebarDiv.addEventListener('mouseleave', () => {
+        //     overlay.style.display = 'none';
+        // });
+        // nameDiv.addEventListener('mouseenter', () => {
+        //     overlay.style.display = 'block';
+        // });
+        // nameDiv.addEventListener('mouseleave', () => {
+        //     overlay.style.display = 'none';
+        // });
+        //
+
          tbody.appendChild(igvRow);
          return igvRow;
     }
 
     /**
      * Create an IGV browser instance and insert it into the image table in a new row. The IGV browser essentially becomes
      * a track in the UCSC browser context.  This function is called when the user adds the first IGV track, or
      * the igv session is restored on page reload.
      *
      * @param config -- The IGV browser configuration object.  Must include a reference genome, but might also include
      *                  an initial locus or tracks.
      * @returns {Promise<Browser>}
      */
     async function createIGVBrowser(config) {
 
@@ -554,31 +624,31 @@
             return;
         }
         const igvRow = insertIGVRow();
 
         // Ammend the igv config to remove most of the IGV widgets.  We only want the track display area.
         Object.assign(config, {
             showNavigation: false,
             showIdeogram: false,
             showRuler: false,
             //showSequence: false,   // Uncomment this to hide igv sequence track
             showAxis: false,
             showTrackDragHandles: false,
             showAxisColumn: false,
             gearColumnPosition: 'left',
             showGearColumn: false,
-                //showTrackLabels: false,   // Uncomment this to hide the "floating div" igv track labels
+            showTrackLabels: false,   // Uncomment this to hide the "floating div" igv track labels
             formEmbedMode: true,  // works around hotkey issues affecting igv input elements
             disableZoom: true,   // disable zooming in igv, use UCSC zoom controls
             minimumBases: 0     // No lower limit on bases per pixel, this is controlled by UCSC zoom
         });
 
         const div = document.getElementById("igv_div");
         igvBrowser = await igv.createBrowser(div, config);
         updateTrackNames();
 
         // Add event handler to remove IGV row from table if all IGV tracks are removed.
         igvBrowser.on('trackremoved', function (track) {
 
             channel.postMessage({type: "removedTrack", config: track.config});
 
             const allTracks = igvBrowser.findTracks(t => "sequence" !== t.type);   // ignore sequence track
@@ -728,44 +798,59 @@
         const upOneDirURL = currentURL.substring(0, currentURL.lastIndexOf('/'));
         const apiUrl = upOneDirURL + `/hubApi?cmd=/list/files;genome=${genomeID};format=text;skipContext=1;fileType=2bit`;
         try {
             const twoBitURL = await getLine(apiUrl);
             return {
                 "id": genomeID,
                 "twoBitURL": twoBitURL,
             };
         } catch (e) {
             console.error(e);
             alert("Internal Error: Cannot get 2bit file from " + apiUrl);
             return null;
         }
     }
 
+    /**
+     * Opens a modal dialog with a message and custom buttons.
+     * @param {string} message - The HTML message to display.
+     * @param {Object} [buttons] - An object where keys are button labels and values are click handler functions.
+     */
+    function showDialog(message, buttons) {
+        $("#igvAlertDialog").html(message);
+        const buttonsToShow = buttons || {
+            "OK": function () {
+                $(this).dialog("close");
+            }
+        };
+        $("#igvAlertDialog").dialog("option", "buttons", buttonsToShow).dialog("open");
+    }
+
+
     /**
      * Parse a IGV / UCSC style locus string, e.g. "chr1:1000-2000" or "chr1:1000" or "chr1", and return
      * a locus object {chr, start, end}.  The start is 0-based, end is 1-based.
      *
      * @param locusString
      * @returns {{chr: *, start: (number|number), end: (number|*)}}
      */
     function parseLocusString(locusString) {
         const locusRegex = /^([^:]+)(?::(\d+)(?:-(\d+))?)?$/;
         const match = locusString.match(locusRegex);
         if (!match) {
             throw new Error(`Invalid locus string: ${locusString}`);
         }
         const chr = match[1];
         let start = match[2] ? parseInt(match[2].replace(",", ""), 10) - 1 : 0; // Convert to 0-based
         let end = match[3] ? parseInt(match[3].replace(",", ""), 10) : start + 100; // Default to 100bp if no end provided
         if (isNaN(start) || isNaN(end) || start < 0 || end <= start) {
             throw new Error(`Invalid start or end in locus string: ${locusString}`);
         }
         return {chr, start, end};
     }
 
 // Attach helper functions to the igv object
     igv.initIgvUcsc = initIgvUcsc;
     igv.updateIgvStartPosition = updateIgvStartPosition;
 
-    }
-)
+})
 ();