From 185be83ddb1ed8c49a690df89929f4d6ee8c3fcb Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 27 Nov 2024 13:20:00 -0500 Subject: [PATCH 01/20] Add a function list panel. lint fix --- locales/en-US/app.ftl | 1 + res/css/style.css | 3 +- src/actions/profile-view.ts | 28 ++ src/app-logic/tabs-handling.ts | 2 + src/app-logic/url-handling.ts | 1 + src/components/app/Details.tsx | 2 + src/components/calltree/CallTree.tsx | 100 +------ src/components/calltree/FunctionList.tsx | 251 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 20 ++ src/components/calltree/columns.ts | 100 +++++++ src/components/sidebar/index.tsx | 1 + src/profile-logic/profile-data.ts | 94 +++++++ src/reducers/profile-view.ts | 69 +++++ src/selectors/per-thread/index.ts | 106 ++++++++ src/selectors/per-thread/stack-sample.ts | 28 ++ src/test/components/Details.test.tsx | 3 + src/test/components/DetailsContainer.test.tsx | 1 + .../__snapshots__/profile-view.test.ts.snap | 1 + src/test/store/useful-tabs.test.ts | 4 + src/types/actions.ts | 12 + src/types/state.ts | 8 + src/utils/types.ts | 1 + 22 files changed, 743 insertions(+), 93 deletions(-) create mode 100644 src/components/calltree/FunctionList.tsx create mode 100644 src/components/calltree/ProfileFunctionListView.tsx create mode 100644 src/components/calltree/columns.ts 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..4e560fdc73 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, @@ -129,6 +130,22 @@ export function changeSelectedCallNode( }; } +/** + * 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 @@ -145,6 +162,17 @@ export function changeRightClickedCallNode( }; } +export function changeRightClickedFunctionIndex( + threadsKey: ThreadsKey, + functionIndex: IndexIntoFuncTable | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_FUNCTION', + threadsKey, + functionIndex, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. 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..c571fd6945 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/'; @@ -122,6 +123,7 @@ class ProfileViewerImpl extends PureComponent { { { calltree: , + 'function-list': , 'flame-graph': , 'stack-chart': , 'marker-chart': , diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 02e6b7333d..15d9a7bf75 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -6,10 +6,8 @@ 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 { Icon } from 'firefox-profiler/components/shared/Icon'; import { getInvertCallstack, - getImplementationFilter, getSearchStringsAsRegExp, getSelectedThreadsKey, } from 'firefox-profiler/selectors/url-state'; @@ -34,7 +32,6 @@ import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import type { State, - ImplementationFilter, ThreadsKey, CategoryList, IndexIntoCallNodeTable, @@ -53,6 +50,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 +69,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; @@ -106,95 +107,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.'); } @@ -412,7 +329,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..c50c8c2386 --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,251 @@ +/* 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, +} 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 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(); + + 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(); + } + } + + 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, + // } = this.props; + // const nodeIndex = + // rightClickedFunctionIndex !== null + // ? rightClickedFunctionIndex + // : selectedFunctionIndex; + // if (nodeIndex === null) { + // return; + // } + // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + }; + + _onEnterOrDoubleClick = (_nodeId: IndexIntoFuncTable) => { + // const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + // const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); + // updateBottomBoxContentsAndMaybeOpen('calltree', 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, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: FunctionListImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx new file mode 100644 index 0000000000..ab1d348eaf --- /dev/null +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -0,0 +1,20 @@ +/* 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 { FunctionList } from './FunctionList'; +import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; +import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; + +export const ProfileFunctionListView = () => ( +
+ + + +
+); 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/sidebar/index.tsx b/src/components/sidebar/index.tsx index a8115372a3..e4a6901069 100644 --- a/src/components/sidebar/index.tsx +++ b/src/components/sidebar/index.tsx @@ -15,6 +15,7 @@ export function selectSidebar( ): React.ComponentType<{}> | null { return { calltree: CallTreeSidebar, + 'function-list': CallTreeSidebar, 'flame-graph': CallTreeSidebar, 'stack-chart': null, 'marker-chart': null, diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 645d8a9cc8..a70f348bb2 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1074,6 +1074,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 +1435,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 diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 9a611c1f69..9e2a4ca9cb 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, @@ -141,6 +142,7 @@ export const defaultThreadViewOptions: ThreadViewOptions = { selectedInvertedCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -268,6 +270,23 @@ const viewOptionsPerThread: Reducer = ( } ); } + 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; + } + + return _updateThreadViewOptions(state, threadsKey, { + selectedFunctionIndex, + }); + } case 'CHANGE_INVERT_CALLSTACK': { const { newSelectedCallNodePath, @@ -589,6 +608,7 @@ const scrollToSelectionGeneration: Reducer = (state = 0, action) => { case 'CHANGE_NETWORK_SEARCH_STRING': 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') { @@ -734,6 +754,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 +902,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..59867c60a3 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -42,6 +42,7 @@ import type { CallNodeSelfAndSummary, State, CallNodeTableBitSet, + IndexIntoFuncTable, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -202,6 +203,14 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): IndexIntoFuncTable | null => { + return threadViewOptions.selectedFunctionIndex; + } + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -492,6 +501,23 @@ 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, @@ -501,6 +527,7 @@ export function getStackAndSampleSelectorsPerThread( getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, @@ -517,5 +544,6 @@ export function getStackAndSampleSelectorsPerThread( getFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, }; } 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/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index f04baf9582..deb6e52fb9 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4250,6 +4250,7 @@ Object { }, }, "lastSeenTransformCount": 1, + "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ 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/types/actions.ts b/src/types/actions.ts index 6f11b70e73..d911774413 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, @@ -187,6 +188,12 @@ type ProfileAction = 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; @@ -197,6 +204,11 @@ type ProfileAction = readonly threadsKey: ThreadsKey; readonly callNodePath: CallNodePath | null; } + | { + readonly type: 'CHANGE_RIGHT_CLICKED_FUNCTION'; + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable | null; + } | { readonly type: 'FOCUS_CALL_TREE'; } diff --git a/src/types/state.ts b/src/types/state.ts index 6e18bb2630..65ee88ea4f 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 { @@ -56,6 +57,7 @@ export type ThreadViewOptions = { readonly selectedInvertedCallNodePath: CallNodePath; readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; + readonly selectedFunctionIndex: IndexIntoFuncTable | null; 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 @@ -78,6 +80,11 @@ export type RightClickedCallNode = { readonly callNodePath: CallNodePath; }; +export type RightClickedFunction = { + readonly threadsKey: ThreadsKey; + readonly functionIndex: IndexIntoFuncTable; +}; + export type MarkerReference = { readonly threadsKey: ThreadsKey; readonly markerIndex: MarkerIndex; @@ -102,6 +109,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': From d231fc37e13b447744c18e402858b3c1ca969d2c Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 18:57:03 -0400 Subject: [PATCH 02/20] Implement activity graph highlighting for function list --- .../calltree/ProfileFunctionListView.tsx | 2 +- src/components/timeline/TrackThread.tsx | 5 +- src/selectors/per-thread/stack-sample.ts | 14 ++ src/test/unit/profile-data.test.ts | 144 ++++++++++++++++++ 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index ab1d348eaf..7695a6c4d2 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -13,7 +13,7 @@ export const ProfileFunctionListView = () => ( role="tabpanel" aria-labelledby="function-list-tab-button" > - + diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b0ee5338a6..b1f24edaa7 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, @@ -349,7 +350,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, diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 59867c60a3..eb8d899242 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -295,6 +295,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleSelectedStatesForFunctionListTab: Selector = + createSelector( + getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSamplesSelectedStatesForFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -532,6 +545,7 @@ export function getStackAndSampleSelectorsPerThread( getExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, + getSampleSelectedStatesForFunctionListTab, getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, 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: { From ce2d8666409f8a33316c010b47a8a651c1cb873e Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:09:53 -0400 Subject: [PATCH 03/20] Implement function list context menu. --- src/actions/profile-view.ts | 2 +- src/components/app/Details.tsx | 2 + .../shared/FunctionListContextMenu.tsx | 488 ++++++++++++++++++ .../FunctionListContextMenu.test.tsx | 119 +++++ .../FunctionListContextMenu.test.tsx.snap | 216 ++++++++ 5 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 src/components/shared/FunctionListContextMenu.tsx create mode 100644 src/test/components/FunctionListContextMenu.test.tsx create mode 100644 src/test/components/__snapshots__/FunctionListContextMenu.test.tsx.snap diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 4e560fdc73..fa63769847 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -165,7 +165,7 @@ export function changeRightClickedCallNode( export function changeRightClickedFunctionIndex( threadsKey: ThreadsKey, functionIndex: IndexIntoFuncTable | null -) { +): Action { return { type: 'CHANGE_RIGHT_CLICKED_FUNCTION', threadsKey, diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index c571fd6945..85d92cc99a 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -27,6 +27,7 @@ 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 { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -135,6 +136,7 @@ class ProfileViewerImpl extends PureComponent { + ); 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/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/__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`] = ` +
+ +
+`; From 3091c5814050474bdb0501dd033a31058478cd28 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:17:12 -0400 Subject: [PATCH 04/20] Implement transform shortcut keys for function list --- src/actions/profile-view.ts | 97 ++++++++++ src/components/calltree/FunctionList.tsx | 32 ++-- .../components/TransformShortcuts.test.tsx | 176 ++++++++++++++++++ 3 files changed, 291 insertions(+), 14 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index fa63769847..2c25357f6e 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2136,3 +2136,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/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index c50c8c2386..ab8a9b7e7f 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -25,6 +25,7 @@ import { addTransformToStack, changeTableViewOptions, updateBottomBoxContentsAndMaybeOpen, + handleFunctionTransformShortcut, } from 'firefox-profiler/actions/profile-view'; import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; import { @@ -70,6 +71,7 @@ type DispatchProps = { readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; readonly addTransformToStack: typeof addTransformToStack; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly handleFunctionTransformShortcut: typeof handleFunctionTransformShortcut; readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; }; @@ -159,20 +161,21 @@ class FunctionListImpl extends PureComponent { _newExpandedCallNodeIndexes: Array ) => {}; - _onKeyDown = (_event: React.KeyboardEvent) => { - // const { - // selectedFunctionIndex, - // rightClickedFunctionIndex, - // threadsKey, - // } = this.props; - // const nodeIndex = - // rightClickedFunctionIndex !== null - // ? rightClickedFunctionIndex - // : selectedFunctionIndex; - // if (nodeIndex === null) { - // return; - // } - // handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + _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) => { @@ -244,6 +247,7 @@ export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ changeRightClickedFunctionIndex, addTransformToStack, updateBottomBoxContentsAndMaybeOpen, + handleFunctionTransformShortcut, onTableViewOptionsChange: (options: TableViewOptions) => changeTableViewOptions('calltree', options), }, 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, + }; + }); + }); + } +}); From 516912e5a1ee6675b4c0bc81579012e1b3397e77 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:24:43 -0400 Subject: [PATCH 05/20] Scroll selection into view when applying/unapplying transforms --- src/reducers/profile-view.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 9e2a4ca9cb..5136831790 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -606,6 +606,8 @@ 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': From f414106c3e40adfd369a9448388a093f8b2d98e6 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 19:35:34 -0400 Subject: [PATCH 06/20] Select self function when clicking in the activity graph --- src/components/timeline/TrackThread.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/timeline/TrackThread.tsx b/src/components/timeline/TrackThread.tsx index b1f24edaa7..7b713077b9 100644 --- a/src/components/timeline/TrackThread.tsx +++ b/src/components/timeline/TrackThread.tsx @@ -38,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'; @@ -100,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; }; @@ -123,6 +125,7 @@ class TimelineTrackThreadImpl extends PureComponent { const { threadsKey, selectSelfCallNode, + selectSelfFunction, focusCallTree, selectedThreadIndexes, callTreeVisible, @@ -130,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) { @@ -368,6 +372,7 @@ export const TimelineTrackThread = explicitConnect< changeSelectedCallNode, focusCallTree, selectSelfCallNode, + selectSelfFunction, reportTrackThreadHeight, }, component: withSize(TimelineTrackThreadImpl), From 4909f97f7089eb063a6951fae479d785d6179da9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 20:14:36 -0400 Subject: [PATCH 07/20] Select first row in the function list on mount. --- src/actions/profile-view.ts | 34 ++++++++++++++++++++++++ src/components/calltree/FunctionList.tsx | 12 +++++++++ 2 files changed, 46 insertions(+) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 2c25357f6e..681f13be68 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -235,6 +235,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. diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index ab8a9b7e7f..3721a96c52 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -115,6 +115,7 @@ class FunctionListImpl extends PureComponent { override componentDidMount() { this.focus(); + this.maybeProcureInitialSelection(); if (this.props.selectedFunctionIndex !== null && this._treeView) { this._treeView.scrollSelectionIntoView(); @@ -138,6 +139,17 @@ class FunctionListImpl extends PureComponent { } } + 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(); From 2afb83c736ecd1975919e55abc2f79ca61fd79bd Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 21:43:21 -0400 Subject: [PATCH 08/20] Implement double-clicking to show the bottom box in the function list. --- src/components/calltree/FunctionList.tsx | 8 +- src/profile-logic/bottom-box.ts | 101 +++++++++++++++++++++++ src/profile-logic/call-tree.ts | 13 ++- src/profile-logic/profile-data.ts | 40 +++++++++ src/test/store/bottom-box.test.ts | 69 +++++++++++++++- 5 files changed, 225 insertions(+), 6 deletions(-) diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index 3721a96c52..57f398645e 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -190,10 +190,10 @@ class FunctionListImpl extends PureComponent { handleFunctionTransformShortcut(event, threadsKey, funcIndex); }; - _onEnterOrDoubleClick = (_nodeId: IndexIntoFuncTable) => { - // const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; - // const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); - // updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo); + _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { + const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = tree.getBottomBoxInfoForFunction(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); }; override render() { 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..6e0389f788 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -38,7 +38,10 @@ 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 { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -626,6 +629,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. * diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index a70f348bb2..e42b06420f 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -886,6 +886,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. 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 + ); + }); +}); From 47abb6f4ac01b4ec10acb9badadf2cfe3e9e46fb Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 30 Mar 2026 17:48:50 -0400 Subject: [PATCH 09/20] Pass callNodeInfo to handleCallNodeTransformShortcut. --- src/actions/profile-view.ts | 2 +- src/components/calltree/CallTree.tsx | 3 ++- src/components/flame-graph/FlameGraph.tsx | 2 +- src/components/stack-chart/index.tsx | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 681f13be68..cf4ca99743 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2052,6 +2052,7 @@ export function closeBottomBox(): ThunkAction { export function handleCallNodeTransformShortcut( event: React.KeyboardEvent, threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, callNodeIndex: IndexIntoCallNodeTable ): ThunkAction { return (dispatch, getState) => { @@ -2060,7 +2061,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); diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 15d9a7bf75..7b747f93aa 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -192,6 +192,7 @@ class CallTreeImpl extends PureComponent { rightClickedCallNodeIndex, handleCallNodeTransformShortcut, threadsKey, + callNodeInfo, } = this.props; const nodeIndex = rightClickedCallNodeIndex !== null @@ -200,7 +201,7 @@ class CallTreeImpl extends PureComponent { if (nodeIndex === null) { return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { diff --git a/src/components/flame-graph/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 376d0f3025..b0c2ad5953 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -302,7 +302,7 @@ class FlameGraphImpl return; } - handleCallNodeTransformShortcut(event, threadsKey, nodeIndex); + handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; _onCopy = (event: ClipboardEvent) => { 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) => { From 7a4116075b6f03dfa66bd69ed6fb262ae13c0269 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 30 Mar 2026 17:47:28 -0400 Subject: [PATCH 10/20] Add inverted butterfly wing. --- src/actions/profile-view.ts | 58 ++- src/components/app/DetailsContainer.css | 2 +- src/components/calltree/Butterfly.css | 9 + src/components/calltree/LowerWing.tsx | 345 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 15 +- src/profile-logic/call-tree.ts | 53 +++ src/reducers/profile-view.ts | 80 ++-- src/selectors/per-thread/stack-sample.ts | 83 +++++ src/selectors/right-clicked-call-node.tsx | 2 + src/test/fixtures/utils.ts | 50 +++ .../__snapshots__/profile-view.test.ts.snap | 4 + src/test/store/profile-view.test.ts | 2 + src/test/unit/profile-tree.test.ts | 42 +++ src/types/actions.ts | 6 +- src/types/state.ts | 5 + 15 files changed, 716 insertions(+), 40 deletions(-) create mode 100644 src/components/calltree/Butterfly.css create mode 100644 src/components/calltree/LowerWing.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index cf4ca99743..4959c9046c 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -121,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, @@ -130,6 +130,21 @@ 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, + }; +} + /** * Select a function for a given thread in the function list. */ @@ -154,11 +169,15 @@ export function changeSelectedFunctionIndex( export function changeRightClickedCallNode( threadsKey: ThreadsKey, callNodePath: CallNodePath | null -): Action { - return { - type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', - threadsKey, - callNodePath, +): ThunkAction { + return (dispatch, getState) => { + const isInverted = getInvertCallstack(getState()); + dispatch({ + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: isInverted ? 'INVERTED_TREE' : 'NON_INVERTED_TREE', + callNodePath, + }); }; } @@ -173,6 +192,18 @@ export function changeRightClickedFunctionIndex( }; } +export function changeLowerWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: 'LOWER_WING', + callNodePath, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. @@ -1610,12 +1641,25 @@ 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 changeSelectedMarker( threadsKey: ThreadsKey, selectedMarker: MarkerIndex | null, 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..110fd0486c --- /dev/null +++ b/src/components/calltree/Butterfly.css @@ -0,0 +1,9 @@ +/* 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; +} diff --git a/src/components/calltree/LowerWing.tsx b/src/components/calltree/LowerWing.tsx new file mode 100644 index 0000000000..027399eec6 --- /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 index 7695a6c4d2..e28201c172 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -2,10 +2,15 @@ * 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 { LowerWing } from './LowerWing'; 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/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 6e0389f788..99cccc6bad 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -836,6 +836,59 @@ 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, + }), + }; +} + export function computeCallTreeTimings( callNodeInfo: CallNodeInfo, callNodeSelfAndSummary: CallNodeSelfAndSummary diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index 5136831790..d882d6a46b 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -37,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) { @@ -140,8 +140,10 @@ const symbolicationStatus: Reducer = ( export const defaultThreadViewOptions: ThreadViewOptions = { selectedNonInvertedCallNodePath: [], selectedInvertedCallNodePath: [], + selectedLowerWingCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + expandedLowerWingCallNodePaths: new PathSet(), selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, @@ -209,7 +211,7 @@ const viewOptionsPerThread: Reducer = ( } case 'CHANGE_SELECTED_CALL_NODE': { const { - isInverted, + area, selectedCallNodePath, threadsKey, optionalExpandedToCallNodePath, @@ -217,9 +219,11 @@ 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, + }[area]; // If the selected node doesn't actually change, let's return the previous // state to avoid rerenders. @@ -230,9 +234,11 @@ 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, + }[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -256,19 +262,25 @@ 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, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } } case 'CHANGE_SELECTED_FUNCTION': { const { selectedFunctionIndex, threadsKey } = action; @@ -321,16 +333,25 @@ 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, + }); + default: + throw assertExhaustiveCheck(area, 'Unhandled case'); + } } case 'CHANGE_SELECTED_NETWORK_MARKER': { const { threadsKey, selectedNetworkMarker } = action; @@ -733,6 +754,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index eb8d899242..039d3b9458 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -145,6 +145,8 @@ export function getStackAndSampleSelectorsPerThread( const _getCallNodeTable: Selector = (state) => _getNonInvertedCallNodeInfo(state).getCallNodeTable(); + const getLowerWingCallNodeInfo = _getInvertedCallNodeInfo; + const _getCallNodeFuncIsDuplicate: Selector = createSelector( _getCallNodeTable, @@ -211,6 +213,13 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getLowerWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedLowerWingCallNodePath + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -229,6 +238,15 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getLowerWingSelectedCallNodeIndex: Selector = + createSelector( + getLowerWingCallNodeInfo, + getLowerWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -238,6 +256,11 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.expandedNonInvertedCallNodePaths ); + const getLowerWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedLowerWingCallNodePaths + ); + const getExpandedCallNodeIndexes: Selector< Array > = createSelector( @@ -249,6 +272,17 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getLowerWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getLowerWingCallNodeInfo, + getLowerWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< Array > = createSelector( @@ -357,6 +391,14 @@ export function getStackAndSampleSelectorsPerThread( CallTree.computeCallTreeTimings ); + const _getLowerWingCallTreeTimings: Selector = + createSelector( + _getInvertedCallNodeInfo, + getCallNodeSelfAndSummary, + getSelectedFunctionIndex, + CallTree.computeLowerWingTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -410,6 +452,16 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getLowerWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getLowerWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getLowerWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + const getSourceViewLineTimings: Selector = createSelector( getSourceViewStackLineInfo, threadSelectors.getPreviewFilteredCtssSamples, @@ -504,6 +556,30 @@ 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, + getCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === 'LOWER_WING' ) { return callNodeInfo.getCallNodeIndexFromPath( rightClickedCallNodeInfo.callNodePath @@ -535,20 +611,26 @@ export function getStackAndSampleSelectorsPerThread( unfilteredSamplesRange, getWeightTypeForCallTree, getCallNodeInfo, + getLowerWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getLowerWingSelectedCallNodePath, + getLowerWingSelectedCallNodeIndex, getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, + getLowerWingExpandedCallNodePaths, + getLowerWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, getSampleSelectedStatesForFunctionListTab, getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, + getLowerWingCallTree, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -559,5 +641,6 @@ export function getStackAndSampleSelectorsPerThread( getFlameGraphTiming, getRightClickedCallNodeIndex, getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index a72dc73d35..f793e4df86 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/fixtures/utils.ts b/src/test/fixtures/utils.ts index 0f50030d9d..0d4714db80 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'; @@ -264,6 +265,55 @@ export function functionListTreeFromProfile( ); } +/** + * 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 deb6e52fb9..7b698392f5 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 [ @@ -4252,6 +4255,7 @@ Object { "lastSeenTransformCount": 1, "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], + "selectedLowerWingCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ 0, 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/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index f50cd99fe8..7e3608a090 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -23,6 +23,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, addSourceToTable, @@ -576,6 +577,47 @@ describe('function list', function () { }); }); +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 d911774413..f960892590 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -42,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'; @@ -182,7 +183,7 @@ type ProfileAction = } | { readonly type: 'CHANGE_SELECTED_CALL_NODE'; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly threadsKey: ThreadsKey; readonly selectedCallNodePath: CallNodePath; readonly optionalExpandedToCallNodePath: CallNodePath | undefined; @@ -202,6 +203,7 @@ type ProfileAction = | { readonly type: 'CHANGE_RIGHT_CLICKED_CALL_NODE'; readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath | null; } | { @@ -215,7 +217,7 @@ type ProfileAction = | { readonly type: 'CHANGE_EXPANDED_CALL_NODES'; readonly threadsKey: ThreadsKey; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly expandedCallNodePaths: Array; } | { diff --git a/src/types/state.ts b/src/types/state.ts index 65ee88ea4f..4d066b1fad 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -58,6 +58,8 @@ export type ThreadViewOptions = { readonly expandedNonInvertedCallNodePaths: PathSet; readonly expandedInvertedCallNodePaths: PathSet; readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly selectedLowerWingCallNodePath: CallNodePath; + readonly expandedLowerWingCallNodePaths: 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 @@ -75,8 +77,11 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; +export type CallNodeArea = 'NON_INVERTED_TREE' | 'INVERTED_TREE' | 'LOWER_WING'; + export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; From 11d43fb58c9d955591e2b2981dfbe975b65f71c3 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 21:10:51 -0400 Subject: [PATCH 11/20] Add context menu for inverted wing --- src/actions/profile-view.ts | 2 +- src/components/app/Details.tsx | 2 + src/components/calltree/LowerWing.tsx | 2 +- .../shared/LowerWingContextMenu.tsx | 489 ++++++++++++++++++ src/selectors/per-thread/stack-sample.ts | 25 +- .../components/LowerWingContextMenu.test.tsx | 119 +++++ 6 files changed, 636 insertions(+), 3 deletions(-) create mode 100644 src/components/shared/LowerWingContextMenu.tsx create mode 100644 src/test/components/LowerWingContextMenu.test.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 4959c9046c..af7ef226fe 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -195,7 +195,7 @@ export function changeRightClickedFunctionIndex( export function changeLowerWingRightClickedCallNode( threadsKey: ThreadsKey, callNodePath: CallNodePath | null -) { +): Action { return { type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', threadsKey, diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 85d92cc99a..0f58c97955 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -28,6 +28,7 @@ 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'; @@ -137,6 +138,7 @@ class ProfileViewerImpl extends PureComponent { +
); diff --git a/src/components/calltree/LowerWing.tsx b/src/components/calltree/LowerWing.tsx index 027399eec6..0f59ecb587 100644 --- a/src/components/calltree/LowerWing.tsx +++ b/src/components/calltree/LowerWing.tsx @@ -294,7 +294,7 @@ class LowerWingImpl extends PureComponent { highlightRegExp={searchStringsRegExp} disableOverscan={disableOverscan} ref={this._takeTreeViewRef} - contextMenuId="CallNodeContextMenu" + contextMenuId="LowerWingContextMenu" maxNodeDepth={callNodeMaxDepthPlusOne} rowHeight={16} indentWidth={10} 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/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 039d3b9458..84fffd4507 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -574,7 +574,7 @@ export function getStackAndSampleSelectorsPerThread( const getLowerWingRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, - getCallNodeInfo, + getLowerWingCallNodeInfo, (rightClickedCallNodeInfo, callNodeInfo) => { if ( rightClickedCallNodeInfo !== null && @@ -590,6 +590,28 @@ export function getStackAndSampleSelectorsPerThread( } ); + 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 getRightClickedFunctionIndex: Selector = createSelector( ProfileSelectors.getProfileViewOptions, @@ -642,5 +664,6 @@ export function getStackAndSampleSelectorsPerThread( getRightClickedCallNodeIndex, getRightClickedFunctionIndex, getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, }; } 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'); + }); + }); +}); From 393b68f65b881027ba0cbe33896b1c252762a56d Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Mon, 30 Mar 2026 17:47:55 -0400 Subject: [PATCH 12/20] Add the upper butterfly wing. --- src/actions/profile-view.ts | 39 ++ .../calltree/ProfileFunctionListView.tsx | 3 +- src/components/calltree/UpperWing.tsx | 345 ++++++++++++++++ src/profile-logic/profile-data.ts | 389 +++++++++++++++++- src/reducers/profile-view.ts | 27 ++ src/selectors/per-thread/stack-sample.ts | 129 ++++++ src/selectors/right-clicked-call-node.tsx | 2 +- src/test/fixtures/utils.ts | 50 +++ .../__snapshots__/profile-view.test.ts.snap | 4 + src/test/unit/profile-tree.test.ts | 36 ++ src/types/state.ts | 9 +- 11 files changed, 1023 insertions(+), 10 deletions(-) create mode 100644 src/components/calltree/UpperWing.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index af7ef226fe..a9e6b3b55f 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -145,6 +145,21 @@ export function changeLowerWingSelectedCallNode( }; } +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. */ @@ -204,6 +219,18 @@ export function changeLowerWingRightClickedCallNode( }; } +export function changeUpperWingRightClickedCallNode( + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +) { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: 'UPPER_WING', + callNodePath, + }; +} + /** * Given a threadIndex and a sampleIndex, select the call node which carries the * sample's self time. In the inverted tree, this will be a root node. @@ -1660,6 +1687,18 @@ export function changeLowerWingExpandedCallNodes( }; } +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, diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index e28201c172..deb4566d82 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -5,6 +5,7 @@ import SplitterLayout from 'react-splitter-layout'; import { FunctionList } from './FunctionList'; +import { UpperWing } from './UpperWing'; import { LowerWing } from './LowerWing'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; @@ -24,7 +25,7 @@ export const ProfileFunctionListView = () => ( - + diff --git a/src/components/calltree/UpperWing.tsx b/src/components/calltree/UpperWing.tsx new file mode 100644 index 0000000000..b044751c11 --- /dev/null +++ b/src/components/calltree/UpperWing.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 { + 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' }); + } + } + + override render() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + 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/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index e42b06420f..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. */ @@ -4561,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/reducers/profile-view.ts b/src/reducers/profile-view.ts index d882d6a46b..2239556dc4 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -141,9 +141,11 @@ 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, @@ -223,6 +225,7 @@ const viewOptionsPerThread: Reducer = ( 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 @@ -238,6 +241,7 @@ const viewOptionsPerThread: Reducer = ( INVERTED_TREE: threadState.expandedInvertedCallNodePaths, NON_INVERTED_TREE: threadState.expandedNonInvertedCallNodePaths, LOWER_WING: threadState.expandedLowerWingCallNodePaths, + UPPER_WING: threadState.expandedUpperWingCallNodePaths, }[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath @@ -278,6 +282,11 @@ const viewOptionsPerThread: Reducer = ( selectedLowerWingCallNodePath: selectedCallNodePath, expandedLowerWingCallNodePaths: expandedCallNodePaths, }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + selectedUpperWingCallNodePath: selectedCallNodePath, + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); default: throw assertExhaustiveCheck(area, 'Unhandled case'); } @@ -295,6 +304,20 @@ const viewOptionsPerThread: Reducer = ( 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, }); @@ -349,6 +372,10 @@ const viewOptionsPerThread: Reducer = ( return _updateThreadViewOptions(state, threadsKey, { expandedLowerWingCallNodePaths: expandedCallNodePaths, }); + case 'UPPER_WING': + return _updateThreadViewOptions(state, threadsKey, { + expandedUpperWingCallNodePaths: expandedCallNodePaths, + }); default: throw assertExhaustiveCheck(area, 'Unhandled case'); } diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 84fffd4507..22fee7065c 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -43,6 +43,7 @@ import type { State, CallNodeTableBitSet, IndexIntoFuncTable, + IndexIntoStackTable, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -213,6 +214,16 @@ export function getStackAndSampleSelectorsPerThread( } ); + 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, @@ -229,6 +240,13 @@ export function getStackAndSampleSelectorsPerThread( : threadViewOptions.selectedNonInvertedCallNodePath ); + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): CallNodePath => + threadViewOptions.selectedUpperWingCallNodePath + ); + const getSelectedCallNodeIndex: Selector = createSelector( getCallNodeInfo, @@ -247,6 +265,15 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingSelectedCallNodeIndex: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingSelectedCallNodePath, + (callNodeInfo, callNodePath) => { + return callNodeInfo.getCallNodeIndexFromPath(callNodePath); + } + ); + const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -261,6 +288,11 @@ export function getStackAndSampleSelectorsPerThread( (threadViewOptions) => threadViewOptions.expandedLowerWingCallNodePaths ); + const getUpperWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedUpperWingCallNodePaths + ); + const getExpandedCallNodeIndexes: Selector< Array > = createSelector( @@ -283,6 +315,17 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getUpperWingExpandedCallNodeIndexes: Selector< + Array + > = createSelector( + getUpperWingCallNodeInfo, + getUpperWingExpandedCallNodePaths, + (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< Array > = createSelector( @@ -302,6 +345,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( @@ -385,6 +449,28 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingCallNodeSelfAndSummary: Selector = + createSelector( + threadSelectors.getPreviewFilteredCtssSamples, + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, + getUpperWingCallNodeInfo, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo, + regularTreeSelfAndSummary + ) => { + const { rootTotalSummary } = regularTreeSelfAndSummary; + const { callNodeSelf } = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + return { rootTotalSummary, callNodeSelf }; + } + ); + const getCallTreeTimings: Selector = createSelector( getCallNodeInfo, getCallNodeSelfAndSummary, @@ -399,6 +485,13 @@ export function getStackAndSampleSelectorsPerThread( CallTree.computeLowerWingTimings ); + const _getUpperWingCallTreeTimings: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -452,6 +545,16 @@ 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, @@ -612,6 +715,25 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingRightClickedCallNodeIndex: Selector = + createSelector( + getRightClickedCallNodeInfo, + getUpperWingCallNodeInfo, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === 'UPPER_WING' + ) { + return callNodeInfo.getCallNodeIndexFromPath( + rightClickedCallNodeInfo.callNodePath + ); + } + + return null; + } + ); + const getRightClickedFunctionIndex: Selector = createSelector( ProfileSelectors.getProfileViewOptions, @@ -634,6 +756,7 @@ export function getStackAndSampleSelectorsPerThread( getWeightTypeForCallTree, getCallNodeInfo, getLowerWingCallNodeInfo, + getUpperWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, @@ -641,11 +764,15 @@ export function getStackAndSampleSelectorsPerThread( getSelectedCallNodeIndex, getLowerWingSelectedCallNodePath, getLowerWingSelectedCallNodeIndex, + getUpperWingSelectedCallNodePath, + getUpperWingSelectedCallNodeIndex, getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getLowerWingExpandedCallNodePaths, getLowerWingExpandedCallNodeIndexes, + getUpperWingExpandedCallNodePaths, + getUpperWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, getSampleSelectedStatesForFunctionListTab, @@ -653,6 +780,7 @@ export function getStackAndSampleSelectorsPerThread( getCallTree, getFunctionListTree, getLowerWingCallTree, + getUpperWingCallTree, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -665,5 +793,6 @@ export function getStackAndSampleSelectorsPerThread( getRightClickedFunctionIndex, getLowerWingRightClickedCallNodeIndex, getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index f793e4df86..a9d03cff46 100644 --- a/src/selectors/right-clicked-call-node.tsx +++ b/src/selectors/right-clicked-call-node.tsx @@ -14,7 +14,7 @@ import type { export type RightClickedCallNodeInfo = { readonly threadsKey: ThreadsKey; - readonly area: CallNodeArea; + readonly area: CallNodeArea, readonly callNodePath: CallNodePath; }; diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index 0d4714db80..e02579364a 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -20,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'; @@ -265,6 +266,55 @@ 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 diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 7b698392f5..b6dca5c7d1 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4252,6 +4252,9 @@ Object { ], }, }, + "expandedUpperWingCallNodePaths": PathSet { + "_table": Map {}, + }, "lastSeenTransformCount": 1, "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], @@ -4261,5 +4264,6 @@ Object { 0, 1, ], + "selectedUpperWingCallNodePath": Array [], } `; diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 7e3608a090..dce6854b71 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -23,6 +23,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + upperWingTreeFromProfile, lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, @@ -577,6 +578,41 @@ 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 = ` diff --git a/src/types/state.ts b/src/types/state.ts index 4d066b1fad..033e02c621 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -52,6 +52,7 @@ 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; @@ -60,6 +61,8 @@ export type ThreadViewOptions = { 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 @@ -77,7 +80,11 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; -export type CallNodeArea = 'NON_INVERTED_TREE' | 'INVERTED_TREE' | 'LOWER_WING'; +export type CallNodeArea = + | 'NON_INVERTED_TREE' + | 'INVERTED_TREE' + | 'LOWER_WING' + | 'UPPER_WING'; export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; From a990401f92df613f329ab92fd23a62598fb49243 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 31 Mar 2026 11:39:10 -0400 Subject: [PATCH 13/20] Column sorting --- src/components/calltree/CallTree.tsx | 12 +- src/components/marker-table/index.tsx | 67 +++++++++- src/components/shared/TreeView.css | 13 ++ src/components/shared/TreeView.tsx | 181 ++++++++++++++++++++++++-- src/profile-logic/call-tree.ts | 103 ++++++++++++--- src/profile-logic/zip-files.ts | 6 + 6 files changed, 345 insertions(+), 37 deletions(-) diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index 7b747f93aa..6064bd7418 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -4,7 +4,10 @@ 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 { getInvertCallstack, @@ -98,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 @@ -204,6 +208,10 @@ class CallTreeImpl extends PureComponent { handleCallNodeTransformShortcut(event, threadsKey, callNodeInfo, nodeIndex); }; + _onSort = (sortedColumns: ColumnSortState) => { + this._sortedColumns = sortedColumns; + }; + _onEnterOrDoubleClick = (nodeId: IndexIntoCallNodeTable) => { const { tree, updateBottomBoxContentsAndMaybeOpen } = this.props; const bottomBoxInfo = tree.getBottomBoxInfoForCallNode(nodeId); @@ -308,6 +316,8 @@ class CallTreeImpl extends PureComponent { onDoubleClick={this._onEnterOrDoubleClick} viewOptions={tableViewOptions} onViewOptionsChange={onTableViewOptionsChange} + initialSortedColumns={this._sortedColumns} + onColumnSortChange={this._onSort} /> ); } 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/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 (
{ + 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 { @@ -448,7 +512,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; 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); } From fea60d932d9ef509bd4c04aec1a991258cb6c30d Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Tue, 31 Mar 2026 15:45:03 -0400 Subject: [PATCH 14/20] Fix splitter styling --- src/components/calltree/Butterfly.css | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css index 110fd0486c..c83bd24172 100644 --- a/src/components/calltree/Butterfly.css +++ b/src/components/calltree/Butterfly.css @@ -7,3 +7,40 @@ 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; +} From 4e234e33789bdabaade4a2eb0aaee2e822d7bfc4 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 15:19:01 -0400 Subject: [PATCH 15/20] First attempt at FlameGraph refactor --- .../flame-graph/ConnectedFlameGraph.tsx | 248 ++++++++++++++++++ src/components/flame-graph/FlameGraph.tsx | 166 +++--------- .../flame-graph/MaybeFlameGraph.tsx | 88 ------- src/components/flame-graph/index.tsx | 76 ++++-- 4 files changed, 339 insertions(+), 239 deletions(-) create mode 100644 src/components/flame-graph/ConnectedFlameGraph.tsx delete mode 100644 src/components/flame-graph/MaybeFlameGraph.tsx 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 b0c2ad5953..9702569d9a 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) { @@ -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, callNodeInfo, 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; From fa9fd7ecd37609d1a8877b6ee64a4a6d4f813d1f Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 16:31:04 -0400 Subject: [PATCH 16/20] Upper wing is now a flame graph --- src/components/calltree/UpperWing.tsx | 8 +- .../calltree/UpperWingFlameGraph.tsx | 254 ++++++++++++++++++ src/selectors/per-thread/stack-sample.ts | 26 ++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 src/components/calltree/UpperWingFlameGraph.tsx diff --git a/src/components/calltree/UpperWing.tsx b/src/components/calltree/UpperWing.tsx index b044751c11..6733c64d9e 100644 --- a/src/components/calltree/UpperWing.tsx +++ b/src/components/calltree/UpperWing.tsx @@ -3,11 +3,13 @@ * 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, @@ -263,7 +265,7 @@ class UpperWingImpl extends PureComponent { } } - override render() { + _renderCallTree() { const { tree, selectedCallNodeIndex, @@ -306,6 +308,10 @@ class UpperWingImpl extends PureComponent { /> ); } + + override render() { + return ; + } } export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ 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/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 22fee7065c..9fec176da1 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -651,6 +651,31 @@ 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 + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -781,6 +806,7 @@ export function getStackAndSampleSelectorsPerThread( getFunctionListTree, getLowerWingCallTree, getUpperWingCallTree, + getUpperWingFlameGraphTiming, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, From aaaf5ad9dcc6d5178db20beafd30cc7f02b7382d Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 16:56:28 -0400 Subject: [PATCH 17/20] Add self wing --- .../calltree/ProfileFunctionListView.tsx | 6 +- src/components/calltree/SelfWing.tsx | 220 ++++++++++++++++++ src/selectors/per-thread/stack-sample.ts | 116 +++++++++ 3 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 src/components/calltree/SelfWing.tsx diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index deb4566d82..0af631493d 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -5,6 +5,7 @@ import SplitterLayout from 'react-splitter-layout'; import { FunctionList } from './FunctionList'; +import { SelfWing } from './SelfWing'; import { UpperWing } from './UpperWing'; import { LowerWing } from './LowerWing'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; @@ -25,7 +26,10 @@ 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/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 9fec176da1..1114ac04df 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'; @@ -44,6 +45,8 @@ import type { CallNodeTableBitSet, IndexIntoFuncTable, IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, } from 'firefox-profiler/types'; import type { CallNodeInfo, @@ -676,6 +679,112 @@ export function getStackAndSampleSelectorsPerThread( 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, + CallTree.computeCallNodeSelfAndSummary + ); + + 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, @@ -807,6 +916,13 @@ export function getStackAndSampleSelectorsPerThread( getLowerWingCallTree, getUpperWingCallTree, getUpperWingFlameGraphTiming, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, From a0f2cec9b0ac54f748b7656423b63080faa2c362 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Thu, 2 Apr 2026 17:31:33 -0400 Subject: [PATCH 18/20] Add disclosure box --- .../calltree/ProfileFunctionListView.tsx | 13 ++++- src/components/shared/DisclosureBox.css | 57 +++++++++++++++++++ src/components/shared/DisclosureBox.tsx | 49 ++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 src/components/shared/DisclosureBox.css create mode 100644 src/components/shared/DisclosureBox.tsx diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 0af631493d..3882673387 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -8,6 +8,7 @@ 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'; @@ -27,10 +28,16 @@ export const ProfileFunctionListView = () => ( - - + + + + + + - + + +
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} +
+ ); + } +} From 33a1af84bb5166da7412c7d399e044a140b008a7 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 09:34:45 -0400 Subject: [PATCH 19/20] Remove horizontal splitters, just use disclosure boxes. --- src/components/calltree/Butterfly.css | 14 ++++++++++ .../calltree/ProfileFunctionListView.tsx | 26 +++++++++---------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css index c83bd24172..21cbf626af 100644 --- a/src/components/calltree/Butterfly.css +++ b/src/components/calltree/Butterfly.css @@ -44,3 +44,17 @@ > .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/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 3882673387..63ed38dee3 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -24,21 +24,21 @@ export const ProfileFunctionListView = () => (
- - - - - - - - - - - - + +
+ +
+
+ + + + - + + + +
From abde12b669ca50b2c0346052bd9921f4a73b3e96 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Fri, 3 Apr 2026 10:33:49 -0400 Subject: [PATCH 20/20] Fix flamegraph percentages for the function list subtree views. --- src/components/flame-graph/Canvas.tsx | 13 +++++----- src/components/flame-graph/FlameGraph.tsx | 4 +-- src/profile-logic/call-tree.ts | 9 ++++--- src/profile-logic/flame-graph.ts | 30 ++++++++++++++++++----- src/selectors/per-thread/stack-sample.ts | 30 ++++++++++++++++++++--- src/test/store/actions.test.ts | 4 +-- src/types/profile-derived.ts | 6 +++++ 7 files changed, 74 insertions(+), 22 deletions(-) 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/FlameGraph.tsx b/src/components/flame-graph/FlameGraph.tsx index 9702569d9a..07fd5d47b5 100644 --- a/src/components/flame-graph/FlameGraph.tsx +++ b/src/components/flame-graph/FlameGraph.tsx @@ -120,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; }; @@ -145,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 { diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index c6602911d6..73671ccf00 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -51,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 }; @@ -762,7 +763,7 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { callNodeSelf, rootTotalSummary, flameGraphTotalForScaling: rootTotalSummary }; } export function getSelfAndTotalForCallNode( @@ -950,6 +951,7 @@ export function computeLowerWingTimings( timings: computeCallTreeTimingsInverted(callNodeInfo, { callNodeSelf: mappedSelf, rootTotalSummary, + flameGraphTotalForScaling: rootTotalSummary, }), }; } @@ -986,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); @@ -1024,6 +1026,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphTotalForScaling, }; } @@ -1426,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/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 1114ac04df..259b8884b2 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -465,12 +465,20 @@ export function getStackAndSampleSelectorsPerThread( regularTreeSelfAndSummary ) => { const { rootTotalSummary } = regularTreeSelfAndSummary; - const { callNodeSelf } = CallTree.computeCallNodeSelfAndSummary( + const upperWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( samples, sampleIndexToCallNodeIndex, callNodeInfo.getCallNodeTable().length ); - return { rootTotalSummary, callNodeSelf }; + 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 }; } ); @@ -723,7 +731,23 @@ export function getStackAndSampleSelectorsPerThread( _getSelfWingSampleIndexToCallNodeIndex, (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable().length, - CallTree.computeCallNodeSelfAndSummary + 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 = 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/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; }; /**