Add editor tabs too
diff --git a/ui/src/assets/components/query_history.scss b/ui/src/assets/components/query_history.scss index dbf9faf..f0ee1cb 100644 --- a/ui/src/assets/components/query_history.scss +++ b/ui/src/assets/components/query_history.scss
@@ -13,20 +13,6 @@ // limitations under the License. .pf-query-history { - &__header { - position: sticky; - top: 0; - display: flex; - align-content: baseline; - background-color: var(--pf-color-background-secondary); - font-size: 15px; - font-weight: 400; - padding: 4px 10px; - vertical-align: middle; - border-bottom: 1px solid var(--pf-color-border); - z-index: 1; - } - &__item { border-bottom: 1px solid var(--pf-color-border-secondary); padding: 0.25em 0.5em;
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss index 1eb1501..ae0e945 100644 --- a/ui/src/assets/perfetto.scss +++ b/ui/src/assets/perfetto.scss
@@ -72,8 +72,8 @@ @import "widgets/split_panel.scss"; @import "widgets/sql_table"; @import "widgets/stack"; -@import "widgets/tab_bar"; @import "widgets/tabs"; +@import "widgets/tab_strip"; @import "widgets/tag_input"; @import "widgets/text_input"; @import "widgets/text_paragraph";
diff --git a/ui/src/assets/widgets/split_panel.scss b/ui/src/assets/widgets/split_panel.scss index 2fb9898..a2c06bc 100644 --- a/ui/src/assets/widgets/split_panel.scss +++ b/ui/src/assets/widgets/split_panel.scss
@@ -36,7 +36,7 @@ .pf-split-panel__handle { flex-shrink: 0; - background-color: var(--pf-color-background-secondary); + background-color: var(--pf-color-background); touch-action: none; }
diff --git a/ui/src/assets/widgets/tab_bar.scss b/ui/src/assets/widgets/tab_bar.scss deleted file mode 100644 index 82b5529..0000000 --- a/ui/src/assets/widgets/tab_bar.scss +++ /dev/null
@@ -1,78 +0,0 @@ -// 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 "../theme"; - -.pf-tab-bar { - display: flex; - flex-direction: column; - height: 100%; - - &__tabs { - display: flex; - background-color: var(--pf-color-background-secondary); - border-bottom: 1px solid var(--pf-color-border); - padding-inline: 3px; - align-items: flex-end; - } - - &__tab { - display: flex; - align-items: baseline; - gap: 2px; - - font-family: var(--pf-font-compact); - font-size: 14px; - - padding: 5px; - margin-top: 2px; - margin-bottom: -1px; // Overlap the border - cursor: pointer; - - border-radius: 3px 3px 0 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - border-style: solid; - border-color: var(--pf-color-border-secondary); - border-width: 1px 1px 0 1px; - - &:not(:first-child) { - margin-left: -1px; - } - - &:hover { - background: color_hover(transparent); - } - - &--active { - cursor: default; - background-color: var(--pf-color-background); - box-shadow: var(--pf-color-box-shadow) 0px 0px 3px; - border-color: var(--pf-color-border); - z-index: 1; - } - } - - &__tab-title { - margin: 0px 4px; - overflow: hidden; - user-select: none; - } - - &__content { - flex: 1; - overflow: auto; - } -}
diff --git a/ui/src/assets/widgets/tab_strip.scss b/ui/src/assets/widgets/tab_strip.scss new file mode 100644 index 0000000..7c37fe7 --- /dev/null +++ b/ui/src/assets/widgets/tab_strip.scss
@@ -0,0 +1,69 @@ +// 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. + +.pf-tab-strip { + display: flex; + align-items: baseline; + + &__tabs { + display: flex; + overflow: hidden; + flex: 1; + } + + &__tab { + color: var(--pf-color-text-muted); + padding: 4px; + margin-top: 3px; + align-items: center; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: flex; + align-items: baseline; + gap: 2px; + + &:hover { + background-color: color_hover(transparent); + } + + &[active] { + color: var(--pf-color-primary); + border-bottom: solid 2px var(--pf-color-primary); + cursor: default; + } + + &:nth-child(1) { + margin-left: 3px; + } + } + + &__tab-title { + margin: 0px 4px; + overflow: hidden; + } + + &__tab-icon { + &--left { + margin-right: 4px; + align-self: center; + } + + &--right { + margin-left: 4px; + align-self: center; + } + } +}
diff --git a/ui/src/assets/widgets/tabs.scss b/ui/src/assets/widgets/tabs.scss index 29ef45c..0baec22 100644 --- a/ui/src/assets/widgets/tabs.scss +++ b/ui/src/assets/widgets/tabs.scss
@@ -12,58 +12,66 @@ // See the License for the specific language governing permissions and // limitations under the License. +@import "../theme"; + .pf-tabs { display: flex; - align-items: baseline; + flex-direction: column; + height: 100%; + margin: 2px; &__tabs { display: flex; - overflow: hidden; - flex: 1; + background-color: var(--pf-color-background); + padding-inline: 3px; + align-items: flex-end; } &__tab { color: var(--pf-color-text-muted); - padding: 4px; - margin-top: 3px; + display: flex; align-items: center; + gap: 4px; + + padding: 4px 6px; + margin-top: 2px; cursor: pointer; + + border-radius: 3px 3px 0 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; - display: flex; - align-items: baseline; - gap: 2px; + + &:not(:first-child) { + margin-left: -1px; + } &:hover { - background-color: color_hover(transparent); + color: var(--pf-color-text); + background: color_hover(transparent); } - &[active] { - color: var(--pf-color-primary); - border-bottom: solid 2px var(--pf-color-primary); + &--active { cursor: default; - } + color: var(--pf-color-text); + background-color: var(--pf-color-background-secondary); - &:nth-child(1) { - margin-left: 3px; + &:hover { + background: var(--pf-color-background-secondary); + } } } &__tab-title { margin: 0px 4px; overflow: hidden; + user-select: none; } - &__tab-icon { - &--left { - margin-right: 4px; - align-self: center; - } - - &--right { - margin-left: 4px; - align-self: center; - } + &__content { + flex: 1; + overflow: auto; + background: var(--pf-color-background-secondary); + border-radius: 3px; } }
diff --git a/ui/src/components/widgets/query_history.ts b/ui/src/components/widgets/query_history.ts index 9ef86e0..70f14b5 100644 --- a/ui/src/components/widgets/query_history.ts +++ b/ui/src/components/widgets/query_history.ts
@@ -48,10 +48,6 @@ { ...rest, }, - m( - '.pf-query-history__header', - `Query history (${queryHistoryStorage.data.length} queries)`, - ), starred.map((attrs) => m(HistoryItemComponent, attrs)), unstarred.map((attrs) => m(HistoryItemComponent, attrs)), );
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/index.ts b/ui/src/plugins/dev.perfetto.QueryPage/index.ts index 110082e..8406792 100644 --- a/ui/src/plugins/dev.perfetto.QueryPage/index.ts +++ b/ui/src/plugins/dev.perfetto.QueryPage/index.ts
@@ -21,45 +21,99 @@ import {Flag} from '../../public/feature_flag'; import {PerfettoPlugin} from '../../public/plugin'; import {Trace} from '../../public/trace'; -import {QueryPage} from './query_page'; +import {QueryPage, QueryTabState} from './query_page'; import {queryHistoryStorage} from '../../components/widgets/query_history'; import {EmptyState} from '../../widgets/empty_state'; import {Anchor} from '../../widgets/anchor'; +let nextTabId = 1; + +function createNewTab(): QueryTabState { + return { + id: `query-${nextTabId++}`, + editorText: '', + executedQuery: undefined, + queryResult: undefined, + }; +} + export default class QueryPagePlugin implements PerfettoPlugin { static readonly id = 'dev.perfetto.QueryPage'; static addQueryPageMiniFlag: Flag; async onTraceLoad(trace: Trace): Promise<void> { - // The query page and tab share the same query data. - let executedQuery: string | undefined; - let queryResult: QueryResponse | undefined; + // State for multiple query tabs + const tabs: QueryTabState[] = [createNewTab()]; + let activeTabId = tabs[0].id; let isLoading = false; - let editorText = ''; + + function getActiveTab(): QueryTabState | undefined { + return tabs.find((t) => t.id === activeTabId); + } async function onExecute(text: string) { if (!text) return; - executedQuery = text; - queryResult = undefined; + const tab = getActiveTab(); + if (!tab) return; + + tab.executedQuery = text; + tab.queryResult = undefined; queryHistoryStorage.saveQuery(text); isLoading = true; - queryResult = await runQueryForQueryTable(text, trace.engine); + m.redraw(); + + tab.queryResult = await runQueryForQueryTable(text, trace.engine); isLoading = false; trace.tabs.showTab('dev.perfetto.QueryPage'); } + function onAddTab() { + const newTab = createNewTab(); + tabs.push(newTab); + activeTabId = newTab.id; + } + + function onCloseTab(tabId: string) { + const index = tabs.findIndex((t) => t.id === tabId); + if (index === -1) return; + + // Don't close the last tab + if (tabs.length === 1) return; + + tabs.splice(index, 1); + + // If we closed the active tab, switch to another + if (activeTabId === tabId) { + activeTabId = tabs[Math.min(index, tabs.length - 1)].id; + } + } + + function onTabChange(tabId: string) { + activeTabId = tabId; + } + + function onEditorContentUpdate(tabId: string, text: string) { + const tab = tabs.find((t) => t.id === tabId); + if (tab) { + tab.editorText = text; + } + } + trace.pages.registerPage({ route: '/query', render: () => m(QueryPage, { trace, - editorText, - executedQuery, - queryResult, - onEditorContentUpdate: (text) => (editorText = text), + tabs, + activeTabId, + isLoading, + onTabChange, + onAddTab, + onCloseTab, + onEditorContentUpdate, onExecute, }), }); @@ -77,11 +131,12 @@ isEphemeral: false, content: { render() { + const activeTab = getActiveTab(); return m(QueryResultsTable, { trace, isLoading, - query: executedQuery, - resp: queryResult, + query: activeTab?.executedQuery, + resp: activeTab?.queryResult, fillHeight: true, emptyState: m( EmptyState,
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss b/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss index b700409..4dbfd7d 100644 --- a/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss +++ b/ui/src/plugins/dev.perfetto.QueryPage/query_page.scss
@@ -83,14 +83,18 @@ display: flex; flex-direction: column; height: 100%; + padding: 2px; + gap: 2px; &__search { - margin: 8px; + margin: 2px; } &__items { flex: 1; overflow: auto; + background: var(--pf-color-background); + border-radius: 3px; } &__item { @@ -119,22 +123,27 @@ } } + &__item-icon { + color: var(--pf-color-foreground-secondary); + } + &__item-name { - cursor: pointer; font-family: var(--pf-font-monospace); font-size: 12px; flex: 1; - - &:hover { - color: var(--pf-color-primary); - text-decoration: underline; - } } &__item-details { padding: 0 8px 12px 32px; } + &__description { + font-size: 12px; + color: var(--pf-color-foreground-secondary); + margin-bottom: 8px; + line-height: 1.4; + } + &__detail-row { display: flex; gap: 8px; @@ -167,10 +176,16 @@ &__column { display: flex; - gap: 8px; + align-items: center; + gap: 4px; padding: 2px 0; } + &__column-icon { + font-size: 14px; + color: var(--pf-color-foreground-secondary); + } + &__column-name { font-size: 12px; min-width: 120px;
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts b/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts index c4122e9..689c49f 100644 --- a/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts +++ b/ui/src/plugins/dev.perfetto.QueryPage/query_page.ts
@@ -33,12 +33,13 @@ import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs'; import {Spinner} from '../../widgets/spinner'; import {SplitPanel} from '../../widgets/split_panel'; -import {TabBar} from '../../widgets/tab_bar'; +import {Tabs} from '../../widgets/tabs'; import {Stack, StackAuto} from '../../widgets/stack'; import {CopyToClipboardButton} from '../../widgets/copy_to_clipboard_button'; import {Anchor} from '../../widgets/anchor'; import {getSliceId, isSliceish} from '../../components/query_table/query_table'; import {DataSource} from '../../components/widgets/datagrid/data_source'; +import {Row} from '../../trace_processor/query_result'; import {PopupMenu} from '../../widgets/menu'; import {PopupPosition} from '../../widgets/popup'; import {AddDebugTrackMenu} from '../../components/tracks/add_debug_track_menu'; @@ -47,35 +48,118 @@ const HIDE_PERFETTO_SQL_AGENT_BANNER_KEY = 'hidePerfettoSqlAgentBanner'; +export interface QueryTabState { + id: string; + editorText: string; + executedQuery?: string; + queryResult?: QueryResponse; +} + export interface QueryPageAttrs { readonly trace: Trace; - readonly editorText: string; - readonly executedQuery?: string; - readonly queryResult?: QueryResponse; + readonly tabs: QueryTabState[]; + readonly activeTabId: string; + readonly isLoading: boolean; - onEditorContentUpdate?(content: string): void; + onTabChange(tabId: string): void; + onAddTab(): void; + onCloseTab(tabId: string): void; + onEditorContentUpdate(tabId: string, content: string): void; + onExecute(query: string): void; +} - onExecute?(query: string): void; +interface DataSourceInfo { + dataSource: DataSource; + sourceRows: readonly Row[]; } export class QueryPage implements m.ClassComponent<QueryPageAttrs> { - private dataSource?: DataSource; - - onbeforeupdate( - vnode: m.Vnode<QueryPageAttrs>, - oldVnode: m.Vnode<QueryPageAttrs>, - ) { - // Update the datasource if present - if (vnode.attrs.queryResult !== oldVnode.attrs.queryResult) { - if (vnode.attrs.queryResult) { - this.dataSource = new InMemoryDataSource(vnode.attrs.queryResult.rows); - } else { - this.dataSource = undefined; - } - } - } + // Map of tab ID to data source info (including source rows for comparison) + private dataSourceInfo = new Map<string, DataSourceInfo>(); view({attrs}: m.CVnode<QueryPageAttrs>) { + const activeTab = attrs.tabs.find((t) => t.id === attrs.activeTabId); + + // Build tabs for the left panel + const queryTabs = attrs.tabs.map((tab, index) => ({ + key: tab.id, + title: `Query ${index + 1}`, + icon: 'code', + closable: attrs.tabs.length > 1, + content: this.renderQueryTab(attrs, tab), + })); + + const leftPanel = m(Tabs, { + className: 'pf-query-page__query-tabs', + tabs: queryTabs, + activeTabKey: attrs.activeTabId, + onTabChange: attrs.onTabChange, + onTabClose: attrs.onCloseTab, + onAddTab: attrs.onAddTab, + }); + + const sidebarPanel = m(Tabs, { + className: 'pf-query-page__sidebar', + tabs: [ + { + key: 'history', + title: 'History', + icon: 'history', + content: m(QueryHistoryComponent, { + className: 'pf-query-page__history', + trace: attrs.trace, + runQuery: (query: string) => { + attrs.onExecute(query); + }, + setQuery: (query: string) => { + if (activeTab) { + attrs.onEditorContentUpdate(activeTab.id, query); + } + }, + }), + }, + { + key: 'tables', + title: 'Tables', + icon: 'table', + content: this.renderTablesTab(attrs), + }, + ], + }); + + return m( + '.pf-query-page', + m(SplitPanel, { + direction: 'horizontal', + split: {percent: 70}, + minSize: 100, + firstPanel: leftPanel, + secondPanel: sidebarPanel, + }), + ); + } + + private renderQueryTab( + attrs: QueryPageAttrs, + tab: QueryTabState, + ): m.Children { + // Get or create data source for this tab + let info = this.dataSourceInfo.get(tab.id); + let dataSource = info?.dataSource; + if (tab.queryResult && tab.queryResult.rows) { + // Check if we need to update the data source + if (!info || info.sourceRows !== tab.queryResult.rows) { + dataSource = new InMemoryDataSource(tab.queryResult.rows); + this.dataSourceInfo.set(tab.id, { + dataSource, + sourceRows: tab.queryResult.rows, + }); + } + } else if (info && !tab.queryResult) { + this.dataSourceInfo.delete(tab.id); + dataSource = undefined; + } + const editorPanel = m('.pf-query-page__editor-panel', [ m(Box, {className: 'pf-query-page__toolbar'}, [ m(Stack, {orientation: 'horizontal'}, [ @@ -85,7 +169,7 @@ intent: Intent.Primary, variant: ButtonVariant.Filled, onclick: () => { - attrs.onExecute?.(attrs.editorText); + attrs.onExecute(tab.editorText); }, }), m( @@ -97,7 +181,7 @@ 'or press', m(HotkeyGlyphs, {hotkey: 'Mod+Enter'}), ), - m(StackAuto), // The spacer pushes the following buttons to the right. + m(StackAuto), attrs.trace.isInternalUser && m(Button, { icon: 'wand_stars', @@ -109,7 +193,7 @@ }, }), m(CopyToClipboardButton, { - textToCopy: attrs.editorText, + textToCopy: tab.editorText, title: 'Copy query to clipboard', label: 'Copy Query', }), @@ -152,7 +236,7 @@ ], ), ), - attrs.editorText.includes('"') && + tab.editorText.includes('"') && m( Box, m( @@ -165,21 +249,17 @@ ), m(Editor, { language: 'perfetto-sql', - text: attrs.editorText, - onUpdate: attrs.onEditorContentUpdate, + text: tab.editorText, + onUpdate: (text) => attrs.onEditorContentUpdate(tab.id, text), onExecute: attrs.onExecute, }), ]); const resultsPanel = m( '.pf-query-page__results-panel', - this.dataSource && attrs.queryResult - ? this.renderQueryResult( - attrs.trace, - attrs.queryResult, - this.dataSource, - ) - : attrs.executedQuery + dataSource && tab.queryResult + ? this.renderQueryResult(attrs.trace, tab.queryResult, dataSource) + : tab.executedQuery && attrs.isLoading && tab.id === attrs.activeTabId ? m(EmptyState, { title: 'Running query...', icon: 'hourglass_empty', @@ -191,49 +271,13 @@ }), ); - const sidebarPanel = m(TabBar, { - className: 'pf-query-page__sidebar', - tabs: [ - { - key: 'history', - title: 'History', - content: m(QueryHistoryComponent, { - className: 'pf-query-page__history', - trace: attrs.trace, - runQuery: (query: string) => { - attrs.onExecute?.(query); - }, - setQuery: (query: string) => { - attrs.onEditorContentUpdate?.(query); - }, - }), - }, - { - key: 'tables', - title: 'Tables', - content: this.renderTablesTab(attrs), - }, - ], - }); - - const leftPanel = m(SplitPanel, { + return m(SplitPanel, { direction: 'vertical', - split: {percent: 70}, + split: {percent: 50}, minSize: 100, firstPanel: editorPanel, secondPanel: resultsPanel, }); - - return m( - '.pf-query-page', - m(SplitPanel, { - direction: 'horizontal', - split: {percent: 70}, - minSize: 100, - firstPanel: leftPanel, - secondPanel: sidebarPanel, - }), - ); } private renderQueryResult( @@ -349,12 +393,7 @@ ); } - return m(SimpleTableList, { - sqlModules, - onTableClick: (tableName) => { - attrs.onExecute?.(`SELECT * FROM ${tableName} LIMIT 100`); - }, - }); + return m(SimpleTableList, {sqlModules}); } private shouldDisplayPerfettoSqlAgentBanner(attrs: QueryPageAttrs) {
diff --git a/ui/src/plugins/dev.perfetto.QueryPage/simple_table_list.ts b/ui/src/plugins/dev.perfetto.QueryPage/simple_table_list.ts index b5ea321..784fd8b 100644 --- a/ui/src/plugins/dev.perfetto.QueryPage/simple_table_list.ts +++ b/ui/src/plugins/dev.perfetto.QueryPage/simple_table_list.ts
@@ -13,13 +13,43 @@ // limitations under the License. import m from 'mithril'; +import { + PerfettoSqlType, + perfettoSqlTypeToString, +} from '../../trace_processor/perfetto_sql_type'; import {Icon} from '../../widgets/icon'; import {TextInput} from '../../widgets/text_input'; import {SqlModules, SqlTable} from '../dev.perfetto.SqlModules/sql_modules'; +function getTypeIcon(type?: PerfettoSqlType): string { + if (!type) return 'help_outline'; + switch (type.kind) { + case 'int': + return 'tag'; + case 'double': + return 'decimal_increase'; + case 'string': + return 'text_fields'; + case 'boolean': + return 'toggle_on'; + case 'timestamp': + return 'schedule'; + case 'duration': + return 'timer'; + case 'bytes': + return 'memory'; + case 'arg_set_id': + return 'data_object'; + case 'id': + case 'joinid': + return 'key'; + default: + return 'help_outline'; + } +} + export interface SimpleTableListAttrs { readonly sqlModules: SqlModules; - readonly onTableClick: (tableName: string) => void; } export class SimpleTableList implements m.ClassComponent<SimpleTableListAttrs> { @@ -49,17 +79,12 @@ }), m( '.pf-simple-table-list__items', - filteredTables.map((table) => - this.renderTableItem(table, attrs.onTableClick), - ), + filteredTables.map((table) => this.renderTableItem(table)), ), ); } - private renderTableItem( - table: SqlTable, - onTableClick: (tableName: string) => void, - ): m.Children { + private renderTableItem(table: SqlTable): m.Children { const isExpanded = this.expandedTables.has(table.name); return m( @@ -81,16 +106,16 @@ }, m(Icon, {icon: isExpanded ? 'expand_more' : 'chevron_right'}), ), - m( - '.pf-simple-table-list__item-name', - {onclick: () => onTableClick(table.name)}, - table.name, - ), + m(Icon, {icon: 'table', className: 'pf-simple-table-list__item-icon'}), + m('.pf-simple-table-list__item-name', table.name), ), // Expandable details isExpanded && m( '.pf-simple-table-list__item-details', + // Description + table.description && + m('.pf-simple-table-list__description', table.description), // Module table.includeKey && m( @@ -108,10 +133,15 @@ table.columns.map((col) => m( '.pf-simple-table-list__column', + {title: col.description}, + m(Icon, { + icon: getTypeIcon(col.type), + className: 'pf-simple-table-list__column-icon', + }), m('code.pf-simple-table-list__column-name', col.name), m( 'span.pf-simple-table-list__column-type', - col.type ?? 'unknown', + perfettoSqlTypeToString(col.type), ), ), ),
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tab_bar_demo.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tab_bar_demo.ts index bb10ee5..8da9229 100644 --- a/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tab_bar_demo.ts +++ b/ui/src/plugins/dev.perfetto.WidgetsPage/demos/tab_bar_demo.ts
@@ -13,7 +13,7 @@ // limitations under the License. import m from 'mithril'; -import {TabBar, TabBarTab} from '../../../widgets/tab_bar'; +import {Tabs, TabBarTab} from '../../../widgets/tabs'; import {renderWidgetShowcase} from '../widgets_page_utils'; export function renderTabBar(): m.Children { @@ -70,7 +70,7 @@ border: '1px solid var(--pf-color-border)', }, }, - m(TabBar, { + m(Tabs, { tabs, onTabClose: opts.closable ? (key) => {
diff --git a/ui/src/widgets/tab_strip.ts b/ui/src/widgets/tab_strip.ts index 5c2f488..54a3970 100644 --- a/ui/src/widgets/tab_strip.ts +++ b/ui/src/widgets/tab_strip.ts
@@ -33,10 +33,10 @@ view({attrs}: m.CVnode<TabStripAttrs>) { const {tabs, currentTabKey, onTabChange, className} = attrs; return m( - '.pf-tabs', + '.pf-tab-strip', {className}, m( - '.pf-tabs__tabs', + '.pf-tab-strip__tabs', tabs.map((tab) => { const {key, title, leftIcon, rightIcon} = tab; const renderIcon = ( @@ -49,10 +49,10 @@ if (typeof icon === 'string') { return m(Icon, {icon, className}); } - return m('.pf-tabs__tab-icon', {className}, icon); + return m('.pf-tab-strip__tab-icon', {className}, icon); }; return m( - '.pf-tabs__tab', + '.pf-tab-strip__tab', { active: currentTabKey === key, key, @@ -61,9 +61,9 @@ }, }, [ - renderIcon(leftIcon, 'pf-tabs__tab-icon--left'), - m('span.pf-tabs__tab-title', title), - renderIcon(rightIcon, 'pf-tabs__tab-icon--right'), + renderIcon(leftIcon, 'pf-tab-strip__tab-icon--left'), + m('span.pf-tab-strip__tab-title', title), + renderIcon(rightIcon, 'pf-tab-strip__tab-icon--right'), ], ); }),
diff --git a/ui/src/widgets/tab_bar.ts b/ui/src/widgets/tabs.ts similarity index 74% rename from ui/src/widgets/tab_bar.ts rename to ui/src/widgets/tabs.ts index bcefe54..1dec8d3 100644 --- a/ui/src/widgets/tab_bar.ts +++ b/ui/src/widgets/tabs.ts
@@ -16,6 +16,7 @@ import {classNames} from '../base/classnames'; import {Gate} from '../base/mithril_utils'; import {Button} from './button'; +import {Icon} from './icon'; import {Icons} from '../base/semantic_icons'; export interface TabBarTab { @@ -25,11 +26,13 @@ readonly title: m.Children; // Content to display when this tab is active. readonly content: m.Children; + // Optional icon to display before the title. + readonly icon?: string; // Whether to show a close button on the tab. readonly closable?: boolean; } -export interface TabBarAttrs { +export interface TabsAttrs { // The tabs to display. readonly tabs: TabBarTab[]; // The currently active tab key (controlled mode). @@ -39,12 +42,16 @@ onTabChange?(key: string): void; // Called when a tab's close button is clicked. onTabClose?(key: string): void; + // Called when the add tab button is clicked. + // If provided, an add tab button will be shown. + onAddTab?(): void; // Additional class name for the container. readonly className?: string; } interface TabHandleAttrs { readonly active?: boolean; + readonly icon?: string; readonly hasCloseButton?: boolean; readonly onClose?: () => void; readonly onclick?: () => void; @@ -52,15 +59,16 @@ class TabHandle implements m.ClassComponent<TabHandleAttrs> { view({attrs, children}: m.CVnode<TabHandleAttrs>): m.Children { - const {active, hasCloseButton, onClose, onclick} = attrs; + const {active, icon, hasCloseButton, onClose, onclick} = attrs; return m( - '.pf-tab-bar__tab', + '.pf-tabs__tab', { - className: classNames(active && 'pf-tab-bar__tab--active'), + className: classNames(active && 'pf-tabs__tab--active'), onclick, onauxclick: () => onClose?.(), }, - m('.pf-tab-bar__tab-title', children), + icon && m(Icon, {icon}), + m('.pf-tabs__tab-title', children), hasCloseButton && m(Button, { compact: true, @@ -74,12 +82,13 @@ } } -export class TabBar implements m.ClassComponent<TabBarAttrs> { +export class Tabs implements m.ClassComponent<TabsAttrs> { // Current active tab key (for uncontrolled mode). private internalActiveTab?: string; - view({attrs}: m.CVnode<TabBarAttrs>): m.Children { - const {tabs, activeTabKey, onTabChange, onTabClose, className} = attrs; + view({attrs}: m.CVnode<TabsAttrs>): m.Children { + const {tabs, activeTabKey, onTabChange, onTabClose, onAddTab, className} = + attrs; if (tabs.length === 0) { return null; @@ -89,15 +98,16 @@ const activeKey = activeTabKey ?? this.internalActiveTab ?? tabs[0].key; return m( - '.pf-tab-bar', + '.pf-tabs', {className}, m( - '.pf-tab-bar__tabs', + '.pf-tabs__tabs', tabs.map((tab) => m( TabHandle, { active: tab.key === activeKey, + icon: tab.icon, hasCloseButton: tab.closable, onclick: () => { this.internalActiveTab = tab.key; @@ -108,9 +118,17 @@ tab.title, ), ), + onAddTab && + m(Button, { + className: 'pf-tabs__add-tab', + compact: true, + icon: Icons.Add, + title: 'Add new tab', + onclick: onAddTab, + }), ), m( - '.pf-tab-bar__content', + '.pf-tabs__content', tabs.map((tab) => m(Gate, {open: tab.key === activeKey}, tab.content)), ), );