diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 9475a20d4c..44cc69d6f1 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -898,6 +898,7 @@ StackSettings--panel-search = ## Tab Bar for the bottom half of the analysis UI. TabBar--calltree-tab = Call Tree +TabBar--function-list-tab = Function List TabBar--flame-graph-tab = Flame Graph TabBar--stack-chart-tab = Stack Chart TabBar--marker-chart-tab = Marker Chart diff --git a/res/css/style.css b/res/css/style.css index fac5320156..ca0c17e3c5 100644 --- a/res/css/style.css +++ b/res/css/style.css @@ -57,7 +57,8 @@ body { flex-shrink: 1; } -.treeAndSidebarWrapper { +.treeAndSidebarWrapper, +.functionTableAndSidebarWrapper { display: flex; flex: 1; flex-flow: column nowrap; diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index a7bf7ddc39..a9e6b3b55f 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -74,6 +74,7 @@ import type { TableViewOptions, SelectionContext, BottomBoxInfo, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, @@ -120,7 +121,7 @@ export function changeSelectedCallNode( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_SELECTED_CALL_NODE', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', selectedCallNodePath, optionalExpandedToCallNodePath, threadsKey, @@ -129,6 +130,52 @@ export function changeSelectedCallNode( }; } +export function changeLowerWingSelectedCallNode( + threadsKey: ThreadsKey, + selectedCallNodePath: CallNodePath, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_CALL_NODE', + area: 'LOWER_WING', + selectedCallNodePath, + optionalExpandedToCallNodePath: [], + threadsKey, + context, + }; +} + +export function changeUpperWingSelectedCallNode( + threadsKey: ThreadsKey, + selectedCallNodePath: CallNodePath, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_CALL_NODE', + area: 'UPPER_WING', + selectedCallNodePath, + optionalExpandedToCallNodePath: [], + threadsKey, + context, + }; +} + +/** + * Select a function for a given thread in the function list. + */ +export function changeSelectedFunctionIndex( + threadsKey: ThreadsKey, + selectedFunctionIndex: IndexIntoFuncTable | null, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }; +} + /** * This action is used when the user right clicks on a call node (in panels such * as the call tree, the flame chart, or the stack chart). It's especially used @@ -137,10 +184,49 @@ export function changeSelectedCallNode( export function changeRightClickedCallNode( threadsKey: ThreadsKey, callNodePath: CallNodePath | null +): ThunkAction { + return (dispatch, getState) => { + const isInverted = getInvertCallstack(getState()); + dispatch({ + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', + callNodePath, + }); + }; +} + +export function changeRightClickedFunctionIndex( + threadsKey: ThreadsKey, + functionIndex: IndexIntoFuncTable | null ): Action { + return { + type: 'CHANGE_RIGHT_CLICKED_FUNCTION', + threadsKey, + functionIndex, + }; +} + +export function changeLowerWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +): Action { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: 'LOWER_WING', + callNodePath, + }; +} + +export function changeUpperWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +) { return { type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', threadsKey, + area: 'UPPER_WING', callNodePath, }; } @@ -207,6 +293,40 @@ export function selectSelfCallNode( }; } +/** + * Like selectSelfCallNode, but selects the function of the self call node + * instead. Used when the function list tab is active. + */ +export function selectSelfFunction( + threadsKey: ThreadsKey, + sampleIndex: IndexIntoSamplesTable | null +): ThunkAction { + return (dispatch, getState) => { + if (sampleIndex === null || sampleIndex < 0) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); + const sampleCallNodes = + threadSelectors.getSampleIndexToNonInvertedCallNodeIndexForFilteredThread( + getState() + ); + if (sampleIndex >= sampleCallNodes.length) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const nonInvertedSelfCallNode = sampleCallNodes[sampleIndex]; + if (nonInvertedSelfCallNode === null) { + dispatch(changeSelectedFunctionIndex(threadsKey, null)); + return; + } + const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); + const funcIndex = + callNodeInfo.getCallNodeTable().func[nonInvertedSelfCallNode]; + dispatch(changeSelectedFunctionIndex(threadsKey, funcIndex)); + }; +} + /** * This selects a set of thread from thread indexes. * Please use it in tests only. @@ -1548,12 +1668,37 @@ export function changeExpandedCallNodes( const isInverted = getInvertCallstack(getState()); dispatch({ type: 'CHANGE_EXPANDED_CALL_NODES', - isInverted, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', threadsKey, expandedCallNodePaths, }); }; } + +export function changeLowerWingExpandedCallNodes( + threadsKey: ThreadsKey, + expandedCallNodePaths: Array +): Action { + return { + type: 'CHANGE_EXPANDED_CALL_NODES', + area: 'LOWER_WING', + threadsKey, + expandedCallNodePaths, + }; +} + +export function changeUpperWingExpandedCallNodes( + threadsKey: ThreadsKey, + expandedCallNodePaths: Array +): Action { + return { + type: 'CHANGE_EXPANDED_CALL_NODES', + area: 'UPPER_WING', + threadsKey, + expandedCallNodePaths, + }; +} + export function changeSelectedMarker( threadsKey: ThreadsKey, selectedMarker: MarkerIndex | null, @@ -1990,6 +2135,7 @@ export function closeBottomBox(): ThunkAction { export function handleCallNodeTransformShortcut( event: React.KeyboardEvent, threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, callNodeIndex: IndexIntoCallNodeTable ): ThunkAction { return (dispatch, getState) => { @@ -1998,7 +2144,6 @@ export function handleCallNodeTransformShortcut( } const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); const unfilteredThread = threadSelectors.getThread(getState()); - const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); const implementation = getImplementationFilter(getState()); const inverted = getInvertCallstack(getState()); const callNodePath = callNodeInfo.getCallNodePathFromIndex(callNodeIndex); @@ -2108,3 +2253,100 @@ export function handleCallNodeTransformShortcut( } }; } + +export function handleFunctionTransformShortcut( + event: React.KeyboardEvent, + threadsKey: ThreadsKey, + funcIndex: IndexIntoFuncTable +): ThunkAction { + return (dispatch, getState) => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); + const callNodeInfo = threadSelectors.getCallNodeInfo(getState()); + const implementation = getImplementationFilter(getState()); + const callNodeTable = callNodeInfo.getCallNodeTable(); + const unfilteredThread = threadSelectors.getThread(getState()); + + switch (event.key) { + case 'f': + dispatch( + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex, + }) + ); + break; + case 'S': + dispatch( + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }) + ); + break; + case 'm': + dispatch( + addTransformToStack(threadsKey, { + type: 'merge-function', + funcIndex, + }) + ); + break; + case 'd': + dispatch( + addTransformToStack(threadsKey, { + type: 'drop-function', + funcIndex, + }) + ); + break; + case 'C': { + const resourceIndex = unfilteredThread.funcTable.resource[funcIndex]; + dispatch( + addCollapseResourceTransformToStack( + threadsKey, + resourceIndex, + implementation + ) + ); + break; + } + case 'r': { + if (funcHasRecursiveCall(callNodeTable, funcIndex)) { + dispatch( + addTransformToStack(threadsKey, { + type: 'collapse-recursion', + funcIndex, + }) + ); + } + break; + } + case 'R': { + if (funcHasDirectRecursiveCall(callNodeTable, funcIndex)) { + dispatch( + addTransformToStack(threadsKey, { + type: 'collapse-direct-recursion', + funcIndex, + implementation, + }) + ); + } + break; + } + case 'c': + dispatch( + addTransformToStack(threadsKey, { + type: 'collapse-function-subtree', + funcIndex, + }) + ); + break; + default: + // This did not match a function transform. + } + }; +} diff --git a/src/app-logic/tabs-handling.ts b/src/app-logic/tabs-handling.ts index 3e2f205c3b..20f3742b48 100644 --- a/src/app-logic/tabs-handling.ts +++ b/src/app-logic/tabs-handling.ts @@ -9,6 +9,7 @@ */ export const tabsWithTitleL10nId = { calltree: 'TabBar--calltree-tab', + 'function-list': 'TabBar--function-list-tab', 'flame-graph': 'TabBar--flame-graph-tab', 'stack-chart': 'TabBar--stack-chart-tab', 'marker-chart': 'TabBar--marker-chart-tab', @@ -41,6 +42,7 @@ export const tabsWithTitleL10nIdArray: readonly TabsWithTitleL10nId[] = export const tabsShowingSampleData: readonly TabSlug[] = [ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', ]; diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index eecd06a2be..a86dabcfa1 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -331,6 +331,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : undefined; /* fallsthrough */ case 'flame-graph': + case 'function-list': case 'calltree': { query = baseQuery as CallTreeQueryShape; diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 643e6c5929..0f58c97955 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -10,6 +10,7 @@ import explicitConnect from 'firefox-profiler/utils/connect'; import { TabBar } from './TabBar'; import { LocalizedErrorBoundary } from './ErrorBoundary'; import { ProfileCallTreeView } from 'firefox-profiler/components/calltree/ProfileCallTreeView'; +import { ProfileFunctionListView } from 'firefox-profiler/components/calltree/ProfileFunctionListView'; import { MarkerTable } from 'firefox-profiler/components/marker-table'; import { StackChart } from 'firefox-profiler/components/stack-chart/'; import { MarkerChart } from 'firefox-profiler/components/marker-chart/'; @@ -26,6 +27,8 @@ import { getSelectedTab } from 'firefox-profiler/selectors/url-state'; import { getIsSidebarOpen } from 'firefox-profiler/selectors/app'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { CallNodeContextMenu } from 'firefox-profiler/components/shared/CallNodeContextMenu'; +import { FunctionListContextMenu } from 'firefox-profiler/components/shared/FunctionListContextMenu'; +import { LowerWingContextMenu } from 'firefox-profiler/components/shared/LowerWingContextMenu'; import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -122,6 +125,7 @@ class ProfileViewerImpl extends PureComponent { { { calltree: , + 'function-list': , 'flame-graph': , 'stack-chart': , 'marker-chart': , @@ -133,6 +137,8 @@ class ProfileViewerImpl extends PureComponent { + + ); diff --git a/src/components/app/DetailsContainer.css b/src/components/app/DetailsContainer.css index 21bcae24d1..c08a735b5a 100644 --- a/src/components/app/DetailsContainer.css +++ b/src/components/app/DetailsContainer.css @@ -4,7 +4,7 @@ box-sizing: border-box; } -.DetailsContainer .layout-pane:not(.layout-pane-primary) { +.DetailsContainer > .layout-pane:not(.layout-pane-primary) { max-width: 600px; } diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css new file mode 100644 index 0000000000..21cbf626af --- /dev/null +++ b/src/components/calltree/Butterfly.css @@ -0,0 +1,60 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.butterflyWrapper { + position: relative; + min-height: 0; + flex: 1; +} + +/* Provide 3px extra grabbable surface on each side of the splitter */ +.butterflyWrapper .splitter-layout > .layout-splitter { + position: relative; /* containing block for absolute ::before */ + border: none; + background-color: var(--base-border-color) !important; +} + +.butterflyWrapper .splitter-layout > .layout-splitter::before { + position: absolute; + z-index: var(--z-bottom-box); + display: block; + content: ''; +} + +.butterflyWrapper + .splitter-layout:not(.splitter-layout-vertical) + > .layout-splitter { + width: 1px; +} + +.butterflyWrapper + .splitter-layout:not(.splitter-layout-vertical) + > .layout-splitter::before { + inset: 0 -3px; +} + +.butterflyWrapper .splitter-layout.splitter-layout-vertical > .layout-splitter { + height: 1px; + margin-bottom: -1px; +} + +.butterflyWrapper + .splitter-layout.splitter-layout-vertical + > .layout-splitter::before { + inset: -3px 0; +} + +.functionListTreeWrapper { + display: flex; + flex-flow: column nowrap; +} + +.functionListTreeWrapper .treeRowToggleButton { + display: none; +} + +.butterflyWings { + display: flex; + flex-flow: column nowrap; +} diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 02e6b7333d..6064bd7418 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -4,12 +4,13 @@ import { PureComponent } from 'react'; import memoize from 'memoize-immutable'; import explicitConnect from 'firefox-profiler/utils/connect'; -import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { + TreeView, + ColumnSortState, +} from 'firefox-profiler/components/shared/TreeView'; import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; -import { Icon } from 'firefox-profiler/components/shared/Icon'; import { getInvertCallstack, - getImplementationFilter, getSearchStringsAsRegExp, getSelectedThreadsKey, } from 'firefox-profiler/selectors/url-state'; @@ -34,7 +35,6 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import type { State, - ImplementationFilter, ThreadsKey, CategoryList, IndexIntoCallNodeTable, @@ -53,6 +53,11 @@ import type { import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './CallTree.css'; +import { + treeColumnsForBytes, + treeColumnsForSamples, + treeColumnsForTracingMs, +} from './columns'; type StateProps = { readonly threadsKey: ThreadsKey; @@ -67,7 +72,6 @@ type StateProps = { readonly searchStringsRegExp: RegExp | null; readonly disableOverscan: boolean; readonly invertCallstack: boolean; - readonly implementationFilter: ImplementationFilter; readonly callNodeMaxDepthPlusOne: number; readonly weightType: WeightType; readonly tableViewOptions: TableViewOptions; @@ -97,6 +101,7 @@ class CallTreeImpl extends PureComponent { _treeView: TreeView | null = null; _takeTreeViewRef = (treeView: TreeView | null) => (this._treeView = treeView); + _sortedColumns = new ColumnSortState([{ column: 'total', ascending: false }]); /** * Call Trees can have different types of "weights" for the data. Choose the @@ -106,95 +111,11 @@ class CallTreeImpl extends PureComponent { (weightType: WeightType): MaybeResizableColumn[] => { switch (weightType) { case 'tracing-ms': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 50, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--tracing-ms-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 50, - }, - { - propName: 'self', - titleL10nId: 'CallTree--tracing-ms-self', - minWidth: 30, - initialWidth: 70, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 10, - }, - ]; + return treeColumnsForTracingMs; case 'samples': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 50, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--samples-total', - minWidth: 30, - initialWidth: 70, - resizable: true, - headerWidthAdjustment: 50, - }, - { - propName: 'self', - titleL10nId: 'CallTree--samples-self', - minWidth: 30, - initialWidth: 70, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 10, - }, - ]; + return treeColumnsForSamples; case 'bytes': - return [ - { - propName: 'totalPercent', - titleL10nId: '', - initialWidth: 50, - hideDividerAfter: true, - }, - { - propName: 'total', - titleL10nId: 'CallTree--bytes-total', - minWidth: 30, - initialWidth: 140, - resizable: true, - headerWidthAdjustment: 50, - }, - { - propName: 'self', - titleL10nId: 'CallTree--bytes-self', - minWidth: 30, - initialWidth: 90, - resizable: true, - }, - { - propName: 'icon', - titleL10nId: '', - component: Icon as any, - initialWidth: 10, - }, - ]; + return treeColumnsForBytes; default: throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); } @@ -275,6 +196,7 @@ class CallTreeImpl extends PureComponent { rightClickedCallNodeIndex, handleCallNodeTransformShortcut, threadsKey, + callNodeInfo, } = this.props; const nodeIndex = rightClickedCallNodeIndex !== null @@ -283,7 +205,11 @@ class CallTreeImpl extends PureComponent { if (nodeIndex === null) { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + _onSort = (sortedColumns: ColumnSortState) => { + this._sortedColumns = sortedColumns; }; _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { @@ -390,6 +316,8 @@ class CallTreeImpl extends PureComponent { onDoubleClick={this._onEnterOrDoubleClick} viewOptions={tableViewOptions} onViewOptionsChange={onTableViewOptionsChange} + initialSortedColumns={this._sortedColumns} + onColumnSortChange={this._onSort} /> ); } @@ -412,7 +340,6 @@ export const CallTree = explicitConnect<{}, StateProps, DispatchProps>({ searchStringsRegExp: getSearchStringsAsRegExp(state), disableOverscan: getPreviewSelectionIsBeingModified(state), invertCallstack: getInvertCallstack(state), - implementationFilter: getImplementationFilter(state), // Use the filtered call node max depth, rather than the preview filtered call node // max depth so that the width of the TreeView component is stable across preview // selections. diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx new file mode 100644 index 0000000000..57f398645e --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,267 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import { PureComponent } from 'react'; +import memoize from 'memoize-immutable'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getFocusCallTreeGeneration, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeRightClickedFunctionIndex, + changeSelectedFunctionIndex, + addTransformToStack, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, + handleFunctionTransformShortcut, +} from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; +import { + treeColumnsForTracingMs, + treeColumnsForSamples, + treeColumnsForBytes, +} from './columns'; + +import type { + State, + ThreadsKey, + IndexIntoFuncTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, +} from 'firefox-profiler/types'; +import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; + +import type { + Column, + MaybeResizableColumn, +} from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly focusCallTreeGeneration: number; + readonly tree: CallTree; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; +}; + +type DispatchProps = { + readonly changeSelectedFunctionIndex: typeof changeSelectedFunctionIndex; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + readonly addTransformToStack: typeof addTransformToStack; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly handleFunctionTransformShortcut: typeof handleFunctionTransformShortcut; + readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FunctionListImpl extends PureComponent { + _mainColumn: Column = { + propName: 'name', + titleL10nId: '', + }; + _appendageColumn: Column = { + propName: 'lib', + titleL10nId: '', + }; + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView) => + (this._treeView = treeView); + + _expandedIndexes: Array = []; + + /** + * Call Trees can have different types of "weights" for the data. Choose the + * appropriate labels for the call tree based on this weight. + */ + _weightTypeToColumns = memoize( + (weightType: WeightType): MaybeResizableColumn[] => { + switch (weightType) { + case 'tracing-ms': + return treeColumnsForTracingMs; + case 'samples': + return treeColumnsForSamples; + case 'bytes': + return treeColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } + }, + // Use a Map cache, as the function only takes one argument, which is a simple string. + { cache: new Map() } + ); + + override componentDidMount() { + this.focus(); + this.maybeProcureInitialSelection(); + + if (this.props.selectedFunctionIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + if ( + this.props.focusCallTreeGeneration > prevProps.focusCallTreeGeneration + ) { + this.focus(); + } + + if ( + this.props.selectedFunctionIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + maybeProcureInitialSelection() { + if (this.props.selectedFunctionIndex !== null) { + return; + } + const { tree, threadsKey, changeSelectedFunctionIndex } = this.props; + const firstRoot = tree.getRoots()[0]; + if (firstRoot !== undefined) { + changeSelectedFunctionIndex(threadsKey, firstRoot, { source: 'auto' }); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectionChange = ( + newSelectedFunction: IndexIntoFuncTable, + context: SelectionContext + ) => { + const { threadsKey, changeSelectedFunctionIndex } = this.props; + changeSelectedFunctionIndex(threadsKey, newSelectedFunction, context); + }; + + _onRightClickSelection = (newSelectedFunction: IndexIntoFuncTable) => { + const { threadsKey, changeRightClickedFunctionIndex } = this.props; + changeRightClickedFunctionIndex(threadsKey, newSelectedFunction); + }; + + _onExpandedCallNodesChange = ( + _newExpandedCallNodeIndexes: Array + ) => {}; + + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedFunctionIndex, + rightClickedFunctionIndex, + threadsKey, + handleFunctionTransformShortcut, + } = this.props; + const funcIndex = + rightClickedFunctionIndex !== null + ? rightClickedFunctionIndex + : selectedFunctionIndex; + if (funcIndex === null) { + return; + } + handleFunctionTransformShortcut(event, threadsKey, funcIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForFunction(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + override render() { + const { + tree, + selectedFunctionIndex, + rightClickedFunctionIndex, + searchStringsRegExp, + disableOverscan, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } +} + +export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + focusCallTreeGeneration: getFocusCallTreeGeneration(state), + tree: selectedThreadSelectors.getFunctionListTree(state), + selectedFunctionIndex: + selectedThreadSelectors.getSelectedFunctionIndex(state), + rightClickedFunctionIndex: + selectedThreadSelectors.getRightClickedFunctionIndex(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + }), + mapDispatchToProps: { + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, + addTransformToStack, + updateBottomBoxContentsAndMaybeOpen, + handleFunctionTransformShortcut, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: FunctionListImpl, +}); diff --git a/src/components/calltree/LowerWing.tsx b/src/components/calltree/LowerWing.tsx new file mode 100644 index 0000000000..0f59ecb587 --- /dev/null +++ b/src/components/calltree/LowerWing.tsx @@ -0,0 +1,345 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import { PureComponent } from 'react'; +import memoize from 'memoize-immutable'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + treeColumnsForTracingMs, + treeColumnsForSamples, + treeColumnsForBytes, +} from './columns'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getCategories, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeLowerWingSelectedCallNode, + changeLowerWingRightClickedCallNode, + changeLowerWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { + State, + ThreadsKey, + CategoryList, + IndexIntoCallNodeTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, +} from 'firefox-profiler/types'; +import type { CallTree as CallTreeType } from 'firefox-profiler/profile-logic/call-tree'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + Column, + MaybeResizableColumn, +} from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly tree: CallTreeType; + readonly callNodeInfo: CallNodeInfo; + readonly categories: CategoryList; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly expandedCallNodeIndexes: Array; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly callNodeMaxDepthPlusOne: number; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; +}; + +type DispatchProps = { + readonly changeLowerWingSelectedCallNode: typeof changeLowerWingSelectedCallNode; + readonly changeLowerWingRightClickedCallNode: typeof changeLowerWingRightClickedCallNode; + readonly changeLowerWingExpandedCallNodes: typeof changeLowerWingExpandedCallNodes; + readonly addTransformToStack: typeof addTransformToStack; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class LowerWingImpl extends PureComponent { + _mainColumn: Column = { + propName: 'name', + titleL10nId: '', + }; + _appendageColumn: Column = { + propName: 'lib', + titleL10nId: '', + }; + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView) => + (this._treeView = treeView); + + /** + * Call Trees can have different types of "weights" for the data. Choose the + * appropriate labels for the call tree based on this weight. + */ + _weightTypeToColumns = memoize( + (weightType: WeightType): MaybeResizableColumn[] => { + switch (weightType) { + case 'tracing-ms': + return treeColumnsForTracingMs; + case 'samples': + return treeColumnsForSamples; + case 'bytes': + return treeColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } + }, + // Use a Map cache, as the function only takes one argument, which is a simple string. + { cache: new Map() } + ); + + override componentDidMount() { + this.focus(); + this.maybeProcureInterestingInitialSelection(); + + if (this.props.selectedCallNodeIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + this.maybeProcureInterestingInitialSelection(); + + if ( + this.props.selectedCallNodeIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectedCallNodeChange = ( + newSelectedCallNode: IndexIntoCallNodeTable, + context: SelectionContext + ) => { + const { callNodeInfo, threadsKey, changeLowerWingSelectedCallNode } = + this.props; + changeLowerWingSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode), + context + ); + }; + + _onRightClickSelection = (newSelectedCallNode: IndexIntoCallNodeTable) => { + const { callNodeInfo, threadsKey, changeLowerWingRightClickedCallNode } = + this.props; + changeLowerWingRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode) + ); + }; + + _onExpandedCallNodesChange = ( + newExpandedCallNodeIndexes: Array + ) => { + const { callNodeInfo, threadsKey, changeLowerWingExpandedCallNodes } = + this.props; + changeLowerWingExpandedCallNodes( + threadsKey, + newExpandedCallNodeIndexes.map((callNodeIndex) => + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ) + ); + }; + + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedCallNodeIndex, + rightClickedCallNodeIndex, + callNodeInfo, + handleCallNodeTransformShortcut, + threadsKey, + } = this.props; + const nodeIndex = + rightClickedCallNodeIndex !== null + ? rightClickedCallNodeIndex + : selectedCallNodeIndex; + if (nodeIndex === null) { + return; + } + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + maybeProcureInterestingInitialSelection() { + // Expand the heaviest callstack up to a certain depth and select the frame + // at that depth. + const { + tree, + expandedCallNodeIndexes, + selectedCallNodeIndex, + callNodeInfo, + categories, + } = this.props; + + if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) { + // Let's not change some existing state. + return; + } + + const idleCategoryIndex = categories.findIndex( + (category) => category.name === 'Idle' + ); + + const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice(); + const maxInterestingDepth = 17; // scientifically determined + let currentCallNodeIndex = tree.getRoots()[0]; + if (currentCallNodeIndex === undefined) { + // This tree is empty. + return; + } + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + for (let i = 0; i < maxInterestingDepth; i++) { + const children = tree.getChildren(currentCallNodeIndex); + if (children.length === 0) { + break; + } + + // Let's find if there's a non idle children. + const firstNonIdleNode = children.find( + (nodeIndex) => + callNodeInfo.categoryForNode(nodeIndex) !== idleCategoryIndex + ); + + // If there's a non idle children, use it; otherwise use the first + // children (that will be idle). + currentCallNodeIndex = + firstNonIdleNode !== undefined ? firstNonIdleNode : children[0]; + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + } + this._onExpandedCallNodesChange(newExpandedCallNodeIndexes); + + const categoryIndex = callNodeInfo.categoryForNode(currentCallNodeIndex); + if (categoryIndex !== idleCategoryIndex) { + // If we selected the call node with a "idle" category, we'd have a + // completely dimmed activity graph because idle stacks are not drawn in + // this graph. Because this isn't probably what the average user wants we + // do it only when the category is something different. + this._onSelectedCallNodeChange(currentCallNodeIndex, { source: 'auto' }); + } + } + + override render() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } +} + +export const LowerWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + tree: selectedThreadSelectors.getLowerWingCallTree(state), + callNodeInfo: selectedThreadSelectors.getLowerWingCallNodeInfo(state), + categories: getCategories(state), + selectedCallNodeIndex: + selectedThreadSelectors.getLowerWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getLowerWingRightClickedCallNodeIndex(state), + expandedCallNodeIndexes: + selectedThreadSelectors.getLowerWingExpandedCallNodeIndexes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + // Use the filtered call node max depth, rather than the preview filtered call node + // max depth so that the width of the TreeView component is stable across preview + // selections. + callNodeMaxDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + }), + mapDispatchToProps: { + changeLowerWingSelectedCallNode, + changeLowerWingRightClickedCallNode, + changeLowerWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: LowerWingImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx new file mode 100644 index 0000000000..63ed38dee3 --- /dev/null +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import SplitterLayout from 'react-splitter-layout'; + +import { FunctionList } from './FunctionList'; +import { SelfWing } from './SelfWing'; +import { UpperWing } from './UpperWing'; +import { LowerWing } from './LowerWing'; +import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox'; +import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; +import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; + +import './Butterfly.css'; + +export const ProfileFunctionListView = () => ( +
+ + +
+ +
+ +
+
+ + + + + + + + + +
+
+
+
+); diff --git a/src/components/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx new file mode 100644 index 0000000000..2a6d8f86fb --- /dev/null +++ b/src/components/calltree/SelfWing.tsx @@ -0,0 +1,220 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import * as React from 'react'; + +import explicitConnect from 'firefox-profiler/utils/connect'; +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getInvertCallstack, +} from 'firefox-profiler/selectors/url-state'; +import { updateBottomBoxContentsAndMaybeOpen } from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; +import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly isInverted: boolean; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +type LocalState = { + selectedCallNodeIndex: IndexIntoCallNodeTable | null; + rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; +}; + +class SelfWingImpl extends React.PureComponent { + override state: LocalState = { + selectedCallNodeIndex: null, + rightClickedCallNodeIndex: null, + }; + + override componentDidUpdate(prevProps: Props, _prevState: LocalState) { + // Reset local selection when the call node info changes (e.g. different + // function selected) since old call node indices are no longer valid. + if ( + prevProps.callNodeInfo !== this.props.callNodeInfo || + prevProps.threadsKey !== this.props.threadsKey + ) { + this.setState({ + selectedCallNodeIndex: null, + rightClickedCallNodeIndex: null, + }); + } + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ selectedCallNodeIndex: callNodeIndex }); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ rightClickedCallNodeIndex: callNodeIndex }); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + // Transforms are disabled in the SelfWing because it operates on an ephemeral + // thread that is not part of the Redux transform stack. + _onKeyboardTransformShortcut = ( + _event: React.KeyboardEvent, + _nodeIndex: IndexIntoCallNodeTable + ) => {}; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + isInverted, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + displayStackType, + } = this.props; + + const { selectedCallNodeIndex, rightClickedCallNodeIndex } = this.state; + + return ( + + ); + } +} + +export const SelfWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getSelfWingThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getSelfWingCallNodeMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getSelfWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getSelfWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getSelfWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + isInverted: getInvertCallstack(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getSelfWingCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getSelfWingCtssSampleCategoriesAndSubcategories( + state + ), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + updateBottomBoxContentsAndMaybeOpen, + }, + component: SelfWingImpl, +}); diff --git a/src/components/calltree/UpperWing.tsx b/src/components/calltree/UpperWing.tsx new file mode 100644 index 0000000000..6733c64d9e --- /dev/null +++ b/src/components/calltree/UpperWing.tsx @@ -0,0 +1,351 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import * as React from 'react'; +import { PureComponent } from 'react'; +import memoize from 'memoize-immutable'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { UpperWingFlameGraph } from './UpperWingFlameGraph'; +import { + treeColumnsForTracingMs, + treeColumnsForSamples, + treeColumnsForBytes, +} from './columns'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, +} from 'firefox-profiler/selectors/url-state'; +import { + getScrollToSelectionGeneration, + getCategories, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + changeUpperWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { + State, + ThreadsKey, + CategoryList, + IndexIntoCallNodeTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, +} from 'firefox-profiler/types'; +import type { CallTree as CallTreeType } from 'firefox-profiler/profile-logic/call-tree'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + Column, + MaybeResizableColumn, +} from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +type StateProps = { + readonly threadsKey: ThreadsKey; + readonly scrollToSelectionGeneration: number; + readonly tree: CallTreeType; + readonly callNodeInfo: CallNodeInfo; + readonly categories: CategoryList; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly expandedCallNodeIndexes: Array; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly callNodeMaxDepthPlusOne: number; + readonly weightType: WeightType; + readonly tableViewOptions: TableViewOptions; +}; + +type DispatchProps = { + readonly changeUpperWingSelectedCallNode: typeof changeUpperWingSelectedCallNode; + readonly changeUpperWingRightClickedCallNode: typeof changeUpperWingRightClickedCallNode; + readonly changeUpperWingExpandedCallNodes: typeof changeUpperWingExpandedCallNodes; + readonly addTransformToStack: typeof addTransformToStack; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class UpperWingImpl extends PureComponent { + _mainColumn: Column = { + propName: 'name', + titleL10nId: '', + }; + _appendageColumn: Column = { + propName: 'lib', + titleL10nId: '', + }; + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView) => + (this._treeView = treeView); + + /** + * Call Trees can have different types of "weights" for the data. Choose the + * appropriate labels for the call tree based on this weight. + */ + _weightTypeToColumns = memoize( + (weightType: WeightType): MaybeResizableColumn[] => { + switch (weightType) { + case 'tracing-ms': + return treeColumnsForTracingMs; + case 'samples': + return treeColumnsForSamples; + case 'bytes': + return treeColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } + }, + // Use a Map cache, as the function only takes one argument, which is a simple string. + { cache: new Map() } + ); + + override componentDidMount() { + this.focus(); + this.maybeProcureInterestingInitialSelection(); + + if (this.props.selectedCallNodeIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + this.maybeProcureInterestingInitialSelection(); + + if ( + this.props.selectedCallNodeIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectedCallNodeChange = ( + newSelectedCallNode: IndexIntoCallNodeTable, + context: SelectionContext + ) => { + const { callNodeInfo, threadsKey, changeUpperWingSelectedCallNode } = + this.props; + changeUpperWingSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode), + context + ); + }; + + _onRightClickSelection = (newSelectedCallNode: IndexIntoCallNodeTable) => { + const { callNodeInfo, threadsKey, changeUpperWingRightClickedCallNode } = + this.props; + changeUpperWingRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode) + ); + }; + + _onExpandedCallNodesChange = ( + newExpandedCallNodeIndexes: Array + ) => { + const { callNodeInfo, threadsKey, changeUpperWingExpandedCallNodes } = + this.props; + changeUpperWingExpandedCallNodes( + threadsKey, + newExpandedCallNodeIndexes.map((callNodeIndex) => + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ) + ); + }; + + _onKeyDown = (event: React.KeyboardEvent) => { + const { + selectedCallNodeIndex, + rightClickedCallNodeIndex, + callNodeInfo, + handleCallNodeTransformShortcut, + threadsKey, + } = this.props; + const nodeIndex = + rightClickedCallNodeIndex !== null + ? rightClickedCallNodeIndex + : selectedCallNodeIndex; + if (nodeIndex === null) { + return; + } + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + maybeProcureInterestingInitialSelection() { + // Expand the heaviest callstack up to a certain depth and select the frame + // at that depth. + const { + tree, + expandedCallNodeIndexes, + selectedCallNodeIndex, + callNodeInfo, + categories, + } = this.props; + + if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) { + // Let's not change some existing state. + return; + } + + const idleCategoryIndex = categories.findIndex( + (category) => category.name === 'Idle' + ); + + const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice(); + const maxInterestingDepth = 17; // scientifically determined + let currentCallNodeIndex = tree.getRoots()[0]; + if (currentCallNodeIndex === undefined) { + // This tree is empty. + return; + } + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + for (let i = 0; i < maxInterestingDepth; i++) { + const children = tree.getChildren(currentCallNodeIndex); + if (children.length === 0) { + break; + } + + // Let's find if there's a non idle children. + const firstNonIdleNode = children.find( + (nodeIndex) => + callNodeInfo.categoryForNode(nodeIndex) !== idleCategoryIndex + ); + + // If there's a non idle children, use it; otherwise use the first + // children (that will be idle). + currentCallNodeIndex = + firstNonIdleNode !== undefined ? firstNonIdleNode : children[0]; + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + } + this._onExpandedCallNodesChange(newExpandedCallNodeIndexes); + + const categoryIndex = callNodeInfo.categoryForNode(currentCallNodeIndex); + if (categoryIndex !== idleCategoryIndex) { + // If we selected the call node with a "idle" category, we'd have a + // completely dimmed activity graph because idle stacks are not drawn in + // this graph. Because this isn't probably what the average user wants we + // do it only when the category is something different. + this._onSelectedCallNodeChange(currentCallNodeIndex, { source: 'auto' }); + } + } + + _renderCallTree() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } + + override render() { + return ; + } +} + +export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State) => ({ + threadsKey: getSelectedThreadsKey(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + tree: selectedThreadSelectors.getUpperWingCallTree(state), + callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state), + categories: getCategories(state), + selectedCallNodeIndex: + selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state), + expandedCallNodeIndexes: + selectedThreadSelectors.getUpperWingExpandedCallNodeIndexes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + // Use the filtered call node max depth, rather than the preview filtered call node + // max depth so that the width of the TreeView component is stable across preview + // selections. + callNodeMaxDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + }), + mapDispatchToProps: { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + changeUpperWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: UpperWingImpl, +}); diff --git a/src/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx new file mode 100644 index 0000000000..3ff9f77eae --- /dev/null +++ b/src/components/calltree/UpperWingFlameGraph.tsx @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// @flow + +import * as React from 'react'; + +import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; +import { FlameGraph } from 'firefox-profiler/components/flame-graph/FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getInvertCallstack, +} from 'firefox-profiler/selectors/url-state'; +import { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + SelectionContext, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly isInverted: boolean; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeUpperWingSelectedCallNode: typeof changeUpperWingSelectedCallNode; + readonly changeUpperWingRightClickedCallNode: typeof changeUpperWingRightClickedCallNode; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface UpperWingFlameGraphHandle { + focus(): void; +} + +class UpperWingFlameGraphImpl + extends React.PureComponent + implements UpperWingFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeUpperWingSelectedCallNode } = + this.props; + const context: SelectionContext = { source: 'pointer' }; + changeUpperWingSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex), + context + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeUpperWingRightClickedCallNode } = + this.props; + changeUpperWingRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + isInverted, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const UpperWingFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + UpperWingFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getPreviewFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getUpperWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getUpperWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getUpperWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getUpperWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getUpperWingRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + isInverted: getInvertCallstack(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeUpperWingSelectedCallNode, + changeUpperWingRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: UpperWingFlameGraphImpl, +}); diff --git a/src/components/calltree/columns.ts b/src/components/calltree/columns.ts new file mode 100644 index 0000000000..55faaad74b --- /dev/null +++ b/src/components/calltree/columns.ts @@ -0,0 +1,100 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Icon } from 'firefox-profiler/components/shared/Icon'; + +import type { MaybeResizableColumn } from 'firefox-profiler/components/shared/TreeView'; +import type { CallNodeDisplayData } from 'firefox-profiler/types'; + +export const treeColumnsForTracingMs: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 50, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--tracing-ms-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 50, + }, + { + propName: 'self', + titleL10nId: 'CallTree--tracing-ms-self', + minWidth: 30, + initialWidth: 70, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 10, + }, + ]; + +export const treeColumnsForSamples: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 50, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--samples-total', + minWidth: 30, + initialWidth: 70, + resizable: true, + headerWidthAdjustment: 50, + }, + { + propName: 'self', + titleL10nId: 'CallTree--samples-self', + minWidth: 30, + initialWidth: 70, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 10, + }, + ]; +export const treeColumnsForBytes: MaybeResizableColumn[] = + [ + { + propName: 'totalPercent', + titleL10nId: '', + initialWidth: 50, + hideDividerAfter: true, + }, + { + propName: 'total', + titleL10nId: 'CallTree--bytes-total', + minWidth: 30, + initialWidth: 140, + resizable: true, + headerWidthAdjustment: 50, + }, + { + propName: 'self', + titleL10nId: 'CallTree--bytes-self', + minWidth: 30, + initialWidth: 90, + resizable: true, + }, + { + propName: 'icon', + titleL10nId: '', + component: Icon as any, + initialWidth: 10, + }, + ]; diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 1c163512b2..5f2a6bc476 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -245,7 +245,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { // The graph is drawn from bottom to top, in order of increasing depth. for (let depth = startDepth; depth < endDepth; depth++) { // Get the timing information for a row of stack frames. - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; if (!stackTiming) { continue; @@ -373,7 +373,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; if (!stackTiming) { return null; } @@ -383,8 +383,9 @@ class FlameGraphCanvasImpl extends React.PureComponent { } const ratio = - stackTiming.end[flameGraphTimingIndex] - - stackTiming.start[flameGraphTimingIndex]; + (stackTiming.end[flameGraphTimingIndex] - + stackTiming.start[flameGraphTimingIndex]) * + flameGraphTiming.tooltipRatioMultiplier; let percentage = formatPercent(ratio); if (tracedTiming) { @@ -443,7 +444,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { const { depth, flameGraphTimingIndex } = hoveredItem; const { flameGraphTiming } = this.props; - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; const callNodeIndex = stackTiming.callNode[flameGraphTimingIndex]; return callNodeIndex; } @@ -477,7 +478,7 @@ class FlameGraphCanvasImpl extends React.PureComponent { const depth = Math.floor( maxStackDepthPlusOne - (y + viewportTop) / ROW_HEIGHT ); - const stackTiming = flameGraphTiming[depth]; + const stackTiming = flameGraphTiming.rows[depth]; if (!stackTiming) { return null; diff --git a/src/components/flame-graph/ConnectedFlameGraph.tsx b/src/components/flame-graph/ConnectedFlameGraph.tsx new file mode 100644 index 0000000000..109fcb4ead --- /dev/null +++ b/src/components/flame-graph/ConnectedFlameGraph.tsx @@ -0,0 +1,248 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; + +import { explicitConnectWithForwardRef } from '../../utils/connect'; +import { FlameGraph } from './FlameGraph'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getInvertCallstack, +} from '../../selectors/url-state'; +import { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, +} from 'firefox-profiler/types'; + +import type { FlameGraphTiming } from 'firefox-profiler/profile-logic/flame-graph'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; + +import type { + CallTree, + CallTreeTimings, +} from 'firefox-profiler/profile-logic/call-tree'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly thread: Thread; + readonly weightType: WeightType; + readonly innerWindowIDToPageMap: Map | null; + readonly maxStackDepthPlusOne: number; + readonly timeRange: StartEndRange; + readonly previewSelection: PreviewSelection | null; + readonly flameGraphTiming: FlameGraphTiming; + readonly callTree: CallTree; + readonly callNodeInfo: CallNodeInfo; + readonly threadsKey: ThreadsKey; + readonly selectedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + readonly scrollToSelectionGeneration: number; + readonly categories: CategoryList; + readonly interval: Milliseconds; + readonly isInverted: boolean; + readonly callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: typeof changeSelectedCallNode; + readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface ConnectedFlameGraphHandle { + focus(): void; +} + +class ConnectedFlameGraphImpl + extends React.PureComponent + implements ConnectedFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); + }; + + _onKeyboardTransformShortcut = ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => { + const { threadsKey, callNodeInfo, handleCallNodeTransformShortcut } = + this.props; + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); + }; + + override render() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + rightClickedCallNodeIndex, + selectedCallNodeIndex, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + isInverted, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const ConnectedFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + ConnectedFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + // Use the filtered call node max depth, rather than the preview filtered one, so + // that the viewport height is stable across preview selections. + maxStackDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), + callTree: selectedThreadSelectors.getCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(state), + isInverted: getInvertCallstack(state), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeSelectedCallNode, + changeRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: ConnectedFlameGraphImpl, +}); diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 376d0f3025..07fd5d47b5 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -3,30 +3,8 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { explicitConnectWithForwardRef } from '../../utils/connect'; import { FlameGraphCanvas } from './Canvas'; - -import { - getCategories, - getCommittedRange, - getPreviewSelection, - getScrollToSelectionGeneration, - getProfileInterval, - getInnerWindowIDToPageMap, - getProfileUsesMultipleStackTypes, -} from 'firefox-profiler/selectors/profile'; -import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; -import { - getSelectedThreadsKey, - getInvertCallstack, -} from '../../selectors/url-state'; import { ContextMenuTrigger } from 'firefox-profiler/components/shared/ContextMenuTrigger'; -import { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, -} from 'firefox-profiler/actions/profile-view'; import { extractNonInvertedCallTreeTimings } from 'firefox-profiler/profile-logic/call-tree'; import { ensureExists } from 'firefox-profiler/utils/types'; @@ -54,8 +32,6 @@ import type { CallTreeTimings, } from 'firefox-profiler/profile-logic/call-tree'; -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - import './FlameGraph.css'; const STACK_FRAME_HEIGHT = 16; @@ -67,7 +43,7 @@ const STACK_FRAME_HEIGHT = 16; */ const SELECTABLE_THRESHOLD = 0.001; -type StateProps = { +export type Props = { readonly thread: Thread; readonly weightType: WeightType; readonly innerWindowIDToPageMap: Map | null; @@ -89,14 +65,20 @@ type StateProps = { readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; readonly tracedTiming: CallTreeTimings | null; readonly displayStackType: boolean; + readonly onSelectedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onRightClickedCallNodeChange: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onCallNodeEnterOrDoubleClick: ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => void; + readonly onKeyboardTransformShortcut: ( + event: React.KeyboardEvent, + nodeIndex: IndexIntoCallNodeTable + ) => void; }; -type DispatchProps = { - readonly changeSelectedCallNode: typeof changeSelectedCallNode; - readonly changeRightClickedCallNode: typeof changeRightClickedCallNode; - readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; - readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; export interface FlameGraphHandle { focus(): void; @@ -116,44 +98,13 @@ class FlameGraphImpl document.removeEventListener('copy', this._onCopy, false); } - _onSelectedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onRightClickedCallNodeChange = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; - changeRightClickedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); - }; - - _onCallNodeEnterOrDoubleClick = ( - callNodeIndex: IndexIntoCallNodeTable | null - ) => { - if (callNodeIndex === null) { - return; - } - const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; - const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); - updateBottomBoxContentsAndMaybeOpen('flame-graph', bottomBoxInfo); - }; - _shouldDisplayTooltips = () => this.props.rightClickedCallNodeIndex === null; _takeViewportRef = (viewport: HTMLDivElement | null) => { this._viewport = viewport; }; - /* This method is called from MaybeFlameGraph. */ + /* This method is called from ConnectedFlameGraph. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus = () => { if (this._viewport) { @@ -169,7 +120,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.rows[depth]; const columnIndex = row.callNode.indexOf(callNodeIndex); return row.end[columnIndex] - row.start[columnIndex] > SELECTABLE_THRESHOLD; }; @@ -194,7 +145,7 @@ class FlameGraphImpl const callNodeTable = callNodeInfo.getCallNodeTable(); const depth = callNodeTable.depth[callNodeIndex]; - const row = flameGraphTiming[depth]; + const row = flameGraphTiming.rows[depth]; let columnIndex = row.callNode.indexOf(callNodeIndex); do { @@ -215,13 +166,13 @@ class FlameGraphImpl _handleKeyDown = (event: React.KeyboardEvent) => { const { - threadsKey, callTree, callNodeInfo, selectedCallNodeIndex, rightClickedCallNodeIndex, - changeSelectedCallNode, - handleCallNodeTransformShortcut, + onSelectedCallNodeChange, + onCallNodeEnterOrDoubleClick, + onKeyboardTransformShortcut, } = this.props; const callNodeTable = callNodeInfo.getCallNodeTable(); @@ -231,10 +182,7 @@ class FlameGraphImpl ) { if (selectedCallNodeIndex === null) { // Just select the "root" node if we've got no prior selection. - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(0) - ); + onSelectedCallNodeChange(0); return; } @@ -242,10 +190,7 @@ class FlameGraphImpl case 'ArrowDown': { const prefix = callNodeTable.prefix[selectedCallNodeIndex]; if (prefix !== -1) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(prefix) - ); + onSelectedCallNodeChange(prefix); } break; } @@ -257,10 +202,7 @@ class FlameGraphImpl // thus the widest box. if (callNodeIndex !== undefined && this._wideEnough(callNodeIndex)) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -272,10 +214,7 @@ class FlameGraphImpl ); if (callNodeIndex !== undefined) { - changeSelectedCallNode( - threadsKey, - callNodeInfo.getCallNodePathFromIndex(callNodeIndex) - ); + onSelectedCallNodeChange(callNodeIndex); } break; } @@ -298,11 +237,11 @@ class FlameGraphImpl } if (event.key === 'Enter') { - this._onCallNodeEnterOrDoubleClick(nodeIndex); + onCallNodeEnterOrDoubleClick(nodeIndex); return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + onKeyboardTransformShortcut(event, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { @@ -343,6 +282,9 @@ class FlameGraphImpl ctssSampleCategoriesAndSubcategories, tracedTiming, displayStackType, + onSelectedCallNodeChange, + onRightClickedCallNodeChange, + onCallNodeEnterOrDoubleClick, } = this.props; // Get the CallTreeTimingsNonInverted out of tracedTiming. We pass this @@ -398,9 +340,9 @@ class FlameGraphImpl scrollToSelectionGeneration, callTreeSummaryStrategy, stackFrameHeight: STACK_FRAME_HEIGHT, - onSelectionChange: this._onSelectedCallNodeChange, - onRightClick: this._onRightClickedCallNodeChange, - onDoubleClick: this._onCallNodeEnterOrDoubleClick, + onSelectionChange: onSelectedCallNodeChange, + onRightClick: onRightClickedCallNodeChange, + onDoubleClick: onCallNodeEnterOrDoubleClick, shouldDisplayTooltips: this._shouldDisplayTooltips, interval, isInverted, @@ -424,50 +366,4 @@ function viewportNeedsUpdate() { return false; } -export const FlameGraph = explicitConnectWithForwardRef< - {}, - StateProps, - DispatchProps, - FlameGraphHandle ->({ - mapStateToProps: (state) => ({ - thread: selectedThreadSelectors.getFilteredThread(state), - weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), - // Use the filtered call node max depth, rather than the preview filtered one, so - // that the viewport height is stable across preview selections. - maxStackDepthPlusOne: - selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), - flameGraphTiming: selectedThreadSelectors.getFlameGraphTiming(state), - callTree: selectedThreadSelectors.getCallTree(state), - timeRange: getCommittedRange(state), - previewSelection: getPreviewSelection(state), - callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), - categories: getCategories(state), - threadsKey: getSelectedThreadsKey(state), - selectedCallNodeIndex: - selectedThreadSelectors.getSelectedCallNodeIndex(state), - rightClickedCallNodeIndex: - selectedThreadSelectors.getRightClickedCallNodeIndex(state), - scrollToSelectionGeneration: getScrollToSelectionGeneration(state), - interval: getProfileInterval(state), - isInverted: getInvertCallstack(state), - callTreeSummaryStrategy: - selectedThreadSelectors.getCallTreeSummaryStrategy(state), - innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), - ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), - ctssSampleCategoriesAndSubcategories: - selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( - state - ), - tracedTiming: selectedThreadSelectors.getTracedTiming(state), - displayStackType: getProfileUsesMultipleStackTypes(state), - }), - mapDispatchToProps: { - changeSelectedCallNode, - changeRightClickedCallNode, - handleCallNodeTransformShortcut, - updateBottomBoxContentsAndMaybeOpen, - }, - options: { forwardRef: true }, - component: FlameGraphImpl, -}); +export { FlameGraphImpl as FlameGraph }; diff --git a/src/components/flame-graph/MaybeFlameGraph.tsx b/src/components/flame-graph/MaybeFlameGraph.tsx deleted file mode 100644 index ef281aa4c5..0000000000 --- a/src/components/flame-graph/MaybeFlameGraph.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import * as React from 'react'; - -import { explicitConnectWithForwardRef } from 'firefox-profiler/utils/connect'; -import { getInvertCallstack } from '../../selectors/url-state'; -import { selectedThreadSelectors } from '../../selectors/per-thread'; -import { changeInvertCallstack } from '../../actions/profile-view'; -import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; -import { FlameGraph, type FlameGraphHandle } from './FlameGraph'; - -import type { ConnectedProps } from 'firefox-profiler/utils/connect'; - -import './MaybeFlameGraph.css'; - -// TODO: This component isn't needed any more. Whenever the selected tab -// is "flame-graph", `invertCallstack` will be `false`. is -// only used in the "flame-graph" tab. - -type StateProps = { - readonly isPreviewSelectionEmpty: boolean; - readonly invertCallstack: boolean; -}; -type DispatchProps = { - readonly changeInvertCallstack: typeof changeInvertCallstack; -}; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; - -class MaybeFlameGraphImpl extends React.PureComponent { - _flameGraph: React.RefObject = React.createRef(); - - _onSwitchToNormalCallstackClick = () => { - this.props.changeInvertCallstack(false); - }; - - override componentDidMount() { - const flameGraph = this._flameGraph.current; - if (flameGraph) { - flameGraph.focus(); - } - } - - override render() { - const { isPreviewSelectionEmpty, invertCallstack } = this.props; - - if (isPreviewSelectionEmpty) { - return ; - } - - if (invertCallstack) { - return ( -
-

The Flame Graph is not available for inverted call stacks

-

- {' '} - to show the Flame Graph. -

-
- ); - } - return ; - } -} - -export const MaybeFlameGraph = explicitConnectWithForwardRef< - {}, - StateProps, - DispatchProps, - MaybeFlameGraphImpl ->({ - mapStateToProps: (state) => { - return { - invertCallstack: getInvertCallstack(state), - isPreviewSelectionEmpty: - !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), - }; - }, - mapDispatchToProps: { - changeInvertCallstack, - }, - component: MaybeFlameGraphImpl, -}); diff --git a/src/components/flame-graph/index.tsx b/src/components/flame-graph/index.tsx index 141147302c..cd4f7eefe4 100644 --- a/src/components/flame-graph/index.tsx +++ b/src/components/flame-graph/index.tsx @@ -2,21 +2,65 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; + import { StackSettings } from '../shared/StackSettings'; import { TransformNavigator } from '../shared/TransformNavigator'; -import { MaybeFlameGraph } from './MaybeFlameGraph'; - -const FlameGraphView = () => ( -
- - - -
-); - -export const FlameGraph = FlameGraphView; +import { + ConnectedFlameGraph, + type ConnectedFlameGraphHandle, +} from './ConnectedFlameGraph'; +import { FlameGraphEmptyReasons } from './FlameGraphEmptyReasons'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './MaybeFlameGraph.css'; + +type StateProps = { + readonly isPreviewSelectionEmpty: boolean; +}; + +type Props = ConnectedProps<{}, StateProps, {}>; + +class FlameGraphViewImpl extends React.PureComponent { + _connectedFlameGraph: React.RefObject = + React.createRef(); + + override componentDidMount() { + this._connectedFlameGraph.current?.focus(); + } + + override render() { + const { isPreviewSelectionEmpty } = this.props; + + return ( +
+ + + {isPreviewSelectionEmpty ? ( + + ) : ( + + )} +
+ ); + } +} + +const FlameGraphViewConnected = explicitConnect<{}, StateProps, {}>({ + mapStateToProps: (state) => ({ + isPreviewSelectionEmpty: + !selectedThreadSelectors.getHasPreviewFilteredCtssSamples(state), + }), + mapDispatchToProps: {}, + component: FlameGraphViewImpl, +}); + +export const FlameGraph = FlameGraphViewConnected; diff --git a/src/components/marker-table/index.tsx b/src/components/marker-table/index.tsx index 2ecaad7e11..84c2c1a464 100644 --- a/src/components/marker-table/index.tsx +++ b/src/components/marker-table/index.tsx @@ -6,7 +6,7 @@ import { PureComponent } from 'react'; import memoize from 'memoize-immutable'; import explicitConnect from '../../utils/connect'; -import { TreeView } from '../shared/TreeView'; +import { TreeView, ColumnSortState } from '../shared/TreeView'; import { MarkerTableEmptyReasons } from './MarkerTableEmptyReasons'; import { getZeroAt, @@ -73,6 +73,16 @@ class MarkerTree { this._getMarkerLabel = getMarkerLabel; } + static _sortableColumns = [ + { name: 'start', prefersDescending: false }, + { name: 'duration', prefersDescending: true }, + { name: 'name', prefersDescending: false }, + ]; + + getSortableColumns(): any[] { + return MarkerTree._sortableColumns; + } + copyTable = ( format: 'plain' | 'markdown', onExceeedMaxCopyRows: (rows: number, maxRows: number) => void @@ -167,12 +177,49 @@ class MarkerTree { copy(text); }; - getRoots(): MarkerIndex[] { + getRoots(sort: ColumnSortState | null = null): MarkerIndex[] { + if (sort !== null) { + return sort.sortItemsHelper( + this._markerIndexes.slice(), + (first: MarkerIndex, second: MarkerIndex, column: string) => { + const firstValue = this._getSortValueForColumn(first, column); + const secondValue = this._getSortValueForColumn(second, column); + if (typeof firstValue === 'string') { + return firstValue.localeCompare(secondValue as string); + } + return (secondValue as number) - (firstValue as number); + } + ); + } return this._markerIndexes; } - getChildren(markerIndex: MarkerIndex): MarkerIndex[] { - return markerIndex === -1 ? this.getRoots() : []; + _getSortValueForColumn( + markerIndex: MarkerIndex, + column: string + ): string | number { + const marker = this._getMarker(markerIndex); + switch (column) { + case 'start': + return marker.start; + case 'duration': { + if (marker.incomplete || marker.end === null) { + return -Infinity; + } + return marker.end - marker.start; + } + case 'name': + return marker.name; + default: + throw new Error('Invalid column ' + column); + } + } + + getChildren( + markerIndex: MarkerIndex, + sort: ColumnSortState | null = null + ): MarkerIndex[] { + return markerIndex === -1 ? this.getRoots(sort) : []; } hasChildren(_markerIndex: MarkerIndex): boolean { @@ -205,10 +252,11 @@ class MarkerTree { } let duration = null; + const markerEnd = marker.end; if (marker.incomplete) { duration = 'unknown'; - } else if (marker.end !== null) { - duration = formatTimestamp(marker.end - marker.start); + } else if (markerEnd !== null) { + duration = formatTimestamp(markerEnd - marker.start); } displayData = { @@ -278,6 +326,7 @@ class MarkerTableImpl extends PureComponent { _treeView: TreeView | null = null; _takeTreeViewRef = (treeView: TreeView | null) => (this._treeView = treeView); + _sortedColumns = new ColumnSortState([{ column: 'start', ascending: true }]); getMarkerTree = memoize( ( @@ -330,6 +379,10 @@ class MarkerTableImpl extends PureComponent { changeSelectedMarker(threadsKey, selectedMarker, context); }; + _onColumnSortChange = (sortedColumns: ColumnSortState) => { + this._sortedColumns = sortedColumns; + }; + _onRightClickSelection = (selectedMarker: MarkerIndex) => { const { threadsKey, changeRightClickedMarker } = this.props; changeRightClickedMarker(threadsKey, selectedMarker); @@ -380,6 +433,8 @@ class MarkerTableImpl extends PureComponent { indentWidth={10} viewOptions={this.props.tableViewOptions} onViewOptionsChange={this.props.onTableViewOptionsChange} + initialSortedColumns={this._sortedColumns} + onColumnSortChange={this._onColumnSortChange} /> )} diff --git a/src/components/shared/DisclosureBox.css b/src/components/shared/DisclosureBox.css new file mode 100644 index 0000000000..be2d8fcfb0 --- /dev/null +++ b/src/components/shared/DisclosureBox.css @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.disclosureBox { + display: flex; + flex-direction: column; + min-height: 0; +} + +.disclosureBox.open { + flex: 1; +} + +.disclosureBoxButton { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 4px; + padding: 2px 6px; + border: none; + border-bottom: 1px solid var(--base-border-color); + background: var(--panel-background-color); + color: var(--panel-foreground-color); + font-size: 11px; + font-weight: bold; + text-align: start; + cursor: pointer; +} + +.disclosureBoxButton:hover { + background: var(--clickable-hover-background-color); +} + +.disclosureBoxButton:active { + background: var(--clickable-active-background-color); +} + +.disclosureBoxArrow { + display: inline-block; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid currentColor; + transition: transform 100ms; +} + +.disclosureBox.open .disclosureBoxArrow { + transform: rotate(90deg); +} + +.disclosureBoxContents { + display: flex; + flex: 1; + min-height: 0; +} diff --git a/src/components/shared/DisclosureBox.tsx b/src/components/shared/DisclosureBox.tsx new file mode 100644 index 0000000000..c0326ee675 --- /dev/null +++ b/src/components/shared/DisclosureBox.tsx @@ -0,0 +1,49 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; + +import './DisclosureBox.css'; + +type Props = { + readonly label: string; + readonly initialOpen?: boolean; + readonly children: React.ReactNode; +}; + +type State = { + isOpen: boolean; +}; + +export class DisclosureBox extends React.PureComponent { + override state: State = { + isOpen: this.props.initialOpen ?? true, + }; + + _onToggle = () => { + this.setState((state) => ({ isOpen: !state.isOpen })); + }; + + override render() { + const { label, children } = this.props; + const { isOpen } = this.state; + + return ( +
+ + {isOpen ? ( +
{children}
+ ) : null} +
+ ); + } +} diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/FunctionListContextMenu.tsx new file mode 100644 index 0000000000..ccbdeb54a8 --- /dev/null +++ b/src/components/shared/FunctionListContextMenu.tsx @@ -0,0 +1,488 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; +import { PureComponent } from 'react'; +import { MenuItem } from '@firefox-devtools/react-contextmenu'; +import { Localized } from '@fluent/react'; + +import { ContextMenu } from './ContextMenu'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + funcHasDirectRecursiveCall, + funcHasRecursiveCall, +} from 'firefox-profiler/profile-logic/transforms'; +import { getFunctionName } from 'firefox-profiler/profile-logic/function-info'; + +import copy from 'copy-to-clipboard'; +import { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +} from 'firefox-profiler/actions/profile-view'; +import { getImplementationFilter } from 'firefox-profiler/selectors/url-state'; +import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors/per-thread'; +import { + getProfileViewOptions, + getShouldDisplaySearchfox, +} from 'firefox-profiler/selectors/profile'; +import { oneLine } from 'common-tags'; + +import { + convertToTransformType, + assertExhaustiveCheck, +} from 'firefox-profiler/utils/types'; + +import type { + TransformType, + ImplementationFilter, + IndexIntoFuncTable, + Thread, + ThreadsKey, + CallNodeTable, + State, +} from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallNodeContextMenu.css'; + +type StateProps = { + readonly thread: Thread | null; + readonly threadsKey: ThreadsKey | null; + readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; + readonly callNodeTable: CallNodeTable | null; + readonly implementation: ImplementationFilter; + readonly displaySearchfox: boolean; +}; + +type DispatchProps = { + readonly addTransformToStack: typeof addTransformToStack; + readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack; + readonly setContextMenuVisibility: typeof setContextMenuVisibility; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FunctionListContextMenuImpl extends PureComponent { + _hidingTimeout: NodeJS.Timeout | null = null; + + _onShow = () => { + if (this._hidingTimeout) { + clearTimeout(this._hidingTimeout); + } + this.props.setContextMenuVisibility(true); + }; + + _onHide = () => { + this._hidingTimeout = setTimeout(() => { + this._hidingTimeout = null; + this.props.setContextMenuVisibility(false); + }); + }; + + _getRightClickedInfo(): null | { + readonly thread: Thread; + readonly threadsKey: ThreadsKey; + readonly funcIndex: IndexIntoFuncTable; + readonly callNodeTable: CallNodeTable; + } { + const { thread, threadsKey, rightClickedFunctionIndex, callNodeTable } = + this.props; + if ( + thread !== null && + threadsKey !== null && + rightClickedFunctionIndex !== null && + callNodeTable !== null + ) { + return { + thread, + threadsKey, + funcIndex: rightClickedFunctionIndex, + callNodeTable, + }; + } + return null; + } + + _getFunctionName(): string { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { stringTable, funcTable }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + const functionCall = stringTable.getString(funcTable.name[funcIndex]); + return isJS ? functionCall : getFunctionName(functionCall); + } + + lookupFunctionOnSearchfox(): void { + window.open( + `https://searchfox.org/mozilla-central/search?q=${encodeURIComponent( + this._getFunctionName() + )}`, + '_blank' + ); + } + + copyFunctionName(): void { + copy(this._getFunctionName()); + } + + getNameForSelectedResource(): string | null { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { funcTable, stringTable, resourceTable, sources }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + if (isJS) { + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex === null) { + return null; + } + return stringTable.getString(sources.filename[sourceIndex]); + } + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex === -1) { + return null; + } + return stringTable.getString(resourceTable.name[resourceIndex]); + } + + addTransformToStack(type: TransformType): void { + const { + addTransformToStack, + addCollapseResourceTransformToStack, + implementation, + } = this.props; + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { threadsKey, thread, funcIndex } = info; + + switch (type) { + case 'focus-function': + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex, + }); + break; + case 'focus-self': + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }); + break; + case 'merge-function': + addTransformToStack(threadsKey, { + type: 'merge-function', + funcIndex, + }); + break; + case 'drop-function': + addTransformToStack(threadsKey, { + type: 'drop-function', + funcIndex, + }); + break; + case 'collapse-resource': { + const resourceIndex = thread.funcTable.resource[funcIndex]; + addCollapseResourceTransformToStack( + threadsKey, + resourceIndex, + implementation + ); + break; + } + case 'collapse-direct-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-direct-recursion', + funcIndex, + implementation, + }); + break; + case 'collapse-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-recursion', + funcIndex, + }); + break; + case 'collapse-function-subtree': + addTransformToStack(threadsKey, { + type: 'collapse-function-subtree', + funcIndex, + }); + break; + case 'focus-subtree': + case 'merge-call-node': + case 'focus-category': + case 'filter-samples': + throw new Error( + `The transform "${type}" is not supported in the function list context menu.` + ); + default: + assertExhaustiveCheck(type); + } + } + + _handleClick = ( + _event: React.ChangeEvent, + data: { type: string } + ): void => { + const { type } = data; + + const transformType = convertToTransformType(type); + if (transformType) { + this.addTransformToStack(transformType); + return; + } + + switch (type) { + case 'searchfox': + this.lookupFunctionOnSearchfox(); + break; + case 'copy-function-name': + this.copyFunctionName(); + break; + default: + throw new Error(`Unknown type ${type}`); + } + }; + + renderTransformMenuItem(props: { + readonly l10nId: string; + readonly content: React.ReactNode; + readonly onClick: ( + event: React.ChangeEvent, + data: { type: string } + ) => void; + readonly transform: string; + readonly shortcut: string; + readonly icon: string; + readonly title: string; + readonly l10nVars?: Record; + readonly l10nElems?: Record; + }) { + return ( + + + +
+ {props.content} +
+
+ {props.shortcut} +
+ ); + } + + renderContextMenuContents() { + const { displaySearchfox } = this.props; + const info = this._getRightClickedInfo(); + + if (info === null) { + console.error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + return
; + } + + const { funcIndex, callNodeTable } = info; + const nameForResource = this.getNameForSelectedResource(); + + return ( + <> + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-merge-function', + shortcut: 'm', + icon: 'Merge', + onClick: this._handleClick, + transform: 'merge-function', + title: '', + content: 'Merge function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-function', + shortcut: 'f', + icon: 'Focus', + onClick: this._handleClick, + transform: 'focus-function', + title: '', + content: 'Focus on function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-self', + shortcut: 'S', + icon: 'FocusSelf', + onClick: this._handleClick, + transform: 'focus-self', + title: '', + content: 'Focus on self only', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', + shortcut: 'c', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-function-subtree', + title: '', + content: 'Collapse function', + })} + + {nameForResource + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-resource', + l10nVars: { nameForResource }, + l10nElems: { strong: }, + shortcut: 'C', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-resource', + title: '', + content: `Collapse ${nameForResource}`, + }) + : null} + + {funcHasRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-recursion', + shortcut: 'r', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-recursion', + title: '', + content: 'Collapse recursion', + }) + : null} + + {funcHasDirectRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: + 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + shortcut: 'R', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-direct-recursion', + title: '', + content: 'Collapse direct recursion only', + }) + : null} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-drop-function', + shortcut: 'd', + icon: 'Drop', + onClick: this._handleClick, + transform: 'drop-function', + title: '', + content: 'Drop samples with this function', + })} + +
+ + {displaySearchfox ? ( + + + Look up the function name on Searchfox + + + ) : null} + + + Copy function name + + + + ); + } + + override render() { + if (this._getRightClickedInfo() === null) { + return null; + } + + return ( + + {this.renderContextMenuContents()} + + ); + } +} + +export const FunctionListContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedFunction = + getProfileViewOptions(state).rightClickedFunction; + + let thread = null; + let threadsKey = null; + let rightClickedFunctionIndex = null; + let callNodeTable = null; + + if (rightClickedFunction !== null) { + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedFunction.threadsKey + ); + thread = selectors.getFilteredThread(state); + threadsKey = rightClickedFunction.threadsKey; + rightClickedFunctionIndex = rightClickedFunction.functionIndex; + // Use the non-inverted call node table for recursion detection. + callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + } + + return { + thread, + threadsKey, + rightClickedFunctionIndex, + callNodeTable, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + }, + mapDispatchToProps: { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, + }, + component: FunctionListContextMenuImpl, +}); diff --git a/src/components/shared/LowerWingContextMenu.tsx b/src/components/shared/LowerWingContextMenu.tsx new file mode 100644 index 0000000000..8c31ef6c3e --- /dev/null +++ b/src/components/shared/LowerWingContextMenu.tsx @@ -0,0 +1,489 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import * as React from 'react'; +import { PureComponent } from 'react'; +import { MenuItem } from '@firefox-devtools/react-contextmenu'; +import { Localized } from '@fluent/react'; + +import { ContextMenu } from './ContextMenu'; +import explicitConnect from 'firefox-profiler/utils/connect'; +import { + funcHasDirectRecursiveCall, + funcHasRecursiveCall, +} from 'firefox-profiler/profile-logic/transforms'; +import { getFunctionName } from 'firefox-profiler/profile-logic/function-info'; + +import copy from 'copy-to-clipboard'; +import { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +} from 'firefox-profiler/actions/profile-view'; +import { getImplementationFilter } from 'firefox-profiler/selectors/url-state'; +import { getThreadSelectorsFromThreadsKey } from 'firefox-profiler/selectors/per-thread'; +import { getShouldDisplaySearchfox } from 'firefox-profiler/selectors/profile'; +import { getRightClickedCallNodeInfo } from 'firefox-profiler/selectors/right-clicked-call-node'; +import { oneLine } from 'common-tags'; + +import { + convertToTransformType, + assertExhaustiveCheck, +} from 'firefox-profiler/utils/types'; + +import type { + TransformType, + ImplementationFilter, + IndexIntoFuncTable, + Thread, + ThreadsKey, + CallNodeTable, + State, +} from 'firefox-profiler/types'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallNodeContextMenu.css'; + +type StateProps = { + readonly thread: Thread | null; + readonly threadsKey: ThreadsKey | null; + readonly rightClickedFuncIndex: IndexIntoFuncTable | null; + readonly callNodeTable: CallNodeTable | null; + readonly implementation: ImplementationFilter; + readonly displaySearchfox: boolean; +}; + +type DispatchProps = { + readonly addTransformToStack: typeof addTransformToStack; + readonly addCollapseResourceTransformToStack: typeof addCollapseResourceTransformToStack; + readonly setContextMenuVisibility: typeof setContextMenuVisibility; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class LowerWingContextMenuImpl extends PureComponent { + _hidingTimeout: NodeJS.Timeout | null = null; + + _onShow = () => { + if (this._hidingTimeout) { + clearTimeout(this._hidingTimeout); + } + this.props.setContextMenuVisibility(true); + }; + + _onHide = () => { + this._hidingTimeout = setTimeout(() => { + this._hidingTimeout = null; + this.props.setContextMenuVisibility(false); + }); + }; + + _getRightClickedInfo(): null | { + readonly thread: Thread; + readonly threadsKey: ThreadsKey; + readonly funcIndex: IndexIntoFuncTable; + readonly callNodeTable: CallNodeTable; + } { + const { thread, threadsKey, rightClickedFuncIndex, callNodeTable } = + this.props; + if ( + thread !== null && + threadsKey !== null && + rightClickedFuncIndex !== null && + callNodeTable !== null + ) { + return { + thread, + threadsKey, + funcIndex: rightClickedFuncIndex, + callNodeTable, + }; + } + return null; + } + + _getFunctionName(): string { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { stringTable, funcTable }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + const functionCall = stringTable.getString(funcTable.name[funcIndex]); + return isJS ? functionCall : getFunctionName(functionCall); + } + + lookupFunctionOnSearchfox(): void { + window.open( + `https://searchfox.org/mozilla-central/search?q=${encodeURIComponent( + this._getFunctionName() + )}`, + '_blank' + ); + } + + copyFunctionName(): void { + copy(this._getFunctionName()); + } + + getNameForSelectedResource(): string | null { + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { + thread: { funcTable, stringTable, resourceTable, sources }, + funcIndex, + } = info; + const isJS = funcTable.isJS[funcIndex]; + if (isJS) { + const sourceIndex = funcTable.source[funcIndex]; + if (sourceIndex === null) { + return null; + } + return stringTable.getString(sources.filename[sourceIndex]); + } + const resourceIndex = funcTable.resource[funcIndex]; + if (resourceIndex === -1) { + return null; + } + return stringTable.getString(resourceTable.name[resourceIndex]); + } + + addTransformToStack(type: TransformType): void { + const { + addTransformToStack, + addCollapseResourceTransformToStack, + implementation, + } = this.props; + const info = this._getRightClickedInfo(); + if (info === null) { + throw new Error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + } + const { threadsKey, thread, funcIndex } = info; + + switch (type) { + case 'focus-function': + addTransformToStack(threadsKey, { + type: 'focus-function', + funcIndex, + }); + break; + case 'focus-self': + addTransformToStack(threadsKey, { + type: 'focus-self', + funcIndex, + implementation, + }); + break; + case 'merge-function': + addTransformToStack(threadsKey, { + type: 'merge-function', + funcIndex, + }); + break; + case 'drop-function': + addTransformToStack(threadsKey, { + type: 'drop-function', + funcIndex, + }); + break; + case 'collapse-resource': { + const resourceIndex = thread.funcTable.resource[funcIndex]; + addCollapseResourceTransformToStack( + threadsKey, + resourceIndex, + implementation + ); + break; + } + case 'collapse-direct-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-direct-recursion', + funcIndex, + implementation, + }); + break; + case 'collapse-recursion': + addTransformToStack(threadsKey, { + type: 'collapse-recursion', + funcIndex, + }); + break; + case 'collapse-function-subtree': + addTransformToStack(threadsKey, { + type: 'collapse-function-subtree', + funcIndex, + }); + break; + case 'focus-subtree': + case 'merge-call-node': + case 'focus-category': + case 'filter-samples': + throw new Error( + `The transform "${type}" is not supported in the lower wing context menu.` + ); + default: + assertExhaustiveCheck(type); + } + } + + _handleClick = ( + _event: React.ChangeEvent, + data: { type: string } + ): void => { + const { type } = data; + + const transformType = convertToTransformType(type); + if (transformType) { + this.addTransformToStack(transformType); + return; + } + + switch (type) { + case 'searchfox': + this.lookupFunctionOnSearchfox(); + break; + case 'copy-function-name': + this.copyFunctionName(); + break; + default: + throw new Error(`Unknown type ${type}`); + } + }; + + renderTransformMenuItem(props: { + readonly l10nId: string; + readonly content: React.ReactNode; + readonly onClick: ( + event: React.ChangeEvent, + data: { type: string } + ) => void; + readonly transform: string; + readonly shortcut: string; + readonly icon: string; + readonly title: string; + readonly l10nVars?: Record; + readonly l10nElems?: Record; + }) { + return ( + + + +
+ {props.content} +
+
+ {props.shortcut} +
+ ); + } + + renderContextMenuContents() { + const { displaySearchfox } = this.props; + const info = this._getRightClickedInfo(); + + if (info === null) { + console.error( + "The context menu assumes there is a right-clicked function and there wasn't one." + ); + return
; + } + + const { funcIndex, callNodeTable } = info; + const nameForResource = this.getNameForSelectedResource(); + + return ( + <> + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-merge-function', + shortcut: 'm', + icon: 'Merge', + onClick: this._handleClick, + transform: 'merge-function', + title: '', + content: 'Merge function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-function', + shortcut: 'f', + icon: 'Focus', + onClick: this._handleClick, + transform: 'focus-function', + title: '', + content: 'Focus on function', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-focus-self', + shortcut: 'S', + icon: 'FocusSelf', + onClick: this._handleClick, + transform: 'focus-self', + title: '', + content: 'Focus on self only', + })} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', + shortcut: 'c', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-function-subtree', + title: '', + content: 'Collapse function', + })} + + {nameForResource + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-resource', + l10nVars: { nameForResource }, + l10nElems: { strong: }, + shortcut: 'C', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-resource', + title: '', + content: `Collapse ${nameForResource}`, + }) + : null} + + {funcHasRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-collapse-recursion', + shortcut: 'r', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-recursion', + title: '', + content: 'Collapse recursion', + }) + : null} + + {funcHasDirectRecursiveCall(callNodeTable, funcIndex) + ? this.renderTransformMenuItem({ + l10nId: + 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + shortcut: 'R', + icon: 'Collapse', + onClick: this._handleClick, + transform: 'collapse-direct-recursion', + title: '', + content: 'Collapse direct recursion only', + }) + : null} + + {this.renderTransformMenuItem({ + l10nId: 'CallNodeContextMenu--transform-drop-function', + shortcut: 'd', + icon: 'Drop', + onClick: this._handleClick, + transform: 'drop-function', + title: '', + content: 'Drop samples with this function', + })} + +
+ + {displaySearchfox ? ( + + + Look up the function name on Searchfox + + + ) : null} + + + Copy function name + + + + ); + } + + override render() { + if (this._getRightClickedInfo() === null) { + return null; + } + + return ( + + {this.renderContextMenuContents()} + + ); + } +} + +export const LowerWingContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedCallNodeInfo = getRightClickedCallNodeInfo(state); + + let thread = null; + let threadsKey = null; + let rightClickedFuncIndex = null; + let callNodeTable = null; + + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.area === 'LOWER_WING' + ) { + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedCallNodeInfo.threadsKey + ); + thread = selectors.getFilteredThread(state); + threadsKey = rightClickedCallNodeInfo.threadsKey; + rightClickedFuncIndex = + selectors.getLowerWingRightClickedFuncIndex(state); + // Use the non-inverted call node table for recursion detection. + callNodeTable = selectors.getCallNodeInfo(state).getCallNodeTable(); + } + + return { + thread, + threadsKey, + rightClickedFuncIndex, + callNodeTable, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + }, + mapDispatchToProps: { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, + }, + component: LowerWingContextMenuImpl, +}); diff --git a/src/components/shared/TreeView.css b/src/components/shared/TreeView.css index 8bcb1f74a0..070f196670 100644 --- a/src/components/shared/TreeView.css +++ b/src/components/shared/TreeView.css @@ -123,6 +123,19 @@ text-overflow: ellipsis; } +.treeViewHeaderColumn.sortInactive::after { + content: ' ▲'; + opacity: 0; +} + +.treeViewHeaderColumn.sortDescending::after { + content: ' ▲'; +} + +.treeViewHeaderColumn.sortAscending::after { + content: ' ▼ '; +} + .treeViewColumnDivider { display: flex; width: 20px; diff --git a/src/components/shared/TreeView.tsx b/src/components/shared/TreeView.tsx index a6eb6e374d..8a54f73e03 100644 --- a/src/components/shared/TreeView.tsx +++ b/src/components/shared/TreeView.tsx @@ -51,6 +51,69 @@ export type Column> = { }>; }; +type SingleColumnSortState = { + column: string; + ascending: boolean; +}; + +export class ColumnSortState { + sortedColumns: SingleColumnSortState[]; + + constructor(sortedColumns: SingleColumnSortState[]) { + this.sortedColumns = sortedColumns; + } + + withToggledSortForColumn( + column: string, + prefersDescending: boolean + ): ColumnSortState { + const current = this.current(); + const sortedColumns = this.sortedColumns.filter((c) => c.column !== column); + + sortedColumns.push({ + column, + ascending: + current && current.column === column + ? !current.ascending + : !prefersDescending, + }); + return new ColumnSortState(sortedColumns); + } + + _isLastSortedColumn(column: string): boolean { + const cur = this.current(); + return cur !== null && cur.column === column; + } + + _invertSortState(state: SingleColumnSortState): SingleColumnSortState { + return { column: state.column, ascending: !state.ascending }; + } + + getStateForColumn(column: string): SingleColumnSortState | null { + return this.sortedColumns.find((c) => c.column === column) ?? null; + } + + current(): SingleColumnSortState | null { + return this.sortedColumns.length > 0 + ? this.sortedColumns[this.sortedColumns.length - 1] + : null; + } + + sortItemsHelper( + items: T[], + compareColumn: (a: T, b: T, column: string) => number + ): T[] { + let sorted = items; + for (const { column, ascending } of this.sortedColumns) { + const sign = ascending ? -1 : 1; + sorted = sorted.sort((a, b) => { + return sign * compareColumn(a, b, column); + }); + } + return sorted; + } +} + export type MaybeResizableColumn> = Column & { /** defaults to initialWidth */ @@ -73,6 +136,9 @@ type TreeViewHeaderProps> = { // passes the column index and the start x coordinate readonly onColumnWidthChangeStart: (param: number, x: CssPixels) => void; readonly onColumnWidthReset: (param: number) => void; + readonly onToggleSortForColumn: (column: string) => void; + readonly currentSortedColumn: SingleColumnSortState | null; + readonly sortableColumns?: Set; }; class TreeViewHeader< @@ -91,8 +157,22 @@ class TreeViewHeader< ); }; + _onToggleSortForColumn = (e: React.MouseEvent) => { + const { onToggleSortForColumn } = this.props; + const target = e.currentTarget; + if (target instanceof HTMLElement && target.dataset.column) { + onToggleSortForColumn(target.dataset.column); + } + }; + override render() { - const { fixedColumns, mainColumn, viewOptions } = this.props; + const { + fixedColumns, + mainColumn, + viewOptions, + currentSortedColumn, + sortableColumns, + } = this.props; const columnWidths = viewOptions.fixedColumnWidths; if (fixedColumns.length === 0 && !mainColumn.titleL10nId) { // If there is nothing to display in the header, do not render it. @@ -102,12 +182,31 @@ class TreeViewHeader<
{fixedColumns.map((col, i) => { const width = columnWidths[i] + (col.headerWidthAdjustment || 0); + let sortClass = ''; + if (sortableColumns && sortableColumns.has(col.propName)) { + if ( + currentSortedColumn && + currentSortedColumn.column === col.propName + ) { + sortClass = currentSortedColumn.ascending + ? 'sortDescending' + : 'sortAscending'; + } else { + sortClass = 'sortInactive'; + } + } return ( {col.hideDividerAfter !== true ? ( @@ -429,14 +528,21 @@ class TreeViewRowScrolledColumns< } } +type SortableColumn = { + name: string; + prefersDescending: boolean; +}; + interface Tree> { getDepth(nodeIndex: NodeIndex): number; - getRoots(): NodeIndex[]; + getRoots(sort: ColumnSortState | null): NodeIndex[]; getDisplayData(nodeIndex: NodeIndex): DisplayData; getParent(nodeIndex: NodeIndex): NodeIndex; - getChildren(nodeIndex: NodeIndex): NodeIndex[]; + getChildren(nodeIndex: NodeIndex, sort: ColumnSortState | null): NodeIndex[]; hasChildren(nodeIndex: NodeIndex): boolean; getAllDescendants(nodeIndex: NodeIndex): Set; + + getSortableColumns(): SortableColumn[]; // constant } type TreeViewProps> = { @@ -465,11 +571,14 @@ type TreeViewProps> = { readonly onKeyDown?: (param: React.KeyboardEvent) => void; readonly viewOptions: TableViewOptions; readonly onViewOptionsChange?: (param: TableViewOptions) => void; + readonly initialSortedColumns?: ColumnSortState; + readonly onColumnSortChange?: (sortedColumns: ColumnSortState) => void; }; type TreeViewState = { readonly fixedColumnWidths: Array | null; readonly isResizingColumns: boolean; + readonly sortedColumns: ColumnSortState; }; export class TreeView< @@ -485,14 +594,23 @@ export class TreeView< initialWidth: CssPixels; } | null = null; - override state = { + override state: TreeViewState = { // This contains the current widths, while or after the user resizes them. fixedColumnWidths: null, // This is true when the user is currently resizing a column. isResizingColumns: false, + + sortedColumns: new ColumnSortState([]), }; + constructor(props: TreeViewProps) { + super(props); + if (props.initialSortedColumns) { + this.state = { ...this.state, sortedColumns: props.initialSortedColumns }; + } + } + // This is incremented when a column changed its size. We use this to force a // rerender of the VirtualList component. _columnSizeChangedCounter: number = 0; @@ -522,6 +640,10 @@ export class TreeView< fixedColumns.map((c) => c.initialWidth) ); + _getSortableColumnNames = memoize( + () => new Set(this.props.tree.getSortableColumns().map((c) => c.name)) + ); + // This returns the column widths from several possible sources, in this order: // * the current state (this means the user changed them recently, or is // currently changing them) @@ -612,7 +734,11 @@ export class TreeView< }; _computeAllVisibleRowsMemoized = memoize( - (tree: Tree, expandedNodes: Set) => { + ( + tree: Tree, + expandedNodes: Set, + sortedColumns: ColumnSortState + ) => { function _addVisibleRowsFromNode( tree: Tree, expandedNodes: Set, @@ -623,13 +749,14 @@ export class TreeView< if (!expandedNodes.has(nodeId)) { return; } - const children = tree.getChildren(nodeId); + const children = tree.getChildren(nodeId, sortedColumns); for (let i = 0; i < children.length; i++) { _addVisibleRowsFromNode(tree, expandedNodes, arr, children[i]); } } - const roots = tree.getRoots(); + // we only sort the root nodes for now + const roots = tree.getRoots(sortedColumns); const allRows: NodeIndex[] = []; for (let i = 0; i < roots.length; i++) { _addVisibleRowsFromNode(tree, expandedNodes, allRows, roots[i]); @@ -721,7 +848,11 @@ export class TreeView< _getAllVisibleRows(): NodeIndex[] { const { tree } = this.props; - return this._computeAllVisibleRowsMemoized(tree, this._getExpandedNodes()); + return this._computeAllVisibleRowsMemoized( + tree, + this._getExpandedNodes(), + this.state.sortedColumns + ); } _getSpecialItems(): [NodeIndex | void, NodeIndex | void] { @@ -909,7 +1040,10 @@ export class TreeView< // Do KEY_DOWN only if the next element is a child if (this.props.tree.hasChildren(selected)) { this._selectWithKeyboard( - this.props.tree.getChildren(selected)[0] + this.props.tree.getChildren( + selected, + this.state.sortedColumns + )[0] ); } } @@ -934,6 +1068,27 @@ export class TreeView< } }; + _onToggleSortForColumn = (column: string) => { + const sortableColumn = this.props.tree + .getSortableColumns() + .find((c) => c.name === column); + if (sortableColumn === undefined) { + return; + } + this.setState((prevState) => { + const newSortedColumns = prevState.sortedColumns.withToggledSortForColumn( + column, + sortableColumn.prefersDescending + ); + if (this.props.onColumnSortChange) { + this.props.onColumnSortChange(newSortedColumns); + } + return { + sortedColumns: newSortedColumns, + }; + }); + }; + /* This method is used by users of this component. */ /* eslint-disable-next-line react/no-unused-class-component-methods */ focus() { @@ -953,7 +1108,8 @@ export class TreeView< rowHeight, selectedNodeId, } = this.props; - const { isResizingColumns } = this.state; + const { isResizingColumns, sortedColumns } = this.state; + const sortableColumns = this._getSortableColumnNames(); return (
| null { return { calltree: CallTreeSidebar, + 'function-list': CallTreeSidebar, 'flame-graph': CallTreeSidebar, 'stack-chart': null, 'marker-chart': null, diff --git a/src/components/stack-chart/index.tsx b/src/components/stack-chart/index.tsx index ba98f87a9b..ba71329ce2 100644 --- a/src/components/stack-chart/index.tsx +++ b/src/components/stack-chart/index.tsx @@ -177,7 +177,7 @@ class StackChartImpl extends React.PureComponent { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onDoubleClick = (callNodeIndex: IndexIntoCallNodeTable | null) => { diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b0ee5338a6..7b713077b9 100644 --- a/src/components/timeline/TrackThread.tsx +++ b/src/components/timeline/TrackThread.tsx @@ -25,6 +25,7 @@ import { getImplementationFilter, getZeroAt, getProfileTimelineUnit, + getSelectedTab, } from 'firefox-profiler/selectors'; import { TimelineMarkersJank, @@ -37,6 +38,7 @@ import { changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, } from 'firefox-profiler/actions/profile-view'; import { reportTrackThreadHeight } from 'firefox-profiler/actions/app'; import { EmptyThreadIndicator } from './EmptyThreadIndicator'; @@ -99,6 +101,7 @@ type DispatchProps = { readonly changeSelectedCallNode: typeof changeSelectedCallNode; readonly focusCallTree: typeof focusCallTree; readonly selectSelfCallNode: typeof selectSelfCallNode; + readonly selectSelfFunction: typeof selectSelfFunction; readonly reportTrackThreadHeight: typeof reportTrackThreadHeight; }; @@ -122,6 +125,7 @@ class TimelineTrackThreadImpl extends PureComponent { const { threadsKey, selectSelfCallNode, + selectSelfFunction, focusCallTree, selectedThreadIndexes, callTreeVisible, @@ -129,6 +133,7 @@ class TimelineTrackThreadImpl extends PureComponent { // Sample clicking only works for one thread. See issue #2709 if (selectedThreadIndexes.size === 1) { + selectSelfFunction(threadsKey, sampleIndex); selectSelfCallNode(threadsKey, sampleIndex); if (sampleIndex !== null && callTreeVisible) { @@ -349,7 +354,9 @@ export const TimelineTrackThread = explicitConnect< hasFileIoMarkers: selectors.getTimelineFileIoMarkerIndexes(state).length !== 0, sampleSelectedStates: - selectors.getSampleSelectedStatesInFilteredThread(state), + getSelectedTab(state) === 'function-list' + ? selectors.getSampleSelectedStatesForFunctionListTab(state) + : selectors.getSampleSelectedStatesInFilteredThread(state), treeOrderSampleComparator: selectors.getTreeOrderComparatorInFilteredThread(state), selectedThreadIndexes, @@ -365,6 +372,7 @@ export const TimelineTrackThread = explicitConnect< changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, reportTrackThreadHeight, }, component: withSize(TimelineTrackThreadImpl), diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index 70ff159167..e736b0d031 100644 --- a/src/profile-logic/bottom-box.ts +++ b/src/profile-logic/bottom-box.ts @@ -8,12 +8,14 @@ import type { Thread, IndexIntoStackTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, BottomBoxInfo, SamplesLikeTable, } from 'firefox-profiler/types'; import type { CallNodeInfo } from './call-node-info'; import { getCallNodeFramePerStack, + getFunctionFramePerStack, getNativeSymbolInfo, getNativeSymbolsForCallNode, getTotalNativeSymbolTimingsForCallNode, @@ -189,3 +191,102 @@ export function getBottomBoxInfoForStackFrame( instructionAddress !== -1 ? instructionAddress : null, }; } + +/** + * Calculate the BottomBoxInfo for a function, i.e. information about which + * things should be shown in the profiler UI's "bottom box" when a function is + * double-clicked in the function list. + * + * Unlike getBottomBoxInfoForCallNode, this considers all stacks where the + * function appears anywhere (not just as the self function), using the + * innermost (leaf-most) frame when the function appears multiple times in one + * stack due to recursion. + */ +export function getBottomBoxInfoForFunction( + funcIndex: IndexIntoFuncTable, + thread: Thread, + samples: SamplesLikeTable +): BottomBoxInfo { + const { + stackTable, + frameTable, + funcTable, + stringTable, + resourceTable, + nativeSymbols, + } = thread; + + const sourceIndex = funcTable.source[funcIndex]; + const resource = funcTable.resource[funcIndex]; + const libIndex = + resource !== -1 && resourceTable.type[resource] === ResourceType.Library + ? resourceTable.lib[resource] + : null; + + const funcFramePerStack = getFunctionFramePerStack( + funcIndex, + stackTable, + frameTable + ); + + const nativeSymbolsForFunc = getNativeSymbolsForCallNode( + funcFramePerStack, + frameTable + ); + let initialNativeSymbol = null; + const nativeSymbolTimings = getTotalNativeSymbolTimingsForCallNode( + samples, + funcFramePerStack, + frameTable + ); + const hottestNativeSymbol = mapGetKeyWithMaxValue(nativeSymbolTimings); + if (hottestNativeSymbol !== undefined) { + nativeSymbolsForFunc.add(hottestNativeSymbol); + initialNativeSymbol = hottestNativeSymbol; + } + const nativeSymbolsForFuncArr = [...nativeSymbolsForFunc]; + nativeSymbolsForFuncArr.sort((a, b) => a - b); + if (nativeSymbolsForFuncArr.length !== 0 && initialNativeSymbol === null) { + initialNativeSymbol = nativeSymbolsForFuncArr[0]; + } + + const nativeSymbolInfosForFunc = nativeSymbolsForFuncArr.map( + (nativeSymbolIndex) => + getNativeSymbolInfo( + nativeSymbolIndex, + nativeSymbols, + frameTable, + stringTable + ) + ); + + const funcLine = funcTable.lineNumber[funcIndex]; + const lineTimings = getTotalLineTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + funcLine + ); + const hottestLine = mapGetKeyWithMaxValue(lineTimings); + const addressTimings = getTotalAddressTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + initialNativeSymbol + ); + const hottestInstructionAddress = mapGetKeyWithMaxValue(addressTimings); + + return { + libIndex, + sourceIndex, + nativeSymbols: nativeSymbolInfosForFunc, + initialNativeSymbol: + initialNativeSymbol !== null + ? nativeSymbolsForFuncArr.indexOf(initialNativeSymbol) + : null, + scrollToLineNumber: hottestLine, + scrollToInstructionAddress: hottestInstructionAddress, + highlightedLineNumber: null, + highlightedInstructionAddress: null, + }; +} diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 57495bec52..73671ccf00 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -38,7 +38,11 @@ import { checkBit } from '../utils/bitset'; import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; -import { getBottomBoxInfoForCallNode } from './bottom-box'; +import type { ColumnSortState } from '../components/shared/TreeView'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -47,6 +51,7 @@ export type CallTreeTimingsNonInverted = { self: Float64Array; total: Float64Array; rootTotalSummary: number; // sum of absolute values, this is used for computing percentages + flameGraphTotalForScaling: number; // used as 100% reference for flame graph box widths }; type TotalAndHasChildren = { total: number; hasChildren: boolean }; @@ -109,7 +114,8 @@ interface CallTreeInternal { hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean; createChildren(nodeIndex: IndexIntoCallNodeTable): CallNodeChildren; createRoots(): CallNodeChildren; - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal; + getSelf(nodeIndex: IndexIntoCallNodeTable): number; + getTotal(nodeIndex: IndexIntoCallNodeTable): number; findHeaviestPathInSubtree( callNodeIndex: IndexIntoCallNodeTable ): CallNodePath; @@ -175,10 +181,12 @@ export class CallTreeInternalNonInverted implements CallTreeInternal { return this._callNodeHasChildren[callNodeIndex] !== 0; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - const self = this._callTreeTimings.self[callNodeIndex]; - const total = this._callTreeTimings.total[callNodeIndex]; - return { self, total }; + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.self[callNodeIndex]; + } + + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { + return this._callTreeTimings.total[callNodeIndex]; } findHeaviestPathInSubtree( @@ -220,11 +228,12 @@ export class CallTreeInternalFunctionList implements CallTreeInternal { return this._timings.sortedFuncs; } - getSelfAndTotal(nodeIndex: IndexIntoCallNodeTable): SelfAndTotal { - return { - self: this._timings.funcSelf[nodeIndex], - total: this._timings.funcTotal[nodeIndex], - }; + getSelf(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcSelf[nodeIndex]; + } + + getTotal(nodeIndex: IndexIntoCallNodeTable): number { + return this._timings.funcTotal[nodeIndex]; } findHeaviestPathInSubtree( @@ -292,15 +301,20 @@ class CallTreeInternalInverted implements CallTreeInternal { return children; } - getSelfAndTotal(callNodeIndex: IndexIntoCallNodeTable): SelfAndTotal { + getSelf(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - const total = this._totalPerRootFunc[callNodeIndex]; - return { self: total, total }; + return this._totalPerRootFunc[callNodeIndex]; } const { total } = this._getTotalAndHasChildren(callNodeIndex); - return { self: 0, total }; + return total; } + getTotal(callNodeIndex: IndexIntoCallNodeTable): number { + if (this._callNodeInfo.isRoot(callNodeIndex)) { + return this._totalPerRootFunc[callNodeIndex]; + } + return 0; + } _getTotalAndHasChildren( callNodeIndex: IndexIntoCallNodeTable ): TotalAndHasChildren { @@ -356,6 +370,11 @@ class CallTreeInternalInverted implements CallTreeInternal { } } +type SortableColumn = { + name: string; + prefersDescending: boolean; +}; + export class CallTree { _categories: CategoryList; _internal: CallTreeInternal; @@ -371,6 +390,11 @@ export class CallTree { _isHighPrecision: boolean; _weightType: WeightType; + static _sortableColumns: SortableColumn[] = [ + { name: 'self', prefersDescending: true }, + { name: 'total', prefersDescending: true }, + ]; + constructor( thread: Thread, categories: CategoryList, @@ -394,17 +418,61 @@ export class CallTree { this._weightType = weightType; } - getRoots() { - return this._roots; + getSortableColumns(): SortableColumn[] { + return CallTree._sortableColumns; + } + + getRoots(sort: ColumnSortState | null = null) { + return this._getSortedNodes(this._roots, sort); } - getChildren(callNodeIndex: IndexIntoCallNodeTable): CallNodeChildren { + _getSortedNodes( + nodes: CallNodeChildren, + sort: ColumnSortState | null = null + ): CallNodeChildren { + if (!sort) { + return nodes; + } + const primarySortColumn = sort.current(); + if (!primarySortColumn) { + return nodes; + } + + if (primarySortColumn.column === 'total' && !primarySortColumn.ascending) { + return nodes; + } + return sort.sortItemsHelper(nodes.slice(), (first, second, column) => { + return ( + this._getSortValueForColumn(second, column) - + this._getSortValueForColumn(first, column) + ); + }); + } + + _getSortValueForColumn( + callNodeIndex: IndexIntoCallNodeTable, + column: string + ): number { + switch (column) { + case 'total': + return this._internal.getTotal(callNodeIndex); + case 'self': + return this._internal.getSelf(callNodeIndex); + default: + throw new Error('Invalid column ' + column); + } + } + + getChildren( + callNodeIndex: IndexIntoCallNodeTable, + sort: ColumnSortState | null = null + ): CallNodeChildren { let children = this._children[callNodeIndex]; if (children === undefined) { children = this._internal.createChildren(callNodeIndex); this._children[callNodeIndex] = children; } - return children; + return this._getSortedNodes(children, sort); } hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean { @@ -445,7 +513,8 @@ export class CallTree { this._thread.funcTable.name[funcIndex] ); - const { self, total } = this._internal.getSelfAndTotal(callNodeIndex); + const total = this._internal.getTotal(callNodeIndex); + const self = this._internal.getSelf(callNodeIndex); const totalRelative = total / this._rootTotalSummary; const selfRelative = self / this._rootTotalSummary; @@ -626,6 +695,14 @@ export class CallTree { ); } + getBottomBoxInfoForFunction(funcIndex: IndexIntoFuncTable): BottomBoxInfo { + return getBottomBoxInfoForFunction( + funcIndex, + this._thread, + this._previewFilteredCtssSamples + ); + } + /** * Take a IndexIntoCallNodeTable, and compute an inverted path for it. * @@ -686,7 +763,7 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling: rootTotalSummary }; } export function getSelfAndTotalForCallNode( @@ -825,6 +902,60 @@ export function computeCallTreeTimingsInverted( }; } +function _computeLowerWingCallNodeSelf( + callNodeSelf: Float64Array, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable +): Float64Array { + // There is an implicit mapping so that every call node in the non-inverted table is mapped to: + // - either the root-most ancestor whose func is selectedFuncIndex, or + // - -1 if no such ancestor exists + const callNodeCount = callNodeTable.length; + const funcCol = callNodeTable.func; + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + const mappedSelf = new Float64Array(callNodeCount); + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + + // Call node i is the root of a subtree for the selected function. + const subtreeEnd = subtreeEndCol[i]; + let subtreeTotal = 0; + for (let j = i; j < subtreeEnd; j++) { + subtreeTotal += callNodeSelf[j]; + } + mappedSelf[i] = subtreeTotal; + i = subtreeEnd - 1; + } + return mappedSelf; +} + +export function computeLowerWingTimings( + callNodeInfo: CallNodeInfoInverted, + { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary, + selectedFuncIndex: IndexIntoFuncTable | null +): CallTreeTimings { + const callNodeTable = callNodeInfo.getCallNodeTable(); + const mappedSelf = + selectedFuncIndex !== null + ? _computeLowerWingCallNodeSelf( + callNodeSelf, + callNodeTable, + selectedFuncIndex + ) + : new Float64Array(callNodeSelf.length); + + return { + type: 'INVERTED', + timings: computeCallTreeTimingsInverted(callNodeInfo, { + callNodeSelf: mappedSelf, + rootTotalSummary, + flameGraphTotalForScaling: rootTotalSummary, + }), + }; +} + export function computeCallTreeTimings( callNodeInfo: CallNodeInfo, callNodeSelfAndSummary: CallNodeSelfAndSummary @@ -857,7 +988,7 @@ export function computeCallTreeTimingsNonInverted( callNodeSelfAndSummary: CallNodeSelfAndSummary ): CallTreeTimingsNonInverted { const callNodeTable = callNodeInfo.getCallNodeTable(); - const { callNodeSelf, rootTotalSummary } = callNodeSelfAndSummary; + const { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling } = callNodeSelfAndSummary; // Compute the following variables: const callNodeTotal = new Float64Array(callNodeTable.length); @@ -895,6 +1026,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphTotalForScaling, }; } @@ -1297,5 +1429,5 @@ export function computeCallNodeTracedSelfAndSummary( } } - return { callNodeSelf, rootTotalSummary }; + return { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling: rootTotalSummary }; } diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 2cf8e3701d..da07641f10 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -30,13 +30,28 @@ export type IndexIntoFlameGraphTiming = number; * selfRelative contains the self time relative to the total time, * which is used to color the drawn functions. */ -export type FlameGraphTiming = Array<{ +export type FlameGraphTimingRow = { start: UnitIntervalOfProfileRange[]; end: UnitIntervalOfProfileRange[]; selfRelative: Array; callNode: IndexIntoCallNodeTable[]; length: number; -}>; +}; + +/** + * FlameGraphTiming is an array of rows plus a scalar adjustment factor. + * + * tooltipRatioMultiplier converts a box's (end - start) width to a percentage + * relative to all filtered samples. Multiply (end - start) by this to get the + * tooltip percentage. It equals flameGraphTotalForScaling / rootTotalSummary, + * which is 1.0 for normal flame graphs and < 1.0 for the upper wing (where + * boxes are scaled so that the root fills the full width, but tooltips still + * show percentages relative to all filtered samples). + */ +export type FlameGraphTiming = { + rows: FlameGraphTimingRow[]; + tooltipRatioMultiplier: number; +}; /** * FlameGraphRows is an array of rows, where each row is an array of call node @@ -232,7 +247,7 @@ export function getFlameGraphTiming( callNodeTable: CallNodeTable, callTreeTimings: CallTreeTimingsNonInverted ): FlameGraphTiming { - const { total, self, rootTotalSummary } = callTreeTimings; + const { total, self, rootTotalSummary, flameGraphTotalForScaling } = callTreeTimings; const { prefix } = callNodeTable; // This is where we build up the return value, one row at a time. @@ -284,8 +299,8 @@ export function getFlameGraphTiming( startPerCallNode[nodeIndex] = currentStart; // Take the absolute value, as native deallocations can be negative. - const totalRelativeVal = abs(totalVal / rootTotalSummary); - const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); + const totalRelativeVal = abs(totalVal / flameGraphTotalForScaling); + const selfRelativeVal = abs(self[nodeIndex] / flameGraphTotalForScaling); const currentEnd = currentStart + totalRelativeVal; start.push(currentStart); @@ -305,5 +320,8 @@ export function getFlameGraphTiming( }; } - return timing; + return { + rows: timing, + tooltipRatioMultiplier: flameGraphTotalForScaling / rootTotalSummary, + }; } diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 645d8a9cc8..e18da91318 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -180,7 +180,10 @@ export function computeCallNodeTable( } const hierarchy = _computeCallNodeTableHierarchy(stackTable, frameTable); - const dfsOrder = _computeCallNodeTableDFSOrder(hierarchy); + const dfsOrder = _computeCallNodeTableDFSOrder( + hierarchy, + hierarchy.stackIndexToCallNodeIndex + ); const { stackIndexToCallNodeIndex } = dfsOrder; const frameInlinedIntoCol = _computeFrameTableInlinedIntoColumn(frameTable); const extraColumns = _computeCallNodeTableExtraColumns( @@ -224,7 +227,6 @@ type CallNodeTableHierarchy = { firstChild: Array; nextSibling: Array; length: number; - stackIndexToCallNodeIndex: Int32Array; }; /** @@ -276,7 +278,7 @@ type CallNodeTableExtraColumns = { function _computeCallNodeTableHierarchy( stackTable: StackTable, frameTable: FrameTable -): CallNodeTableHierarchy { +): CallNodeTableHierarchy & { stackIndexToCallNodeIndex: Int32Array } { const stackIndexToCallNodeIndex = new Int32Array(stackTable.length); // The callNodeTable components. @@ -309,7 +311,7 @@ function _computeCallNodeTableHierarchy( // Check if the call node for this stack already exists. let callNodeIndex = -1; - if (stackIndex !== 0) { + if (length !== 0) { const currentFirstSibling = prefixCallNode === -1 ? 0 : firstChild[prefixCallNode]; for ( @@ -394,10 +396,10 @@ function _computeCallNodeTableHierarchy( * stack nodes in the stack table. */ function _computeCallNodeTableDFSOrder( - hierarchy: CallNodeTableHierarchy + hierarchy: CallNodeTableHierarchy, + stackIndexToCallNodeIndex: Int32Array ): CallNodeTableDFSOrder { - const { prefix, firstChild, nextSibling, length, stackIndexToCallNodeIndex } = - hierarchy; + const { prefix, firstChild, nextSibling, length } = hierarchy; const prefixSorted = new Int32Array(length); const nextSiblingSorted = new Int32Array(length); @@ -489,6 +491,114 @@ function _computeCallNodeTableDFSOrder( }; } +// mutates originalCallNodeToCallNodeIndex +function _computeCallNodeTableDFSOrder2( + hierarchy: CallNodeTableHierarchy, + originalCallNodeToCallNodeIndex: Int32Array, + stackIndexToOriginalCallNodeIndex: Int32Array +): CallNodeTableDFSOrder { + const { prefix, firstChild, nextSibling, length } = hierarchy; + + const prefixSorted = new Int32Array(length); + const nextSiblingSorted = new Int32Array(length); + const subtreeRangeEndSorted = new Uint32Array(length); + const depthSorted = new Int32Array(length); + let maxDepth = 0; + + if (length === 0) { + return { + prefixSorted, + subtreeRangeEndSorted, + nextSiblingSorted, + depthSorted, + maxDepth, + length, + stackIndexToCallNodeIndex: stackIndexToOriginalCallNodeIndex, + }; + } + + // Traverse the entire tree, as follows: + // 1. nextOldIndex is the next node in DFS order. Copy over all values from + // the unsorted columns into the sorted columns. + // 2. Find the next node in DFS order, set nextOldIndex to it, and continue + // to the next loop iteration. + const oldIndexToNewIndex = new Uint32Array(length); + let nextOldIndex = 0; + let nextNewIndex = 0; + let currentDepth = 0; + let currentOldPrefix = -1; + let currentNewPrefix = -1; + while (nextOldIndex !== -1) { + const oldIndex = nextOldIndex; + const newIndex = nextNewIndex; + oldIndexToNewIndex[oldIndex] = newIndex; + nextNewIndex++; + + prefixSorted[newIndex] = currentNewPrefix; + depthSorted[newIndex] = currentDepth; + // The remaining two columns, nextSiblingSorted and subtreeRangeEndSorted, + // will be filled in when we get to the end of the current subtree. + + // Find the next index in DFS order: If we have children, then our first child + // is next. Otherwise, we need to advance to our next sibling, if we have one, + // otherwise to the next sibling of the first ancestor which has one. + const oldFirstChild = firstChild[oldIndex]; + if (oldFirstChild !== -1) { + // We have children. Our first child is the next node in DFS order. + currentOldPrefix = oldIndex; + currentNewPrefix = newIndex; + nextOldIndex = oldFirstChild; + currentDepth++; + if (currentDepth > maxDepth) { + maxDepth = currentDepth; + } + continue; + } + + // We have no children. The next node is the next sibling of this node or + // of an ancestor node. Now is also a good time to fill in the values for + // subtreeRangeEnd and nextSibling. + subtreeRangeEndSorted[newIndex] = nextNewIndex; + nextOldIndex = nextSibling[oldIndex]; + nextSiblingSorted[newIndex] = nextOldIndex === -1 ? -1 : nextNewIndex; + while (nextOldIndex === -1 && currentOldPrefix !== -1) { + subtreeRangeEndSorted[currentNewPrefix] = nextNewIndex; + const oldPrefixNextSibling = nextSibling[currentOldPrefix]; + nextSiblingSorted[currentNewPrefix] = + oldPrefixNextSibling === -1 ? -1 : nextNewIndex; + nextOldIndex = oldPrefixNextSibling; + currentOldPrefix = prefix[currentOldPrefix]; + currentNewPrefix = prefixSorted[currentNewPrefix]; + currentDepth--; + } + } + + for (let i = 0; i < originalCallNodeToCallNodeIndex.length; i++) { + const oldCallNodeIndex = originalCallNodeToCallNodeIndex[i]; + if (oldCallNodeIndex !== -1) { + originalCallNodeToCallNodeIndex[i] = oldIndexToNewIndex[oldCallNodeIndex]; + } + } + + const stackIndexToCallNodeIndex = new Int32Array( + stackIndexToOriginalCallNodeIndex.length + ); + for (let i = 0; i < stackIndexToCallNodeIndex.length; i++) { + stackIndexToCallNodeIndex[i] = + originalCallNodeToCallNodeIndex[stackIndexToOriginalCallNodeIndex[i]]; + } + + return { + prefixSorted, + subtreeRangeEndSorted, + nextSiblingSorted, + depthSorted, + maxDepth, + length, + stackIndexToCallNodeIndex, + }; +} + /** * Used as part of creating the call node table. * @@ -578,6 +688,81 @@ function _computeCallNodeTableExtraColumns( }; } +function _computeCallNodeTableExtraColumns2( + originalCallNodeTable: CallNodeTable, + oldCallNodeToNewCallNode: Int32Array, + callNodeCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeTableExtraColumns { + const originalCallNodeTableCategoryCol = originalCallNodeTable.category; + const originalCallNodeTableSubcategoryCol = originalCallNodeTable.subcategory; + const funcCol = new Int32Array(callNodeCount); + const categoryCol = new Int32Array(callNodeCount); + const subcategoryCol = new Int32Array(callNodeCount); + const innerWindowIDCol = new Float64Array(callNodeCount); + const inlinedIntoCol = new Int32Array(callNodeCount); + + const haveFilled = new Uint8Array(callNodeCount); + + for (let i = 0; i < originalCallNodeTable.length; i++) { + const category = originalCallNodeTableCategoryCol[i]; + const subcategory = originalCallNodeTableSubcategoryCol[i]; + const inlinedIntoSymbol = + originalCallNodeTable.sourceFramesInlinedIntoSymbol[i]; + + const callNodeIndex = oldCallNodeToNewCallNode[i]; + if (callNodeIndex === -1) { + continue; + } + + if (haveFilled[callNodeIndex] === 0) { + funcCol[callNodeIndex] = originalCallNodeTable.func[i]; + + categoryCol[callNodeIndex] = category; + subcategoryCol[callNodeIndex] = subcategory; + inlinedIntoCol[callNodeIndex] = inlinedIntoSymbol; + + const innerWindowID = originalCallNodeTable.innerWindowID[i]; + if (innerWindowID !== null && innerWindowID !== 0) { + // Set innerWindowID when it's not zero. Otherwise the value is already + // zero because typed arrays are initialized to zero. + innerWindowIDCol[callNodeIndex] = innerWindowID; + } + + haveFilled[callNodeIndex] = 1; + } else { + // Resolve category conflicts, by resetting a conflicting subcategory or + // category to the default category. + if (categoryCol[callNodeIndex] !== category) { + // Conflicting origin stack categories -> default category + subcategory. + categoryCol[callNodeIndex] = defaultCategory; + subcategoryCol[callNodeIndex] = 0; + } else if (subcategoryCol[callNodeIndex] !== subcategory) { + // Conflicting origin stack subcategories -> "Other" subcategory. + subcategoryCol[callNodeIndex] = 0; + } + + // Resolve "inlined into" conflicts. This can happen if you have two + // function calls A -> B where only one of the B calls is inlined, or + // if you use call tree transforms in such a way that a function B which + // was inlined into two different callers (A -> B, C -> B) gets collapsed + // into one call node. + if (inlinedIntoCol[callNodeIndex] !== inlinedIntoSymbol) { + // Conflicting inlining: -1. + inlinedIntoCol[callNodeIndex] = -1; + } + } + } + + return { + funcCol, + categoryCol, + subcategoryCol, + innerWindowIDCol, + inlinedIntoCol, + }; +} + /** * Generate the inverted CallNodeInfo for a thread. */ @@ -886,6 +1071,46 @@ export function getCallNodeFramePerStackInverted( return callNodeFramePerStack; } +/** + * For each stack, returns the innermost (leaf-most) frame whose function matches + * funcIndex, or -1 if funcIndex doesn't appear in that stack at all. + * + * This is used when double-clicking a function in the function list, to find + * which frame to show in the source and assembly views. When a function appears + * multiple times in a stack (due to recursion), we use the innermost occurrence, + * because that is the one doing the most specific work. + * + * Example: for stack A -> B -> C -> B -> D, asking for func B gives: + * - frame of the B in "C -> B" (the innermost B), not the B in "A -> B" + * + * The algorithm takes advantage of the stack table's ordering (parents before + * children): for each stack, we start with the parent's result and overwrite + * whenever we encounter funcIndex again, so the last write wins (innermost). + */ +export function getFunctionFramePerStack( + funcIndex: IndexIntoFuncTable, + stackTable: StackTable, + frameTable: FrameTable +): Int32Array { + const { frame: frameCol, prefix: prefixCol, length: stackCount } = stackTable; + const funcCol = frameTable.func; + + const funcFramePerStack = new Int32Array(stackCount); + + for (let stackIndex = 0; stackIndex < stackCount; stackIndex++) { + const frame = frameCol[stackIndex]; + if (funcCol[frame] === funcIndex) { + // This stack's own frame matches: it is the innermost so far, overwrite. + funcFramePerStack[stackIndex] = frame; + } else { + // Inherit from parent (or -1 if there is no parent). + const prefix = prefixCol[stackIndex]; + funcFramePerStack[stackIndex] = prefix !== null ? funcFramePerStack[prefix] : -1; + } + } + return funcFramePerStack; +} + /** * Take a samples table, and return an array that contain indexes that point to the * leaf most call node, or null. @@ -1074,6 +1299,65 @@ export function getSampleSelectedStates( ); } +/** + * Go through the samples, and determine their current state. + * + * For samples that are neither 'FILTERED_OUT_*' nor 'SELECTED', + * this function uses 'UNSELECTED_ORDERED_AFTER_SELECTED'. It uses the same + * ordering as the function compareCallNodes in getTreeOrderComparator. + */ +export function getSamplesSelectedStatesForFunction( + sampleCallNodes: Array, + selectedFunctionIndex: IndexIntoFuncTable | null, + callNodeTable: CallNodeTable +): Uint8Array { + if (selectedFunctionIndex === null) { + return _getSampleSelectedStatesForNoSelection(sampleCallNodes); + } + + const sampleCount = sampleCallNodes.length; + + // Go through each call node, and label it as containing the function or not. + // callNodeContainsFunc is a callNodeIndex => bool map, implemented as a U8 typed + // array for better performance. 0 means false, 1 means true. + const callNodeCount = callNodeTable.length; + const callNodeContainsFunc = new Uint8Array(callNodeCount); + for (let callNodeIndex = 0; callNodeIndex < callNodeCount; callNodeIndex++) { + const prefix = callNodeTable.prefix[callNodeIndex]; + const funcIndex = callNodeTable.func[callNodeIndex]; + if ( + funcIndex === selectedFunctionIndex || + // The parent of this stack contained the function. + (prefix !== -1 && callNodeContainsFunc[prefix] === 1) + ) { + callNodeContainsFunc[callNodeIndex] = 1; + } + } + + // Go through each sample, and label its state. + const samplesSelectedStates = new Uint8Array(sampleCount); + for ( + let sampleIndex = 0; + sampleIndex < sampleCallNodes.length; + sampleIndex++ + ) { + let sampleSelectedState: SelectedState = SelectedState.Selected; + const callNodeIndex = sampleCallNodes[sampleIndex]; + if (callNodeIndex !== null) { + if (callNodeContainsFunc[callNodeIndex] === 1) { + sampleSelectedState = SelectedState.Selected; + } else { + sampleSelectedState = SelectedState.UnselectedOrderedBeforeSelected; + } + } else { + // This sample was filtered out. + sampleSelectedState = SelectedState.FilteredOutByTransform; + } + samplesSelectedStates[sampleIndex] = sampleSelectedState; + } + return samplesSelectedStates; +} + /** * This function returns the function index for a specific call node path. This * is the last element of this path, or the leaf element of the path. @@ -1376,6 +1660,41 @@ export function computeCallNodeFuncIsDuplicate( return nodeFuncIsDuplicateBitSet; } +/** + * This function returns the timings for a specific function. + * + * Note that the unfilteredThread should be the original thread before any filtering + * (by range or other) happens. Also sampleIndexOffset needs to be properly + * specified and is the offset to be applied on thread's indexes to access + * the same samples in unfilteredThread. + */ +export function getTimingsForFunction( + _funcIndex: IndexIntoFuncTable | null, + _interval: Milliseconds, + _thread: Thread, + _unfilteredThread: Thread, + _sampleIndexOffset: number, + _categories: CategoryList, + _samples: SamplesLikeTable, + _unfilteredSamples: SamplesLikeTable, + _displayImplementation: boolean +): TimingsForPath { + // TODO + return { + forPath: { + selfTime: { + value: 0, + breakdownByCategory: null, + }, + totalTime: { + value: 0, + breakdownByCategory: null, + }, + }, + rootTime: 1, + }; +} + // This function computes the time range for a thread, using both its samples // and markers data. It's memoized and exported below, because it's called both // here in getTimeRangeIncludingAllThreads, and in selectors when dealing with @@ -4427,3 +4746,193 @@ export function computeStackTableFromRawStackTable( length: rawStackTable.length, }; } + +export function createUpperWingCallNodeInfo( + callNodeInfo: CallNodeInfo, + selectedFunc: IndexIntoFuncTable | null, + stackTable: StackTable, + frameTable: FrameTable, + funcCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeInfo { + const originalCallNodeTable = callNodeInfo.getCallNodeTable(); + const originalStackIndexToCallNodeIndex = + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(); + const { callNodeTable, stackIndexToCallNodeIndex } = + _computeSelectedFuncCallNodeTable3( + selectedFunc, + originalCallNodeTable, + originalStackIndexToCallNodeIndex, + stackTable, + frameTable, + funcCount, + defaultCategory + ); + return new CallNodeInfoNonInverted(callNodeTable, stackIndexToCallNodeIndex); +} + +function _computeSelectedFuncCallNodeTableHierarchy( + originalCallNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null +): CallNodeTableHierarchy & { + originalCallNodeToCallNodeIndex: Int32Array; +} { + const prefix = new Array(); + const firstChild = new Array(); + const nextSibling = new Array(); + const func = new Array(); + + const originalCallNodeToCallNodeIndex = new Int32Array( + originalCallNodeTable.length + ); + + let length = 0; + + // An extra column that only gets used while the table is built up: For each + // node A, currentLastChild[A] tracks the last currently-known child node of A. + // It is updated whenever a new node is created; e.g. creating node B updates + // currentLastChild[prefix[B]]. + // currentLastChild[A] is -1 while A has no children. + const currentLastChild: Array = []; + + // The last currently-known root node, i.e. the last known "child of -1". + let currentLastRoot = -1; + + // Go through each stack, and create a new callNode table, which is based off of + // functions rather than frames. + for (let i = 0; i < originalCallNodeTable.length; i++) { + const funcIndex = originalCallNodeTable.func[i]; + + const originalPrefixCallNode = originalCallNodeTable.prefix[i]; + // We know that at this point the following condition holds: + // assert(originalPrefixCallNode === -1 || originalPrefixCallNode < i); + const prefixCallNode = + funcIndex === selectedFuncIndex || originalPrefixCallNode === -1 + ? -1 + : originalCallNodeToCallNodeIndex[originalPrefixCallNode]; + + if (prefixCallNode === -1 && funcIndex !== selectedFuncIndex) { + originalCallNodeToCallNodeIndex[i] = -1; + continue; + } + + // Check if the call node for this stack already exists. + let callNodeIndex = -1; + if (length !== 0) { + const currentFirstSibling = + prefixCallNode === -1 ? 0 : firstChild[prefixCallNode]; + for ( + let currentSibling = currentFirstSibling; + currentSibling !== -1; + currentSibling = nextSibling[currentSibling] + ) { + if (func[currentSibling] === funcIndex) { + callNodeIndex = currentSibling; + break; + } + } + } + + if (callNodeIndex !== -1) { + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + continue; + } + + // New call node. + callNodeIndex = length++; + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + + prefix[callNodeIndex] = prefixCallNode; + func[callNodeIndex] = funcIndex; + + // Initialize these firstChild and nextSibling to -1. They will be updated + // once this node's first child or next sibling gets created. + firstChild[callNodeIndex] = -1; + nextSibling[callNodeIndex] = -1; + currentLastChild[callNodeIndex] = -1; + + // Update the next sibling of our previous sibling, and the first child of + // our prefix (if we're the first child). + // Also set this node's depth. + if (prefixCallNode === -1) { + // This node is a root. Just update the previous root's nextSibling. Because + // this node has no parent, there's also no firstChild information to update. + if (currentLastRoot !== -1) { + nextSibling[currentLastRoot] = callNodeIndex; + } + currentLastRoot = callNodeIndex; + } else { + // This node is not a root: update both firstChild and nextSibling information + // when appropriate. + const prevSiblingIndex = currentLastChild[prefixCallNode]; + if (prevSiblingIndex === -1) { + // This is the first child for this prefix. + firstChild[prefixCallNode] = callNodeIndex; + } else { + nextSibling[prevSiblingIndex] = callNodeIndex; + } + currentLastChild[prefixCallNode] = callNodeIndex; + } + } + + return { + prefix, + firstChild, + nextSibling, + originalCallNodeToCallNodeIndex, + length, + }; +} + +function _computeSelectedFuncCallNodeTable3( + selectedFuncIndex: IndexIntoFuncTable | null, + originalCallNodeTable: CallNodeTable, + stackIndexToOriginalCallNodeIndex: Int32Array, + _stackTable: StackTable, + _frameTable: FrameTable, + _funcCount: number, + defaultCategory: IndexIntoCategoryList +): CallNodeTableAndStackMap { + if (originalCallNodeTable.length === 0) { + return { + callNodeTable: getEmptyCallNodeTable(), + stackIndexToCallNodeIndex: new Int32Array(0), + }; + } + + const hierarchy = _computeSelectedFuncCallNodeTableHierarchy( + originalCallNodeTable, + selectedFuncIndex + ); + const { originalCallNodeToCallNodeIndex } = hierarchy; + const dfsOrder = _computeCallNodeTableDFSOrder2( + hierarchy, + originalCallNodeToCallNodeIndex, + stackIndexToOriginalCallNodeIndex + ); + const { stackIndexToCallNodeIndex } = dfsOrder; + const extraColumns = _computeCallNodeTableExtraColumns2( + originalCallNodeTable, + originalCallNodeToCallNodeIndex, + hierarchy.length, + defaultCategory + ); + + const callNodeTable = { + prefix: dfsOrder.prefixSorted, + nextSibling: dfsOrder.nextSiblingSorted, + subtreeRangeEnd: dfsOrder.subtreeRangeEndSorted, + func: extraColumns.funcCol, + category: extraColumns.categoryCol, + subcategory: extraColumns.subcategoryCol, + innerWindowID: extraColumns.innerWindowIDCol, + sourceFramesInlinedIntoSymbol: extraColumns.inlinedIntoCol, + depth: dfsOrder.depthSorted, + maxDepth: dfsOrder.maxDepth, + length: hierarchy.length, + }; + return { + callNodeTable, + stackIndexToCallNodeIndex, + }; +} diff --git a/src/profile-logic/zip-files.ts b/src/profile-logic/zip-files.ts index 78b451ea5c..c5108e1e6a 100644 --- a/src/profile-logic/zip-files.ts +++ b/src/profile-logic/zip-files.ts @@ -103,6 +103,8 @@ export class ZipFileTree { _displayDataByIndex: Map; _zipFileUrl: string; + static _sortableColumns: any[] = []; + constructor(zipFileTable: ZipFileTable, zipFileUrl: string) { this._zipFileTable = zipFileTable; this._zipFileUrl = zipFileUrl; @@ -113,6 +115,10 @@ export class ZipFileTree { this._parentToChildren.set(null, this._computeChildrenArray(null)); } + getSortableColumns(): any[] { + return ZipFileTree._sortableColumns; + } + getRoots(): IndexIntoZipFileTable[] { return this.getChildren(null); } diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 9a611c1f69..2239556dc4 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -29,6 +29,7 @@ import type { ThreadsKey, Milliseconds, TableViewOptions, + RightClickedFunction, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -36,7 +37,7 @@ import { } from '../profile-logic/symbolication'; import type { TabSlug } from '../app-logic/tabs-handling'; -import { objectMap } from '../utils/types'; +import { assertExhaustiveCheck, objectMap } from '../utils/types'; const profile: Reducer = (state = null, action) => { switch (action.type) { @@ -139,8 +140,13 @@ const symbolicationStatus: Reducer = ( export const defaultThreadViewOptions: ThreadViewOptions = { selectedNonInvertedCallNodePath: [], selectedInvertedCallNodePath: [], + selectedLowerWingCallNodePath: [], + selectedUpperWingCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + expandedLowerWingCallNodePaths: new PathSet(), + expandedUpperWingCallNodePaths: new PathSet(), + selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -207,7 +213,7 @@ const viewOptionsPerThread: Reducer = ( } case 'CHANGE_SELECTED_CALL_NODE': { const { - isInverted, + area, selectedCallNodePath, threadsKey, optionalExpandedToCallNodePath, @@ -215,9 +221,12 @@ const viewOptionsPerThread: Reducer = ( const threadState = _getThreadViewOptions(state, threadsKey); - const previousSelectedCallNodePath = isInverted - ? threadState.selectedInvertedCallNodePath - : threadState.selectedNonInvertedCallNodePath; + const previousSelectedCallNodePath = { + INVERTED_TREE: threadState.selectedInvertedCallNodePath, + NON_INVERTED_TREE: threadState.selectedNonInvertedCallNodePath, + LOWER_WING: threadState.selectedLowerWingCallNodePath, + UPPER_WING: threadState.selectedUpperWingCallNodePath, + }[area]; // If the selected node doesn't actually change, let's return the previous // state to avoid rerenders. @@ -228,9 +237,12 @@ const viewOptionsPerThread: Reducer = ( return state; } - let expandedCallNodePaths = isInverted - ? threadState.expandedInvertedCallNodePaths - : threadState.expandedNonInvertedCallNodePaths; + let expandedCallNodePaths = { + INVERTED_TREE: threadState.expandedInvertedCallNodePaths, + NON_INVERTED_TREE: threadState.expandedNonInvertedCallNodePaths, + LOWER_WING: threadState.expandedLowerWingCallNodePaths, + UPPER_WING: threadState.expandedUpperWingCallNodePaths, + }[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -254,19 +266,61 @@ const viewOptionsPerThread: Reducer = ( ); } - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { - selectedInvertedCallNodePath: selectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - selectedNonInvertedCallNodePath: selectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - } - ); + switch (area) { + case 'INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + selectedInvertedCallNodePath: selectedCallNodePath, + expandedInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'NON_INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + selectedNonInvertedCallNodePath: selectedCallNodePath, + expandedNonInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'LOWER_WING': + return _updateThreadViewOptions(state, threadsKey, { + selectedLowerWingCallNodePath: selectedCallNodePath, + expandedLowerWingCallNodePaths: expandedCallNodePaths, + }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + selectedUpperWingCallNodePath: selectedCallNodePath, + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } + } + case 'CHANGE_SELECTED_FUNCTION': { + const { selectedFunctionIndex, threadsKey } = action; + + const threadState = _getThreadViewOptions(state, threadsKey); + + const previousSelectedFunction = threadState.selectedFunctionIndex; + + // If the selected function doesn't actually change, let's return the previous + // state to avoid rerenders. + if (selectedFunctionIndex === previousSelectedFunction) { + return state; + } + + if (selectedFunctionIndex !== null) { + return _updateThreadViewOptions(state, threadsKey, { + selectedFunctionIndex, + selectedLowerWingCallNodePath: [selectedFunctionIndex], + expandedLowerWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + selectedUpperWingCallNodePath: [selectedFunctionIndex], + expandedUpperWingCallNodePaths: new PathSet([ + [selectedFunctionIndex], + ]), + }); + } + + return _updateThreadViewOptions(state, threadsKey, { + selectedFunctionIndex, + }); } case 'CHANGE_INVERT_CALLSTACK': { const { @@ -302,16 +356,29 @@ const viewOptionsPerThread: Reducer = ( }); } case 'CHANGE_EXPANDED_CALL_NODES': { - const { threadsKey, isInverted } = action; + const { threadsKey, area } = action; const expandedCallNodePaths = new PathSet(action.expandedCallNodePaths); - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { expandedInvertedCallNodePaths: expandedCallNodePaths } - : { expandedNonInvertedCallNodePaths: expandedCallNodePaths } - ); + switch (area) { + case 'INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + expandedInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'NON_INVERTED_TREE': + return _updateThreadViewOptions(state, threadsKey, { + expandedNonInvertedCallNodePaths: expandedCallNodePaths, + }); + case 'LOWER_WING': + return _updateThreadViewOptions(state, threadsKey, { + expandedLowerWingCallNodePaths: expandedCallNodePaths, + }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } } case 'CHANGE_SELECTED_NETWORK_MARKER': { const { threadsKey, selectedNetworkMarker } = action; @@ -587,8 +654,11 @@ const scrollToSelectionGeneration: Reducer = (state = 0, action) => { case 'CHANGE_CALL_TREE_SEARCH_STRING': case 'CHANGE_MARKER_SEARCH_STRING': case 'CHANGE_NETWORK_SEARCH_STRING': + case 'ADD_TRANSFORM_TO_STACK': + case 'POP_TRANSFORMS_FROM_STACK': return state + 1; case 'CHANGE_SELECTED_CALL_NODE': + case 'CHANGE_SELECTED_FUNCTION': case 'CHANGE_SELECTED_MARKER': case 'CHANGE_SELECTED_NETWORK_MARKER': if (action.context.source === 'pointer') { @@ -711,6 +781,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } @@ -734,6 +805,54 @@ const rightClickedCallNode: Reducer = ( } }; +const rightClickedFunction: Reducer = ( + state = null, + action +) => { + switch (action.type) { + case 'BULK_SYMBOLICATION': { + if (state === null) { + return null; + } + + const { oldFuncToNewFuncsMap } = action; + const functionIndexes = oldFuncToNewFuncsMap.get(state.functionIndex); + if (functionIndexes === undefined || functionIndexes.length === 0) { + return null; + } + + return { + ...state, + functionIndex: functionIndexes[0], + }; + } + case 'CHANGE_RIGHT_CLICKED_FUNCTION': + if (action.functionIndex !== null) { + return { + threadsKey: action.threadsKey, + functionIndex: action.functionIndex, + }; + } + + return null; + case 'SET_CONTEXT_MENU_VISIBILITY': + // We want to change the state only when the menu is hidden. + if (action.isVisible) { + return state; + } + + return null; + case 'PROFILE_LOADED': + case 'CHANGE_INVERT_CALLSTACK': + case 'ADD_TRANSFORM_TO_STACK': + case 'POP_TRANSFORMS_FROM_STACK': + case 'CHANGE_IMPLEMENTATION_FILTER': + return null; + default: + return state; + } +}; + const rightClickedMarker: Reducer = ( state = null, action @@ -834,6 +953,7 @@ const profileViewReducer: Reducer = wrapReducerInResetter( lastNonShiftClick, rightClickedTrack, rightClickedCallNode, + rightClickedFunction, rightClickedMarker, hoveredMarker, mouseTimePosition, diff --git a/src/selectors/per-thread/index.ts b/src/selectors/per-thread/index.ts index 6b777e2ef5..e8d9776be8 100644 --- a/src/selectors/per-thread/index.ts +++ b/src/selectors/per-thread/index.ts @@ -256,3 +256,109 @@ export const selectedNodeSelectors: NodeSelectors = (() => { getTimingsForSidebar, }; })(); + +// export type FunctionSelectors = {| +// +getTimingsForSidebar: Selector, +// +getSourceViewStackLineInfo: Selector, +// +getSourceViewLineTimings: Selector, +// |}; +// +// export const selectedFunctionTableNodeSelectors: FunctionSelectors = (() => { +// // const getName: Selector = createSelector( +// // selectedThreadSelectors.getSelectedFunctionTableFunction, +// // selectedThreadSelectors.getFilteredThread, +// // (selectedFunction, { stringTable, funcTable }) => { +// // if (selectedFunction === null) { +// // return ''; +// // } +// +// // return stringTable.getString(funcTable.name[selectedFunction]); +// // } +// // ); +// +// // const getIsJS: Selector = createSelector( +// // selectedThreadSelectors.getSelectedFunctionTableFunction, +// // selectedThreadSelectors.getFilteredThread, +// // (selectedFunction, { funcTable }) => { +// // return selectedFunction !== null && funcTable.isJS[selectedFunction]; +// // } +// // ); +// +// // const getLib: Selector = createSelector( +// // selectedThreadSelectors.getSelectedFunctionTableFunction, +// // selectedThreadSelectors.getFilteredThread, +// // (selectedFunction, { stringTable, funcTable, resourceTable }) => { +// // if (selectedFunction === null) { +// // return ''; +// // } +// +// // return ProfileData.getOriginAnnotationForFunc( +// // selectedFunction, +// // funcTable, +// // resourceTable, +// // stringTable +// // ); +// // } +// // ); +// +// const getTimingsForSidebar: Selector = createSelector( +// selectedThreadSelectors.getSelectedFunctionIndex, +// ProfileSelectors.getProfileInterval, +// selectedThreadSelectors.getPreviewFilteredThread, +// selectedThreadSelectors.getThread, +// selectedThreadSelectors.getSampleIndexOffsetFromPreviewRange, +// ProfileSelectors.getCategories, +// selectedThreadSelectors.getPreviewFilteredSamplesForCallTree, +// selectedThreadSelectors.getUnfilteredSamplesForCallTree, +// ProfileSelectors.getProfileUsesFrameImplementation, +// ProfileData.getTimingsForFunction +// ); +// +// const getSourceViewStackLineInfo: Selector = +// createSelector( +// selectedThreadSelectors.getFilteredThread, +// UrlState.getSourceViewFile, +// selectedThreadSelectors.getCallNodeInfo, +// selectedThreadSelectors.getSelectedCallNodeIndex, +// UrlState.getInvertCallstack, +// ( +// { stackTable, frameTable, funcTable, stringTable }: Thread, +// sourceViewFile, +// callNodeInfo, +// selectedCallNodeIndex, +// invertCallStack +// ): StackLineInfo | null => { +// if (sourceViewFile === null || selectedCallNodeIndex === null) { +// return null; +// } +// const selectedFunc = +// callNodeInfo.callNodeTable.func[selectedCallNodeIndex]; +// const selectedFuncFile = funcTable.fileName[selectedFunc]; +// if ( +// selectedFuncFile === null || +// stringTable.getString(selectedFuncFile) !== sourceViewFile +// ) { +// return null; +// } +// return getStackLineInfoForCallNode( +// stackTable, +// frameTable, +// selectedCallNodeIndex, +// callNodeInfo, +// invertCallStack +// ); +// } +// ); +// +// const getSourceViewLineTimings: Selector = createSelector( +// getSourceViewStackLineInfo, +// selectedThreadSelectors.getPreviewFilteredSamplesForCallTree, +// getLineTimings +// ); +// +// return { +// // getTimingsForSidebar, +// // getSourceViewStackLineInfo, +// // getSourceViewLineTimings, +// }; +// })(); diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 11f1a282a5..259b8884b2 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -11,6 +11,7 @@ import * as ProfileData from '../../profile-logic/profile-data'; import * as StackTiming from '../../profile-logic/stack-timing'; import * as FlameGraph from '../../profile-logic/flame-graph'; import * as CallTree from '../../profile-logic/call-tree'; +import * as Transforms from '../../profile-logic/transforms'; import type { PathSet } from '../../utils/path'; import * as ProfileSelectors from '../profile'; import { getRightClickedCallNodeInfo } from '../right-clicked-call-node'; @@ -42,6 +43,10 @@ import type { CallNodeSelfAndSummary, State, CallNodeTableBitSet, + IndexIntoFuncTable, + IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -144,6 +149,8 @@ export function getStackAndSampleSelectorsPerThread( const _getCallNodeTable: Selector = (state) => _getNonInvertedCallNodeInfo(state).getCallNodeTable(); + const getLowerWingCallNodeInfo = _getInvertedCallNodeInfo; + const _getCallNodeFuncIsDuplicate: Selector = createSelector( _getCallNodeTable, @@ -202,6 +209,31 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): IndexIntoFuncTable | null => { + return threadViewOptions.selectedFunctionIndex; + } + ); + + const getUpperWingCallNodeInfo: Selector = createSelector( + _getNonInvertedCallNodeInfo, + getSelectedFunctionIndex, + (state: State) => threadSelectors.getFilteredThread(state).stackTable, + (state: State) => threadSelectors.getFilteredThread(state).frameTable, + (state: State) => threadSelectors.getFilteredThread(state).funcTable.length, + ProfileSelectors.getDefaultCategory, + ProfileData.createUpperWingCallNodeInfo + ); + + const getLowerWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedLowerWingCallNodePath + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -211,6 +243,13 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.selectedNonInvertedCallNodePath ); + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedUpperWingCallNodePath + ); + const getSelectedCallNodeIndex: Selector = createSelector( getCallNodeInfo, @@ -220,6 +259,24 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getLowerWingSelectedCallNodeIndex: Selector = + createSelector( + getLowerWingCallNodeInfo, + getLowerWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + + const getUpperWingSelectedCallNodeIndex: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -229,6 +286,16 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.expandedNonInvertedCallNodePaths ); + const getLowerWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedLowerWingCallNodePaths + ); + + const getUpperWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedUpperWingCallNodePaths + ); + const getExpandedCallNodeIndexes: Selector< Array > = createSelector( @@ -240,6 +307,28 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getLowerWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getLowerWingCallNodeInfo, + getLowerWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + + const getUpperWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getUpperWingCallNodeInfo, + getUpperWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< Array > = createSelector( @@ -259,6 +348,27 @@ export function getStackAndSampleSelectorsPerThread( ProfileData.getSampleIndexToCallNodeIndex ); + const _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex: Selector< + Array + > = createSelector( + (state: State) => + threadSelectors.getPreviewFilteredCtssSamples(state).stack, + (state: State) => + getUpperWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(), + (sampleStacks, stackIndexToCallNodeIndex) => { + return sampleStacks.map((stackIndex: IndexIntoStackTable | null) => { + if (stackIndex === null) { + return null; + } + const callNodeIndex = stackIndexToCallNodeIndex[stackIndex]; + if (callNodeIndex === -1) { + return null; + } + return callNodeIndex; + }); + } + ); + const getSampleIndexToNonInvertedCallNodeIndexForFilteredThread: Selector< Array > = createSelector( @@ -286,6 +396,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleSelectedStatesForFunctionListTab: Selector = + createSelector( + getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSamplesSelectedStatesForFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -329,12 +452,57 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingCallNodeSelfAndSummary: Selector = + createSelector( + threadSelectors.getPreviewFilteredCtssSamples, + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, + getUpperWingCallNodeInfo, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo, + regularTreeSelfAndSummary + ) => { + const { rootTotalSummary } = regularTreeSelfAndSummary; + const upperWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + const { callNodeSelf } = upperWingSelfAndSummary; + // Use the upper wing's own total as the flame graph scaling reference, + // so that the root node (the selected function) fills the full flame + // graph width. The rootTotalSummary from the regular tree is kept for + // percentage display, so tooltips show percentages relative to all + // filtered samples (e.g. "80%" if 800 of 1000 samples contain the + // selected function). + const flameGraphTotalForScaling = upperWingSelfAndSummary.rootTotalSummary; + return { rootTotalSummary, callNodeSelf, flameGraphTotalForScaling }; + } + ); + const getCallTreeTimings: Selector = createSelector( getCallNodeInfo, getCallNodeSelfAndSummary, CallTree.computeCallTreeTimings ); + const _getLowerWingCallTreeTimings: Selector = + createSelector( + _getInvertedCallNodeInfo, + getCallNodeSelfAndSummary, + getSelectedFunctionIndex, + CallTree.computeLowerWingTimings + ); + + const _getUpperWingCallTreeTimings: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -388,6 +556,26 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getUpperWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getUpperWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getUpperWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + + const getLowerWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getLowerWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getLowerWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + const getSourceViewLineTimings: Selector = createSelector( getSourceViewStackLineInfo, threadSelectors.getPreviewFilteredCtssSamples, @@ -474,6 +662,153 @@ export function getStackAndSampleSelectorsPerThread( FlameGraph.getFlameGraphTiming ); + const _getUpperWingCallTreeTimingsNonInverted: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimingsNonInverted + ); + + const getUpperWingFlameGraphRows: Selector = + createSelector( + (state: State) => getUpperWingCallNodeInfo(state).getCallNodeTable(), + (state: State) => + threadSelectors.getPreviewFilteredThread(state).funcTable, + (state: State) => + threadSelectors.getPreviewFilteredThread(state).stringTable, + FlameGraph.computeFlameGraphRows + ); + + const getUpperWingFlameGraphTiming: Selector = + createSelector( + getUpperWingFlameGraphRows, + (state: State) => getUpperWingCallNodeInfo(state).getCallNodeTable(), + _getUpperWingCallTreeTimingsNonInverted, + FlameGraph.getFlameGraphTiming + ); + + // Self wing: focusSelf(rangeAndTransformFilteredThread, selectedFunc, implFilter) + // This uses the thread BEFORE the implementation filter so that native frames + // that are "inside" the selected function's self time are visible even when + // the implementation filter is set to "JS only". + const getSelfWingThread: Selector = createSelector( + threadSelectors.getRangeAndTransformFilteredThread, + getSelectedFunctionIndex, + UrlState.getImplementationFilter, + (thread, funcIndex, implFilter) => { + if (funcIndex === null) { + return thread; + } + return Transforms.focusSelf(thread, funcIndex, implFilter); + } + ); + + const _getSelfWingCallNodeInfo: Selector = createSelector( + (state: State) => getSelfWingThread(state).stackTable, + (state: State) => getSelfWingThread(state).frameTable, + ProfileSelectors.getDefaultCategory, + ProfileData.getCallNodeInfo + ); + + const _getSelfWingCtssSamples: Selector = createSelector( + getSelfWingThread, + threadSelectors.getCallTreeSummaryStrategy, + CallTree.extractSamplesLikeTable + ); + + const _getSelfWingSampleIndexToCallNodeIndex: Selector< + Array + > = createSelector( + (state: State) => _getSelfWingCtssSamples(state).stack, + (state: State) => + _getSelfWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(), + ProfileData.getSampleIndexToCallNodeIndex + ); + + const _getSelfWingCallNodeSelfAndSummary: Selector = + createSelector( + _getSelfWingCtssSamples, + _getSelfWingSampleIndexToCallNodeIndex, + (state: State) => + _getSelfWingCallNodeInfo(state).getCallNodeTable().length, + getCallNodeSelfAndSummary, + (samples, sampleIndexToCallNodeIndex, callNodeCount, regularTreeSelfAndSummary) => { + const selfWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeCount + ); + // Keep flameGraphTotalForScaling as the self wing's own total so the + // root fills the full flame graph width. Override rootTotalSummary with + // the regular tree's value so tooltips show percentages relative to all + // filtered samples. + return { + callNodeSelf: selfWingSelfAndSummary.callNodeSelf, + flameGraphTotalForScaling: selfWingSelfAndSummary.rootTotalSummary, + rootTotalSummary: regularTreeSelfAndSummary.rootTotalSummary, + }; + } + ); + + const _getSelfWingCallTreeTimings: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + + const _getSelfWingCallTreeTimingsNonInverted: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimingsNonInverted + ); + + const getSelfWingCallTree: Selector = createSelector( + getSelfWingThread, + _getSelfWingCallNodeInfo, + ProfileSelectors.getCategories, + _getSelfWingCtssSamples, + _getSelfWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + + const _getSelfWingFlameGraphRows: Selector = + createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (state: State) => getSelfWingThread(state).funcTable, + (state: State) => getSelfWingThread(state).stringTable, + FlameGraph.computeFlameGraphRows + ); + + const getSelfWingFlameGraphTiming: Selector = + createSelector( + _getSelfWingFlameGraphRows, + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + _getSelfWingCallTreeTimingsNonInverted, + FlameGraph.getFlameGraphTiming + ); + + const getSelfWingCallNodeMaxDepthPlusOne: Selector = createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (callNodeTable) => callNodeTable.maxDepth + 1 + ); + + const getSelfWingCallNodeInfo: Selector = + _getSelfWingCallNodeInfo; + + const getSelfWingCtssSamples: Selector = + _getSelfWingCtssSamples; + + const getSelfWingCtssSampleCategoriesAndSubcategories: Selector = + createSelector( + getSelfWingThread, + _getSelfWingCtssSamples, + ProfileSelectors.getDefaultCategory, + CallTree.computeUnfilteredCtssSampleCategoriesAndSubcategories + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -482,6 +817,71 @@ export function getStackAndSampleSelectorsPerThread( if ( rightClickedCallNodeInfo !== null && threadsKey === rightClickedCallNodeInfo.threadsKey + ) { + const expectedArea = callNodeInfo.isInverted() + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + if (rightClickedCallNodeInfo.area === expectedArea) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } + } + + return null; + } + ); + + const getLowerWingRightClickedCallNodeIndex: Selector = + createSelector( + getRightClickedCallNodeInfo, + getLowerWingCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === 'LOWER_WING' + ) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } + + return null; + } + ); + + const getLowerWingRightClickedFuncIndex: Selector = + createSelector( + getRightClickedCallNodeInfo, + getLowerWingCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo === null || + rightClickedCallNodeInfo.threadsKey !== threadsKey || + rightClickedCallNodeInfo.area !== 'LOWER_WING' + ) { + return null; + } + const callNodeIndex = callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + if (callNodeIndex === null) { + return null; + } + return callNodeInfo.funcForNode(callNodeIndex); + } + ); + + const getUpperWingRightClickedCallNodeIndex: Selector = + createSelector( + getRightClickedCallNodeInfo, + getUpperWingCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === 'UPPER_WING' ) { return callNodeInfo.getCallNodeIndexFromPath( rightClickedCallNodeInfo.callNodePath @@ -492,22 +892,61 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getRightClickedFunctionIndex: Selector = + createSelector( + ProfileSelectors.getProfileViewOptions, + (profileViewOptions) => { + const rightClickedFunctionInfo = + profileViewOptions.rightClickedFunction; + if ( + rightClickedFunctionInfo !== null && + threadsKey === rightClickedFunctionInfo.threadsKey + ) { + return rightClickedFunctionInfo.functionIndex; + } + + return null; + } + ); + return { unfilteredSamplesRange, getWeightTypeForCallTree, getCallNodeInfo, + getLowerWingCallNodeInfo, + getUpperWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getLowerWingSelectedCallNodePath, + getLowerWingSelectedCallNodeIndex, + getUpperWingSelectedCallNodePath, + getUpperWingSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, + getLowerWingExpandedCallNodePaths, + getLowerWingExpandedCallNodeIndexes, + getUpperWingExpandedCallNodePaths, + getUpperWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, + getSampleSelectedStatesForFunctionListTab, getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, + getLowerWingCallTree, + getUpperWingCallTree, + getUpperWingFlameGraphTiming, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -517,5 +956,9 @@ export function getStackAndSampleSelectorsPerThread( getFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index a72dc73d35..a9d03cff46 100644 --- a/src/selectors/right-clicked-call-node.tsx +++ b/src/selectors/right-clicked-call-node.tsx @@ -9,10 +9,12 @@ import type { ThreadsKey, CallNodePath, Selector, + CallNodeArea, } from 'firefox-profiler/types'; export type RightClickedCallNodeInfo = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea, readonly callNodePath: CallNodePath; }; diff --git a/src/test/components/Details.test.tsx b/src/test/components/Details.test.tsx index 73b05db864..0970c890d0 100644 --- a/src/test/components/Details.test.tsx +++ b/src/test/components/Details.test.tsx @@ -20,6 +20,9 @@ import type { TabSlug } from '../../app-logic/tabs-handling'; jest.mock('../../components/calltree/ProfileCallTreeView', () => ({ ProfileCallTreeView: 'call-tree', })); +jest.mock('../../components/calltree/ProfileFunctionListView', () => ({ + ProfileFunctionListView: 'function-list', +})); jest.mock('../../components/flame-graph', () => ({ FlameGraph: 'flame-graph', })); diff --git a/src/test/components/DetailsContainer.test.tsx b/src/test/components/DetailsContainer.test.tsx index 54c142dba0..59d449fccb 100644 --- a/src/test/components/DetailsContainer.test.tsx +++ b/src/test/components/DetailsContainer.test.tsx @@ -37,6 +37,7 @@ describe('app/DetailsContainer', function () { const expectedSidebar: { [slug in TabSlug]: boolean } = { calltree: true, + 'function-list': true, 'flame-graph': true, 'stack-chart': false, 'marker-chart': false, diff --git a/src/test/components/FunctionListContextMenu.test.tsx b/src/test/components/FunctionListContextMenu.test.tsx new file mode 100644 index 0000000000..6f85957870 --- /dev/null +++ b/src/test/components/FunctionListContextMenu.test.tsx @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Provider } from 'react-redux'; +import copy from 'copy-to-clipboard'; + +import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; +import { FunctionListContextMenu } from '../../components/shared/FunctionListContextMenu'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { fireFullClick } from '../fixtures/utils'; +import { + changeRightClickedFunctionIndex, + setContextMenuVisibility, +} from '../../actions/profile-view'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { ensureExists } from '../../utils/types'; + +describe('FunctionListContextMenu', function () { + // Create a profile that exercises all the conditional menu items: + // - B[lib:XUL] appears three times in a row (direct + indirect recursion) + // - B[lib:XUL] belongs to the XUL library (collapse-resource) + function createStore() { + const { + profile, + funcNamesDictPerThread: [{ B }], + } = getProfileFromTextSamples(` + A A A + B[lib:XUL] B[lib:XUL] B[lib:XUL] + B[lib:XUL] B[lib:XUL] B[lib:XUL] + B[lib:XUL] B[lib:XUL] B[lib:XUL] + C C H + D F I + E E + `); + const store = storeWithProfile(profile); + store.dispatch(changeRightClickedFunctionIndex(0, B)); + return store; + } + + function setup(store = createStore()) { + store.dispatch(setContextMenuVisibility(true)); + const renderResult = render( + + + + ); + return { ...renderResult, getState: store.getState }; + } + + describe('basic rendering', function () { + it('does not render when no function is right-clicked', () => { + const store = storeWithProfile(getProfileFromTextSamples('A').profile); + store.dispatch(setContextMenuVisibility(true)); + const { container } = render( + + + + ); + expect(container.querySelector('.react-contextmenu')).toBeNull(); + }); + + it('renders a full context menu when a function is right-clicked', () => { + const { container } = setup(); + expect( + ensureExists( + container.querySelector('.react-contextmenu'), + `Couldn't find the context menu root component .react-contextmenu` + ).children.length > 1 + ).toBeTruthy(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('does not include call-node-specific transforms', () => { + setup(); + expect(screen.queryByText(/Merge node only/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Focus on subtree only/) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Expand all/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Copy stack/)).not.toBeInTheDocument(); + }); + }); + + describe('clicking on transforms', function () { + const fixtures = [ + { matcher: /Merge function/, type: 'merge-function' }, + { matcher: /Focus on function/, type: 'focus-function' }, + { matcher: /Focus on self only/, type: 'focus-self' }, + { matcher: /Collapse function/, type: 'collapse-function-subtree' }, + { matcher: /XUL/, type: 'collapse-resource' }, + { matcher: /^Collapse recursion/, type: 'collapse-recursion' }, + { + matcher: /Collapse direct recursion/, + type: 'collapse-direct-recursion', + }, + { matcher: /Drop samples/, type: 'drop-function' }, + ]; + + fixtures.forEach(({ matcher, type }) => { + it(`adds a transform for "${type}"`, function () { + const { getState } = setup(); + fireFullClick(screen.getByText(matcher)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe(type); + }); + }); + }); + + describe('clicking on utility items', function () { + it('can copy a function name', function () { + setup(); + fireFullClick(screen.getByText('Copy function name')); + expect(copy).toHaveBeenCalledWith('B'); + }); + }); +}); diff --git a/src/test/components/LowerWingContextMenu.test.tsx b/src/test/components/LowerWingContextMenu.test.tsx new file mode 100644 index 0000000000..2094e95275 --- /dev/null +++ b/src/test/components/LowerWingContextMenu.test.tsx @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Provider } from 'react-redux'; + +import { render, screen } from 'firefox-profiler/test/fixtures/testing-library'; +import { LowerWingContextMenu } from '../../components/shared/LowerWingContextMenu'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { fireFullClick } from '../fixtures/utils'; +import { + changeLowerWingRightClickedCallNode, + setContextMenuVisibility, +} from '../../actions/profile-view'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { ensureExists } from '../../utils/types'; + +describe('LowerWingContextMenu', function () { + // Samples: A->B->C, A->E->C + // When C is selected, the lower wing (inverted) tree shows: + // C (root/self function) + // B (caller of C) + // A + // E (caller of C) + // A + // + // Right-clicking B (an inverted child = caller) should give a context menu + // for B, not C. + function createStore() { + const { + profile, + funcNamesDictPerThread: [{ B, C }], + } = getProfileFromTextSamples(` + A A + B E + C C + `); + const store = storeWithProfile(profile); + + // The inverted call node path for B-as-caller-of-C is [C, B]. + const threadsKey = 0; + store.dispatch( + changeLowerWingRightClickedCallNode(threadsKey, [C, B]) + ); + return store; + } + + function setup(store = createStore()) { + store.dispatch(setContextMenuVisibility(true)); + const renderResult = render( + + + + ); + return { ...renderResult, getState: store.getState }; + } + + describe('basic rendering', function () { + it('does not render when no node is right-clicked', () => { + const store = storeWithProfile(getProfileFromTextSamples('A').profile); + store.dispatch(setContextMenuVisibility(true)); + const { container } = render( + + + + ); + expect(container.querySelector('.react-contextmenu')).toBeNull(); + }); + + it('renders a context menu when a node is right-clicked', () => { + const { container } = setup(); + expect( + ensureExists( + container.querySelector('.react-contextmenu'), + `Couldn't find the context menu root component .react-contextmenu` + ).children.length > 1 + ).toBeTruthy(); + }); + + it('does not include call-node-specific transforms', () => { + setup(); + expect(screen.queryByText(/Merge node only/)).not.toBeInTheDocument(); + expect( + screen.queryByText(/Focus on subtree only/) + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Expand all/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Copy stack/)).not.toBeInTheDocument(); + }); + }); + + describe('clicking on transforms', function () { + it('applies transforms to function B, not to the selected function C', function () { + const { getState } = setup(); + fireFullClick(screen.getByText(/Merge function/)); + const transform = selectedThreadSelectors.getTransformStack(getState())[0]; + expect(transform.type).toBe('merge-function'); + // The transform should target B (the right-clicked caller), not C (the root). + if (transform.type === 'merge-function') { + const { + funcNamesDictPerThread: [{ B }], + } = getProfileFromTextSamples(` + A A + B E + C C + `); + expect(transform.funcIndex).toBe(B); + } + }); + + it('adds a focus-function transform for the right-clicked node', function () { + const { getState } = setup(); + fireFullClick(screen.getByText(/Focus on function/)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe('focus-function'); + }); + }); +}); diff --git a/src/test/components/TransformShortcuts.test.tsx b/src/test/components/TransformShortcuts.test.tsx index aefbcfbd4a..dd17a20645 100644 --- a/src/test/components/TransformShortcuts.test.tsx +++ b/src/test/components/TransformShortcuts.test.tsx @@ -11,6 +11,8 @@ import { storeWithProfile } from '../fixtures/stores'; import { changeSelectedCallNode, changeRightClickedCallNode, + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, } from '../../actions/profile-view'; import { FlameGraph } from '../../components/flame-graph'; import { selectedThreadSelectors } from 'firefox-profiler/selectors'; @@ -18,6 +20,7 @@ import { ensureExists, objectEntries } from '../../utils/types'; import { fireFullKeyPress } from '../fixtures/utils'; import { autoMockCanvasContext } from '../fixtures/mocks/canvas-context'; import { ProfileCallTreeView } from '../../components/calltree/ProfileCallTreeView'; +import { ProfileFunctionListView } from '../../components/calltree/ProfileFunctionListView'; import { StackChart } from 'firefox-profiler/components/stack-chart'; import type { Transform, @@ -186,6 +189,104 @@ const pressKeyBuilder = (className: string) => (options: KeyPressOptions) => { fireFullKeyPress(div, options); }; +function testFunctionTransformKeyboardShortcuts( + setup: () => { + getTransform: () => null | Transform; + pressKey: (options: KeyPressOptions) => void; + expectedFuncIndex: IndexIntoFuncTable; + expectedResourceIndex: IndexIntoResourceTable; + } +) { + describe('function shortcuts', () => { + it('handles focus-function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'f' }); + expect(getTransform()).toEqual({ + type: 'focus-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles focus-self', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'S' }); + expect(getTransform()).toMatchObject({ + type: 'focus-self', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles merge function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'm' }); + expect(getTransform()).toEqual({ + type: 'merge-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles drop function', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'd' }); + expect(getTransform()).toEqual({ + type: 'drop-function', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse resource', () => { + const { pressKey, getTransform, expectedResourceIndex } = setup(); + pressKey({ key: 'C' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-resource', + resourceIndex: expectedResourceIndex, + }); + }); + + it('handles collapse recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'r' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse direct recursion', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'R' }); + expect(getTransform()).toMatchObject({ + type: 'collapse-direct-recursion', + funcIndex: expectedFuncIndex, + }); + }); + + it('handles collapse function subtree', () => { + const { pressKey, getTransform, expectedFuncIndex } = setup(); + pressKey({ key: 'c' }); + expect(getTransform()).toEqual({ + type: 'collapse-function-subtree', + funcIndex: expectedFuncIndex, + }); + }); + + it('does not handle call-node-specific shortcuts', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'F' }); // focus-subtree + pressKey({ key: 'M' }); // merge-call-node + pressKey({ key: 'g' }); // focus-category + expect(getTransform()).toBeNull(); + }); + + it('ignores shortcuts with modifiers', () => { + const { pressKey, getTransform } = setup(); + pressKey({ key: 'c', ctrlKey: true }); + pressKey({ key: 'c', metaKey: true }); + expect(getTransform()).toBeNull(); + }); + }); // end describe('function shortcuts') +} + /* eslint-disable jest/no-standalone-expect */ // Disable the jest/no-standalone-expect rule because eslint doesn't know that // these expectations will run in a test block later. @@ -326,3 +427,78 @@ describe('stack chart transform shortcuts', () => { }); } }); + +/* eslint-disable jest/no-standalone-expect */ +const functionListActions = { + 'a selected function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).toBeNull(); + }, + 'a right-clicked function': ( + { dispatch, getState }: Store, + { B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, null)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, + 'both a selected and a right-clicked function': ( + { dispatch, getState }: Store, + { A, B }: FuncNamesDict + ) => { + act(() => { + dispatch(changeSelectedFunctionIndex(0, A)); + }); + act(() => { + dispatch(changeRightClickedFunctionIndex(0, B)); + }); + expect( + selectedThreadSelectors.getSelectedFunctionIndex(getState()) + ).not.toBeNull(); + expect( + selectedThreadSelectors.getRightClickedFunctionIndex(getState()) + ).not.toBeNull(); + }, +}; +/* eslint-enable jest/no-standalone-expect */ + +describe('function list transform shortcuts', () => { + for (const [name, action] of objectEntries(functionListActions)) { + describe(`with ${name}`, () => { + testFunctionTransformKeyboardShortcuts(() => { + const { store, funcNames, getTransform } = setupStore( + + ); + + const { B } = funcNames; + action(store, funcNames); + + return { + getTransform, + pressKey: pressKeyBuilder('treeViewBody'), + expectedFuncIndex: B, + expectedResourceIndex: 0, + }; + }); + }); + } +}); diff --git a/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap b/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap new file mode 100644 index 0000000000..ce0c6f9ce3 --- /dev/null +++ b/src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap @@ -0,0 +1,216 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`FunctionListContextMenu basic rendering renders a full context menu when a function is right-clicked 1`] = ` +
+ +
+`; diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index 0f50030d9d..e02579364a 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -7,6 +7,7 @@ import { computeCallNodeSelfAndSummary, computeCallTreeTimings, computeFunctionListTimings, + computeLowerWingTimings, type CallTree, } from 'firefox-profiler/profile-logic/call-tree'; import { getEmptyThread } from 'firefox-profiler/profile-logic/data-structures'; @@ -19,6 +20,7 @@ import { createThreadFromDerivedTables, computeStackTableFromRawStackTable, computeSamplesTableFromRawSamplesTable, + createUpperWingCallNodeInfo, } from 'firefox-profiler/profile-logic/profile-data'; import { getProfileWithDicts } from './profiles/processed-profile'; import { StringTable } from '../../utils/string-table'; @@ -264,6 +266,104 @@ export function functionListTreeFromProfile( ); } +/** + * This function creates the "upper wing" CallTree for a profile and a selected + * function. The upper wing shows the call subtrees that are rooted at the + * selected function, i.e. it answers "where is this function called from / what + * does it call". + */ +export function upperWingTreeFromProfile( + profile: Profile, + selectedFuncName: string, + threadIndex: number = 0 +): CallTree { + const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); + const thread = derivedThreads[threadIndex]; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const selectedFunc = + thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? null; + const upperWingCallNodeInfo = createUpperWingCallNodeInfo( + callNodeInfo, + selectedFunc === -1 ? null : selectedFunc, + thread.stackTable, + thread.frameTable, + thread.funcTable.length, + defaultCategory + ); + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + upperWingCallNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + upperWingCallNodeInfo.getCallNodeTable().length + ); + const timings = computeCallTreeTimings(upperWingCallNodeInfo, selfAndSummary); + return getCallTree( + thread, + upperWingCallNodeInfo, + ensureExists(profile.meta.categories), + thread.samples, + timings, + 'samples' + ); +} + +/** + * This function creates the "lower wing" CallTree for a profile and a selected + * function. The lower wing is an inverted call tree where each root's total + * counts only samples where the selected function appears in the call stack. + */ +export function lowerWingTreeFromProfile( + profile: Profile, + selectedFuncName: string, + threadIndex: number = 0 +): CallTree { + const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); + const thread = derivedThreads[threadIndex]; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const invertedCallNodeInfo = getInvertedCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length + ); + const selectedFunc = + thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? null; + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + callNodeInfo.getCallNodeTable().length + ); + const timings = computeLowerWingTimings( + invertedCallNodeInfo, + selfAndSummary, + selectedFunc === -1 ? null : selectedFunc + ); + return getCallTree( + thread, + invertedCallNodeInfo, + ensureExists(profile.meta.categories), + thread.samples, + timings, + 'samples' + ); +} + /** * This function formats a call tree into a human readable form, to make it easy * to assert certain relationships about the data structure in a really terse diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index f04baf9582..b6dca5c7d1 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4238,6 +4238,9 @@ Object { "expandedInvertedCallNodePaths": PathSet { "_table": Map {}, }, + "expandedLowerWingCallNodePaths": PathSet { + "_table": Map {}, + }, "expandedNonInvertedCallNodePaths": PathSet { "_table": Map { "0" => Array [ @@ -4249,12 +4252,18 @@ Object { ], }, }, + "expandedUpperWingCallNodePaths": PathSet { + "_table": Map {}, + }, "lastSeenTransformCount": 1, + "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], + "selectedLowerWingCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ 0, 1, ], + "selectedUpperWingCallNodePath": Array [], } `; diff --git a/src/test/store/actions.test.ts b/src/test/store/actions.test.ts index 7c2b2f17c6..42a02d1ea3 100644 --- a/src/test/store/actions.test.ts +++ b/src/test/store/actions.test.ts @@ -206,7 +206,7 @@ describe('selectors/getFlameGraphTiming', function () { store.getState() ); - return flameGraphTiming.map(({ callNode, end, length, start }) => { + return flameGraphTiming.rows.map(({ callNode, end, length, start }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; @@ -240,7 +240,7 @@ describe('selectors/getFlameGraphTiming', function () { store.getState() ); - return flameGraphTiming.map(({ selfRelative, callNode, length }) => { + return flameGraphTiming.rows.map(({ selfRelative, callNode, length }) => { const lines = []; for (let i = 0; i < length; i++) { const callNodeIndex = callNode[i]; diff --git a/src/test/store/bottom-box.test.ts b/src/test/store/bottom-box.test.ts index 32a6e1c1a4..b27d251bf4 100644 --- a/src/test/store/bottom-box.test.ts +++ b/src/test/store/bottom-box.test.ts @@ -8,7 +8,10 @@ import * as UrlStateSelectors from '../../selectors/url-state'; import * as ProfileSelectors from '../../selectors/profile'; import { selectedThreadSelectors } from '../../selectors/per-thread'; import { emptyAddressTimings } from '../../profile-logic/address-timings'; -import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from '../../profile-logic/bottom-box'; import { changeSelectedCallNode, updateBottomBoxContentsAndMaybeOpen, @@ -373,3 +376,67 @@ describe('bottom box', function () { // - A test with multiple threads: Open the assembly view for a symbol, switch // to a different thread, check timings }); + +describe('getBottomBoxInfoForFunction', function () { + it('uses the innermost frame when a function appears multiple times due to recursion (A->B->A->B)', function () { + // Stack: A[line:20] -> B[line:30] -> A[line:21] -> B[line:31] + // B appears at line 30 (outer) and line 31 (inner/leaf). + // Double-clicking B in the function list should use line 31 (innermost). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + A[file:a.js][line:21] + B[file:b.js][line:31] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction(B, thread, thread.samples); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('uses the innermost frame when a function appears multiple times due to recursion via another function (A->B->C->B->D)', function () { + // Stack: A[line:20] -> B[line:30] -> C[line:40] -> B[line:31] -> D[line:50] + // B appears at line 30 (outer) and line 31 (inner), with C and D in between. + // Double-clicking B in the function list should use line 31 (innermost B). + const { derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + C[file:c.js][line:40] + B[file:b.js][line:31] + D[file:d.js][line:50] + `); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + const bottomBoxInfo = getBottomBoxInfoForFunction(B, thread, thread.samples); + + // scrollToLineNumber should be 31 (the innermost B), not 30 (the outer B). + expect(bottomBoxInfo.scrollToLineNumber).toBe(31); + }); + + it('opens the source view when double-clicking a function in the function list', function () { + const { profile, derivedThreads, funcNamesDictPerThread } = + getProfileFromTextSamples(` + A[file:a.js][line:20] + B[file:b.js][line:30] + `); + const { dispatch, getState } = storeWithProfile(profile); + const [thread] = derivedThreads; + const [{ B }] = funcNamesDictPerThread; + + dispatch(changeSelectedTab('function-list')); + + const bottomBoxInfo = getBottomBoxInfoForFunction(B, thread, thread.samples); + dispatch(updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo)); + + expect(UrlStateSelectors.getIsBottomBoxOpen(getState())).toBeTrue(); + expect(UrlStateSelectors.getSourceViewScrollToLineNumber(getState())).toBe( + 30 + ); + }); +}); diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index c78779dcf3..95f94cfa13 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -3439,6 +3439,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); }); @@ -3450,6 +3451,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); diff --git a/src/test/store/useful-tabs.test.ts b/src/test/store/useful-tabs.test.ts index 2e770a2581..7f6444c07e 100644 --- a/src/test/store/useful-tabs.test.ts +++ b/src/test/store/useful-tabs.test.ts @@ -21,6 +21,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -59,6 +60,7 @@ describe('getUsefulTabs', function () { }); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -84,6 +86,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -116,6 +119,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', diff --git a/src/test/unit/profile-data.test.ts b/src/test/unit/profile-data.test.ts index 9e074fe2e0..65871b7eef 100644 --- a/src/test/unit/profile-data.test.ts +++ b/src/test/unit/profile-data.test.ts @@ -19,6 +19,7 @@ import { getSampleIndexToCallNodeIndex, getTreeOrderComparator, getSampleSelectedStates, + getSamplesSelectedStatesForFunction, extractProfileFilterPageData, findAddressProofForFile, calculateFunctionSizeLowerBound, @@ -1234,6 +1235,149 @@ describe('getSampleSelectedStates', function () { }); }); +describe('getSamplesSelectedStatesForFunction', function () { + function setup(textSamples: string) { + const { + derivedThreads, + funcNamesDictPerThread: [funcNamesDict], + } = getProfileFromTextSamples(textSamples); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + 0 + ); + const sampleCallNodes = getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ); + return { + callNodeTable: callNodeInfo.getCallNodeTable(), + sampleCallNodes, + funcNamesDict, + }; + } + + it('marks all non-filtered samples as selected when nothing is selected', function () { + const { callNodeTable, sampleCallNodes } = setup(` + A A A + B C + `); + expect( + Array.from( + getSamplesSelectedStatesForFunction( + sampleCallNodes, + null, + callNodeTable + ) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks samples as selected when their call stack contains the selected function', function () { + // 0 1 2 3 4 + // A A A A A + // B D B D D + // C E F G + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B, D }, + } = setup(` + A A A A A + B D B D D + C E F G + `); + + // Selecting function B: samples 0, 2 have B in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + + // Selecting function D: samples 1, 3, 4 have D in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, D, callNodeTable) + ) + ).toEqual([ + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks filtered-out samples as FilteredOutByTransform', function () { + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B }, + } = setup(` + A A A + B C + `); + // Sample 2 has no stack (null), treated as filtered out. + // Manually null out sample 2's call node. + sampleCallNodes[2] = null; + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.FilteredOutByTransform, + ]); + }); + + it('selects samples whose ancestor call node contains the function, not just the leaf', function () { + // 0 1 + // A A + // B C + // D + // Selecting A should match all samples (A is an ancestor of everything). + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { A, B }, + } = setup(` + A A + B C + D + `); + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, A, callNodeTable) + ) + ).toEqual([SelectedState.Selected, SelectedState.Selected]); + + // Selecting B only matches sample 0 (B is in the path A->B->D). + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + }); +}); + describe('extractProfileFilterPageData', function () { const pages = { mozilla: { diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index f50cd99fe8..dce6854b71 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -23,6 +23,8 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + upperWingTreeFromProfile, + lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, addSourceToTable, @@ -576,6 +578,82 @@ describe('function list', function () { }); }); +describe('upper wing', function () { + // Samples: A->B->C, A->B->D, A->E->C, A->E->F + const textSamples = ` + A A A A + B B E E + C D C F + `; + + it('shows all callee subtrees of the selected function', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select B: show subtrees rooted at B (i.e. B->C and B->D) + const callTree = upperWingTreeFromProfile(profile, 'B'); + expect(formatTree(callTree)).toEqual([ + '- B (total: 2, self: —)', + ' - C (total: 1, self: 1)', + ' - D (total: 1, self: 1)', + ]); + }); + + it('merges call nodes with the same function across different callers', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // C appears under both B and E; the upper wing for C should show both + // subtrees merged into one root C node + const callTree = upperWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual(['- C (total: 2, self: 2)']); + }); + + it('returns an empty tree when no function is selected', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // null selection: no subtrees to show + const callTree = upperWingTreeFromProfile(profile, 'NONEXISTENT'); + expect(formatTree(callTree)).toEqual([]); + }); +}); + +describe('lower wing', function () { + // Samples: A->B->C, A->B->D, A->E->C, A->E->F + const textSamples = ` + A A A A + B B E E + C D C F + `; + + it('shows callers of the selected function as inverted roots', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select C: C has self-time in both A->B->C and A->E->C, so C becomes the + // inverted root with total 2. Its callers B and E appear as children. + const callTree = lowerWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual([ + '- C (total: 2, self: 2)', + ' - B (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - E (total: 1, self: —)', + ' - A (total: 1, self: —)', + ]); + }); + + it('only counts samples where the selected function is present', function () { + const { profile } = getProfileFromTextSamples(textSamples); + // Select B: the self-time of B's subtree (C and D) gets attributed to B in + // the non-inverted table, so the inverted tree shows B as the root with + // total 2, and its caller A as a child. + const callTree = lowerWingTreeFromProfile(profile, 'B'); + expect(formatTree(callTree)).toEqual([ + '- B (total: 2, self: 2)', + ' - A (total: 2, self: —)', + ]); + }); + + it('returns an empty tree when no function is selected', function () { + const { profile } = getProfileFromTextSamples(textSamples); + const callTree = lowerWingTreeFromProfile(profile, 'NONEXISTENT'); + expect(formatTree(callTree)).toEqual([]); + }); +}); + describe('diffing trees', function () { function getProfile() { return getMergedProfileFromTextSamples([ diff --git a/src/types/actions.ts b/src/types/actions.ts index 6f11b70e73..f960892590 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -14,6 +14,7 @@ import type { IndexIntoLibs, PageList, IndexIntoSourceTable, + IndexIntoFuncTable, } from './profile'; import type { Thread, @@ -41,6 +42,7 @@ import type { ApiQueryError, TableViewOptions, DecodedInstruction, + CallNodeArea, } from './state'; import type { CssPixels, StartEndRange, Milliseconds } from './units'; import type { BrowserConnectionStatus } from '../app-logic/browser-connection'; @@ -181,12 +183,18 @@ type ProfileAction = } | { readonly type: 'CHANGE_SELECTED_CALL_NODE'; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly threadsKey: ThreadsKey; readonly selectedCallNodePath: CallNodePath; readonly optionalExpandedToCallNodePath: CallNodePath | undefined; readonly context: SelectionContext; } + | { + readonly type: 'CHANGE_SELECTED_FUNCTION'; + readonly threadsKey: ThreadsKey; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly context: SelectionContext; + } | { readonly type: 'UPDATE_TRACK_THREAD_HEIGHT'; readonly height: CssPixels; @@ -195,15 +203,21 @@ type ProfileAction = | { readonly type: 'CHANGE_RIGHT_CLICKED_CALL_NODE'; readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath | null; } + | { + readonly type: 'CHANGE_RIGHT_CLICKED_FUNCTION'; + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable | null; + } | { readonly type: 'FOCUS_CALL_TREE'; } | { readonly type: 'CHANGE_EXPANDED_CALL_NODES'; readonly threadsKey: ThreadsKey; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly expandedCallNodePaths: Array; } | { diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index f52454fd9e..5d9e0b0b2a 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -729,6 +729,12 @@ export type CallNodeSelfAndSummary = { // The sum of absolute values in callNodeSelf. // This is used for computing the percentages displayed in the call tree. rootTotalSummary: number; + // The total used as the 100% reference for flame graph box widths. + // Usually equals rootTotalSummary, but for the upper wing it is set to the + // total of samples that contain the selected function, so that the root node + // fills the full flame graph width while percentages remain relative to all + // filtered samples. + flameGraphTotalForScaling: number; }; /** diff --git a/src/types/state.ts b/src/types/state.ts index 6e18bb2630..033e02c621 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -23,6 +23,7 @@ import type { TabID, IndexIntoLibs, IndexIntoSourceTable, + IndexIntoFuncTable, } from './profile'; import type { @@ -51,11 +52,17 @@ export type Reducer = (state: T | undefined, action: Action) => T; export type UploadedProfileInformation = ImportedUploadedProfileInformation; export type SymbolicationStatus = 'DONE' | 'SYMBOLICATING'; + export type ThreadViewOptions = { readonly selectedNonInvertedCallNodePath: CallNodePath; readonly selectedInvertedCallNodePath: CallNodePath; readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly selectedLowerWingCallNodePath: CallNodePath; + readonly expandedLowerWingCallNodePaths: PathSet; + readonly selectedUpperWingCallNodePath: CallNodePath; + readonly expandedUpperWingCallNodePaths: PathSet; readonly selectedNetworkMarker: MarkerIndex | null; // Track the number of transforms to detect when they change via browser // navigation. This helps us know when to reset paths that may be invalid @@ -73,11 +80,23 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; +export type CallNodeArea = + | 'NON_INVERTED_TREE' + | 'INVERTED_TREE' + | 'LOWER_WING' + | 'UPPER_WING'; + export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; +export type RightClickedFunction = { + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable; +}; + export type MarkerReference = { readonly threadsKey: ThreadsKey; readonly markerIndex: MarkerIndex; @@ -102,6 +121,7 @@ export type ProfileViewState = { lastNonShiftClick: LastNonShiftClickInformation | null; rightClickedTrack: TrackReference | null; rightClickedCallNode: RightClickedCallNode | null; + rightClickedFunction: RightClickedFunction | null; rightClickedMarker: MarkerReference | null; hoveredMarker: MarkerReference | null; mouseTimePosition: Milliseconds | null; diff --git a/src/utils/types.ts b/src/utils/types.ts index 1d6df05f5e..9b41c5b1d9 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -42,6 +42,7 @@ export function toValidTabSlug(tabSlug: any): TabSlug | null { const coercedTabSlug = tabSlug as TabSlug; switch (coercedTabSlug) { case 'calltree': + case 'function-list': case 'stack-chart': case 'marker-chart': case 'network-chart':