Merge "Add commands."
diff --git a/ui/src/assets/topbar.scss b/ui/src/assets/topbar.scss
index 14a42b4..a4e0fb7 100644
--- a/ui/src/assets/topbar.scss
+++ b/ui/src/assets/topbar.scss
@@ -64,7 +64,7 @@
         font-weight: 400;
       }
     }
-    &.command-mode {
+    &.query-mode {
       background-color: #111;
       border-radius: 0;
       width: 100%;
@@ -84,6 +84,14 @@
         padding-top: 5px;
       }
     }
+    &.command-mode {
+      &:before {
+        @include material-icon("chevron_right");
+        margin: 5px;
+        color: #aaa;
+        grid-area: icon;
+      }
+    }
     &.message-mode {
       background-color: hsl(0, 0%, 89%);
       border-radius: $pf-border-radius;
@@ -230,7 +238,41 @@
 }
 
 .hide-sidebar {
-  .command-mode {
+  .query-mode {
     padding-left: 48px;
   }
 }
+
+.pf-cmd-container {
+  margin: 2px;
+}
+
+.pf-cmd {
+  font-family: $pf-font;
+  cursor: pointer;
+  display: grid;
+  width: 400px;
+  grid-template-columns: [titles]1fr [icon]auto;
+  padding: 4px;
+  border-radius: $pf-border-radius;
+  align-items: center;
+  &:hover {
+    background-color: $pf-minimal-background-hover;
+  }
+  &.pf-highlighted {
+    background-color: $pf-primary-background;
+    color: white;
+  }
+  i {
+    grid-column: icon;
+    grid-row: 1 / span 2;
+  }
+  h1 {
+    grid-column: titles;
+    font-size: larger;
+  }
+  h2 {
+    grid-column: titles;
+    font-size: smaller;
+  }
+}
diff --git a/ui/src/common/commands.ts b/ui/src/common/commands.ts
new file mode 100644
index 0000000..3b9446b
--- /dev/null
+++ b/ui/src/common/commands.ts
@@ -0,0 +1,52 @@
+// 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.
+
+export interface Command {
+  id: string;
+  name: string;
+  callback: (...args: any[]) => void;
+}
+
+export interface CommandSource {
+  commands(): Command[];
+}
+
+export class CommandManager {
+  private commandSources: CommandSource[] = [];
+
+  registerCommandSource(cs: CommandSource) {
+    this.commandSources.push(cs);
+  }
+
+  get commands(): Command[] {
+    return this.commandSources.flatMap((source) => source.commands());
+  }
+
+  runCommand(id: string, ...args: any[]): void {
+    const cmd = this.commands.find((cmd) => cmd.id === id);
+    if (cmd) {
+      cmd.callback(...args);
+    } else {
+      console.error(`No such command: ${id}`);
+    }
+  }
+
+  // TODO(stevegolton): Improve filter algo.
+  fuzzyFilterCommands(searchTerm: string): Command[] {
+    const searchTermLower = searchTerm.toLowerCase();
+    return this.commands.filter(({name}) => {
+      return name.toLowerCase().includes(searchTermLower);
+    });
+  }
+}
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index ba45aac..ede9e57 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -29,16 +29,14 @@
   TrackProvider,
 } from '../public';
 
+import {Command} from './commands';
 import {Engine} from './engine';
 import {Registry} from './registry';
 import {State} from './state';
 
 // All trace plugins must implement this interface.
 export interface TracePlugin extends Disposable {
-  // This is where we would add potential extension points that plugins can
-  // override.
-  // E.g. commands(): Command[];
-  // For now, plugins don't do anything so this interface is empty.
+  commands(): Command[];
 }
 
 // This interface defines what a plugin factory should look like.
@@ -147,7 +145,7 @@
   revoke() {
     // TODO(hjd): Remove from trackControllerRegistry, trackRegistry,
     // etc.
-    // TODO(stevegolton): Close the trace plugin.
+    // TODO(stevegolton): Dispose the trace plugin.
   }
   // ==================================================================
 }
@@ -216,6 +214,17 @@
       context.onTraceClosed();
     }
   }
+
+  commands(): Command[] {
+    return Array.from(this.contexts.values()).flatMap((ctx) => {
+      const tracePlugin = ctx.tracePlugin;
+      if (tracePlugin && tracePlugin.commands) {
+        return tracePlugin.commands();
+      } else {
+        return [];
+      }
+    });
+  }
 }
 
 // TODO(hjd): Sort out the story for global singletons like these:
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 9ccea07..2e8ae1f 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -17,6 +17,7 @@
 import {Actions, DeferredAction} from '../common/actions';
 import {AggregateData} from '../common/aggregation_data';
 import {Args} from '../common/arg_types';
+import {CommandManager} from '../common/commands';
 import {
   ConversionJobName,
   ConversionJobStatus,
@@ -251,6 +252,7 @@
   private _hideSidebar?: boolean = undefined;
   private _ftraceCounters?: FtraceStat[] = undefined;
   private _ftracePanelData?: FtracePanelData = undefined;
+  private _cmdManager?: CommandManager = undefined;
 
   // TODO(hjd): Remove once we no longer need to update UUID on redraw.
   private _publishRedraw?: () => void = undefined;
@@ -271,10 +273,13 @@
 
   engines = new Map<string, Engine>();
 
-  initialize(dispatch: Dispatch, router: Router, initialState: State) {
+  initialize(
+      dispatch: Dispatch, router: Router, initialState: State,
+      cmdManager: CommandManager) {
     this._dispatch = dispatch;
     this._router = router;
     this._store = createStore(initialState);
+    this._cmdManager = cmdManager;
     this._frontendLocalState = new FrontendLocalState();
 
     setPerfHooks(
@@ -700,6 +705,10 @@
       return 1;
     }
   }
+
+  get commandManager(): CommandManager {
+    return assertExists(this._cmdManager);
+  }
 }
 
 export const globals = new Globals();
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 20ddf3b..3b8f9bd 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -22,10 +22,12 @@
 import {defer} from '../base/deferred';
 import {assertExists, reportError, setErrorHandler} from '../base/logging';
 import {Actions, DeferredAction, StateActions} from '../common/actions';
+import {CommandManager} from '../common/commands';
 import {createEmptyState} from '../common/empty_state';
 import {RECORDING_V2_FLAG} from '../common/feature_flags';
 import {pluginManager, pluginRegistry} from '../common/plugins';
 import {State} from '../common/state';
+import {setTimestampFormat, TimestampFormat} from '../common/time';
 import {initWasm} from '../common/wasm_engine_proxy';
 import {initController, runControllers} from '../controller';
 import {
@@ -33,6 +35,7 @@
 } from '../controller/chrome_proxy_record_controller';
 import {raf} from '../core/raf_scheduler';
 
+import {addTab} from './bottom_tab';
 import {initCssConstants} from './css_constants';
 import {registerDebugGlobals} from './debug';
 import {maybeShowErrorDialog} from './error_dialog';
@@ -49,6 +52,8 @@
 import {RecordPageV2} from './record_page_v2';
 import {Router} from './router';
 import {CheckHttpRpcConnection} from './rpc_http_dialog';
+import {SqlTableTab} from './sql_table/tab';
+import {SqlTables} from './sql_table/well_known_tables';
 import {TraceInfoPage} from './trace_info_page';
 import {maybeOpenTraceFromRoute} from './trace_url_handler';
 import {ViewerPage} from './viewer_page';
@@ -211,7 +216,64 @@
   globals.embeddedMode = route.args.mode === 'embedded';
   globals.hideSidebar = route.args.hideSidebar === true;
 
-  globals.initialize(dispatch, router, createEmptyState());
+  const cmdManager = new CommandManager();
+
+  // Register some "core" commands.
+  // TODO(stevegolton): Find a better place to put this.
+  cmdManager.registerCommandSource({
+    commands() {
+      return [
+        {
+          id: 'dev.perfetto.SetTimestampFormatTimecodes',
+          name: 'Set timestamp format: Timecode',
+          callback: () => {
+            setTimestampFormat(TimestampFormat.Timecode);
+            raf.scheduleFullRedraw();
+          },
+        },
+        {
+          id: 'dev.perfetto.SetTimestampFormatSeconds',
+          name: 'Set timestamp format: Seconds',
+          callback: () => {
+            setTimestampFormat(TimestampFormat.Seconds);
+            raf.scheduleFullRedraw();
+          },
+        },
+        {
+          id: 'dev.perfetto.SetTimestampFormatRaw',
+          name: 'Set timestamp format: Raw',
+          callback: () => {
+            setTimestampFormat(TimestampFormat.Raw);
+            raf.scheduleFullRedraw();
+          },
+        },
+        {
+          id: 'dev.perfetto.SetTimestampFormatLocaleRaw',
+          name: 'Set timestamp format: Raw (formatted)',
+          callback: () => {
+            setTimestampFormat(TimestampFormat.RawLocale);
+            raf.scheduleFullRedraw();
+          },
+        },
+        {
+          id: 'dev.perfetto.ShowSliceTabe',
+          name: 'Show slice table',
+          callback: () => {
+            addTab({
+              kind: SqlTableTab.kind,
+              config: {
+                table: SqlTables.slice,
+                displayName: 'slice',
+              },
+            });
+          },
+        },
+      ];
+    },
+  });
+
+  globals.initialize(dispatch, router, createEmptyState(), cmdManager);
+
   globals.serviceWorkerController.install();
 
   const frontendApi = new FrontendApi();
@@ -264,6 +326,8 @@
   for (const plugin of pluginRegistry.values()) {
     pluginManager.activatePlugin(plugin.pluginId);
   }
+
+  cmdManager.registerCommandSource(pluginManager);
 }
 
 function onCssLoaded() {
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index abbd26c..11bd3dc 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -15,23 +15,33 @@
 import m from 'mithril';
 
 import {Actions} from '../common/actions';
+import {Command} from '../common/commands';
 import {raf} from '../core/raf_scheduler';
 import {VERSION} from '../gen/perfetto_version';
 
+import {classNames} from './classnames';
 import {globals} from './globals';
 import {runQueryInNewTab} from './query_result_tab';
 import {executeSearch} from './search_handler';
 import {taskTracker} from './task_tracker';
+import {EmptyState} from './widgets/empty_state';
+import {Icon} from './widgets/icon';
+import {Popup} from './widgets/popup';
 
 const SEARCH = Symbol('search');
+const QUERY = Symbol('query');
 const COMMAND = Symbol('command');
-type Mode = typeof SEARCH|typeof COMMAND;
+type Mode = typeof SEARCH|typeof QUERY|typeof COMMAND;
+let highlightedCommandIndex = 0;
 
 const PLACEHOLDER = {
   [SEARCH]: 'Search',
-  [COMMAND]: 'e.g. select * from sched left join thread using(utid) limit 10',
+  [QUERY]: 'e.g. select * from sched left join thread using(utid) limit 10',
+  [COMMAND]: 'Start typing a command..',
 };
 
+let matchingCommands: Command[] = [];
+
 export const DISMISSED_PANNING_HINT_KEY = 'dismissedPanningHint';
 
 let mode: Mode = SEARCH;
@@ -47,22 +57,55 @@
 
   if (mode === SEARCH && txt.value === '' && key === ':') {
     e.preventDefault();
+    mode = QUERY;
+    raf.scheduleFullRedraw();
+    return;
+  }
+
+  if (mode === SEARCH && txt.value === '' && key === '>') {
+    e.preventDefault();
     mode = COMMAND;
     raf.scheduleFullRedraw();
     return;
   }
 
-  if (mode === COMMAND && txt.value === '' && key === 'Backspace') {
+  if (mode !== SEARCH && txt.value === '' && key === 'Backspace') {
     mode = SEARCH;
     raf.scheduleFullRedraw();
     return;
   }
 
+  if (mode === COMMAND) {
+    if (key === 'ArrowDown') {
+      highlightedCommandIndex++;
+      highlightedCommandIndex =
+          Math.min(matchingCommands.length - 1, highlightedCommandIndex);
+      raf.scheduleFullRedraw();
+    } else if (key === 'ArrowUp') {
+      highlightedCommandIndex--;
+      highlightedCommandIndex = Math.max(0, highlightedCommandIndex);
+      raf.scheduleFullRedraw();
+    } else if (key === 'Enter') {
+      const cmd = matchingCommands[highlightedCommandIndex];
+      if (cmd) {
+        globals.commandManager.runCommand(cmd.id);
+      }
+      highlightedCommandIndex = 0;
+      mode = SEARCH;
+      globals.dispatch(Actions.setOmnibox({
+        omnibox: '',
+        mode: 'SEARCH',
+      }));
+    } else {
+      highlightedCommandIndex = 0;
+    }
+  }
+
   if (mode === SEARCH && key === 'Enter') {
     txt.blur();
   }
 
-  if (mode === COMMAND && key === 'Enter') {
+  if (mode === QUERY && key === 'Enter') {
     const openInPinnedTab = event.metaKey || event.ctrlKey;
     runQueryInNewTab(
         txt.value,
@@ -87,13 +130,31 @@
   }
 }
 
-class Omnibox implements m.ClassComponent {
-  oncreate(vnode: m.VnodeDOM) {
-    const txt = vnode.dom.querySelector('input') as HTMLInputElement;
-    txt.addEventListener('keydown', onKeyDown);
-    txt.addEventListener('keyup', onKeyUp);
-  }
+interface CmdAttrs {
+  title: string;
+  subtitle: string;
+  highlighted?: boolean;
+  icon?: string;
+  [htmlAttrs: string]: any;
+}
 
+class Cmd implements m.ClassComponent<CmdAttrs> {
+  view({attrs}: m.Vnode<CmdAttrs, this>): void|m.Children {
+    const {title, subtitle, icon, highlighted = false, ...htmlAttrs} = attrs;
+    return m(
+        'section.pf-cmd',
+        {
+          class: classNames(highlighted && 'pf-highlighted'),
+          ...htmlAttrs,
+        },
+        m('h1', title),
+        m('h2', subtitle),
+        m(Icon, {className: 'pf-right-icon', icon: icon ?? 'play_arrow'}),
+    );
+  }
+}
+
+class Omnibox implements m.ClassComponent {
   view() {
     const msgTTL = globals.state.status.timestamp + 1 - Date.now() / 1e3;
     const engineIsBusy =
@@ -107,17 +168,69 @@
             value: '',
           }));
     }
-
-    const commandMode = mode === COMMAND;
     return m(
-        `.omnibox${commandMode ? '.command-mode' : ''}`,
+        Popup,
+        {
+          isOpen: mode === COMMAND,
+          trigger: this.renderOmnibox(),
+          className: 'pf-popup-padded',
+        },
+        m(
+            '.pf-cmd-container',
+            this.renderCommandDropdown(),
+            ),
+    );
+  }
+
+  private renderCommandDropdown(): m.Children {
+    if (mode === COMMAND) {
+      const searchTerm = globals.state.omniboxState.omnibox;
+      matchingCommands = globals.commandManager.fuzzyFilterCommands(searchTerm);
+      if (matchingCommands.length === 0) {
+        return m(EmptyState, {header: 'No matching commands'});
+      } else {
+        return matchingCommands.map((cmd, index) => {
+          return m(Cmd, {
+            title: cmd.name,
+            subtitle: cmd.id,
+            highlighted: index === highlightedCommandIndex,
+            onclick: () => {
+              globals.commandManager.runCommand(cmd.id);
+              mode = SEARCH;
+              globals.dispatch(Actions.setOmnibox({
+                omnibox: '',
+                mode: 'SEARCH',
+              }));
+              highlightedCommandIndex = 0;
+            },
+          });
+        });
+      }
+    } else {
+      return null;
+    }
+  }
+
+  private renderOmnibox() {
+    const queryMode = mode === QUERY;
+    const classes = classNames(
+        mode === QUERY && 'query-mode',
+        mode === COMMAND && 'command-mode',
+    );
+    return m(
+        `.omnibox`,
+        {
+          class: classes,
+        },
         m('input', {
           placeholder: PLACEHOLDER[mode],
+          onkeydown: (e: Event) => onKeyDown(e),
+          onkeyup: (e: Event) => onKeyUp(e),
           oninput: (e: InputEvent) => {
             const value = (e.target as HTMLInputElement).value;
             globals.dispatch(Actions.setOmnibox({
               omnibox: value,
-              mode: commandMode ? 'COMMAND' : 'SEARCH',
+              mode: queryMode ? 'COMMAND' : 'SEARCH',
             }));
             if (mode === SEARCH) {
               displayStepThrough = value.length >= 4;
@@ -127,29 +240,27 @@
           value: globals.state.omniboxState.omnibox,
         }),
         displayStepThrough ?
-            m(
-                '.stepthrough',
-                m('.current',
-                  `${
-                      globals.currentSearchResults.totalResults === 0 ?
-                          '0 / 0' :
-                          `${globals.state.searchIndex + 1} / ${
-                              globals.currentSearchResults.totalResults}`}`),
-                m('button',
-                  {
-                    onclick: () => {
-                      executeSearch(true /* reverse direction */);
-                    },
+            m('.stepthrough',
+              m('.current',
+                `${
+                    globals.currentSearchResults.totalResults === 0 ?
+                        '0 / 0' :
+                        `${globals.state.searchIndex + 1} / ${
+                            globals.currentSearchResults.totalResults}`}`),
+              m('button',
+                {
+                  onclick: () => {
+                    executeSearch(true /* reverse direction */);
                   },
-                  m('i.material-icons.left', 'keyboard_arrow_left')),
-                m('button',
-                  {
-                    onclick: () => {
-                      executeSearch();
-                    },
+                },
+                m('i.material-icons.left', 'keyboard_arrow_left')),
+              m('button',
+                {
+                  onclick: () => {
+                    executeSearch();
                   },
-                  m('i.material-icons.right', 'keyboard_arrow_right')),
-                ) :
+                },
+                m('i.material-icons.right', 'keyboard_arrow_right'))) :
             '');
   }
 }
@@ -239,7 +350,7 @@
     if (globals.embeddedMode) return;
 
     const errors = globals.traceErrors;
-    if (!errors && !globals.metricError || mode === COMMAND) return;
+    if (!errors && !globals.metricError || mode === QUERY) return;
     const message = errors ? `${errors} import or data loss errors detected.` :
                              `Metric error detected.`;
     return m(
diff --git a/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts b/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts
index 09400e5..1ce82f3 100644
--- a/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts
+++ b/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts
@@ -12,6 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {Command} from '../../common/commands';
 import {TracePlugin} from '../../common/plugins';
 import {Store} from '../../frontend/store';
 import {EngineProxy, PluginContext} from '../../public';
@@ -33,6 +34,18 @@
   dispose(): void {
     // No-op
   }
+
+  commands(): Command[] {
+    // Example return value:
+    // return [
+    //   {
+    //     id: 'dev.perfetto.ExampleCommand',
+    //     name: 'Example Command',
+    //     callback: () => console.log('Hello from example command'),
+    //   },
+    // ];
+    return [];
+  }
 }
 
 function activate(ctx: PluginContext) {