ui: add support for saving/restoring sets of tracks by name

Allows for having multiple sets of pinned tracks stashed at the same
time.

Bug: https://github.com/google/perfetto/issues/242
Change-Id: I1d0c46cecaa78ed00722d7e2402b4e2043fb2dad
diff --git a/ui/src/plugins/dev.perfetto.RestorePinnedTracks/index.ts b/ui/src/plugins/dev.perfetto.RestorePinnedTracks/index.ts
index fadb4ed..f787163 100644
--- a/ui/src/plugins/dev.perfetto.RestorePinnedTracks/index.ts
+++ b/ui/src/plugins/dev.perfetto.RestorePinnedTracks/index.ts
@@ -17,6 +17,13 @@
 import {PerfettoPlugin} from '../../public/plugin';
 import {TrackDescriptor} from '../../public/track';
 import {z} from 'zod';
+import {
+  base64Decode,
+  base64Encode,
+  utf8Decode,
+  utf8Encode,
+} from '../../base/string_utils';
+import {assertExists} from '../../base/logging';
 
 const PLUGIN_ID = 'dev.perfetto.RestorePinnedTrack';
 const SAVED_TRACKS_KEY = `${PLUGIN_ID}#savedPerfettoTracks`;
@@ -41,40 +48,85 @@
       id: `${PLUGIN_ID}#save`,
       name: 'Save: Pinned tracks',
       callback: () => {
-        this.saveTracks();
+        this.savedState = {
+          ...this.savedState,
+          tracks: this.getCurrentPinnedTracks(),
+        };
       },
     });
     ctx.commands.registerCommand({
       id: RESTORE_COMMAND_ID,
       name: 'Restore: Pinned tracks',
       callback: () => {
-        this.restoreTracks();
+        const tracks = this.savedState?.tracks;
+        if (!tracks) {
+          alert('No saved tracks. Use the Save command first');
+          return;
+        }
+        this.restoreTracks(tracks);
+      },
+    });
+
+    ctx.commands.registerCommand({
+      id: `${PLUGIN_ID}#saveByName`,
+      name: 'Save by name: Pinned tracks',
+      callback: async () => {
+        const res = await this.ctx.omnibox.prompt(
+          'Give a name to the pinned set of tracks',
+        );
+        if (res) {
+          const rawTracksByName = this.savedState?.tracksByName ?? [];
+          const tracksByNameMap = new Map(
+            rawTracksByName.map((x) => [x.name, x.tracks]),
+          );
+          tracksByNameMap.set(
+            base64Encode(utf8Encode(res)),
+            this.getCurrentPinnedTracks(),
+          );
+          this.savedState = {
+            ...this.savedState,
+            tracksByName: Array.from(tracksByNameMap.entries()).map(
+              ([k, v]) => ({
+                name: k,
+                tracks: v,
+              }),
+            ),
+          };
+        }
+      },
+    });
+    ctx.commands.registerCommand({
+      id: `${PLUGIN_ID}#restoreByName`,
+      name: 'Restore by name: Pinned tracks',
+      callback: async () => {
+        const tracksByName = this.savedState?.tracksByName ?? [];
+        if (tracksByName.length === 0) {
+          alert('No saved tracks. Use the Save by name command first');
+          return;
+        }
+        const res = await this.ctx.omnibox.prompt(
+          'Select name of set of pinned tracks to restore',
+          tracksByName.map((x) => ({
+            key: x.name,
+            displayName: utf8Decode(base64Decode(x.name)),
+          })),
+        );
+        if (res) {
+          const tracks = assertExists(
+            tracksByName.find((x) => x.name === res)?.tracks,
+          );
+          this.restoreTracks(tracks);
+        }
       },
     });
   }
 
-  private saveTracks() {
-    this.savedState = {
-      tracks: this.ctx.workspace.pinnedTracks.map((track) =>
-        this.toSavedTrack(track),
-      ),
-    };
-  }
-
-  private restoreTracks() {
-    const savedState = this.savedState;
-    if (!savedState) {
-      alert('No saved tracks. Use the Save command first');
-      return;
-    }
-
-    const localTracks: Array<LocalTrack> = this.ctx.workspace.flatTracks.map(
-      (track) => ({
-        savedTrack: this.toSavedTrack(track),
-        track: track,
-      }),
-    );
-    savedState.tracks.forEach((trackToRestore) => {
+  private restoreTracks(tracks: ReadonlyArray<SavedPinnedTrack>) {
+    const localTracks = this.ctx.workspace.flatTracks.map((track) => ({
+      savedTrack: this.toSavedTrack(track),
+      track: track,
+    }));
+    tracks.forEach((trackToRestore) => {
       const foundTrack = this.findMatchingTrack(localTracks, trackToRestore);
       if (foundTrack) {
         foundTrack.pin();
@@ -87,6 +139,12 @@
     });
   }
 
+  private getCurrentPinnedTracks() {
+    return this.ctx.workspace.pinnedTracks.map((track) =>
+      this.toSavedTrack(track),
+    );
+  }
+
   private findMatchingTrack(
     localTracks: Array<LocalTrack>,
     savedTrack: SavedPinnedTrack,
@@ -256,7 +314,20 @@
 type SavedPinnedTrack = z.infer<typeof SAVED_PINNED_TRACK_SCHEMA>;
 
 const SAVED_STATE_SCHEMA = z
-  .object({tracks: z.array(SAVED_PINNED_TRACK_SCHEMA).readonly()})
+  .object({
+    tracks: z.array(SAVED_PINNED_TRACK_SCHEMA).optional().readonly(),
+    tracksByName: z
+      .array(
+        z
+          .object({
+            name: z.string(),
+            tracks: z.array(SAVED_PINNED_TRACK_SCHEMA).readonly(),
+          })
+          .readonly(),
+      )
+      .optional()
+      .readonly(),
+  })
   .readonly();
 
 type SavedState = z.infer<typeof SAVED_STATE_SCHEMA>;