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