ui: Extend plugin selection detail tabs to track events (#7003)

This PR extends the subtab concept from area selection tabs to also
track event selections. This allows plugins to register their custom
subtabs which can be displayed in the selection details bottom drawer
even on regular track slices.

Bug: 289343169
diff --git a/docs/contributing/ui-plugins.md b/docs/contributing/ui-plugins.md
index fce152b..e518b6f 100644
--- a/docs/contributing/ui-plugins.md
+++ b/docs/contributing/ui-plugins.md
@@ -1478,30 +1478,51 @@
 This feature allows for creating interactive workflows directly within the
 omnibox, guided by your plugin.
 
-### Area Selection Tabs
+### Selection Tabs
 
-Plugins can register tabs to be displayed in the details panel when an area of
-the timeline is selected.
+Plugins can register custom subtabs in the bottom details panel for timeline selections.
 
-To register an area selection tab, use the
-`trace.selection.registerAreaSelectionTab` method.
+Perfetto provides convenience helpers targeting specific selection types, as well as a generic method:
+
+- `trace.selection.registerTrackEventSelectionTab`: Registers a tab for track events (slices).
+- `trace.selection.registerAreaSelectionTab`: Registers a tab for area selections.
+- `trace.selection.registerSelectionTab`: Generic registration for any selection type (`Selection` union).
+
+#### Track Event Selection Tab Example
+
+```ts
+trace.selection.registerTrackEventSelectionTab({
+  id: 'my-slice-tab',
+  name: 'My Slice Tab',
+  render: (selection) => {
+    return {
+      isLoading: false,
+      content: m('div', `Selected event: ${selection.eventId} on track ${selection.trackUri}`),
+    };
+  },
+});
+```
+
+#### Area Selection Tab Example
 
 ```ts
 trace.selection.registerAreaSelectionTab({
   id: 'my-area-selection-tab',
   name: 'My Area Selection Tab',
   render: (selection) => {
-    return m('div', `Selected area: ${selection.start} - ${selection.end}`);
+    return {
+      isLoading: false,
+      content: m('div', `Selected area: ${selection.start} - ${selection.end}`),
+    };
   },
 });
 ```
 
-The `render` callback should return mithril content to be displayed in the tab.
-The `selection` argument is an `AreaSelection` object, which contains
-information about the selected area.
+The `render` callback should return a `ContentWithLoadingFlag` object (or `undefined` if the tab is not applicable to the current selection).
 
 Examples:
 
+- [com.android.AndroidLockContention](https://github.com/google/perfetto/blob/main/ui/src/plugins/com.android.AndroidLockContention/index.ts).
 - [dev.perfetto.TraceProcessorTrack/index.ts](https://github.com/google/perfetto/blob/main/ui/src/plugins/dev.perfetto.TraceProcessorTrack/index.ts).
 
 ### Metric Visualisations
diff --git a/ui/src/core/selection_manager.ts b/ui/src/core/selection_manager.ts
index f05f089..01a2134 100644
--- a/ui/src/core/selection_manager.ts
+++ b/ui/src/core/selection_manager.ts
@@ -20,6 +20,8 @@
   SelectionManager,
   TrackEventSelection,
   AreaSelectionTab,
+  TrackEventSelectionTab,
+  SelectionTab,
 } from '../public/selection';
 import {TimeSpan} from '../base/time';
 import {raf} from './raf_scheduler';
@@ -58,7 +60,7 @@
     Selection,
     SelectionDetailsPanel
   >();
-  public readonly areaSelectionTabs: AreaSelectionTab[] = [];
+  public readonly selectionTabs: SelectionTab[] = [];
   private _currentSelectionSubTab?: string;
 
   constructor(
@@ -520,8 +522,28 @@
     return undefined;
   }
 
+  registerSelectionTab(tab: SelectionTab): void {
+    this.selectionTabs.push(tab);
+  }
+
   registerAreaSelectionTab(tab: AreaSelectionTab): void {
-    this.areaSelectionTabs.push(tab);
+    this.registerSelectionTab({
+      id: tab.id,
+      name: tab.name,
+      priority: tab.priority,
+      render: (selection) =>
+        selection.kind === 'area' ? tab.render(selection) : undefined,
+    });
+  }
+
+  registerTrackEventSelectionTab(tab: TrackEventSelectionTab): void {
+    this.registerSelectionTab({
+      id: tab.id,
+      name: tab.name,
+      priority: tab.priority,
+      render: (selection) =>
+        selection.kind === 'track_event' ? tab.render(selection) : undefined,
+    });
   }
 
   get currentSelectionSubTab(): string | undefined {
diff --git a/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts b/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts
index acd976a..db35993 100644
--- a/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts
+++ b/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts
@@ -23,6 +23,9 @@
 import type {
   AreaSelection,
   NoteSelection,
+  Selection,
+  SelectionTab,
+  TrackEventSelection,
   TrackSelection,
 } from '../../public/selection';
 import {assertUnreachable} from '../../base/assert';
@@ -30,6 +33,79 @@
 import {NoteEditor} from './note_editor';
 import {Gate} from '../../base/mithril_utils';
 
+interface TabEntry {
+  readonly id: string;
+  readonly name: string;
+  readonly content: m.Children;
+  readonly isLoading: boolean;
+  readonly buttons?: m.Children;
+}
+
+function renderTabs(
+  tabs: ReadonlyArray<SelectionTab>,
+  selection: Selection,
+): TabEntry[] {
+  return tabs
+    .slice()
+    .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
+    .flatMap((tab) => {
+      const content = tab.render(selection);
+      if (!content) return [];
+      return [
+        {
+          id: tab.id,
+          name: tab.name,
+          content: content.content,
+          isLoading: content.isLoading,
+          buttons: content.buttons,
+        },
+      ];
+    });
+}
+
+function renderTabbedDetails(
+  trace: TraceImpl,
+  title: string,
+  tabs: ReadonlyArray<TabEntry>,
+) {
+  if (tabs.length === 0) {
+    return undefined;
+  }
+
+  // Find the active tab or just pick the first one if that selected tab is
+  // not available.
+  const activeTab =
+    tabs.find((tab) => tab.id === trace.selection.currentSelectionSubTab) ??
+    tabs[0];
+
+  // Determine if any tab content is loading
+  const isLoading = tabs.some((tab) => tab.isLoading);
+
+  return {
+    isLoading,
+    content: m(
+      DetailsShell,
+      {
+        title,
+        description: m(
+          ButtonBar,
+          tabs.map((tab) =>
+            m(Button, {
+              label: tab.name,
+              key: tab.id,
+              active: activeTab === tab,
+              onclick: () => trace.selection.setCurrentSelectionSubTab(tab.id),
+            }),
+          ),
+        ),
+        buttons: activeTab.buttons,
+      },
+      // Render all tabs but control visibility with Gate
+      tabs.map((tab) => m(Gate, {open: activeTab === tab}, tab.content)),
+    ),
+  };
+}
+
 export interface CurrentSelectionTabAttrs {
   readonly trace: TraceImpl;
 }
@@ -56,7 +132,7 @@
       case 'track':
         return this.renderTrackSelection(trace, selection);
       case 'track_event':
-        return this.renderTrackEventSelection(trace);
+        return this.renderTrackEventSelection(trace, selection);
       case 'area':
         return this.renderAreaSelection(trace, selection);
       case 'note':
@@ -83,72 +159,47 @@
     };
   }
 
-  private renderTrackEventSelection(trace: TraceImpl) {
+  private renderTrackEventSelection(
+    trace: TraceImpl,
+    selection: TrackEventSelection,
+  ) {
     // The selection panel has already loaded the details panel for us... let's
     // hope it's the right one!
     const detailsPanel = trace.selection.getDetailsPanelForSelection();
-    if (detailsPanel) {
-      return {
-        isLoading: detailsPanel.isLoading,
-        content: detailsPanel.render(),
-      };
-    } else {
+    const extraTabs = renderTabs(trace.selection.selectionTabs, selection);
+
+    if (extraTabs.length === 0) {
+      if (detailsPanel) {
+        return {
+          isLoading: detailsPanel.isLoading,
+          content: detailsPanel.render(),
+        };
+      }
       return {
         isLoading: true,
         content: 'Loading...',
       };
     }
+
+    const allTabs: TabEntry[] = [
+      {
+        id: 'overview',
+        name: 'Details',
+        content: detailsPanel ? detailsPanel.render() : 'Loading...',
+        isLoading: detailsPanel ? detailsPanel.isLoading : true,
+      },
+      ...extraTabs,
+    ];
+
+    return renderTabbedDetails(trace, 'Selection', allTabs)!;
   }
 
   private renderAreaSelection(trace: TraceImpl, selection: AreaSelection) {
-    const tabs = trace.selection.areaSelectionTabs.sort(
-      (a, b) => (b.priority ?? 0) - (a.priority ?? 0),
+    const tabs = renderTabs(trace.selection.selectionTabs, selection);
+    return (
+      renderTabbedDetails(trace, 'Area Selection', tabs) ??
+      this.renderEmptySelection('No details available for selection')
     );
-
-    const renderedTabs = tabs
-      .map((tab) => [tab, tab.render(selection)] as const)
-      .filter(([_, content]) => content !== undefined);
-
-    if (renderedTabs.length === 0) {
-      return this.renderEmptySelection('No details available for selection');
-    }
-
-    // Find the active tab or just pick the first one if that selected tab is
-    // not available.
-    const [activeTab, activeTabContent] =
-      renderedTabs.find(
-        ([tab]) => tab.id === trace.selection.currentSelectionSubTab,
-      ) ?? renderedTabs[0];
-
-    // Determine if any tab content is loading
-    const isLoading = renderedTabs.some(([_, content]) => content?.isLoading);
-
-    return {
-      isLoading,
-      content: m(
-        DetailsShell,
-        {
-          title: 'Area Selection',
-          description: m(
-            ButtonBar,
-            renderedTabs.map(([tab]) => {
-              return m(Button, {
-                label: tab.name,
-                key: tab.id,
-                active: activeTab === tab,
-                onclick: () =>
-                  trace.selection.setCurrentSelectionSubTab(tab.id),
-              });
-            }),
-          ),
-          buttons: activeTabContent?.buttons,
-        },
-        // Render all tabs but control visibility with Gate
-        renderedTabs.map(([tab, content]) =>
-          m(Gate, {open: activeTab === tab}, content?.content),
-        ),
-      ),
-    };
   }
 
   private renderNoteSelection(trace: TraceImpl, selection: NoteSelection) {
diff --git a/ui/src/public/selection.ts b/ui/src/public/selection.ts
index cb9f740..e7dcd99 100644
--- a/ui/src/public/selection.ts
+++ b/ui/src/public/selection.ts
@@ -35,7 +35,7 @@
   readonly buttons?: m.Children;
 }
 
-export interface AreaSelectionTab {
+export interface SelectionTab<T = Selection> {
   // Unique id for this tab.
   readonly id: string;
 
@@ -54,13 +54,16 @@
    * has nothing relevant to show.
    *
    * The |isLoading| flag is used to avoid flickering. If set to true, we keep
-   * hold of the the previous vnodes, rendering them instead, for up to 50ms
+   * hold of the previous vnodes, rendering them instead, for up to 50ms
    * before switching to the new content. This avoids very fast load times
    * from causing flickering loading screens, which can be somewhat jarring.
    */
-  render(selection: AreaSelection): ContentWithLoadingFlag | undefined;
+  render(selection: T): ContentWithLoadingFlag | undefined;
 }
 
+export type AreaSelectionTab = SelectionTab<AreaSelection>;
+export type TrackEventSelectionTab = SelectionTab<TrackEventSelection>;
+
 /**
  * Compare two area selections for equality. Returns true if the selections are
  * equivalent, false otherwise.
@@ -105,9 +108,9 @@
   readonly selection: Selection;
 
   /**
-   * Provides a list of registered area selection tabs.
+   * Provides a list of registered selection tabs.
    */
-  readonly areaSelectionTabs: ReadonlyArray<AreaSelectionTab>;
+  readonly selectionTabs: ReadonlyArray<SelectionTab>;
 
   /**
    * Clears the current selection, selects nothing.
@@ -182,9 +185,19 @@
   getTimeSpanOfSelection(): TimeSpan | undefined;
 
   /**
+   * Register a new tab under the selection details panel.
+   */
+  registerSelectionTab(tab: SelectionTab): void;
+
+  /**
    * Register a new tab under the area selection details panel.
    */
   registerAreaSelectionTab(tab: AreaSelectionTab): void;
+
+  /**
+   * Register a new tab under the track event selection details panel.
+   */
+  registerTrackEventSelectionTab(tab: TrackEventSelectionTab): void;
 }
 
 export type Selection =