| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import m from 'mithril'; |
| |
| import {copyToClipboard} from '../base/clipboard'; |
| import {Trash} from '../base/disposable'; |
| import {findRef} from '../base/dom_utils'; |
| import {FuzzyFinder} from '../base/fuzzy'; |
| import {assertExists} from '../base/logging'; |
| import {undoCommonChatAppReplacements} from '../base/string_utils'; |
| import {duration, Span, Time, time, TimeSpan} from '../base/time'; |
| import {Actions} from '../common/actions'; |
| import {getLegacySelection} from '../common/state'; |
| import {runQuery} from '../common/queries'; |
| import { |
| DurationPrecision, |
| setDurationPrecision, |
| setTimestampFormat, |
| TimestampFormat, |
| } from '../core/timestamp_format'; |
| import {raf} from '../core/raf_scheduler'; |
| import {Command} from '../public'; |
| import {EngineProxy} from '../trace_processor/engine'; |
| import {THREAD_STATE_TRACK_KIND} from '../tracks/thread_state'; |
| import {HotkeyConfig, HotkeyContext} from '../widgets/hotkey_context'; |
| import {HotkeyGlyphs} from '../widgets/hotkey_glyphs'; |
| import {maybeRenderFullscreenModalDialog} from '../widgets/modal'; |
| |
| import {onClickCopy} from './clipboard'; |
| import {CookieConsent} from './cookie_consent'; |
| import {globals} from './globals'; |
| import {toggleHelp} from './help_modal'; |
| import {Notes} from './notes'; |
| import {Omnibox, OmniboxOption} from './omnibox'; |
| import {addQueryResultsTab} from './query_result_tab'; |
| import {verticalScrollToTrack} from './scroll_helper'; |
| import {executeSearch} from './search_handler'; |
| import {Sidebar} from './sidebar'; |
| import {Utid} from './sql_types'; |
| import {getThreadInfo} from './thread_and_process_info'; |
| import {Topbar} from './topbar'; |
| import {shareTrace} from './trace_attrs'; |
| import {addDebugSliceTrack} from './debug_tracks'; |
| import {AggregationsTabs} from './aggregation_tab'; |
| import {addSqlTableTab} from './sql_table/tab'; |
| import {SqlTables} from './sql_table/well_known_tables'; |
| import { |
| findCurrentSelection, |
| focusOtherFlow, |
| lockSliceSpan, |
| moveByFocusedFlow, |
| } from './keyboard_event_handler'; |
| |
| function renderPermalink(): m.Children { |
| const permalink = globals.state.permalink; |
| if (!permalink.requestId || !permalink.hash) return null; |
| const url = `${self.location.origin}/#!/?s=${permalink.hash}`; |
| const linkProps = {title: 'Click to copy the URL', onclick: onClickCopy(url)}; |
| |
| return m('.alert-permalink', [ |
| m('div', 'Permalink: ', m(`a[href=${url}]`, linkProps, url)), |
| m( |
| 'button', |
| { |
| onclick: () => globals.dispatch(Actions.clearPermalink({})), |
| }, |
| m('i.material-icons.disallow-selection', 'close'), |
| ), |
| ]); |
| } |
| |
| class Alerts implements m.ClassComponent { |
| view() { |
| return m('.alerts', renderPermalink()); |
| } |
| } |
| |
| interface PromptOption { |
| key: string; |
| displayName: string; |
| } |
| |
| interface Prompt { |
| text: string; |
| options?: PromptOption[]; |
| resolve(result: string): void; |
| reject(): void; |
| } |
| |
| enum OmniboxMode { |
| Search, |
| Query, |
| Command, |
| Prompt, |
| } |
| |
| const criticalPathSliceColumns = { |
| ts: 'ts', |
| dur: 'dur', |
| name: 'name', |
| }; |
| const criticalPathsliceColumnNames = [ |
| 'id', |
| 'utid', |
| 'ts', |
| 'dur', |
| 'name', |
| 'table_name', |
| ]; |
| |
| const criticalPathsliceLiteColumns = { |
| ts: 'ts', |
| dur: 'dur', |
| name: 'thread_name', |
| }; |
| const criticalPathsliceLiteColumnNames = [ |
| 'id', |
| 'utid', |
| 'ts', |
| 'dur', |
| 'thread_name', |
| 'process_name', |
| 'table_name', |
| ]; |
| |
| export class App implements m.ClassComponent { |
| private trash = new Trash(); |
| |
| private omniboxMode: OmniboxMode = OmniboxMode.Search; |
| private omniboxText = ''; |
| private queryText = ''; |
| private omniboxSelectionIndex = 0; |
| private focusOmniboxNextRender = false; |
| private pendingCursorPlacement = -1; |
| private pendingPrompt?: Prompt; |
| static readonly OMNIBOX_INPUT_REF = 'omnibox'; |
| private omniboxInputEl?: HTMLInputElement; |
| private recentCommands: string[] = []; |
| |
| constructor() { |
| this.trash.add(new Notes()); |
| this.trash.add(new AggregationsTabs()); |
| } |
| |
| private getEngine(): EngineProxy | undefined { |
| const engineId = globals.getCurrentEngine()?.id; |
| if (engineId === undefined) { |
| return undefined; |
| } |
| const engine = globals.engines.get(engineId)?.getProxy('QueryPage'); |
| return engine; |
| } |
| |
| private enterCommandMode(): void { |
| this.omniboxMode = OmniboxMode.Command; |
| this.resetOmnibox(); |
| this.rejectPendingPrompt(); |
| this.focusOmniboxNextRender = true; |
| |
| raf.scheduleFullRedraw(); |
| } |
| |
| private enterQueryMode(): void { |
| this.omniboxMode = OmniboxMode.Query; |
| this.resetOmnibox(); |
| this.rejectPendingPrompt(); |
| this.focusOmniboxNextRender = true; |
| |
| raf.scheduleFullRedraw(); |
| } |
| |
| private enterSearchMode(focusOmnibox: boolean): void { |
| this.omniboxMode = OmniboxMode.Search; |
| this.resetOmnibox(); |
| this.rejectPendingPrompt(); |
| |
| if (focusOmnibox) { |
| this.focusOmniboxNextRender = true; |
| } |
| |
| globals.dispatch(Actions.setOmniboxMode({mode: 'SEARCH'})); |
| |
| raf.scheduleFullRedraw(); |
| } |
| |
| // Start a prompt. If options are supplied, the user must pick one from the |
| // list, otherwise the input is free-form text. |
| private prompt(text: string, options?: PromptOption[]): Promise<string> { |
| this.omniboxMode = OmniboxMode.Prompt; |
| this.resetOmnibox(); |
| this.rejectPendingPrompt(); |
| |
| const promise = new Promise<string>((resolve, reject) => { |
| this.pendingPrompt = { |
| text, |
| options, |
| resolve, |
| reject, |
| }; |
| }); |
| |
| this.focusOmniboxNextRender = true; |
| raf.scheduleFullRedraw(); |
| |
| return promise; |
| } |
| |
| // Resolve the pending prompt with a value to return to the prompter. |
| private resolvePrompt(value: string): void { |
| if (this.pendingPrompt) { |
| this.pendingPrompt.resolve(value); |
| this.pendingPrompt = undefined; |
| } |
| this.enterSearchMode(false); |
| } |
| |
| // Reject the prompt outright. Doing this will force the owner of the prompt |
| // promise to catch, so only do this when things go seriously wrong. |
| // Use |resolvePrompt(null)| to indicate cancellation. |
| private rejectPrompt(): void { |
| if (this.pendingPrompt) { |
| this.pendingPrompt.reject(); |
| this.pendingPrompt = undefined; |
| } |
| this.enterSearchMode(false); |
| } |
| |
| private getFirstUtidOfSelectionOrVisibleWindow(): number { |
| const selection = getLegacySelection(globals.state); |
| if (selection && selection.kind === 'AREA') { |
| const selectedArea = globals.state.areas[selection.areaId]; |
| const firstThreadStateTrack = selectedArea.tracks.find((trackId) => { |
| return globals.state.tracks[trackId]; |
| }); |
| |
| if (firstThreadStateTrack) { |
| const trackInfo = globals.state.tracks[firstThreadStateTrack]; |
| const trackDesc = globals.trackManager.resolveTrackInfo(trackInfo.uri); |
| if ( |
| trackDesc?.kind === THREAD_STATE_TRACK_KIND && |
| trackDesc?.utid !== undefined |
| ) { |
| return trackDesc?.utid; |
| } |
| } |
| } |
| |
| return 0; |
| } |
| |
| private cmds: Command[] = [ |
| { |
| id: 'perfetto.SetTimestampFormat', |
| name: 'Set timestamp and duration format', |
| callback: async () => { |
| const options: PromptOption[] = [ |
| {key: TimestampFormat.Timecode, displayName: 'Timecode'}, |
| {key: TimestampFormat.UTC, displayName: 'Realtime (UTC)'}, |
| { |
| key: TimestampFormat.TraceTz, |
| displayName: 'Realtime (Trace TZ)', |
| }, |
| {key: TimestampFormat.Seconds, displayName: 'Seconds'}, |
| {key: TimestampFormat.Raw, displayName: 'Raw'}, |
| { |
| key: TimestampFormat.RawLocale, |
| displayName: 'Raw (with locale-specific formatting)', |
| }, |
| ]; |
| const promptText = 'Select format...'; |
| |
| try { |
| const result = await this.prompt(promptText, options); |
| setTimestampFormat(result as TimestampFormat); |
| raf.scheduleFullRedraw(); |
| } catch { |
| // Prompt was probably cancelled - do nothing. |
| } |
| }, |
| }, |
| { |
| id: 'perfetto.SetDurationPrecision', |
| name: 'Set duration precision', |
| callback: async () => { |
| const options: PromptOption[] = [ |
| {key: DurationPrecision.Full, displayName: 'Full'}, |
| { |
| key: DurationPrecision.HumanReadable, |
| displayName: 'Human readable', |
| }, |
| ]; |
| const promptText = 'Select duration precision mode...'; |
| |
| try { |
| const result = await this.prompt(promptText, options); |
| setDurationPrecision(result as DurationPrecision); |
| raf.scheduleFullRedraw(); |
| } catch { |
| // Prompt was probably cancelled - do nothing. |
| } |
| }, |
| }, |
| { |
| id: 'perfetto.CriticalPathLite', |
| name: `Critical path lite`, |
| callback: async () => { |
| const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow(); |
| const window = getTimeSpanOfSelectionOrVisibleWindow(); |
| const engine = this.getEngine(); |
| |
| if (engine !== undefined && trackUtid != 0) { |
| await runQuery( |
| `INCLUDE PERFETTO MODULE sched.thread_executing_span;`, |
| engine, |
| ); |
| await addDebugSliceTrack( |
| engine, |
| { |
| sqlSource: ` |
| SELECT |
| cr.id, |
| cr.utid, |
| cr.ts, |
| cr.dur, |
| thread.name AS thread_name, |
| process.name AS process_name, |
| 'thread_state' AS table_name |
| FROM |
| _thread_executing_span_critical_path( |
| ${trackUtid}, |
| ${window.start}, |
| ${window.end} - ${window.start}) cr |
| JOIN thread USING(utid) |
| JOIN process USING(upid) |
| `, |
| columns: criticalPathsliceLiteColumnNames, |
| }, |
| (await getThreadInfo(engine, trackUtid as Utid)).name ?? |
| '<thread name>', |
| criticalPathsliceLiteColumns, |
| criticalPathsliceLiteColumnNames, |
| ); |
| } |
| }, |
| }, |
| { |
| id: 'perfetto.CriticalPath', |
| name: `Critical path`, |
| callback: async () => { |
| const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow(); |
| const window = getTimeSpanOfSelectionOrVisibleWindow(); |
| const engine = this.getEngine(); |
| |
| if (engine !== undefined && trackUtid != 0) { |
| await runQuery( |
| `INCLUDE PERFETTO MODULE sched.thread_executing_span_with_slice;`, |
| engine, |
| ); |
| await addDebugSliceTrack( |
| engine, |
| { |
| sqlSource: ` |
| SELECT cr.id, cr.utid, cr.ts, cr.dur, cr.name, cr.table_name |
| FROM |
| _critical_path_stack( |
| ${trackUtid}, |
| ${window.start}, |
| ${window.end} - ${window.start}, 1, 1, 1, 1) cr WHERE name IS NOT NULL |
| `, |
| columns: criticalPathsliceColumnNames, |
| }, |
| (await getThreadInfo(engine, trackUtid as Utid)).name ?? |
| '<thread name>', |
| criticalPathSliceColumns, |
| criticalPathsliceColumnNames, |
| ); |
| } |
| }, |
| }, |
| { |
| id: 'perfetto.CriticalPathPprof', |
| name: `Critical path pprof`, |
| callback: () => { |
| const trackUtid = this.getFirstUtidOfSelectionOrVisibleWindow(); |
| const window = getTimeSpanOfSelectionOrVisibleWindow(); |
| const engine = this.getEngine(); |
| |
| if (engine !== undefined && trackUtid != 0) { |
| addQueryResultsTab({ |
| query: |
| `INCLUDE PERFETTO MODULE sched.thread_executing_span_with_slice; |
| SELECT * |
| FROM |
| _thread_executing_span_critical_path_graph( |
| "criical_path", |
| ${trackUtid}, |
| ${window.start}, |
| ${window.end} - ${window.start}) cr`, |
| title: 'Critical path', |
| }); |
| } |
| }, |
| }, |
| { |
| id: 'perfetto.ShowSliceTable', |
| name: 'Show slice table', |
| callback: () => { |
| addSqlTableTab({ |
| table: SqlTables.slice, |
| displayName: 'slice', |
| }); |
| }, |
| }, |
| { |
| id: 'perfetto.TogglePerformanceMetrics', |
| name: 'Toggle performance metrics', |
| callback: () => { |
| globals.dispatch(Actions.togglePerfDebug({})); |
| }, |
| }, |
| { |
| id: 'perfetto.ShareTrace', |
| name: 'Share trace', |
| callback: shareTrace, |
| }, |
| { |
| id: 'perfetto.SearchNext', |
| name: 'Go to next search result', |
| callback: () => { |
| executeSearch(); |
| }, |
| defaultHotkey: 'Enter', |
| }, |
| { |
| id: 'perfetto.SearchPrev', |
| name: 'Go to previous search result', |
| callback: () => { |
| executeSearch(true); |
| }, |
| defaultHotkey: 'Shift+Enter', |
| }, |
| { |
| id: 'perfetto.OpenCommandPalette', |
| name: 'Open Command Palette', |
| callback: () => this.enterCommandMode(), |
| defaultHotkey: '!Mod+Shift+P', |
| }, |
| { |
| id: 'perfetto.RunQuery', |
| name: 'Run Query', |
| callback: () => this.enterQueryMode(), |
| defaultHotkey: '!Mod+O', |
| }, |
| { |
| id: 'perfetto.Search', |
| name: 'Search', |
| callback: () => this.enterSearchMode(true), |
| defaultHotkey: '!Mod+S', |
| }, |
| { |
| id: 'perfetto.ShowHelp', |
| name: 'Show help', |
| callback: () => toggleHelp(), |
| defaultHotkey: '?', |
| }, |
| { |
| id: 'perfetto.RunQueryInSelectedTimeWindow', |
| name: `Run query in selected time window`, |
| callback: () => { |
| const window = getTimeSpanOfSelectionOrVisibleWindow(); |
| this.enterQueryMode(); |
| this.queryText = `select where ts >= ${window.start} and ts < ${window.end}`; |
| this.pendingCursorPlacement = 7; |
| }, |
| }, |
| { |
| id: 'perfetto.CopyTimeWindow', |
| name: `Copy selected time window to clipboard`, |
| callback: () => { |
| const window = getTimeSpanOfSelectionOrVisibleWindow(); |
| const query = `ts >= ${window.start} and ts < ${window.end}`; |
| copyToClipboard(query); |
| }, |
| }, |
| { |
| // Selects & reveals the first track on the timeline with a given URI. |
| id: 'perfetto.FindTrack', |
| name: 'Find track by URI', |
| callback: async () => { |
| const tracks = globals.trackManager.getAllTracks(); |
| const options = tracks.map(({uri}): PromptOption => { |
| return {key: uri, displayName: uri}; |
| }); |
| |
| // Sort tracks in a natural sort order |
| const collator = new Intl.Collator('en', { |
| numeric: true, |
| sensitivity: 'base', |
| }); |
| const sortedOptions = options.sort((a, b) => { |
| return collator.compare(a.displayName, b.displayName); |
| }); |
| |
| try { |
| const selectedUri = await this.prompt( |
| 'Choose a track...', |
| sortedOptions, |
| ); |
| |
| // Find the first track with this URI |
| const firstTrack = Object.values(globals.state.tracks).find( |
| ({uri}) => uri === selectedUri, |
| ); |
| if (firstTrack) { |
| console.log(firstTrack); |
| verticalScrollToTrack(firstTrack.key, true); |
| const traceTime = globals.stateTraceTimeTP(); |
| globals.makeSelection( |
| Actions.selectArea({ |
| area: { |
| start: traceTime.start, |
| end: traceTime.end, |
| tracks: [firstTrack.key], |
| }, |
| }), |
| ); |
| } else { |
| alert(`No tracks with uri ${selectedUri} on the timeline`); |
| } |
| } catch { |
| // Prompt was probably cancelled - do nothing. |
| } |
| }, |
| }, |
| { |
| id: 'perfetto.FocusSelection', |
| name: 'Focus selection', |
| callback: () => findCurrentSelection(), |
| defaultHotkey: 'F', |
| }, |
| { |
| id: 'perfetto.Deselect', |
| name: 'Deselect', |
| callback: () => { |
| globals.timeline.deselectArea(); |
| globals.clearSelection(); |
| globals.dispatch(Actions.removeNote({id: '0'})); |
| }, |
| defaultHotkey: 'Escape', |
| }, |
| { |
| id: 'perfetto.MarkArea', |
| name: 'Mark area', |
| callback: () => { |
| const selection = getLegacySelection(globals.state); |
| if (selection && selection.kind === 'AREA') { |
| globals.dispatch(Actions.toggleMarkCurrentArea({persistent: false})); |
| } else if (selection) { |
| lockSliceSpan(false); |
| } |
| }, |
| defaultHotkey: 'M', |
| }, |
| { |
| id: 'perfetto.MarkAreaPersistent', |
| name: 'Mark area (persistent)', |
| callback: () => { |
| const selection = getLegacySelection(globals.state); |
| if (selection && selection.kind === 'AREA') { |
| globals.dispatch(Actions.toggleMarkCurrentArea({persistent: true})); |
| } else if (selection) { |
| lockSliceSpan(true); |
| } |
| }, |
| defaultHotkey: 'Shift+M', |
| }, |
| { |
| id: 'perfetto.NextFlow', |
| name: 'Next flow', |
| callback: () => focusOtherFlow('Forward'), |
| defaultHotkey: 'Mod+]', |
| }, |
| { |
| id: 'perfetto.PrevFlow', |
| name: 'Prev flow', |
| callback: () => focusOtherFlow('Backward'), |
| defaultHotkey: 'Mod+[', |
| }, |
| { |
| id: 'perfetto.MoveNextFlow', |
| name: 'Move next flow', |
| callback: () => moveByFocusedFlow('Forward'), |
| defaultHotkey: ']', |
| }, |
| { |
| id: 'perfetto.MovePrevFlow', |
| name: 'Move prev flow', |
| callback: () => moveByFocusedFlow('Backward'), |
| defaultHotkey: '[', |
| }, |
| { |
| id: 'perfetto.SelectAll', |
| name: 'Select all', |
| callback: () => { |
| let tracksToSelect: string[] = []; |
| |
| const selection = getLegacySelection(globals.state); |
| if (selection !== null && selection.kind === 'AREA') { |
| const area = globals.state.areas[selection.areaId]; |
| const coversEntireTimeRange = |
| globals.state.traceTime.start === area.start && |
| globals.state.traceTime.end === area.end; |
| if (!coversEntireTimeRange) { |
| // If the current selection is an area which does not cover the |
| // entire time range, preserve the list of selected tracks and |
| // expand the time range. |
| tracksToSelect = area.tracks; |
| } else { |
| // If the entire time range is already covered, update the selection |
| // to cover all tracks. |
| tracksToSelect = Object.keys(globals.state.tracks); |
| } |
| } else { |
| // If the current selection is not an area, select all. |
| tracksToSelect = Object.keys(globals.state.tracks); |
| } |
| const {start, end} = globals.state.traceTime; |
| globals.dispatch( |
| Actions.selectArea({ |
| area: { |
| start, |
| end, |
| tracks: tracksToSelect, |
| }, |
| }), |
| ); |
| }, |
| defaultHotkey: 'Mod+A', |
| }, |
| ]; |
| |
| commands() { |
| return this.cmds; |
| } |
| |
| private rejectPendingPrompt() { |
| if (this.pendingPrompt) { |
| this.pendingPrompt.reject(); |
| this.pendingPrompt = undefined; |
| } |
| } |
| |
| private resetOmnibox() { |
| this.omniboxText = ''; |
| this.omniboxSelectionIndex = 0; |
| } |
| |
| private renderOmnibox(): m.Children { |
| const msgTTL = globals.state.status.timestamp + 1 - Date.now() / 1e3; |
| const engineIsBusy = |
| globals.state.engine !== undefined && !globals.state.engine.ready; |
| |
| if (msgTTL > 0 || engineIsBusy) { |
| setTimeout(() => raf.scheduleFullRedraw(), msgTTL * 1000); |
| return m( |
| `.omnibox.message-mode`, |
| m(`input[readonly][disabled][ref=omnibox]`, { |
| value: '', |
| placeholder: globals.state.status.msg, |
| }), |
| ); |
| } |
| |
| if (this.omniboxMode === OmniboxMode.Command) { |
| return this.renderCommandOmnibox(); |
| } else if (this.omniboxMode === OmniboxMode.Prompt) { |
| return this.renderPromptOmnibox(); |
| } else if (this.omniboxMode === OmniboxMode.Query) { |
| return this.renderQueryOmnibox(); |
| } else if (this.omniboxMode === OmniboxMode.Search) { |
| return this.renderSearchOmnibox(); |
| } else { |
| const x: never = this.omniboxMode; |
| throw new Error(`Unhandled omnibox mode ${x}`); |
| } |
| } |
| |
| renderPromptOmnibox(): m.Children { |
| const prompt = assertExists(this.pendingPrompt); |
| |
| let options: OmniboxOption[] | undefined = undefined; |
| |
| if (prompt.options) { |
| const fuzzy = new FuzzyFinder( |
| prompt.options, |
| ({displayName}) => displayName, |
| ); |
| const result = fuzzy.find(this.omniboxText); |
| options = result.map((result) => { |
| return { |
| key: result.item.key, |
| displayName: result.segments, |
| }; |
| }); |
| } |
| |
| return m(Omnibox, { |
| value: this.omniboxText, |
| placeholder: prompt.text, |
| inputRef: App.OMNIBOX_INPUT_REF, |
| extraClasses: 'prompt-mode', |
| closeOnOutsideClick: true, |
| options, |
| selectedOptionIndex: this.omniboxSelectionIndex, |
| onSelectedOptionChanged: (index) => { |
| this.omniboxSelectionIndex = index; |
| raf.scheduleFullRedraw(); |
| }, |
| onInput: (value) => { |
| this.omniboxText = value; |
| this.omniboxSelectionIndex = 0; |
| raf.scheduleFullRedraw(); |
| }, |
| onSubmit: (value, _alt) => { |
| this.resolvePrompt(value); |
| }, |
| onClose: () => { |
| this.rejectPrompt(); |
| }, |
| }); |
| } |
| |
| renderCommandOmnibox(): m.Children { |
| const cmdMgr = globals.commandManager; |
| |
| // Fuzzy-filter commands by the filter string. |
| const filteredCmds = cmdMgr.fuzzyFilterCommands(this.omniboxText); |
| |
| // Create an array of commands with attached heuristics from the recent |
| // command register. |
| const commandsWithHeuristics = filteredCmds.map((cmd) => { |
| return { |
| recentsIndex: this.recentCommands.findIndex((id) => id === cmd.id), |
| cmd, |
| }; |
| }); |
| |
| // Sort by recentsIndex then by alphabetical order |
| const sorted = commandsWithHeuristics.sort((a, b) => { |
| if (b.recentsIndex === a.recentsIndex) { |
| return a.cmd.name.localeCompare(b.cmd.name); |
| } else { |
| return b.recentsIndex - a.recentsIndex; |
| } |
| }); |
| |
| const options = sorted.map(({recentsIndex, cmd}): OmniboxOption => { |
| const {segments, id, defaultHotkey} = cmd; |
| return { |
| key: id, |
| displayName: segments, |
| tag: recentsIndex !== -1 ? 'recently used' : undefined, |
| rightContent: defaultHotkey && m(HotkeyGlyphs, {hotkey: defaultHotkey}), |
| }; |
| }); |
| |
| return m(Omnibox, { |
| value: this.omniboxText, |
| placeholder: 'Filter commands...', |
| inputRef: App.OMNIBOX_INPUT_REF, |
| extraClasses: 'command-mode', |
| options, |
| closeOnSubmit: true, |
| closeOnOutsideClick: true, |
| selectedOptionIndex: this.omniboxSelectionIndex, |
| onSelectedOptionChanged: (index) => { |
| this.omniboxSelectionIndex = index; |
| raf.scheduleFullRedraw(); |
| }, |
| onInput: (value) => { |
| this.omniboxText = value; |
| this.omniboxSelectionIndex = 0; |
| raf.scheduleFullRedraw(); |
| }, |
| onClose: () => { |
| if (this.omniboxInputEl) { |
| this.omniboxInputEl.blur(); |
| } |
| this.enterSearchMode(false); |
| raf.scheduleFullRedraw(); |
| }, |
| onSubmit: (key: string) => { |
| this.addRecentCommand(key); |
| cmdMgr.runCommand(key); |
| }, |
| }); |
| } |
| |
| private addRecentCommand(id: string): void { |
| this.recentCommands = this.recentCommands.filter((x) => x !== id); |
| this.recentCommands.push(id); |
| while (this.recentCommands.length > 6) { |
| this.recentCommands.shift(); |
| } |
| } |
| |
| renderQueryOmnibox(): m.Children { |
| const ph = 'e.g. select * from sched left join thread using(utid) limit 10'; |
| return m(Omnibox, { |
| value: this.queryText, |
| placeholder: ph, |
| inputRef: App.OMNIBOX_INPUT_REF, |
| extraClasses: 'query-mode', |
| onInput: (value) => { |
| this.queryText = value; |
| raf.scheduleFullRedraw(); |
| }, |
| onSubmit: (query, alt) => { |
| const config = { |
| query: undoCommonChatAppReplacements(query), |
| title: alt ? 'Pinned query' : 'Omnibox query', |
| }; |
| const tag = alt ? undefined : 'omnibox_query'; |
| addQueryResultsTab(config, tag); |
| }, |
| onClose: () => { |
| this.queryText = ''; |
| if (this.omniboxInputEl) { |
| this.omniboxInputEl.blur(); |
| } |
| this.enterSearchMode(false); |
| raf.scheduleFullRedraw(); |
| }, |
| }); |
| } |
| |
| renderSearchOmnibox(): m.Children { |
| const omniboxState = globals.state.omniboxState; |
| const displayStepThrough = |
| omniboxState.omnibox.length >= 4 || omniboxState.force; |
| |
| return m(Omnibox, { |
| value: globals.state.omniboxState.omnibox, |
| placeholder: "Search or type '>' for commands or ':' for SQL mode", |
| inputRef: App.OMNIBOX_INPUT_REF, |
| onInput: (value, prev) => { |
| if (prev === '') { |
| if (value === '>') { |
| this.enterCommandMode(); |
| return; |
| } else if (value === ':') { |
| this.enterQueryMode(); |
| return; |
| } |
| } |
| globals.dispatch(Actions.setOmnibox({omnibox: value, mode: 'SEARCH'})); |
| }, |
| onClose: () => { |
| if (this.omniboxInputEl) { |
| this.omniboxInputEl.blur(); |
| } |
| }, |
| onSubmit: (value, _mod, shift) => { |
| executeSearch(shift); |
| globals.dispatch( |
| Actions.setOmnibox({omnibox: value, mode: 'SEARCH', force: true}), |
| ); |
| if (this.omniboxInputEl) { |
| this.omniboxInputEl.blur(); |
| } |
| }, |
| rightContent: displayStepThrough && this.renderStepThrough(), |
| }); |
| } |
| |
| private renderStepThrough() { |
| return m( |
| '.stepthrough', |
| m( |
| '.current', |
| `${ |
| globals.currentSearchResults.totalResults === 0 |
| ? '0 / 0' |
| : `${globals.state.searchIndex + 1} / ${ |
| globals.currentSearchResults.totalResults |
| }` |
| }`, |
| ), |
| m( |
| 'button', |
| { |
| onclick: () => { |
| executeSearch(true /* reverse direction */); |
| }, |
| }, |
| m('i.material-icons.left', 'keyboard_arrow_left'), |
| ), |
| m( |
| 'button', |
| { |
| onclick: () => { |
| executeSearch(); |
| }, |
| }, |
| m('i.material-icons.right', 'keyboard_arrow_right'), |
| ), |
| ); |
| } |
| |
| view({children}: m.Vnode): m.Children { |
| const hotkeys: HotkeyConfig[] = []; |
| const commands = globals.commandManager.commands; |
| for (const {id, defaultHotkey} of commands) { |
| if (defaultHotkey) { |
| hotkeys.push({ |
| callback: () => { |
| globals.commandManager.runCommand(id); |
| }, |
| hotkey: defaultHotkey, |
| }); |
| } |
| } |
| |
| return m( |
| HotkeyContext, |
| {hotkeys}, |
| m( |
| 'main', |
| m(Sidebar), |
| m(Topbar, { |
| omnibox: this.renderOmnibox(), |
| }), |
| m(Alerts), |
| children, |
| m(CookieConsent), |
| maybeRenderFullscreenModalDialog(), |
| globals.state.perfDebug && m('.perf-stats'), |
| ), |
| ); |
| } |
| |
| oncreate({dom}: m.VnodeDOM) { |
| this.updateOmniboxInputRef(dom); |
| this.maybeFocusOmnibar(); |
| |
| // Register each command with the command manager |
| this.cmds.forEach((cmd) => { |
| const dispose = globals.commandManager.registerCommand(cmd); |
| this.trash.add(dispose); |
| }); |
| } |
| |
| onupdate({dom}: m.VnodeDOM) { |
| this.updateOmniboxInputRef(dom); |
| this.maybeFocusOmnibar(); |
| } |
| |
| onremove(_: m.VnodeDOM) { |
| this.trash.dispose(); |
| this.omniboxInputEl = undefined; |
| } |
| |
| private updateOmniboxInputRef(dom: Element): void { |
| const el = findRef(dom, App.OMNIBOX_INPUT_REF); |
| if (el && el instanceof HTMLInputElement) { |
| this.omniboxInputEl = el; |
| } |
| } |
| |
| private maybeFocusOmnibar() { |
| if (this.focusOmniboxNextRender) { |
| const omniboxEl = this.omniboxInputEl; |
| if (omniboxEl) { |
| omniboxEl.focus(); |
| if (this.pendingCursorPlacement === -1) { |
| omniboxEl.select(); |
| } else { |
| omniboxEl.setSelectionRange( |
| this.pendingCursorPlacement, |
| this.pendingCursorPlacement, |
| ); |
| this.pendingCursorPlacement = -1; |
| } |
| } |
| this.focusOmniboxNextRender = false; |
| } |
| } |
| } |
| |
| // Returns the time span of the current selection, or the visible window if |
| // there is no current selection. |
| function getTimeSpanOfSelectionOrVisibleWindow(): Span<time, duration> { |
| const range = globals.findTimeRangeOfSelection(); |
| if (range.end !== Time.INVALID && range.start !== Time.INVALID) { |
| return new TimeSpan(range.start, range.end); |
| } else { |
| return globals.stateVisibleTime(); |
| } |
| } |