[ui] Tweaked plugin API and added more functionality to timeline API.

- Merge `Viewer` into `PluginContext` and `PluginContextTrace`.
- Re-jig naming conventions.
- Add extra methods to control the timeline.

Change-Id: I780c00a824c205fdd32441313a9c6c47f7c83664
diff --git a/docs/contributing/ui-plugins.md b/docs/contributing/ui-plugins.md
index 23fbe25..3b84547 100644
--- a/docs/contributing/ui-plugins.md
+++ b/docs/contributing/ui-plugins.md
@@ -64,8 +64,8 @@
 Follow the [create a plugin](#create-a-plugin) to get an initial
 skeleton for your plugin.
 
-To add your first command, add a call to `ctx.addCommand()` in either your
-`onActivate()` or `onTraceLoad()` hooks. The recommendation is to register
+To add your first command, add a call to `ctx.registerCommand()` in either
+your `onActivate()` or `onTraceLoad()` hooks. The recommendation is to register
 commands in `onActivate()` by default unless they require something from
 `TracePluginContext` which is not available on `PluginContext`.
 
@@ -76,7 +76,7 @@
 ```typescript
 class MyPlugin implements Plugin {
   onActivate(ctx: PluginContext): void {
-    ctx.addCommand(
+    ctx.registerCommand(
        {
          id: 'dev.perfetto.ExampleSimpleCommand#LogHelloPlugin',
          name: 'Log "Hello, plugin!"',
@@ -86,7 +86,7 @@
   }
 
   onTraceLoad(ctx: TracePluginContext): void {
-    ctx.addCommand(
+    ctx.registerCommand(
        {
          id: 'dev.perfetto.ExampleSimpleTraceCommand#LogHelloTrace',
          name: 'Log "Hello, trace!"',
diff --git a/python/tools/check_ratchet.py b/python/tools/check_ratchet.py
index ab6a5b7..2dc27ac 100755
--- a/python/tools/check_ratchet.py
+++ b/python/tools/check_ratchet.py
@@ -36,7 +36,7 @@
     os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 UI_SRC_DIR = os.path.join(ROOT_DIR, 'ui', 'src')
 
-EXPECTED_ANY_COUNT = 74
+EXPECTED_ANY_COUNT = 73
 # 'any' is too generic. It will show up in many comments etc. So
 # instead of counting any directly we forbid it using eslint and count
 # the number of suppressions.
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index 091aacb..69e0390 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -12,9 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {v4 as uuidv4} from 'uuid';
+
 import {Disposable, Trash} from '../base/disposable';
 import {assertFalse} from '../base/logging';
-import {ViewerImpl, ViewerProxy} from '../common/viewer';
 import {globals} from '../frontend/globals';
 import {
   Command,
@@ -26,16 +27,19 @@
   PluginContext,
   PluginContextTrace,
   PluginDescriptor,
+  PrimaryTrackSortKey,
   Store,
   Track,
   TrackContext,
   TrackDescriptor,
+  TrackPredicate,
   TrackRef,
-  Viewer,
 } from '../public';
 import {Engine} from '../trace_processor/engine';
 
+import {Actions} from './actions';
 import {Registry} from './registry';
+import {SCROLLING_TRACK_GROUP} from './state';
 
 // Every plugin gets its own PluginContext. This is how we keep track
 // what each plugin is doing and how we can blame issues on particular
@@ -45,13 +49,23 @@
   private trash = new Trash();
   private alive = true;
 
-  constructor(
-      readonly pluginId: string, readonly viewer: ViewerProxy,
-      private commandRegistry: Map<string, Command>) {
-    this.trash.add(viewer);
-  }
+  readonly sidebar = {
+    hide() {
+      globals.dispatch(Actions.setSidebar({
+        visible: false,
+      }));
+    },
+    show() {
+      globals.dispatch(Actions.setSidebar({
+        visible: true,
+      }));
+    },
+    isVisible() {
+      return globals.state.sidebarVisible;
+    },
+  };
 
-  addCommand(cmd: Command): void {
+  registerCommand(cmd: Command): void {
     // Silently ignore if context is dead.
     if (!this.alive) return;
 
@@ -64,8 +78,21 @@
         this.commandRegistry.delete(id);
       },
     });
+  };
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  runCommand(id: string, ...args: any[]): any {
+    return globals.commandManager.runCommand(id, ...args);
+  };
+
+  get commands(): Command[] {
+    return globals.commandManager.commands;
   }
 
+  constructor(
+      readonly pluginId: string,
+      private commandRegistry: Map<string, Command>) {}
+
   dispose(): void {
     this.trash.dispose();
     this.alive = false;
@@ -88,7 +115,7 @@
     this.trash.add(engine);
   }
 
-  addCommand(cmd: Command): void {
+  registerCommand(cmd: Command): void {
     // Silently ignore if context is dead.
     if (!this.alive) return;
 
@@ -103,29 +130,14 @@
     });
   }
 
-  get viewer(): Viewer {
-    return this.ctx.viewer;
-  }
-
-  get pluginId(): string {
-    return this.ctx.pluginId;
-  }
-
-  // Register a new track in this context.
-  // All tracks registered through this method are removed when this context is
-  // destroyed, i.e. when the trace is unloaded.
   registerTrack(trackDesc: TrackDescriptor): void {
     // Silently ignore if context is dead.
     if (!this.alive) return;
-
     this.trackRegistry.set(trackDesc.uri, trackDesc);
     this.trash.addCallback(() => this.trackRegistry.delete(trackDesc.uri));
   }
 
   addDefaultTrack(track: TrackRef): void {
-    // Silently ignore if context is dead.
-    if (!this.alive) return;
-
     this.defaultTracks.add(track);
     this.trash.addCallback(() => this.defaultTracks.delete(track));
   }
@@ -142,6 +154,112 @@
     // });
   }
 
+  get commands(): Command[] {
+    return this.ctx.commands;
+  }
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  runCommand(id: string, ...args: any[]): any {
+    return this.ctx.runCommand(id, ...args);
+  }
+
+  get sidebar() {
+    return this.ctx.sidebar;
+  }
+
+  readonly tabs = {
+    openQuery: globals.openQuery,
+  };
+
+  get pluginId(): string {
+    return this.ctx.pluginId;
+  }
+
+  readonly timeline = {
+    // Add a new track to the timeline, returning its key.
+    addTrack(uri: string, displayName: string, params?: unknown): string {
+      const trackKey = uuidv4();
+      globals.dispatch(Actions.addTrack({
+        key: trackKey,
+        uri,
+        name: displayName,
+        params,
+        trackSortKey: PrimaryTrackSortKey.ORDINARY_TRACK,
+        trackGroup: SCROLLING_TRACK_GROUP,
+      }));
+      return trackKey;
+    },
+
+    removeTrack(key: string):
+        void {
+          globals.dispatch(Actions.removeTracks({trackKeys: [key]}));
+        },
+
+    pinTrack(key: string) {
+      if (!isPinned(key)) {
+        globals.dispatch(Actions.toggleTrackPinned({trackKey: key}));
+      }
+    },
+
+    unpinTrack(key: string) {
+      if (isPinned(key)) {
+        globals.dispatch(Actions.toggleTrackPinned({trackKey: key}));
+      }
+    },
+
+    pinTracksByPredicate(predicate: TrackPredicate) {
+      const tracks = Object.values(globals.state.tracks);
+      for (const track of tracks) {
+        const tags = {
+          name: track.name,
+        };
+        if (predicate(tags) && !isPinned(track.key)) {
+          globals.dispatch(Actions.toggleTrackPinned({
+            trackKey: track.key,
+          }));
+        }
+      }
+    },
+
+    unpinTracksByPredicate(predicate: TrackPredicate) {
+      const tracks = Object.values(globals.state.tracks);
+      for (const track of tracks) {
+        const tags = {
+          name: track.name,
+        };
+        if (predicate(tags) && isPinned(track.key)) {
+          globals.dispatch(Actions.toggleTrackPinned({
+            trackKey: track.key,
+          }));
+        }
+      }
+    },
+
+    removeTracksByPredicate(predicate: TrackPredicate) {
+      const trackKeysToRemove = Object.values(globals.state.tracks)
+                                    .filter((track) => {
+                                      const tags = {
+                                        name: track.name,
+                                      };
+                                      return predicate(tags);
+                                    })
+                                    .map((trackState) => trackState.key);
+
+      globals.dispatch(Actions.removeTracks({trackKeys: trackKeysToRemove}));
+    },
+
+    get tracks():
+        TrackRef[] {
+          return Object.values(globals.state.tracks).map((trackState) => {
+            return {
+              displayName: trackState.name,
+              uri: trackState.uri,
+              params: trackState.params,
+            };
+          });
+        },
+  };
+
   dispose(): void {
     this.trash.dispose();
     this.alive = false;
@@ -164,6 +282,10 @@
   }
 }
 
+function isPinned(trackId: string): boolean {
+  return globals.state.pinnedTracks.includes(trackId);
+}
+
 // 'Static' registry of all known plugins.
 export class PluginRegistry extends Registry<PluginDescriptor> {
   constructor() {
@@ -209,7 +331,7 @@
     this.plugins = new Map();
   }
 
-  activatePlugin(id: string, viewer: ViewerImpl): void {
+  activatePlugin(id: string): void {
     if (this.isActive(id)) {
       return;
     }
@@ -217,9 +339,7 @@
     const pluginInfo = this.registry.get(id);
     const plugin = makePlugin(pluginInfo);
 
-    const viewerProxy = viewer.getProxy(id);
-    const context =
-        new PluginContextImpl(id, viewerProxy, this.commandRegistry);
+    const context = new PluginContextImpl(id, this.commandRegistry);
 
     plugin.onActivate && plugin.onActivate(context);
 
diff --git a/ui/src/common/plugins_unittest.ts b/ui/src/common/plugins_unittest.ts
index 1d17350..05238a5 100644
--- a/ui/src/common/plugins_unittest.ts
+++ b/ui/src/common/plugins_unittest.ts
@@ -18,7 +18,6 @@
 
 import {createEmptyState} from './empty_state';
 import {PluginManager, PluginRegistry} from './plugins';
-import {ViewerImpl} from './viewer';
 
 class FakeEngine extends Engine {
   id: string = 'TestEngine';
@@ -35,7 +34,6 @@
   };
 }
 
-const viewer = new ViewerImpl();
 const engine = new FakeEngine();
 globals.initStore(createEmptyState());
 
@@ -57,14 +55,14 @@
   });
 
   it('can activate plugin', () => {
-    manager.activatePlugin('foo', viewer);
+    manager.activatePlugin('foo');
 
     expect(manager.isActive('foo')).toBe(true);
     expect(mockPlugin.onActivate).toHaveBeenCalledTimes(1);
   });
 
   it('can deactivate plugin', () => {
-    manager.activatePlugin('foo', viewer);
+    manager.activatePlugin('foo');
     manager.deactivatePlugin('foo');
 
     expect(manager.isActive('foo')).toBe(false);
@@ -72,7 +70,7 @@
   });
 
   it('invokes onTraceLoad when trace is loaded', () => {
-    manager.activatePlugin('foo', viewer);
+    manager.activatePlugin('foo');
     manager.onTraceLoad(engine);
 
     expect(mockPlugin.onTraceLoad).toHaveBeenCalledTimes(1);
@@ -80,13 +78,13 @@
 
   it('invokes onTraceLoad when plugin activated while trace loaded', () => {
     manager.onTraceLoad(engine);
-    manager.activatePlugin('foo', viewer);
+    manager.activatePlugin('foo');
 
     expect(mockPlugin.onTraceLoad).toHaveBeenCalledTimes(1);
   });
 
   it('invokes onTraceUnload when plugin deactivated while trace loaded', () => {
-    manager.activatePlugin('foo', viewer);
+    manager.activatePlugin('foo');
     manager.onTraceLoad(engine);
     manager.deactivatePlugin('foo');
 
diff --git a/ui/src/common/viewer.ts b/ui/src/common/viewer.ts
deleted file mode 100644
index 659cafb..0000000
--- a/ui/src/common/viewer.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-// Copyright (C) 2023 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {Disposable} from '../base/disposable';
-import {globals} from '../frontend/globals';
-import {runQueryInNewTab} from '../frontend/query_result_tab';
-import {TrackPredicate, Viewer} from '../public';
-
-import {Actions} from './actions';
-
-class SidebarImpl {
-  hide() {
-    globals.dispatch(Actions.setSidebar({
-      visible: false,
-    }));
-  }
-
-  show() {
-    globals.dispatch(Actions.setSidebar({
-      visible: true,
-    }));
-  }
-
-  isVisible() {
-    return globals.state.sidebarVisible;
-  }
-};
-
-class TracksImpl {
-  pin(predicate: TrackPredicate) {
-    const tracks = Object.values(globals.state.tracks);
-    for (const track of tracks) {
-      const tags = {
-        name: track.name,
-      };
-      if (predicate(tags) && !this.isPinned(track.key)) {
-        globals.dispatch(Actions.toggleTrackPinned({
-          trackKey: track.key,
-        }));
-      }
-    }
-  }
-
-  unpin(predicate: TrackPredicate) {
-    const tracks = Object.values(globals.state.tracks);
-    for (const track of tracks) {
-      const tags = {
-        name: track.name,
-      };
-      if (predicate(tags) && this.isPinned(track.key)) {
-        globals.dispatch(Actions.toggleTrackPinned({
-          trackKey: track.key,
-        }));
-      }
-    }
-  }
-
-  private isPinned(trackId: string): boolean {
-    return globals.state.pinnedTracks.includes(trackId);
-  }
-};
-
-export class ViewerImpl implements Viewer {
-  sidebar = new SidebarImpl();
-  tracks = new TracksImpl();
-
-  tabs = {
-    openQuery: runQueryInNewTab,
-  };
-
-  commands = {
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    run: (id: string, ...args: any[]) => {
-      globals.commandManager.runCommand(id, ...args);
-    },
-  };
-
-  constructor() {}
-
-  getProxy(pluginId: string): ViewerProxy {
-    return new ViewerProxy(this, pluginId);
-  }
-}
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type AnyFunction = (...args: any[]) => any;
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type AnyProcedure = (...args: any[]) => void;
-
-function wrap<F extends AnyFunction>(
-    allow: () => boolean, f: F, deadResult: ReturnType<F>) {
-  return (...args: Parameters<F>) => {
-    if (allow()) {
-      return f(...args);
-    } else {
-      return deadResult;
-    }
-  };
-}
-
-function wrapVoid<F extends AnyProcedure>(allow: () => boolean, f: F) {
-  return (...args: Parameters<F>) => {
-    if (allow()) {
-      f(...args);
-    }
-  };
-}
-
-export class ViewerProxy implements Viewer, Disposable {
-  readonly parent: ViewerImpl;
-  readonly pluginId: string;
-  private alive: boolean;
-
-  // ViewerImpl:
-  sidebar: Viewer['sidebar'];
-  tracks: Viewer['tracks'];
-  tabs: Viewer['tabs'];
-  commands: Viewer['commands'];
-
-  // ViewerProxy:
-  constructor(parent: ViewerImpl, pluginId: string) {
-    this.parent = parent;
-    this.pluginId = pluginId;
-    this.alive = true;
-    const allow = () => this.alive;
-
-    const p = parent;
-    this.sidebar = {
-      hide: wrapVoid(allow, p.sidebar.hide.bind(p.sidebar)),
-      show: wrapVoid(allow, p.sidebar.show.bind(p.sidebar)),
-      isVisible: wrap(allow, p.sidebar.isVisible.bind(p.sidebar), false),
-    };
-
-    this.tracks = {
-      pin: wrapVoid(allow, p.tracks.pin.bind(p.tracks)),
-      unpin: wrapVoid(allow, p.tracks.unpin.bind(p.tracks)),
-    };
-
-    this.tabs = {
-      openQuery: wrapVoid(allow, p.tabs.openQuery.bind(p.tabs)),
-    };
-
-    this.commands = {
-      run: wrapVoid(allow, p.commands.run.bind(p.commands)),
-    };
-  }
-
-  dispose(): void {
-    this.alive = false;
-  }
-}
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index c44ce90..5fb6b7b 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -62,7 +62,6 @@
   publishRealtimeOffset,
   publishThreads,
 } from '../frontend/publish';
-import {runQueryInNewTab} from '../frontend/query_result_tab';
 import {Router} from '../frontend/router';
 import {Engine} from '../trace_processor/engine';
 import {
@@ -616,7 +615,7 @@
             pendingDeeplink.visStart, pendingDeeplink.visEnd);
       }
       if (pendingDeeplink.query !== undefined) {
-        runQueryInNewTab(pendingDeeplink.query, 'Deeplink Query');
+        globals.openQuery(pendingDeeplink.query, 'Deeplink Query');
       }
     }
 
diff --git a/ui/src/frontend/app.ts b/ui/src/frontend/app.ts
index 04d8012..a62cbf1 100644
--- a/ui/src/frontend/app.ts
+++ b/ui/src/frontend/app.ts
@@ -42,7 +42,6 @@
 import {toggleHelp} from './help_modal';
 import {fullscreenModalContainer} from './modal';
 import {Omnibox, OmniboxOption} from './omnibox';
-import {runQueryInNewTab} from './query_result_tab';
 import {verticalScrollToTrack} from './scroll_helper';
 import {executeSearch} from './search_handler';
 import {Sidebar} from './sidebar';
@@ -532,7 +531,7 @@
         raf.scheduleFullRedraw();
       },
       onSubmit: (value, alt) => {
-        runQueryInNewTab(
+        globals.openQuery(
             undoCommonChatAppReplacements(value),
             alt ? 'Pinned query' : 'Omnibox query',
             alt ? undefined : 'omnibox_query');
diff --git a/ui/src/frontend/chrome_slice_details_tab.ts b/ui/src/frontend/chrome_slice_details_tab.ts
index d7f6073..91eb840 100644
--- a/ui/src/frontend/chrome_slice_details_tab.ts
+++ b/ui/src/frontend/chrome_slice_details_tab.ts
@@ -36,7 +36,6 @@
   NewBottomTabArgs,
 } from './bottom_tab';
 import {FlowPoint, globals} from './globals';
-import {runQueryInNewTab} from './query_result_tab';
 import {renderArguments} from './slice_args';
 import {renderDetails} from './slice_details';
 import {getSlice, SliceDetails, SliceRef} from './sql/slice';
@@ -92,7 +91,7 @@
   {
     name: 'Average duration of slice name',
     shouldDisplay: (slice: SliceDetails) => hasName(slice),
-    run: (slice: SliceDetails) => runQueryInNewTab(
+    run: (slice: SliceDetails) => globals.openQuery(
         `SELECT AVG(dur) / 1e9 FROM slice WHERE name = '${slice.name!}'`,
         `${slice.name} average dur`,
         ),
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index fa0d2ad..9a45e50 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -230,6 +230,8 @@
   clearSearch?: boolean;
 }
 
+type OpenQueryHandler = (query: string, title: string, tag?: string) => void;
+
 /**
  * Global accessors for state/dispatch in the frontend.
  */
@@ -275,6 +277,7 @@
   private _cmdManager?: CommandManager = undefined;
   private _realtimeOffset = Time.ZERO;
   private _utcOffset = Time.ZERO;
+  private _openQueryHandler?: OpenQueryHandler;
 
   // TODO(hjd): Remove once we no longer need to update UUID on redraw.
   private _publishRedraw?: () => void = undefined;
@@ -839,6 +842,22 @@
 
     return {start, end};
   }
+
+  // The implementation of the query results tab is not part of the core so we
+  // decouple globals from the implementation using this registration interface.
+  // Once we move the implementation to a plugin, this decoupling will be
+  // simpler as we just need to call a command with a well-known ID, and a
+  // plugin will provide the implementation.
+  registerOpenQueryHandler(cb: OpenQueryHandler) {
+    this._openQueryHandler = cb;
+  }
+
+  // Runs a query and displays results in a new tab.
+  // Queries will override previously opened queries with the same tag.
+  // If the tag is omitted, the results will always open in a new tab.
+  openQuery(query: string, title: string, tag?: string) {
+    assertExists(this._openQueryHandler)(query, title, tag);
+  }
 }
 
 export const globals = new Globals();
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 0024ce8..3e24ef5 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -28,7 +28,6 @@
 import {flattenArgs, traceEvent} from '../common/metatracing';
 import {pluginManager, pluginRegistry} from '../common/plugins';
 import {State} from '../common/state';
-import {ViewerImpl} from '../common/viewer';
 import {initWasm} from '../common/wasm_engine_proxy';
 import {initController, runControllers} from '../controller';
 import {
@@ -292,10 +291,8 @@
     document.body.classList.add('testing');
   }
 
-  // Initialize all plugins:
-  const viewer = new ViewerImpl();
   for (const plugin of pluginRegistry.values()) {
-    pluginManager.activatePlugin(plugin.pluginId, viewer);
+    pluginManager.activatePlugin(plugin.pluginId);
   }
 
   cmdManager.registerCommandSource(pluginManager);
diff --git a/ui/src/frontend/query_result_tab.ts b/ui/src/frontend/query_result_tab.ts
index c6b40b6..f0efe26 100644
--- a/ui/src/frontend/query_result_tab.ts
+++ b/ui/src/frontend/query_result_tab.ts
@@ -34,6 +34,7 @@
   closeTab,
   NewBottomTabArgs,
 } from './bottom_tab';
+import {globals} from './globals';
 import {QueryTable} from './query_table';
 
 export function runQueryInNewTab(query: string, title: string, tag?: string) {
@@ -47,6 +48,8 @@
   });
 }
 
+globals.registerOpenQueryHandler(runQueryInNewTab);
+
 interface QueryResultTabConfig {
   readonly query: string;
   readonly title: string;
diff --git a/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts b/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
index 8947364..44e52af 100644
--- a/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
@@ -15,15 +15,18 @@
 import {
   Plugin,
   PluginContext,
+  PluginContextTrace,
   PluginDescriptor,
 } from '../../public';
 
 class AndroidCujs implements Plugin {
-  onActivate(ctx: PluginContext): void {
-    ctx.addCommand({
+  onActivate(_ctx: PluginContext): void {}
+
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidCujs#ListJankCUJs',
       name: 'Run query: Android Jank CUJs',
-      callback: () => ctx.viewer.tabs.openQuery(
+      callback: () => ctx.tabs.openQuery(
           `
             SELECT RUN_METRIC('android/android_jank_cuj.sql');
             SELECT RUN_METRIC('android/jank/internal/counters.sql');
@@ -89,10 +92,10 @@
           'Android Jank CUJs'),
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidCujs#ListLatencyCUJs',
       name: 'Run query: Android Latency CUJs',
-      callback: () => ctx.viewer.tabs.openQuery(
+      callback: () => ctx.tabs.openQuery(
           `
             SELECT
               CASE
diff --git a/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts b/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts
index e1f0a57..a762a5e 100644
--- a/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidNetwork/index.ts
@@ -44,7 +44,7 @@
   }
 
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidNetwork#batteryEvents',
       name: 'Run query: Pin battery events',
       callback: async (track) => {
@@ -64,7 +64,7 @@
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidNetwork#activityTrack',
       name: 'Run query: Visualize Network Activity',
       callback: async (groupby, filter, trackName) => {
diff --git a/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts b/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts
index feb7080..7658fb6 100644
--- a/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidPerf/index.ts
@@ -15,44 +15,45 @@
 import {
   Plugin,
   PluginContext,
+  PluginContextTrace,
   PluginDescriptor,
 } from '../../public';
 
 class AndroidPerf implements Plugin {
-  onActivate(ctx: PluginContext): void {
-    const {viewer} = ctx;
+  onActivate(_ctx: PluginContext): void {}
 
-    ctx.addCommand({
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidPerf#BinderSystemServerIncoming',
       name: 'Run query: system_server incoming binder graph',
-      callback: () => viewer.tabs.openQuery(
+      callback: () => ctx.tabs.openQuery(
           `INCLUDE PERFETTO MODULE android.binder;
            SELECT * FROM android_binder_incoming_graph((SELECT upid FROM process WHERE name = 'system_server'))`,
           'system_server incoming binder graph'),
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidPerf#BinderSystemServerOutgoing',
       name: 'Run query: system_server outgoing binder graph',
-      callback: () => viewer.tabs.openQuery(
+      callback: () => ctx.tabs.openQuery(
           `INCLUDE PERFETTO MODULE android.binder;
            SELECT * FROM android_binder_outgoing_graph((SELECT upid FROM process WHERE name = 'system_server'))`,
           'system_server outgoing binder graph'),
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidPerf#MonitorContentionSystemServer',
       name: 'Run query: system_server monitor_contention graph',
-      callback: () => viewer.tabs.openQuery(
+      callback: () => ctx.tabs.openQuery(
           `INCLUDE PERFETTO MODULE android.monitor_contention;
            SELECT * FROM android_monitor_contention_graph((SELECT upid FROM process WHERE name = 'system_server'))`,
           'system_server monitor_contention graph'),
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.AndroidPerf#BinderAll',
       name: 'Run query: all process binder graph',
-      callback: () => viewer.tabs.openQuery(
+      callback: () => ctx.tabs.openQuery(
           `INCLUDE PERFETTO MODULE android.binder;
            SELECT * FROM android_binder_graph(-1000, 1000, -1000, 1000)`,
           'all process binder graph'),
diff --git a/ui/src/plugins/dev.perfetto.CoreCommands/index.ts b/ui/src/plugins/dev.perfetto.CoreCommands/index.ts
index 5b94d08..7024520 100644
--- a/ui/src/plugins/dev.perfetto.CoreCommands/index.ts
+++ b/ui/src/plugins/dev.perfetto.CoreCommands/index.ts
@@ -15,6 +15,7 @@
 import {
   Plugin,
   PluginContext,
+  PluginContextTrace,
   PluginDescriptor,
 } from '../../public';
 
@@ -85,88 +86,88 @@
 limit 100;`;
 
 const coreCommands: Plugin = {
-  onActivate: function(ctx: PluginContext): void {
-    const {viewer} = ctx;
+  onActivate(_ctx: PluginContext) {},
 
-    ctx.addCommand({
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#ToggleLeftSidebar',
       name: 'Toggle left sidebar',
       callback: () => {
-        if (viewer.sidebar.isVisible()) {
-          viewer.sidebar.hide();
+        if (ctx.sidebar.isVisible()) {
+          ctx.sidebar.hide();
         } else {
-          viewer.sidebar.show();
+          ctx.sidebar.show();
         }
       },
       defaultHotkey: '!Mod+B',
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#RunQueryAllProcesses',
       name: 'Run query: all processes',
       callback: () => {
-        viewer.tabs.openQuery(ALL_PROCESSES_QUERY, 'All Processes');
+        ctx.tabs.openQuery(ALL_PROCESSES_QUERY, 'All Processes');
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#RunQueryCpuTimeByProcess',
       name: 'Run query: CPU time by process',
       callback: () => {
-        viewer.tabs.openQuery(CPU_TIME_FOR_PROCESSES, 'CPU time by process');
+        ctx.tabs.openQuery(CPU_TIME_FOR_PROCESSES, 'CPU time by process');
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#RunQueryCyclesByStateByCpu',
       name: 'Run query: cycles by p-state by CPU',
       callback: () => {
-        viewer.tabs.openQuery(
+        ctx.tabs.openQuery(
             CYCLES_PER_P_STATE_PER_CPU, 'Cycles by p-state by CPU');
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#RunQueryCyclesByCpuByProcess',
       name: 'Run query: CPU Time by CPU by process',
       callback: () => {
-        viewer.tabs.openQuery(
+        ctx.tabs.openQuery(
             CPU_TIME_BY_CPU_BY_PROCESS, 'CPU Time by CPU by process');
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#RunQueryHeapGraphBytesPerType',
       name: 'Run query: heap graph bytes per type',
       callback: () => {
-        viewer.tabs.openQuery(
+        ctx.tabs.openQuery(
             HEAP_GRAPH_BYTES_PER_TYPE, 'Heap graph bytes per type');
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#DebugSqlPerformance',
       name: 'Debug SQL performance',
       callback: () => {
-        viewer.tabs.openQuery(SQL_STATS, 'Recent SQL queries');
+        ctx.tabs.openQuery(SQL_STATS, 'Recent SQL queries');
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#PinFtraceTracks',
       name: 'Pin ftrace tracks',
       callback: () => {
-        viewer.tracks.pin((tags) => {
+        ctx.timeline.pinTracksByPredicate((tags) => {
           return !!tags.name?.startsWith('Ftrace Events Cpu ');
         });
       },
     });
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.CoreCommands#UnpinAllTracks',
       name: 'Unpin all tracks',
       callback: () => {
-        viewer.tracks.unpin((_) => {
+        ctx.timeline.unpinTracksByPredicate((_) => {
           return true;
         });
       },
diff --git a/ui/src/plugins/dev.perfetto.ExampleSimpleCommand/index.ts b/ui/src/plugins/dev.perfetto.ExampleSimpleCommand/index.ts
index 4bb6e12..d09462c 100644
--- a/ui/src/plugins/dev.perfetto.ExampleSimpleCommand/index.ts
+++ b/ui/src/plugins/dev.perfetto.ExampleSimpleCommand/index.ts
@@ -21,7 +21,7 @@
 // This is just an example plugin, used to prove that the plugin system works.
 class ExampleSimpleCommand implements Plugin {
   onActivate(ctx: PluginContext): void {
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.ExampleSimpleCommand#LogHelloWorld',
       name: 'Log "Hello, world!"',
       callback: () => console.log('Hello, world!'),
diff --git a/ui/src/plugins/dev.perfetto.ExampleState/index.ts b/ui/src/plugins/dev.perfetto.ExampleState/index.ts
index 9e4e31c..4470e6c 100644
--- a/ui/src/plugins/dev.perfetto.ExampleState/index.ts
+++ b/ui/src/plugins/dev.perfetto.ExampleState/index.ts
@@ -40,15 +40,14 @@
   }
 
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
-    const {viewer} = ctx;
     const store = ctx.mountStore((init: unknown) => this.migrate(init));
 
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.ExampleState#ShowCounter',
       name: 'Show ExampleState counter',
       callback: () => {
         const counter = store.state.counter;
-        viewer.tabs.openQuery(
+        ctx.tabs.openQuery(
             `SELECT ${counter} as counter;`, `Show counter ${counter}`);
         store.edit((draft) => ++draft.counter);
       },
diff --git a/ui/src/plugins/dev.perfetto.LargeScreensPerf/index.ts b/ui/src/plugins/dev.perfetto.LargeScreensPerf/index.ts
index 07872ed..9c05772 100644
--- a/ui/src/plugins/dev.perfetto.LargeScreensPerf/index.ts
+++ b/ui/src/plugins/dev.perfetto.LargeScreensPerf/index.ts
@@ -15,16 +15,19 @@
 import {
   Plugin,
   PluginContext,
+  PluginContextTrace,
   PluginDescriptor,
 } from '../../public';
 
 class LargeScreensPerf implements Plugin {
-  onActivate(ctx: PluginContext): void {
-    ctx.addCommand({
+  onActivate(_ctx: PluginContext): void {}
+
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    ctx.registerCommand({
       id: 'dev.perfetto.LargeScreensPerf#PinUnfoldLatencyTracks',
       name: 'Pin: Unfold latency tracks',
       callback: () => {
-        ctx.viewer.tracks.pin((tags) => {
+        ctx.timeline.pinTracksByPredicate((tags) => {
           return !!tags.name?.includes('UNFOLD') ||
               tags.name?.includes('Screen on blocked') ||
               tags.name?.startsWith('waitForAllWindowsDrawn') ||
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index cc2fb77..e0d7c9d 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -32,35 +32,6 @@
   STR_NULL,
 } from '../trace_processor/query_result';
 
-
-// An imperative API for plugins to change the UI.
-export interface Viewer {
-  // Control of the sidebar.
-  sidebar: {
-    // Show the sidebar.
-    show(): void;
-    // Hide the sidebar.
-    hide(): void;
-    // Returns true if the sidebar is visble.
-    isVisible(): boolean;
-  }
-
-  // Tracks
-  tracks: {
-    pin(predicate: TrackPredicate): void;
-    unpin(predicate: TrackPredicate): void;
-  }
-
-  // Control over the bottom details pane.
-  tabs: {
-    // Creates a new tab running the provided query.
-    openQuery(query: string, title: string): void;
-  }
-
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  commands: {run(name: string, ...args: any[]): void;}
-}
-
 export interface Command {
   // A unique id for this command.
   id: string;
@@ -68,7 +39,7 @@
   name: string;
   // Callback is called when the command is invoked.
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  callback: (...args: any[]) => void;
+  callback: (...args: any[]) => any;
   // Default hotkey for this command.
   // Note: this is just the default and may be changed by the user.
   // Examples:
@@ -118,11 +89,27 @@
   // The unique ID for this plugin.
   readonly pluginId: string;
 
-  // The viewer API, used to interface with Perfetto.
-  readonly viewer: Viewer;
+  // Register command against this plugin context.
+  registerCommand(command: Command): void;
 
-  // Add a command.
-  addCommand(command: Command): void;
+  // Retrieve a list of all commands.
+  commands: Command[];
+
+  // Run a command, optionally passing some args.
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  runCommand(id: string, ...args: any[]): any;
+
+  // Control of the sidebar.
+  sidebar: {
+    // Show the sidebar.
+    show(): void;
+
+    // Hide the sidebar.
+    hide(): void;
+
+    // Returns true if the sidebar is visible.
+    isVisible(): boolean;
+  };
 }
 
 export type Migrate<State> = (init: unknown) => State;
@@ -166,7 +153,7 @@
   // A unique identifier for this track.
   uri: string;
 
-  // A factory function returning a track object.
+  // A factory function returning the track object.
   track: (ctx: TrackContext) => Track;
 
   // The track "kind", used by various subsystems e.g. aggregation controllers.
@@ -224,27 +211,96 @@
   ORDINARY_TRACK,
 }
 
+export interface SliceTrackColNames {
+  ts: string;
+  name: string;
+  dur: string;
+}
+
+export interface DebugSliceTrackArgs {
+  // Title of the track. If omitted a placeholder name will be chosen instead.
+  trackName?: string;
+
+  // Mapping definitions of the 'ts', 'dur', and 'name' columns.
+  // By default, columns called ts, dur and name will be used.
+  // If dur is assigned the value '0', all slices shall be instant events.
+  columnMapping?: Partial<SliceTrackColNames>;
+
+  // Any extra columns to be used as args.
+  args?: string[];
+
+  // Optional renaming of columns.
+  columns?: string[];
+}
+
+export interface CounterTrackColNames {
+  ts: string;
+  value: string;
+}
+
+export interface DebugCounterTrackArgs {
+  // Title of the track. If omitted a placeholder name will be chosen instead.
+  trackName?: string;
+
+  // Mapping definitions of the ts and value columns.
+  columnMapping?: Partial<CounterTrackColNames>;
+}
+
 // Similar to PluginContext but with additional methods to operate on the
 // currently loaded trace. Passed to trace-relevant hooks on a plugin instead of
 // PluginContext.
 export interface PluginContextTrace extends PluginContext {
   readonly engine: EngineProxy;
 
+  // Control over the main timeline.
+  timeline: {
+    // Add a new track to the scrolling track section, returning the newly
+    // created track key.
+    addTrack(uri: string, displayName: string, params?: unknown): string;
+
+    // Remove a single track from the timeline.
+    removeTrack(key: string): void;
+
+    // Pin a single track.
+    pinTrack(key: string): void;
+
+    // Unpin a single track.
+    unpinTrack(key: string): void;
+
+    // Pin all tracks that match a predicate.
+    pinTracksByPredicate(predicate: TrackPredicate): void;
+
+    // Unpin all tracks that match a predicate.
+    unpinTracksByPredicate(predicate: TrackPredicate): void;
+
+    // Remove all tracks that match a predicate.
+    removeTracksByPredicate(predicate: TrackPredicate): void;
+
+    // Retrieve a list of tracks on the timeline.
+    tracks: TrackRef[];
+  }
+
+  // Control over the bottom details pane.
+  tabs: {
+    // Creates a new tab running the provided query.
+    openQuery(query: string, title: string): void;
+  }
+
   // Register a new track against a unique key known as a URI.
   // Once a track is registered it can be referenced multiple times on the
-  // timeline.
+  // timeline with different params to allow customising each instance.
   registerTrack(trackDesc: TrackDescriptor): void;
 
-  // Add a new entry to the pool of default tracks. Default tracks are a list of
-  // track references that describe the list of tracks that should be added to
-  // the main timeline on startup.
-  // Default tracks are only used when a trace is first loaded, not when loading
-  // from a permalink, where the existing list of tracks from the shared state
-  // is used instead.
+  // Add a new entry to the pool of default tracks. Default tracks are a list
+  // of track references that describe the list of tracks that should be added
+  // to the main timeline on startup.
+  // Default tracks are only used when a trace is first loaded, not when
+  // loading from a permalink, where the existing list of tracks from the
+  // shared state is used instead.
   addDefaultTrack(track: TrackRef): void;
 
   // Simultaneously register a track and add it as a default track in one go.
-  // This is simply a helper which calls registerTrack() then addDefaultTrack()
+  // This is simply a helper which calls registerTrack() and addDefaultTrack()
   // with the same URI.
   registerStaticTrack(track: TrackDescriptor&TrackRef): void;
 
diff --git a/ui/src/tracks/chrome_critical_user_interactions/index.ts b/ui/src/tracks/chrome_critical_user_interactions/index.ts
index 3d1b498..b77fc31 100644
--- a/ui/src/tracks/chrome_critical_user_interactions/index.ts
+++ b/ui/src/tracks/chrome_critical_user_interactions/index.ts
@@ -96,7 +96,7 @@
   }
 
   onActivate(ctx: PluginContext): void {
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'perfetto.CriticalUserInteraction.AddInteractionTrack',
       name: 'Add Chrome Interactions track',
       callback: () => addCriticalUserInteractionTrack(),
diff --git a/ui/src/tracks/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts b/ui/src/tracks/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts
index 2461c66..83a5a14 100644
--- a/ui/src/tracks/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts
+++ b/ui/src/tracks/chrome_scroll_jank/chrome_tasks_scroll_jank_track.ts
@@ -13,11 +13,11 @@
 // limitations under the License.
 
 import {InThreadTrackSortKey} from '../../common/state';
+import {globals} from '../../frontend/globals';
 import {
   NamedSliceTrack,
   NamedSliceTrackTypes,
 } from '../../frontend/named_slice_track';
-import {runQueryInNewTab} from '../../frontend/query_result_tab';
 import {NewTrackArgs, TrackBase} from '../../frontend/track';
 import {Engine} from '../../trace_processor/engine';
 import {NUM} from '../../trace_processor/query_result';
@@ -109,7 +109,8 @@
      from chrome_tasks_delaying_input_processing s1
      join slice s2 on s1.slice_id=s2.id
      `;
-  runQueryInNewTab(query, 'Scroll Jank: long tasks');
+  // TODO(stevegolton): This will soon be replaced by ctx.openQuery.
+  globals.openQuery(query, 'Scroll Jank: long tasks');
 
   return result;
 }
diff --git a/ui/src/tracks/sched/index.ts b/ui/src/tracks/sched/index.ts
index f9bba55..af411ed 100644
--- a/ui/src/tracks/sched/index.ts
+++ b/ui/src/tracks/sched/index.ts
@@ -46,18 +46,18 @@
   }
 
   onActivate(ctx: PluginContext): void {
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.Sched.AddRunnableThreadCountTrackCommand',
       name: 'Add runnable thread count track',
       callback: () => addRunnableThreadCountTrack(),
     });
-    ctx.addCommand({
+    ctx.registerCommand({
       id: 'dev.perfetto.Sched.AddActiveCPUCountTrackCommand',
       name: 'Add active CPU count track',
       callback: () => addActiveCPUCountTrack(),
     });
     for (const cpuType of ['big', 'little', 'mid']) {
-      ctx.addCommand({
+      ctx.registerCommand({
         id: `dev.perfetto.Sched.AddActiveCPUCountTrackCommand.${cpuType}`,
         name: `Add active ${cpuType} CPU count track`,
         callback: () => addActiveCPUCountTrack(cpuType),