Merge "Add commands."
diff --git a/ui/src/assets/topbar.scss b/ui/src/assets/topbar.scss
index 14a42b4..a4e0fb7 100644
--- a/ui/src/assets/topbar.scss
+++ b/ui/src/assets/topbar.scss
@@ -64,7 +64,7 @@
font-weight: 400;
}
}
- &.command-mode {
+ &.query-mode {
background-color: #111;
border-radius: 0;
width: 100%;
@@ -84,6 +84,14 @@
padding-top: 5px;
}
}
+ &.command-mode {
+ &:before {
+ @include material-icon("chevron_right");
+ margin: 5px;
+ color: #aaa;
+ grid-area: icon;
+ }
+ }
&.message-mode {
background-color: hsl(0, 0%, 89%);
border-radius: $pf-border-radius;
@@ -230,7 +238,41 @@
}
.hide-sidebar {
- .command-mode {
+ .query-mode {
padding-left: 48px;
}
}
+
+.pf-cmd-container {
+ margin: 2px;
+}
+
+.pf-cmd {
+ font-family: $pf-font;
+ cursor: pointer;
+ display: grid;
+ width: 400px;
+ grid-template-columns: [titles]1fr [icon]auto;
+ padding: 4px;
+ border-radius: $pf-border-radius;
+ align-items: center;
+ &:hover {
+ background-color: $pf-minimal-background-hover;
+ }
+ &.pf-highlighted {
+ background-color: $pf-primary-background;
+ color: white;
+ }
+ i {
+ grid-column: icon;
+ grid-row: 1 / span 2;
+ }
+ h1 {
+ grid-column: titles;
+ font-size: larger;
+ }
+ h2 {
+ grid-column: titles;
+ font-size: smaller;
+ }
+}
diff --git a/ui/src/common/commands.ts b/ui/src/common/commands.ts
new file mode 100644
index 0000000..3b9446b
--- /dev/null
+++ b/ui/src/common/commands.ts
@@ -0,0 +1,52 @@
+// 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.
+
+export interface Command {
+ id: string;
+ name: string;
+ callback: (...args: any[]) => void;
+}
+
+export interface CommandSource {
+ commands(): Command[];
+}
+
+export class CommandManager {
+ private commandSources: CommandSource[] = [];
+
+ registerCommandSource(cs: CommandSource) {
+ this.commandSources.push(cs);
+ }
+
+ get commands(): Command[] {
+ return this.commandSources.flatMap((source) => source.commands());
+ }
+
+ runCommand(id: string, ...args: any[]): void {
+ const cmd = this.commands.find((cmd) => cmd.id === id);
+ if (cmd) {
+ cmd.callback(...args);
+ } else {
+ console.error(`No such command: ${id}`);
+ }
+ }
+
+ // TODO(stevegolton): Improve filter algo.
+ fuzzyFilterCommands(searchTerm: string): Command[] {
+ const searchTermLower = searchTerm.toLowerCase();
+ return this.commands.filter(({name}) => {
+ return name.toLowerCase().includes(searchTermLower);
+ });
+ }
+}
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index ba45aac..ede9e57 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -29,16 +29,14 @@
TrackProvider,
} from '../public';
+import {Command} from './commands';
import {Engine} from './engine';
import {Registry} from './registry';
import {State} from './state';
// All trace plugins must implement this interface.
export interface TracePlugin extends Disposable {
- // This is where we would add potential extension points that plugins can
- // override.
- // E.g. commands(): Command[];
- // For now, plugins don't do anything so this interface is empty.
+ commands(): Command[];
}
// This interface defines what a plugin factory should look like.
@@ -147,7 +145,7 @@
revoke() {
// TODO(hjd): Remove from trackControllerRegistry, trackRegistry,
// etc.
- // TODO(stevegolton): Close the trace plugin.
+ // TODO(stevegolton): Dispose the trace plugin.
}
// ==================================================================
}
@@ -216,6 +214,17 @@
context.onTraceClosed();
}
}
+
+ commands(): Command[] {
+ return Array.from(this.contexts.values()).flatMap((ctx) => {
+ const tracePlugin = ctx.tracePlugin;
+ if (tracePlugin && tracePlugin.commands) {
+ return tracePlugin.commands();
+ } else {
+ return [];
+ }
+ });
+ }
}
// TODO(hjd): Sort out the story for global singletons like these:
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 9ccea07..2e8ae1f 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -17,6 +17,7 @@
import {Actions, DeferredAction} from '../common/actions';
import {AggregateData} from '../common/aggregation_data';
import {Args} from '../common/arg_types';
+import {CommandManager} from '../common/commands';
import {
ConversionJobName,
ConversionJobStatus,
@@ -251,6 +252,7 @@
private _hideSidebar?: boolean = undefined;
private _ftraceCounters?: FtraceStat[] = undefined;
private _ftracePanelData?: FtracePanelData = undefined;
+ private _cmdManager?: CommandManager = undefined;
// TODO(hjd): Remove once we no longer need to update UUID on redraw.
private _publishRedraw?: () => void = undefined;
@@ -271,10 +273,13 @@
engines = new Map<string, Engine>();
- initialize(dispatch: Dispatch, router: Router, initialState: State) {
+ initialize(
+ dispatch: Dispatch, router: Router, initialState: State,
+ cmdManager: CommandManager) {
this._dispatch = dispatch;
this._router = router;
this._store = createStore(initialState);
+ this._cmdManager = cmdManager;
this._frontendLocalState = new FrontendLocalState();
setPerfHooks(
@@ -700,6 +705,10 @@
return 1;
}
}
+
+ get commandManager(): CommandManager {
+ return assertExists(this._cmdManager);
+ }
}
export const globals = new Globals();
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 20ddf3b..3b8f9bd 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -22,10 +22,12 @@
import {defer} from '../base/deferred';
import {assertExists, reportError, setErrorHandler} from '../base/logging';
import {Actions, DeferredAction, StateActions} from '../common/actions';
+import {CommandManager} from '../common/commands';
import {createEmptyState} from '../common/empty_state';
import {RECORDING_V2_FLAG} from '../common/feature_flags';
import {pluginManager, pluginRegistry} from '../common/plugins';
import {State} from '../common/state';
+import {setTimestampFormat, TimestampFormat} from '../common/time';
import {initWasm} from '../common/wasm_engine_proxy';
import {initController, runControllers} from '../controller';
import {
@@ -33,6 +35,7 @@
} from '../controller/chrome_proxy_record_controller';
import {raf} from '../core/raf_scheduler';
+import {addTab} from './bottom_tab';
import {initCssConstants} from './css_constants';
import {registerDebugGlobals} from './debug';
import {maybeShowErrorDialog} from './error_dialog';
@@ -49,6 +52,8 @@
import {RecordPageV2} from './record_page_v2';
import {Router} from './router';
import {CheckHttpRpcConnection} from './rpc_http_dialog';
+import {SqlTableTab} from './sql_table/tab';
+import {SqlTables} from './sql_table/well_known_tables';
import {TraceInfoPage} from './trace_info_page';
import {maybeOpenTraceFromRoute} from './trace_url_handler';
import {ViewerPage} from './viewer_page';
@@ -211,7 +216,64 @@
globals.embeddedMode = route.args.mode === 'embedded';
globals.hideSidebar = route.args.hideSidebar === true;
- globals.initialize(dispatch, router, createEmptyState());
+ const cmdManager = new CommandManager();
+
+ // Register some "core" commands.
+ // TODO(stevegolton): Find a better place to put this.
+ cmdManager.registerCommandSource({
+ commands() {
+ return [
+ {
+ id: 'dev.perfetto.SetTimestampFormatTimecodes',
+ name: 'Set timestamp format: Timecode',
+ callback: () => {
+ setTimestampFormat(TimestampFormat.Timecode);
+ raf.scheduleFullRedraw();
+ },
+ },
+ {
+ id: 'dev.perfetto.SetTimestampFormatSeconds',
+ name: 'Set timestamp format: Seconds',
+ callback: () => {
+ setTimestampFormat(TimestampFormat.Seconds);
+ raf.scheduleFullRedraw();
+ },
+ },
+ {
+ id: 'dev.perfetto.SetTimestampFormatRaw',
+ name: 'Set timestamp format: Raw',
+ callback: () => {
+ setTimestampFormat(TimestampFormat.Raw);
+ raf.scheduleFullRedraw();
+ },
+ },
+ {
+ id: 'dev.perfetto.SetTimestampFormatLocaleRaw',
+ name: 'Set timestamp format: Raw (formatted)',
+ callback: () => {
+ setTimestampFormat(TimestampFormat.RawLocale);
+ raf.scheduleFullRedraw();
+ },
+ },
+ {
+ id: 'dev.perfetto.ShowSliceTabe',
+ name: 'Show slice table',
+ callback: () => {
+ addTab({
+ kind: SqlTableTab.kind,
+ config: {
+ table: SqlTables.slice,
+ displayName: 'slice',
+ },
+ });
+ },
+ },
+ ];
+ },
+ });
+
+ globals.initialize(dispatch, router, createEmptyState(), cmdManager);
+
globals.serviceWorkerController.install();
const frontendApi = new FrontendApi();
@@ -264,6 +326,8 @@
for (const plugin of pluginRegistry.values()) {
pluginManager.activatePlugin(plugin.pluginId);
}
+
+ cmdManager.registerCommandSource(pluginManager);
}
function onCssLoaded() {
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index abbd26c..11bd3dc 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -15,23 +15,33 @@
import m from 'mithril';
import {Actions} from '../common/actions';
+import {Command} from '../common/commands';
import {raf} from '../core/raf_scheduler';
import {VERSION} from '../gen/perfetto_version';
+import {classNames} from './classnames';
import {globals} from './globals';
import {runQueryInNewTab} from './query_result_tab';
import {executeSearch} from './search_handler';
import {taskTracker} from './task_tracker';
+import {EmptyState} from './widgets/empty_state';
+import {Icon} from './widgets/icon';
+import {Popup} from './widgets/popup';
const SEARCH = Symbol('search');
+const QUERY = Symbol('query');
const COMMAND = Symbol('command');
-type Mode = typeof SEARCH|typeof COMMAND;
+type Mode = typeof SEARCH|typeof QUERY|typeof COMMAND;
+let highlightedCommandIndex = 0;
const PLACEHOLDER = {
[SEARCH]: 'Search',
- [COMMAND]: 'e.g. select * from sched left join thread using(utid) limit 10',
+ [QUERY]: 'e.g. select * from sched left join thread using(utid) limit 10',
+ [COMMAND]: 'Start typing a command..',
};
+let matchingCommands: Command[] = [];
+
export const DISMISSED_PANNING_HINT_KEY = 'dismissedPanningHint';
let mode: Mode = SEARCH;
@@ -47,22 +57,55 @@
if (mode === SEARCH && txt.value === '' && key === ':') {
e.preventDefault();
+ mode = QUERY;
+ raf.scheduleFullRedraw();
+ return;
+ }
+
+ if (mode === SEARCH && txt.value === '' && key === '>') {
+ e.preventDefault();
mode = COMMAND;
raf.scheduleFullRedraw();
return;
}
- if (mode === COMMAND && txt.value === '' && key === 'Backspace') {
+ if (mode !== SEARCH && txt.value === '' && key === 'Backspace') {
mode = SEARCH;
raf.scheduleFullRedraw();
return;
}
+ if (mode === COMMAND) {
+ if (key === 'ArrowDown') {
+ highlightedCommandIndex++;
+ highlightedCommandIndex =
+ Math.min(matchingCommands.length - 1, highlightedCommandIndex);
+ raf.scheduleFullRedraw();
+ } else if (key === 'ArrowUp') {
+ highlightedCommandIndex--;
+ highlightedCommandIndex = Math.max(0, highlightedCommandIndex);
+ raf.scheduleFullRedraw();
+ } else if (key === 'Enter') {
+ const cmd = matchingCommands[highlightedCommandIndex];
+ if (cmd) {
+ globals.commandManager.runCommand(cmd.id);
+ }
+ highlightedCommandIndex = 0;
+ mode = SEARCH;
+ globals.dispatch(Actions.setOmnibox({
+ omnibox: '',
+ mode: 'SEARCH',
+ }));
+ } else {
+ highlightedCommandIndex = 0;
+ }
+ }
+
if (mode === SEARCH && key === 'Enter') {
txt.blur();
}
- if (mode === COMMAND && key === 'Enter') {
+ if (mode === QUERY && key === 'Enter') {
const openInPinnedTab = event.metaKey || event.ctrlKey;
runQueryInNewTab(
txt.value,
@@ -87,13 +130,31 @@
}
}
-class Omnibox implements m.ClassComponent {
- oncreate(vnode: m.VnodeDOM) {
- const txt = vnode.dom.querySelector('input') as HTMLInputElement;
- txt.addEventListener('keydown', onKeyDown);
- txt.addEventListener('keyup', onKeyUp);
- }
+interface CmdAttrs {
+ title: string;
+ subtitle: string;
+ highlighted?: boolean;
+ icon?: string;
+ [htmlAttrs: string]: any;
+}
+class Cmd implements m.ClassComponent<CmdAttrs> {
+ view({attrs}: m.Vnode<CmdAttrs, this>): void|m.Children {
+ const {title, subtitle, icon, highlighted = false, ...htmlAttrs} = attrs;
+ return m(
+ 'section.pf-cmd',
+ {
+ class: classNames(highlighted && 'pf-highlighted'),
+ ...htmlAttrs,
+ },
+ m('h1', title),
+ m('h2', subtitle),
+ m(Icon, {className: 'pf-right-icon', icon: icon ?? 'play_arrow'}),
+ );
+ }
+}
+
+class Omnibox implements m.ClassComponent {
view() {
const msgTTL = globals.state.status.timestamp + 1 - Date.now() / 1e3;
const engineIsBusy =
@@ -107,17 +168,69 @@
value: '',
}));
}
-
- const commandMode = mode === COMMAND;
return m(
- `.omnibox${commandMode ? '.command-mode' : ''}`,
+ Popup,
+ {
+ isOpen: mode === COMMAND,
+ trigger: this.renderOmnibox(),
+ className: 'pf-popup-padded',
+ },
+ m(
+ '.pf-cmd-container',
+ this.renderCommandDropdown(),
+ ),
+ );
+ }
+
+ private renderCommandDropdown(): m.Children {
+ if (mode === COMMAND) {
+ const searchTerm = globals.state.omniboxState.omnibox;
+ matchingCommands = globals.commandManager.fuzzyFilterCommands(searchTerm);
+ if (matchingCommands.length === 0) {
+ return m(EmptyState, {header: 'No matching commands'});
+ } else {
+ return matchingCommands.map((cmd, index) => {
+ return m(Cmd, {
+ title: cmd.name,
+ subtitle: cmd.id,
+ highlighted: index === highlightedCommandIndex,
+ onclick: () => {
+ globals.commandManager.runCommand(cmd.id);
+ mode = SEARCH;
+ globals.dispatch(Actions.setOmnibox({
+ omnibox: '',
+ mode: 'SEARCH',
+ }));
+ highlightedCommandIndex = 0;
+ },
+ });
+ });
+ }
+ } else {
+ return null;
+ }
+ }
+
+ private renderOmnibox() {
+ const queryMode = mode === QUERY;
+ const classes = classNames(
+ mode === QUERY && 'query-mode',
+ mode === COMMAND && 'command-mode',
+ );
+ return m(
+ `.omnibox`,
+ {
+ class: classes,
+ },
m('input', {
placeholder: PLACEHOLDER[mode],
+ onkeydown: (e: Event) => onKeyDown(e),
+ onkeyup: (e: Event) => onKeyUp(e),
oninput: (e: InputEvent) => {
const value = (e.target as HTMLInputElement).value;
globals.dispatch(Actions.setOmnibox({
omnibox: value,
- mode: commandMode ? 'COMMAND' : 'SEARCH',
+ mode: queryMode ? 'COMMAND' : 'SEARCH',
}));
if (mode === SEARCH) {
displayStepThrough = value.length >= 4;
@@ -127,29 +240,27 @@
value: globals.state.omniboxState.omnibox,
}),
displayStepThrough ?
- 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('.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.left', 'keyboard_arrow_left')),
+ m('button',
+ {
+ onclick: () => {
+ executeSearch();
},
- m('i.material-icons.right', 'keyboard_arrow_right')),
- ) :
+ },
+ m('i.material-icons.right', 'keyboard_arrow_right'))) :
'');
}
}
@@ -239,7 +350,7 @@
if (globals.embeddedMode) return;
const errors = globals.traceErrors;
- if (!errors && !globals.metricError || mode === COMMAND) return;
+ if (!errors && !globals.metricError || mode === QUERY) return;
const message = errors ? `${errors} import or data loss errors detected.` :
`Metric error detected.`;
return m(
diff --git a/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts b/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts
index 09400e5..1ce82f3 100644
--- a/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts
+++ b/ui/src/plugins/dev.perfetto.ExamplePlugin/index.ts
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import {Command} from '../../common/commands';
import {TracePlugin} from '../../common/plugins';
import {Store} from '../../frontend/store';
import {EngineProxy, PluginContext} from '../../public';
@@ -33,6 +34,18 @@
dispose(): void {
// No-op
}
+
+ commands(): Command[] {
+ // Example return value:
+ // return [
+ // {
+ // id: 'dev.perfetto.ExampleCommand',
+ // name: 'Example Command',
+ // callback: () => console.log('Hello from example command'),
+ // },
+ // ];
+ return [];
+ }
}
function activate(ctx: PluginContext) {