c5a326f3cc42beca0b59c284a2819763548c166a
max
  Wed Jul 8 05:31:13 2026 -0700
Add top-right "Share a link" and "Login" links to the menu bar, refs #10138

New js/topLinks.js drives two links at the top-right of the menu bar on all pages:

- Login: links to the login page when logged out; when logged in it shows the
username and opens an account dialog (My Sessions / My Custom Tracks /
My Track Hubs, change password, sign out).
- Share a link: on hgTracks, one click saves the current view as a session and
shows a copyable short link, with an optional "Specify name" rename step; works
logged-in or anonymously (reserved user "l"). On hgTrackUi and the hgc/hgGene
item-details popup it instead shares the page URL with the hgsid stripped and
the db argument kept.

hgSession gains two JSON endpoints (hgS_doSaveSessionJson, hgS_doRenameSessionJson)
that reuse saveCartAsSession() and addSessionLink(). The menu bar is patched in
lib/web.c (CGI pages) and hgMenubar.c (static pages) via comment placeholders in
globalNavBar.inc. On narrow/phone screens the links collapse into a menu icon with
a dropdown so they no longer overlap the menu.

diff --git src/hg/hgSession/hgSession.c src/hg/hgSession/hgSession.c
index 9d03630d62b..eb96e088e86 100644
--- src/hg/hgSession/hgSession.c
+++ src/hg/hgSession/hgSession.c
@@ -28,30 +28,31 @@
 #include "ra.h"
 #include "wikiLink.h"
 #include "customTrack.h"
 #include "customFactory.h"
 #include "udc.h"
 #include "hgSession.h"
 #include "hgConfig.h"
 #include "sessionThumbnail.h"
 #include "filePath.h"
 #include "obscure.h"
 #include "trashDir.h"
 #include "hubConnect.h"
 #include "trackHub.h"
 #include "errCatch.h"
 #include "sessionData.h"
+#include "jsonParse.h"
 
 char *database = NULL;
 
 void usage()
 /* Explain usage and exit. */
 {
 errAbort(
   "hgSession - Interface with wiki login and do session saving/loading.\n"
   "usage:\n"
   "    hgSession <various CGI settings>\n"
   );
 }
 
 /* Global variables. */
 struct cart *cart;
@@ -987,30 +988,166 @@
         {
         printShareMessage(dyMessage, encUserName, encSessionName, FALSE);
         }
     cartCheckForCustomTracks(cart, dyMessage);
     }
 else
     dyStringPrintf(dyMessage,
 	  "Sorry, required table %s does not exist yet in the central "
 	  "database (%s).  Please ask a developer to create it using "
 	  "kent/src/hg/lib/namedSessionDb.sql .",
 	  namedSessionTable, sqlGetDatabase(conn));
 hDisconnectCentral(&conn);
 return dyStringCannibalize(&dyMessage);
 }
 
+static void saveSessionJsonError(struct sqlConnection *conn, char *message)
+/* Emit a JSON error response for the "Share a link" AJAX endpoints and disconnect. */
+{
+puts("Content-Type:application/json\n");
+printf("{\"error\": \"%s\"}\n", jsonStringEscape(message));
+hDisconnectCentral(&conn);
+}
+
+static void saveSessionJsonResult(struct sqlConnection *conn, char *encUserName,
+                                  char *encSessionName, char *sessionName)
+/* Emit {"name": ..., "url": ...} for the "Share a link" AJAX endpoints and disconnect.
+ * sessionName is the human-readable (decoded) name; the client uses it as the rename "old name". */
+{
+struct dyString *dyUrl = dyStringNew(0);
+addSessionLink(dyUrl, encUserName, encSessionName, FALSE, TRUE);
+puts("Content-Type:application/json\n");
+printf("{\"name\": \"%s\", \"url\": \"%s\"}\n",
+       jsonStringEscape(sessionName), jsonStringEscape(dyUrl->string));
+dyStringFree(&dyUrl);
+hDisconnectCentral(&conn);
+}
+
+void doSaveSessionJson(char *userName)
+/* AJAX endpoint behind the "Share a link" menu button.  Save the current cart as a named session
+ * and print JSON {"name": <session name>, "url": <shareable link>}.  When the user is not logged
+ * in (or hgsShareAnon is set), save under the reserved anonymous user "l" with a random token
+ * name.  When logged in with no name given, generate a short random name.  Saved shared by link so
+ * the link works for anyone.  Reuses saveCartAsSession() and addSessionLink(). */
+{
+struct sqlConnection *conn = hConnectCentral();
+if (!sqlTableExists(conn, namedSessionTable))
+    {
+    saveSessionJsonError(conn, "Required session table does not exist in the central database.");
+    return;
+    }
+
+boolean anon = isEmpty(userName) || cgiBoolean(hgsShareAnon);
+// Read the requested name from the request, not the cart (hgSession's Save form leaves a sticky
+// value in the cart under this same variable that would otherwise shadow ours).
+char *sessionName = trimSpaces(cloneString(cgiUsualString(hgsNewSessionName, "")));
+
+/* Keep our control variables out of the saved session contents and the user's own cart. */
+cartRemove(cart, hgsDoSaveSessionJson);
+cartRemove(cart, hgsShareAnon);
+cartRemove(cart, hgsNewSessionName);
+cartRemove(cart, hgsNewSessionShare);
+
+char *encUserName = NULL;
+char *encSessionName = NULL;
+if (anon)
+    {
+    encUserName = "l";                    /* reserved anonymous user -> short link /s/l/<token> */
+    sessionName = makeRandomKey(96);      /* 16 URL-safe alphanumeric chars; no encoding needed */
+    encSessionName = sessionName;
+    }
+else
+    {
+    if (isEmpty(sessionName))
+        {
+        /* One-click share: auto-name the session.  "_" is kept verbatim by cgiEncodeFull (unlike
+         * "-"), so the short link /s/<user>/<name> stays clean. */
+        char randName[32];
+        char *rk = makeRandomKey(48);     /* 8 URL-safe alphanumeric chars */
+        safef(randName, sizeof randName, "share_%s", rk);
+        freeMem(rk);
+        sessionName = cloneString(randName);
+        }
+    encUserName = cgiEncodeFull(userName);
+    encSessionName = cgiEncodeFull(sessionName);
+    }
+
+saveCartAsSession(conn, encUserName, encSessionName, 1);  /* shared by link */
+saveSessionJsonResult(conn, encUserName, encSessionName, sessionName);
+}
+
+void doRenameSessionJson(char *userName)
+/* AJAX endpoint for the "Specify name" step of the Share dialog: rename an existing session
+ * (hgsOldSessionName -> hgsNewSessionName) under the current user.  Logged-in only.  Rejects a
+ * name already in use rather than overwriting it.  Returns {"name","url"} or {"error"}. */
+{
+struct sqlConnection *conn = hConnectCentral();
+// Read the names from the request, not the cart: hgSession's Save form also uses these variables
+// and leaves a sticky value (e.g. the username) in the cart that would otherwise shadow ours.
+char *oldName = trimSpaces(cloneString(cgiUsualString(hgsOldSessionName, "")));
+char *newName = trimSpaces(cloneString(cgiUsualString(hgsNewSessionName, "")));
+cartRemove(cart, hgsDoRenameSessionJson);
+cartRemove(cart, hgsOldSessionName);
+cartRemove(cart, hgsNewSessionName);
+
+if (isEmpty(userName))
+    {
+    saveSessionJsonError(conn, "Please log in to name this link.");
+    return;
+    }
+if (isEmpty(oldName) || isEmpty(newName))
+    {
+    saveSessionJsonError(conn, "Please enter a name.");
+    return;
+    }
+
+char *encUserName = cgiEncodeFull(userName);
+char *encOldName = cgiEncodeFull(oldName);
+char *encNewName = cgiEncodeFull(newName);
+char query[1024];
+
+if (sameString(oldName, newName))
+    {
+    saveSessionJsonResult(conn, encUserName, encNewName, newName);
+    return;
+    }
+
+/* Reject a name the user is already using (don't clobber an existing saved session). */
+sqlSafef(query, sizeof query, "select count(*) from %s where userName = '%s' and sessionName = '%s'",
+         namedSessionTable, encUserName, encNewName);
+if (sqlQuickNum(conn, query) > 0)
+    {
+    saveSessionJsonError(conn, "You already have a session with that name. Please pick another.");
+    return;
+    }
+sqlSafef(query, sizeof query, "select count(*) from %s where userName = '%s' and sessionName = '%s'",
+         namedSessionTable, encUserName, encOldName);
+if (sqlQuickNum(conn, query) == 0)
+    {
+    saveSessionJsonError(conn, "Could not find the link to rename.");
+    return;
+    }
+
+/* Same UPDATE that doSessionChange uses to rename a session. */
+sqlSafef(query, sizeof query,
+         "UPDATE %s set sessionName = '%s' WHERE userName = '%s' AND sessionName = '%s';",
+         namedSessionTable, encNewName, encUserName, encOldName);
+sqlUpdate(conn, query);
+
+saveSessionJsonResult(conn, encUserName, encNewName, newName);
+}
+
 int thumbnailAdd(char *encUserName, char *encSessionName, struct sqlConnection *conn, struct dyString *dyMessage)
 /* Create a thumbnail image for the gallery.  If the necessary tools can't be found,
  * add a warning message to dyMessage unless the hg.conf setting
  * sessionThumbnail.suppressWarning is set to "on".
  * Leaks memory from a generated filename string, plus a couple of dyStrings.
  * Returns without determining if image creation succeeded (it happens in a separate
  * thread); the return value is 0 if a message was added to dyMessage, otherwise it's 1. */
 {
 char query[4096];
 
 char *suppressConvert = cfgOption("sessionThumbnail.suppress");
 if (suppressConvert != NULL && sameString(suppressConvert, "on"))
     return 1;
 
 char *convertPath = cfgOption("sessionThumbnail.convertPath");
@@ -1920,30 +2057,38 @@
 	launchForeAndBackGround("makeDownloadSessionCtData");
 
 	exit(0);
 	}
 
     }
 else if (doDownloadList)
     doDownloadSessionCtData(doDownloadList);
 else if (cartVarExists(cart, hgsDoMainPage) || cartVarExists(cart, hgsCancel))
     doMainPage(userName, NULL);
 else if (cartVarExists(cart, hgsDoNewSession))
     {
     char *message = doNewSession(userName);
     doMainPage(userName, message);
     }
+else if (cartVarExists(cart, hgsDoSaveSessionJson))
+    {
+    doSaveSessionJson(userName);
+    }
+else if (cartVarExists(cart, hgsDoRenameSessionJson))
+    {
+    doRenameSessionJson(userName);
+    }
 else if (cartVarExists(cart, hgsDoOtherUser))
     {
     char *message = doOtherUser(hgsDoOtherUser);
     doMainPage(userName, message);
     }
 else if (cartVarExists(cart, hgsDoSaveLocal))
     {
     doSaveLocal();
     }
 else if (cartVarExists(cart, hgsDoLoadLocal))
     {
     char *message = doLoad(FALSE, hgsDoLoadLocal);
     doMainPage(userName, message);
     }
 else if (cartVarExists(cart, hgsDoLoadUrl))