xref: /aosp_15_r20/external/perfetto/ui/src/core/selection_manager.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import {assertTrue, assertUnreachable} from '../base/logging';
16import {
17  Selection,
18  Area,
19  SelectionOpts,
20  SelectionManager,
21  AreaSelectionAggregator,
22  SqlSelectionResolver,
23  TrackEventSelection,
24} from '../public/selection';
25import {TimeSpan} from '../base/time';
26import {raf} from './raf_scheduler';
27import {exists} from '../base/utils';
28import {TrackManagerImpl} from './track_manager';
29import {Engine} from '../trace_processor/engine';
30import {ScrollHelper} from './scroll_helper';
31import {NoteManagerImpl} from './note_manager';
32import {SearchResult} from '../public/search';
33import {SelectionAggregationManager} from './selection_aggregation_manager';
34import {AsyncLimiter} from '../base/async_limiter';
35import m from 'mithril';
36import {SerializedSelection} from './state_serialization_schema';
37
38const INSTANT_FOCUS_DURATION = 1n;
39const INCOMPLETE_SLICE_DURATION = 30_000n;
40
41interface SelectionDetailsPanel {
42  isLoading: boolean;
43  render(): m.Children;
44  serializatonState(): unknown;
45}
46
47// There are two selection-related states in this class.
48// 1. _selection: This is the "input" / locator of the selection, what other
49//    parts of the codebase specify (e.g., a tuple of trackUri + eventId) to say
50//    "please select this object if it exists".
51// 2. _selected{Slice,ThreadState}: This is the resolved selection, that is, the
52//    rich details about the object that has been selected. If the input
53//    `_selection` is valid, this is filled in the near future. Doing so
54//    requires querying the SQL engine, which is an async operation.
55export class SelectionManagerImpl implements SelectionManager {
56  private readonly detailsPanelLimiter = new AsyncLimiter();
57  private _selection: Selection = {kind: 'empty'};
58  private _aggregationManager: SelectionAggregationManager;
59  // Incremented every time _selection changes.
60  private readonly selectionResolvers = new Array<SqlSelectionResolver>();
61  private readonly detailsPanels = new WeakMap<
62    Selection,
63    SelectionDetailsPanel
64  >();
65
66  constructor(
67    engine: Engine,
68    private trackManager: TrackManagerImpl,
69    private noteManager: NoteManagerImpl,
70    private scrollHelper: ScrollHelper,
71    private onSelectionChange: (s: Selection, opts: SelectionOpts) => void,
72  ) {
73    this._aggregationManager = new SelectionAggregationManager(
74      engine.getProxy('SelectionAggregationManager'),
75    );
76  }
77
78  registerAreaSelectionAggregator(aggr: AreaSelectionAggregator): void {
79    this._aggregationManager.registerAggregator(aggr);
80  }
81
82  clear(): void {
83    this.setSelection({kind: 'empty'});
84  }
85
86  async selectTrackEvent(
87    trackUri: string,
88    eventId: number,
89    opts?: SelectionOpts,
90  ) {
91    this.selectTrackEventInternal(trackUri, eventId, opts);
92  }
93
94  selectTrack(trackUri: string, opts?: SelectionOpts) {
95    this.setSelection({kind: 'track', trackUri}, opts);
96  }
97
98  selectNote(args: {id: string}, opts?: SelectionOpts) {
99    this.setSelection(
100      {
101        kind: 'note',
102        id: args.id,
103      },
104      opts,
105    );
106  }
107
108  selectArea(area: Area, opts?: SelectionOpts): void {
109    const {start, end} = area;
110    assertTrue(start <= end);
111
112    // In the case of area selection, the caller provides a list of trackUris.
113    // However, all the consumer want to access the resolved TrackDescriptor.
114    // Rather than delegating this to the various consumers, we resolve them
115    // now once and for all and place them in the selection object.
116    const tracks = [];
117    for (const uri of area.trackUris) {
118      const trackDescr = this.trackManager.getTrack(uri);
119      if (trackDescr === undefined) continue;
120      tracks.push(trackDescr);
121    }
122
123    this.setSelection(
124      {
125        ...area,
126        kind: 'area',
127        tracks,
128      },
129      opts,
130    );
131  }
132
133  deserialize(serialized: SerializedSelection | undefined) {
134    if (serialized === undefined) {
135      return;
136    }
137    switch (serialized.kind) {
138      case 'TRACK_EVENT':
139        this.selectTrackEventInternal(
140          serialized.trackKey,
141          parseInt(serialized.eventId),
142          undefined,
143          serialized.detailsPanel,
144        );
145        break;
146      case 'AREA':
147        this.selectArea({
148          start: serialized.start,
149          end: serialized.end,
150          trackUris: serialized.trackUris,
151        });
152    }
153  }
154
155  toggleTrackAreaSelection(trackUri: string) {
156    const curSelection = this._selection;
157    if (curSelection.kind !== 'area') return;
158
159    let trackUris = curSelection.trackUris.slice();
160    if (!trackUris.includes(trackUri)) {
161      trackUris.push(trackUri);
162    } else {
163      trackUris = trackUris.filter((t) => t !== trackUri);
164    }
165    this.selectArea({
166      ...curSelection,
167      trackUris,
168    });
169  }
170
171  toggleGroupAreaSelection(trackUris: string[]) {
172    const curSelection = this._selection;
173    if (curSelection.kind !== 'area') return;
174
175    const allTracksSelected = trackUris.every((t) =>
176      curSelection.trackUris.includes(t),
177    );
178
179    let newTrackUris: string[];
180    if (allTracksSelected) {
181      // Deselect all tracks in the list
182      newTrackUris = curSelection.trackUris.filter(
183        (t) => !trackUris.includes(t),
184      );
185    } else {
186      newTrackUris = curSelection.trackUris.slice();
187      trackUris.forEach((t) => {
188        if (!newTrackUris.includes(t)) {
189          newTrackUris.push(t);
190        }
191      });
192    }
193    this.selectArea({
194      ...curSelection,
195      trackUris: newTrackUris,
196    });
197  }
198
199  get selection(): Selection {
200    return this._selection;
201  }
202
203  getDetailsPanelForSelection(): SelectionDetailsPanel | undefined {
204    return this.detailsPanels.get(this._selection);
205  }
206
207  registerSqlSelectionResolver(resolver: SqlSelectionResolver): void {
208    this.selectionResolvers.push(resolver);
209  }
210
211  async resolveSqlEvent(
212    sqlTableName: string,
213    id: number,
214  ): Promise<{eventId: number; trackUri: string} | undefined> {
215    const matchingResolvers = this.selectionResolvers.filter(
216      (r) => r.sqlTableName === sqlTableName,
217    );
218
219    for (const resolver of matchingResolvers) {
220      const result = await resolver.callback(id, sqlTableName);
221      if (result) {
222        // If we have multiple resolvers for the same table, just return the first one.
223        return result;
224      }
225    }
226
227    return undefined;
228  }
229
230  selectSqlEvent(sqlTableName: string, id: number, opts?: SelectionOpts): void {
231    this.resolveSqlEvent(sqlTableName, id).then((selection) => {
232      selection &&
233        this.selectTrackEvent(selection.trackUri, selection.eventId, opts);
234    });
235  }
236
237  private setSelection(selection: Selection, opts?: SelectionOpts) {
238    this._selection = selection;
239    this.onSelectionChange(selection, opts ?? {});
240    raf.scheduleFullRedraw();
241
242    if (opts?.scrollToSelection) {
243      this.scrollToCurrentSelection();
244    }
245
246    if (this._selection.kind === 'area') {
247      this._aggregationManager.aggregateArea(this._selection);
248    } else {
249      this._aggregationManager.clear();
250    }
251  }
252
253  selectSearchResult(searchResult: SearchResult) {
254    const {source, eventId, trackUri} = searchResult;
255    if (eventId === undefined) {
256      return;
257    }
258    switch (source) {
259      case 'track':
260        this.selectTrack(trackUri, {
261          clearSearch: false,
262          scrollToSelection: true,
263        });
264        break;
265      case 'cpu':
266        this.selectSqlEvent('sched_slice', eventId, {
267          clearSearch: false,
268          scrollToSelection: true,
269          switchToCurrentSelectionTab: true,
270        });
271        break;
272      case 'log':
273        // TODO(stevegolton): Get log selection working.
274        break;
275      case 'slice':
276        // Search results only include slices from the slice table for now.
277        // When we include annotations we need to pass the correct table.
278        this.selectSqlEvent('slice', eventId, {
279          clearSearch: false,
280          scrollToSelection: true,
281          switchToCurrentSelectionTab: true,
282        });
283        break;
284      default:
285        assertUnreachable(source);
286    }
287  }
288
289  scrollToCurrentSelection() {
290    const uri = (() => {
291      switch (this.selection.kind) {
292        case 'track_event':
293        case 'track':
294          return this.selection.trackUri;
295        // TODO(stevegolton): Handle scrolling to area and note selections.
296        default:
297          return undefined;
298      }
299    })();
300    const range = this.findFocusRangeOfSelection();
301    this.scrollHelper.scrollTo({
302      time: range ? {...range} : undefined,
303      track: uri ? {uri: uri, expandGroup: true} : undefined,
304    });
305  }
306
307  // Finds the time range range that we should actually focus on - using dummy
308  // values for instant and incomplete slices, so we don't end up super zoomed
309  // in.
310  private findFocusRangeOfSelection(): TimeSpan | undefined {
311    const sel = this.selection;
312    if (sel.kind === 'track_event') {
313      // The focus range of slices is different to that of the actual span
314      if (sel.dur === -1n) {
315        return TimeSpan.fromTimeAndDuration(sel.ts, INCOMPLETE_SLICE_DURATION);
316      } else if (sel.dur === 0n) {
317        return TimeSpan.fromTimeAndDuration(sel.ts, INSTANT_FOCUS_DURATION);
318      } else {
319        return TimeSpan.fromTimeAndDuration(sel.ts, sel.dur);
320      }
321    } else {
322      return this.findTimeRangeOfSelection();
323    }
324  }
325
326  private async selectTrackEventInternal(
327    trackUri: string,
328    eventId: number,
329    opts?: SelectionOpts,
330    serializedDetailsPanel?: unknown,
331  ) {
332    const details = await this.trackManager
333      .getTrack(trackUri)
334      ?.track.getSelectionDetails?.(eventId);
335
336    if (!exists(details)) {
337      throw new Error('Unable to resolve selection details');
338    }
339
340    const selection: TrackEventSelection = {
341      ...details,
342      kind: 'track_event',
343      trackUri,
344      eventId,
345    };
346    this.createTrackEventDetailsPanel(selection, serializedDetailsPanel);
347    this.setSelection(selection, opts);
348  }
349
350  private createTrackEventDetailsPanel(
351    selection: TrackEventSelection,
352    serializedState: unknown,
353  ) {
354    const td = this.trackManager.getTrack(selection.trackUri);
355    if (!td) {
356      return;
357    }
358    const panel = td.track.detailsPanel?.(selection);
359    if (!panel) {
360      return;
361    }
362
363    if (panel.serialization && serializedState !== undefined) {
364      const res = panel.serialization.schema.safeParse(serializedState);
365      if (res.success) {
366        panel.serialization.state = res.data;
367      }
368    }
369
370    const detailsPanel: SelectionDetailsPanel = {
371      render: () => panel.render(),
372      serializatonState: () => panel.serialization?.state,
373      isLoading: true,
374    };
375    // Associate this details panel with this selection object
376    this.detailsPanels.set(selection, detailsPanel);
377
378    this.detailsPanelLimiter.schedule(async () => {
379      await panel?.load?.(selection);
380      detailsPanel.isLoading = false;
381      raf.scheduleFullRedraw();
382    });
383  }
384
385  findTimeRangeOfSelection(): TimeSpan | undefined {
386    const sel = this.selection;
387    if (sel.kind === 'area') {
388      return new TimeSpan(sel.start, sel.end);
389    } else if (sel.kind === 'note') {
390      const selectedNote = this.noteManager.getNote(sel.id);
391      if (selectedNote !== undefined) {
392        const kind = selectedNote.noteType;
393        switch (kind) {
394          case 'SPAN':
395            return new TimeSpan(selectedNote.start, selectedNote.end);
396          case 'DEFAULT':
397            return TimeSpan.fromTimeAndDuration(
398              selectedNote.timestamp,
399              INSTANT_FOCUS_DURATION,
400            );
401          default:
402            assertUnreachable(kind);
403        }
404      }
405    } else if (sel.kind === 'track_event') {
406      return TimeSpan.fromTimeAndDuration(sel.ts, sel.dur);
407    }
408
409    return undefined;
410  }
411
412  get aggregation() {
413    return this._aggregationManager;
414  }
415}
416