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