blob: b9ad6f0c48f85abe51f21f5bed2c11fcb26d1170 [file]
// Copyright (C) 2025 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 {Engine} from 'syntaqlite';
import {Icons} from '../../base/semantic_icons';
import type {QueryResponse} from '../../components/query_table/queries';
import {InMemoryDataSource} from '../../components/widgets/datagrid/in_memory_data_source';
import {QueryHistoryComponent} from '../../components/widgets/query_history';
import type {Trace} from '../../public/trace';
import {Box} from '../../widgets/box';
import {Button, ButtonVariant} from '../../widgets/button';
import {Callout} from '../../widgets/callout';
import {Intent} from '../../widgets/common';
import {Editor} from '../../widgets/editor';
import {EmptyState} from '../../widgets/empty_state';
import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs';
import {Spinner} from '../../widgets/spinner';
import {SplitPanel} from '../../widgets/split_panel';
import {Tabs, type TabsTab} from '../../widgets/tabs';
import {Stack, StackAuto} from '../../widgets/stack';
import {Anchor} from '../../widgets/anchor';
import type {DataSource} from '../../components/widgets/datagrid/data_source';
import SqlModulesPlugin from '../dev.perfetto.SqlModules';
import {TableList} from './table_list';
import {ResultsTable} from './results_table';
const HIDE_PERFETTO_SQL_AGENT_BANNER_KEY = 'hidePerfettoSqlAgentBanner';
// Represents a single query editor tab with its own state.
export interface QueryEditorTab {
readonly id: string;
editorText: string;
queryResult?: QueryResponse;
isLoading: boolean;
title: string;
}
export interface QueryPageAttrs {
// The trace to run queries against.
readonly trace: Trace;
// All editor tabs.
readonly editorTabs: QueryEditorTab[];
// The currently active editor tab ID.
readonly activeTabId: string;
// Called when the content of an editor is updated.
onEditorContentUpdate?(tabId: string, content: string): void;
// Called when the user requests to execute a query.
onExecute?(tabId: string, query: string): void;
// Called when the user switches to a different tab.
onTabChange?(tabId: string): void;
// Called when the user closes a tab.
onTabClose?(tabId: string): void;
// Called when the user wants to add a new tab.
onTabAdd?(
tabName?: string,
initialQuery?: string,
autoExecute?: boolean,
): void;
// Called when the user renames a tab.
onTabRename?(tabId: string, newName: string): void;
// Called when the user reorders tabs via drag and drop.
// draggedTabId is the tab being moved, beforeTabId is the tab it should be
// placed before (or undefined if moved to the end).
onTabReorder?(draggedTabId: string, beforeTabId: string | undefined): void;
}
export class QueryPage implements m.ClassComponent<QueryPageAttrs> {
// Map of tab ID to DataSource for each tab's query results
private dataSources = new Map<string, DataSource>();
// Track previous query results to detect changes
private prevQueryResults = new Map<string, QueryResponse | undefined>();
// Lazily-initialized SQL formatter engine, scoped to this component instance.
private formatterEnginePromise?: Promise<Engine>;
private getFormatterEngine(): Promise<Engine> {
if (this.formatterEnginePromise === undefined) {
const engine = new Engine({
runtimeJsPath: 'assets/syntaqlite-runtime.js',
runtimeWasmPath: 'assets/syntaqlite-runtime.wasm',
});
this.formatterEnginePromise = (async () => {
await engine.load();
const binding = await engine.loadDialectFromUrl(
'assets/syntaqlite-perfetto.wasm',
'syntaqlite_perfetto_dialect_template',
);
engine.setDialectPointer(binding.ptr);
return engine;
})();
}
return this.formatterEnginePromise;
}
view({attrs}: m.CVnode<QueryPageAttrs>) {
const {editorTabs, activeTabId} = attrs;
// Update data sources for tabs whose results have changed
for (const tab of editorTabs) {
const prevResult = this.prevQueryResults.get(tab.id);
if (tab.queryResult !== prevResult) {
if (tab.queryResult) {
this.dataSources.set(
tab.id,
new InMemoryDataSource(tab.queryResult.rows),
);
} else {
this.dataSources.delete(tab.id);
}
this.prevQueryResults.set(tab.id, tab.queryResult);
}
}
// Clean up data sources for removed tabs
const tabIds = new Set(editorTabs.map((t) => t.id));
for (const id of this.dataSources.keys()) {
if (!tabIds.has(id)) {
this.dataSources.delete(id);
this.prevQueryResults.delete(id);
}
}
// Build editor tabs for the left panel
const leftTabs: TabsTab[] = editorTabs.map((tab) => ({
key: tab.id,
title: tab.title,
leftIcon: 'code',
closeButton: editorTabs.length > 1,
content: this.renderEditorTabContent(attrs, tab),
}));
const leftPanel = m(Tabs, {
className: 'pf-query-page__editor-tabs',
tabs: leftTabs,
activeTabKey: activeTabId,
reorderable: true,
onTabChange: (key) => attrs.onTabChange?.(key),
onTabRename: (key, newTitle) => {
attrs.onTabRename?.(key, newTitle);
},
onTabClose: (key) => attrs.onTabClose?.(key),
onTabReorder: (draggedKey, beforeKey) =>
attrs.onTabReorder?.(draggedKey, beforeKey),
onNewTab: () => attrs.onTabAdd?.(),
});
const activeTab = editorTabs.find((t) => t.id === activeTabId);
const sidebarPanel = m(Tabs, {
className: 'pf-query-page__sidebar',
tabs: [
{
key: 'history',
title: 'History',
leftIcon: 'history',
content: m(QueryHistoryComponent, {
className: 'pf-query-page__history',
trace: attrs.trace,
runQuery: (query: string) => {
if (activeTab) {
attrs.onExecute?.(activeTab.id, query);
}
},
setQuery: (query: string) => {
if (activeTab) {
attrs.onEditorContentUpdate?.(activeTab.id, query);
}
},
}),
},
{
key: 'tables',
title: 'Tables',
leftIcon: 'table_chart',
content: this.renderTablesTab(attrs),
},
],
});
return m(
'.pf-query-page',
m(SplitPanel, {
direction: 'horizontal',
initialSplit: {pixels: 500},
controlledPanel: 'second',
minSize: 100,
firstPanel: leftPanel,
secondPanel: sidebarPanel,
}),
);
}
private renderEditorTabContent(
attrs: QueryPageAttrs,
tab: QueryEditorTab,
): m.Children {
const {trace} = attrs;
const dataSource = this.dataSources.get(tab.id);
const editorPanel = m('.pf-query-page__editor-panel', [
m(Box, {className: 'pf-query-page__toolbar'}, [
m(Stack, {orientation: 'horizontal'}, [
m(Button, {
label: 'Run Query',
icon: 'play_arrow',
loading: tab.isLoading,
intent: tab.isLoading ? Intent.None : Intent.Primary,
variant: ButtonVariant.Filled,
onclick: () => {
attrs.onExecute?.(tab.id, tab.editorText);
},
}),
m(
Stack,
{
orientation: 'horizontal',
className: 'pf-query-page__hotkeys',
},
'or press',
m(HotkeyGlyphs, {hotkey: 'Mod+Enter'}),
),
m(StackAuto), // The spacer pushes the following buttons to the right.
m(Button, {
label: 'Format',
icon: 'format_align_left',
title: 'Auto-format the SQL query',
onclick: () => this.formatSql(attrs, tab.id, tab.editorText),
}),
trace.isInternalUser &&
m(Button, {
icon: 'wand_stars',
title:
'Generate SQL queries with the Perfetto SQL Agent! Give feedback: go/perfetto-llm-bug',
label: 'Generate SQL Queries with AI',
onclick: () => {
window.open('http://go/perfetto-sql-agent', '_blank');
},
}),
]),
]),
this.shouldDisplayPerfettoSqlAgentBanner(attrs) &&
m(
Box,
m(
Callout,
{
icon: 'wand_stars',
dismissible: true,
onDismiss: () => {
this.hidePerfettoSqlAgentBanner();
},
},
[
'Try out the ',
m(
Anchor,
{
href: 'http://go/perfetto-sql-agent',
target: '_blank',
icon: Icons.ExternalLink,
},
'Perfetto SQL Agent',
),
' to generate SQL queries and ',
m(
Anchor,
{
href: 'http://go/perfetto-llm-user-guide#report-issues',
target: '_blank',
icon: Icons.ExternalLink,
},
'give feedback',
),
'!',
],
),
),
tab.editorText.includes('"') &&
m(
Box,
m(
Callout,
{icon: 'warning', intent: Intent.None},
`" (double quote) character observed in query; if this is being used to ` +
`define a string, please use ' (single quote) instead. Using double quotes ` +
`can cause subtle problems which are very hard to debug.`,
),
),
m(Editor, {
language: 'perfetto-sql',
text: tab.editorText,
onUpdate: (content) => attrs.onEditorContentUpdate?.(tab.id, content),
onExecute: (query) => attrs.onExecute?.(tab.id, query),
onFormat: (text) => this.formatSql(attrs, tab.id, text),
}),
]);
const resp = tab.queryResult;
const resultsPanel = resp
? m(ResultsTable, {
data: resp.error
? {kind: 'error', errorMessage: resp.error}
: {
kind: 'success',
columns: resp.columns,
rows: resp.rows,
dataSource: dataSource!,
rowCount: resp.totalRowCount,
queryTimeMs: resp.durationMs,
query: resp.query,
lastStatementSql: resp.lastStatementSql,
statementCount: resp.statementCount,
statementWithOutputCount: resp.statementWithOutputCount,
},
fillHeight: true,
trace,
onIdClick: (sqlTable, id, doubleClick) => {
trace.navigate('#!/viewer');
trace.selection.selectSqlEvent(sqlTable, id, {
switchToCurrentSelectionTab: doubleClick,
scrollToSelection: true,
});
},
})
: m(EmptyState, {
title: 'Query results will appear here',
fillHeight: true,
});
return m(SplitPanel, {
direction: 'vertical',
initialSplit: {percent: 50},
minSize: 100,
firstPanel: editorPanel,
secondPanel: resultsPanel,
});
}
private renderTablesTab(attrs: QueryPageAttrs): m.Children {
const sqlModulesPlugin = attrs.trace.plugins.getPlugin(SqlModulesPlugin);
const sqlModules = sqlModulesPlugin.getSqlModules();
if (!sqlModules) {
return m(
EmptyState,
{
title: 'Loading tables...',
icon: 'hourglass_empty',
fillHeight: true,
},
m(Spinner),
);
}
return m(TableList, {
sqlModules,
onQueryTable: (tableName, query) => {
attrs.onTabAdd?.(tableName, query, true);
},
});
}
private shouldDisplayPerfettoSqlAgentBanner(attrs: QueryPageAttrs) {
return (
attrs.trace.isInternalUser &&
localStorage.getItem(HIDE_PERFETTO_SQL_AGENT_BANNER_KEY) !== 'true'
);
}
private hidePerfettoSqlAgentBanner() {
localStorage.setItem(HIDE_PERFETTO_SQL_AGENT_BANNER_KEY, 'true');
}
private async formatSql(attrs: QueryPageAttrs, tabId: string, text: string) {
try {
const engine = await this.getFormatterEngine();
const result = engine.runFmt(text, {
lineWidth: 80,
indentWidth: 2,
keywordCase: 1,
semicolons: true,
});
if (result.ok) {
attrs.onEditorContentUpdate?.(tabId, result.text);
m.redraw();
} else {
console.error('SQL formatting failed', result.text);
}
} catch (e) {
console.error('SQL formatting failed', e);
}
}
}