• 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';
16
17import {copyToClipboard} from '../base/clipboard';
18import {DisposableStack} from '../base/disposable';
19import {findRef} from '../base/dom_utils';
20import {FuzzyFinder} from '../base/fuzzy';
21import {assertExists, assertUnreachable} from '../base/logging';
22import {undoCommonChatAppReplacements} from '../base/string_utils';
23import {Actions} from '../common/actions';
24import {
25  DurationPrecision,
26  setDurationPrecision,
27  setTimestampFormat,
28  TimestampFormat,
29} from '../core/timestamp_format';
30import {raf} from '../core/raf_scheduler';
31import {Command, Engine, addDebugSliceTrack} from '../public';
32import {HotkeyConfig, HotkeyContext} from '../widgets/hotkey_context';
33import {HotkeyGlyphs} from '../widgets/hotkey_glyphs';
34import {maybeRenderFullscreenModalDialog} from '../widgets/modal';
35
36import {onClickCopy} from './clipboard';
37import {CookieConsent} from './cookie_consent';
38import {getTimeSpanOfSelectionOrVisibleWindow, globals} from './globals';
39import {toggleHelp} from './help_modal';
40import {Notes} from './notes';
41import {Omnibox, OmniboxOption} from './omnibox';
42import {addQueryResultsTab} from './query_result_tab';
43import {executeSearch} from './search_handler';
44import {Sidebar} from './sidebar';
45import {Topbar} from './topbar';
46import {shareTrace} from './trace_attrs';
47import {AggregationsTabs} from './aggregation_tab';
48import {addSqlTableTab} from './sql_table/tab';
49import {SqlTables} from './sql_table/well_known_tables';
50import {
51  findCurrentSelection,
52  focusOtherFlow,
53  moveByFocusedFlow,
54} from './keyboard_event_handler';
55import {publishPermalinkHash} from './publish';
56import {OmniboxMode, PromptOption} from './omnibox_manager';
57import {Utid} from './sql_types';
58import {getThreadInfo} from './thread_and_process_info';
59import {THREAD_STATE_TRACK_KIND} from '../core/track_kinds';
60
61function renderPermalink(): m.Children {
62  const hash = globals.permalinkHash;
63  if (!hash) return null;
64  const url = `${self.location.origin}/#!/?s=${hash}`;
65  const linkProps = {title: 'Click to copy the URL', onclick: onClickCopy(url)};
66
67  return m('.alert-permalink', [
68    m('div', 'Permalink: ', m(`a[href=${url}]`, linkProps, url)),
69    m(
70      'button',
71      {
72        onclick: () => publishPermalinkHash(undefined),
73      },
74      m('i.material-icons.disallow-selection', 'close'),
75    ),
76  ]);
77}
78
79class Alerts implements m.ClassComponent {
80  view() {
81    return m('.alerts', renderPermalink());
82  }
83}
84
85const criticalPathSliceColumns = {
86  ts: 'ts',
87  dur: 'dur',
88  name: 'name',
89};
90const criticalPathsliceColumnNames = [
91  'id',
92  'utid',
93  'ts',
94  'dur',
95  'name',
96  'table_name',
97];
98
99const criticalPathsliceLiteColumns = {
100  ts: 'ts',
101  dur: 'dur',
102  name: 'thread_name',
103};
104const criticalPathsliceLiteColumnNames = [
105  'id',
106  'utid',
107  'ts',
108  'dur',
109  'thread_name',
110  'process_name',
111  'table_name',
112];
113
114export class App implements m.ClassComponent {
115  private trash = new DisposableStack();
116  static readonly OMNIBOX_INPUT_REF = 'omnibox';
117  private omniboxInputEl?: HTMLInputElement;
118  private recentCommands: string[] = [];
119
120  constructor() {
121    this.trash.use(new Notes());
122    this.trash.use(new AggregationsTabs());
123  }
124
125  private getEngine(): Engine | undefined {
126    const engineId = globals.getCurrentEngine()?.id;
127    if (engineId === undefined) {
128      return undefined;
129    }
130    const engine = globals.engines.get(engineId)?.getProxy('QueryPage');
131    return engine;
132  }
133
134  private getFirstUtidOfSelectionOrVisibleWindow(): number {
135    const selection = globals.state.selection;
136    if (selection.kind === 'area') {
137      const firstThreadStateTrack = selection.tracks.find((trackId) => {
138        return globals.state.tracks[trackId];
139      });
140
141      if (firstThreadStateTrack) {
142        const trackInfo = globals.state.tracks[firstThreadStateTrack];
143        const trackDesc = globals.trackManager.resolveTrackInfo(trackInfo.uri);
144        if (
145          trackDesc?.kind === THREAD_STATE_TRACK_KIND &&
146          trackDesc?.utid !== undefined
147        ) {
148          return trackDesc?.utid;
149        }
150      }
151    }
152
153    return 0;
154  }
155
156  private cmds: Command[] = [
157    {
158      id: 'perfetto.SetTimestampFormat',
159      name: 'Set timestamp and duration format',
160      callback: async () => {
161        const options: PromptOption[] = [
162          {key: TimestampFormat.Timecode, displayName: 'Timecode'},
163          {key: TimestampFormat.UTC, displayName: 'Realtime (UTC)'},
164          {
165            key: TimestampFormat.TraceTz,
166            displayName: 'Realtime (Trace TZ)',
167          },
168          {key: TimestampFormat.Seconds, displayName: 'Seconds'},
169          {key: TimestampFormat.Raw, displayName: 'Raw'},
170          {
171            key: TimestampFormat.RawLocale,
172            displayName: 'Raw (with locale-specific formatting)',
173          },
174        ];
175        const promptText = 'Select format...';
176
177        try {
178          const result = await globals.omnibox.prompt(promptText, options);
179          setTimestampFormat(result as TimestampFormat);
180          raf.scheduleFullRedraw();
181        } catch {
182          // Prompt was probably cancelled - do nothing.
183        }
184      },
185    },
186    {
187      id: 'perfetto.SetDurationPrecision',
188      name: 'Set duration precision',
189      callback: async () => {
190        const options: PromptOption[] = [
191          {key: DurationPrecision.Full, displayName: 'Full'},
192          {
193            key: DurationPrecision.HumanReadable,
194            displayName: 'Human readable',
195          },
196        ];
197        const promptText = 'Select duration precision mode...';
198
199        try {
200          const result = await globals.omnibox.prompt(promptText, options);
201          setDurationPrecision(result as DurationPrecision);
202          raf.scheduleFullRedraw();
203        } catch {
204          // Prompt was probably cancelled - do nothing.
205        }
206      },
207    },
208    {
209      id: 'perfetto.CriticalPathLite',
210      name: `Critical path lite`,
211      callback: async () => {
212        const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow();
213        const window = await getTimeSpanOfSelectionOrVisibleWindow();
214        const engine = this.getEngine();
215
216        if (engine !== undefined && trackUtid != 0) {
217          await engine.query(
218            `INCLUDE PERFETTO MODULE sched.thread_executing_span;`,
219          );
220          await addDebugSliceTrack(
221            // NOTE(stevegolton): This is a temporary patch, this menu should
222            // become part of a critical path plugin, at which point we can just
223            // use the plugin's context object.
224            {
225              engine,
226              registerTrack: (x) => globals.trackManager.registerTrack(x),
227            },
228            {
229              sqlSource: `
230                   SELECT
231                      cr.id,
232                      cr.utid,
233                      cr.ts,
234                      cr.dur,
235                      thread.name AS thread_name,
236                      process.name AS process_name,
237                      'thread_state' AS table_name
238                    FROM
239                      _thread_executing_span_critical_path(
240                          ${trackUtid},
241                          ${window.start},
242                          ${window.end} - ${window.start}) cr
243                    JOIN thread USING(utid)
244                    JOIN process USING(upid)
245                  `,
246              columns: criticalPathsliceLiteColumnNames,
247            },
248            (await getThreadInfo(engine, trackUtid as Utid)).name ??
249              '<thread name>',
250            criticalPathsliceLiteColumns,
251            criticalPathsliceLiteColumnNames,
252          );
253        }
254      },
255    },
256    {
257      id: 'perfetto.CriticalPath',
258      name: `Critical path`,
259      callback: async () => {
260        const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow();
261        const window = await getTimeSpanOfSelectionOrVisibleWindow();
262        const engine = this.getEngine();
263
264        if (engine !== undefined && trackUtid != 0) {
265          await engine.query(
266            `INCLUDE PERFETTO MODULE sched.thread_executing_span_with_slice;`,
267          );
268          await addDebugSliceTrack(
269            // NOTE(stevegolton): This is a temporary patch, this menu should
270            // become part of a critical path plugin, at which point we can just
271            // use the plugin's context object.
272            {
273              engine,
274              registerTrack: (x) => globals.trackManager.registerTrack(x),
275            },
276            {
277              sqlSource: `
278                        SELECT cr.id, cr.utid, cr.ts, cr.dur, cr.name, cr.table_name
279                        FROM
280                        _critical_path_stack(
281                          ${trackUtid},
282                          ${window.start},
283                          ${window.end} - ${window.start}, 1, 1, 1, 1) cr WHERE name IS NOT NULL
284                  `,
285              columns: criticalPathsliceColumnNames,
286            },
287            (await getThreadInfo(engine, trackUtid as Utid)).name ??
288              '<thread name>',
289            criticalPathSliceColumns,
290            criticalPathsliceColumnNames,
291          );
292        }
293      },
294    },
295    {
296      id: 'perfetto.CriticalPathPprof',
297      name: `Critical path pprof`,
298      callback: async () => {
299        const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow();
300        const window = await getTimeSpanOfSelectionOrVisibleWindow();
301        const engine = this.getEngine();
302
303        if (engine !== undefined && trackUtid != 0) {
304          addQueryResultsTab({
305            query: `INCLUDE PERFETTO MODULE sched.thread_executing_span_with_slice;
306                   SELECT *
307                      FROM
308                        _thread_executing_span_critical_path_graph(
309                        "criical_path",
310                         ${trackUtid},
311                         ${window.start},
312                         ${window.end} - ${window.start}) cr`,
313            title: 'Critical path',
314          });
315        }
316      },
317    },
318    {
319      id: 'perfetto.ShowSliceTable',
320      name: 'Open new slice table tab',
321      callback: () => {
322        addSqlTableTab({
323          table: SqlTables.slice,
324          displayName: 'slice',
325        });
326      },
327    },
328    {
329      id: 'perfetto.TogglePerformanceMetrics',
330      name: 'Toggle performance metrics',
331      callback: () => {
332        globals.dispatch(Actions.togglePerfDebug({}));
333      },
334    },
335    {
336      id: 'perfetto.ShareTrace',
337      name: 'Share trace',
338      callback: shareTrace,
339    },
340    {
341      id: 'perfetto.SearchNext',
342      name: 'Go to next search result',
343      callback: () => {
344        executeSearch();
345      },
346      defaultHotkey: 'Enter',
347    },
348    {
349      id: 'perfetto.SearchPrev',
350      name: 'Go to previous search result',
351      callback: () => {
352        executeSearch(true);
353      },
354      defaultHotkey: 'Shift+Enter',
355    },
356    {
357      id: 'perfetto.OpenCommandPalette',
358      name: 'Open command palette',
359      callback: () => globals.omnibox.setMode(OmniboxMode.Command),
360      defaultHotkey: '!Mod+Shift+P',
361    },
362    {
363      id: 'perfetto.RunQuery',
364      name: 'Run query',
365      callback: () => globals.omnibox.setMode(OmniboxMode.Query),
366      defaultHotkey: '!Mod+O',
367    },
368    {
369      id: 'perfetto.Search',
370      name: 'Search',
371      callback: () => globals.omnibox.setMode(OmniboxMode.Search),
372      defaultHotkey: '/',
373    },
374    {
375      id: 'perfetto.ShowHelp',
376      name: 'Show help',
377      callback: () => toggleHelp(),
378      defaultHotkey: '?',
379    },
380    {
381      id: 'perfetto.CopyTimeWindow',
382      name: `Copy selected time window to clipboard`,
383      callback: async () => {
384        const window = await getTimeSpanOfSelectionOrVisibleWindow();
385        const query = `ts >= ${window.start} and ts < ${window.end}`;
386        copyToClipboard(query);
387      },
388    },
389    {
390      id: 'perfetto.FocusSelection',
391      name: 'Focus current selection',
392      callback: () => findCurrentSelection(),
393      defaultHotkey: 'F',
394    },
395    {
396      id: 'perfetto.Deselect',
397      name: 'Deselect',
398      callback: () => {
399        globals.timeline.deselectArea();
400        globals.clearSelection();
401        globals.dispatch(Actions.removeNote({id: '0'}));
402      },
403      defaultHotkey: 'Escape',
404    },
405    {
406      id: 'perfetto.SetTemporarySpanNote',
407      name: 'Set the temporary span note based on the current selection',
408      callback: async () => {
409        const range = await globals.findTimeRangeOfSelection();
410        if (range) {
411          globals.dispatch(
412            Actions.addSpanNote({
413              start: range.start,
414              end: range.end,
415              id: '__temp__',
416            }),
417          );
418        }
419      },
420      defaultHotkey: 'M',
421    },
422    {
423      id: 'perfetto.AddSpanNote',
424      name: 'Add a new span note based on the current selection',
425      callback: async () => {
426        const range = await globals.findTimeRangeOfSelection();
427        if (range) {
428          globals.dispatch(
429            Actions.addSpanNote({start: range.start, end: range.end}),
430          );
431        }
432      },
433      defaultHotkey: 'Shift+M',
434    },
435    {
436      id: 'perfetto.RemoveSelectedNote',
437      name: 'Remove selected note',
438      callback: () => {
439        const selection = globals.state.selection;
440        if (selection.kind === 'note') {
441          globals.dispatch(
442            Actions.removeNote({
443              id: selection.id,
444            }),
445          );
446        }
447      },
448      defaultHotkey: 'Delete',
449    },
450    {
451      id: 'perfetto.NextFlow',
452      name: 'Next flow',
453      callback: () => focusOtherFlow('Forward'),
454      defaultHotkey: 'Mod+]',
455    },
456    {
457      id: 'perfetto.PrevFlow',
458      name: 'Prev flow',
459      callback: () => focusOtherFlow('Backward'),
460      defaultHotkey: 'Mod+[',
461    },
462    {
463      id: 'perfetto.MoveNextFlow',
464      name: 'Move next flow',
465      callback: () => moveByFocusedFlow('Forward'),
466      defaultHotkey: ']',
467    },
468    {
469      id: 'perfetto.MovePrevFlow',
470      name: 'Move prev flow',
471      callback: () => moveByFocusedFlow('Backward'),
472      defaultHotkey: '[',
473    },
474    {
475      id: 'perfetto.SelectAll',
476      name: 'Select all',
477      callback: () => {
478        // This is a dual state command:
479        // - If one ore more tracks are already area selected, expand the time
480        //   range to include the entire trace, but keep the selection on just
481        //   these tracks.
482        // - If nothing is selected, or all selected tracks are entirely
483        //   selected, then select the entire trace. This allows double tapping
484        //   Ctrl+A to select the entire track, then select the entire trace.
485        let tracksToSelect: string[] = [];
486        const selection = globals.state.selection;
487        if (selection.kind === 'area') {
488          // Something is already selected, let's see if it covers the entire
489          // span of the trace or not
490          const coversEntireTimeRange =
491            globals.traceContext.start === selection.start &&
492            globals.traceContext.end === selection.end;
493          if (!coversEntireTimeRange) {
494            // If the current selection is an area which does not cover the
495            // entire time range, preserve the list of selected tracks and
496            // expand the time range.
497            tracksToSelect = selection.tracks;
498          } else {
499            // If the entire time range is already covered, update the selection
500            // to cover all tracks.
501            tracksToSelect = Object.keys(globals.state.tracks);
502          }
503        } else {
504          // If the current selection is not an area, select all.
505          tracksToSelect = Object.keys(globals.state.tracks);
506        }
507        const {start, end} = globals.traceContext;
508        globals.dispatch(
509          Actions.selectArea({
510            start,
511            end,
512            tracks: tracksToSelect,
513          }),
514        );
515      },
516      defaultHotkey: 'Mod+A',
517    },
518  ];
519
520  commands() {
521    return this.cmds;
522  }
523
524  private renderOmnibox(): m.Children {
525    const msgTTL = globals.state.status.timestamp + 1 - Date.now() / 1e3;
526    const engineIsBusy =
527      globals.state.engine !== undefined && !globals.state.engine.ready;
528
529    if (msgTTL > 0 || engineIsBusy) {
530      setTimeout(() => raf.scheduleFullRedraw(), msgTTL * 1000);
531      return m(
532        `.omnibox.message-mode`,
533        m(`input[readonly][disabled][ref=omnibox]`, {
534          value: '',
535          placeholder: globals.state.status.msg,
536        }),
537      );
538    }
539
540    const omniboxMode = globals.omnibox.omniboxMode;
541
542    if (omniboxMode === OmniboxMode.Command) {
543      return this.renderCommandOmnibox();
544    } else if (omniboxMode === OmniboxMode.Prompt) {
545      return this.renderPromptOmnibox();
546    } else if (omniboxMode === OmniboxMode.Query) {
547      return this.renderQueryOmnibox();
548    } else if (omniboxMode === OmniboxMode.Search) {
549      return this.renderSearchOmnibox();
550    } else {
551      assertUnreachable(omniboxMode);
552    }
553  }
554
555  renderPromptOmnibox(): m.Children {
556    const prompt = assertExists(globals.omnibox.pendingPrompt);
557
558    let options: OmniboxOption[] | undefined = undefined;
559
560    if (prompt.options) {
561      const fuzzy = new FuzzyFinder(
562        prompt.options,
563        ({displayName}) => displayName,
564      );
565      const result = fuzzy.find(globals.omnibox.text);
566      options = result.map((result) => {
567        return {
568          key: result.item.key,
569          displayName: result.segments,
570        };
571      });
572    }
573
574    return m(Omnibox, {
575      value: globals.omnibox.text,
576      placeholder: prompt.text,
577      inputRef: App.OMNIBOX_INPUT_REF,
578      extraClasses: 'prompt-mode',
579      closeOnOutsideClick: true,
580      options,
581      selectedOptionIndex: globals.omnibox.omniboxSelectionIndex,
582      onSelectedOptionChanged: (index) => {
583        globals.omnibox.setOmniboxSelectionIndex(index);
584        raf.scheduleFullRedraw();
585      },
586      onInput: (value) => {
587        globals.omnibox.setText(value);
588        globals.omnibox.setOmniboxSelectionIndex(0);
589        raf.scheduleFullRedraw();
590      },
591      onSubmit: (value, _alt) => {
592        globals.omnibox.resolvePrompt(value);
593      },
594      onClose: () => {
595        globals.omnibox.rejectPrompt();
596      },
597    });
598  }
599
600  renderCommandOmnibox(): m.Children {
601    const cmdMgr = globals.commandManager;
602
603    // Fuzzy-filter commands by the filter string.
604    const filteredCmds = cmdMgr.fuzzyFilterCommands(globals.omnibox.text);
605
606    // Create an array of commands with attached heuristics from the recent
607    // command register.
608    const commandsWithHeuristics = filteredCmds.map((cmd) => {
609      return {
610        recentsIndex: this.recentCommands.findIndex((id) => id === cmd.id),
611        cmd,
612      };
613    });
614
615    // Sort by recentsIndex then by alphabetical order
616    const sorted = commandsWithHeuristics.sort((a, b) => {
617      if (b.recentsIndex === a.recentsIndex) {
618        return a.cmd.name.localeCompare(b.cmd.name);
619      } else {
620        return b.recentsIndex - a.recentsIndex;
621      }
622    });
623
624    const options = sorted.map(({recentsIndex, cmd}): OmniboxOption => {
625      const {segments, id, defaultHotkey} = cmd;
626      return {
627        key: id,
628        displayName: segments,
629        tag: recentsIndex !== -1 ? 'recently used' : undefined,
630        rightContent: defaultHotkey && m(HotkeyGlyphs, {hotkey: defaultHotkey}),
631      };
632    });
633
634    return m(Omnibox, {
635      value: globals.omnibox.text,
636      placeholder: 'Filter commands...',
637      inputRef: App.OMNIBOX_INPUT_REF,
638      extraClasses: 'command-mode',
639      options,
640      closeOnSubmit: true,
641      closeOnOutsideClick: true,
642      selectedOptionIndex: globals.omnibox.omniboxSelectionIndex,
643      onSelectedOptionChanged: (index) => {
644        globals.omnibox.setOmniboxSelectionIndex(index);
645        raf.scheduleFullRedraw();
646      },
647      onInput: (value) => {
648        globals.omnibox.setText(value);
649        globals.omnibox.setOmniboxSelectionIndex(0);
650        raf.scheduleFullRedraw();
651      },
652      onClose: () => {
653        if (this.omniboxInputEl) {
654          this.omniboxInputEl.blur();
655        }
656        globals.omnibox.reset();
657      },
658      onSubmit: (key: string) => {
659        this.addRecentCommand(key);
660        cmdMgr.runCommand(key);
661      },
662      onGoBack: () => {
663        globals.omnibox.reset();
664      },
665    });
666  }
667
668  private addRecentCommand(id: string): void {
669    this.recentCommands = this.recentCommands.filter((x) => x !== id);
670    this.recentCommands.push(id);
671    while (this.recentCommands.length > 6) {
672      this.recentCommands.shift();
673    }
674  }
675
676  renderQueryOmnibox(): m.Children {
677    const ph = 'e.g. select * from sched left join thread using(utid) limit 10';
678    return m(Omnibox, {
679      value: globals.omnibox.text,
680      placeholder: ph,
681      inputRef: App.OMNIBOX_INPUT_REF,
682      extraClasses: 'query-mode',
683
684      onInput: (value) => {
685        globals.omnibox.setText(value);
686        raf.scheduleFullRedraw();
687      },
688      onSubmit: (query, alt) => {
689        const config = {
690          query: undoCommonChatAppReplacements(query),
691          title: alt ? 'Pinned query' : 'Omnibox query',
692        };
693        const tag = alt ? undefined : 'omnibox_query';
694        addQueryResultsTab(config, tag);
695      },
696      onClose: () => {
697        globals.omnibox.setText('');
698        if (this.omniboxInputEl) {
699          this.omniboxInputEl.blur();
700        }
701        globals.omnibox.reset();
702        raf.scheduleFullRedraw();
703      },
704      onGoBack: () => {
705        globals.omnibox.reset();
706      },
707    });
708  }
709
710  renderSearchOmnibox(): m.Children {
711    const omniboxState = globals.state.omniboxState;
712    const displayStepThrough =
713      omniboxState.omnibox.length >= 4 || omniboxState.force;
714
715    return m(Omnibox, {
716      value: globals.state.omniboxState.omnibox,
717      placeholder: "Search or type '>' for commands or ':' for SQL mode",
718      inputRef: App.OMNIBOX_INPUT_REF,
719      onInput: (value, prev) => {
720        if (prev === '') {
721          if (value === '>') {
722            globals.omnibox.setMode(OmniboxMode.Command);
723            return;
724          } else if (value === ':') {
725            globals.omnibox.setMode(OmniboxMode.Query);
726            return;
727          }
728        }
729        globals.dispatch(Actions.setOmnibox({omnibox: value, mode: 'SEARCH'}));
730      },
731      onClose: () => {
732        if (this.omniboxInputEl) {
733          this.omniboxInputEl.blur();
734        }
735      },
736      onSubmit: (value, _mod, shift) => {
737        executeSearch(shift);
738        globals.dispatch(
739          Actions.setOmnibox({omnibox: value, mode: 'SEARCH', force: true}),
740        );
741        if (this.omniboxInputEl) {
742          this.omniboxInputEl.blur();
743        }
744      },
745      rightContent: displayStepThrough && this.renderStepThrough(),
746    });
747  }
748
749  private renderStepThrough() {
750    return m(
751      '.stepthrough',
752      m(
753        '.current',
754        `${
755          globals.currentSearchResults.totalResults === 0
756            ? '0 / 0'
757            : `${globals.state.searchIndex + 1} / ${
758                globals.currentSearchResults.totalResults
759              }`
760        }`,
761      ),
762      m(
763        'button',
764        {
765          onclick: () => {
766            executeSearch(true /* reverse direction */);
767          },
768        },
769        m('i.material-icons.left', 'keyboard_arrow_left'),
770      ),
771      m(
772        'button',
773        {
774          onclick: () => {
775            executeSearch();
776          },
777        },
778        m('i.material-icons.right', 'keyboard_arrow_right'),
779      ),
780    );
781  }
782
783  view({children}: m.Vnode): m.Children {
784    const hotkeys: HotkeyConfig[] = [];
785    const commands = globals.commandManager.commands;
786    for (const {id, defaultHotkey} of commands) {
787      if (defaultHotkey) {
788        hotkeys.push({
789          callback: () => {
790            globals.commandManager.runCommand(id);
791          },
792          hotkey: defaultHotkey,
793        });
794      }
795    }
796
797    return m(
798      HotkeyContext,
799      {hotkeys},
800      m(
801        'main',
802        m(Sidebar),
803        m(Topbar, {
804          omnibox: this.renderOmnibox(),
805        }),
806        m(Alerts),
807        children,
808        m(CookieConsent),
809        maybeRenderFullscreenModalDialog(),
810        globals.state.perfDebug && m('.perf-stats'),
811      ),
812    );
813  }
814
815  oncreate({dom}: m.VnodeDOM) {
816    this.updateOmniboxInputRef(dom);
817    this.maybeFocusOmnibar();
818
819    // Register each command with the command manager
820    this.cmds.forEach((cmd) => {
821      const dispose = globals.commandManager.registerCommand(cmd);
822      this.trash.use(dispose);
823    });
824  }
825
826  onupdate({dom}: m.VnodeDOM) {
827    this.updateOmniboxInputRef(dom);
828    this.maybeFocusOmnibar();
829  }
830
831  onremove(_: m.VnodeDOM) {
832    this.trash.dispose();
833    this.omniboxInputEl = undefined;
834  }
835
836  private updateOmniboxInputRef(dom: Element): void {
837    const el = findRef(dom, App.OMNIBOX_INPUT_REF);
838    if (el && el instanceof HTMLInputElement) {
839      this.omniboxInputEl = el;
840    }
841  }
842
843  private maybeFocusOmnibar() {
844    if (globals.omnibox.focusOmniboxNextRender) {
845      const omniboxEl = this.omniboxInputEl;
846      if (omniboxEl) {
847        omniboxEl.focus();
848        if (globals.omnibox.pendingCursorPlacement === undefined) {
849          omniboxEl.select();
850        } else {
851          omniboxEl.setSelectionRange(
852            globals.omnibox.pendingCursorPlacement,
853            globals.omnibox.pendingCursorPlacement,
854          );
855        }
856      }
857      globals.omnibox.clearOmniboxFocusFlag();
858    }
859  }
860}
861