| 1 | /** |
| 2 | * Copyright (c) Meta Platforms, Inc. and affiliates. |
| 3 | * |
| 4 | * This source code is licensed under the MIT license found in the |
| 5 | * LICENSE file in the root directory of this source tree. |
| 6 | * |
| 7 | * @flow |
| 8 | */ |
| 9 | |
| 10 | import * as React from 'react'; |
| 11 | import {Fragment, useContext, useEffect, useRef, useEffectEvent} from 'react'; |
| 12 | import {ModalDialog} from '../ModalDialog'; |
| 13 | import {ProfilerContext} from './ProfilerContext'; |
| 14 | import Button from '../Button'; |
| 15 | import ButtonIcon from '../ButtonIcon'; |
| 16 | import TabBar from '../TabBar'; |
| 17 | import ClearProfilingDataButton from './ClearProfilingDataButton'; |
| 18 | import CommitFlamegraph from './CommitFlamegraph'; |
| 19 | import CommitRanked from './CommitRanked'; |
| 20 | import RootSelector from './RootSelector'; |
| 21 | import RecordToggle from './RecordToggle'; |
| 22 | import ReloadAndProfileButton from './ReloadAndProfileButton'; |
| 23 | import ProfilingImportExportButtons from './ProfilingImportExportButtons'; |
| 24 | import SnapshotSelector from './SnapshotSelector'; |
| 25 | import ProfilerSearchInput from './ProfilerSearchInput'; |
| 26 | import SidebarCommitInfo from './SidebarCommitInfo'; |
| 27 | import NoProfilingData from './NoProfilingData'; |
| 28 | import RecordingInProgress from './RecordingInProgress'; |
| 29 | import ProcessingData from './ProcessingData'; |
| 30 | import ProfilingNotSupported from './ProfilingNotSupported'; |
| 31 | import SidebarSelectedFiberInfo from './SidebarSelectedFiberInfo'; |
| 32 | import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal'; |
| 33 | import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle'; |
| 34 | import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext'; |
| 35 | import portaledContent from '../portaledContent'; |
| 36 | |
| 37 | import styles from './Profiler.css'; |
| 38 | |
| 39 | function Profiler(_: {}) { |
| 40 | const profilerRef = useRef<HTMLDivElement | null>(null); |
| 41 | const isMac = |
| 42 | typeof navigator !== 'undefined' && |
| 43 | navigator.platform.toUpperCase().indexOf('MAC') >= 0; |
| 44 | |
| 45 | const { |
| 46 | didRecordCommits, |
| 47 | isProcessingData, |
| 48 | isProfiling, |
| 49 | selectedCommitIndex, |
| 50 | selectedFiberID, |
| 51 | selectedTabID, |
| 52 | selectTab, |
| 53 | supportsProfiling, |
| 54 | startProfiling, |
| 55 | stopProfiling, |
| 56 | selectPrevCommitIndex, |
| 57 | selectNextCommitIndex, |
| 58 | isSearchInputVisible, |
| 59 | showSearchInput, |
| 60 | hideSearchInput, |
| 61 | } = useContext(ProfilerContext); |
| 62 | |
| 63 | const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { |
| 64 | const correctModifier = isMac ? event.metaKey : event.ctrlKey; |
| 65 | // Cmd+E to start/stop profiler recording |
| 66 | if (correctModifier && event.key === 'e') { |
| 67 | if (isProfiling) { |
| 68 | stopProfiling(); |
| 69 | } else { |
| 70 | startProfiling(); |
| 71 | } |
| 72 | event.preventDefault(); |
| 73 | event.stopPropagation(); |
| 74 | } else if (didRecordCommits && correctModifier && event.key === 'f') { |
| 75 | // Cmd+F (Mac) or Ctrl+F (Windows/Linux) to search components in the commit |
| 76 | showSearchInput(); |
| 77 | event.preventDefault(); |
| 78 | event.stopPropagation(); |
| 79 | } else if (isSearchInputVisible && event.key === 'Escape') { |
| 80 | // Escape closes the search input. |
| 81 | hideSearchInput(); |
| 82 | event.preventDefault(); |
| 83 | event.stopPropagation(); |
| 84 | } else if (didRecordCommits && selectedCommitIndex !== null) { |
| 85 | // Cmd+Left/Right (Mac) or Ctrl+Left/Right (Windows/Linux) to navigate commits |
| 86 | if ( |
| 87 | correctModifier && |
| 88 | (event.key === 'ArrowLeft' || event.key === 'ArrowRight') |
| 89 | ) { |
| 90 | if (event.key === 'ArrowLeft') { |
| 91 | selectPrevCommitIndex(); |
| 92 | } else { |
| 93 | selectNextCommitIndex(); |
| 94 | } |
| 95 | event.preventDefault(); |
| 96 | event.stopPropagation(); |
| 97 | } |
| 98 | } |
| 99 | }); |
| 100 | |
| 101 | useEffect(() => { |
| 102 | const div = profilerRef.current; |
| 103 | if (!div) { |
| 104 | return; |
| 105 | } |
| 106 | const ownerWindow = div.ownerDocument.defaultView; |
| 107 | // Capture phase: Cmd/Ctrl+F is a reserved browser shortcut (Find), so we |
| 108 | // must intercept it before the browser to open our own search instead. |
| 109 | ownerWindow.addEventListener('keydown', handleKeyDown, true); |
| 110 | return () => { |
| 111 | ownerWindow.removeEventListener('keydown', handleKeyDown, true); |
| 112 | }; |
| 113 | }, []); |
| 114 | |
| 115 | let view = null; |
| 116 | if (didRecordCommits) { |
| 117 | switch (selectedTabID) { |
| 118 | case 'flame-chart': |
| 119 | view = <CommitFlamegraph />; |
| 120 | break; |
| 121 | case 'ranked-chart': |
| 122 | view = <CommitRanked />; |
| 123 | break; |
| 124 | default: |
| 125 | break; |
| 126 | } |
| 127 | } else if (isProfiling) { |
| 128 | view = <RecordingInProgress />; |
| 129 | } else if (isProcessingData) { |
| 130 | view = <ProcessingData />; |
| 131 | } else if (supportsProfiling) { |
| 132 | view = <NoProfilingData />; |
| 133 | } else { |
| 134 | view = <ProfilingNotSupported />; |
| 135 | } |
| 136 | |
| 137 | let sidebar = null; |
| 138 | if (!isProfiling && !isProcessingData && didRecordCommits) { |
| 139 | switch (selectedTabID) { |
| 140 | case 'flame-chart': |
| 141 | case 'ranked-chart': |
| 142 | // TRICKY |
| 143 | // Handle edge case where no commit is selected because of a min-duration filter update. |
| 144 | // In that case, the selected commit index would be null. |
| 145 | // We could still show a sidebar for the previously selected fiber, |
| 146 | // but it would be an odd user experience. |
| 147 | // TODO (ProfilerContext) This check should not be necessary. |
| 148 | if (selectedCommitIndex !== null) { |
| 149 | if (selectedFiberID !== null) { |
| 150 | sidebar = <SidebarSelectedFiberInfo />; |
| 151 | } else { |
| 152 | sidebar = <SidebarCommitInfo />; |
| 153 | } |
| 154 | } |
| 155 | break; |
| 156 | default: |
| 157 | break; |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | return ( |
| 162 | <SettingsModalContextController> |
| 163 | <div ref={profilerRef} className={styles.Profiler}> |
| 164 | <div className={styles.LeftColumn}> |
| 165 | <div className={styles.Toolbar}> |
| 166 | <RecordToggle disabled={!supportsProfiling} /> |
| 167 | <ReloadAndProfileButton disabled={!supportsProfiling} /> |
| 168 | <ClearProfilingDataButton /> |
| 169 | <ProfilingImportExportButtons /> |
| 170 | <div className={styles.VRule} /> |
| 171 | <TabBar |
| 172 | currentTab={selectedTabID} |
| 173 | id="Profiler" |
| 174 | selectTab={selectTab} |
| 175 | tabs={tabs} |
| 176 | type="profiler" |
| 177 | /> |
| 178 | <RootSelector /> |
| 179 | <div className={styles.Spacer} /> |
| 180 | <SettingsModalContextToggle /> |
| 181 | {didRecordCommits && ( |
| 182 | <Fragment> |
| 183 | <div className={styles.VRule} /> |
| 184 | <Button |
| 185 | onClick={ |
| 186 | isSearchInputVisible ? hideSearchInput : showSearchInput |
| 187 | } |
| 188 | title={`Search components in this commit (${ |
| 189 | isMac ? '⌘' : 'Ctrl+' |
| 190 | }F)`} |
| 191 | data-testname="ProfilerSearchButton"> |
| 192 | <ButtonIcon type="find" /> |
| 193 | </Button> |
| 194 | <SnapshotSelector /> |
| 195 | </Fragment> |
| 196 | )} |
| 197 | </div> |
| 198 | <div className={styles.Content}> |
| 199 | {didRecordCommits && isSearchInputVisible && ( |
| 200 | <div className={styles.SearchInputOverlay}> |
| 201 | <ProfilerSearchInput /> |
| 202 | </div> |
| 203 | )} |
| 204 | {view} |
| 205 | <ModalDialog /> |
| 206 | </div> |
| 207 | </div> |
| 208 | <div className={styles.RightColumn}>{sidebar}</div> |
| 209 | <SettingsModal /> |
| 210 | </div> |
| 211 | </SettingsModalContextController> |
| 212 | ); |
| 213 | } |
| 214 | |
| 215 | const tabs = [ |
| 216 | { |
| 217 | id: 'flame-chart', |
| 218 | icon: 'flame-chart', |
| 219 | label: 'Flamegraph', |
| 220 | title: 'Flamegraph chart', |
| 221 | }, |
| 222 | { |
| 223 | id: 'ranked-chart', |
| 224 | icon: 'ranked-chart', |
| 225 | label: 'Ranked', |
| 226 | title: 'Ranked chart', |
| 227 | }, |
| 228 | ]; |
| 229 | |
| 230 | export default portaledContent(Profiler) as component(); |