• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2023 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 m from 'mithril';
16import {copyToClipboard} from '../base/clipboard';
17import {findRef} from '../base/dom_utils';
18import {FuzzyFinder} from '../base/fuzzy';
19import {assertExists, assertUnreachable} from '../base/logging';
20import {undoCommonChatAppReplacements} from '../base/string_utils';
21import {
22  setDurationPrecision,
23  setTimestampFormat,
24} from '../core/timestamp_format';
25import {Command} from '../public/command';
26import {HotkeyConfig, HotkeyContext} from '../widgets/hotkey_context';
27import {HotkeyGlyphs} from '../widgets/hotkey_glyphs';
28import {maybeRenderFullscreenModalDialog, showModal} from '../widgets/modal';
29import {CookieConsent} from '../core/cookie_consent';
30import {toggleHelp} from './help_modal';
31import {Omnibox, OmniboxOption} from './omnibox';
32import {addQueryResultsTab} from '../components/query_table/query_result_tab';
33import {Sidebar} from './sidebar';
34import {Topbar} from './topbar';
35import {shareTrace} from './trace_share_utils';
36import {OmniboxMode} from '../core/omnibox_manager';
37import {DisposableStack} from '../base/disposable_stack';
38import {Spinner} from '../widgets/spinner';
39import {TraceImpl} from '../core/trace_impl';
40import {AppImpl} from '../core/app_impl';
41import {NotesListEditor} from './notes_list_editor';
42import {getTimeSpanOfSelectionOrVisibleWindow} from '../public/utils';
43import {DurationPrecision, TimestampFormat} from '../public/timeline';
44import {Workspace} from '../public/workspace';
45import {
46  deserializeAppStatePhase1,
47  deserializeAppStatePhase2,
48  JsonSerialize,
49  parseAppState,
50  serializeAppState,
51} from '../core/state_serialization';
52import {featureFlags} from '../core/feature_flags';
53import {trackMatchesFilter} from '../core/track_manager';
54
55const QUICKSAVE_LOCALSTORAGE_KEY = 'quicksave';
56const OMNIBOX_INPUT_REF = 'omnibox';
57
58// This wrapper creates a new instance of UiMainPerTrace for each new trace
59// loaded (including the case of no trace at the beginning).
60export class UiMain implements m.ClassComponent {
61  view() {
62    const currentTraceId = AppImpl.instance.trace?.engine.engineId ?? '';
63    return [m(UiMainPerTrace, {key: currentTraceId})];
64  }
65}
66
67// This components gets destroyed and recreated every time the current trace
68// changes. Note that in the beginning the current trace is undefined.
69export class UiMainPerTrace implements m.ClassComponent {
70  // NOTE: this should NOT need to be an AsyncDisposableStack. If you feel the
71  // need of making it async because you want to clean up SQL resources, that
72  // will cause bugs (see comments in oncreate()).
73  private trash = new DisposableStack();
74  private omniboxInputEl?: HTMLInputElement;
75  private recentCommands: string[] = [];
76  private trace?: TraceImpl;
77
78  // This function is invoked once per trace.
79  constructor() {
80    const app = AppImpl.instance;
81    const trace = app.trace;
82    this.trace = trace;
83
84    // Register global commands (commands that are useful even without a trace
85    // loaded).
86    const globalCmds: Command[] = [
87      {
88        id: 'perfetto.OpenCommandPalette',
89        name: 'Open command palette',
90        callback: () => app.omnibox.setMode(OmniboxMode.Command),
91        defaultHotkey: '!Mod+Shift+P',
92      },
93
94      {
95        id: 'perfetto.ShowHelp',
96        name: 'Show help',
97        callback: () => toggleHelp(),
98        defaultHotkey: '?',
99      },
100    ];
101    globalCmds.forEach((cmd) => {
102      this.trash.use(app.commands.registerCommand(cmd));
103    });
104
105    // When the UI loads there is no trace. There is no point registering
106    // commands or anything in this state as they will be useless.
107    if (trace === undefined) return;
108    document.title = `${trace.traceInfo.traceTitle || 'Trace'} - Perfetto UI`;
109    this.maybeShowJsonWarning();
110
111    this.trash.use(
112      trace.tabs.registerTab({
113        uri: 'notes.manager',
114        isEphemeral: false,
115        content: {
116          getTitle: () => 'Notes & markers',
117          render: () => m(NotesListEditor, {trace}),
118        },
119      }),
120    );
121
122    const cmds: Command[] = [
123      {
124        id: 'perfetto.SetTimestampFormat',
125        name: 'Set timestamp and duration format',
126        callback: async () => {
127          const TF = TimestampFormat;
128          const result = await app.omnibox.prompt('Select format...', {
129            values: [
130              {format: TF.Timecode, name: 'Timecode'},
131              {format: TF.UTC, name: 'Realtime (UTC)'},
132              {format: TF.TraceTz, name: 'Realtime (Trace TZ)'},
133              {format: TF.Seconds, name: 'Seconds'},
134              {format: TF.Milliseconds, name: 'Milliseconds'},
135              {format: TF.Microseconds, name: 'Microseconds'},
136              {format: TF.TraceNs, name: 'Trace nanoseconds'},
137              {
138                format: TF.TraceNsLocale,
139                name: 'Trace nanoseconds (with locale-specific formatting)',
140              },
141            ],
142            getName: (x) => x.name,
143          });
144          result && setTimestampFormat(result.format);
145        },
146      },
147      {
148        id: 'perfetto.SetDurationPrecision',
149        name: 'Set duration precision',
150        callback: async () => {
151          const DF = DurationPrecision;
152          const result = await app.omnibox.prompt(
153            'Select duration precision mode...',
154            {
155              values: [
156                {format: DF.Full, name: 'Full'},
157                {format: DF.HumanReadable, name: 'Human readable'},
158              ],
159              getName: (x) => x.name,
160            },
161          );
162          result && setDurationPrecision(result.format);
163        },
164      },
165      {
166        id: 'perfetto.TogglePerformanceMetrics',
167        name: 'Toggle performance metrics',
168        callback: () =>
169          (app.perfDebugging.enabled = !app.perfDebugging.enabled),
170      },
171      {
172        id: 'perfetto.ShareTrace',
173        name: 'Share trace',
174        callback: () => shareTrace(trace),
175      },
176      {
177        id: 'perfetto.SearchNext',
178        name: 'Go to next search result',
179        callback: () => {
180          trace.search.stepForward();
181        },
182        defaultHotkey: 'Enter',
183      },
184      {
185        id: 'perfetto.SearchPrev',
186        name: 'Go to previous search result',
187        callback: () => {
188          trace.search.stepBackwards();
189        },
190        defaultHotkey: 'Shift+Enter',
191      },
192      {
193        id: 'perfetto.RunQuery',
194        name: 'Run query',
195        callback: () => trace.omnibox.setMode(OmniboxMode.Query),
196      },
197      {
198        id: 'perfetto.Search',
199        name: 'Search',
200        callback: () => trace.omnibox.setMode(OmniboxMode.Search),
201        defaultHotkey: '/',
202      },
203      {
204        id: 'perfetto.CopyTimeWindow',
205        name: `Copy selected time window to clipboard`,
206        callback: async () => {
207          const window = await getTimeSpanOfSelectionOrVisibleWindow(trace);
208          const query = `ts >= ${window.start} and ts < ${window.end}`;
209          copyToClipboard(query);
210        },
211      },
212      {
213        id: 'perfetto.FocusSelection',
214        name: 'Focus current selection',
215        callback: () => trace.selection.scrollToCurrentSelection(),
216        defaultHotkey: 'F',
217      },
218      {
219        id: 'perfetto.Deselect',
220        name: 'Deselect',
221        callback: () => {
222          trace.selection.clear();
223        },
224        defaultHotkey: 'Escape',
225      },
226      {
227        id: 'perfetto.SetTemporarySpanNote',
228        name: 'Set the temporary span note based on the current selection',
229        callback: () => {
230          const range = trace.selection.findTimeRangeOfSelection();
231          if (range) {
232            trace.notes.addSpanNote({
233              start: range.start,
234              end: range.end,
235              id: '__temp__',
236            });
237
238            // Also select an area for this span
239            const selection = trace.selection.selection;
240            if (selection.kind === 'track_event') {
241              trace.selection.selectArea({
242                start: range.start,
243                end: range.end,
244                trackUris: [selection.trackUri],
245              });
246            }
247          }
248        },
249        defaultHotkey: 'M',
250      },
251      {
252        id: 'perfetto.AddSpanNote',
253        name: 'Add a new span note based on the current selection',
254        callback: () => {
255          const range = trace.selection.findTimeRangeOfSelection();
256          if (range) {
257            trace.notes.addSpanNote({
258              start: range.start,
259              end: range.end,
260            });
261          }
262        },
263        defaultHotkey: 'Shift+M',
264      },
265      {
266        id: 'perfetto.RemoveSelectedNote',
267        name: 'Remove selected note',
268        callback: () => {
269          const selection = trace.selection.selection;
270          if (selection.kind === 'note') {
271            trace.notes.removeNote(selection.id);
272          }
273        },
274        defaultHotkey: 'Delete',
275      },
276      {
277        id: 'perfetto.NextFlow',
278        name: 'Next flow',
279        callback: () => trace.flows.focusOtherFlow('Forward'),
280        defaultHotkey: 'Mod+]',
281      },
282      {
283        id: 'perfetto.PrevFlow',
284        name: 'Prev flow',
285        callback: () => trace.flows.focusOtherFlow('Backward'),
286        defaultHotkey: 'Mod+[',
287      },
288      {
289        id: 'perfetto.MoveNextFlow',
290        name: 'Move next flow',
291        callback: () => trace.flows.moveByFocusedFlow('Forward'),
292        defaultHotkey: ']',
293      },
294      {
295        id: 'perfetto.MovePrevFlow',
296        name: 'Move prev flow',
297        callback: () => trace.flows.moveByFocusedFlow('Backward'),
298        defaultHotkey: '[',
299      },
300
301      // Provides a test bed for resolving events using a SQL table name and ID
302      // which is used in deep-linking, amongst other places.
303      {
304        id: 'perfetto.SelectEventByTableNameAndId',
305        name: 'Select event by table name and ID',
306        callback: async () => {
307          const rootTableName = await trace.omnibox.prompt('Enter table name');
308          if (rootTableName === undefined) return;
309
310          const id = await trace.omnibox.prompt('Enter ID');
311          if (id === undefined) return;
312
313          const num = Number(id);
314          if (!isFinite(num)) return; // Rules out NaN or +-Infinity
315
316          trace.selection.selectSqlEvent(rootTableName, num, {
317            scrollToSelection: true,
318          });
319        },
320      },
321      {
322        id: 'perfetto.SelectAll',
323        name: 'Select all',
324        callback: () => {
325          // This is a dual state command:
326          // - If one ore more tracks are already area selected, expand the time
327          //   range to include the entire trace, but keep the selection on just
328          //   these tracks.
329          // - If nothing is selected, or all selected tracks are entirely
330          //   selected, then select the entire trace. This allows double tapping
331          //   Ctrl+A to select the entire track, then select the entire trace.
332          let tracksToSelect: string[];
333          const selection = trace.selection.selection;
334          if (selection.kind === 'area') {
335            // Something is already selected, let's see if it covers the entire
336            // span of the trace or not
337            const coversEntireTimeRange =
338              trace.traceInfo.start === selection.start &&
339              trace.traceInfo.end === selection.end;
340            if (!coversEntireTimeRange) {
341              // If the current selection is an area which does not cover the
342              // entire time range, preserve the list of selected tracks and
343              // expand the time range.
344              tracksToSelect = selection.trackUris;
345            } else {
346              // If the entire time range is already covered, update the selection
347              // to cover all tracks.
348              tracksToSelect = trace.workspace.flatTracks
349                .map((t) => t.uri)
350                .filter((uri) => uri !== undefined);
351            }
352          } else {
353            // If the current selection is not an area, select all.
354            tracksToSelect = trace.workspace.flatTracks
355              .map((t) => t.uri)
356              .filter((uri) => uri !== undefined);
357          }
358          const {start, end} = trace.traceInfo;
359          trace.selection.selectArea({
360            start,
361            end,
362            trackUris: tracksToSelect,
363          });
364        },
365        defaultHotkey: 'Mod+A',
366      },
367      {
368        id: 'perfetto.ConvertSelectionToArea',
369        name: 'Convert the current selection to an area selection',
370        callback: () => {
371          const selection = trace.selection.selection;
372          const range = trace.selection.findTimeRangeOfSelection();
373          if (selection.kind === 'track_event' && range) {
374            trace.selection.selectArea({
375              start: range.start,
376              end: range.end,
377              trackUris: [selection.trackUri],
378            });
379          }
380        },
381        // TODO(stevegolton): Decide on a sensible hotkey.
382        // defaultHotkey: 'L',
383      },
384      {
385        id: 'perfetto.ToggleDrawer',
386        name: 'Toggle drawer',
387        defaultHotkey: 'Q',
388        callback: () => trace.tabs.toggleTabPanelVisibility(),
389      },
390      {
391        id: 'perfetto.CopyPinnedToWorkspace',
392        name: 'Copy pinned tracks to workspace',
393        callback: async () => {
394          const pinnedTracks = trace.workspace.pinnedTracks;
395          if (!pinnedTracks.length) {
396            window.alert('No pinned tracks to copy');
397            return;
398          }
399
400          const ws = await this.selectWorkspace(trace, 'Pinned tracks');
401          if (!ws) return;
402
403          for (const pinnedTrack of pinnedTracks) {
404            const clone = pinnedTrack.clone();
405            ws.addChildLast(clone);
406          }
407          trace.workspaces.switchWorkspace(ws);
408        },
409      },
410      {
411        id: 'perfetto.CopyFilteredToWorkspace',
412        name: 'Copy filtered tracks to workspace',
413        callback: async () => {
414          // Copies all filtered tracks as a flat list to a new workspace. This
415          // means parents are not included.
416          const tracks = trace.workspace.flatTracks.filter((track) =>
417            trackMatchesFilter(trace, track),
418          );
419
420          if (!tracks.length) {
421            window.alert('No filtered tracks to copy');
422            return;
423          }
424
425          const ws = await this.selectWorkspace(trace, 'Filtered tracks');
426          if (!ws) return;
427
428          for (const track of tracks) {
429            const clone = track.clone();
430            ws.addChildLast(clone);
431          }
432          trace.workspaces.switchWorkspace(ws);
433        },
434      },
435      {
436        id: 'perfetto.CopySelectedTracksToWorkspace',
437        name: 'Copy selected tracks to workspace',
438        callback: async () => {
439          const selection = trace.selection.selection;
440
441          if (selection.kind !== 'area' || selection.trackUris.length === 0) {
442            window.alert('No selected tracks to copy');
443            return;
444          }
445
446          const workspace = await this.selectWorkspace(trace);
447          if (!workspace) return;
448
449          for (const uri of selection.trackUris) {
450            const node = trace.workspace.getTrackByUri(uri);
451            if (!node) continue;
452            const newNode = node.clone();
453            workspace.addChildLast(newNode);
454          }
455          trace.workspaces.switchWorkspace(workspace);
456        },
457      },
458      {
459        id: 'perfetto.Quicksave',
460        name: 'Quicksave UI state to localStorage',
461        callback: () => {
462          const state = serializeAppState(trace);
463          const json = JsonSerialize(state);
464          localStorage.setItem(QUICKSAVE_LOCALSTORAGE_KEY, json);
465        },
466      },
467      {
468        id: 'perfetto.Quickload',
469        name: 'Quickload UI state from the localStorage',
470        callback: () => {
471          const json = localStorage.getItem(QUICKSAVE_LOCALSTORAGE_KEY);
472          if (json === null) {
473            showModal({
474              title: 'Nothing saved in the quicksave slot',
475              buttons: [{text: 'Dismiss'}],
476            });
477            return;
478          }
479          const parsed = JSON.parse(json);
480          const state = parseAppState(parsed);
481          if (state.success) {
482            deserializeAppStatePhase1(state.data, trace);
483            deserializeAppStatePhase2(state.data, trace);
484          }
485        },
486      },
487      {
488        id: `${app.pluginId}#RestoreDefaults`,
489        name: 'Reset all flags back to default values',
490        callback: () => {
491          featureFlags.resetAll();
492          window.location.reload();
493        },
494      },
495    ];
496
497    // Register each command with the command manager
498    cmds.forEach((cmd) => {
499      this.trash.use(trace.commands.registerCommand(cmd));
500    });
501  }
502
503  // Selects a workspace or creates a new one.
504  private async selectWorkspace(
505    trace: TraceImpl,
506    newWorkspaceName = 'Untitled workspace',
507  ): Promise<Workspace | undefined> {
508    const options = trace.workspaces.all
509      .filter((ws) => ws.userEditable)
510      .map((ws) => ({title: ws.title, fn: () => ws}))
511      .concat([
512        {
513          title: 'New workspace...',
514          fn: () => trace.workspaces.createEmptyWorkspace(newWorkspaceName),
515        },
516      ]);
517
518    const result = await trace.omnibox.prompt('Select a workspace...', {
519      values: options,
520      getName: (ws) => ws.title,
521    });
522
523    if (!result) return undefined;
524    return result.fn();
525  }
526
527  private renderOmnibox(): m.Children {
528    const omnibox = AppImpl.instance.omnibox;
529    const omniboxMode = omnibox.mode;
530    const statusMessage = omnibox.statusMessage;
531    if (statusMessage !== undefined) {
532      return m(
533        `.omnibox.message-mode`,
534        m(`input[readonly][disabled][ref=omnibox]`, {
535          value: '',
536          placeholder: statusMessage,
537        }),
538      );
539    } else if (omniboxMode === OmniboxMode.Command) {
540      return this.renderCommandOmnibox();
541    } else if (omniboxMode === OmniboxMode.Prompt) {
542      return this.renderPromptOmnibox();
543    } else if (omniboxMode === OmniboxMode.Query) {
544      return this.renderQueryOmnibox();
545    } else if (omniboxMode === OmniboxMode.Search) {
546      return this.renderSearchOmnibox();
547    } else {
548      assertUnreachable(omniboxMode);
549    }
550  }
551
552  renderPromptOmnibox(): m.Children {
553    const omnibox = AppImpl.instance.omnibox;
554    const prompt = assertExists(omnibox.pendingPrompt);
555
556    let options: OmniboxOption[] | undefined = undefined;
557
558    if (prompt.options) {
559      const fuzzy = new FuzzyFinder(
560        prompt.options,
561        ({displayName}) => displayName,
562      );
563      const result = fuzzy.find(omnibox.text);
564      options = result.map((result) => {
565        return {
566          key: result.item.key,
567          displayName: result.segments,
568        };
569      });
570    }
571
572    return m(Omnibox, {
573      value: omnibox.text,
574      placeholder: prompt.text,
575      inputRef: OMNIBOX_INPUT_REF,
576      extraClasses: 'prompt-mode',
577      closeOnOutsideClick: true,
578      options,
579      selectedOptionIndex: omnibox.selectionIndex,
580      onSelectedOptionChanged: (index) => {
581        omnibox.setSelectionIndex(index);
582      },
583      onInput: (value) => {
584        omnibox.setText(value);
585        omnibox.setSelectionIndex(0);
586      },
587      onSubmit: (value, _alt) => {
588        omnibox.resolvePrompt(value);
589      },
590      onClose: () => {
591        omnibox.rejectPrompt();
592      },
593    });
594  }
595
596  renderCommandOmnibox(): m.Children {
597    // Fuzzy-filter commands by the filter string.
598    const {commands, omnibox} = AppImpl.instance;
599    const filteredCmds = commands.fuzzyFilterCommands(omnibox.text);
600
601    // Create an array of commands with attached heuristics from the recent
602    // command register.
603    const commandsWithHeuristics = filteredCmds.map((cmd) => {
604      return {
605        recentsIndex: this.recentCommands.findIndex((id) => id === cmd.id),
606        cmd,
607      };
608    });
609
610    // Sort recentsIndex first
611    const sorted = commandsWithHeuristics.sort((a, b) => {
612      if (b.recentsIndex === a.recentsIndex) {
613        // If recentsIndex is the same, retain original sort order
614        return 0;
615      } else {
616        return b.recentsIndex - a.recentsIndex;
617      }
618    });
619
620    const options = sorted.map(({recentsIndex, cmd}): OmniboxOption => {
621      const {segments, id, defaultHotkey} = cmd;
622      return {
623        key: id,
624        displayName: segments,
625        tag: recentsIndex !== -1 ? 'recently used' : undefined,
626        rightContent: defaultHotkey && m(HotkeyGlyphs, {hotkey: defaultHotkey}),
627      };
628    });
629
630    return m(Omnibox, {
631      value: omnibox.text,
632      placeholder: 'Filter commands...',
633      inputRef: OMNIBOX_INPUT_REF,
634      extraClasses: 'command-mode',
635      options,
636      closeOnSubmit: true,
637      closeOnOutsideClick: true,
638      selectedOptionIndex: omnibox.selectionIndex,
639      onSelectedOptionChanged: (index) => {
640        omnibox.setSelectionIndex(index);
641      },
642      onInput: (value) => {
643        omnibox.setText(value);
644        omnibox.setSelectionIndex(0);
645      },
646      onClose: () => {
647        if (this.omniboxInputEl) {
648          this.omniboxInputEl.blur();
649        }
650        omnibox.reset();
651      },
652      onSubmit: (key: string) => {
653        this.addRecentCommand(key);
654        commands.runCommand(key);
655      },
656      onGoBack: () => {
657        omnibox.reset();
658      },
659    });
660  }
661
662  private addRecentCommand(id: string): void {
663    this.recentCommands = this.recentCommands.filter((x) => x !== id);
664    this.recentCommands.push(id);
665    while (this.recentCommands.length > 6) {
666      this.recentCommands.shift();
667    }
668  }
669
670  renderQueryOmnibox(): m.Children {
671    const ph = 'e.g. select * from sched left join thread using(utid) limit 10';
672    return m(Omnibox, {
673      value: AppImpl.instance.omnibox.text,
674      placeholder: ph,
675      inputRef: OMNIBOX_INPUT_REF,
676      extraClasses: 'query-mode',
677
678      onInput: (value) => {
679        AppImpl.instance.omnibox.setText(value);
680      },
681      onSubmit: (query, alt) => {
682        const config = {
683          query: undoCommonChatAppReplacements(query),
684          title: alt ? 'Pinned query' : 'Omnibox query',
685        };
686        const tag = alt ? undefined : 'omnibox_query';
687        if (this.trace === undefined) return; // No trace loaded
688        addQueryResultsTab(this.trace, config, tag);
689      },
690      onClose: () => {
691        AppImpl.instance.omnibox.setText('');
692        if (this.omniboxInputEl) {
693          this.omniboxInputEl.blur();
694        }
695        AppImpl.instance.omnibox.reset();
696      },
697      onGoBack: () => {
698        AppImpl.instance.omnibox.reset();
699      },
700    });
701  }
702
703  renderSearchOmnibox(): m.Children {
704    return m(Omnibox, {
705      value: AppImpl.instance.omnibox.text,
706      placeholder: "Search or type '>' for commands or ':' for SQL mode",
707      inputRef: OMNIBOX_INPUT_REF,
708      onInput: (value, _prev) => {
709        if (value === '>') {
710          AppImpl.instance.omnibox.setMode(OmniboxMode.Command);
711          return;
712        } else if (value === ':') {
713          AppImpl.instance.omnibox.setMode(OmniboxMode.Query);
714          return;
715        }
716        AppImpl.instance.omnibox.setText(value);
717        if (this.trace === undefined) return; // No trace loaded.
718        if (value.length >= 4) {
719          this.trace.search.search(value);
720        } else {
721          this.trace.search.reset();
722        }
723      },
724      onClose: () => {
725        if (this.omniboxInputEl) {
726          this.omniboxInputEl.blur();
727        }
728      },
729      onSubmit: (value, _mod, shift) => {
730        if (this.trace === undefined) return; // No trace loaded.
731        this.trace.search.search(value);
732        if (shift) {
733          this.trace.search.stepBackwards();
734        } else {
735          this.trace.search.stepForward();
736        }
737        if (this.omniboxInputEl) {
738          this.omniboxInputEl.blur();
739        }
740      },
741      rightContent: this.renderStepThrough(),
742    });
743  }
744
745  private renderStepThrough() {
746    const children = [];
747    const results = this.trace?.search.searchResults;
748    if (this.trace?.search.searchInProgress) {
749      children.push(m('.current', m(Spinner)));
750    } else if (results !== undefined) {
751      const searchMgr = assertExists(this.trace).search;
752      const index = searchMgr.resultIndex;
753      const total = results.totalResults ?? 0;
754      children.push(
755        m('.current', `${total === 0 ? '0 / 0' : `${index + 1} / ${total}`}`),
756        m(
757          'button',
758          {
759            onclick: () => searchMgr.stepBackwards(),
760          },
761          m('i.material-icons.left', 'keyboard_arrow_left'),
762        ),
763        m(
764          'button',
765          {
766            onclick: () => searchMgr.stepForward(),
767          },
768          m('i.material-icons.right', 'keyboard_arrow_right'),
769        ),
770      );
771    }
772    return m('.stepthrough', children);
773  }
774
775  oncreate(vnode: m.VnodeDOM) {
776    this.updateOmniboxInputRef(vnode.dom);
777    this.maybeFocusOmnibar();
778  }
779
780  view(): m.Children {
781    const app = AppImpl.instance;
782    const hotkeys: HotkeyConfig[] = [];
783    for (const {id, defaultHotkey} of app.commands.commands) {
784      if (defaultHotkey) {
785        hotkeys.push({
786          callback: () => app.commands.runCommand(id),
787          hotkey: defaultHotkey,
788        });
789      }
790    }
791
792    return m(
793      HotkeyContext,
794      {hotkeys},
795      m(
796        'main',
797        m(Sidebar, {trace: this.trace}),
798        m(Topbar, {
799          omnibox: this.renderOmnibox(),
800          trace: this.trace,
801        }),
802        app.pages.renderPageForCurrentRoute(app.trace),
803        m(CookieConsent),
804        maybeRenderFullscreenModalDialog(),
805        app.perfDebugging.renderPerfStats(),
806      ),
807    );
808  }
809
810  onupdate({dom}: m.VnodeDOM) {
811    this.updateOmniboxInputRef(dom);
812    this.maybeFocusOmnibar();
813  }
814
815  onremove(_: m.VnodeDOM) {
816    this.omniboxInputEl = undefined;
817
818    // NOTE: if this becomes ever an asyncDispose(), then the promise needs to
819    // be returned to onbeforeremove, so mithril delays the removal until
820    // the promise is resolved, but then also the UiMain wrapper needs to be
821    // more complex to linearize the destruction of the old instane with the
822    // creation of the new one, without overlaps.
823    // However, we should not add disposables that issue cleanup queries on the
824    // Engine. Doing so is: (1) useless: we throw away the whole wasm instance
825    // on each trace load, so what's the point of deleting tables from a TP
826    // instance that is going to be destroyed?; (2) harmful: we don't have
827    // precise linearization with the wasm teardown, so we might end up awaiting
828    // forever for the asyncDispose() because the query will never run.
829    this.trash.dispose();
830  }
831
832  private updateOmniboxInputRef(dom: Element): void {
833    const el = findRef(dom, OMNIBOX_INPUT_REF);
834    if (el && el instanceof HTMLInputElement) {
835      this.omniboxInputEl = el;
836    }
837  }
838
839  private maybeFocusOmnibar() {
840    if (AppImpl.instance.omnibox.focusOmniboxNextRender) {
841      const omniboxEl = this.omniboxInputEl;
842      if (omniboxEl) {
843        omniboxEl.focus();
844        if (AppImpl.instance.omnibox.pendingCursorPlacement === undefined) {
845          omniboxEl.select();
846        } else {
847          omniboxEl.setSelectionRange(
848            AppImpl.instance.omnibox.pendingCursorPlacement,
849            AppImpl.instance.omnibox.pendingCursorPlacement,
850          );
851        }
852      }
853      AppImpl.instance.omnibox.clearFocusFlag();
854    }
855  }
856
857  private async maybeShowJsonWarning() {
858    // Show warning if the trace is in JSON format.
859    const isJsonTrace = this.trace?.traceInfo.traceType === 'json';
860    const SHOWN_JSON_WARNING_KEY = 'shownJsonWarning';
861
862    if (
863      !isJsonTrace ||
864      window.localStorage.getItem(SHOWN_JSON_WARNING_KEY) === 'true' ||
865      AppImpl.instance.embeddedMode
866    ) {
867      // When in embedded mode, the host app will control which trace format
868      // it passes to Perfetto, so we don't need to show this warning.
869      return;
870    }
871
872    // Save that the warning has been shown. Value is irrelevant since only
873    // the presence of key is going to be checked.
874    window.localStorage.setItem(SHOWN_JSON_WARNING_KEY, 'true');
875
876    showModal({
877      title: 'Warning',
878      content: m(
879        'div',
880        m(
881          'span',
882          'Perfetto UI features are limited for JSON traces. ',
883          'We recommend recording ',
884          m(
885            'a',
886            {href: 'https://perfetto.dev/docs/quickstart/chrome-tracing'},
887            'proto-format traces',
888          ),
889          ' from Chrome.',
890        ),
891        m('br'),
892      ),
893      buttons: [],
894    });
895  }
896}
897