| // Copyright (C) 2026 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 './sidebar.scss'; |
| import m from 'mithril'; |
| import {assetSrc} from '../../base/assets'; |
| import {AppImpl} from '../../core/app_impl'; |
| import {getCurrentChannel} from '../../core/channels'; |
| import {isMetatracingEnabled} from '../../core/metatracing'; |
| import {raf} from '../../core/raf_scheduler'; |
| import type {SidebarMenuItemInternal} from '../../core/sidebar_manager'; |
| import type {TraceImpl} from '../../core/trace_impl'; |
| import {SCM_REVISION, VERSION} from '../../virtual/version'; |
| import type {App} from '../../public/app'; |
| import {SIDEBAR_SECTIONS, type SidebarSections} from '../../public/sidebar'; |
| import {Icon} from '../../widgets/icon'; |
| import {Animation} from '../animation'; |
| import {toggleHelp} from '../help_modal'; |
| import { |
| convertTraceToJson, |
| convertTraceToSystrace, |
| downloadTrace, |
| toggleMetatrace, |
| } from '../trace_actions'; |
| import {shareTrace} from '../trace_share_utils'; |
| import {EngineStatusBadge} from './engine_status_badge'; |
| import {HiringBanner, shouldShowHiringBanner} from './hiring_banner'; |
| import {ServiceWorkerStatusBadge} from './service_worker_status_badge'; |
| import {SidebarSection} from './sidebar_section'; |
| |
| const GITILES_URL = 'https://github.com/google/perfetto'; |
| |
| export interface SidebarAttrs { |
| readonly app: AppImpl; |
| } |
| |
| export class Sidebar implements m.ClassComponent<SidebarAttrs> { |
| private _redrawWhileAnimating = new Animation(() => raf.scheduleFullRedraw()); |
| |
| view({attrs}: m.CVnode<SidebarAttrs>) { |
| const app = attrs.app; |
| const sidebar = app.sidebar; |
| if (!sidebar.enabled) return null; |
| return m( |
| 'nav.pf-sidebar', |
| { |
| class: sidebar.visible ? undefined : 'pf-sidebar--hidden', |
| // 150 here matches --sidebar-timing in the css. |
| // TODO(hjd): Should link to the CSS variable. |
| ontransitionstart: (e: TransitionEvent) => { |
| if (e.target !== e.currentTarget) return; |
| this._redrawWhileAnimating.start(150); |
| }, |
| ontransitionend: (e: TransitionEvent) => { |
| if (e.target !== e.currentTarget) return; |
| this._redrawWhileAnimating.stop(); |
| }, |
| }, |
| shouldShowHiringBanner(app) && m(HiringBanner), |
| this.renderSidebarHeader(app), |
| this.renderSidebarContent(app), |
| this.renderSidebarFooter(app), |
| ); |
| } |
| |
| private renderSidebarHeader(app: AppImpl) { |
| const sidebar = app.sidebar; |
| return m( |
| `header.pf-sidebar__header`, |
| { |
| className: `pf-sidebar__header--${getCurrentChannel()}`, |
| }, |
| m(`img[src=${assetSrc('assets/brand.png')}].pf-sidebar__brand`), |
| app.embedder.brandingBadge && |
| m( |
| 'span.pf-sidebar__branding-badge', |
| {style: {color: app.embedder.brandingBadge.color}}, |
| app.embedder.brandingBadge.image |
| ? m('img.pf-sidebar__branding-badge-img', { |
| src: app.embedder.brandingBadge.image, |
| }) |
| : app.embedder.brandingBadge.icon && |
| m(Icon, { |
| icon: app.embedder.brandingBadge.icon, |
| className: 'pf-sidebar__branding-badge-icon', |
| }), |
| app.embedder.brandingBadge.text, |
| ), |
| m( |
| 'button.pf-sidebar-button', |
| { |
| onclick: () => sidebar.toggleVisibility(), |
| title: sidebar.visible ? 'Hide sidebar' : 'Show sidebar', |
| }, |
| m(Icon, {icon: 'menu'}), |
| ), |
| ); |
| } |
| |
| private renderSidebarContent(app: AppImpl) { |
| return m( |
| '.pf-sidebar__content', |
| (Object.keys(SIDEBAR_SECTIONS) as SidebarSections[]).map((sectionId) => |
| this.renderSection(app, sectionId), |
| ), |
| ); |
| } |
| |
| private renderSidebarFooter(app: AppImpl) { |
| return m( |
| '.pf-sidebar__footer', |
| m(EngineStatusBadge, {app}), |
| m(ServiceWorkerStatusBadge, {app}), |
| m( |
| '.pf-sidebar__version.pf-test-volatile', |
| m( |
| 'a', |
| { |
| href: `${GITILES_URL}/tree/${SCM_REVISION}/ui`, |
| title: `Channel: ${getCurrentChannel()}`, |
| target: '_blank', |
| }, |
| VERSION, |
| ), |
| ), |
| ); |
| } |
| |
| private renderSection(app: AppImpl, sectionId: SidebarSections) { |
| const section = SIDEBAR_SECTIONS[sectionId]; |
| const trace = app.trace; |
| |
| const items: SidebarMenuItemInternal[] = app.sidebar.menuItems |
| .valuesAsArray() |
| .filter((item) => item.section === sectionId); |
| |
| let defaultCollapsed = false; |
| let leading: m.Children; |
| switch (sectionId) { |
| case 'current_trace': |
| if (trace !== undefined) { |
| items.push(...getCurrentTraceItems(trace)); |
| if (trace.traceInfo.traceTitle) { |
| leading = m( |
| 'span.pf-sidebar__trace-file-name', |
| trace.traceInfo.traceTitle, |
| ); |
| } |
| } |
| break; |
| case 'convert_trace': |
| if (trace !== undefined) { |
| items.push(...getConvertTraceItems(trace)); |
| } |
| defaultCollapsed = true; |
| break; |
| case 'support': |
| items.push(...getSupportGlobalItems(app)); |
| if (trace !== undefined) { |
| items.push(...getSupportTraceItems(trace)); |
| } |
| defaultCollapsed = true; |
| break; |
| } |
| |
| return m(SidebarSection, { |
| app, |
| title: section.title, |
| summary: section.summary, |
| items, |
| defaultCollapsed, |
| leading, |
| }); |
| } |
| } |
| |
| // TODO(primiano): The items below should be moved to dedicated |
| // plugins (most of this really belongs to core_plugins/commands/index.ts). |
| // For now keeping everything here as splitting these require moving some |
| // functions like share_trace() out of core, splitting out permalink, etc. |
| |
| // Returns menu items for the 'current_trace' section. |
| function getCurrentTraceItems(trace: TraceImpl): SidebarMenuItemInternal[] { |
| const items: SidebarMenuItemInternal[] = []; |
| const downloadDisabled = trace.traceInfo.downloadable |
| ? false |
| : 'Cannot download external trace'; |
| |
| if (AppImpl.instance.isInternalUser) { |
| items.push({ |
| id: 'perfetto.ShareTrace', |
| section: 'current_trace', |
| sortOrder: 50, |
| text: 'Share', |
| action: async () => await shareTrace(trace), |
| icon: 'share', |
| }); |
| } |
| |
| items.push({ |
| id: 'perfetto.DownloadTrace', |
| section: 'current_trace', |
| sortOrder: 51, |
| text: 'Download', |
| action: async () => await downloadTrace(trace), |
| icon: 'file_download', |
| disabled: downloadDisabled, |
| }); |
| |
| return items; |
| } |
| |
| // Returns menu items for the 'convert_trace' section. |
| function getConvertTraceItems(trace: TraceImpl): SidebarMenuItemInternal[] { |
| const items: SidebarMenuItemInternal[] = []; |
| const downloadDisabled = trace.traceInfo.downloadable |
| ? false |
| : 'Cannot download external trace'; |
| |
| items.push({ |
| id: 'perfetto.ConvertToJson', |
| section: 'convert_trace', |
| text: 'Convert to .json', |
| action: async () => await convertTraceToJson(trace), |
| icon: 'file_download', |
| disabled: downloadDisabled, |
| }); |
| |
| if (trace.traceInfo.hasFtrace) { |
| items.push({ |
| id: 'perfetto.ConvertToSystrace', |
| section: 'convert_trace', |
| text: 'Convert to .systrace', |
| action: async () => await convertTraceToSystrace(trace), |
| icon: 'file_download', |
| disabled: downloadDisabled, |
| }); |
| } |
| |
| return items; |
| } |
| |
| // Returns global menu items for the 'support' section (always visible). |
| function getSupportGlobalItems(app: App): SidebarMenuItemInternal[] { |
| // TODO(primiano): The Open file / Open with legacy entries are registered by |
| // the 'perfetto.CoreCommands' plugin. These built-in items should move there too. |
| return [ |
| { |
| id: 'perfetto.KeyboardShortcuts', |
| section: 'support', |
| text: 'Keyboard shortcuts', |
| action: toggleHelp, |
| icon: 'help', |
| }, |
| { |
| id: 'perfetto.Documentation', |
| section: 'support', |
| text: 'Documentation', |
| href: 'https://perfetto.dev/docs', |
| icon: 'find_in_page', |
| }, |
| { |
| id: 'perfetto.ReportBug', |
| section: 'support', |
| sortOrder: 4, |
| text: 'Report a bug', |
| href: getBugReportUrl(app), |
| icon: 'bug_report', |
| }, |
| ]; |
| } |
| |
| function getBugReportUrl(app: App): string { |
| if (app.isInternalUser) { |
| return 'https://goto.google.com/perfetto-ui-bug'; |
| } else { |
| return 'https://github.com/google/perfetto/issues/new'; |
| } |
| } |
| |
| // Returns trace-specific menu items for the 'support' section. |
| function getSupportTraceItems(trace: TraceImpl): SidebarMenuItemInternal[] { |
| return [ |
| { |
| id: 'perfetto.Metatrace', |
| section: 'support', |
| sortOrder: 5, |
| text: () => |
| isMetatracingEnabled() ? 'Finalize metatrace' : 'Record metatrace', |
| action: () => toggleMetatrace(trace.engine), |
| icon: () => (isMetatracingEnabled() ? 'download' : 'fiber_smart_record'), |
| }, |
| ]; |
| } |