Merge "Enable kgsl ftrace event capture" into main
diff --git a/docs/contributing/ui-plugins.md b/docs/contributing/ui-plugins.md
index 9b1535a..6baadc6 100644
--- a/docs/contributing/ui-plugins.md
+++ b/docs/contributing/ui-plugins.md
@@ -120,7 +120,7 @@
 
 Examples:
 - [dev.perfetto.ExampleSimpleCommand](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleSimpleCommand/index.ts).
-- [dev.perfetto.CoreCommands](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.CoreCommands/index.ts).
+- [perfetto.CoreCommands](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/core_plugins/commands/index.ts).
 - [dev.perfetto.ExampleState](https://cs.android.com/android/platform/superproject/main/+/main:external/perfetto/ui/src/plugins/dev.perfetto.ExampleState/index.ts).
 
 #### Hotkeys
diff --git a/src/trace_processor/importers/proto/system_probes_parser.cc b/src/trace_processor/importers/proto/system_probes_parser.cc
index 8f9580f..3cfe982 100644
--- a/src/trace_processor/importers/proto/system_probes_parser.cc
+++ b/src/trace_processor/importers/proto/system_probes_parser.cc
@@ -820,6 +820,10 @@
       std::all_of(cpu_infos.begin(), cpu_infos.end(),
                   [](CpuInfo info) { return info.capacity.has_value(); });
 
+  bool valid_frequencies =
+      std::all_of(cpu_infos.begin(), cpu_infos.end(),
+                  [](CpuInfo info) { return !info.frequencies.empty(); });
+
   std::vector<uint32_t> cluster_ids(cpu_infos.size());
   uint32_t cluster_id = 0;
 
@@ -836,7 +840,7 @@
       }
       cluster_ids[cpu_info.cpu] = cluster_id;
     }
-  } else {
+  } else if (valid_frequencies) {
     // Use max frequency if capacities are invalid
     std::vector<CpuMaxFrequency> cpu_max_freqs;
     for (CpuInfo& info : cpu_infos) {
diff --git a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
index fcb619a..4a7ccd0 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_1.png.sha256
@@ -1 +1 @@
-fb0f4cbdc19ce7b744405afcd849e3f4228c910ba055a5050e0c4b35dfe9da79
\ No newline at end of file
+07b0221b35ae6ea9e911040f853e72d19339eec92fd2c439515743ce84dfe985
\ No newline at end of file
diff --git a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256 b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
index b2e5b32..1677d33 100644
--- a/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
+++ b/test/data/ui-screenshots/ui-modal_dialog_show_dialog_2.png.sha256
@@ -1 +1 @@
-95e8c1aab784b8503fd4f99d89bdaa0314d0a57ff0d75966c7603afca725e1c5
\ No newline at end of file
+1d86da6fc4d65abe02d0abb376161b7d97c63b0bf7e383353667b84c9f6fcf49
\ No newline at end of file
diff --git a/test/trace_processor/diff_tests/stdlib/android/cpu_cluster_tests.py b/test/trace_processor/diff_tests/stdlib/android/cpu_cluster_tests.py
index fc1820b..18e6940 100644
--- a/test/trace_processor/diff_tests/stdlib/android/cpu_cluster_tests.py
+++ b/test/trace_processor/diff_tests/stdlib/android/cpu_cluster_tests.py
@@ -312,3 +312,37 @@
         1,1,"[NULL]"
         2,2,"[NULL]"
         """))
+
+  def test_android_cpu_cluster_type_no_frequencies(self):
+    return DiffTestBlueprint(
+        trace=TextProto(r"""
+        packet {
+          cpu_info {
+            cpus {
+              processor: "unknown"
+            }
+            cpus {
+              processor: "unknown"
+            }
+            cpus {
+              processor: "unknown"
+            }
+          }
+        }
+        """),
+        query="""
+        INCLUDE PERFETTO MODULE android.cpu.cluster_type;
+
+        SELECT
+          ucpu,
+          cpu,
+          cluster_type
+        FROM
+          android_cpu_cluster_mapping;
+        """,
+        out=Csv("""
+        "ucpu","cpu","cluster_type"
+        0,0,"[NULL]"
+        1,1,"[NULL]"
+        2,2,"[NULL]"
+        """))
diff --git a/ui/src/base/hotkeys.ts b/ui/src/base/hotkeys.ts
index 80672be..2dec16c 100644
--- a/ui/src/base/hotkeys.ts
+++ b/ui/src/base/hotkeys.ts
@@ -46,6 +46,7 @@
 // these keys.
 
 import {elementIsEditable} from './dom_utils';
+import {Optional} from './utils';
 
 type Alphabet =
   | 'A'
@@ -126,6 +127,34 @@
   ']',
 ];
 
+const macModifierStrings: ReadonlyMap<Modifier, string> = new Map<
+  Modifier,
+  string
+>([
+  ['', ''],
+  ['Mod+', '⌘'],
+  ['Shift+', '⇧'],
+  ['Ctrl+', '⌃'],
+  ['Alt+', '⌥'],
+  ['Mod+Shift+', '⌘⇧'],
+  ['Mod+Alt+', '⌘⌥'],
+  ['Mod+Shift+Alt+', '⌘⇧⌥'],
+  ['Ctrl+Shift+', '⌃⇧'],
+  ['Ctrl+Alt', '⌃⌥'],
+  ['Ctrl+Shift+Alt', '⌃⇧⌥'],
+]);
+
+const pcModifierStrings: ReadonlyMap<Modifier, string> = new Map<
+  Modifier,
+  string
+>([
+  ['', ''],
+  ['Mod+', 'Ctrl+'],
+  ['Mod+Shift+', 'Ctrl+Shift+'],
+  ['Mod+Alt+', 'Ctrl+Alt+'],
+  ['Mod+Shift+Alt+', 'Ctrl+Shift+Alt+'],
+]);
+
 // Represents a deconstructed hotkey.
 export interface HotkeyParts {
   // The name of the primary key of this hotkey.
@@ -141,12 +170,12 @@
 
 // Deconstruct a hotkey from its string representation into its constituent
 // parts.
-export function parseHotkey(hotkey: Hotkey): HotkeyParts | null {
+export function parseHotkey(hotkey: Hotkey): Optional<HotkeyParts> {
   const regex = /^(!?)((?:Mod\+|Shift\+|Alt\+|Ctrl\+)*)(.*)$/;
   const result = hotkey.match(regex);
 
   if (!result) {
-    return null;
+    return undefined;
   }
 
   return {
@@ -156,6 +185,28 @@
   };
 }
 
+// Print the hotkey in a human readable format.
+export function formatHotkey(
+  hotkey: Hotkey,
+  spoof?: Platform,
+): Optional<string> {
+  const parsed = parseHotkey(hotkey);
+  return parsed && formatHotkeyParts(parsed, spoof);
+}
+
+function formatHotkeyParts(
+  {modifier, key}: HotkeyParts,
+  spoof?: Platform,
+): string {
+  return `${formatModifier(modifier, spoof)}${key}`;
+}
+
+function formatModifier(modifier: Modifier, spoof?: Platform): string {
+  const platform = spoof || getPlatform();
+  const strings = platform === 'Mac' ? macModifierStrings : pcModifierStrings;
+  return strings.get(modifier) ?? modifier;
+}
+
 // Like |KeyboardEvent| but all fields apart from |key| are optional.
 export type KeyboardEventLike = Pick<KeyboardEvent, 'key'> &
   Partial<KeyboardEvent>;
@@ -227,10 +278,6 @@
 export type Platform = 'Mac' | 'PC';
 
 // Get the current platform (PC or Mac).
-export function getPlatform(spoof?: Platform): Platform {
-  if (spoof) {
-    return spoof;
-  } else {
-    return window.navigator.platform.indexOf('Mac') !== -1 ? 'Mac' : 'PC';
-  }
+export function getPlatform(): Platform {
+  return window.navigator.platform.indexOf('Mac') !== -1 ? 'Mac' : 'PC';
 }
diff --git a/ui/src/base/hotkeys_unittest.ts b/ui/src/base/hotkeys_unittest.ts
index 3ec91ec..08f8297 100644
--- a/ui/src/base/hotkeys_unittest.ts
+++ b/ui/src/base/hotkeys_unittest.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {checkHotkey, Hotkey, parseHotkey} from './hotkeys';
+import {checkHotkey, formatHotkey, Hotkey, parseHotkey} from './hotkeys';
 
 test('parseHotkey', () => {
   expect(parseHotkey('A')).toEqual({
@@ -90,3 +90,11 @@
     expect(checkHotkey('!X', {key: 'x', target: el})).toBe(true);
   });
 });
+
+test('formatHotkey', () => {
+  expect(formatHotkey('Mod+X', 'Mac')).toEqual('⌘X');
+  expect(formatHotkey('Mod+Shift+X', 'Mac')).toEqual('⌘⇧X');
+
+  expect(formatHotkey('Mod+X', 'PC')).toEqual('Ctrl+X');
+  expect(formatHotkey('Mod+Shift+X', 'PC')).toEqual('Ctrl+Shift+X');
+});
diff --git a/ui/src/base/registry.ts b/ui/src/base/registry.ts
index 0aa80dc..80f385c 100644
--- a/ui/src/base/registry.ts
+++ b/ui/src/base/registry.ts
@@ -71,6 +71,10 @@
     yield* this.registry.values();
   }
 
+  valuesAsArray(): ReadonlyArray<T> {
+    return Array.from(this.values());
+  }
+
   unregisterAllForTesting(): void {
     this.registry.clear();
   }
diff --git a/ui/src/common/commands.ts b/ui/src/common/commands.ts
index a9abcc1..2375559 100644
--- a/ui/src/common/commands.ts
+++ b/ui/src/common/commands.ts
@@ -23,6 +23,10 @@
 export class CommandManager {
   private readonly registry = new Registry<Command>((cmd) => cmd.id);
 
+  getCommand(commandId: string): Command {
+    return this.registry.get(commandId);
+  }
+
   get commands(): Command[] {
     return Array.from(this.registry.values());
   }
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index c3d6044..919545d 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -33,6 +33,7 @@
   TrackPredicate,
   GroupPredicate,
   TrackRef,
+  SidebarMenuItem,
 } from '../public';
 import {EngineBase, Engine} from '../trace_processor/engine';
 import {Actions} from './actions';
@@ -55,26 +56,6 @@
   private trash = new DisposableStack();
   private alive = true;
 
-  readonly sidebar = {
-    hide() {
-      globals.dispatch(
-        Actions.setSidebar({
-          visible: false,
-        }),
-      );
-    },
-    show() {
-      globals.dispatch(
-        Actions.setSidebar({
-          visible: true,
-        }),
-      );
-    },
-    isVisible() {
-      return globals.state.sidebarVisible;
-    },
-  };
-
   registerCommand(cmd: Command): void {
     // Silently ignore if context is dead.
     if (!this.alive) return;
@@ -94,6 +75,10 @@
     this.trash.dispose();
     this.alive = false;
   }
+
+  addSidebarMenuItem(menuItem: SidebarMenuItem): void {
+    this.trash.use(globals.sidebarMenuItems.register(menuItem));
+  }
 }
 
 // This PluginContextTrace implementation provides the plugin access to trace
@@ -122,6 +107,13 @@
     this.trash.use(dispose);
   }
 
+  addSidebarMenuItem(menuItem: SidebarMenuItem): void {
+    // Silently ignore if context is dead.
+    if (!this.alive) return;
+
+    this.trash.use(globals.sidebarMenuItems.register(menuItem));
+  }
+
   registerTrack(trackDesc: TrackDescriptor): void {
     // Silently ignore if context is dead.
     if (!this.alive) return;
@@ -171,10 +163,6 @@
     this.trash.use(unregister);
   }
 
-  get sidebar() {
-    return this.ctx.sidebar;
-  }
-
   readonly tabs = {
     openQuery: (query: string, title: string) => {
       addQueryResultsTab({query, title});
diff --git a/ui/src/core/default_plugins.ts b/ui/src/core/default_plugins.ts
index b068075..ab57814 100644
--- a/ui/src/core/default_plugins.ts
+++ b/ui/src/core/default_plugins.ts
@@ -31,7 +31,6 @@
   'dev.perfetto.AndroidPerfTraceCounters',
   'dev.perfetto.AndroidStartup',
   'dev.perfetto.BookmarkletApi',
-  'dev.perfetto.CoreCommands',
   'dev.perfetto.LargeScreensPerf',
   'dev.perfetto.PinAndroidPerfMetrics',
   'dev.perfetto.PinSysUITracks',
@@ -43,12 +42,14 @@
   'perfetto.Annotation',
   'perfetto.AsyncSlices',
   'perfetto.ChromeScrollJank',
+  'perfetto.CoreCommands',
   'perfetto.Counter',
   'perfetto.CpuFreq',
   'perfetto.CpuProfile',
   'perfetto.CpuSlices',
   'perfetto.CriticalUserInteraction',
   'perfetto.DebugTracks',
+  'perfetto.ExampleTraces',
   'perfetto.Flows',
   'perfetto.Frames',
   'perfetto.FtraceRaw',
diff --git a/ui/src/core/query_flamegraph.ts b/ui/src/core/query_flamegraph.ts
index 85cd51c..8e600af 100644
--- a/ui/src/core/query_flamegraph.ts
+++ b/ui/src/core/query_flamegraph.ts
@@ -193,27 +193,27 @@
     showStackAndPivot.length === 0
       ? '0'
       : showStackAndPivot
-          .map((x, i) => `((name like '%${x}%') << ${i})`)
+          .map((x, i) => `((name like '${makeSqlFilter(x)}') << ${i})`)
           .join(' | ');
   const showStackBits = (1 << showStackAndPivot.length) - 1;
 
   const hideStackFilter =
     hideStack.length === 0
       ? 'false'
-      : hideStack.map((x) => `name like '%${x}%'`).join(' OR ');
+      : hideStack.map((x) => `name like '${makeSqlFilter(x)}'`).join(' OR ');
 
   const showFromFrameFilter =
     showFromFrame.length === 0
       ? '0'
       : showFromFrame
-          .map((x, i) => `((name like '%${x}%') << ${i})`)
+          .map((x, i) => `((name like '${makeSqlFilter(x)}') << ${i})`)
           .join(' | ');
   const showFromFrameBits = (1 << showFromFrame.length) - 1;
 
   const hideFrameFilter =
     hideFrame.length === 0
       ? 'false'
-      : hideFrame.map((x) => `name like '%${x}%'`).join(' OR ');
+      : hideFrame.map((x) => `name like '${makeSqlFilter(x)}'`).join(' OR ');
 
   const pivotFilter = getPivotFilter(view);
 
@@ -434,9 +434,16 @@
   };
 }
 
+function makeSqlFilter(x: string) {
+  if (x.startsWith('^') && x.endsWith('$')) {
+    return x.slice(1, -1);
+  }
+  return `%${x}%`;
+}
+
 function getPivotFilter(view: FlamegraphView) {
   if (view.kind === 'PIVOT') {
-    return `name like '%${view.pivot}%'`;
+    return `name like '${makeSqlFilter(view.pivot)}'`;
   }
   if (view.kind === 'BOTTOM_UP') {
     return 'value > 0';
diff --git a/ui/src/plugins/dev.perfetto.CoreCommands/index.ts b/ui/src/core_plugins/commands/index.ts
similarity index 60%
rename from ui/src/plugins/dev.perfetto.CoreCommands/index.ts
rename to ui/src/core_plugins/commands/index.ts
index 54cd458..c87284f 100644
--- a/ui/src/plugins/dev.perfetto.CoreCommands/index.ts
+++ b/ui/src/core_plugins/commands/index.ts
@@ -14,12 +14,20 @@
 
 import {Time, time} from '../../base/time';
 import {exists} from '../../base/utils';
+import {Actions} from '../../common/actions';
+import {globals} from '../../frontend/globals';
+import {openInOldUIWithSizeCheck} from '../../frontend/legacy_trace_viewer';
 import {
   Plugin,
   PluginContext,
   PluginContextTrace,
   PluginDescriptor,
 } from '../../public';
+import {
+  isLegacyTrace,
+  openFileWithLegacyTraceViewer,
+} from '../../frontend/legacy_trace_viewer';
+import {DisposableStack} from '../../base/disposable_stack';
 
 const SQL_STATS = `
 with first as (select started as ts from sqlstats limit 1)
@@ -88,24 +96,76 @@
 limit 100;`;
 
 class CoreCommandsPlugin implements Plugin {
+  private readonly disposable = new DisposableStack();
+
   onActivate(ctx: PluginContext) {
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#ToggleLeftSidebar',
+      id: 'perfetto.CoreCommands#ToggleLeftSidebar',
       name: 'Toggle left sidebar',
       callback: () => {
-        if (ctx.sidebar.isVisible()) {
-          ctx.sidebar.hide();
+        if (globals.state.sidebarVisible) {
+          globals.dispatch(
+            Actions.setSidebar({
+              visible: false,
+            }),
+          );
         } else {
-          ctx.sidebar.show();
+          globals.dispatch(
+            Actions.setSidebar({
+              visible: true,
+            }),
+          );
         }
       },
       defaultHotkey: '!Mod+B',
     });
+
+    const input = document.createElement('input');
+    input.classList.add('trace_file');
+    input.setAttribute('type', 'file');
+    input.style.display = 'none';
+    input.addEventListener('change', onInputElementFileSelectionChanged);
+    document.body.appendChild(input);
+    this.disposable.defer(() => {
+      document.body.removeChild(input);
+    });
+
+    const OPEN_TRACE_COMMAND_ID = 'perfetto.CoreCommands#openTrace';
+    ctx.registerCommand({
+      id: OPEN_TRACE_COMMAND_ID,
+      name: 'Open trace file',
+      callback: () => {
+        delete input.dataset['useCatapultLegacyUi'];
+        input.click();
+      },
+      defaultHotkey: '!Mod+O',
+    });
+    ctx.addSidebarMenuItem({
+      commandId: OPEN_TRACE_COMMAND_ID,
+      group: 'navigation',
+      icon: 'folder_open',
+    });
+
+    const OPEN_LEGACY_TRACE_COMMAND_ID =
+      'perfetto.CoreCommands#openTraceInLegacyUi';
+    ctx.registerCommand({
+      id: OPEN_LEGACY_TRACE_COMMAND_ID,
+      name: 'Open with legacy UI',
+      callback: () => {
+        input.dataset['useCatapultLegacyUi'] = '1';
+        input.click();
+      },
+    });
+    ctx.addSidebarMenuItem({
+      commandId: OPEN_LEGACY_TRACE_COMMAND_ID,
+      group: 'navigation',
+      icon: 'filter_none',
+    });
   }
 
   async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#RunQueryAllProcesses',
+      id: 'perfetto.CoreCommands#RunQueryAllProcesses',
       name: 'Run query: All processes',
       callback: () => {
         ctx.tabs.openQuery(ALL_PROCESSES_QUERY, 'All Processes');
@@ -113,7 +173,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#RunQueryCpuTimeByProcess',
+      id: 'perfetto.CoreCommands#RunQueryCpuTimeByProcess',
       name: 'Run query: CPU time by process',
       callback: () => {
         ctx.tabs.openQuery(CPU_TIME_FOR_PROCESSES, 'CPU time by process');
@@ -121,7 +181,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#RunQueryCyclesByStateByCpu',
+      id: 'perfetto.CoreCommands#RunQueryCyclesByStateByCpu',
       name: 'Run query: cycles by p-state by CPU',
       callback: () => {
         ctx.tabs.openQuery(
@@ -132,7 +192,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#RunQueryCyclesByCpuByProcess',
+      id: 'perfetto.CoreCommands#RunQueryCyclesByCpuByProcess',
       name: 'Run query: CPU Time by CPU by process',
       callback: () => {
         ctx.tabs.openQuery(
@@ -143,7 +203,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#RunQueryHeapGraphBytesPerType',
+      id: 'perfetto.CoreCommands#RunQueryHeapGraphBytesPerType',
       name: 'Run query: heap graph bytes per type',
       callback: () => {
         ctx.tabs.openQuery(
@@ -154,7 +214,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#DebugSqlPerformance',
+      id: 'perfetto.CoreCommands#DebugSqlPerformance',
       name: 'Debug SQL performance',
       callback: () => {
         ctx.tabs.openQuery(SQL_STATS, 'Recent SQL queries');
@@ -162,7 +222,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#UnpinAllTracks',
+      id: 'perfetto.CoreCommands#UnpinAllTracks',
       name: 'Unpin all pinned tracks',
       callback: () => {
         ctx.timeline.unpinTracksByPredicate((_) => {
@@ -172,7 +232,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#ExpandAllGroups',
+      id: 'perfetto.CoreCommands#ExpandAllGroups',
       name: 'Expand all track groups',
       callback: () => {
         ctx.timeline.expandGroupsByPredicate((_) => {
@@ -182,7 +242,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#CollapseAllGroups',
+      id: 'perfetto.CoreCommands#CollapseAllGroups',
       name: 'Collapse all track groups',
       callback: () => {
         ctx.timeline.collapseGroupsByPredicate((_) => {
@@ -192,7 +252,7 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#PanToTimestamp',
+      id: 'perfetto.CoreCommands#PanToTimestamp',
       name: 'Pan to timestamp',
       callback: (tsRaw: unknown) => {
         if (exists(tsRaw)) {
@@ -211,13 +271,17 @@
     });
 
     ctx.registerCommand({
-      id: 'dev.perfetto.CoreCommands#ShowCurrentSelectionTab',
+      id: 'perfetto.CoreCommands#ShowCurrentSelectionTab',
       name: 'Show current selection tab',
       callback: () => {
         ctx.tabs.showTab('current_selection');
       },
     });
   }
+
+  onDeactivate(_: PluginContext): void {
+    this.disposable[Symbol.dispose]();
+  }
 }
 
 function promptForTimestamp(message: string): time | undefined {
@@ -232,7 +296,35 @@
   return undefined;
 }
 
+function onInputElementFileSelectionChanged(e: Event) {
+  if (!(e.target instanceof HTMLInputElement)) {
+    throw new Error('Not an input element');
+  }
+  if (!e.target.files) return;
+  const file = e.target.files[0];
+  // Reset the value so onchange will be fired with the same file.
+  e.target.value = '';
+
+  if (e.target.dataset['useCatapultLegacyUi'] === '1') {
+    openWithLegacyUi(file);
+    return;
+  }
+
+  globals.logging.logEvent('Trace Actions', 'Open trace from file');
+  globals.dispatch(Actions.openTraceFromFile({file}));
+}
+
+async function openWithLegacyUi(file: File) {
+  // Switch back to the old catapult UI.
+  globals.logging.logEvent('Trace Actions', 'Open trace in Legacy UI');
+  if (await isLegacyTrace(file)) {
+    openFileWithLegacyTraceViewer(file);
+    return;
+  }
+  openInOldUIWithSizeCheck(file);
+}
+
 export const plugin: PluginDescriptor = {
-  pluginId: 'dev.perfetto.CoreCommands',
+  pluginId: 'perfetto.CoreCommands',
   plugin: CoreCommandsPlugin,
 };
diff --git a/ui/src/core_plugins/example_traces/index.ts b/ui/src/core_plugins/example_traces/index.ts
new file mode 100644
index 0000000..d1fc43d
--- /dev/null
+++ b/ui/src/core_plugins/example_traces/index.ts
@@ -0,0 +1,67 @@
+// Copyright (C) 2024 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 {Actions} from '../../common/actions';
+import {globals} from '../../frontend/globals';
+import {Plugin, PluginContext, PluginDescriptor} from '../../public';
+
+const EXAMPLE_ANDROID_TRACE_URL =
+  'https://storage.googleapis.com/perfetto-misc/example_android_trace_15s';
+
+const EXAMPLE_CHROME_TRACE_URL =
+  'https://storage.googleapis.com/perfetto-misc/chrome_example_wikipedia.perfetto_trace.gz';
+
+function openTraceUrl(url: string): void {
+  globals.logging.logEvent('Trace Actions', 'Open example trace');
+  globals.dispatch(Actions.openTraceFromUrl({url}));
+}
+
+class ExampleTracesPlugin implements Plugin {
+  onActivate(ctx: PluginContext) {
+    const OPEN_EXAMPLE_ANDROID_TRACE_COMMAND_ID =
+      'perfetto.CoreCommands#openExampleAndroidTrace';
+    ctx.registerCommand({
+      id: OPEN_EXAMPLE_ANDROID_TRACE_COMMAND_ID,
+      name: 'Open Android example',
+      callback: () => {
+        openTraceUrl(EXAMPLE_ANDROID_TRACE_URL);
+      },
+    });
+    ctx.addSidebarMenuItem({
+      commandId: OPEN_EXAMPLE_ANDROID_TRACE_COMMAND_ID,
+      group: 'example_traces',
+      icon: 'description',
+    });
+
+    const OPEN_EXAMPLE_CHROME_TRACE_COMMAND_ID =
+      'perfetto.CoreCommands#openExampleChromeTrace';
+    ctx.registerCommand({
+      id: OPEN_EXAMPLE_CHROME_TRACE_COMMAND_ID,
+      name: 'Open Chrome example',
+      callback: () => {
+        openTraceUrl(EXAMPLE_CHROME_TRACE_URL);
+      },
+    });
+    ctx.addSidebarMenuItem({
+      commandId: OPEN_EXAMPLE_CHROME_TRACE_COMMAND_ID,
+      group: 'example_traces',
+      icon: 'description',
+    });
+  }
+}
+
+export const plugin: PluginDescriptor = {
+  pluginId: 'perfetto.ExampleTraces',
+  plugin: ExampleTracesPlugin,
+};
diff --git a/ui/src/frontend/app.ts b/ui/src/frontend/app.ts
index e9c91a7..f6d3939 100644
--- a/ui/src/frontend/app.ts
+++ b/ui/src/frontend/app.ts
@@ -364,7 +364,6 @@
       id: 'perfetto.RunQuery',
       name: 'Run query',
       callback: () => globals.omnibox.setMode(OmniboxMode.Query),
-      defaultHotkey: '!Mod+O',
     },
     {
       id: 'perfetto.Search',
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index ce10df2..f497a6b 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -51,6 +51,8 @@
 } from './search_overview_track';
 import {AppContext} from './app_context';
 import {TraceContext} from './trace_context';
+import {Registry} from '../base/registry';
+import {SidebarMenuItem} from '../public';
 
 const INSTANT_FOCUS_DURATION = 1n;
 const INCOMPLETE_SLICE_DURATION = 30_000n;
@@ -237,6 +239,8 @@
 
   traceContext = defaultTraceContext;
 
+  readonly sidebarMenuItems = new Registry<SidebarMenuItem>((m) => m.commandId);
+
   // This is the app's equivalent of a plugin's onTraceLoad() function.
   // TODO(stevegolton): Eventually initialization that should be done on trace
   // load should be moved into here, and then we can remove TraceController
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 8efff31..c8a078b 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -264,8 +264,6 @@
   if (globals.testing) {
     document.body.classList.add('testing');
   }
-
-  pluginManager.initialize();
 }
 
 function onCssLoaded() {
@@ -355,6 +353,12 @@
     // cases.
     routeChange(route);
   });
+
+  // Force one initial render to get everything in place
+  m.render(document.body, m(App, router.resolve()));
+
+  // Initialize plugins, now that we are ready to go
+  pluginManager.initialize();
 }
 
 // If the URL is /#!?rpc_port=1234, change the default RPC port.
diff --git a/ui/src/frontend/legacy_trace_viewer.ts b/ui/src/frontend/legacy_trace_viewer.ts
index f12e370..f496712 100644
--- a/ui/src/frontend/legacy_trace_viewer.ts
+++ b/ui/src/frontend/legacy_trace_viewer.ts
@@ -19,6 +19,7 @@
 import {showModal} from '../widgets/modal';
 import {globals} from './globals';
 import {utf8Decode} from '../base/string_utils';
+import {convertToJson} from './trace_converter';
 
 const CTRACE_HEADER = 'TRACE:\n';
 
@@ -171,6 +172,56 @@
   });
 }
 
+export function openInOldUIWithSizeCheck(trace: Blob) {
+  // Perfetto traces smaller than 50mb can be safely opened in the legacy UI.
+  if (trace.size < 1024 * 1024 * 50) {
+    convertToJson(trace);
+    return;
+  }
+
+  // Give the user the option to truncate larger perfetto traces.
+  const size = Math.round(trace.size / (1024 * 1024));
+  showModal({
+    title: 'Legacy UI may fail to open this trace',
+    content: m(
+      'div',
+      m(
+        'p',
+        `This trace is ${size}mb, opening it in the legacy UI ` + `may fail.`,
+      ),
+      m(
+        'p',
+        'More options can be found at ',
+        m(
+          'a',
+          {
+            href: 'https://goto.google.com/opening-large-traces',
+            target: '_blank',
+          },
+          'go/opening-large-traces',
+        ),
+        '.',
+      ),
+    ),
+    buttons: [
+      {
+        text: 'Open full trace (not recommended)',
+        action: () => convertToJson(trace),
+      },
+      {
+        text: 'Open beginning of trace',
+        action: () => convertToJson(trace, /* truncate*/ 'start'),
+      },
+      {
+        text: 'Open end of trace',
+        primary: true,
+        action: () => convertToJson(trace, /* truncate*/ 'end'),
+      },
+    ],
+  });
+  return;
+}
+
 // TraceViewer method that we wire up to trigger the file load.
 interface TraceViewerAPI extends Element {
   setActiveTrace(name: string, data: ArrayBuffer | string): void;
diff --git a/ui/src/frontend/notes_panel.ts b/ui/src/frontend/notes_panel.ts
index c458baa..c83ce11 100644
--- a/ui/src/frontend/notes_panel.ts
+++ b/ui/src/frontend/notes_panel.ts
@@ -98,11 +98,11 @@
               e.preventDefault();
               if (allCollapsed) {
                 globals.commandManager.runCommand(
-                  'dev.perfetto.CoreCommands#ExpandAllGroups',
+                  'perfetto.CoreCommands#ExpandAllGroups',
                 );
               } else {
                 globals.commandManager.runCommand(
-                  'dev.perfetto.CoreCommands#CollapseAllGroups',
+                  'perfetto.CoreCommands#CollapseAllGroups',
                 );
               }
             },
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index b1599ab..02a42c7 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -36,17 +36,15 @@
 import {downloadData, downloadUrl} from './download_utils';
 import {globals} from './globals';
 import {toggleHelp} from './help_modal';
-import {
-  isLegacyTrace,
-  openFileWithLegacyTraceViewer,
-} from './legacy_trace_viewer';
 import {Router} from './router';
 import {createTraceLink, isDownloadable, shareTrace} from './trace_attrs';
 import {
-  convertToJson,
   convertTraceToJsonAndDownload,
   convertTraceToSystraceAndDownload,
 } from './trace_converter';
+import {openInOldUIWithSizeCheck} from './legacy_trace_viewer';
+import {formatHotkey} from '../base/hotkeys';
+import {SidebarMenuItem} from '../public';
 
 const GITILES_URL =
   'https://android.googlesource.com/platform/external/perfetto';
@@ -98,16 +96,11 @@
   return globals.isInternalUser && HIRING_BANNER_FLAG.get();
 }
 
-export const EXAMPLE_ANDROID_TRACE_URL =
-  'https://storage.googleapis.com/perfetto-misc/example_android_trace_15s';
-
-export const EXAMPLE_CHROME_TRACE_URL =
-  'https://storage.googleapis.com/perfetto-misc/chrome_example_wikipedia.perfetto_trace.gz';
-
 interface SectionItem {
   t: string;
   a: string | ((e: Event) => void);
   i: string;
+  title?: string;
   isPending?: () => boolean;
   isVisible?: () => boolean;
   internalUserOnly?: boolean;
@@ -125,6 +118,34 @@
   appendOpenedTraceTitle?: boolean;
 }
 
+function insertSidebarMenuitems(
+  groupSelector: SidebarMenuItem['group'],
+): ReadonlyArray<SectionItem> {
+  return globals.sidebarMenuItems
+    .valuesAsArray()
+    .filter(({group}) => group === groupSelector)
+    .sort((a, b) => {
+      const prioA = a.priority ?? 0;
+      const prioB = b.priority ?? 0;
+      return prioA - prioB;
+    })
+    .map((item) => {
+      const cmd = globals.commandManager.getCommand(item.commandId);
+      const title = cmd.defaultHotkey
+        ? `${cmd.name} [${formatHotkey(cmd.defaultHotkey)}]`
+        : cmd.name;
+      return {
+        t: cmd.name,
+        a: (e: Event) => {
+          e.preventDefault();
+          cmd.callback();
+        },
+        i: item.icon,
+        title,
+      };
+    });
+}
+
 function getSections(): Section[] {
   return [
     {
@@ -132,12 +153,7 @@
       summary: 'Open or record a new trace',
       expanded: true,
       items: [
-        {t: 'Open trace file', a: popupFileSelectionDialog, i: 'folder_open'},
-        {
-          t: 'Open with legacy UI',
-          a: popupFileSelectionDialogOldUI,
-          i: 'filter_none',
-        },
+        ...insertSidebarMenuitems('navigation'),
         {t: 'Record new trace', a: navigateRecord, i: 'fiber_smart_record'},
         {
           t: 'Widgets',
@@ -236,18 +252,7 @@
       title: 'Example Traces',
       expanded: true,
       summary: 'Open an example trace',
-      items: [
-        {
-          t: 'Open Android example',
-          a: openTraceUrl(EXAMPLE_ANDROID_TRACE_URL),
-          i: 'description',
-        },
-        {
-          t: 'Open Chrome example',
-          a: openTraceUrl(EXAMPLE_CHROME_TRACE_URL),
-          i: 'description',
-        },
-      ],
+      items: [...insertSidebarMenuitems('example_traces')],
     },
 
     {
@@ -285,24 +290,6 @@
   toggleHelp();
 }
 
-function getFileElement(): HTMLInputElement {
-  return assertExists(
-    document.querySelector<HTMLInputElement>('input[type=file]'),
-  );
-}
-
-function popupFileSelectionDialog(e: Event) {
-  e.preventDefault();
-  delete getFileElement().dataset['useCatapultLegacyUi'];
-  getFileElement().click();
-}
-
-function popupFileSelectionDialogOldUI(e: Event) {
-  e.preventDefault();
-  getFileElement().dataset['useCatapultLegacyUi'] = '1';
-  getFileElement().click();
-}
-
 function downloadTraceFromUrl(url: string): Promise<File> {
   return m.request({
     method: 'GET',
@@ -388,92 +375,6 @@
   return globals.getCurrentEngine() !== undefined;
 }
 
-export function openTraceUrl(url: string): (e: Event) => void {
-  return (e) => {
-    globals.logging.logEvent('Trace Actions', 'Open example trace');
-    e.preventDefault();
-    globals.dispatch(Actions.openTraceFromUrl({url}));
-  };
-}
-
-function onInputElementFileSelectionChanged(e: Event) {
-  if (!(e.target instanceof HTMLInputElement)) {
-    throw new Error('Not an input element');
-  }
-  if (!e.target.files) return;
-  const file = e.target.files[0];
-  // Reset the value so onchange will be fired with the same file.
-  e.target.value = '';
-
-  if (e.target.dataset['useCatapultLegacyUi'] === '1') {
-    openWithLegacyUi(file);
-    return;
-  }
-
-  globals.logging.logEvent('Trace Actions', 'Open trace from file');
-  globals.dispatch(Actions.openTraceFromFile({file}));
-}
-
-async function openWithLegacyUi(file: File) {
-  // Switch back to the old catapult UI.
-  globals.logging.logEvent('Trace Actions', 'Open trace in Legacy UI');
-  if (await isLegacyTrace(file)) {
-    openFileWithLegacyTraceViewer(file);
-    return;
-  }
-  openInOldUIWithSizeCheck(file);
-}
-
-function openInOldUIWithSizeCheck(trace: Blob) {
-  // Perfetto traces smaller than 50mb can be safely opened in the legacy UI.
-  if (trace.size < 1024 * 1024 * 50) {
-    convertToJson(trace);
-    return;
-  }
-
-  // Give the user the option to truncate larger perfetto traces.
-  const size = Math.round(trace.size / (1024 * 1024));
-  showModal({
-    title: 'Legacy UI may fail to open this trace',
-    content: m(
-      'div',
-      m(
-        'p',
-        `This trace is ${size}mb, opening it in the legacy UI ` + `may fail.`,
-      ),
-      m(
-        'p',
-        'More options can be found at ',
-        m(
-          'a',
-          {
-            href: 'https://goto.google.com/opening-large-traces',
-            target: '_blank',
-          },
-          'go/opening-large-traces',
-        ),
-        '.',
-      ),
-    ),
-    buttons: [
-      {
-        text: 'Open full trace (not recommended)',
-        action: () => convertToJson(trace),
-      },
-      {
-        text: 'Open beginning of trace',
-        action: () => convertToJson(trace, /* truncate*/ 'start'),
-      },
-      {
-        text: 'Open end of trace',
-        primary: true,
-        action: () => convertToJson(trace, /* truncate*/ 'end'),
-      },
-    ],
-  });
-  return;
-}
-
 function navigateRecord(e: Event) {
   e.preventDefault();
   Router.navigate('#!/record');
@@ -866,7 +767,15 @@
           };
         }
         vdomItems.push(
-          m('li', m(`a${css}`, attrs, m('i.material-icons', item.i), item.t)),
+          m(
+            'li',
+            m(
+              `a${css}`,
+              {...attrs, title: item.title},
+              m('i.material-icons', item.i),
+              item.t,
+            ),
+          ),
         );
       }
       if (section.appendOpenedTraceTitle) {
@@ -917,7 +826,7 @@
           {
             onclick: () => {
               globals.commandManager.runCommand(
-                'dev.perfetto.CoreCommands#ToggleLeftSidebar',
+                'perfetto.CoreCommands#ToggleLeftSidebar',
               );
             },
           },
@@ -930,9 +839,6 @@
           ),
         ),
       ),
-      m('input.trace_file[type=file]', {
-        onchange: onInputElementFileSelectionChanged,
-      }),
       m(
         '.sidebar-scroll',
         m('.sidebar-scroll-container', ...vdomSections, m(SidebarFooter)),
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index e73b32d..730ea90 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -133,6 +133,19 @@
   path: string[];
 }
 
+export interface SidebarMenuItem {
+  readonly commandId: string;
+  readonly group:
+    | 'navigation'
+    | 'current_trace'
+    | 'convert_trace'
+    | 'example_traces'
+    | 'support';
+  when?(): boolean;
+  readonly icon: string;
+  readonly priority?: number;
+}
+
 // This interface defines a context for a plugin, which is an object passed to
 // most hooks within the plugin. It should be used to interact with Perfetto.
 export interface PluginContext {
@@ -146,17 +159,10 @@
   // 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;
-  };
+  // Adds a new menu item to the sidebar.
+  // All entries must map to a command. This will allow the shortcut and
+  // optional shortcut to be displayed on the UI.
+  addSidebarMenuItem(menuItem: SidebarMenuItem): void;
 }
 
 export interface SliceTrackColNames {
diff --git a/ui/src/widgets/flamegraph.ts b/ui/src/widgets/flamegraph.ts
index e60952b..22722f6 100644
--- a/ui/src/widgets/flamegraph.ts
+++ b/ui/src/widgets/flamegraph.ts
@@ -17,7 +17,6 @@
 import {findRef} from '../base/dom_utils';
 import {assertExists, assertTrue} from '../base/logging';
 import {Monitor} from '../base/monitor';
-import {cropText} from '../base/string_utils';
 
 import {Button, ButtonBar} from './button';
 import {EmptyState} from './empty_state';
@@ -28,7 +27,7 @@
 import {TagInput} from './tag_input';
 import {SegmentedButtons} from './segmented_buttons';
 
-const LABEL_FONT_STYLE = '12px Roboto Mono';
+const LABEL_FONT_STYLE = '12px Roboto';
 const NODE_HEIGHT = 20;
 const MIN_PIXEL_DISPLAYED = 3;
 const FILTER_COMMON_TEXT = `
@@ -456,7 +455,7 @@
       if (widthNoPadding >= LABEL_MIN_WIDTH_FOR_TEXT_PX) {
         ctx.fillStyle = 'black';
         ctx.fillText(
-          cropText(name, this.labelCharWidth, widthNoPadding),
+          name.substring(0, widthNoPadding / this.labelCharWidth),
           x + LABEL_PADDING_PX,
           y + (NODE_HEIGHT - 1) / 2,
           widthNoPadding,
@@ -639,31 +638,31 @@
         m(Button, {
           label: 'Show Stack',
           onclick: () => {
-            filterButtonClick(`Show Stack: ${name}`);
+            filterButtonClick(`Show Stack: ^${name}$`);
           },
         }),
         m(Button, {
           label: 'Hide Stack',
           onclick: () => {
-            filterButtonClick(`Hide Stack: ${name}`);
+            filterButtonClick(`Hide Stack: ^${name}$`);
           },
         }),
         m(Button, {
           label: 'Hide Frame',
           onclick: () => {
-            filterButtonClick(`Hide Frame: ${name}`);
+            filterButtonClick(`Hide Frame: ^${name}$`);
           },
         }),
         m(Button, {
           label: 'Show From Frame',
           onclick: () => {
-            filterButtonClick(`Show From Frame: ${name}`);
+            filterButtonClick(`Show From Frame: ^${name}$`);
           },
         }),
         m(Button, {
           label: 'Pivot',
           onclick: () => {
-            filterButtonClick(`Pivot: ${name}`);
+            filterButtonClick(`Pivot: ^${name}$`);
           },
         }),
       ),
diff --git a/ui/src/widgets/hotkey_glyphs.ts b/ui/src/widgets/hotkey_glyphs.ts
index 667410c..90ac6d4 100644
--- a/ui/src/widgets/hotkey_glyphs.ts
+++ b/ui/src/widgets/hotkey_glyphs.ts
@@ -28,7 +28,7 @@
   view({attrs}: m.Vnode<HotkeyGlyphsAttrs>) {
     const {hotkey, spoof} = attrs;
 
-    const platform = getPlatform(spoof);
+    const platform = spoof || getPlatform();
     const result = parseHotkey(hotkey);
     if (result) {
       const {key, modifier} = result;
@@ -60,7 +60,7 @@
 export class KeycapGlyph implements m.ClassComponent<KeycapGlyphsAttrs> {
   view({attrs}: m.Vnode<KeycapGlyphsAttrs>) {
     const {keyValue, spoof} = attrs;
-    const platform = getPlatform(spoof);
+    const platform = spoof || getPlatform();
     return m('span.pf-keycap', glyphForKey(keyValue, platform));
   }
 }