d7dd4f55e28699bb783563b8bece5bf23f392995
chmalee
  Wed May 13 11:07:57 2026 -0700
Adds hg.conf myVariantsDataDir for writing ctfiles outside trash/, per-db short/long label rename on hgTrackUi, and a shared removal helper so the hgTrackUi remove button, hgTracks trash icon, and hgCustom checkbox all function the same, refs #33808

diff --git src/hg/lib/myVariants.c src/hg/lib/myVariants.c
index 3a037263ab5..cf1785a1593 100644
--- src/hg/lib/myVariants.c
+++ src/hg/lib/myVariants.c
@@ -561,33 +561,144 @@
             "    alt varchar(255) not null,\n"
             "    project varchar(255) not null,\n"
             "    mouseover varchar(255) not null,\n"
             "    id int auto_increment,\n"
             "    PRIMARY KEY(id),\n"
             "    INDEX(chrom(16),bin),\n"
             "    INDEX(db),\n"
             "    INDEX(project)\n"
             ") ENGINE=InnoDB;", db, tableName);
     sqlUpdate(conn, dyStringCannibalize(&createTable));
     return myVariantsGetDbTable(userName);
     }
 } 
 
 
+static void readLabelsFromCtFile(char *path, char *trackName,
+                                 char **retShort, char **retLong)
+/* Parse the matching track line in an existing ctfile and pull shortLabel
+ * and longLabel via hashVarLine.  Leaves *ret as NULL when not found or
+ * path is missing. */
+{
+*retShort = NULL;
+*retLong = NULL;
+if (isEmpty(path) || isEmpty(trackName) || !fileExists(path))
+    return;
+struct lineFile *lf = lineFileOpen(path, TRUE);
+char *line = NULL;
+while (lineFileNext(lf, &line, NULL))
+    {
+    if (!startsWith("track ", line))
+        continue;
+    struct hash *settings = hashVarLine(line + strlen("track "), lf->lineIx);
+    char *name = hashFindVal(settings, "name");
+    if (name != NULL && sameString(name, trackName))
+        {
+        char *s = hashFindVal(settings, "shortLabel");
+        char *l = hashFindVal(settings, "longLabel");
+        if (s != NULL)
+            *retShort = cloneString(s);
+        if (l != NULL)
+            *retLong = cloneString(l);
+        hashFree(&settings);
+        break;
+        }
+    hashFree(&settings);
+    }
+lineFileClose(&lf);
+}
+
+static char *sanitizeLabel(char *raw, int maxLen)
+/* Strip both quote characters, newlines and carriage returns, then cap to
+ * maxLen characters.  Returns NULL for NULL/empty input or when sanitizing
+ * leaves nothing behind.  Matches the precedent in hgCustom.c for
+ * user-supplied CT labels written into the trackline. */
+{
+if (isEmpty(raw))
+    return NULL;
+char *s = cloneString(raw);
+stripChar(s, '"');
+stripChar(s, '\'');
+stripChar(s, '\\');
+stripChar(s, '\n');
+stripChar(s, '\r');
+if ((int)strlen(s) > maxLen)
+    s[maxLen] = '\0';
+if (isEmpty(s))
+    {
+    freeMem(s);
+    return NULL;
+    }
+return s;
+}
+
+static void myVariantsCtFilePath(struct tempName *tn,
+                                 char *encodedTableName, char *targetDb)
+/* Build the per-user-per-db ctfile path.  Prefers
+ * cfgOption("myVariantsDataDir") so the file lives outside trash and the
+ * trashCleaner doesn't expire it; falls back to trashDirReusableFile when
+ * the dir isn't configured. */
+{
+char *persistentDir = cfgOption("myVariantsDataDir");
+if (isNotEmpty(persistentDir))
+    {
+    /* Group files per user so one user's tracks live together:
+     * ${persistentDir}/<encodedTableName>/<db>.bed. */
+    char *subdir = isNotEmpty(encodedTableName) ? encodedTableName : "shared";
+    char *sep = endsWith(persistentDir, "/") ? "" : "/";
+    char path[PATH_LEN];
+    safef(path, sizeof path, "%s%s%s/%s.bed", persistentDir, sep, subdir, targetDb);
+    char dirPart[PATH_LEN];
+    splitPath(path, dirPart, NULL, NULL);
+    makeDirsOnPath(dirPart);
+    safef(tn->forCgi, sizeof tn->forCgi, "%s", path);
+    safef(tn->forHtml, sizeof tn->forHtml, "%s", path);
+    }
+else
+    {
+    char base[PATH_LEN];
+    char *hostPort = cgiServerNamePort();
+    safef(base, sizeof base, "myVariants_%s_%s_%s",
+        hostPort ? hostPort : "localhost", targetDb,
+        isNotEmpty(encodedTableName) ? encodedTableName : "shared");
+    for (char *p = base; *p; p++) if (*p == '/') *p = '_';
+    trashDirReusableFile(tn, "ct", base, ".bed");
+    }
+}
+
+void myVariantsUnlinkCtFile(char *userName, char *targetDb)
+/* Delete the on-disk ctfile for this user+assembly if it exists.  Uses the
+ * same path resolution as myVariantsWriteCtFile so it targets either the
+ * persistent dir (myVariantsDataDir) or the trash fallback. */
+{
+if (isEmpty(userName) || isEmpty(targetDb))
+    return;
+char *encodedTableName = myVariantsGetTableName(userName);
+if (isEmpty(encodedTableName))
+    return;
+struct tempName tn;
+myVariantsCtFilePath(&tn, encodedTableName, targetDb);
+if (fileExists(tn.forCgi))
+    unlink(tn.forCgi);
+freeMem(encodedTableName);
+}
+
 char *myVariantsWriteCtFile(char *userName, char *targetDb, struct cart *cart)
-/* Write a Custom Track file to trash for user's myVariants in targetDb and any shared
- * tracks found in cart. Return filename or NULL if nothing to write. */
+/* Write a Custom Track file for user's myVariants in targetDb and any shared
+ * tracks found in cart. Return filename or NULL if nothing to write.
+ * If cfgOption("myVariantsDataDir") is set the file is placed there so the
+ * trashCleaner doesn't expire it; otherwise it goes in trash/ct as before. */
 {
 if (isEmpty(targetDb))
     return NULL;
 
 /* Identifier-safe form of userName for the CT track name and trash filename.
  * The raw userName may contain non-ASCII or characters unsafe in trackDb
  * syntax, filesystem paths, or SQL (e.g. '@', ';', spaces, quotes). */
 char *encodedTableName = NULL;
 if (isNotEmpty(userName))
     encodedTableName = myVariantsGetTableName(userName);
 
 /* Check if user has their own items */
 boolean hasOwnItems = FALSE;
 if (isNotEmpty(userName))
     {
@@ -647,69 +758,152 @@
         char *label = isNotEmpty(share->label) ? cloneString(share->label) : NULL;
         stripChar(owner, '"');
         stripChar(project, '"');
         if (label != NULL)
             stripChar(label, '"');
         char *projectLabel = sameString(project, "*") ? "All" : project;
         char shortLabel[64];
         if (isNotEmpty(label))
             safef(shortLabel, sizeof(shortLabel), "%s", label);
         else
             safef(shortLabel, sizeof(shortLabel), "%s's %s", owner, projectLabel);
         dyStringPrintf(sharedLines,
             "track name=\"myVariants_shared_%s\" type=\"myVariants\" itemRgb=\"on\""
             " visibility=\"pack\""
             " shortLabel=\"%s\""
-            " longLabel=\"Shared variants: %s (from %s)\"\n",
+            " longLabel=\"Shared annotations: %s (from %s)\"\n",
             token, shortLabel, projectLabel, owner);
         freeMem(owner);
         freeMem(project);
         freeMem(label);
         myVariantsShareFree(&share);
         }
     hashElFreeList(&shareVars);
     }
 
 if (!hasOwnItems && dyStringLen(sharedLines) == 0)
     {
+    /* Nothing to write: remove any stale on-disk file so it doesn't leak
+     * when the user empties their table.  Under the old trash-only path
+     * the trashCleaner would have done this for us. */
+    if (isNotEmpty(encodedTableName))
+        {
+        struct tempName stale;
+        myVariantsCtFilePath(&stale, encodedTableName, targetDb);
+        if (fileExists(stale.forCgi))
+            unlink(stale.forCgi);
+        }
     dyStringFree(&sharedLines);
     freeMem(encodedTableName);
     return NULL;
     }
 
 /* Reusable, stable filename per user+db - always rewrite since shares are dynamic */
 struct tempName tn;
-char base[PATH_LEN];
-char *hostPort = cgiServerNamePort();
-safef(base, sizeof base, "myVariants_%s_%s_%s",
-    hostPort ? hostPort : "localhost", targetDb,
-    isNotEmpty(encodedTableName) ? encodedTableName : "shared");
-for (char *p = base; *p; p++) if (*p == '/') *p = '_';
-trashDirReusableFile(&tn, "ct", base, ".bed");
+myVariantsCtFilePath(&tn, encodedTableName, targetDb);
+
+/* Resolve display labels for the user's own track: cart vars set by the
+ * rename UI win, then the existing on-disk file (so a rename survives a
+ * cart reset), then the default. */
+char *shortLabel = NULL, *longLabel = NULL;
+if (hasOwnItems)
+    {
+    if (cart != NULL)
+        {
+        char cartVar[256];
+        safef(cartVar, sizeof cartVar, "%s.%s.shortLabel",
+            encodedTableName, targetDb);
+        shortLabel = sanitizeLabel(cartOptionalString(cart, cartVar), 80);
+        safef(cartVar, sizeof cartVar, "%s.%s.longLabel",
+            encodedTableName, targetDb);
+        longLabel = sanitizeLabel(cartOptionalString(cart, cartVar), 200);
+        }
+    if (shortLabel == NULL || longLabel == NULL)
+        {
+        char *diskShort = NULL, *diskLong = NULL;
+        readLabelsFromCtFile(tn.forCgi, encodedTableName, &diskShort, &diskLong);
+        if (shortLabel == NULL)
+            shortLabel = sanitizeLabel(diskShort, 80);
+        if (longLabel == NULL)
+            longLabel = sanitizeLabel(diskLong, 200);
+        freeMem(diskShort);
+        freeMem(diskLong);
+        }
+    if (shortLabel == NULL)
+        shortLabel = cloneString("My Annotations");
+    if (longLabel == NULL)
+        longLabel = cloneString("My Annotations");
+    }
+
 FILE *f = mustOpen(tn.forCgi, "w");
 if (hasOwnItems)
     fprintf(f, "track name=\"%s\" type=\"myVariants\" itemRgb=\"on\""
-        " visibility=\"pack\" shortLabel=\"My Annotations\""
-        " longLabel=\"My Annotations (%s)\"\n", encodedTableName, encodedTableName);
+        " visibility=\"pack\" shortLabel=\"%s\""
+        " longLabel=\"%s\"\n", encodedTableName, shortLabel, longLabel);
 if (dyStringLen(sharedLines) > 0)
     fprintf(f, "%s", dyStringContents(sharedLines));
 carefulClose(&f);
 dyStringFree(&sharedLines);
 freeMem(encodedTableName);
+freeMem(shortLabel);
+freeMem(longLabel);
 return cloneString(tn.forCgi);
 }
 
+boolean myVariantsHandleCtRemoval(struct customTrack *ct, struct cart *cart,
+                                  char *database)
+/* If ct is a myVariants own track: delete the user's rows for the current
+ * assembly, unlink the persistent ctfile, drop the per-db renamed labels
+ * and the visibility var.  If ct is a myVariants shared track: drop the
+ * share-acceptance cart var and the visibility var.  Returns TRUE when ct
+ * was a myVariants track and this function fully handled the cart cleanup
+ * (caller must skip its own per-track cart cleanup so other-assembly
+ * labels are preserved); FALSE otherwise. */
+{
+if (ct == NULL || ct->tdb == NULL || isEmpty(ct->tdb->track))
+    return FALSE;
+char *trackName = ct->tdb->track;
+if (startsWith("myVariants_shared_", trackName))
+    {
+    char *token = trackName + strlen("myVariants_shared_");
+    char shareCartVar[256];
+    safef(shareCartVar, sizeof shareCartVar,
+        MYVAR_SHARED_CART_PREFIX "%s", token);
+    cartRemove(cart, shareCartVar);
+    cartRemove(cart, trackName);
+    return TRUE;
+    }
+if (startsWith("myVariants_", trackName))
+    {
+    char *userName = wikiLinkUserName();
+    if (isNotEmpty(userName))
+        {
+        myVariantsDeleteForDb(userName, database);
+        myVariantsUnlinkCtFile(userName, database);
+        }
+    /* Drop the per-db renamed labels so a freshly created track on this
+     * assembly starts at "My Annotations" again.  Labels for other
+     * assemblies stay because they belong to different displayed tracks. */
+    char labelPrefix[256];
+    safef(labelPrefix, sizeof labelPrefix, "%s.%s.", trackName, database);
+    cartRemovePrefix(cart, labelPrefix);
+    cartRemove(cart, trackName);
+    return TRUE;
+    }
+return FALSE;
+}
+
 struct slName *myVariantsGetProjects(char *userName)
 /* Return list of distinct non-empty project values for this user's myVariants table.
  * Caller must slFreeList the result. Returns NULL if no projects or table doesn't exist. */
 {
 if (isEmpty(userName))
     return NULL;
 
 char *dbTable = myVariantsTableExists(userName);
 if (isEmpty(dbTable))
     return NULL;
 
 struct sqlConnection *conn = hAllocConn(CUSTOM_TRASH);
 struct slName *projects = NULL;
 char query[512];
 sqlSafef(query, sizeof(query),