From 8e4c9721c10a1c67da0944e8ce4b31b3d6fe6be0 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 01/13] Add "open bottom box for function X" support code. This will be used by the function list when double-clicking on a function. --- src/profile-logic/bottom-box.ts | 108 ++++++++++++++++++++++++++++++ src/profile-logic/call-tree.ts | 13 +++- src/profile-logic/profile-data.ts | 41 ++++++++++++ src/test/store/bottom-box.test.ts | 83 ++++++++++++++++++++++- 4 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index cbd935ed8f..45ab325fef 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, getOriginalPositionForFrame, @@ -202,3 +204,109 @@ 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 { source: sourceIndex, line: funcLine } = getOriginalPositionForFrame( + null, + funcIndex, + frameTable, + funcTable, + thread.sourceLocationTable + ); + 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 lineTimings = getTotalLineTimingsForCallNode( + samples, + funcFramePerStack, + frameTable, + funcTable, + funcLine, + thread.sourceLocationTable + ); + 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 29189178db..59db1d6fb4 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -39,7 +39,10 @@ import * as ProfileData from './profile-data'; import type { CallTreeSummaryStrategy } from '../types/actions'; import type { CallNodeInfo, CallNodeInfoInverted } from './call-node-info'; import type { SortableColumn } from '../components/shared/TreeView'; -import { getBottomBoxInfoForCallNode } from './bottom-box'; +import { + getBottomBoxInfoForCallNode, + getBottomBoxInfoForFunction, +} from './bottom-box'; type CallNodeChildren = IndexIntoCallNodeTable[]; @@ -647,6 +650,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 865efbd41f..0a88ba6df8 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -948,6 +948,47 @@ 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..600c43db74 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,81 @@ 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 8c37f53d3a13bce37268eaf547cb428acc29200b Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:51:35 -0400 Subject: [PATCH 02/13] Add a function list panel. --- locales/en-US/app.ftl | 1 + res/css/style.css | 3 +- src/actions/profile-view.ts | 37 +++ src/app-logic/tabs-handling.ts | 2 + src/app-logic/url-handling.ts | 57 ++++ src/components/app/Details.tsx | 2 + src/components/calltree/CallTree.css | 6 +- src/components/calltree/CallTree.tsx | 4 - src/components/calltree/FunctionList.tsx | 264 ++++++++++++++++++ .../calltree/ProfileFunctionListView.tsx | 20 ++ src/components/calltree/columns.ts | 44 +++ src/components/sidebar/index.tsx | 1 + src/components/timeline/TrackThread.tsx | 5 +- src/profile-logic/call-tree.ts | 38 ++- src/profile-logic/profile-data.ts | 94 +++++++ src/reducers/profile-view.ts | 69 +++++ src/reducers/url-state.ts | 13 + src/selectors/per-thread/stack-sample.ts | 42 +++ src/selectors/url-state.ts | 3 + src/test/components/Details.test.tsx | 3 + src/test/components/DetailsContainer.test.tsx | 1 + .../__snapshots__/profile-view.test.ts.snap | 1 + src/test/store/icons.test.ts | 1 + src/test/store/useful-tabs.test.ts | 4 + src/test/unit/profile-data.test.ts | 144 ++++++++++ src/test/unit/profile-tree.test.ts | 2 + src/test/url-handling.test.ts | 52 ++++ src/types/actions.ts | 16 ++ src/types/profile-derived.ts | 1 + src/types/state.ts | 9 + src/utils/types.ts | 1 + 31 files changed, 929 insertions(+), 11 deletions(-) create mode 100644 src/components/calltree/FunctionList.tsx create mode 100644 src/components/calltree/ProfileFunctionListView.tsx diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 3697baf81e..6909ac106f 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -909,6 +909,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 af47396293..255fafba72 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, @@ -130,6 +131,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 @@ -146,6 +163,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. @@ -1625,6 +1653,15 @@ export function changeMarkerTableSort( }; } +export function changeFunctionListSort( + sort: SingleColumnSortState[] | null +): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SORT', + sort, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', 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 70c6f026f2..28ac2100df 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -186,6 +186,7 @@ type CallTreeQuery = BaseQuery & { invertCallstack: null | undefined; hideIdleSamples: null | undefined; ctSummary: string; + functionListSort?: string; // "total-desc~self-asc" — primary first }; type MarkersQuery = BaseQuery & { @@ -232,6 +233,9 @@ type Query = BaseQuery & { marker?: MarkerIndex; markerSort?: string; + // Function list specific + functionListSort?: string; + // Network specific networkSearch?: string; @@ -338,6 +342,7 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { : undefined; /* fallsthrough */ case 'flame-graph': + case 'function-list': case 'calltree': { query = baseQuery as CallTreeQueryShape; @@ -385,6 +390,11 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.bottomFullscreen = true; } } + if (selectedTab === 'function-list') { + query.functionListSort = convertFunctionListSortToString( + urlState.profileSpecific.functionListSort + ); + } break; } case 'marker-table': @@ -641,6 +651,9 @@ export function stateFromLocation( : null, selectedMarkers, markerTableSort: convertMarkerTableSortFromString(query.markerSort), + functionListSort: convertFunctionListSortFromString( + query.functionListSort + ), }, }; } @@ -687,6 +700,50 @@ function convertMarkerTableSortFromString( return parsed; } +// FunctionList sort URL encoding. Same convention as the marker table: +// internal storage is primary-last, URL is primary-first. `null` means "at the +// function list's default", encoded as an absent query parameter. +const VALID_FUNCTION_LIST_SORT_COLUMNS = new Set(['total', 'self']); + +function convertFunctionListSortToString( + sort: SingleColumnSortState[] | null +): string | undefined { + if (sort === null) { + return undefined; + } + return sort + .slice() + .reverse() + .map((s) => `${s.column}-${s.ascending ? 'asc' : 'desc'}`) + .join('~'); +} + +function convertFunctionListSortFromString( + raw: string | null | void +): SingleColumnSortState[] | null { + if (!raw) { + return null; + } + const parsed: SingleColumnSortState[] = []; + for (const part of raw.split('~')) { + const dashIndex = part.lastIndexOf('-'); + if (dashIndex === -1) { + return null; + } + const column = part.slice(0, dashIndex); + const dir = part.slice(dashIndex + 1); + if ( + !VALID_FUNCTION_LIST_SORT_COLUMNS.has(column) || + (dir !== 'asc' && dir !== 'desc') + ) { + return null; + } + parsed.push({ column, ascending: dir === 'asc' }); + } + // URL is primary-first; internal storage is primary-last. + return parsed.reverse(); +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { 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.css b/src/components/calltree/CallTree.css index 73e7f3935c..2efc3e8b38 100644 --- a/src/components/calltree/CallTree.css +++ b/src/components/calltree/CallTree.css @@ -7,8 +7,9 @@ text-align: right; } -/* The header for the totalPercent column is not visible */ -.treeViewHeaderColumn.totalPercent { +/* The headers for the percent columns are not visible */ +.treeViewHeaderColumn.totalPercent, +.treeViewHeaderColumn.selfPercent { display: none; } @@ -26,6 +27,7 @@ .treeViewRowColumn.total, .treeViewRowColumn.totalPercent, .treeViewRowColumn.self, +.treeViewRowColumn.selfPercent, .treeViewRowColumn.timestamp { text-align: right; } diff --git a/src/components/calltree/CallTree.tsx b/src/components/calltree/CallTree.tsx index a86053f130..9ec2279d9d 100644 --- a/src/components/calltree/CallTree.tsx +++ b/src/components/calltree/CallTree.tsx @@ -7,7 +7,6 @@ import { TreeView } from 'firefox-profiler/components/shared/TreeView'; import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; import { getInvertCallstack, - getImplementationFilter, getSearchStringsAsRegExp, getSelectedThreadsKey, } from 'firefox-profiler/selectors/url-state'; @@ -31,7 +30,6 @@ import { import type { State, - ImplementationFilter, ThreadsKey, IndexIntoCategoryList, IndexIntoCallNodeTable, @@ -61,7 +59,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; @@ -291,7 +288,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..c92105ea53 --- /dev/null +++ b/src/components/calltree/FunctionList.tsx @@ -0,0 +1,264 @@ +/* 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, + ColumnSortState, +} from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, + getFunctionListSort, +} 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, + changeFunctionListSort, +} from 'firefox-profiler/actions/profile-view'; +import { + nameColumn, + libColumn, + functionListColumnsForWeightType, +} 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 { SingleColumnSortState } from 'firefox-profiler/components/shared/TreeView'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +const DEFAULT_FUNCTION_LIST_SORT: SingleColumnSortState[] = [ + { column: 'total', ascending: false }, +]; +const DEFAULT_FUNCTION_LIST_SORT_STATE = new ColumnSortState( + DEFAULT_FUNCTION_LIST_SORT +); + +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; + readonly sort: SingleColumnSortState[] | null; +}; + +type DispatchProps = { + readonly changeSelectedFunctionIndex: typeof changeSelectedFunctionIndex; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + readonly addTransformToStack: typeof addTransformToStack; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; + readonly changeFunctionListSort: typeof changeFunctionListSort; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class FunctionListImpl extends PureComponent { + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView | null) => { + this._treeView = treeView; + }; + + _expandedIndexes: Array = []; + + _getColumnSort = memoize((sort: SingleColumnSortState[] | null) => + sort === null ? DEFAULT_FUNCTION_LIST_SORT_STATE : new ColumnSortState(sort) + ); + + _onColumnSortChange = (columnSort: ColumnSortState) => { + // Normalize matches-default to null so the URL omits the param and every + // client can treat "user hasn't overridden the default" uniformly. + this.props.changeFunctionListSort( + columnSort.equals(DEFAULT_FUNCTION_LIST_SORT_STATE) + ? null + : columnSort.entries + ); + }; + + override componentDidMount() { + this.focus(); + this.maybeProcureInitialSelection(); + + if (this.props.selectedFunctionIndex !== null && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + } + + override componentDidUpdate(prevProps: Props) { + if ( + this.props.focusCallTreeGeneration > prevProps.focusCallTreeGeneration + ) { + this.focus(); + } + + if ( + this.props.selectedFunctionIndex !== null && + this.props.scrollToSelectionGeneration > + prevProps.scrollToSelectionGeneration && + this._treeView + ) { + this._treeView.scrollSelectionIntoView(); + } + } + + maybeProcureInitialSelection() { + if (this.props.selectedFunctionIndex !== null) { + return; + } + const { tree, threadsKey, changeSelectedFunctionIndex } = this.props; + const firstRoot = tree.getRoots()[0]; + if (firstRoot !== undefined) { + changeSelectedFunctionIndex(threadsKey, firstRoot, { source: 'auto' }); + } + } + + focus() { + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectionChange = ( + newSelectedFunction: IndexIntoFuncTable, + context: SelectionContext + ) => { + const { threadsKey, changeSelectedFunctionIndex } = this.props; + changeSelectedFunctionIndex(threadsKey, newSelectedFunction, context); + }; + + _onRightClickSelection = (newSelectedFunction: IndexIntoFuncTable) => { + const { threadsKey, changeRightClickedFunctionIndex } = this.props; + changeRightClickedFunctionIndex(threadsKey, newSelectedFunction); + }; + + _onExpandedCallNodesChange = ( + _newExpandedCallNodeIndexes: Array + ) => {}; + + _onKeyDown = (_event: React.KeyboardEvent) => { + // const { + // selectedFunctionIndex, + // rightClickedFunctionIndex, + // threadsKey, + // } = 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.getBottomBoxInfoForFunction(nodeId); + updateBottomBoxContentsAndMaybeOpen('function-list', bottomBoxInfo); + }; + + override render() { + const { + tree, + selectedFunctionIndex, + rightClickedFunctionIndex, + searchStringsRegExp, + disableOverscan, + weightType, + tableViewOptions, + onTableViewOptionsChange, + sort, + } = 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), + sort: getFunctionListSort(state), + }), + mapDispatchToProps: { + changeSelectedFunctionIndex, + changeRightClickedFunctionIndex, + addTransformToStack, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + changeFunctionListSort, + }, + component: FunctionListImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx new file mode 100644 index 0000000000..7695a6c4d2 --- /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 index 14f597410a..a3f92fa837 100644 --- a/src/components/calltree/columns.ts +++ b/src/components/calltree/columns.ts @@ -128,3 +128,47 @@ export function treeColumnsForWeightType( throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); } } + +function withSelfPercentColumn( + columns: MaybeResizableColumn[] +): MaybeResizableColumn[] { + const selfPercentColumn = { + propName: 'selfPercent', + titleL10nId: '', + initialWidth: 55, + hideDividerAfter: true, + }; + const selfColumnIndex = columns.findIndex((c) => c.propName === 'self'); + const selfColumn = { + ...columns[selfColumnIndex], + headerWidthAdjustment: selfPercentColumn.initialWidth, + }; + const newColumns = columns.slice(); + newColumns[selfColumnIndex] = selfColumn; + newColumns.splice(selfColumnIndex, 0, selfPercentColumn); + return newColumns; +} + +export const functionListColumnsForTracingMs: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForTracingMs); +export const functionListColumnsForSamples: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForSamples); +export const functionListColumnsForBytes: MaybeResizableColumn[] = + withSelfPercentColumn(treeColumnsForBytes); + +// Like `treeColumnsForWeightType`, but for the FunctionList table (which has +// an extra selfPercent column compared to the call-tree variants). +export function functionListColumnsForWeightType( + weightType: WeightType +): MaybeResizableColumn[] { + switch (weightType) { + case 'tracing-ms': + return functionListColumnsForTracingMs; + case 'samples': + return functionListColumnsForSamples; + case 'bytes': + return functionListColumnsForBytes; + default: + throw assertExhaustiveCheck(weightType, 'Unhandled WeightType.'); + } +} 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/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/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 59db1d6fb4..e8dd595dca 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 type { SortableColumn } from '../components/shared/TreeView'; +import type { + ColumnSortState, + SortableColumn, +} from '../components/shared/TreeView'; import { getBottomBoxInfoForCallNode, getBottomBoxInfoForFunction, @@ -407,6 +410,12 @@ export class CallTree { // The call tree's own order (by total sample count) is the intended order, // so no columns are user-sortable. getSortableColumns(): SortableColumn[] { + if (this._internal instanceof CallTreeInternalFunctionList) { + return [ + { column: 'total', prefersDescending: true }, + { column: 'self', prefersDescending: true }, + ]; + } return []; } @@ -414,7 +423,28 @@ export class CallTree { return this._rootTotalSummary; } - getRoots() { + getRoots(sort: ColumnSortState | null = null): IndexIntoCallNodeTable[] { + if ( + sort !== null && + sort.entries.length > 0 && + this._internal instanceof CallTreeInternalFunctionList + ) { + const internal = this._internal; + return sort.sortItems( + this._roots, + ( + a: IndexIntoCallNodeTable, + b: IndexIntoCallNodeTable, + column: string + ) => { + const aValue = + column === 'self' ? internal.getSelf(a) : internal.getTotal(a); + const bValue = + column === 'self' ? internal.getSelf(b) : internal.getTotal(b); + return aValue - bValue; + } + ); + } return this._roots; } @@ -518,7 +548,7 @@ export class CallTree { let displayData: CallNodeDisplayData | void = this._displayDataByIndex.get(callNodeIndex); if (displayData === undefined) { - const { funcName, total, totalRelative, self } = + const { funcName, total, totalRelative, self, selfRelative } = this.getNodeData(callNodeIndex); const funcIndex = this._callNodeInfo.funcForNode(callNodeIndex); const categoryIndex = this._callNodeInfo.categoryForNode(callNodeIndex); @@ -556,6 +586,7 @@ export class CallTree { self ); const totalPercent = `${formatPercent(totalRelative)}`; + const selfPercent = `${formatPercent(selfRelative)}`; let ariaLabel; let totalWithUnit; @@ -606,6 +637,7 @@ export class CallTree { self: self === 0 ? '—' : formattedSelf, selfWithUnit: self === 0 ? '—' : selfWithUnit, totalPercent, + selfPercent, name: funcName, lib: libName.slice(0, 1000), // Dim platform pseudo-stacks. diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 0a88ba6df8..0611d64f49 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -1177,6 +1177,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. @@ -1479,6 +1538,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 b2a58f78c0..fa2b3fd6c8 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -30,6 +30,7 @@ import type { ThreadsKey, Milliseconds, TableViewOptions, + RightClickedFunction, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -188,6 +189,7 @@ export const defaultThreadViewOptions: ThreadViewOptions = { selectedInvertedCallNodePath: [], expandedNonInvertedCallNodePaths: new PathSet(), expandedInvertedCallNodePaths: new PathSet(), + selectedFunctionIndex: null, selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -315,6 +317,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, @@ -636,6 +655,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') { @@ -781,6 +801,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 @@ -882,6 +950,7 @@ const profileViewReducer: Reducer = wrapReducerInResetter( lastNonShiftClick, rightClickedTrack, rightClickedCallNode, + rightClickedFunction, rightClickedMarker, hoveredMarker, mouseTimePosition, diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 97b41cbeae..2ba81ca643 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -206,6 +206,18 @@ const markerTableSort: Reducer = ( } }; +const functionListSort: Reducer = ( + state = null, + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SORT': + return action.sort; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -807,6 +819,7 @@ const profileSpecific = combineReducers({ tabFilter, selectedMarkers, markerTableSort, + functionListSort, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 21bbecb964..181868fac1 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, @@ -208,6 +209,14 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSelectedFunctionIndex: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions): IndexIntoFuncTable | null => { + return threadViewOptions.selectedFunctionIndex; + } + ); + const getSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, @@ -292,6 +301,19 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getSampleSelectedStatesForFunctionListTab: Selector = + createSelector( + getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, + _getCallNodeTable, + getSelectedFunctionIndex, + (sampleCallNodes, callNodeTable, selectedFunctionIndex) => + ProfileData.getSamplesSelectedStatesForFunction( + sampleCallNodes, + selectedFunctionIndex, + callNodeTable + ) + ); + const getTreeOrderComparatorInFilteredThread: Selector< ( sampleIndexA: IndexIntoSamplesTable, @@ -498,6 +520,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, @@ -507,10 +546,12 @@ export function getStackAndSampleSelectorsPerThread( getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, + getSampleSelectedStatesForFunctionListTab, getTreeOrderComparatorInFilteredThread, getCallTree, getFunctionListTree, @@ -524,5 +565,6 @@ export function getStackAndSampleSelectorsPerThread( getFilteredCallNodeMaxDepthPlusOne, getFlameGraphTiming, getRightClickedCallNodeIndex, + getRightClickedFunctionIndex, }; } diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 9e06d122bd..8635d93a30 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -121,6 +121,9 @@ export const getMarkersSearchString: Selector = (state) => export const getMarkerTableSort: Selector = ( state ) => getProfileSpecificState(state).markerTableSort; +export const getFunctionListSort: Selector = ( + state +) => getProfileSpecificState(state).functionListSort; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => 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 0479910d2b..7cdfa59a22 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4431,6 +4431,7 @@ Object { }, }, "lastSeenTransformCount": 1, + "selectedFunctionIndex": null, "selectedInvertedCallNodePath": Array [], "selectedNetworkMarker": null, "selectedNonInvertedCallNodePath": Array [ diff --git a/src/test/store/icons.test.ts b/src/test/store/icons.test.ts index 3a9d3df13f..c1b77810b9 100644 --- a/src/test/store/icons.test.ts +++ b/src/test/store/icons.test.ts @@ -37,6 +37,7 @@ describe('actions/icons', function () { totalPercent: '0', self: '0', selfWithUnit: '0 ms', + selfPercent: '0', name: 'icon', lib: 'icon', isFrameLabel: false, diff --git a/src/test/store/useful-tabs.test.ts b/src/test/store/useful-tabs.test.ts index 2e770a2581..7f6444c07e 100644 --- a/src/test/store/useful-tabs.test.ts +++ b/src/test/store/useful-tabs.test.ts @@ -21,6 +21,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -59,6 +60,7 @@ describe('getUsefulTabs', function () { }); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -84,6 +86,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', @@ -116,6 +119,7 @@ describe('getUsefulTabs', function () { const { getState } = storeWithProfile(profile); expect(selectedThreadSelectors.getUsefulTabs(getState())).toEqual([ 'calltree', + 'function-list', 'flame-graph', 'stack-chart', 'marker-chart', diff --git a/src/test/unit/profile-data.test.ts b/src/test/unit/profile-data.test.ts index 7be0938fde..e3c5946432 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, @@ -1233,6 +1234,149 @@ describe('getSampleSelectedStates', function () { }); }); +describe('getSamplesSelectedStatesForFunction', function () { + function setup(textSamples: string) { + const { + derivedThreads, + funcNamesDictPerThread: [funcNamesDict], + } = getProfileFromTextSamples(textSamples); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + 0 + ); + const sampleCallNodes = getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ); + return { + callNodeTable: callNodeInfo.getCallNodeTable(), + sampleCallNodes, + funcNamesDict, + }; + } + + it('marks all non-filtered samples as selected when nothing is selected', function () { + const { callNodeTable, sampleCallNodes } = setup(` + A A A + B C + `); + expect( + Array.from( + getSamplesSelectedStatesForFunction( + sampleCallNodes, + null, + callNodeTable + ) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks samples as selected when their call stack contains the selected function', function () { + // 0 1 2 3 4 + // A A A A A + // B D B D D + // C E F G + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B, D }, + } = setup(` + A A A A A + B D B D D + C E F G + `); + + // Selecting function B: samples 0, 2 have B in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + + // Selecting function D: samples 1, 3, 4 have D in their stack + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, D, callNodeTable) + ) + ).toEqual([ + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.Selected, + SelectedState.Selected, + ]); + }); + + it('marks filtered-out samples as FilteredOutByTransform', function () { + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { B }, + } = setup(` + A A A + B C + `); + // Sample 2 has no stack (null), treated as filtered out. + // Manually null out sample 2's call node. + sampleCallNodes[2] = null; + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + SelectedState.FilteredOutByTransform, + ]); + }); + + it('selects samples whose ancestor call node contains the function, not just the leaf', function () { + // 0 1 + // A A + // B C + // D + // Selecting A should match all samples (A is an ancestor of everything). + const { + callNodeTable, + sampleCallNodes, + funcNamesDict: { A, B }, + } = setup(` + A A + B C + D + `); + + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, A, callNodeTable) + ) + ).toEqual([SelectedState.Selected, SelectedState.Selected]); + + // Selecting B only matches sample 0 (B is in the path A->B->D). + expect( + Array.from( + getSamplesSelectedStatesForFunction(sampleCallNodes, B, callNodeTable) + ) + ).toEqual([ + SelectedState.Selected, + SelectedState.UnselectedOrderedBeforeSelected, + ]); + }); +}); + describe('extractProfileFilterPageData', function () { const pages = { mozilla: { diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 566bedf105..de37b8d418 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -330,6 +330,7 @@ describe('unfiltered call tree', function () { name: 'A', self: '—', selfWithUnit: '—', + selfPercent: '0%', total: '3', totalWithUnit: '3 samples', totalPercent: '100%', @@ -346,6 +347,7 @@ describe('unfiltered call tree', function () { name: 'I', self: '1', selfWithUnit: '1 sample', + selfPercent: '33%', total: '1', totalWithUnit: '1 sample', totalPercent: '33%', diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts index ea910591fa..71669cd59f 100644 --- a/src/test/url-handling.test.ts +++ b/src/test/url-handling.test.ts @@ -23,6 +23,7 @@ import { changeIncludeIdleSamples, changeSelectedMarker, changeMarkerTableSort, + changeFunctionListSort, } from '../actions/profile-view'; import { changeSelectedTab, changeProfilesToCompare } from '../actions/app'; import { @@ -550,6 +551,57 @@ describe('marker table sort', function () { }); }); +describe('function list sort', function () { + function _getStoreOnFunctionList() { + const store = _getStoreWithURL(); + store.dispatch(changeSelectedTab('function-list')); + return store; + } + + it('omits the default sort from the URL', function () { + const { getState } = _getStoreOnFunctionList(); + expect(getQueryStringFromState(getState())).not.toContain( + 'functionListSort' + ); + }); + + it('serializes a non-default sort with primary first', function () { + const { getState, dispatch } = _getStoreOnFunctionList(); + // self desc primary, total asc tiebreaker (internal: primary last) + dispatch( + changeFunctionListSort([ + { column: 'total', ascending: true }, + { column: 'self', ascending: false }, + ]) + ); + expect(getQueryStringFromState(getState())).toContain( + 'functionListSort=self-desc~total-asc' + ); + }); + + it('round-trips a non-default sort through the URL', function () { + const { getState, dispatch } = _getStoreOnFunctionList(); + dispatch(changeFunctionListSort([{ column: 'self', ascending: false }])); + const url = urlFromState(getState().urlState); + const restored = stateFromLocation({ + pathname: new URL(url, 'http://localhost').pathname, + search: new URL(url, 'http://localhost').search, + hash: '', + }); + expect(restored.profileSpecific.functionListSort).toEqual([ + { column: 'self', ascending: false }, + ]); + }); + + it('falls back to the default when the URL has an invalid column', function () { + const { getState } = _getStoreWithURL({ + pathname: '/public/abc/function-list/', + search: '?functionListSort=bogus-desc', + }); + expect(getState().urlState.profileSpecific.functionListSort).toBeNull(); + }); +}); + describe('profileName', function () { it('serializes the profileName in the URL', function () { const { getState, dispatch } = _getStoreWithURL(); diff --git a/src/types/actions.ts b/src/types/actions.ts index fdde883ad1..0e4c8535ba 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -18,6 +18,7 @@ import type { FrameTable, SourceLocationTable, SourceTable, + IndexIntoFuncTable, } from './profile'; import type { Thread, @@ -192,6 +193,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; @@ -202,6 +209,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'; } @@ -563,6 +575,10 @@ type UrlStateAction = readonly type: 'CHANGE_MARKER_TABLE_SORT'; readonly sort: SingleColumnSortState[] | null; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SORT'; + readonly sort: SingleColumnSortState[] | null; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 6568b3b209..7b90f5a209 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -517,6 +517,7 @@ export type CallNodeDisplayData = Readonly<{ totalPercent: string; self: string; selfWithUnit: string; + selfPercent: string; name: string; lib: string; isFrameLabel: boolean; diff --git a/src/types/state.ts b/src/types/state.ts index 882e745990..e4b68063cb 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 { @@ -61,6 +62,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 @@ -83,6 +85,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; @@ -108,6 +115,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; @@ -385,6 +393,7 @@ export type ProfileSpecificUrlState = { legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; markerTableSort: SingleColumnSortState[] | null; + functionListSort: SingleColumnSortState[] | null; }; export type UrlState = { 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 7444fe9eb283e81e7ab653f55d7487083400fed1 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:52:56 -0400 Subject: [PATCH 03/13] 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 255fafba72..9a3f830dbc 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -166,7 +166,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 e5cee549573516e7060d8b3fc7af83635dcea0a9 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:02 -0400 Subject: [PATCH 04/13] Implement transform shortcut keys for function list --- src/actions/profile-view.ts | 94 ++++++---- src/components/calltree/FunctionList.tsx | 36 ++-- .../components/TransformShortcuts.test.tsx | 176 ++++++++++++++++++ 3 files changed, 260 insertions(+), 46 deletions(-) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 9a3f830dbc..2b8c27f8a0 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -2069,49 +2069,89 @@ export function handleCallNodeTransformShortcut( if (event.metaKey || event.ctrlKey || event.altKey) { return; } - const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); - const unfilteredThread = threadSelectors.getThread(getState()); - const implementation = getImplementationFilter(getState()); - const inverted = getInvertCallstack(getState()); - const callNodePath = callNodeInfo.getCallNodePathFromIndex(callNodeIndex); const funcIndex = callNodeInfo.funcForNode(callNodeIndex); - const category = callNodeInfo.categoryForNode(callNodeIndex); - - const callNodeTable = callNodeInfo.getCallNodeTable(); switch (event.key) { - case 'F': + case 'F': { + const callNodePath = + callNodeInfo.getCallNodePathFromIndex(callNodeIndex); + const implementation = getImplementationFilter(getState()); + const inverted = getInvertCallstack(getState()); dispatch( addTransformToStack(threadsKey, { type: 'focus-subtree', - callNodePath: callNodePath, + callNodePath, implementation, inverted, }) ); break; - case 'f': + } + case 'M': { + const callNodePath = + callNodeInfo.getCallNodePathFromIndex(callNodeIndex); + const implementation = getImplementationFilter(getState()); dispatch( addTransformToStack(threadsKey, { - type: 'focus-function', - funcIndex, + type: 'merge-call-node', + callNodePath, + implementation, }) ); break; - case 'S': + } + case 'g': { + const category = callNodeInfo.categoryForNode(callNodeIndex); dispatch( addTransformToStack(threadsKey, { - type: 'focus-self', + type: 'focus-category', + category, + }) + ); + break; + } + default: + dispatch( + handleFunctionTransformShortcut( + event, + threadsKey, + callNodeInfo, + funcIndex + ) + ); + } + }; +} + +export function handleFunctionTransformShortcut( + event: React.KeyboardEvent, + threadsKey: ThreadsKey, + callNodeInfo: CallNodeInfo, + funcIndex: IndexIntoFuncTable +): ThunkAction { + return (dispatch, getState) => { + if (event.metaKey || event.ctrlKey || event.altKey) { + return; + } + const threadSelectors = getThreadSelectorsFromThreadsKey(threadsKey); + 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, - implementation, }) ); break; - case 'M': + case 'S': dispatch( addTransformToStack(threadsKey, { - type: 'merge-call-node', - callNodePath: callNodePath, + type: 'focus-self', + funcIndex, implementation, }) ); @@ -2133,8 +2173,7 @@ export function handleCallNodeTransformShortcut( ); break; case 'C': { - const { funcTable } = unfilteredThread; - const resourceIndex = funcTable.resource[funcIndex]; + const resourceIndex = unfilteredThread.funcTable.resource[funcIndex]; dispatch( addCollapseResourceTransformToStack( threadsKey, @@ -2167,7 +2206,7 @@ export function handleCallNodeTransformShortcut( } break; } - case 'c': { + case 'c': dispatch( addTransformToStack(threadsKey, { type: 'collapse-function-subtree', @@ -2175,17 +2214,8 @@ export function handleCallNodeTransformShortcut( }) ); break; - } - case 'g': - dispatch( - addTransformToStack(threadsKey, { - type: 'focus-category', - category, - }) - ); - break; default: - // This did not match a call tree transform. + // This did not match a function transform. } }; } diff --git a/src/components/calltree/FunctionList.tsx b/src/components/calltree/FunctionList.tsx index c92105ea53..09d8b2e371 100644 --- a/src/components/calltree/FunctionList.tsx +++ b/src/components/calltree/FunctionList.tsx @@ -30,6 +30,7 @@ import { changeTableViewOptions, updateBottomBoxContentsAndMaybeOpen, changeFunctionListSort, + handleFunctionTransformShortcut, } from 'firefox-profiler/actions/profile-view'; import { nameColumn, @@ -47,6 +48,7 @@ import type { SelectionContext, } from 'firefox-profiler/types'; import type { CallTree } from 'firefox-profiler/profile-logic/call-tree'; +import type { CallNodeInfo } from 'firefox-profiler/profile-logic/call-node-info'; import type { SingleColumnSortState } from 'firefox-profiler/components/shared/TreeView'; import type { ConnectedProps } from 'firefox-profiler/utils/connect'; @@ -62,6 +64,7 @@ const DEFAULT_FUNCTION_LIST_SORT_STATE = new ColumnSortState( type StateProps = { readonly threadsKey: ThreadsKey; + readonly callNodeInfo: CallNodeInfo; readonly scrollToSelectionGeneration: number; readonly focusCallTreeGeneration: number; readonly tree: CallTree; @@ -79,6 +82,7 @@ type DispatchProps = { readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; readonly addTransformToStack: typeof addTransformToStack; readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly handleFunctionTransformShortcut: typeof handleFunctionTransformShortcut; readonly onTableViewOptionsChange: (opts: TableViewOptions) => any; readonly changeFunctionListSort: typeof changeFunctionListSort; }; @@ -167,20 +171,22 @@ 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, + callNodeInfo, + handleFunctionTransformShortcut, + } = this.props; + const funcIndex = + rightClickedFunctionIndex !== null + ? rightClickedFunctionIndex + : selectedFunctionIndex; + if (funcIndex === null) { + return; + } + handleFunctionTransformShortcut(event, threadsKey, callNodeInfo, funcIndex); }; _onEnterOrDoubleClick = (nodeId: IndexIntoFuncTable) => { @@ -238,6 +244,7 @@ class FunctionListImpl extends PureComponent { export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ mapStateToProps: (state: State) => ({ threadsKey: getSelectedThreadsKey(state), + callNodeInfo: selectedThreadSelectors.getCallNodeInfo(state), scrollToSelectionGeneration: getScrollToSelectionGeneration(state), focusCallTreeGeneration: getFocusCallTreeGeneration(state), tree: selectedThreadSelectors.getFunctionListTree(state), @@ -256,6 +263,7 @@ export const FunctionList = explicitConnect<{}, StateProps, DispatchProps>({ changeRightClickedFunctionIndex, addTransformToStack, updateBottomBoxContentsAndMaybeOpen, + handleFunctionTransformShortcut, onTableViewOptionsChange: (options: TableViewOptions) => changeTableViewOptions('calltree', options), changeFunctionListSort, 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 3bda13836693fed85cebd5ecbf120a7aca34014f Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:53:03 -0400 Subject: [PATCH 05/13] Select self function when clicking in the activity graph --- src/actions/profile-view.ts | 34 +++++++++++++++++++++++++ src/components/timeline/TrackThread.tsx | 5 ++++ 2 files changed, 39 insertions(+) diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 2b8c27f8a0..5d80b02a4f 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -236,6 +236,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/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 729dab5f8aef6f160ae668e3c2a56111fef248ca Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 06/13] lower wing support code --- src/profile-logic/call-tree.ts | 57 ++++++++++++++++++++++++++++++ src/test/fixtures/utils.ts | 50 ++++++++++++++++++++++++++ src/test/unit/profile-tree.test.ts | 42 ++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index e8dd595dca..ece0278d7c 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -889,6 +889,63 @@ export function computeCallTreeTimingsInverted( }; } +// Returns a "self" array indexed by non-inverted call node. Only root-most +// entries to selectedFuncIndex are non-zero, and at each one we store the +// *inclusive* time of that invocation (self + descendants). Feeding this +// through computeCallTreeTimingsInverted then attributes each invocation's +// inclusive time to the lower-wing node whose path is its caller chain. +function _computeLowerWingCallNodeSelf( + callNodeSelf: Float64Array, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable +): Float64Array { + 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; + // Skip nested re-entries of selectedFuncIndex; their time is already + // included in the outer subtree total above. + 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/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index d91e76e8fa..ac20c54b4e 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'; @@ -265,6 +266,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/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index de37b8d418..aad3511810 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -24,6 +24,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, addSourceToTable, @@ -579,6 +580,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([ From 46bee6c1b6c6d9d62c98ce1d3adb0c12edc2415b Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:09 -0400 Subject: [PATCH 07/13] Upper wing support code --- src/profile-logic/profile-data.ts | 439 ++++++++++++++++++++++++++++- src/test/fixtures/utils.ts | 50 ++++ src/test/unit/profile-tree.test.ts | 36 +++ 3 files changed, 513 insertions(+), 12 deletions(-) diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index 0611d64f49..c273481e08 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -184,7 +184,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( @@ -231,7 +234,6 @@ type CallNodeTableHierarchy = { // there are no call nodes. firstRoot: IndexIntoCallNodeTable; length: number; - stackIndexToCallNodeIndex: Int32Array; }; /** @@ -296,7 +298,7 @@ type CallNodeTableExtraColumns = { function _computeCallNodeTableHierarchy( stackTable: StackTable, frameTable: FrameTable -): CallNodeTableHierarchy { +): CallNodeTableHierarchy & { stackIndexToCallNodeIndex: Int32Array } { const stackIndexToCallNodeIndex = new Int32Array(stackTable.length); // The callNodeTable components. @@ -447,16 +449,10 @@ function _computeCallNodeTableHierarchy( * siblings as what's in the `hierarchy` argument.) */ function _computeCallNodeTableDFSOrder( - hierarchy: CallNodeTableHierarchy + hierarchy: CallNodeTableHierarchy, + stackIndexToCallNodeIndex: Int32Array ): CallNodeTableDFSOrder { - const { - prefix, - firstChild, - firstRoot, - nextSibling, - length, - stackIndexToCallNodeIndex, - } = hierarchy; + const { prefix, firstChild, firstRoot, nextSibling, length } = hierarchy; const prefixSorted = new Int32Array(length); const nextSiblingSorted = new Int32Array(length); @@ -550,6 +546,116 @@ function _computeCallNodeTableDFSOrder( }; } +// mutates originalCallNodeToCallNodeIndex +function _computeCallNodeTableDFSOrder2( + hierarchy: CallNodeTableHierarchy, + originalCallNodeToCallNodeIndex: Int32Array, + stackIndexToOriginalCallNodeIndex: Int32Array +): CallNodeTableDFSOrder { + const { prefix, firstChild, firstRoot, 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. + // Start at firstRoot because, with func-sorted siblings, the head of the + // roots' sibling list is not necessarily call node 0. + const oldIndexToNewIndex = new Uint32Array(length); + let nextOldIndex = firstRoot; + 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. * @@ -639,6 +745,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. */ @@ -4924,3 +5105,237 @@ 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); +} + +/** + * Like `_computeCallNodeTableHierarchy`, but iterates an existing call node + * table rather than the stack table, applying the upper-wing transform: every + * occurrence of `selectedFuncIndex` becomes a root, and original nodes outside + * a selected-func subtree are dropped. + * + * Multiple original nodes can collapse into one new node — every selected-func + * instance merges into a single root, and their descendants can further merge + * when they share a (mapped-prefix, func) pair. We therefore need the same + * "find existing sibling or insert in sorted order" logic the regular hierarchy + * builder uses, including the `lastUsed` hot path and early-out/late-enter on + * the sorted sibling lists. + */ +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, lastUsedChild[A] is the child of A that we most recently matched + // or inserted. It is -1 while A has no children. + const lastUsedChild: Array = []; + + // The root counterparts to firstChild / lastUsedChild for the "virtual" + // parent -1. + let firstRoot = -1; + let lastUsedRoot = -1; + + 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; + } + + const firstSibling = + prefixCallNode === -1 ? firstRoot : firstChild[prefixCallNode]; + + // Locate this (prefixCallNode, funcIndex) in the sorted sibling list. + // Either we find an existing match and reuse it, or we find the insertion + // point for a new node. When we need to insert, prevSibling is the node + // our new node should be linked after, or -1 if it should become the new + // head of the sibling list. + let callNodeIndex = -1; + let prevSibling = -1; // used for insertion, if callNodeIndex === -1 + + if (firstSibling !== -1) { + // Get the sibling that we used most recently for this parent. + // We know lastUsed is !== -1 because we know there is at least one sibling. + const lastUsed = + prefixCallNode === -1 ? lastUsedRoot : lastUsedChild[prefixCallNode]; + + if (funcIndex === func[lastUsed]) { + // Hot path: same func as the last child we touched for this parent. + callNodeIndex = lastUsed; + } else { + // We'll have to scan (at least part of) the list of siblings. + let sibling = firstSibling; + if (funcIndex > func[lastUsed]) { + // Since the list of siblings is ordered by func, we now know that can + // skip the part of the list that's before lastUsed. + // If lastUsed is the tail, sibling starts at -1 and we append without + // scanning. + prevSibling = lastUsed; + sibling = nextSibling[lastUsed]; + } + while (sibling !== -1) { + const siblingFunc = func[sibling]; + if (siblingFunc === funcIndex) { + // Found a match! + callNodeIndex = sibling; + break; + } + if (siblingFunc > funcIndex) { + // No match, and we can stop scanning here due to the ordering. + // We'll insert the new node before `sibling`; prevSibling is + // already its predecessor. + break; + } + prevSibling = sibling; + sibling = nextSibling[sibling]; + } + } + } + + if (callNodeIndex !== -1) { + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + if (prefixCallNode === -1) { + lastUsedRoot = callNodeIndex; + } else { + lastUsedChild[prefixCallNode] = callNodeIndex; + } + continue; + } + + // New call node. + callNodeIndex = length++; + originalCallNodeToCallNodeIndex[i] = callNodeIndex; + + prefix[callNodeIndex] = prefixCallNode; + func[callNodeIndex] = funcIndex; + firstChild[callNodeIndex] = -1; + lastUsedChild[callNodeIndex] = -1; + + // Splice the new node into the sibling list. + if (prevSibling === -1) { + // Insert at head. + nextSibling[callNodeIndex] = firstSibling; + if (prefixCallNode === -1) { + firstRoot = callNodeIndex; + } else { + firstChild[prefixCallNode] = callNodeIndex; + } + } else { + // Insert after prevSibling. + nextSibling[callNodeIndex] = nextSibling[prevSibling]; + nextSibling[prevSibling] = callNodeIndex; + } + + if (prefixCallNode === -1) { + lastUsedRoot = callNodeIndex; + } else { + lastUsedChild[prefixCallNode] = callNodeIndex; + } + } + + return { + prefix, + firstRoot, + 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/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index ac20c54b4e..95148b6a53 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'; @@ -266,6 +267,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/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index aad3511810..466996c7d1 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -24,6 +24,7 @@ import { ResourceType } from 'firefox-profiler/types'; import { callTreeFromProfile, functionListTreeFromProfile, + upperWingTreeFromProfile, lowerWingTreeFromProfile, formatTree, formatTreeIncludeCategories, @@ -580,6 +581,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 = ` From 0e31e10ddcc4abec06fd1de8eea87de9fece5234 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 08/13] LowerWingCallNodeInfo (data part) --- src/profile-logic/call-node-info.ts | 697 +++++++++++++++++++++++++++- src/profile-logic/call-tree.ts | 67 +-- src/profile-logic/profile-data.ts | 33 +- src/test/fixtures/utils.ts | 28 +- src/test/unit/profile-tree.test.ts | 95 ++++ 5 files changed, 873 insertions(+), 47 deletions(-) diff --git a/src/profile-logic/call-node-info.ts b/src/profile-logic/call-node-info.ts index 7cf3a8daf3..c524db0969 100644 --- a/src/profile-logic/call-node-info.ts +++ b/src/profile-logic/call-node-info.ts @@ -581,8 +581,60 @@ type ChildrenInfo = { export type SuffixOrderIndex = number; /** - * The CallNodeInfo implementation for the inverted tree, with additional - * functionality for the inverted call tree. + * The interface for the inverted-mode call node info, which extends + * CallNodeInfo with the additional functionality that consumers of inverted + * call trees need (suffix order, root identification, function count). + * + * Two implementations exist: + * - LazyInvertedCallNodeInfo, which has one root per func and materializes + * non-root nodes on demand. Used for the regular "invert call stack" mode + * and the function list tab, where any func can be a displayed root. + * - LowerWingCallNodeInfo, which is built eagerly from the entry points of + * a single selected function. Used for the lower wing. + */ +export interface CallNodeInfoInverted extends CallNodeInfo { + // Get a mapping SuffixOrderIndex -> IndexIntoCallNodeTable. + // The contents may be mutated by the implementation as inverted nodes get + // materialized on demand. Callers should not hold on to this array across + // calls that may create new inverted nodes (getChildren, getCallNodeIndexFromPath). + getSuffixOrderedCallNodes(): Uint32Array; + + // The inverse of getSuffixOrderedCallNodes: IndexIntoCallNodeTable -> SuffixOrderIndex. + // Same staleness caveat as above. + getSuffixOrderIndexes(): Uint32Array; + + // The [start, exclusiveEnd] suffix order index range for this inverted node. + getSuffixOrderIndexRangeForCallNode( + nodeHandle: IndexIntoCallNodeTable + ): [SuffixOrderIndex, SuffixOrderIndex]; + + // The number of functions in the func table this CNI was built from. + // (Sizes per-func scratch buffers in callers; not directly tied to the + // number of inverted-tree roots — see getRootCount.) + getFuncCount(): number; + + // The number of roots in the inverted tree. Root call node handles are + // 0..getRootCount()-1. For the full inverted tree this equals funcCount + // (one root per func); for a lower-wing CNI it is 1. + getRootCount(): number; + + // Returns the inverted root call node handle for `funcIndex`, or -1 if no + // root in this CNI corresponds to that function (e.g. a lower-wing CNI only + // has a root for its selected func). + getRootNodeForFunc( + funcIndex: IndexIntoFuncTable + ): IndexIntoCallNodeTable | -1; + + // True if the given node is a root of the inverted tree. + isRoot(nodeHandle: IndexIntoCallNodeTable): boolean; + + // Materialize and return the children of this inverted node. Sorted by func. + getChildren(nodeIndex: IndexIntoCallNodeTable): IndexIntoCallNodeTable[]; +} + +/** + * The lazy CallNodeInfoInverted implementation. It has one root per func and + * materializes non-root nodes on demand. * * # The Suffix Order * @@ -902,7 +954,7 @@ export type SuffixOrderIndex = number; * deep nodes. They're not needed anymore. So _takeDeepNodesForInvertedNode * nulls out the stored deepNodes for an inverted node when it's called. */ -export class CallNodeInfoInverted implements CallNodeInfo { +export class LazyInvertedCallNodeInfo implements CallNodeInfoInverted { // The non-inverted call node table. _callNodeTable: CallNodeTable; @@ -1020,13 +1072,23 @@ export class CallNodeInfoInverted implements CallNodeInfo { return this._suffixOrderIndexes; } - // Get the number of functions. There is one root per function. - // So this is also the number of roots at the same time. - // The inverted call node index for a root is the same as the function index. + // Get the number of functions. There is one root per function in this + // (full inverted) CNI, so it also equals getRootCount(). + // For roots, the inverted call node handle equals the function index. getFuncCount(): number { return this._rootCount; } + getRootCount(): number { + return this._rootCount; + } + + // For the full inverted tree, each func has its own root and the root + // handle equals the func index. + getRootNodeForFunc(funcIndex: IndexIntoFuncTable): InvertedCallNodeHandle { + return funcIndex; + } + // Returns whether the given node is a root node. isRoot(nodeHandle: InvertedCallNodeHandle): boolean { return nodeHandle < this._rootCount; @@ -1716,3 +1778,626 @@ export class CallNodeInfoInverted implements CallNodeInfo { ]; } } + +// The lower wing's inverted call node table. Index 0 is the selected-func +// root; indices 1..length-1 are non-root nodes. The public InvertedCallNodeHandle +// IS the table index (no translation): handle 0 is the root, handles +// 1..length-1 are non-roots. +// +// Layout note: rows are appended in BFS-by-inverted-depth order, with each +// parent's children emitted contiguously, sorted by func index. Index 0 is the +// root (depth 0); indices 1..length-1 have monotonically non-decreasing depth. +// +// The columns are plain `number[]` (not typed arrays). The CNI grows them by +// `push()` as the BFS extends; `getLowerWingTableUpToDepth(k)` returns a +// snapshot object that aliases the same backing arrays (no copy). Consumers +// must iterate `[0, length)` only — entries beyond `length` may be appended +// later as the BFS expands further, and `children[i]` for `i >= _nextK` is the +// shared empty placeholder. +export type LowerWingTable = { + prefix: number[]; // -> InvertedCallNodeHandle + func: number[]; // -> IndexIntoFuncTable + category: number[]; + subcategory: number[]; + innerWindowID: number[]; + sourceFramesInlinedIntoSymbol: number[]; + depth: number[]; + suffixOrderIndexRangeStart: number[]; + suffixOrderIndexRangeEnd: number[]; + children: Array; // Sorted by func + length: number; +}; + +/** + * Lazy, depth-incremental CallNodeInfoInverted for the lower wing. + * + * Mirrors LazyInvertedCallNodeInfo's role but only for the single selected + * function: the lower wing's "root" is the inverted node for `selectedFuncIndex`, + * and every other logical root is empty. Construction is cheap: only Pass 1 + * (entry-point collection) runs upfront. The rest of the BFS is resumable and + * runs on demand via `getLowerWingTableUpToDepth(k)` or any accessor that needs + * to read a node's children. + * + * Why lazy: + * - For deeply-nested selections (e.g. an allocator called from everywhere), + * the full inverted subtree can have thousands of rows. The flame graph + * only renders the rows currently in the viewport, so building deeper than + * necessary is wasted work. + * + * Handle scheme: + * - There is exactly one root, with handle === 0. Its func is `selectedFuncIndex` + * (or 0 when no selection — the row is then a placeholder with no children). + * - Non-root handles are 1..length-1 and equal their table index. Handles + * other than 0 only exist after the BFS has expanded to materialize them. + * - `getRootNodeForFunc(f)` returns 0 iff f === selectedFuncIndex, else -1. + * - `getSuffixOrderedCallNodes()` and `getSuffixOrderIndexes()` are kept in + * sync as the BFS refines partitions. + */ +export class LowerWingCallNodeInfo implements CallNodeInfoInverted { + _callNodeTable: CallNodeTable; + _stackIndexToNonInvertedCallNodeIndex: Int32Array; + _defaultCategory: IndexIntoCategoryList; + // Number of functions in the originating func table. Used to size per-func + // scratch buffers in `_extendToDepth`; unrelated to the number of roots + // (which is always 1 — see getRootCount). + _funcCount: number; + _selectedFuncIndex: IndexIntoFuncTable | null; + + // Suffix-ordered entry points: maps suffix order index -> non-inverted call + // node index. Length === number of entry points. Permuted in-place as the + // BFS refines partitions, so consumers that hold the reference across an + // extension see updated positions. The set of entries in any node's + // [soStart, soEnd) range stays invariant. + _selfNodes: Uint32Array; + + // Inverse mapping: non-inverted call node -> suffix order index. Length === + // non-inverted call node count. Non-entry-point nodes get 0xFFFFFFFF. Kept + // in sync with _selfNodes during every partition step. + _suffixOrderIndexes: Uint32Array; + + // Per-suffix-position "deep" cursor advanced by the BFS. `_deepNodes[i]` is + // the non-inverted ancestor of `_selfNodes[i]` at the inverted depth that + // has currently been expanded for that suffix-order slot — i.e. how far up + // the original (non-inverted) call chain we've walked for that entry. The + // BFS steps it via `prefixCol[deepNodes[i]]` once per row. -1 marks an + // entry whose chain bottomed out (no more callers to expand). + _deepNodes: Int32Array; + + // Column-store of the growing inverted call node table. Index 0 is the + // selected-func root; indices 1..length-1 are non-roots. `_tChildren` + // always has the same length as the other columns — entries for not-yet- + // processed nodes hold the shared empty placeholder, so the snapshot is + // safe to index at any valid table index. + _tPrefix: number[]; + _tFunc: number[]; + _tCategory: number[]; + _tSubcategory: number[]; + _tInnerWindowID: number[]; + _tInlinedInto: number[]; + _tDepth: number[]; + _tSoStart: number[]; + _tSoEnd: number[]; + _tChildren: Array; + + // BFS cursor. Nodes with table index < _nextK have been "processed" (their + // children at depth+1 have been emitted and `_tChildren[idx]` is final). + // Nodes with index >= _nextK exist in the table but their children entries + // still hold the placeholder. + _nextK: number; + + // Pre-allocated scratch buffers reused across every BFS iteration inside + // `_extendToDepth`. None of these carry state across iterations — each + // iteration writes the slice it needs before reading it back. + _scratchSelf: Uint32Array; + _scratchDeep: Int32Array; + _countPerFunc: Int32Array; + _funcToChildIdx: Int32Array; + _startPerChild: Uint32Array; + _writePerChild: Uint32Array; + + // Cached snapshot wrapper. Reused as long as the table length hasn't grown + // since it was built. + _cachedTable: LowerWingTable | null; + _cachedTableLength: number; + + constructor( + callNodeTable: CallNodeTable, + stackIndexToNonInvertedCallNodeIndex: Int32Array, + defaultCategory: IndexIntoCategoryList, + funcCount: number, + selectedFuncIndex: IndexIntoFuncTable | null + ) { + this._callNodeTable = callNodeTable; + this._stackIndexToNonInvertedCallNodeIndex = + stackIndexToNonInvertedCallNodeIndex; + this._defaultCategory = defaultCategory; + this._funcCount = funcCount; + this._selectedFuncIndex = selectedFuncIndex; + + // Pass 1: collect root-most non-inverted nodes whose func is the selected + // one. The subtree skip-ahead prevents nested re-entries from being + // counted twice (an `f` called by `f` only contributes once). + const callNodeCount = callNodeTable.length; + const funcCol = callNodeTable.func; + const entries: number[] = []; + if (selectedFuncIndex !== null) { + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + entries.push(i); + i = subtreeEndCol[i] - 1; + } + } + + const N = entries.length; + this._selfNodes = new Uint32Array(N); + this._deepNodes = new Int32Array(N); + this._suffixOrderIndexes = new Uint32Array(callNodeCount); + this._suffixOrderIndexes.fill(0xffffffff); + + // Resolve root attributes. With entries, scan them for category/subcategory/ + // inlined conflicts (same rule as a non-root). Without entries, fall back + // to the same defaults the old `_emptyLowerWingTree` used so accessor + // results don't change for empty trees. + let rootCategory: number; + let rootSubcategory: number; + let rootInnerWindowID: number; + let rootInlined: number; + let rootFunc: number; + if (N === 0) { + rootCategory = defaultCategory; + rootSubcategory = 0; + rootInnerWindowID = 0; + rootInlined = -2; + rootFunc = selectedFuncIndex ?? 0; + } else { + const categoryCol = callNodeTable.category; + const subcategoryCol = callNodeTable.subcategory; + const innerWindowIDCol = callNodeTable.innerWindowID; + const inlinedCol = callNodeTable.sourceFramesInlinedIntoSymbol; + const e0 = entries[0]; + rootCategory = categoryCol[e0]; + rootSubcategory = subcategoryCol[e0]; + rootInnerWindowID = innerWindowIDCol[e0]; + rootInlined = inlinedCol[e0]; + this._selfNodes[0] = e0; + this._deepNodes[0] = e0; + this._suffixOrderIndexes[e0] = 0; + for (let k = 1; k < N; k++) { + const e = entries[k]; + this._selfNodes[k] = e; + this._deepNodes[k] = e; + this._suffixOrderIndexes[e] = k; + if (categoryCol[e] !== rootCategory) { + rootCategory = defaultCategory; + rootSubcategory = 0; + } else if (subcategoryCol[e] !== rootSubcategory) { + rootSubcategory = 0; + } + if (inlinedCol[e] !== rootInlined) { + rootInlined = -1; + } + } + rootFunc = selectedFuncIndex as number; + } + + // Seed the table with the root row. `_tChildren[0]` starts as the empty + // placeholder and gets overwritten by `_processNode(0)` when the root is + // expanded. + this._tPrefix = [-1]; + this._tFunc = [rootFunc]; + this._tCategory = [rootCategory]; + this._tSubcategory = [rootSubcategory]; + this._tInnerWindowID = [rootInnerWindowID]; + this._tInlinedInto = [rootInlined]; + this._tDepth = [0]; + this._tSoStart = [0]; + this._tSoEnd = [N]; + this._tChildren = [EMPTY_LOWER_WING_CHILDREN]; + + this._scratchSelf = new Uint32Array(N); + this._scratchDeep = new Int32Array(N); + this._countPerFunc = new Int32Array(funcCount); + this._funcToChildIdx = new Int32Array(funcCount); + this._startPerChild = new Uint32Array(funcCount); + this._writePerChild = new Uint32Array(funcCount); + + this._nextK = 0; + this._cachedTable = null; + this._cachedTableLength = -1; + } + + isInverted(): boolean { + return true; + } + + asInverted(): CallNodeInfoInverted | null { + return this; + } + + getCallNodeTable(): CallNodeTable { + return this._callNodeTable; + } + + getStackIndexToNonInvertedCallNodeIndex(): Int32Array { + return this._stackIndexToNonInvertedCallNodeIndex; + } + + getSuffixOrderedCallNodes(): Uint32Array { + return this._selfNodes; + } + + getSuffixOrderIndexes(): Uint32Array { + return this._suffixOrderIndexes; + } + + getFuncCount(): number { + return this._funcCount; + } + + getRootCount(): number { + return 1; + } + + // Only the selected func has a root in this CNI. Other funcs return -1. + getRootNodeForFunc( + funcIndex: IndexIntoFuncTable + ): InvertedCallNodeHandle | -1 { + return funcIndex === this._selectedFuncIndex ? 0 : -1; + } + + isRoot(nodeHandle: InvertedCallNodeHandle): boolean { + return nodeHandle === 0; + } + + getSuffixOrderIndexRangeForCallNode( + nodeHandle: InvertedCallNodeHandle + ): [SuffixOrderIndex, SuffixOrderIndex] { + return [this._tSoStart[nodeHandle], this._tSoEnd[nodeHandle]]; + } + + getChildren(nodeIndex: InvertedCallNodeHandle): InvertedCallNodeHandle[] { + // Extend the BFS far enough for this node to have its children + // materialized (depth 0 for the root, depth d for a node at depth d). + this._extendToDepth(this._tDepth[nodeIndex]); + return this._tChildren[nodeIndex]; + } + + getCallNodePathFromIndex( + callNodeHandle: InvertedCallNodeHandle | null + ): CallNodePath { + if (callNodeHandle === null || callNodeHandle === -1) { + return []; + } + const callNodePath: number[] = []; + let current = callNodeHandle; + while (current !== 0) { + callNodePath.push(this._tFunc[current]); + current = this._tPrefix[current]; + } + callNodePath.push(this._tFunc[0]); + callNodePath.reverse(); + return callNodePath; + } + + getCallNodeIndexFromPath( + callNodePath: CallNodePath + ): InvertedCallNodeHandle | null { + if (callNodePath.length === 0) { + return null; + } + // The only root in this CNI is for the selected func. Any path that doesn't + // start there has no match. + if (callNodePath[0] !== this._selectedFuncIndex) { + return null; + } + let handle: InvertedCallNodeHandle = 0; + for (let i = 1; i < callNodePath.length; i++) { + const next = this.getCallNodeIndexFromParentAndFunc( + handle, + callNodePath[i] + ); + if (next === null) { + return null; + } + handle = next; + } + return handle; + } + + getCallNodeIndexFromParentAndFunc( + parent: InvertedCallNodeHandle | -1, + func: IndexIntoFuncTable + ): InvertedCallNodeHandle | null { + if (parent === -1) { + return this.getRootNodeForFunc(func) === -1 ? null : 0; + } + const children = this.getChildren(parent); + // Children are sorted by func; bisect to find a match. + const index = bisectionRightByKey(children, func, (node) => + this.funcForNode(node) + ); + if (index === 0) { + return null; + } + const candidate = children[index - 1]; + return this.funcForNode(candidate) === func ? candidate : null; + } + + prefixForNode( + callNodeHandle: InvertedCallNodeHandle + ): InvertedCallNodeHandle | -1 { + return this._tPrefix[callNodeHandle]; + } + + funcForNode(callNodeHandle: InvertedCallNodeHandle): IndexIntoFuncTable { + return this._tFunc[callNodeHandle]; + } + + categoryForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoCategoryList { + return this._tCategory[callNodeHandle]; + } + + subcategoryForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoSubcategoryListForCategory { + return this._tSubcategory[callNodeHandle]; + } + + innerWindowIDForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoCategoryList { + return this._tInnerWindowID[callNodeHandle]; + } + + depthForNode(callNodeHandle: InvertedCallNodeHandle): number { + return this._tDepth[callNodeHandle]; + } + + sourceFramesInlinedIntoSymbolForNode( + callNodeHandle: InvertedCallNodeHandle + ): IndexIntoNativeSymbolTable | -1 | -2 { + return this._tInlinedInto[callNodeHandle] as + | IndexIntoNativeSymbolTable + | -1 + | -2; + } + + // Returns a snapshot of the lower-wing inverted call node table containing + // at least every node with inverted depth <= `targetDepth`. The snapshot may + // also include nodes at `targetDepth + 1` (children emitted when the deepest + // requested level is processed) — callers must iterate up to `length`. + // + // Nodes whose `children` array is the shared `EMPTY_LOWER_WING_CHILDREN` + // placeholder are those that haven't been processed yet. They sit at + // `targetDepth + 1` (or are true leaves whose entry chains bottomed out). + // Calling `getLowerWingTableUpToDepth(k')` for any k' >= their depth will + // expand them on the next call. + getLowerWingTableUpToDepth(targetDepth: number): LowerWingTable { + this._extendToDepth(targetDepth); + return this._getSnapshotTable(); + } + + // Run the BFS while the next queued node sits at depth <= `targetDepth`. + // Nodes at depth `targetDepth + 1` may be added to the table by the final + // iteration but are not themselves processed (their `_tChildren[idx]` keeps + // the placeholder). + // + // The body that processes a single node (Pass 1 — bucket-count entries by + // their next ancestor's func; Pass 2 — scatter entries into scratch and + // copy back; Pass 3 — emit one child row per distinct func) is inlined here + // so all the `this.*` column references and scratch buffers are hoisted to + // locals once per BFS run rather than once per processed node. The local + // references stay valid across the loop because each column is a JS array + // grown in place via `push()` — the backing array identity doesn't change. + _extendToDepth(targetDepth: number): void { + const tPrefix = this._tPrefix; + const tFunc = this._tFunc; + const tCategory = this._tCategory; + const tSubcategory = this._tSubcategory; + const tInnerWindowID = this._tInnerWindowID; + const tInlinedInto = this._tInlinedInto; + const tDepth = this._tDepth; + const tSoStart = this._tSoStart; + const tSoEnd = this._tSoEnd; + const tChildren = this._tChildren; + + const selfNodes = this._selfNodes; + const deepNodes = this._deepNodes; + const suffixOrderIndexes = this._suffixOrderIndexes; + const callNodeTable = this._callNodeTable; + const funcCol = callNodeTable.func; + const prefixCol = callNodeTable.prefix; + const categoryCol = callNodeTable.category; + const subcategoryCol = callNodeTable.subcategory; + const innerWindowIDCol = callNodeTable.innerWindowID; + const inlinedCol = callNodeTable.sourceFramesInlinedIntoSymbol; + const countPerFunc = this._countPerFunc; + const funcToChildIdx = this._funcToChildIdx; + const startPerChild = this._startPerChild; + const writePerChild = this._writePerChild; + const scratchSelf = this._scratchSelf; + const scratchDeep = this._scratchDeep; + const defaultCategory = this._defaultCategory; + + // Scratch list of distinct funcs seen in Pass 1 of the current node. + // Cleared at the top of each iteration. + const childFuncs: number[] = []; + + let k = this._nextK; + while (k < tFunc.length) { + if (tDepth[k] > targetDepth) { + break; + } + + const parentSuffixStart = tSoStart[k]; + const parentSuffixEnd = tSoEnd[k]; + const parentDepth = tDepth[k]; + // Public handle === table index. + const parentHandle = k; + + // Pass 1: walk each entry's deepNode one step up, bucket-counting by + // the new deepNode's func. Entries whose chain bottoms out are tallied + // as leaves and stay at the parent. + childFuncs.length = 0; + let leafCount = 0; + for (let i = parentSuffixStart; i < parentSuffixEnd; i++) { + const newDeep = prefixCol[deepNodes[i]]; + if (newDeep === -1) { + leafCount++; + continue; + } + const f = funcCol[newDeep]; + if (countPerFunc[f] === 0) { + childFuncs.push(f); + } + countPerFunc[f]++; + } + + const childCount = childFuncs.length; + if (childCount === 0) { + // No children — leave the placeholder we seeded at child-emit time + // (or for the root in the constructor) in place. + k++; + continue; + } + + childFuncs.sort((a, b) => a - b); + + // Compute each child's absolute [start, end) range, leaves first then + // per-func partitions in sorted order. + let cursor = parentSuffixStart + leafCount; + for (let c = 0; c < childCount; c++) { + const f = childFuncs[c]; + funcToChildIdx[f] = c + 1; // 1-based; 0 means "unset" + startPerChild[c] = cursor; + writePerChild[c] = cursor; + cursor += countPerFunc[f]; + countPerFunc[f] = 0; + } + + // Pass 2: scatter (self, deep) pairs into scratch, then copy back — + // updating `_suffixOrderIndexes` so the inverse mapping stays in sync. + let leafWrite = parentSuffixStart; + for (let i = parentSuffixStart; i < parentSuffixEnd; i++) { + const self = selfNodes[i]; + const newDeep = prefixCol[deepNodes[i]]; + let writeAt; + let storeDeep; + if (newDeep === -1) { + writeAt = leafWrite++; + storeDeep = -1; + } else { + const c = funcToChildIdx[funcCol[newDeep]] - 1; + writeAt = writePerChild[c]++; + storeDeep = newDeep; + } + const local = writeAt - parentSuffixStart; + scratchSelf[local] = self; + scratchDeep[local] = storeDeep; + } + const len = parentSuffixEnd - parentSuffixStart; + for (let i = 0; i < len; i++) { + const pos = parentSuffixStart + i; + const ns = scratchSelf[i]; + selfNodes[pos] = ns; + deepNodes[pos] = scratchDeep[i]; + suffixOrderIndexes[ns] = pos; + } + + for (let i = 0; i < childCount; i++) { + funcToChildIdx[childFuncs[i]] = 0; + } + + // Pass 3: emit one child row per distinct func. Each child gets a + // placeholder `_tChildren` slot so column lengths stay aligned; the + // placeholder is overwritten when the child is itself processed. + const childHandles: InvertedCallNodeHandle[] = new Array(childCount); + const childDepth = parentDepth + 1; + for (let c = 0; c < childCount; c++) { + const f = childFuncs[c]; + const start = startPerChild[c]; + const end = writePerChild[c]; + const first = deepNodes[start]; + let category = categoryCol[first]; + let subcategory = subcategoryCol[first]; + const innerWindowID = innerWindowIDCol[first]; + let inlined = inlinedCol[first]; + for (let j = start + 1; j < end; j++) { + const dn = deepNodes[j]; + if (categoryCol[dn] !== category) { + category = defaultCategory; + subcategory = 0; + } else if (subcategoryCol[dn] !== subcategory) { + subcategory = 0; + } + if (inlinedCol[dn] !== inlined) { + inlined = -1; + } + } + + // Public handle === table index, which is the slot the child row is + // about to be pushed into. + const newHandle = tPrefix.length; + tPrefix.push(parentHandle); + tFunc.push(f); + tCategory.push(category); + tSubcategory.push(subcategory); + tInnerWindowID.push(innerWindowID); + tInlinedInto.push(inlined); + tDepth.push(childDepth); + tSoStart.push(start); + tSoEnd.push(end); + tChildren.push(EMPTY_LOWER_WING_CHILDREN); + + childHandles[c] = newHandle; + } + tChildren[k] = childHandles; + + k++; + } + this._nextK = k; + } + + // Return a snapshot view of the current table. The column fields alias the + // CNI's live backing arrays directly — no copy, no typed-array conversion. + // The arrays only grow by `push()` and `_tChildren[k]` is overwritten in + // place from the placeholder to the node's real children when the node is + // processed; in both cases the prefix `[0, length)` observed at snapshot + // time stays stable, so consumers iterating that prefix see consistent + // data even if the BFS extends further. + // + // The wrapper object itself is cached and reused as long as `length` hasn't + // grown since the last call — this keeps `===` identity stable for selector + // memoization downstream. + _getSnapshotTable(): LowerWingTable { + const length = this._tFunc.length; + if (this._cachedTable !== null && this._cachedTableLength === length) { + return this._cachedTable; + } + const table: LowerWingTable = { + prefix: this._tPrefix, + func: this._tFunc, + category: this._tCategory, + subcategory: this._tSubcategory, + innerWindowID: this._tInnerWindowID, + sourceFramesInlinedIntoSymbol: this._tInlinedInto, + depth: this._tDepth, + suffixOrderIndexRangeStart: this._tSoStart, + suffixOrderIndexRangeEnd: this._tSoEnd, + children: this._tChildren, + length, + }; + this._cachedTable = table; + this._cachedTableLength = length; + return table; + } +} + +// Shared empty children array for unused roots. Returned by reference; callers +// must not mutate. +const EMPTY_LOWER_WING_CHILDREN: InvertedCallNodeHandle[] = []; diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index ece0278d7c..82c78ffc62 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -66,9 +66,15 @@ export type InvertedCallTreeRoot = { export type CallTreeTimingsInverted = { callNodeSelf: Float64Array; rootTotalSummary: number; - sortedRoots: IndexIntoFuncTable[]; - totalPerRootFunc: Float64Array; - hasChildrenPerRootFunc: Uint8Array; + // Inverted-tree root call node indices, sorted by their total descending. + // For LazyInvertedCallNodeInfo these happen to equal func indices; for other + // CNIs (e.g. lower-wing) they're whatever index the CNI uses for its root. + sortedRoots: IndexIntoCallNodeTable[]; + // Indexed by inverted-tree root call node index (i.e. 0..rootCount-1, where + // rootCount = callNodeInfo.getRootCount()). For the standard inverted tree, + // root index === func index; for a lower-wing CNI, the array has length 1. + totalPerRootNode: Float64Array; + hasChildrenPerRootNode: Uint8Array; }; export type CallTreeTimingsFunctionList = { @@ -248,8 +254,8 @@ class CallTreeInternalInverted implements CallTreeInternal { _callNodeTable: CallNodeTable; _callNodeSelf: Float64Array; _rootNodes: IndexIntoCallNodeTable[]; - _totalPerRootFunc: Float64Array; - _hasChildrenPerRootFunc: Uint8Array; + _totalPerRootNode: Float64Array; + _hasChildrenPerRootNode: Uint8Array; _totalAndHasChildrenPerNonRootNode: Map< IndexIntoCallNodeTable, TotalAndHasChildren @@ -262,10 +268,10 @@ class CallTreeInternalInverted implements CallTreeInternal { this._callNodeInfo = callNodeInfo; this._callNodeTable = callNodeInfo.getCallNodeTable(); this._callNodeSelf = callTreeTimingsInverted.callNodeSelf; - const { sortedRoots, totalPerRootFunc, hasChildrenPerRootFunc } = + const { sortedRoots, totalPerRootNode, hasChildrenPerRootNode } = callTreeTimingsInverted; - this._totalPerRootFunc = totalPerRootFunc; - this._hasChildrenPerRootFunc = hasChildrenPerRootFunc; + this._totalPerRootNode = totalPerRootNode; + this._hasChildrenPerRootNode = hasChildrenPerRootNode; this._rootNodes = sortedRoots; } @@ -275,7 +281,7 @@ class CallTreeInternalInverted implements CallTreeInternal { hasChildren(callNodeIndex: IndexIntoCallNodeTable): boolean { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._hasChildrenPerRootFunc[callNodeIndex] !== 0; + return this._hasChildrenPerRootNode[callNodeIndex] !== 0; } return this._getTotalAndHasChildren(callNodeIndex).hasChildren; } @@ -301,14 +307,14 @@ class CallTreeInternalInverted implements CallTreeInternal { getSelf(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._totalPerRootFunc[callNodeIndex]; + return this._totalPerRootNode[callNodeIndex]; } return 0; } getTotal(callNodeIndex: IndexIntoCallNodeTable): number { if (this._callNodeInfo.isRoot(callNodeIndex)) { - return this._totalPerRootFunc[callNodeIndex]; + return this._totalPerRootNode[callNodeIndex]; } const { total } = this._getTotalAndHasChildren(callNodeIndex); return total; @@ -779,9 +785,9 @@ export function getSelfAndTotalForCallNode( case 'INVERTED': { const callNodeInfoInverted = ensureExists(callNodeInfo.asInverted()); const { timings } = callTreeTimings; - const { callNodeSelf, totalPerRootFunc } = timings; + const { callNodeSelf, totalPerRootNode } = timings; if (callNodeInfoInverted.isRoot(callNodeIndex)) { - const total = totalPerRootFunc[callNodeIndex]; + const total = totalPerRootNode[callNodeIndex]; return { self: total, total }; } const { total } = _getInvertedTreeNodeTotalAndHasChildren( @@ -849,14 +855,14 @@ export function computeCallTreeTimingsInverted( callNodeInfo: CallNodeInfoInverted, { callNodeSelf, rootTotalSummary }: CallNodeSelfAndSummary ): CallTreeTimingsInverted { - const funcCount = callNodeInfo.getFuncCount(); + const rootCount = callNodeInfo.getRootCount(); const callNodeTable = callNodeInfo.getCallNodeTable(); const callNodeTableFuncCol = callNodeTable.func; const callNodeTableDepthCol = callNodeTable.depth; - const totalPerRootFunc = new Float64Array(funcCount); - const hasChildrenPerRootFunc = new Uint8Array(funcCount); - const seenPerRootFunc = new Uint8Array(funcCount); - const sortedRoots = []; + const totalPerRootNode = new Float64Array(rootCount); + const hasChildrenPerRootNode = new Uint8Array(rootCount); + const seenPerRootNode = new Uint8Array(rootCount); + const sortedRoots: IndexIntoCallNodeTable[] = []; for (let i = 0; i < callNodeSelf.length; i++) { const self = callNodeSelf[i]; if (self === 0) { @@ -864,28 +870,33 @@ export function computeCallTreeTimingsInverted( } // Map the non-inverted call node to its corresponding root in the inverted - // call tree. This is done by finding the inverted root which corresponds to - // the self function of the non-inverted call node. + // call tree, via the CNI. For the full inverted tree this is the identity + // on func index; for a lower-wing CNI only one func has a root, so other + // funcs return -1 and are skipped here. const func = callNodeTableFuncCol[i]; + const rootNode = callNodeInfo.getRootNodeForFunc(func); + if (rootNode === -1) { + continue; + } - totalPerRootFunc[func] += self; - if (seenPerRootFunc[func] === 0) { - seenPerRootFunc[func] = 1; - sortedRoots.push(func); + totalPerRootNode[rootNode] += self; + if (seenPerRootNode[rootNode] === 0) { + seenPerRootNode[rootNode] = 1; + sortedRoots.push(rootNode); } if (callNodeTableDepthCol[i] !== 0) { - hasChildrenPerRootFunc[func] = 1; + hasChildrenPerRootNode[rootNode] = 1; } } sortedRoots.sort( - (a, b) => Math.abs(totalPerRootFunc[b]) - Math.abs(totalPerRootFunc[a]) + (a, b) => Math.abs(totalPerRootNode[b]) - Math.abs(totalPerRootNode[a]) ); return { callNodeSelf, rootTotalSummary, sortedRoots, - totalPerRootFunc, - hasChildrenPerRootFunc, + totalPerRootNode, + hasChildrenPerRootNode, }; } diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index c273481e08..aad2f2da37 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -14,7 +14,8 @@ import { } from './data-structures'; import { CallNodeInfoNonInverted, - CallNodeInfoInverted, + LazyInvertedCallNodeInfo, + LowerWingCallNodeInfo, } from './call-node-info'; import { computeThreadCPUPercent } from './cpu'; import { @@ -105,7 +106,11 @@ import type { SourceLocationTable, } from 'firefox-profiler/types'; import { SelectedState, ResourceType } from 'firefox-profiler/types'; -import type { CallNodeInfo, SuffixOrderIndex } from './call-node-info'; +import type { + CallNodeInfo, + CallNodeInfoInverted, + SuffixOrderIndex, +} from './call-node-info'; /** * Various helpers for dealing with the profile as a data structure. @@ -828,7 +833,7 @@ export function getInvertedCallNodeInfo( defaultCategory: IndexIntoCategoryList, funcCount: number ): CallNodeInfoInverted { - return new CallNodeInfoInverted( + return new LazyInvertedCallNodeInfo( callNodeInfo.getCallNodeTable(), callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(), defaultCategory, @@ -836,6 +841,28 @@ export function getInvertedCallNodeInfo( ); } +/** + * Generate the lower wing CallNodeInfo for a thread and a selected function. + * + * Unlike getInvertedCallNodeInfo, this builds the entire inverted subtree + * upfront, restricted to the selected function's entry points. It's the + * dedicated structure for the "lower wing" view. + */ +export function getLowerWingCallNodeInfo( + callNodeInfo: CallNodeInfo, + defaultCategory: IndexIntoCategoryList, + funcCount: number, + selectedFuncIndex: IndexIntoFuncTable | null +): CallNodeInfoInverted { + return new LowerWingCallNodeInfo( + callNodeInfo.getCallNodeTable(), + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(), + defaultCategory, + funcCount, + selectedFuncIndex + ); +} + // Compare two non-inverted call nodes in "suffix order". // The suffix order is defined as the lexicographical order of the inverted call // path, or, in other words, the "backwards" lexicographical order of the diff --git a/src/test/fixtures/utils.ts b/src/test/fixtures/utils.ts index 95148b6a53..3eee0cad25 100644 --- a/src/test/fixtures/utils.ts +++ b/src/test/fixtures/utils.ts @@ -15,6 +15,7 @@ import { computeCallNodeFuncIsDuplicate, getCallNodeInfo, getInvertedCallNodeInfo, + getLowerWingCallNodeInfo, getSampleIndexToCallNodeIndex, getOriginAnnotationForFunc, createThreadFromDerivedTables, @@ -320,10 +321,13 @@ export function upperWingTreeFromProfile( * 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. + * + * Pass `null` (or a name that does not exist in the thread) to exercise the + * "no function selected" path. */ export function lowerWingTreeFromProfile( profile: Profile, - selectedFuncName: string, + selectedFuncName: string | null, threadIndex: number = 0 ): CallTree { const { derivedThreads, defaultCategory } = getProfileWithDicts(profile); @@ -333,15 +337,19 @@ export function lowerWingTreeFromProfile( thread.frameTable, defaultCategory ); - const invertedCallNodeInfo = getInvertedCallNodeInfo( + const selectedFunc = + selectedFuncName === null + ? -1 + : (thread.funcTable.name.findIndex( + (i) => thread.stringTable.getString(i) === selectedFuncName + ) ?? -1); + const selectedFuncIndex = selectedFunc === -1 ? null : selectedFunc; + const lowerWingCallNodeInfo = getLowerWingCallNodeInfo( callNodeInfo, defaultCategory, - thread.funcTable.length + thread.funcTable.length, + selectedFuncIndex ); - const selectedFunc = - thread.funcTable.name.findIndex( - (i) => thread.stringTable.getString(i) === selectedFuncName - ) ?? null; const selfAndSummary = computeCallNodeSelfAndSummary( thread.samples, getSampleIndexToCallNodeIndex( @@ -351,13 +359,13 @@ export function lowerWingTreeFromProfile( callNodeInfo.getCallNodeTable().length ); const timings = computeLowerWingTimings( - invertedCallNodeInfo, + lowerWingCallNodeInfo, selfAndSummary, - selectedFunc === -1 ? null : selectedFunc + selectedFuncIndex ); return getCallTree( thread, - invertedCallNodeInfo, + lowerWingCallNodeInfo, ensureExists(profile.meta.categories), thread.samples, timings, diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 466996c7d1..e9181bd6fe 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -655,6 +655,101 @@ describe('lower wing', function () { const callTree = lowerWingTreeFromProfile(profile, 'NONEXISTENT'); expect(formatTree(callTree)).toEqual([]); }); + + it('returns an empty tree when null is passed', function () { + // Exercises the `selectedFuncIndex === null` early return in + // _buildLowerWingTree. + const { profile } = getProfileFromTextSamples(textSamples); + const callTree = lowerWingTreeFromProfile(profile, null); + expect(formatTree(callTree)).toEqual([]); + }); + + it('does not double-count nested re-entries of the selected function', function () { + // C calls itself: A->C->B->C. The inner C must not contribute a second + // entry point — the outer C's entry already covers the whole stack. This + // exercises the subtree skip-ahead at the `entries` collection loop. + const { profile } = getProfileFromTextSamples(` + A + C + B + C + `); + const callTree = lowerWingTreeFromProfile(profile, 'C'); + expect(formatTree(callTree)).toEqual([ + '- C (total: 1, self: 1)', + ' - A (total: 1, self: —)', + ]); + }); + + it('sorts caller children by func index', function () { + // Three distinct callers of X. Func indices follow column-major discovery + // order: Z=0, X=1, A=2, M=3. The lower wing must list the children sorted + // by func index (Z, then A, then M), not by stack iteration order. + const { profile } = getProfileFromTextSamples(` + Z A M + X X X + `); + const callTree = lowerWingTreeFromProfile(profile, 'X'); + expect(formatTree(callTree)).toEqual([ + '- X (total: 3, self: 3)', + ' - Z (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - M (total: 1, self: —)', + ]); + }); + + it('partitions callers across multiple depths', function () { + // Two stacks share a depth-4 ancestor chain through D. Exercising the + // partition loop past depth 1 ensures the suffix-ordered ranges and + // per-iteration scratch buffers behave correctly across iterations. + const { profile } = getProfileFromTextSamples(` + A A + B E + C C + D D + `); + const callTree = lowerWingTreeFromProfile(profile, 'D'); + expect(formatTree(callTree)).toEqual([ + '- D (total: 2, self: 2)', + ' - C (total: 2, self: —)', + ' - B (total: 1, self: —)', + ' - A (total: 1, self: —)', + ' - E (total: 1, self: —)', + ' - A (total: 1, self: —)', + ]); + }); + + it('attributes self time to a parent when an entry runs out of ancestors mid-walk', function () { + // Two entry points for X: one is at the top of its stack (no ancestor), + // the other has B above it. At root X the first entry is a "leaf" in the + // ancestor walk (newDeep === -1) and contributes to X's self only; the + // second contributes to child B. + const { profile } = getProfileFromTextSamples(` + X B + _ X + `); + const callTree = lowerWingTreeFromProfile(profile, 'X'); + expect(formatTree(callTree)).toEqual([ + '- X (total: 2, self: 2)', + ' - B (total: 1, self: —)', + ]); + }); + + it('falls back to the default category when entry points disagree', function () { + // Two entry points for C with conflicting categories. The non-inverted + // call nodes have different categories (Graphics vs DOM), and the lower + // wing root for C must resolve to the default category ('Other'). + const { profile } = getProfileFromTextSamples(` + A B + C[cat:Graphics] C[cat:DOM] + `); + const callTree = lowerWingTreeFromProfile(profile, 'C'); + expect(formatTreeIncludeCategories(callTree)).toEqual([ + '- C [Other] (total: 2, self: 2)', + ' - A [Other] (total: 1, self: —)', + ' - B [Other] (total: 1, self: —)', + ]); + }); }); describe('diffing trees', function () { From bfdc843c5f27447be7752074d3ee4fca1990cfaa Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Wed, 24 Jun 2026 16:24:30 -0400 Subject: [PATCH 09/13] Move flame graph tooltip percentage computation into a FlameGraphTimings method. --- src/components/flame-graph/Canvas.tsx | 8 ++++---- src/profile-logic/flame-graph.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/flame-graph/Canvas.tsx b/src/components/flame-graph/Canvas.tsx index 4fd54e7042..564b57d0d2 100644 --- a/src/components/flame-graph/Canvas.tsx +++ b/src/components/flame-graph/Canvas.tsx @@ -391,10 +391,10 @@ class FlameGraphCanvasImpl extends React.PureComponent { return null; } - const ratio = - stackTiming.end[flameGraphTimingIndex] - - stackTiming.start[flameGraphTimingIndex]; - + const ratio = flameGraphTiming.getRatioOfRootTotalSummary( + depth, + flameGraphTimingIndex + ); let percentage = formatPercent(ratio); if (tracedTiming) { const time = formatCallNodeNumberWithUnit( diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 14f118e843..36d292f355 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -94,6 +94,20 @@ export class FlameGraphTiming { return rows; } + // For a given node / "box", get the sample percentage (as a fraction of 1) that + // should be displayed in the tooltip for this node. + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { + const row = this.getRow(depth); + if (indexInRow < 0 || indexInRow >= row.length) { + throw new Error( + `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}` + ); + } + + const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; + return ratioOfFullWidth; + } + _buildNextTimingRow(): void { const { total, self, rootTotalSummary } = this._callTreeTimings; const { prefix } = this._callNodeTable; From 52df964913ddf9bc3bbb9a6c87163e324fa906f5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:11 -0400 Subject: [PATCH 10/13] Restructure FlameGraphTiming and CallTreeTimings to support subtree-scoped percentages. --- src/profile-logic/call-tree.ts | 18 +++++++++++++++--- src/profile-logic/flame-graph.ts | 9 +++++---- .../__snapshots__/profile-view.test.ts.snap | 2 ++ src/test/unit/profile-tree.test.ts | 1 + src/types/profile-derived.ts | 6 ++++++ 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 82c78ffc62..6c63dffcbd 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -54,6 +54,7 @@ export type CallTreeTimingsNonInverted = { self: Float64Array; total: Float64Array; rootTotalSummary: number; // sum of absolute values, this is used for computing percentages + flameGraphWidthTotal: number; // used as 100% reference for flame graph box widths }; type TotalAndHasChildren = { total: number; hasChildren: boolean }; @@ -756,7 +757,11 @@ export function computeCallNodeSelfAndSummary( rootTotalSummary += abs(callNodeSelf[callNodeIndex]); } - return { callNodeSelf, rootTotalSummary }; + return { + callNodeSelf, + rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, + }; } export function getSelfAndTotalForCallNode( @@ -953,6 +958,7 @@ export function computeLowerWingTimings( timings: computeCallTreeTimingsInverted(callNodeInfo, { callNodeSelf: mappedSelf, rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, }), }; } @@ -989,7 +995,8 @@ export function computeCallTreeTimingsNonInverted( callNodeSelfAndSummary: CallNodeSelfAndSummary ): CallTreeTimingsNonInverted { const callNodeTable = callNodeInfo.getCallNodeTable(); - const { callNodeSelf, rootTotalSummary } = callNodeSelfAndSummary; + const { callNodeSelf, rootTotalSummary, flameGraphWidthTotal } = + callNodeSelfAndSummary; // Compute the following variables: const callNodeTotal = new Float64Array(callNodeTable.length); @@ -1027,6 +1034,7 @@ export function computeCallTreeTimingsNonInverted( total: callNodeTotal, callNodeHasChildren, rootTotalSummary, + flameGraphWidthTotal, }; } @@ -1429,5 +1437,9 @@ export function computeCallNodeTracedSelfAndSummary( } } - return { callNodeSelf, rootTotalSummary }; + return { + callNodeSelf, + rootTotalSummary, + flameGraphWidthTotal: rootTotalSummary, + }; } diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 36d292f355..25a8e6961d 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -105,11 +105,12 @@ export class FlameGraphTiming { } const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; - return ratioOfFullWidth; + const total = ratioOfFullWidth * this._callTreeTimings.flameGraphWidthTotal; + return total / this._callTreeTimings.rootTotalSummary; } _buildNextTimingRow(): void { - const { total, self, rootTotalSummary } = this._callTreeTimings; + const { total, self, flameGraphWidthTotal } = this._callTreeTimings; const { prefix } = this._callNodeTable; const depth = this._timingRows.length; @@ -146,8 +147,8 @@ export class FlameGraphTiming { } startPerCallNode[nodeIndex] = currentStart; - const totalRelative = abs(totalVal / rootTotalSummary); - const selfRelativeVal = abs(self[nodeIndex] / rootTotalSummary); + const totalRelative = abs(totalVal / flameGraphWidthTotal); + const selfRelativeVal = abs(self[nodeIndex] / flameGraphWidthTotal); const currentEnd = currentStart + totalRelative; start.push(currentStart); diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index 7cdfa59a22..fe6752c337 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -2346,6 +2346,7 @@ CallTree { 0, 0, ], + "flameGraphWidthTotal": 2, "rootTotalSummary": 2, "self": Float64Array [ 0, @@ -3244,6 +3245,7 @@ Object { ], }, ], + "tooltipRatioMultiplier": 1, } `; diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index e9181bd6fe..7fa5b568e7 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -86,6 +86,7 @@ describe('unfiltered call tree', function () { type: 'NON_INVERTED', timings: { rootTotalSummary: 3, + flameGraphWidthTotal: 3, callNodeHasChildren: new Uint8Array([1, 1, 1, 1, 0, 1, 0, 1, 0]), self: new Float64Array([0, 0, 0, 0, 1, 0, 1, 0, 1]), total: new Float64Array([3, 3, 2, 1, 1, 1, 1, 1, 1]), diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index 7b90f5a209..3ca2a6339d 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -730,6 +730,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. + flameGraphWidthTotal: number; }; /** From 85f7bb9e713ea8c81731f0b4a227eac1ca73ff1a Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:56:12 -0400 Subject: [PATCH 11/13] Add lower wing flame graph computation code. --- src/profile-logic/call-node-info.ts | 53 ++ src/profile-logic/call-tree.ts | 4 +- src/profile-logic/flame-graph.ts | 453 +++++++++++++++++- src/profile-logic/profile-data.ts | 5 +- .../__snapshots__/profile-view.test.ts.snap | 1 - src/test/unit/profile-tree.test.ts | 248 +++++++++- 6 files changed, 735 insertions(+), 29 deletions(-) diff --git a/src/profile-logic/call-node-info.ts b/src/profile-logic/call-node-info.ts index c524db0969..ef0a056292 100644 --- a/src/profile-logic/call-node-info.ts +++ b/src/profile-logic/call-node-info.ts @@ -2167,6 +2167,13 @@ export class LowerWingCallNodeInfo implements CallNodeInfoInverted { | -2; } + // Returns a snapshot of the lower-wing inverted call node table, fully + // expanded. Kept for callers (and tests) that want the whole tree at once; + // new code should prefer `getLowerWingTableUpToDepth(k)`. + getLowerWingTable(): LowerWingTable { + return this.getLowerWingTableUpToDepth(Infinity); + } + // Returns a snapshot of the lower-wing inverted call node table containing // at least every node with inverted depth <= `targetDepth`. The snapshot may // also include nodes at `targetDepth + 1` (children emitted when the deepest @@ -2182,6 +2189,10 @@ export class LowerWingCallNodeInfo implements CallNodeInfoInverted { return this._getSnapshotTable(); } + getSelectedFuncIndex(): IndexIntoFuncTable | null { + return this._selectedFuncIndex; + } + // Run the BFS while the next queued node sits at depth <= `targetDepth`. // Nodes at depth `targetDepth + 1` may be added to the table by the final // iteration but are not themselves processed (their `_tChildren[idx]` keeps @@ -2398,6 +2409,48 @@ export class LowerWingCallNodeInfo implements CallNodeInfoInverted { } } +/** + * Compute the number of rows in the lower-wing flame graph for `selectedFuncIndex` + * without building the lower-wing tree. + * + * The lower-wing max inverted depth equals the maximum non-inverted depth across + * all entry points (root-most non-inverted call nodes whose func is the selected + * one): an entry at non-inverted depth D contributes D ancestor steps above the + * inverted root, giving a final inverted depth of D. We add 1 to match the + * "depth-plus-one" row-count convention. + * + * Entry-point collection mirrors the skip-ahead in `_buildLowerWingTree` (Pass 1) + * so nested re-entries aren't double-counted. Returns 1 when there is no + * selection or no entry points — matching the empty-tree fallback in + * `_emptyLowerWingTree`, which has a length-1 root row at depth 0. + */ +export function computeLowerWingMaxDepthPlusOne( + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null +): number { + if (selectedFuncIndex === null) { + return 1; + } + const funcCol = callNodeTable.func; + const subtreeEndCol = callNodeTable.subtreeRangeEnd; + const depthCol = callNodeTable.depth; + const callNodeCount = callNodeTable.length; + let maxDepth = 0; + let found = false; + for (let i = 0; i < callNodeCount; i++) { + if (funcCol[i] !== selectedFuncIndex) { + continue; + } + found = true; + const d = depthCol[i]; + if (d > maxDepth) { + maxDepth = d; + } + i = subtreeEndCol[i] - 1; + } + return found ? maxDepth + 1 : 1; +} + // Shared empty children array for unused roots. Returned by reference; callers // must not mutate. const EMPTY_LOWER_WING_CHILDREN: InvertedCallNodeHandle[] = []; diff --git a/src/profile-logic/call-tree.ts b/src/profile-logic/call-tree.ts index 6c63dffcbd..9ec17ed143 100644 --- a/src/profile-logic/call-tree.ts +++ b/src/profile-logic/call-tree.ts @@ -910,7 +910,7 @@ export function computeCallTreeTimingsInverted( // *inclusive* time of that invocation (self + descendants). Feeding this // through computeCallTreeTimingsInverted then attributes each invocation's // inclusive time to the lower-wing node whose path is its caller chain. -function _computeLowerWingCallNodeSelf( +export function computeLowerWingCallNodeSelf( callNodeSelf: Float64Array, callNodeTable: CallNodeTable, selectedFuncIndex: IndexIntoFuncTable @@ -946,7 +946,7 @@ export function computeLowerWingTimings( const callNodeTable = callNodeInfo.getCallNodeTable(); const mappedSelf = selectedFuncIndex !== null - ? _computeLowerWingCallNodeSelf( + ? computeLowerWingCallNodeSelf( callNodeSelf, callNodeTable, selectedFuncIndex diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 25a8e6961d..2618fc6ef5 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -6,9 +6,14 @@ import type { CallNodeTable, FuncTable, IndexIntoCallNodeTable, + IndexIntoFuncTable, + CallNodeSelfAndSummary, } from 'firefox-profiler/types'; import type { StringTable } from 'firefox-profiler/utils/string-table'; import type { CallTreeTimingsNonInverted } from './call-tree'; +import { computeLowerWingCallNodeSelf } from './call-tree'; +import type { LowerWingCallNodeInfo } from './call-node-info'; +import { computeLowerWingMaxDepthPlusOne } from './call-node-info'; import { bisectionRightByStrKey } from 'firefox-profiler/utils/bisect'; @@ -44,33 +49,17 @@ export type FlameGraphTimingRow = { * the implementation to generate rows lazily as the user scrolls towards * deeper calls. */ -export class FlameGraphTiming { - _flameGraphRows: FlameGraphRows; - _callNodeTable: CallNodeTable; - _callTreeTimings: CallTreeTimingsNonInverted; - - // Populated lazily by _buildNextTimingRow(). - _timingRows: FlameGraphTimingRow[]; - - // Used to position the children call node boxes: For a given parent box, its - // first (left-most) child box starts at the same x position as the parent. - _startPerCallNode: Float32Array; - - constructor( - flameGraphRows: FlameGraphRows, - callNodeTable: CallNodeTable, - callTreeTimings: CallTreeTimingsNonInverted - ) { - this._flameGraphRows = flameGraphRows; - this._callNodeTable = callNodeTable; - this._callTreeTimings = callTreeTimings; +export abstract class FlameGraphTiming { + _rowCount: number; + _timingRows: FlameGraphTimingRow[]; // populated lazily by _buildNextTimingRow() + constructor(rowCount: number) { this._timingRows = []; - this._startPerCallNode = new Float32Array(callNodeTable.length); + this._rowCount = rowCount; } get rowCount(): number { - return this._flameGraphRows.length; + return this._rowCount; } getRow(depth: number): FlameGraphTimingRow { @@ -96,6 +85,49 @@ export class FlameGraphTiming { // For a given node / "box", get the sample percentage (as a fraction of 1) that // should be displayed in the tooltip for this node. + abstract getRatioOfRootTotalSummary( + depth: number, + indexInRow: number + ): number; + + // Build and push `_timingRows[_timingRows.length]`. Called by `getRow` until + // enough rows have been built. Subclasses may extend their underlying tables + // here too. + abstract _buildNextTimingRow(): void; +} + +/** + * Non-inverted lazy timing: drives the regular flame graph, upper wing, and + * self wing. Reads per-node totals and selfs straight out of + * `CallTreeTimingsNonInverted` (which is already computed for the call tree + * view), and uses `callNodeTable.prefix` to align children under parents. + * + * The inner build loop is the structural twin of `LowerWingFlameGraphTiming`'s, + * minus the handle decoding and the `self = total − Σ child totals` step (here + * `self` is read directly). + */ +export class RegularFlameGraphTiming extends FlameGraphTiming { + _flameGraphRows: FlameGraphRows; + _callNodeTable: CallNodeTable; + _callTreeTimings: CallTreeTimingsNonInverted; + + // Used to position the children call node boxes: For a given parent box, its + // first (left-most) child box starts at the same x position as the parent. + _startPerCallNode: Float32Array; + + constructor( + flameGraphRows: FlameGraphRows, + callNodeTable: CallNodeTable, + callTreeTimings: CallTreeTimingsNonInverted + ) { + super(flameGraphRows.length); + this._flameGraphRows = flameGraphRows; + this._callNodeTable = callNodeTable; + this._callTreeTimings = callTreeTimings; + + this._startPerCallNode = new Float32Array(callNodeTable.length); + } + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { const row = this.getRow(depth); if (indexInRow < 0 || indexInRow >= row.length) { @@ -362,6 +394,379 @@ export function getFlameGraphTiming( flameGraphRows: FlameGraphRows, callNodeTable: CallNodeTable, callTreeTimings: CallTreeTimingsNonInverted -): FlameGraphTiming { - return new FlameGraphTiming(flameGraphRows, callNodeTable, callTreeTimings); +): RegularFlameGraphTiming { + return new RegularFlameGraphTiming( + flameGraphRows, + callNodeTable, + callTreeTimings + ); +} + +/** + * Per-LowerWingTable-index total and self values used to drive the lower wing + * flame graph. Both arrays have length equal to the lower-wing table length. + * Index 0 is the root (the selected function). + */ +export type LowerWingFlameGraphTotals = { + totalPerTableIdx: Float64Array; + selfPerTableIdx: Float64Array; + rootTotalSummary: number; + // Equal to rootTotalSummary — the lower wing's flame graph root fills the + // entire width. + flameGraphTotalForScaling: number; +}; + +/** + * Lazy, depth-incremental FlameGraphTiming for the lower wing. + * + * Wraps a `LowerWingCallNodeInfo` and produces a `FlameGraphTimingRow` for any + * depth on demand. Internal state: + * + * - `_rowsCallNodes[d]` is the row of call-node handles for depth `d`, sorted + * in flame-graph display order. Lazily built one depth at a time from the + * previous row's children — same algorithm as the old + * `computeLowerWingFlameGraphRows`, just sliced. + * - `_timingRows[d]` is the per-cell start/end/self/etc. for depth `d`. + * - `_prefixSums` is the running prefix sum over `lowerWing.getSuffixOrderedCallNodes()` + * in its *current* (partition-refined) order. We recompute whenever the + * underlying table has grown since the last recompute — extending the BFS + * permutes the suffix order within nodes' fixed `[soStart, soEnd)` ranges, + * which invalidates per-position prefix sums but leaves per-range sums + * unchanged. Detection is just `lowerWing.getLowerWingTableUpToDepth(-1).length`. + * - `_startPerTableIdx` carries cells' left edges across rows so children can + * be aligned under their parent. Grown as the table grows. + * + * `numRows` is the cheap upper bound from `computeLowerWingMaxDepthPlusOne`, + * so the Canvas can size the scroll area without forcing any tree build. + * + * Self-time correctness: row D's self values depend on the totals of nodes at + * depth D+1. `getRow(D)` extends the lower-wing CNI to depth D, which processes + * nodes at depth D and emits their depth-D+1 children — so children's + * `[soStart, soEnd)` ranges are set and their totals are computable. (For the + * deepest row, nodes have no children and self = total, which is also correct.) + */ +export class LowerWingFlameGraphTiming extends FlameGraphTiming { + _lowerWing: LowerWingCallNodeInfo; + _callNodeSelfAndSummary: CallNodeSelfAndSummary; + _callNodeTable: CallNodeTable; + _selectedFuncIndex: IndexIntoFuncTable | null; + _funcTable: FuncTable; + _stringTable: StringTable; + + // Lazy timing state. + _mappedSelf: Float64Array | null; + _prefixSums: Float64Array | null; + _prefixSumsForTableLength: number; + _rootTotalSummary: number; + _tooltipRatioMultiplier: number; + + // Lazy row state. `_rowsCallNodes[d]` is dense for `d < _rowsCallNodes.length`. + _rowsCallNodes: IndexIntoCallNodeTable[][]; + + // Persistent left-edge cache (table-index keyed) used by row alignment. + _startPerTableIdx: Float32Array; + + // Persistent total cache (table-index keyed). Populated for each node while + // iterating its parent's children to compute `childSum`, then read back when + // the node itself is emitted in the next row — avoids recomputing the same + // prefix-sums subtraction twice. Row-0 roots are the only nodes never seen + // as a child, so they compute their total directly. + _totalPerTableIdx: Float64Array; + + constructor( + lowerWing: LowerWingCallNodeInfo, + callNodeSelfAndSummary: CallNodeSelfAndSummary, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null, + funcTable: FuncTable, + stringTable: StringTable + ) { + super(computeLowerWingMaxDepthPlusOne(callNodeTable, selectedFuncIndex)); + this._lowerWing = lowerWing; + this._callNodeSelfAndSummary = callNodeSelfAndSummary; + this._callNodeTable = callNodeTable; + this._selectedFuncIndex = selectedFuncIndex; + this._funcTable = funcTable; + this._stringTable = stringTable; + + this._mappedSelf = null; + this._prefixSums = null; + this._prefixSumsForTableLength = -1; + this._rootTotalSummary = 0; + // tooltipRatioMultiplier for the lower wing is flameGraphTotalForScaling / + // rootTotalSummary, both equal to total[0]. So 1 when there's a selection + // with sample weight, 0 otherwise. We resolve this lazily inside + // `_ensurePrefixSums` since both values come from the prefix-sums pass. + this._tooltipRatioMultiplier = 0; + + this._rowsCallNodes = []; + this._startPerTableIdx = new Float32Array(16); + this._totalPerTableIdx = new Float64Array(16); + } + + getRatioOfRootTotalSummary(depth: number, indexInRow: number): number { + const row = this.getRow(depth); + if (indexInRow < 0 || indexInRow >= row.length) { + throw new Error( + `Out-of-bounds call to getRatioOfRootTotalSummary: For depth ${depth}, ${indexInRow} is outside 0..${row.length}` + ); + } + + const ratioOfFullWidth = row.end[indexInRow] - row.start[indexInRow]; + return ratioOfFullWidth; + } + + _ensureRowCallNodes(depth: number): void { + if (this._selectedFuncIndex === null) { + while (this._rowsCallNodes.length <= depth) { + this._rowsCallNodes.push([]); + } + return; + } + if (this._rowsCallNodes.length === 0) { + // Row 0: only the inverted root, whose handle is 0 in the lower wing. + this._rowsCallNodes.push([0]); + } + while (this._rowsCallNodes.length <= depth) { + this._buildNextRowCallNodes(); + } + } + + // Build `_rowsCallNodes[next]` from the previous row's children. The + // lower-wing table stores children sorted by func index, so we re-sort by + // name via bisect-insert here (mirrors the old `computeLowerWingFlameGraphRows`). + _buildNextRowCallNodes(): void { + const nextDepth = this._rowsCallNodes.length; + // Need parents (at nextDepth - 1) processed, i.e. CNI extended to + // nextDepth - 1 so their children at nextDepth are populated. + const table = this._lowerWing.getLowerWingTableUpToDepth(nextDepth - 1); + const funcNameCol = this._funcTable.name; + const stringTable = this._stringTable; + const tableFunc = table.func; + const tableChildren = table.children; + + const parentRow = this._rowsCallNodes[nextDepth - 1]; + const childRow: IndexIntoCallNodeTable[] = []; + + // Handle === table index for the lower-wing CNI, so no translation. + for (let p = 0; p < parentRow.length; p++) { + const parentHandle = parentRow[p]; + const children = tableChildren[parentHandle]; + if (children.length === 0) { + continue; + } + const groupStart = childRow.length; + for (let c = 0; c < children.length; c++) { + const childHandle = children[c]; + const childFunc = tableFunc[childHandle]; + const childName = stringTable.getString(funcNameCol[childFunc]); + const groupEnd = childRow.length; + if (groupStart === groupEnd) { + childRow.push(childHandle); + } else { + const insertionIndex = bisectionRightByStrKey( + childRow, + childName, + (handle) => stringTable.getString(funcNameCol[tableFunc[handle]]), + groupStart, + groupEnd + ); + childRow.splice(insertionIndex, 0, childHandle); + } + } + } + + this._rowsCallNodes.push(childRow); + } + + // Compute (or refresh) `_prefixSums` if the underlying CNI table has grown + // since the last computation. Also resolves `_rootTotalSummary` and + // `_tooltipRatioMultiplier` on the first run — those don't change as the + // tree grows because the root's `[soStart, soEnd)` covers the full entry + // set and that set is invariant. + _ensurePrefixSums(): void { + if (this._selectedFuncIndex === null) { + if (this._prefixSums === null) { + this._prefixSums = new Float64Array(1); + // _rootTotalSummary and _tooltipRatioMultiplier stay at their default 0. + this._prefixSumsForTableLength = + this._lowerWing.getLowerWingTableUpToDepth(-1).length; + } + return; + } + + const tableLength = this._lowerWing.getLowerWingTableUpToDepth(-1).length; + if ( + this._prefixSums !== null && + this._prefixSumsForTableLength === tableLength + ) { + return; + } + + if (this._mappedSelf === null) { + this._mappedSelf = computeLowerWingCallNodeSelf( + this._callNodeSelfAndSummary.callNodeSelf, + this._callNodeTable, + this._selectedFuncIndex + ); + } + const mappedSelf = this._mappedSelf; + const suffixOrdered = this._lowerWing.getSuffixOrderedCallNodes(); + const N = suffixOrdered.length; + const prefixSums = new Float64Array(N + 1); + for (let k = 0; k < N; k++) { + prefixSums[k + 1] = prefixSums[k] + mappedSelf[suffixOrdered[k]]; + } + this._prefixSums = prefixSums; + this._prefixSumsForTableLength = tableLength; + // total[0] = root's range = all entries. Compute once; invariant across + // further extensions. + if (this._rootTotalSummary === 0 && N > 0) { + this._rootTotalSummary = prefixSums[N]; + this._tooltipRatioMultiplier = this._rootTotalSummary === 0 ? 0 : 1; + } + } + + _growPerTableIdxArraysTo(minLength: number): void { + if (this._startPerTableIdx.length >= minLength) { + return; + } + let newCap = this._startPerTableIdx.length; + while (newCap < minLength) { + newCap *= 2; + } + const nextStart = new Float32Array(newCap); + nextStart.set(this._startPerTableIdx); + this._startPerTableIdx = nextStart; + const nextTotal = new Float64Array(newCap); + nextTotal.set(this._totalPerTableIdx); + this._totalPerTableIdx = nextTotal; + } + + _buildNextTimingRow(): void { + const nextDepth = this._timingRows.length; + + // Extend the CNI to nextDepth so depth-nextDepth nodes are processed — + // this populates their `_tChildren` and adds the depth-(nextDepth+1) + // children whose totals feed into self-time at nextDepth. + const table = this._lowerWing.getLowerWingTableUpToDepth(nextDepth); + + this._ensureRowCallNodes(nextDepth); + this._ensurePrefixSums(); + this._growPerTableIdxArraysTo(table.length); + + const rowNodes = this._rowsCallNodes[nextDepth]; + const flameGraphTotalForScaling = this._rootTotalSummary; + if (flameGraphTotalForScaling === 0) { + this._timingRows.push({ + start: [], + end: [], + selfRelative: [], + callNode: [], + length: 0, + }); + return; + } + + const tablePrefix = table.prefix; + const tableSoStart = table.suffixOrderIndexRangeStart; + const tableSoEnd = table.suffixOrderIndexRangeEnd; + const tableChildren = table.children; + const prefixSums = this._prefixSums as Float64Array; + const startPerTableIdx = this._startPerTableIdx; + const totalPerTableIdx = this._totalPerTableIdx; + + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1858310 + const abs = Math.abs; + + const start: UnitIntervalOfProfileRange[] = []; + const end: UnitIntervalOfProfileRange[] = []; + const selfRelative: number[] = []; + const timingCallNodes: IndexIntoCallNodeTable[] = []; + + let currentStart = 0; + let previousPrefixIdx = -1; + // Handle === table index for the lower-wing CNI. + for (let i = 0; i < rowNodes.length; i++) { + const tableIdx = rowNodes[i]; + // Row-0 roots are never seen as a child, so their total isn't cached; + // compute directly. Deeper rows read the total cached by their parent's + // childSum loop on the previous row. + const totalVal = + nextDepth === 0 + ? prefixSums[tableSoEnd[tableIdx]] - + prefixSums[tableSoStart[tableIdx]] + : totalPerTableIdx[tableIdx]; + if (totalVal === 0) { + continue; + } + + // self = total − Σ children's totals. Children at depth nextDepth+1 + // are guaranteed to exist (CNI was extended to nextDepth), so each + // child's [soStart, soEnd) is final and its total is well-defined. + // Cache each child's total so the next row can read it directly. + const children = tableChildren[tableIdx]; + let childSum = 0; + for (let c = 0; c < children.length; c++) { + const childIdx = children[c]; + const childTotal = + prefixSums[tableSoEnd[childIdx]] - prefixSums[tableSoStart[childIdx]]; + totalPerTableIdx[childIdx] = childTotal; + childSum += childTotal; + } + const selfVal = totalVal - childSum; + + const parentIdx = tablePrefix[tableIdx]; + + if (parentIdx !== previousPrefixIdx) { + currentStart = parentIdx === -1 ? 0 : startPerTableIdx[parentIdx]; + previousPrefixIdx = parentIdx; + } + startPerTableIdx[tableIdx] = currentStart; + + const totalRelative = abs(totalVal / flameGraphTotalForScaling); + const selfRelativeVal = abs(selfVal / flameGraphTotalForScaling); + + const currentEnd = currentStart + totalRelative; + start.push(currentStart); + end.push(currentEnd); + selfRelative.push(selfRelativeVal); + timingCallNodes.push(tableIdx); + + currentStart = currentEnd; + } + + this._timingRows.push({ + start, + end, + selfRelative, + callNode: timingCallNodes, + length: timingCallNodes.length, + }); + } +} + +/** + * Construct the (lazy) flame-graph timing for the lower wing. + * + * The returned object is the unit of work the Canvas talks to: it asks for a + * row by depth and the timing object extends the lower-wing CNI, refreshes + * prefix sums, and computes only the rows that have been asked for. + */ +export function createLowerWingFlameGraphTiming( + lowerWing: LowerWingCallNodeInfo, + callNodeSelfAndSummary: CallNodeSelfAndSummary, + callNodeTable: CallNodeTable, + selectedFuncIndex: IndexIntoFuncTable | null, + funcTable: FuncTable, + stringTable: StringTable +): LowerWingFlameGraphTiming { + return new LowerWingFlameGraphTiming( + lowerWing, + callNodeSelfAndSummary, + callNodeTable, + selectedFuncIndex, + funcTable, + stringTable + ); } diff --git a/src/profile-logic/profile-data.ts b/src/profile-logic/profile-data.ts index aad2f2da37..d7b9b17b30 100644 --- a/src/profile-logic/profile-data.ts +++ b/src/profile-logic/profile-data.ts @@ -16,7 +16,10 @@ import { CallNodeInfoNonInverted, LazyInvertedCallNodeInfo, LowerWingCallNodeInfo, + computeLowerWingMaxDepthPlusOne, } from './call-node-info'; + +export { computeLowerWingMaxDepthPlusOne }; import { computeThreadCPUPercent } from './cpu'; import { INSTANT, @@ -853,7 +856,7 @@ export function getLowerWingCallNodeInfo( defaultCategory: IndexIntoCategoryList, funcCount: number, selectedFuncIndex: IndexIntoFuncTable | null -): CallNodeInfoInverted { +): LowerWingCallNodeInfo { return new LowerWingCallNodeInfo( callNodeInfo.getCallNodeTable(), callNodeInfo.getStackIndexToNonInvertedCallNodeIndex(), diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index fe6752c337..c9f051d5ab 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -3245,7 +3245,6 @@ Object { ], }, ], - "tooltipRatioMultiplier": 1, } `; diff --git a/src/test/unit/profile-tree.test.ts b/src/test/unit/profile-tree.test.ts index 7fa5b568e7..65a9e34d1a 100644 --- a/src/test/unit/profile-tree.test.ts +++ b/src/test/unit/profile-tree.test.ts @@ -11,10 +11,19 @@ import { computeCallNodeSelfAndSummary, computeCallTreeTimings, } from '../../profile-logic/call-tree'; -import { computeFlameGraphRows } from '../../profile-logic/flame-graph'; import { + computeFlameGraphRows, + createLowerWingFlameGraphTiming, +} from '../../profile-logic/flame-graph'; +import type { + FlameGraphTiming, + FlameGraphTimingRow, +} from '../../profile-logic/flame-graph'; +import { + computeLowerWingMaxDepthPlusOne, getCallNodeInfo, getInvertedCallNodeInfo, + getLowerWingCallNodeInfo, getOriginAnnotationForFunc, getOriginalPositionForFrame, filterRawThreadSamplesToRange, @@ -140,6 +149,243 @@ describe('unfiltered call tree', function () { }); }); + describe('lower wing flame graph', function () { + // The shared fixture profile (see `getProfile` above) has every sample + // rooted at A, so A has no callers in the inverted (lower wing) view. + // This used to render an empty flame graph; the regression test below + // exercises the fix that emits a single root cell with the full total. + function setup(selectedFuncIndex: number | null) { + const { derivedThreads, defaultCategory } = getProfile(); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const lowerWing = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length, + selectedFuncIndex + ); + const selfAndSummary = computeCallNodeSelfAndSummary( + thread.samples, + getSampleIndexToCallNodeIndex( + thread.samples.stack, + callNodeInfo.getStackIndexToNonInvertedCallNodeIndex() + ), + callNodeInfo.getCallNodeTable().length + ); + const timing = createLowerWingFlameGraphTiming( + lowerWing, + selfAndSummary, + callNodeInfo.getCallNodeTable(), + selectedFuncIndex, + thread.funcTable, + thread.stringTable + ); + return { thread, callNodeInfo, lowerWing, selfAndSummary, timing }; + } + + // Materialize every row of a lazy timing into a flat array for compact + // assertions. Equivalent to the old `flameGraphTiming.rows` field. + function materializeRows(timing: FlameGraphTiming): FlameGraphTimingRow[] { + const rows: FlameGraphTimingRow[] = []; + for (let depth = 0; depth < timing.rowCount; depth++) { + rows.push(timing.getRow(depth)); + } + return rows; + } + + it('renders a single full-width root cell when the selected function has no callers', function () { + const { timing } = setup(A); + + // Every one of the 3 samples passes through A at the bottom of the + // stack, so A's lower-wing row is a single full-width cell with full + // self time. + expect(timing.rowCount).toBe(1); + expect(timing.getRow(0)).toEqual({ + start: [0], + end: [1], + selfRelative: [1], + callNode: [A], + length: 1, + }); + }); + + it('builds a multi-row flame graph for a function with callers', function () { + // C appears in two samples and is called by B in both, so the lower + // wing tree has C as root with one caller chain (B -> A). + const { lowerWing, timing } = setup(C); + + // Three rows: [C], [B], [A]. Each cell occupies the full width since + // it's the only sibling at its depth. + expect(timing.rowCount).toBe(3); + const rows = materializeRows(timing); + // Cell values are InvertedCallNodeHandle values (root === funcIndex, + // non-roots come from the lower-wing handle scheme); translate via + // funcForNode for a clear func-based assertion. + expect( + rows.map((r) => r.callNode.map((h) => lowerWing.funcForNode(h))) + ).toEqual([[C], [B], [A]]); + expect(rows.map((r) => ({ start: r.start, end: r.end }))).toEqual([ + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + ]); + // Only the deepest inverted node (A, the topmost expanded caller) + // accumulates self; intermediate nodes' self is zero. + expect(rows.map((r) => r.selfRelative)).toEqual([[0], [0], [1]]); + }); + + it('extends the lower-wing tree lazily as rows are requested', function () { + // E sits at the deepest leaf of the fixture (A -> B -> C -> D -> E), + // so its lower-wing tree has rows E, D, C, B, A (max depth 4). + const { lowerWing, timing } = setup(E); + + // The cheap max-depth selector knows the row count up front, without + // building any of the tree. + expect(timing.rowCount).toBe(5); + + // Before any rows are fetched: only the root row of the lower-wing + // tree exists; no BFS work has happened. + expect(lowerWing.getLowerWingTableUpToDepth(-1).length).toBe(1); + + // Fetching row 0 extends the BFS just enough to process the root and + // emit its depth-1 child. The internal table is now length 2. + timing.getRow(0); + expect(lowerWing.getLowerWingTableUpToDepth(-1).length).toBe(2); + + // Fetching row 2 extends through depth 2, growing the table further. + timing.getRow(2); + expect(lowerWing.getLowerWingTableUpToDepth(-1).length).toBe(4); + + // Materializing every row matches an independently-built eager + // baseline (one row per depth, each cell full-width). + const rows = materializeRows(timing); + expect( + rows.map((r) => r.callNode.map((h) => lowerWing.funcForNode(h))) + ).toEqual([[E], [D], [C], [B], [A]]); + expect(rows.map((r) => ({ start: r.start, end: r.end }))).toEqual([ + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + { start: [0], end: [1] }, + ]); + // Only the deepest caller (A, the topmost expanded ancestor) carries + // self time. Self values stay correct as the tree is grown one row at + // a time because each `getRow(D)` extends the CNI to depth D, which + // emits depth D+1 children needed to compute self = total − Σchildren. + expect(rows.map((r) => r.selfRelative)).toEqual([ + [0], + [0], + [0], + [0], + [1], + ]); + }); + + it('builds the lower wing tree incrementally via getLowerWingTableUpToDepth', function () { + // E sits at the deepest leaf of the fixture (A -> B -> C -> D -> E), + // so its lower-wing tree has rows E, D, C, B, A (max depth 4). + const { derivedThreads, defaultCategory } = getProfile(); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + + const lowerWing = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length, + E + ); + + // Before any extension: only the root row (placeholder children) exists. + // The cached snapshot is invalidated as the BFS grows the table, so each + // partial call returns a snapshot whose `length` reflects how far the + // tree has been expanded. + const tableAtRoot = lowerWing.getLowerWingTableUpToDepth(0); + // Processing depth 0 emits the single depth-1 child (D), so the table + // length is 2. + expect(tableAtRoot.length).toBe(2); + expect(tableAtRoot.depth).toEqual([0, 1]); + + // Extending by one more level reveals depth-2 children of D (only C). + const tableAtDepth1 = lowerWing.getLowerWingTableUpToDepth(1); + expect(tableAtDepth1.length).toBe(3); + expect(tableAtDepth1.depth).toEqual([0, 1, 2]); + + // Going past the tree's real depth caps at the actual max (4 + 1 = 5). + const tableFull = lowerWing.getLowerWingTableUpToDepth(99); + expect(tableFull.length).toBe(5); + expect(tableFull.depth).toEqual([0, 1, 2, 3, 4]); + + // The lazy build must match the eager build that getLowerWingTable + // returns (which is just getLowerWingTableUpToDepth(Infinity)). + const eager = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + thread.funcTable.length, + E + ).getLowerWingTable(); + expect(tableFull.length).toBe(eager.length); + expect(tableFull.prefix).toEqual(eager.prefix); + expect(tableFull.func).toEqual(eager.func); + expect(tableFull.depth).toEqual(eager.depth); + expect(tableFull.suffixOrderIndexRangeStart).toEqual( + eager.suffixOrderIndexRangeStart + ); + expect(tableFull.suffixOrderIndexRangeEnd).toEqual( + eager.suffixOrderIndexRangeEnd + ); + }); + + it('computes max depth without building the lower wing tree, matching the eager build', function () { + const { derivedThreads, defaultCategory } = getProfile(); + const [thread] = derivedThreads; + const callNodeInfo = getCallNodeInfo( + thread.stackTable, + thread.frameTable, + defaultCategory + ); + const callNodeTable = callNodeInfo.getCallNodeTable(); + + // No selection → single empty root row. + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, null)).toBe(1); + + // For every func in the fixture, the cheap helper should agree with the + // depth read off the fully-built lower-wing table. + const funcCount = thread.funcTable.length; + for (let f = 0; f < funcCount; f++) { + const lowerWing = getLowerWingCallNodeInfo( + callNodeInfo, + defaultCategory, + funcCount, + f + ); + const table = lowerWing.getLowerWingTable(); + const eagerMaxDepthPlusOne = table.depth[table.length - 1] + 1; + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, f)).toBe( + eagerMaxDepthPlusOne + ); + } + + // Spot-check the absolute values for a few funcs in the fixture: + // A (root, no callers) -> 1 + // B (called by A) -> 2 + // C (called by B, A) -> 3 + // E (deepest leaf chain) -> 5 + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, A)).toBe(1); + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, B)).toBe(2); + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, C)).toBe(3); + expect(computeLowerWingMaxDepthPlusOne(callNodeTable, E)).toBe(5); + }); + }); + /** * Explicitly test the structure of the unfiltered call tree. */ From 8f166b9c2c6cd3826299e5a25bb8529da6bc11a5 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 12/13] Add a DisclosureBox component. --- src/components/shared/DisclosureBox.css | 72 +++++++++++++++++++++++++ src/components/shared/DisclosureBox.tsx | 48 +++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/components/shared/DisclosureBox.css create mode 100644 src/components/shared/DisclosureBox.tsx diff --git a/src/components/shared/DisclosureBox.css b/src/components/shared/DisclosureBox.css new file mode 100644 index 0000000000..2b91918d85 --- /dev/null +++ b/src/components/shared/DisclosureBox.css @@ -0,0 +1,72 @@ +/* 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; + min-height: 0; + flex-direction: column; +} + +.disclosureBox.open { + flex: 1; +} + +.disclosureBoxHeader { + display: flex; + flex-shrink: 0; + align-items: stretch; + border-top: 1px solid var(--base-border-color); + background: var(--panel-background-color); +} + +.disclosureBoxButton { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + padding: 2px 6px; + border: none; + background: transparent; + color: var(--panel-foreground-color); + cursor: pointer; + font-size: 11px; + font-weight: bold; + gap: 4px; + text-align: start; +} + +.disclosureBoxButton:hover { + background: var(--clickable-ghost-hover-background-color); +} + +.disclosureBoxButton:active { + background: var(--clickable-ghost-active-background-color); +} + +.disclosureBoxHeaderActions { + display: flex; + flex-shrink: 0; + align-items: center; + padding-right: 4px; + gap: 2px; +} + +.disclosureBoxArrow { + display: inline-block; + width: 0; + height: 0; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid currentcolor; +} + +.disclosureBox.open .disclosureBoxArrow { + transform: rotate(90deg); +} + +.disclosureBoxContents { + display: flex; + min-height: 0; + flex: 1; +} diff --git a/src/components/shared/DisclosureBox.tsx b/src/components/shared/DisclosureBox.tsx new file mode 100644 index 0000000000..22e5d0d610 --- /dev/null +++ b/src/components/shared/DisclosureBox.tsx @@ -0,0 +1,48 @@ +/* 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 { useCallback } from 'react'; + +import './DisclosureBox.css'; + +type Props = { + readonly label: string; + readonly isOpen: boolean; + readonly onToggle: (isOpen: boolean) => void; + readonly headerActions?: React.ReactNode; + readonly children: React.ReactNode; +}; + +export function DisclosureBox({ + label, + isOpen, + onToggle, + headerActions, + children, +}: Props) { + const handleToggle = useCallback(() => { + onToggle(!isOpen); + }, [isOpen, onToggle]); + + return ( +
+
+ + {headerActions ? ( +
{headerActions}
+ ) : null} +
+ {isOpen ?
{children}
: null} +
+ ); +} From 26676601d2f5568247d069bc7c8886d571518b6b Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 21 Jun 2026 10:55:52 -0400 Subject: [PATCH 13/13] Add function list wings. --- src/actions/profile-view.ts | 114 ++++- src/app-logic/url-handling.ts | 145 ++++++ src/components/app/Details.tsx | 6 +- src/components/calltree/Butterfly.css | 55 ++ .../calltree/LowerWingFlameGraph.tsx | 262 ++++++++++ .../calltree/ProfileFunctionListView.tsx | 112 ++++- src/components/calltree/SelfWing.tsx | 333 ++++++++++++ .../calltree/UpperWingFlameGraph.tsx | 274 ++++++++++ src/components/calltree/WingTreeView.tsx | 426 ++++++++++++++++ src/components/calltree/WingViewToggle.css | 44 ++ src/components/calltree/WingViewToggle.tsx | 123 +++++ ...istContextMenu.tsx => WingContextMenu.tsx} | 372 ++++++++------ src/profile-logic/flame-graph.ts | 12 +- src/reducers/profile-view.ts | 302 +++++++---- src/reducers/url-state.ts | 62 +++ src/selectors/per-thread/stack-sample.ts | 473 +++++++++++++++++- src/selectors/right-clicked-call-node.tsx | 2 + src/selectors/url-state.ts | 21 + .../FunctionListContextMenu.test.tsx | 2 +- .../components/LowerWingContextMenu.test.tsx | 119 +++++ .../__snapshots__/profile-view.test.ts.snap | 47 +- src/test/store/profile-view.test.ts | 2 + src/types/actions.ts | 16 +- src/types/state.ts | 41 +- 24 files changed, 3016 insertions(+), 349 deletions(-) create mode 100644 src/components/calltree/Butterfly.css create mode 100644 src/components/calltree/LowerWingFlameGraph.tsx create mode 100644 src/components/calltree/SelfWing.tsx create mode 100644 src/components/calltree/UpperWingFlameGraph.tsx create mode 100644 src/components/calltree/WingTreeView.tsx create mode 100644 src/components/calltree/WingViewToggle.css create mode 100644 src/components/calltree/WingViewToggle.tsx rename src/components/shared/{FunctionListContextMenu.tsx => WingContextMenu.tsx} (53%) create mode 100644 src/test/components/LowerWingContextMenu.test.tsx diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 5d80b02a4f..6006c80b45 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -34,6 +34,7 @@ import { getHiddenLocalTracks, getInvertCallstack, getHash, + getUrlState, } from 'firefox-profiler/selectors/url-state'; import { assertExhaustiveCheck, @@ -75,12 +76,15 @@ import type { SelectionContext, BottomBoxInfo, IndexIntoFuncTable, + CallNodeArea, + WingName, } from 'firefox-profiler/types'; import { funcHasDirectRecursiveCall, funcHasRecursiveCall, } from '../profile-logic/transforms'; import { changeStoredProfileNameInDb } from 'firefox-profiler/app-logic/uploaded-profiles-db'; +import { replaceHistoryWithUrlState } from 'firefox-profiler/app-logic/url-handling'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; import type { SingleColumnSortState } from '../components/shared/TreeView'; @@ -122,7 +126,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, @@ -131,19 +135,50 @@ export function changeSelectedCallNode( }; } +const WING_AREAS: Record = { + upper: 'UPPER_WING', + lower: 'LOWER_WING', +}; + +export function changeWingSelectedCallNode( + wing: WingName, + threadsKey: ThreadsKey, + selectedCallNodePath: CallNodePath, + context: SelectionContext = { source: 'auto' } +): Action { + return { + type: 'CHANGE_SELECTED_CALL_NODE', + area: WING_AREAS[wing], + selectedCallNodePath, + optionalExpandedToCallNodePath: [], + threadsKey, + context, + }; +} + /** * Select a function for a given thread in the function list. + * + * Replaces the current history entry rather than pushing a new one, so that + * holding e.g. the down arrow key in the function list doesn't get rate-limited + * by the browser and doesn't flood the back/forward history. */ export function changeSelectedFunctionIndex( threadsKey: ThreadsKey, selectedFunctionIndex: IndexIntoFuncTable | null, context: SelectionContext = { source: 'auto' } -): Action { - return { - type: 'CHANGE_SELECTED_FUNCTION', - selectedFunctionIndex, - threadsKey, - context, +): ThunkAction { + return (dispatch, getState) => { + dispatch({ + type: 'CHANGE_SELECTED_FUNCTION', + selectedFunctionIndex, + threadsKey, + context, + }); + // Update window.history synchronously instead of waiting for the + // UrlManager's componentDidUpdate, which is deferred by React's render + // scheduling and would otherwise pushState a new entry. + replaceHistoryWithUrlState(getUrlState(getState())); }; } @@ -155,11 +190,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, + }); }; } @@ -174,6 +213,19 @@ export function changeRightClickedFunctionIndex( }; } +export function changeWingRightClickedCallNode( + wing: WingName, + threadsKey: ThreadsKey, + callNodePath: CallNodePath | null +): Action { + return { + type: 'CHANGE_RIGHT_CLICKED_CALL_NODE', + threadsKey, + area: WING_AREAS[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. @@ -1608,12 +1660,26 @@ 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 changeWingExpandedCallNodes( + wing: WingName, + threadsKey: ThreadsKey, + expandedCallNodePaths: Array +): Action { + return { + type: 'CHANGE_EXPANDED_CALL_NODES', + area: WING_AREAS[wing], + threadsKey, + expandedCallNodePaths, + }; +} + export function changeSelectedMarker( threadsKey: ThreadsKey, selectedMarker: MarkerIndex | null, @@ -1696,6 +1762,28 @@ export function changeFunctionListSort( }; } +export function changeFunctionListSectionOpen( + section: 'descendants' | 'ancestors' | 'self', + isOpen: boolean +): Action { + return { + type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN', + section, + isOpen, + }; +} + +export function changeWingView( + wing: 'upper' | 'lower' | 'self', + view: 'flame-graph' | 'call-tree' +): Action { + return { + type: 'CHANGE_WING_VIEW', + wing, + view, + }; +} + export function changeNetworkSearchString(searchString: string): Action { return { type: 'CHANGE_NETWORK_SEARCH_STRING', diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 28ac2100df..6014c8d425 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -42,6 +42,9 @@ import type { IndexIntoFrameTable, MarkerIndex, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; import { decodeUintArrayFromUrlComponent, @@ -120,6 +123,19 @@ export function getIsHistoryReplaceState(): boolean { return _isReplaceState; } +/** + * Synchronously replace the current history entry to match the given UrlState. + * + * Use this from action thunks for high-frequency actions (e.g. arrow-key + * navigation) where deferring the URL update to UrlManager.componentDidUpdate + * would result in unwanted pushState calls and history-flooding. By updating + * window.history synchronously here, the URL already matches the new state by + * the time UrlManager's componentDidUpdate runs, so it becomes a no-op. + */ +export function replaceHistoryWithUrlState(urlState: UrlState): void { + window.history.replaceState(urlState, document.title, urlFromState(urlState)); +} + function getPathParts(urlState: UrlState): string[] { const { dataSource } = urlState; switch (dataSource) { @@ -187,6 +203,9 @@ type CallTreeQuery = BaseQuery & { hideIdleSamples: null | undefined; ctSummary: string; functionListSort?: string; // "total-desc~self-asc" — primary first + funcListSections?: string; // "descendants,self" — comma-separated open sections + wingViews?: string; // "upper:call-tree,lower:call-tree" — non-default wing views + selectedFunc?: number; // Selected function index for the current thread, e.g. 42 }; type MarkersQuery = BaseQuery & { @@ -235,6 +254,9 @@ type Query = BaseQuery & { // Function list specific functionListSort?: string; + funcListSections?: string; + wingViews?: string; + selectedFunc?: number; // Network specific networkSearch?: string; @@ -394,6 +416,20 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { query.functionListSort = convertFunctionListSortToString( urlState.profileSpecific.functionListSort ); + query.funcListSections = convertFunctionListSectionsOpenToString( + urlState.profileSpecific.functionListSectionsOpen + ); + query.wingViews = convertWingViewsToString( + urlState.profileSpecific.wingViews + ); + query.selectedFunc = + selectedThreadsKey !== null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + null && + urlState.profileSpecific.selectedFunctions[selectedThreadsKey] !== + undefined + ? urlState.profileSpecific.selectedFunctions[selectedThreadsKey] + : undefined; } break; } @@ -559,6 +595,19 @@ export function stateFromLocation( } } + // Parse the selected function for the current thread + const selectedFunctions: SelectedFunctionsPerThread = {}; + if ( + selectedThreadsKey !== null && + query.selectedFunc !== undefined && + query.selectedFunc !== null + ) { + const funcIndex = Number(query.selectedFunc); + if (Number.isInteger(funcIndex) && funcIndex >= 0) { + selectedFunctions[selectedThreadsKey] = funcIndex; + } + } + // tabID is used for the tab selector that we have in our full view. let tabID = null; if (query.tabID && Number.isInteger(Number(query.tabID))) { @@ -650,10 +699,15 @@ export function stateFromLocation( ? query.hiddenThreads.split('-').map((index) => Number(index)) : null, selectedMarkers, + selectedFunctions, markerTableSort: convertMarkerTableSortFromString(query.markerSort), functionListSort: convertFunctionListSortFromString( query.functionListSort ), + functionListSectionsOpen: convertFunctionListSectionsOpenFromString( + query.funcListSections + ), + wingViews: convertWingViewsFromString(query.wingViews), }, }; } @@ -744,6 +798,97 @@ function convertFunctionListSortFromString( return parsed.reverse(); } +// FunctionList section disclosure-box open/closed state. The URL stores a +// comma-separated list of the open sections; the param is omitted when the +// state matches the default (only "descendants" open). The value "none" is +// used as a sentinel for the all-closed case so the param is non-empty. +const FUNCTION_LIST_SECTION_NAMES: ReadonlyArray< + keyof FunctionListSectionsOpenState +> = ['descendants', 'ancestors', 'self']; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +function convertFunctionListSectionsOpenToString( + state: FunctionListSectionsOpenState +): string | undefined { + const matchesDefault = FUNCTION_LIST_SECTION_NAMES.every( + (name) => state[name] === FUNCTION_LIST_SECTIONS_OPEN_DEFAULT[name] + ); + if (matchesDefault) { + return undefined; + } + const open = FUNCTION_LIST_SECTION_NAMES.filter((name) => state[name]); + return open.length === 0 ? 'none' : open.join(','); +} + +function convertFunctionListSectionsOpenFromString( + raw: string | null | void +): FunctionListSectionsOpenState { + if (raw === undefined || raw === null) { + return { ...FUNCTION_LIST_SECTIONS_OPEN_DEFAULT }; + } + const result: FunctionListSectionsOpenState = { + descendants: false, + ancestors: false, + self: false, + }; + if (raw === 'none' || raw === '') { + return result; + } + for (const part of raw.split(',')) { + if (part === 'descendants' || part === 'ancestors' || part === 'self') { + result[part] = true; + } + } + return result; +} + +// WingView visualization choice for the Descendants ("upper"), Ancestors +// ("lower"), and Self wings of the FunctionList tab. The URL stores a +// comma-separated list of "wing:view" pairs for wings whose view differs from +// the default (flame-graph), e.g. "upper:call-tree". The param is omitted +// when all wings use the default. +const WING_VIEWS_DEFAULT: WingViewsState = { + upper: 'flame-graph', + lower: 'flame-graph', + self: 'flame-graph', +}; +const WING_VIEW_NAMES: ReadonlyArray = [ + 'upper', + 'lower', + 'self', +]; + +function convertWingViewsToString(state: WingViewsState): string | undefined { + const parts: string[] = []; + for (const wing of WING_VIEW_NAMES) { + if (state[wing] !== WING_VIEWS_DEFAULT[wing]) { + parts.push(`${wing}:${state[wing]}`); + } + } + return parts.length === 0 ? undefined : parts.join(','); +} + +function convertWingViewsFromString(raw: string | null | void): WingViewsState { + const result: WingViewsState = { ...WING_VIEWS_DEFAULT }; + if (raw === undefined || raw === null || raw === '') { + return result; + } + for (const part of raw.split(',')) { + const [wing, view] = part.split(':'); + if ( + (wing === 'upper' || wing === 'lower' || wing === 'self') && + (view === 'flame-graph' || view === 'call-tree') + ) { + result[wing] = view; + } + } + return result; +} + function convertGlobalTrackOrderFromString( rawString: string | null | void ): TrackIndex[] { diff --git a/src/components/app/Details.tsx b/src/components/app/Details.tsx index 85d92cc99a..0ac75ddc70 100644 --- a/src/components/app/Details.tsx +++ b/src/components/app/Details.tsx @@ -27,7 +27,10 @@ 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 { + FunctionListContextMenu, + LowerWingContextMenu, +} from 'firefox-profiler/components/shared/WingContextMenu'; import { MaybeMarkerContextMenu } from 'firefox-profiler/components/shared/MarkerContextMenu'; import { toValidTabSlug } from 'firefox-profiler/utils/types'; @@ -137,6 +140,7 @@ class ProfileViewerImpl extends PureComponent { +
); diff --git a/src/components/calltree/Butterfly.css b/src/components/calltree/Butterfly.css new file mode 100644 index 0000000000..62f236e0cb --- /dev/null +++ b/src/components/calltree/Butterfly.css @@ -0,0 +1,55 @@ +/* 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; + display: flex; + min-height: 0; + flex: 1; +} + +.butterflyWings > .resizableWithSplitterInner { + display: flex; + flex-flow: column nowrap; +} + +/* Provide 3px extra grabbable surface on each side of the splitter */ +.butterflyWrapper .resizableWithSplitterSplitter { + position: relative; /* containing block for absolute ::before */ + border: none; + background-color: var(--base-border-color) !important; +} + +.butterflyWrapper .resizableWithSplitterSplitter::before { + position: absolute; + z-index: var(--z-bottom-box); + display: block; + content: ''; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesWidth { + width: 1px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesWidth::before { + inset: 0 -3px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesHeight { + height: 1px; + margin-bottom: -1px; +} + +.butterflyWrapper .resizableWithSplitterSplitter.resizesHeight::before { + inset: -3px 0; +} + +.functionListTreeWrapper { + display: flex; + flex-flow: column nowrap; +} + +.functionListTreeWrapper .treeRowToggleButton { + display: none; +} diff --git a/src/components/calltree/LowerWingFlameGraph.tsx b/src/components/calltree/LowerWingFlameGraph.tsx new file mode 100644 index 0000000000..fc27795c8d --- /dev/null +++ b/src/components/calltree/LowerWingFlameGraph.tsx @@ -0,0 +1,262 @@ +/* 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 { 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 } from 'firefox-profiler/selectors/url-state'; +import { + changeWingSelectedCallNode, + changeWingRightClickedCallNode, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + SelectionContext, + CallNodePath, +} 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 callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => any; + readonly changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => any; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +export interface LowerWingFlameGraphHandle { + focus(): void; +} + +class LowerWingFlameGraphImpl + extends React.PureComponent + implements LowerWingFlameGraphHandle +{ + _flameGraph: React.RefObject = React.createRef(); + + // eslint-disable-next-line react/no-unused-class-component-methods -- called via LowerWingFlameGraphHandle ref from LowerWingImpl + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + const context: SelectionContext = { source: 'pointer' }; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex), + context + ); + }; + + _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('function-list', 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, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + tracedTiming, + displayStackType, + } = this.props; + + return ( + + ); + } +} + +export const LowerWingFlameGraph = explicitConnectWithForwardRef< + {}, + StateProps, + DispatchProps, + LowerWingFlameGraphHandle +>({ + mapStateToProps: (state) => ({ + thread: selectedThreadSelectors.getPreviewFilteredThread(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + maxStackDepthPlusOne: + selectedThreadSelectors.getLowerWingFlameGraphMaxDepthPlusOne(state), + flameGraphTiming: + selectedThreadSelectors.getLowerWingFlameGraphTiming(state), + callTree: selectedThreadSelectors.getLowerWingCallTree(state), + timeRange: getCommittedRange(state), + previewSelection: getPreviewSelection(state), + callNodeInfo: selectedThreadSelectors.getLowerWingCallNodeInfo(state), + categories: getCategories(state), + threadsKey: getSelectedThreadsKey(state), + selectedCallNodeIndex: + selectedThreadSelectors.getLowerWingSelectedCallNodeIndex(state), + rightClickedCallNodeIndex: + selectedThreadSelectors.getLowerWingRightClickedCallNodeIndex(state), + scrollToSelectionGeneration: getScrollToSelectionGeneration(state), + interval: getProfileInterval(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: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => changeWingSelectedCallNode('lower', threadsKey, path, context), + changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => changeWingRightClickedCallNode('lower', threadsKey, path), + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: LowerWingFlameGraphImpl, +}); diff --git a/src/components/calltree/ProfileFunctionListView.tsx b/src/components/calltree/ProfileFunctionListView.tsx index 7695a6c4d2..a3c2f0900b 100644 --- a/src/components/calltree/ProfileFunctionListView.tsx +++ b/src/components/calltree/ProfileFunctionListView.tsx @@ -2,19 +2,107 @@ * 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 explicitConnect from 'firefox-profiler/utils/connect'; import { FunctionList } from './FunctionList'; +import { SelfWing } from './SelfWing'; +import { UpperWing, LowerWing } from './WingTreeView'; +import { DisclosureBox } from 'firefox-profiler/components/shared/DisclosureBox'; +import { WingViewToggle } from './WingViewToggle'; import { StackSettings } from 'firefox-profiler/components/shared/StackSettings'; import { TransformNavigator } from 'firefox-profiler/components/shared/TransformNavigator'; +import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; +import { getFunctionListSectionsOpen } from 'firefox-profiler/selectors/url-state'; +import { changeFunctionListSectionOpen } from 'firefox-profiler/actions/profile-view'; + +import type { FunctionListSectionsOpenState } from 'firefox-profiler/types'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './Butterfly.css'; + +type StateProps = { + readonly sectionsOpen: FunctionListSectionsOpenState; +}; + +type DispatchProps = { + readonly changeFunctionListSectionOpen: typeof changeFunctionListSectionOpen; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +class ProfileFunctionListViewImpl extends React.PureComponent { + _onDescendantsToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('descendants', isOpen); + }; + _onAncestorsToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('ancestors', isOpen); + }; + _onSelfToggle = (isOpen: boolean) => { + this.props.changeFunctionListSectionOpen('self', isOpen); + }; + + override render() { + const { sectionsOpen } = this.props; + return ( +
+ + +
+ + + } + > + + + } + > + + + } + > + + + +
+
+ ); + } +} -export const ProfileFunctionListView = () => ( -
- - - -
-); +export const ProfileFunctionListView = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state) => ({ + sectionsOpen: getFunctionListSectionsOpen(state), + }), + mapDispatchToProps: { + changeFunctionListSectionOpen, + }, + component: ProfileFunctionListViewImpl, +}); diff --git a/src/components/calltree/SelfWing.tsx b/src/components/calltree/SelfWing.tsx new file mode 100644 index 0000000000..07de24874f --- /dev/null +++ b/src/components/calltree/SelfWing.tsx @@ -0,0 +1,333 @@ +/* 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 { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { nameColumn, libColumn, treeColumnsForWeightType } from './columns'; + +import { + getCategories, + getCommittedRange, + getPreviewSelection, + getScrollToSelectionGeneration, + getProfileInterval, + getInnerWindowIDToPageMap, + getProfileUsesMultipleStackTypes, + getCurrentTableViewOptions, + getPreviewSelectionIsBeingModified, +} from 'firefox-profiler/selectors/profile'; +import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; +import { + getSelectedThreadsKey, + getSearchStringsAsRegExp, + getSelfWingView, +} from 'firefox-profiler/selectors/url-state'; +import { + updateBottomBoxContentsAndMaybeOpen, + changeRightClickedFunctionIndex, + changeTableViewOptions, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + CallNodeDisplayData, + TableViewOptions, + WingViewType, +} 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 callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly displayStackType: boolean; + readonly searchStringsRegExp: RegExp | null; + readonly disableOverscan: boolean; + readonly tableViewOptions: TableViewOptions; + readonly view: WingViewType; +}; + +type DispatchProps = { + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +type LocalState = { + selectedCallNodeIndex: IndexIntoCallNodeTable | null; + rightClickedCallNodeIndex: IndexIntoCallNodeTable | null; + expandedCallNodeIndexes: Array; +}; + +class SelfWingImpl extends React.PureComponent { + override state: LocalState = { + selectedCallNodeIndex: null, + rightClickedCallNodeIndex: null, + expandedCallNodeIndexes: [], + }; + + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView | null) => { + this._treeView = treeView; + }; + + 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, + expandedCallNodeIndexes: [], + }); + } + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ selectedCallNodeIndex: callNodeIndex }); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + this.setState({ rightClickedCallNodeIndex: callNodeIndex }); + const { callNodeInfo, threadsKey, changeRightClickedFunctionIndex } = + this.props; + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); + }; + + _onTreeViewSelectionChange = ( + callNodeIndex: IndexIntoCallNodeTable, + context: { source: 'keyboard' | 'pointer' } + ) => { + this.setState({ selectedCallNodeIndex: callNodeIndex }, () => { + // Selection in this wing is local state, so the Redux-driven + // scrollToSelectionGeneration mechanism used by the other wings does not + // apply. Scroll directly when keyboard navigation moves the selection. + if (context.source === 'keyboard' && this._treeView) { + this._treeView.scrollSelectionIntoView(); + } + }); + }; + + _onTreeViewRightClickSelection = (callNodeIndex: IndexIntoCallNodeTable) => { + this._onRightClickedCallNodeChange(callNodeIndex); + }; + + _onTreeViewExpandedNodesChange = ( + expandedCallNodeIndexes: Array + ) => { + this.setState({ expandedCallNodeIndexes }); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('function-list', 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 + ) => {}; + + _renderFlameGraph() { + const { + thread, + threadsKey, + maxStackDepthPlusOne, + flameGraphTiming, + callTree, + callNodeInfo, + timeRange, + previewSelection, + scrollToSelectionGeneration, + callTreeSummaryStrategy, + categories, + interval, + innerWindowIDToPageMap, + weightType, + ctssSamples, + ctssSampleCategoriesAndSubcategories, + displayStackType, + } = this.props; + + const { selectedCallNodeIndex, rightClickedCallNodeIndex } = this.state; + + return ( + + ); + } + + _renderCallTree() { + const { + callTree, + searchStringsRegExp, + disableOverscan, + maxStackDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + } = this.props; + const { + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + } = this.state; + if (callTree.getRoots().length === 0) { + return ; + } + return ( + + ); + } + + override render() { + if (this.props.view === 'call-tree') { + return this._renderCallTree(); + } + return this._renderFlameGraph(); + } +} + +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), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getSelfWingCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getSelfWingCtssSampleCategoriesAndSubcategories( + state + ), + displayStackType: getProfileUsesMultipleStackTypes(state), + searchStringsRegExp: getSearchStringsAsRegExp(state), + disableOverscan: getPreviewSelectionIsBeingModified(state), + tableViewOptions: getCurrentTableViewOptions(state), + view: getSelfWingView(state), + }), + mapDispatchToProps: { + updateBottomBoxContentsAndMaybeOpen, + changeRightClickedFunctionIndex, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }, + component: SelfWingImpl, +}); diff --git a/src/components/calltree/UpperWingFlameGraph.tsx b/src/components/calltree/UpperWingFlameGraph.tsx new file mode 100644 index 0000000000..01c5f3e8b5 --- /dev/null +++ b/src/components/calltree/UpperWingFlameGraph.tsx @@ -0,0 +1,274 @@ +/* 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 } from 'firefox-profiler/selectors/url-state'; +import { + changeWingSelectedCallNode, + changeWingRightClickedCallNode, + changeRightClickedFunctionIndex, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; + +import type { + Thread, + CategoryList, + Milliseconds, + StartEndRange, + WeightType, + SamplesLikeTable, + PreviewSelection, + CallTreeSummaryStrategy, + IndexIntoCallNodeTable, + ThreadsKey, + InnerWindowID, + Page, + SampleCategoriesAndSubcategories, + SelectionContext, + CallNodePath, +} 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 callTreeSummaryStrategy: CallTreeSummaryStrategy; + readonly ctssSamples: SamplesLikeTable; + readonly ctssSampleCategoriesAndSubcategories: SampleCategoriesAndSubcategories; + readonly tracedTiming: CallTreeTimings | null; + readonly displayStackType: boolean; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => any; + readonly changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => any; + readonly changeRightClickedFunctionIndex: typeof changeRightClickedFunctionIndex; + 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(); + + // eslint-disable-next-line react/no-unused-class-component-methods -- called via UpperWingFlameGraphHandle ref from UpperWingImpl + focus() { + this._flameGraph.current?.focus(); + } + + _onSelectedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + const context: SelectionContext = { source: 'pointer' }; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex), + context + ); + }; + + _onRightClickedCallNodeChange = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + const { + callNodeInfo, + threadsKey, + changeRightClickedCallNode, + changeRightClickedFunctionIndex, + } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(callNodeIndex) + ); + const funcIndex = + callNodeIndex !== null ? callNodeInfo.funcForNode(callNodeIndex) : null; + changeRightClickedFunctionIndex(threadsKey, funcIndex); + }; + + _onCallNodeEnterOrDoubleClick = ( + callNodeIndex: IndexIntoCallNodeTable | null + ) => { + if (callNodeIndex === null) { + return; + } + const { callTree, updateBottomBoxContentsAndMaybeOpen } = this.props; + const bottomBoxInfo = callTree.getBottomBoxInfoForCallNode(callNodeIndex); + updateBottomBoxContentsAndMaybeOpen('function-list', 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, + 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), + callTreeSummaryStrategy: + selectedThreadSelectors.getCallTreeSummaryStrategy(state), + innerWindowIDToPageMap: getInnerWindowIDToPageMap(state), + ctssSamples: selectedThreadSelectors.getPreviewFilteredCtssSamples(state), + ctssSampleCategoriesAndSubcategories: + selectedThreadSelectors.getPreviewFilteredCtssSampleCategoriesAndSubcategories( + state + ), + tracedTiming: selectedThreadSelectors.getTracedTiming(state), + displayStackType: getProfileUsesMultipleStackTypes(state), + }), + mapDispatchToProps: { + changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => changeWingSelectedCallNode('upper', threadsKey, path, context), + changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => changeWingRightClickedCallNode('upper', threadsKey, path), + changeRightClickedFunctionIndex, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + }, + component: UpperWingFlameGraphImpl, +}); diff --git a/src/components/calltree/WingTreeView.tsx b/src/components/calltree/WingTreeView.tsx new file mode 100644 index 0000000000..e6141e5bad --- /dev/null +++ b/src/components/calltree/WingTreeView.tsx @@ -0,0 +1,426 @@ +/* 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 explicitConnect from 'firefox-profiler/utils/connect'; +import { TreeView } from 'firefox-profiler/components/shared/TreeView'; +import { CallTreeEmptyReasons } from './CallTreeEmptyReasons'; +import { + UpperWingFlameGraph, + type UpperWingFlameGraphHandle, +} from './UpperWingFlameGraph'; +import { + LowerWingFlameGraph, + type LowerWingFlameGraphHandle, +} from './LowerWingFlameGraph'; +import { nameColumn, libColumn, treeColumnsForWeightType } from './columns'; +import { + getSearchStringsAsRegExp, + getSelectedThreadsKey, + getUpperWingView, + getLowerWingView, +} 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 { + changeWingSelectedCallNode, + changeWingRightClickedCallNode, + changeWingExpandedCallNodes, + addTransformToStack, + handleCallNodeTransformShortcut, + changeTableViewOptions, + updateBottomBoxContentsAndMaybeOpen, +} from 'firefox-profiler/actions/profile-view'; +import type { + State, + ThreadsKey, + CategoryList, + IndexIntoCallNodeTable, + CallNodeDisplayData, + WeightType, + TableViewOptions, + SelectionContext, + WingViewType, + CallNodePath, + WingName, +} 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 { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './CallTree.css'; + +// Structural interface satisfied by both wing flame graph components' ref +// handles. Used so the shared impl can call focus() on either. +type WingFlameGraphHandle = { focus(): void }; + +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; + readonly view: WingViewType; +}; + +type DispatchProps = { + readonly changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => any; + readonly changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => any; + readonly changeExpandedCallNodes: ( + threadsKey: ThreadsKey, + paths: Array + ) => any; + readonly addTransformToStack: typeof addTransformToStack; + readonly handleCallNodeTransformShortcut: typeof handleCallNodeTransformShortcut; + readonly updateBottomBoxContentsAndMaybeOpen: typeof updateBottomBoxContentsAndMaybeOpen; + readonly onTableViewOptionsChange: (options: TableViewOptions) => any; +}; + +// Wing-specific bits that vary between Upper and Lower wing. Injected by +// each wrapper below. +type WingConfigProps = { + readonly contextMenuId: string; + readonly renderFlameGraph: ( + ref: React.RefObject + ) => React.ReactNode; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps> & WingConfigProps; + +class WingTreeViewImpl extends PureComponent { + _treeView: TreeView | null = null; + _takeTreeViewRef = (treeView: TreeView | null) => { + this._treeView = treeView; + }; + _flameGraphRef: React.RefObject = + React.createRef(); + + 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.props.view === 'flame-graph') { + this._flameGraphRef.current?.focus(); + return; + } + if (this._treeView) { + this._treeView.focus(); + } + } + + _onSelectedCallNodeChange = ( + newSelectedCallNode: IndexIntoCallNodeTable, + context: SelectionContext + ) => { + const { callNodeInfo, threadsKey, changeSelectedCallNode } = this.props; + changeSelectedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode), + context + ); + }; + + _onRightClickSelection = (newSelectedCallNode: IndexIntoCallNodeTable) => { + const { callNodeInfo, threadsKey, changeRightClickedCallNode } = this.props; + changeRightClickedCallNode( + threadsKey, + callNodeInfo.getCallNodePathFromIndex(newSelectedCallNode) + ); + }; + + _onExpandedCallNodesChange = ( + newExpandedCallNodeIndexes: Array + ) => { + const { callNodeInfo, threadsKey, changeExpandedCallNodes } = this.props; + changeExpandedCallNodes( + 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('function-list', bottomBoxInfo); + }; + + maybeProcureInterestingInitialSelection() { + // Expand the heaviest callstack up to a certain depth and select the frame + // at that depth. + const { + tree, + expandedCallNodeIndexes, + selectedCallNodeIndex, + callNodeInfo, + categories, + } = this.props; + + if (selectedCallNodeIndex !== null || expandedCallNodeIndexes.length > 0) { + // Let's not change some existing state. + return; + } + + const idleCategoryIndex = categories.findIndex( + (category) => category.name === 'Idle' + ); + + const newExpandedCallNodeIndexes = expandedCallNodeIndexes.slice(); + const maxInterestingDepth = 17; // scientifically determined + let currentCallNodeIndex = tree.getRoots()[0]; + if (currentCallNodeIndex === undefined) { + // This tree is empty. + return; + } + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + for (let i = 0; i < maxInterestingDepth; i++) { + const children = tree.getChildren(currentCallNodeIndex); + if (children.length === 0) { + break; + } + + // Let's find if there's a non idle children. + const firstNonIdleNode = children.find( + (nodeIndex) => + callNodeInfo.categoryForNode(nodeIndex) !== idleCategoryIndex + ); + + // If there's a non idle children, use it; otherwise use the first + // children (that will be idle). + currentCallNodeIndex = + firstNonIdleNode !== undefined ? firstNonIdleNode : children[0]; + newExpandedCallNodeIndexes.push(currentCallNodeIndex); + } + this._onExpandedCallNodesChange(newExpandedCallNodeIndexes); + + const categoryIndex = callNodeInfo.categoryForNode(currentCallNodeIndex); + if (categoryIndex !== idleCategoryIndex) { + // If we selected the call node with a "idle" category, we'd have a + // completely dimmed activity graph because idle stacks are not drawn in + // this graph. Because this isn't probably what the average user wants we + // do it only when the category is something different. + this._onSelectedCallNodeChange(currentCallNodeIndex, { source: 'auto' }); + } + } + + _renderCallTree() { + const { + tree, + selectedCallNodeIndex, + rightClickedCallNodeIndex, + expandedCallNodeIndexes, + searchStringsRegExp, + disableOverscan, + callNodeMaxDepthPlusOne, + weightType, + tableViewOptions, + onTableViewOptionsChange, + contextMenuId, + } = this.props; + if (tree.getRoots().length === 0) { + return ; + } + return ( + + ); + } + + override render() { + if (this.props.view === 'call-tree') { + return this._renderCallTree(); + } + return this.props.renderFlameGraph(this._flameGraphRef); + } +} + +function makeMapDispatchToProps(wing: WingName) { + return { + changeSelectedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath, + context?: SelectionContext + ) => changeWingSelectedCallNode(wing, threadsKey, path, context), + changeRightClickedCallNode: ( + threadsKey: ThreadsKey, + path: CallNodePath | null + ) => changeWingRightClickedCallNode(wing, threadsKey, path), + changeExpandedCallNodes: ( + threadsKey: ThreadsKey, + paths: Array + ) => changeWingExpandedCallNodes(wing, threadsKey, paths), + addTransformToStack, + handleCallNodeTransformShortcut, + updateBottomBoxContentsAndMaybeOpen, + onTableViewOptionsChange: (options: TableViewOptions) => + changeTableViewOptions('calltree', options), + }; +} + +const renderUpperWingFlameGraph = ( + ref: React.RefObject +) => } />; + +const renderLowerWingFlameGraph = ( + ref: React.RefObject +) => } />; + +function UpperWingComponent( + props: ConnectedProps<{}, StateProps, DispatchProps> +) { + return ( + + ); +} + +function LowerWingComponent( + props: ConnectedProps<{}, StateProps, DispatchProps> +) { + return ( + + ); +} + +export const UpperWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State): StateProps => ({ + 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), + view: getUpperWingView(state), + }), + mapDispatchToProps: makeMapDispatchToProps('upper'), + component: UpperWingComponent, +}); + +export const LowerWing = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state: State): StateProps => ({ + 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), + callNodeMaxDepthPlusOne: + selectedThreadSelectors.getFilteredCallNodeMaxDepthPlusOne(state), + weightType: selectedThreadSelectors.getWeightTypeForCallTree(state), + tableViewOptions: getCurrentTableViewOptions(state), + view: getLowerWingView(state), + }), + mapDispatchToProps: makeMapDispatchToProps('lower'), + component: LowerWingComponent, +}); diff --git a/src/components/calltree/WingViewToggle.css b/src/components/calltree/WingViewToggle.css new file mode 100644 index 0000000000..0743c3bfec --- /dev/null +++ b/src/components/calltree/WingViewToggle.css @@ -0,0 +1,44 @@ +/* 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/. */ + +.wingViewToggle { + display: flex; + align-items: center; + gap: 1px; +} + +.wingViewToggleButton { + display: flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + padding: 0; + border: none; + border-radius: 2px; + background: transparent; + color: var(--panel-foreground-color); + cursor: pointer; +} + +.wingViewToggleButton:hover { + background: var(--clickable-ghost-hover-background-color); +} + +.wingViewToggleButton:active { + background: var(--clickable-ghost-active-background-color); +} + +.wingViewToggleButton.selected { + background: var(--clickable-ghost-active-background-color); +} + +.wingViewToggleButton svg { + fill: currentcolor; + opacity: 0.55; +} + +.wingViewToggleButton.selected svg { + opacity: 1; +} diff --git a/src/components/calltree/WingViewToggle.tsx b/src/components/calltree/WingViewToggle.tsx new file mode 100644 index 0000000000..44ab072baa --- /dev/null +++ b/src/components/calltree/WingViewToggle.tsx @@ -0,0 +1,123 @@ +/* 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 explicitConnect from 'firefox-profiler/utils/connect'; +import { + getUpperWingView, + getLowerWingView, + getSelfWingView, +} from 'firefox-profiler/selectors/url-state'; +import { changeWingView } from 'firefox-profiler/actions/profile-view'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; + +import type { State, WingViewType } from 'firefox-profiler/types'; +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +import './WingViewToggle.css'; + +type OwnProps = { + readonly wing: 'upper' | 'lower' | 'self'; +}; + +type StateProps = { + readonly view: WingViewType; +}; + +type DispatchProps = { + readonly changeWingView: typeof changeWingView; +}; + +type Props = ConnectedProps; + +class WingViewToggleImpl extends React.PureComponent { + _onFlameGraphClick = (event: React.MouseEvent) => { + event.stopPropagation(); + this.props.changeWingView(this.props.wing, 'flame-graph'); + }; + _onCallTreeClick = (event: React.MouseEvent) => { + event.stopPropagation(); + this.props.changeWingView(this.props.wing, 'call-tree'); + }; + + override render() { + const { view } = this.props; + return ( +
+ + +
+ ); + } +} + +export const WingViewToggle = explicitConnect< + OwnProps, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State, ownProps: OwnProps) => { + let view; + switch (ownProps.wing) { + case 'upper': + view = getUpperWingView(state); + break; + case 'lower': + view = getLowerWingView(state); + break; + case 'self': + view = getSelfWingView(state); + break; + default: + throw assertExhaustiveCheck(ownProps.wing, 'Unhandled wing.'); + } + return { view }; + }, + mapDispatchToProps: { + changeWingView, + }, + component: WingViewToggleImpl, +}); diff --git a/src/components/shared/FunctionListContextMenu.tsx b/src/components/shared/WingContextMenu.tsx similarity index 53% rename from src/components/shared/FunctionListContextMenu.tsx rename to src/components/shared/WingContextMenu.tsx index ccbdeb54a8..5b8075b07c 100644 --- a/src/components/shared/FunctionListContextMenu.tsx +++ b/src/components/shared/WingContextMenu.tsx @@ -26,6 +26,7 @@ import { getProfileViewOptions, getShouldDisplaySearchfox, } from 'firefox-profiler/selectors/profile'; +import { getRightClickedCallNodeInfo } from 'firefox-profiler/selectors/right-clicked-call-node'; import { oneLine } from 'common-tags'; import { @@ -47,11 +48,103 @@ import type { ConnectedProps } from 'firefox-profiler/utils/connect'; import './CallNodeContextMenu.css'; +// Context provided to data-table menu items so they can compute visibility, +// label, and l10n vars from the current right-clicked function. +type MenuItemContext = { + readonly funcIndex: IndexIntoFuncTable; + readonly callNodeTables: ReadonlyArray; + readonly nameForResource: string | null; +}; + +// A descriptor for a transform menu item. The data table below drives the +// rendering of the menu, instead of repeating identical JSX three times. +type TransformMenuItem = { + readonly transform: TransformType; + readonly shortcut: string; + readonly icon: string; + readonly l10nId: string; + readonly content: (ctx: MenuItemContext) => string; + readonly visible?: (ctx: MenuItemContext) => boolean; + readonly l10nVars?: (ctx: MenuItemContext) => Record; + readonly l10nElems?: Record; +}; + +const MENU_ITEMS: ReadonlyArray = [ + { + transform: 'merge-function', + shortcut: 'm', + icon: 'Merge', + l10nId: 'CallNodeContextMenu--transform-merge-function', + content: () => 'Merge function', + }, + { + transform: 'focus-function', + shortcut: 'f', + icon: 'Focus', + l10nId: 'CallNodeContextMenu--transform-focus-function', + content: () => 'Focus on function', + }, + { + transform: 'focus-self', + shortcut: 'S', + icon: 'FocusSelf', + l10nId: 'CallNodeContextMenu--transform-focus-self', + content: () => 'Focus on self only', + }, + { + transform: 'collapse-function-subtree', + shortcut: 'c', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-function-subtree', + content: () => 'Collapse function', + }, + { + transform: 'collapse-resource', + shortcut: 'C', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-resource', + content: ({ nameForResource }) => `Collapse ${nameForResource}`, + visible: ({ nameForResource }) => nameForResource !== null, + l10nVars: ({ nameForResource }) => ({ + nameForResource: nameForResource ?? '', + }), + l10nElems: { strong: }, + }, + { + transform: 'collapse-recursion', + shortcut: 'r', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-recursion', + content: () => 'Collapse recursion', + visible: ({ funcIndex, callNodeTables }) => + callNodeTables.some((t) => funcHasRecursiveCall(t, funcIndex)), + }, + { + transform: 'collapse-direct-recursion', + shortcut: 'R', + icon: 'Collapse', + l10nId: 'CallNodeContextMenu--transform-collapse-direct-recursion-only', + content: () => 'Collapse direct recursion only', + visible: ({ funcIndex, callNodeTables }) => + callNodeTables.some((t) => funcHasDirectRecursiveCall(t, funcIndex)), + }, + { + transform: 'drop-function', + shortcut: 'd', + icon: 'Drop', + l10nId: 'CallNodeContextMenu--transform-drop-function', + content: () => 'Drop samples with this function', + }, +]; + type StateProps = { readonly thread: Thread | null; readonly threadsKey: ThreadsKey | null; - readonly rightClickedFunctionIndex: IndexIntoFuncTable | null; - readonly callNodeTable: CallNodeTable | null; + readonly funcIndex: IndexIntoFuncTable | null; + // Call node tables in which to check for recursion. Multiple tables are + // useful for the self wing menu, where the focusSelf-filtered table may + // reveal recursion that the regular table hides. + readonly callNodeTables: ReadonlyArray; readonly implementation: ImplementationFilter; readonly displaySearchfox: boolean; }; @@ -62,9 +155,13 @@ type DispatchProps = { readonly setContextMenuVisibility: typeof setContextMenuVisibility; }; -type Props = ConnectedProps<{}, StateProps, DispatchProps>; +// The DOM id is baked in by each connected variant below; the impl receives +// it as a regular prop. +type Props = ConnectedProps<{}, StateProps, DispatchProps> & { + readonly id: string; +}; -class FunctionListContextMenuImpl extends PureComponent { +class WingContextMenuImpl extends PureComponent { _hidingTimeout: NodeJS.Timeout | null = null; _onShow = () => { @@ -85,22 +182,10 @@ class FunctionListContextMenuImpl extends PureComponent { 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, - }; + const { thread, threadsKey, funcIndex } = this.props; + if (thread !== null && threadsKey !== null && funcIndex !== null) { + return { thread, threadsKey, funcIndex }; } return null; } @@ -233,7 +318,7 @@ class FunctionListContextMenuImpl extends PureComponent { case 'focus-category': case 'filter-samples': throw new Error( - `The transform "${type}" is not supported in the function list context menu.` + `The transform "${type}" is not supported in the wing context menu.` ); default: assertExhaustiveCheck(type); @@ -264,45 +349,33 @@ class FunctionListContextMenuImpl extends PureComponent { } }; - 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; - }) { + renderTransformMenuItem(item: TransformMenuItem, ctx: MenuItemContext) { return ( - + -
- {props.content} +
+ {item.content(ctx)}
- {props.shortcut} + {item.shortcut} ); } renderContextMenuContents() { - const { displaySearchfox } = this.props; + const { displaySearchfox, callNodeTables } = this.props; const info = this._getRightClickedInfo(); if (info === null) { @@ -312,99 +385,19 @@ class FunctionListContextMenuImpl extends PureComponent { return
; } - const { funcIndex, callNodeTable } = info; - const nameForResource = this.getNameForSelectedResource(); + const ctx: MenuItemContext = { + funcIndex: info.funcIndex, + callNodeTables, + nameForResource: this.getNameForSelectedResource(), + }; + + const renderedItems = MENU_ITEMS.filter( + (item) => !item.visible || item.visible(ctx) + ).map((item) => this.renderTransformMenuItem(item, ctx)); 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', - })} + {renderedItems}
@@ -434,7 +427,7 @@ class FunctionListContextMenuImpl extends PureComponent { return ( { } } +const dispatchToProps: DispatchProps = { + addTransformToStack, + addCollapseResourceTransformToStack, + setContextMenuVisibility, +}; + +// Connected variant used by the function list and self wing: the right-clicked +// function comes from the rightClickedFunction profile-view state. Recursion +// detection considers both the regular call node table and the self wing's +// call node table (where the focusSelf filter may surface recursion that the +// regular table hides). export const FunctionListContextMenu = explicitConnect< {}, StateProps, @@ -454,35 +458,83 @@ export const FunctionListContextMenu = explicitConnect< 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(); + if (rightClickedFunction === null) { + return { + thread: null, + threadsKey: null, + funcIndex: null, + callNodeTables: [], + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; } + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedFunction.threadsKey + ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + selectors.getSelfWingCallNodeInfo(state).getCallNodeTable(), + ]; + return { - thread, - threadsKey, - rightClickedFunctionIndex, - callNodeTable, + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedFunction.threadsKey, + funcIndex: rightClickedFunction.functionIndex, + callNodeTables, implementation: getImplementationFilter(state), displaySearchfox: getShouldDisplaySearchfox(state), }; }, - mapDispatchToProps: { - addTransformToStack, - addCollapseResourceTransformToStack, - setContextMenuVisibility, + mapDispatchToProps: dispatchToProps, + component: (props) => ( + + ), +}); + +// Connected variant used by the lower wing: the right-clicked function comes +// from a right-clicked call node in the LOWER_WING area. Only the regular +// call node table is consulted for recursion. +export const LowerWingContextMenu = explicitConnect< + {}, + StateProps, + DispatchProps +>({ + mapStateToProps: (state: State) => { + const rightClickedCallNodeInfo = getRightClickedCallNodeInfo(state); + + if ( + rightClickedCallNodeInfo === null || + rightClickedCallNodeInfo.area !== 'LOWER_WING' + ) { + return { + thread: null, + threadsKey: null, + funcIndex: null, + callNodeTables: [], + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; + } + + const selectors = getThreadSelectorsFromThreadsKey( + rightClickedCallNodeInfo.threadsKey + ); + const callNodeTables: CallNodeTable[] = [ + selectors.getCallNodeInfo(state).getCallNodeTable(), + ]; + + return { + thread: selectors.getFilteredThread(state), + threadsKey: rightClickedCallNodeInfo.threadsKey, + funcIndex: selectors.getLowerWingRightClickedFuncIndex(state), + callNodeTables, + implementation: getImplementationFilter(state), + displaySearchfox: getShouldDisplaySearchfox(state), + }; }, - component: FunctionListContextMenuImpl, + mapDispatchToProps: dispatchToProps, + component: (props) => ( + + ), }); diff --git a/src/profile-logic/flame-graph.ts b/src/profile-logic/flame-graph.ts index 2618fc6ef5..187fc13fb2 100644 --- a/src/profile-logic/flame-graph.ts +++ b/src/profile-logic/flame-graph.ts @@ -413,7 +413,7 @@ export type LowerWingFlameGraphTotals = { rootTotalSummary: number; // Equal to rootTotalSummary — the lower wing's flame graph root fills the // entire width. - flameGraphTotalForScaling: number; + flameGraphWidthTotal: number; }; /** @@ -493,7 +493,7 @@ export class LowerWingFlameGraphTiming extends FlameGraphTiming { this._prefixSums = null; this._prefixSumsForTableLength = -1; this._rootTotalSummary = 0; - // tooltipRatioMultiplier for the lower wing is flameGraphTotalForScaling / + // tooltipRatioMultiplier for the lower wing is flameGraphWidthTotal / // rootTotalSummary, both equal to total[0]. So 1 when there's a selection // with sample weight, 0 otherwise. We resolve this lazily inside // `_ensurePrefixSums` since both values come from the prefix-sums pass. @@ -656,8 +656,8 @@ export class LowerWingFlameGraphTiming extends FlameGraphTiming { this._growPerTableIdxArraysTo(table.length); const rowNodes = this._rowsCallNodes[nextDepth]; - const flameGraphTotalForScaling = this._rootTotalSummary; - if (flameGraphTotalForScaling === 0) { + const flameGraphWidthTotal = this._rootTotalSummary; + if (flameGraphWidthTotal === 0) { this._timingRows.push({ start: [], end: [], @@ -724,8 +724,8 @@ export class LowerWingFlameGraphTiming extends FlameGraphTiming { } startPerTableIdx[tableIdx] = currentStart; - const totalRelative = abs(totalVal / flameGraphTotalForScaling); - const selfRelativeVal = abs(selfVal / flameGraphTotalForScaling); + const totalRelative = abs(totalVal / flameGraphWidthTotal); + const selfRelativeVal = abs(selfVal / flameGraphWidthTotal); const currentEnd = currentStart + totalRelative; start.push(currentStart); diff --git a/src/reducers/profile-view.ts b/src/reducers/profile-view.ts index fa2b3fd6c8..0f9bffb48e 100644 --- a/src/reducers/profile-view.ts +++ b/src/reducers/profile-view.ts @@ -31,6 +31,7 @@ import type { Milliseconds, TableViewOptions, RightClickedFunction, + CallNodeArea, } from 'firefox-profiler/types'; import { applyFuncSubstitutionToCallPath, @@ -184,12 +185,25 @@ const sourceMapSymbolicationStatus: Reducer = ( } }; +const _emptySelectedCallNodePaths: Record = { + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + LOWER_WING: [], + UPPER_WING: [], +}; + +function _emptyExpandedCallNodePaths(): Record { + return { + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + LOWER_WING: new PathSet(), + UPPER_WING: new PathSet(), + }; +} + export const defaultThreadViewOptions: ThreadViewOptions = { - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), - selectedFunctionIndex: null, + selectedCallNodePaths: _emptySelectedCallNodePaths, + expandedCallNodePaths: _emptyExpandedCallNodePaths(), selectedNetworkMarker: null, lastSeenTransformCount: 0, }; @@ -231,24 +245,29 @@ const viewOptionsPerThread: Reducer = ( const newState = objectMap(state, (threadViewOptions) => { return { ...threadViewOptions, - selectedNonInvertedCallNodePath: applyFuncSubstitutionToCallPath( - oldFuncToNewFuncsMap, - threadViewOptions.selectedNonInvertedCallNodePath - ), - selectedInvertedCallNodePath: applyFuncSubstitutionToCallPath( - oldFuncToNewFuncsMap, - threadViewOptions.selectedInvertedCallNodePath - ), - expandedNonInvertedCallNodePaths: - applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + selectedCallNodePaths: { + ...threadViewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: applyFuncSubstitutionToCallPath( oldFuncToNewFuncsMap, - threadViewOptions.expandedNonInvertedCallNodePaths + threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE ), - expandedInvertedCallNodePaths: - applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + INVERTED_TREE: applyFuncSubstitutionToCallPath( oldFuncToNewFuncsMap, - threadViewOptions.expandedInvertedCallNodePaths + threadViewOptions.selectedCallNodePaths.INVERTED_TREE ), + }, + expandedCallNodePaths: { + ...threadViewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: + applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: applyFuncSubstitutionToPathSetAndIncludeNewAncestors( + oldFuncToNewFuncsMap, + threadViewOptions.expandedCallNodePaths.INVERTED_TREE + ), + }, }; }); @@ -256,17 +275,15 @@ const viewOptionsPerThread: Reducer = ( } case 'CHANGE_SELECTED_CALL_NODE': { const { - isInverted, + area, selectedCallNodePath, threadsKey, optionalExpandedToCallNodePath, } = action; const threadState = _getThreadViewOptions(state, threadsKey); - - const previousSelectedCallNodePath = isInverted - ? threadState.selectedInvertedCallNodePath - : threadState.selectedNonInvertedCallNodePath; + const previousSelectedCallNodePath = + threadState.selectedCallNodePaths[area]; // If the selected node doesn't actually change, let's return the previous // state to avoid rerenders. @@ -277,9 +294,7 @@ const viewOptionsPerThread: Reducer = ( return state; } - let expandedCallNodePaths = isInverted - ? threadState.expandedInvertedCallNodePaths - : threadState.expandedNonInvertedCallNodePaths; + let expandedCallNodePaths = threadState.expandedCallNodePaths[area]; const expandToNode = optionalExpandedToCallNodePath ? optionalExpandedToCallNodePath : selectedCallNodePath; @@ -303,35 +318,51 @@ const viewOptionsPerThread: Reducer = ( ); } - return _updateThreadViewOptions( - state, - threadsKey, - isInverted - ? { - selectedInvertedCallNodePath: selectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - selectedNonInvertedCallNodePath: selectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - } - ); + return _updateThreadViewOptions(state, threadsKey, { + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + [area]: selectedCallNodePath, + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }); } 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) { + const previousLowerWingPath = + threadState.selectedCallNodePaths.LOWER_WING; + const isSameSelection = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + + if (isSameSelection) { return state; } + const wingPath: CallNodePath = + selectedFunctionIndex !== null ? [selectedFunctionIndex] : []; + const wingExpanded = + selectedFunctionIndex !== null + ? new PathSet([[selectedFunctionIndex]]) + : new PathSet(); + return _updateThreadViewOptions(state, threadsKey, { - selectedFunctionIndex, + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + LOWER_WING: wingPath, + UPPER_WING: wingPath, + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + LOWER_WING: wingExpanded, + UPPER_WING: wingExpanded, + }, }); } case 'CHANGE_INVERT_CALLSTACK': { @@ -352,32 +383,34 @@ const viewOptionsPerThread: Reducer = ( expandedCallNodePaths.add(newSelectedCallNodePath.slice(0, i)); } - return invertCallstack - ? { - ...viewOptions, - selectedInvertedCallNodePath: newSelectedCallNodePath, - expandedInvertedCallNodePaths: expandedCallNodePaths, - } - : { - ...viewOptions, - selectedNonInvertedCallNodePath: newSelectedCallNodePath, - expandedNonInvertedCallNodePaths: expandedCallNodePaths, - }; + const area: CallNodeArea = invertCallstack + ? 'INVERTED_TREE' + : 'NON_INVERTED_TREE'; + return { + ...viewOptions, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + [area]: newSelectedCallNodePath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }; } return viewOptions; }); } 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 } - ); + const threadState = _getThreadViewOptions(state, threadsKey); + return _updateThreadViewOptions(state, threadsKey, { + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + [area]: expandedCallNodePaths, + }, + }); } case 'CHANGE_SELECTED_NETWORK_MARKER': { const { threadsKey, selectedNetworkMarker } = action; @@ -413,27 +446,28 @@ const viewOptionsPerThread: Reducer = ( }; const threadViewOptions = _getThreadViewOptions(state, threadsKey); - const selectedNonInvertedCallNodePath = getFilteredPath( - threadViewOptions.selectedNonInvertedCallNodePath - ); - const selectedInvertedCallNodePath = getFilteredPath( - threadViewOptions.selectedInvertedCallNodePath - ); - const expandedNonInvertedCallNodePaths = getFilteredPathSet( - threadViewOptions.expandedNonInvertedCallNodePaths - ); - const expandedInvertedCallNodePaths = getFilteredPathSet( - threadViewOptions.expandedInvertedCallNodePaths - ); - const lastSeenTransformCount = threadViewOptions.lastSeenTransformCount + 1; return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath, - selectedInvertedCallNodePath, - expandedNonInvertedCallNodePaths, - expandedInvertedCallNodePaths, + selectedCallNodePaths: { + ...threadViewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: getFilteredPath( + threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: getFilteredPath( + threadViewOptions.selectedCallNodePaths.INVERTED_TREE + ), + }, + expandedCallNodePaths: { + ...threadViewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: getFilteredPathSet( + threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE + ), + INVERTED_TREE: getFilteredPathSet( + threadViewOptions.expandedCallNodePaths.INVERTED_TREE + ), + }, lastSeenTransformCount, }); } @@ -441,11 +475,18 @@ const viewOptionsPerThread: Reducer = ( // Simply reset the stored paths until this bug is fixed: // https://github.com/firefox-devtools/profiler/issues/882 const { threadsKey } = action; + const threadState = _getThreadViewOptions(state, threadsKey); return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), + selectedCallNodePaths: { + ...threadState.selectedCallNodePaths, + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + }, + expandedCallNodePaths: { + ...threadState.expandedCallNodePaths, + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + }, lastSeenTransformCount: 0, }); } @@ -458,8 +499,48 @@ const viewOptionsPerThread: Reducer = ( return state; } - const { transforms } = action.newUrlState.profileSpecific; - return objectMap(state, (viewOptions, threadsKey) => { + const { transforms, selectedFunctions } = + action.newUrlState.profileSpecific; + + // The selected function lives in URL state; mirror it into the per-thread + // wing paths so that initial loads and back/forward navigation restore the + // wings to the right function. + const newState: ThreadViewOptionsPerThreads = { ...state }; + for (const threadsKey of Object.keys(selectedFunctions)) { + const selectedFunctionIndex = selectedFunctions[threadsKey]; + const viewOptions = _getThreadViewOptions(newState, threadsKey); + const previousLowerWingPath = + viewOptions.selectedCallNodePaths.LOWER_WING; + const matchesExisting = + selectedFunctionIndex === null + ? previousLowerWingPath.length === 0 + : previousLowerWingPath.length === 1 && + previousLowerWingPath[0] === selectedFunctionIndex; + if (matchesExisting) { + continue; + } + const wingPath: CallNodePath = + selectedFunctionIndex !== null ? [selectedFunctionIndex] : []; + const wingExpanded = + selectedFunctionIndex !== null + ? new PathSet([[selectedFunctionIndex]]) + : new PathSet(); + newState[threadsKey] = { + ...viewOptions, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + LOWER_WING: wingPath, + UPPER_WING: wingPath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + LOWER_WING: wingExpanded, + UPPER_WING: wingExpanded, + }, + }; + } + + return objectMap(newState, (viewOptions, threadsKey) => { const transformStack = transforms[threadsKey] || []; const newTransformCount = transformStack.length; const oldTransformCount = viewOptions.lastSeenTransformCount; @@ -468,10 +549,16 @@ const viewOptionsPerThread: Reducer = ( if (newTransformCount < oldTransformCount) { return { ...viewOptions, - selectedNonInvertedCallNodePath: [], - selectedInvertedCallNodePath: [], - expandedNonInvertedCallNodePaths: new PathSet(), - expandedInvertedCallNodePaths: new PathSet(), + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: [], + INVERTED_TREE: [], + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: new PathSet(), + INVERTED_TREE: new PathSet(), + }, lastSeenTransformCount: newTransformCount, }; } @@ -536,23 +623,25 @@ const viewOptionsPerThread: Reducer = ( }; const selectedNonInvertedCallNodePath = getUpdatedPath( - viewOptions.selectedNonInvertedCallNodePath + viewOptions.selectedCallNodePaths.NON_INVERTED_TREE ); const selectedInvertedCallNodePath = getUpdatedPath( - viewOptions.selectedInvertedCallNodePath - ); - const expandedNonInvertedCallNodePaths = getAncestorPathSet( - selectedNonInvertedCallNodePath - ); - const expandedInvertedCallNodePaths = getAncestorPathSet( - selectedInvertedCallNodePath + viewOptions.selectedCallNodePaths.INVERTED_TREE ); return _updateThreadViewOptions(state, threadsKey, { - selectedNonInvertedCallNodePath, - selectedInvertedCallNodePath, - expandedNonInvertedCallNodePaths, - expandedInvertedCallNodePaths, + selectedCallNodePaths: { + ...viewOptions.selectedCallNodePaths, + NON_INVERTED_TREE: selectedNonInvertedCallNodePath, + INVERTED_TREE: selectedInvertedCallNodePath, + }, + expandedCallNodePaths: { + ...viewOptions.expandedCallNodePaths, + NON_INVERTED_TREE: getAncestorPathSet( + selectedNonInvertedCallNodePath + ), + INVERTED_TREE: getAncestorPathSet(selectedInvertedCallNodePath), + }, }); } default: @@ -778,6 +867,7 @@ const rightClickedCallNode: Reducer = ( if (action.callNodePath !== null) { return { threadsKey: action.threadsKey, + area: action.area, callNodePath: action.callNodePath, }; } diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 2ba81ca643..3117f50a57 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -24,6 +24,9 @@ import type { IsOpenPerPanelState, TabID, SelectedMarkersPerThread, + SelectedFunctionsPerThread, + FunctionListSectionsOpenState, + WingViewsState, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -218,6 +221,42 @@ const functionListSort: Reducer = ( } }; +const FUNCTION_LIST_SECTIONS_OPEN_DEFAULT: FunctionListSectionsOpenState = { + descendants: true, + ancestors: false, + self: false, +}; + +const functionListSectionsOpen: Reducer = ( + state = FUNCTION_LIST_SECTIONS_OPEN_DEFAULT, + action +) => { + switch (action.type) { + case 'CHANGE_FUNCTION_LIST_SECTION_OPEN': + return { ...state, [action.section]: action.isOpen }; + default: + return state; + } +}; + +const WING_VIEWS_DEFAULT: WingViewsState = { + upper: 'flame-graph', + lower: 'flame-graph', + self: 'flame-graph', +}; + +const wingViews: Reducer = ( + state = WING_VIEWS_DEFAULT, + action +) => { + switch (action.type) { + case 'CHANGE_WING_VIEW': + return { ...state, [action.wing]: action.view }; + default: + return state; + } +}; + const networkSearchString: Reducer = (state = '', action) => { switch (action.type) { case 'CHANGE_NETWORK_SEARCH_STRING': @@ -789,6 +828,26 @@ const selectedMarkers: Reducer = ( } }; +const selectedFunctions: Reducer = ( + state = {}, + action +): SelectedFunctionsPerThread => { + switch (action.type) { + case 'CHANGE_SELECTED_FUNCTION': { + const { threadsKey, selectedFunctionIndex } = action; + if (state[threadsKey] === selectedFunctionIndex) { + return state; + } + return { + ...state, + [threadsKey]: selectedFunctionIndex, + }; + } + default: + return state; + } +}; + /** * These values are specific to an individual profile. */ @@ -818,8 +877,11 @@ const profileSpecific = combineReducers({ showJsTracerSummary, tabFilter, selectedMarkers, + selectedFunctions, markerTableSort, functionListSort, + functionListSectionsOpen, + wingViews, // The timeline tracks used to be hidden and sorted by thread indexes, rather than // track indexes. The only way to migrate this information to tracks-based data is to // first retrieve the profile, so they can't be upgraded by the normal url upgrading diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 181868fac1..f6b2b88441 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'; @@ -43,10 +44,15 @@ import type { State, CallNodeTableBitSet, IndexIntoFuncTable, + IndexIntoStackTable, + SamplesLikeTable, + SampleCategoriesAndSubcategories, + CallNodeArea, } from 'firefox-profiler/types'; import type { CallNodeInfo, CallNodeInfoInverted, + LowerWingCallNodeInfo, } from 'firefox-profiler/profile-logic/call-node-info'; import type { ThreadSelectorsPerThread } from './thread'; @@ -209,12 +215,66 @@ export function getStackAndSampleSelectorsPerThread( } ); - const getSelectedFunctionIndex: Selector = + const getSelectedFunctionIndex: Selector = ( + state + ) => UrlState.getSelectedFunction(state, threadsKey); + + 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 + ); + + // The lower wing uses its own dedicated CallNodeInfo: a single inverted + // root for the selected function, with the entire subtree built eagerly + // from that function's entry points. See LowerWingCallNodeInfo in + // call-node-info.ts. + const getLowerWingCallNodeInfo: Selector = + createSelector( + _getNonInvertedCallNodeInfo, + ProfileSelectors.getDefaultCategory, + (state: State) => + threadSelectors.getFilteredThread(state).funcTable.length, + getSelectedFunctionIndex, + ProfileData.getLowerWingCallNodeInfo + ); + + // Combines a CallNodeInfo selector with a CallNodePath selector to produce + // the corresponding call node index. + const makeCallNodeIndexFromPathSelector = ( + callNodeInfoSel: Selector, + pathSel: Selector + ): Selector => + createSelector(callNodeInfoSel, pathSel, (callNodeInfo, callNodePath) => + callNodeInfo.getCallNodeIndexFromPath(callNodePath) + ); + + // Combines a CallNodeInfo selector with an expanded-paths selector to + // produce the corresponding array of call node indexes. + const makeExpandedIndexesSelector = ( + callNodeInfoSel: Selector, + pathsSel: Selector + ): Selector> => + createSelector(callNodeInfoSel, pathsSel, (callNodeInfo, callNodePaths) => + Array.from(callNodePaths).map((path) => + callNodeInfo.getCallNodeIndexFromPath(path) + ) + ); + + const getLowerWingSelectedCallNodePath: Selector = createSelector( threadSelectors.getViewOptions, - (threadViewOptions): IndexIntoFuncTable | null => { - return threadViewOptions.selectedFunctionIndex; - } + (threadViewOptions) => threadViewOptions.selectedCallNodePaths.LOWER_WING + ); + + const getUpperWingSelectedCallNodePath: Selector = + createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.selectedCallNodePaths.UPPER_WING ); const getSelectedCallNodePath: Selector = createSelector( @@ -222,37 +282,53 @@ export function getStackAndSampleSelectorsPerThread( UrlState.getInvertCallstack, (threadViewOptions, invertCallStack): CallNodePath => invertCallStack - ? threadViewOptions.selectedInvertedCallNodePath - : threadViewOptions.selectedNonInvertedCallNodePath + ? threadViewOptions.selectedCallNodePaths.INVERTED_TREE + : threadViewOptions.selectedCallNodePaths.NON_INVERTED_TREE ); - const getSelectedCallNodeIndex: Selector = - createSelector( - getCallNodeInfo, - getSelectedCallNodePath, - (callNodeInfo, callNodePath) => { - return callNodeInfo.getCallNodeIndexFromPath(callNodePath); - } - ); + const getSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getCallNodeInfo, + getSelectedCallNodePath + ); + const getLowerWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getLowerWingCallNodeInfo, + getLowerWingSelectedCallNodePath + ); + const getUpperWingSelectedCallNodeIndex = makeCallNodeIndexFromPathSelector( + getUpperWingCallNodeInfo, + getUpperWingSelectedCallNodePath + ); const getExpandedCallNodePaths: Selector = createSelector( threadSelectors.getViewOptions, UrlState.getInvertCallstack, (threadViewOptions, invertCallStack) => invertCallStack - ? threadViewOptions.expandedInvertedCallNodePaths - : threadViewOptions.expandedNonInvertedCallNodePaths + ? threadViewOptions.expandedCallNodePaths.INVERTED_TREE + : threadViewOptions.expandedCallNodePaths.NON_INVERTED_TREE ); - const getExpandedCallNodeIndexes: Selector< - Array - > = createSelector( + const getLowerWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedCallNodePaths.LOWER_WING + ); + + const getUpperWingExpandedCallNodePaths: Selector = createSelector( + threadSelectors.getViewOptions, + (threadViewOptions) => threadViewOptions.expandedCallNodePaths.UPPER_WING + ); + + const getExpandedCallNodeIndexes = makeExpandedIndexesSelector( getCallNodeInfo, - getExpandedCallNodePaths, - (callNodeInfo, callNodePaths) => - Array.from(callNodePaths).map((path) => - callNodeInfo.getCallNodeIndexFromPath(path) - ) + getExpandedCallNodePaths + ); + const getLowerWingExpandedCallNodeIndexes = makeExpandedIndexesSelector( + getLowerWingCallNodeInfo, + getLowerWingExpandedCallNodePaths + ); + const getUpperWingExpandedCallNodeIndexes = makeExpandedIndexesSelector( + getUpperWingCallNodeInfo, + getUpperWingExpandedCallNodePaths ); const _getSampleIndexToNonInvertedCallNodeIndexForPreviewFilteredCtssThread: Selector< @@ -274,6 +350,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( @@ -357,12 +454,57 @@ export function getStackAndSampleSelectorsPerThread( } ); + const getUpperWingCallNodeSelfAndSummary: Selector = + createSelector( + threadSelectors.getPreviewFilteredCtssSamples, + _getPreviewFilteredCtssSampleIndexToUpperWingCallNodeIndex, + getUpperWingCallNodeInfo, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo, + regularTreeSelfAndSummary + ) => { + const { rootTotalSummary } = regularTreeSelfAndSummary; + const upperWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeInfo.getCallNodeTable().length + ); + const { callNodeSelf } = upperWingSelfAndSummary; + // Use the upper wing's own total as the flame graph scaling reference, + // so that the root node (the selected function) fills the full flame + // graph width. The rootTotalSummary from the regular tree is kept for + // percentage display, so tooltips show percentages relative to all + // filtered samples (e.g. "80%" if 800 of 1000 samples contain the + // selected function). + const flameGraphWidthTotal = upperWingSelfAndSummary.rootTotalSummary; + return { rootTotalSummary, callNodeSelf, flameGraphWidthTotal }; + } + ); + const getCallTreeTimings: Selector = createSelector( getCallNodeInfo, getCallNodeSelfAndSummary, CallTree.computeCallTreeTimings ); + const _getLowerWingCallTreeTimings: Selector = + createSelector( + getLowerWingCallNodeInfo, + getCallNodeSelfAndSummary, + getSelectedFunctionIndex, + CallTree.computeLowerWingTimings + ); + + const _getUpperWingCallTreeTimings: Selector = + createSelector( + getUpperWingCallNodeInfo, + getUpperWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + const getCallTreeTimingsNonInverted: Selector = createSelector( getCallNodeInfo, @@ -416,6 +558,26 @@ export function getStackAndSampleSelectorsPerThread( ) ); + const getUpperWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getUpperWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getUpperWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + + const getLowerWingCallTree: Selector = createSelector( + threadSelectors.getPreviewFilteredThread, + getLowerWingCallNodeInfo, + ProfileSelectors.getCategories, + threadSelectors.getPreviewFilteredCtssSamples, + _getLowerWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + const getSourceViewLineTimings: Selector = createSelector( getSourceViewStackLineInfo, threadSelectors.getPreviewFilteredCtssSamples, @@ -502,6 +664,190 @@ 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 + ); + + // Lower wing flame graph: rendered top-down (icicle). The cells are + // InvertedCallNodeHandle values that map back to LowerWingCallNodeInfo. + // The returned object is stateful — it extends the lower-wing CNI and + // computes per-row timing on demand as the Canvas scrolls — so the selector + // is keyed only on inputs that change when the underlying tree must be + // rebuilt. Treat the object's mutations as invisible (no caller observes + // anything but `getRow(depth)` results, which are write-once / additive). + const getLowerWingFlameGraphTiming: Selector = + createSelector( + getLowerWingCallNodeInfo, + getCallNodeSelfAndSummary, + _getCallNodeTable, + getSelectedFunctionIndex, + (state: State) => + threadSelectors.getPreviewFilteredThread(state).funcTable, + (state: State) => + threadSelectors.getPreviewFilteredThread(state).stringTable, + FlameGraph.createLowerWingFlameGraphTiming + ); + + // The max depth equals the deepest entry point's non-inverted depth (an entry + // at non-inverted depth D contributes D ancestor steps above the inverted + // root). We can read this off the non-inverted call node table without + // building the lower-wing tree, so the scroll-area height is available before + // any rows are computed. + const getLowerWingFlameGraphMaxDepthPlusOne: Selector = + createSelector( + _getCallNodeTable, + getSelectedFunctionIndex, + ProfileData.computeLowerWingMaxDepthPlusOne + ); + + // Self wing: focusSelf(rangeAndTransformFilteredThread, selectedFunc, implFilter) + // This uses the thread BEFORE the implementation filter so that native frames + // that are "inside" the selected function's self time are visible even when + // the implementation filter is set to "JS only". + const getSelfWingThread: Selector = createSelector( + threadSelectors.getRangeAndTransformFilteredThread, + getSelectedFunctionIndex, + UrlState.getImplementationFilter, + (thread, funcIndex, implFilter) => { + if (funcIndex === null) { + return thread; + } + return Transforms.focusSelf(thread, funcIndex, implFilter); + } + ); + + const _getSelfWingCallNodeInfo: Selector = createSelector( + (state: State) => getSelfWingThread(state).stackTable, + (state: State) => getSelfWingThread(state).frameTable, + ProfileSelectors.getDefaultCategory, + ProfileData.getCallNodeInfo + ); + + const _getSelfWingCtssSamples: Selector = createSelector( + getSelfWingThread, + threadSelectors.getCallTreeSummaryStrategy, + CallTree.extractSamplesLikeTable + ); + + const _getSelfWingSampleIndexToCallNodeIndex: Selector< + Array + > = createSelector( + (state: State) => _getSelfWingCtssSamples(state).stack, + (state: State) => + _getSelfWingCallNodeInfo(state).getStackIndexToNonInvertedCallNodeIndex(), + ProfileData.getSampleIndexToCallNodeIndex + ); + + const _getSelfWingCallNodeSelfAndSummary: Selector = + createSelector( + _getSelfWingCtssSamples, + _getSelfWingSampleIndexToCallNodeIndex, + (state: State) => + _getSelfWingCallNodeInfo(state).getCallNodeTable().length, + getCallNodeSelfAndSummary, + ( + samples, + sampleIndexToCallNodeIndex, + callNodeCount, + regularTreeSelfAndSummary + ) => { + const selfWingSelfAndSummary = CallTree.computeCallNodeSelfAndSummary( + samples, + sampleIndexToCallNodeIndex, + callNodeCount + ); + // Keep flameGraphWidthTotal 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, + flameGraphWidthTotal: selfWingSelfAndSummary.rootTotalSummary, + rootTotalSummary: regularTreeSelfAndSummary.rootTotalSummary, + }; + } + ); + + const _getSelfWingCallTreeTimings: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimings + ); + + const _getSelfWingCallTreeTimingsNonInverted: Selector = + createSelector( + _getSelfWingCallNodeInfo, + _getSelfWingCallNodeSelfAndSummary, + CallTree.computeCallTreeTimingsNonInverted + ); + + const getSelfWingCallTree: Selector = createSelector( + getSelfWingThread, + _getSelfWingCallNodeInfo, + ProfileSelectors.getCategories, + _getSelfWingCtssSamples, + _getSelfWingCallTreeTimings, + getWeightTypeForCallTree, + CallTree.getCallTree + ); + + const _getSelfWingFlameGraphRows: Selector = + createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (state: State) => getSelfWingThread(state).funcTable, + (state: State) => getSelfWingThread(state).stringTable, + FlameGraph.computeFlameGraphRows + ); + + const getSelfWingFlameGraphTiming: Selector = + createSelector( + _getSelfWingFlameGraphRows, + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + _getSelfWingCallTreeTimingsNonInverted, + FlameGraph.getFlameGraphTiming + ); + + const getSelfWingCallNodeMaxDepthPlusOne: Selector = createSelector( + (state: State) => _getSelfWingCallNodeInfo(state).getCallNodeTable(), + (callNodeTable) => callNodeTable.maxDepth + 1 + ); + + const getSelfWingCallNodeInfo: Selector = + _getSelfWingCallNodeInfo; + + const getSelfWingCtssSamples: Selector = + _getSelfWingCtssSamples; + + const getSelfWingCtssSampleCategoriesAndSubcategories: Selector = + createSelector( + getSelfWingThread, + _getSelfWingCtssSamples, + ProfileSelectors.getDefaultCategory, + CallTree.computeUnfilteredCtssSampleCategoriesAndSubcategories + ); + const getRightClickedCallNodeIndex: Selector = createSelector( getRightClickedCallNodeInfo, @@ -510,16 +856,68 @@ 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; + } + ); + + // Returns the right-clicked call node index in `area` for this thread, or + // null if the right-clicked call node belongs to a different thread or area. + const makeAreaRightClickedCallNodeIndexSelector = ( + area: CallNodeArea, + callNodeInfoSel: Selector + ): Selector => + createSelector( + getRightClickedCallNodeInfo, + callNodeInfoSel, + (rightClickedCallNodeInfo, callNodeInfo) => { + if ( + rightClickedCallNodeInfo !== null && + rightClickedCallNodeInfo.threadsKey === threadsKey && + rightClickedCallNodeInfo.area === area ) { return callNodeInfo.getCallNodeIndexFromPath( rightClickedCallNodeInfo.callNodePath ); } - return null; } ); + const getLowerWingRightClickedCallNodeIndex = + makeAreaRightClickedCallNodeIndexSelector( + 'LOWER_WING', + getLowerWingCallNodeInfo + ); + + const getUpperWingRightClickedCallNodeIndex = + makeAreaRightClickedCallNodeIndexSelector( + 'UPPER_WING', + getUpperWingCallNodeInfo + ); + + const getLowerWingRightClickedFuncIndex: Selector = + createSelector( + getLowerWingRightClickedCallNodeIndex, + getLowerWingCallNodeInfo, + (callNodeIndex, callNodeInfo) => { + if (callNodeIndex === null) { + return null; + } + return callNodeInfo.funcForNode(callNodeIndex); + } + ); + const getRightClickedFunctionIndex: Selector = createSelector( ProfileSelectors.getProfileViewOptions, @@ -541,14 +939,24 @@ export function getStackAndSampleSelectorsPerThread( unfilteredSamplesRange, getWeightTypeForCallTree, getCallNodeInfo, + getLowerWingCallNodeInfo, + getUpperWingCallNodeInfo, getSourceViewStackLineInfo, getAssemblyViewNativeSymbolIndex, getAssemblyViewStackAddressInfo, getSelectedCallNodePath, getSelectedCallNodeIndex, + getLowerWingSelectedCallNodePath, + getLowerWingSelectedCallNodeIndex, + getUpperWingSelectedCallNodePath, + getUpperWingSelectedCallNodeIndex, getSelectedFunctionIndex, getExpandedCallNodePaths, getExpandedCallNodeIndexes, + getLowerWingExpandedCallNodePaths, + getLowerWingExpandedCallNodeIndexes, + getUpperWingExpandedCallNodePaths, + getUpperWingExpandedCallNodeIndexes, getSampleIndexToNonInvertedCallNodeIndexForFilteredThread, getSampleSelectedStatesInFilteredThread, getSampleSelectedStatesForFunctionListTab, @@ -556,6 +964,18 @@ export function getStackAndSampleSelectorsPerThread( getCallTree, getFunctionListTree, getFunctionListTimings, + getLowerWingCallTree, + getUpperWingCallTree, + getUpperWingFlameGraphTiming, + getLowerWingFlameGraphTiming, + getLowerWingFlameGraphMaxDepthPlusOne, + getSelfWingThread, + getSelfWingCallNodeInfo, + getSelfWingCallTree, + getSelfWingFlameGraphTiming, + getSelfWingCallNodeMaxDepthPlusOne, + getSelfWingCtssSamples, + getSelfWingCtssSampleCategoriesAndSubcategories, getSourceViewLineTimings, getAssemblyViewAddressTimings, getTracedTiming, @@ -566,5 +986,8 @@ export function getStackAndSampleSelectorsPerThread( getFlameGraphTiming, getRightClickedCallNodeIndex, getRightClickedFunctionIndex, + getLowerWingRightClickedCallNodeIndex, + getLowerWingRightClickedFuncIndex, + getUpperWingRightClickedCallNodeIndex, }; } diff --git a/src/selectors/right-clicked-call-node.tsx b/src/selectors/right-clicked-call-node.tsx index a72dc73d35..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/selectors/url-state.ts b/src/selectors/url-state.ts index 8635d93a30..3e49d61b34 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -32,6 +32,10 @@ import type { TabID, IndexIntoSourceTable, MarkerIndex, + IndexIntoFuncTable, + FunctionListSectionsOpenState, + WingViewsState, + WingViewType, } from 'firefox-profiler/types'; import type { TabSlug } from '../app-logic/tabs-handling'; @@ -124,6 +128,17 @@ export const getMarkerTableSort: Selector = ( export const getFunctionListSort: Selector = ( state ) => getProfileSpecificState(state).functionListSort; +export const getFunctionListSectionsOpen: Selector< + FunctionListSectionsOpenState +> = (state) => getProfileSpecificState(state).functionListSectionsOpen; +export const getWingViews: Selector = (state) => + getProfileSpecificState(state).wingViews; +export const getUpperWingView: Selector = (state) => + getWingViews(state).upper; +export const getLowerWingView: Selector = (state) => + getWingViews(state).lower; +export const getSelfWingView: Selector = (state) => + getWingViews(state).self; export const getNetworkSearchString: Selector = (state) => getProfileSpecificState(state).networkSearchString; export const getSelectedTab: Selector = (state) => @@ -252,6 +267,12 @@ export const getSelectedMarker: DangerousSelectorWithArguments< > = (state, threadsKey) => getProfileSpecificState(state).selectedMarkers[threadsKey] ?? null; +export const getSelectedFunction: DangerousSelectorWithArguments< + IndexIntoFuncTable | null, + ThreadsKey +> = (state, threadsKey) => + getProfileSpecificState(state).selectedFunctions[threadsKey] ?? null; + export const getIsBottomBoxOpen: Selector = (state) => { const tab = getSelectedTab(state); return getProfileSpecificState(state).isBottomBoxOpenPerPanel[tab]; diff --git a/src/test/components/FunctionListContextMenu.test.tsx b/src/test/components/FunctionListContextMenu.test.tsx index 6f85957870..019b0a8936 100644 --- a/src/test/components/FunctionListContextMenu.test.tsx +++ b/src/test/components/FunctionListContextMenu.test.tsx @@ -6,7 +6,7 @@ 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 { FunctionListContextMenu } from '../../components/shared/WingContextMenu'; import { storeWithProfile } from '../fixtures/stores'; import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; import { fireFullClick } from '../fixtures/utils'; diff --git a/src/test/components/LowerWingContextMenu.test.tsx b/src/test/components/LowerWingContextMenu.test.tsx new file mode 100644 index 0000000000..244db89b31 --- /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/WingContextMenu'; +import { storeWithProfile } from '../fixtures/stores'; +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { fireFullClick } from '../fixtures/utils'; +import { + changeWingRightClickedCallNode, + changeSelectedFunctionIndex, + 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 lower wing only exists when a function is selected. Select C so the + // lower wing tree is built with C as the root. + const threadsKey = 0; + store.dispatch(changeSelectedFunctionIndex(threadsKey, C)); + // The inverted call node path for B-as-caller-of-C is [C, B]. + store.dispatch(changeWingRightClickedCallNode('lower', 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]; + const { + funcNamesDictPerThread: [{ B }], + } = getProfileFromTextSamples(` + A A + B E + C C + `); + // The transform should target B (the right-clicked caller), not C (the root). + expect(transform).toMatchObject({ type: 'merge-function', funcIndex: B }); + }); + + it('adds a focus-function transform for the right-clicked node', function () { + const { getState } = setup(); + fireFullClick(screen.getByText(/Focus on function/)); + expect( + selectedThreadSelectors.getTransformStack(getState())[0].type + ).toBe('focus-function'); + }); + }); +}); diff --git a/src/test/store/__snapshots__/profile-view.test.ts.snap b/src/test/store/__snapshots__/profile-view.test.ts.snap index c9f051d5ab..98280aac21 100644 --- a/src/test/store/__snapshots__/profile-view.test.ts.snap +++ b/src/test/store/__snapshots__/profile-view.test.ts.snap @@ -4417,27 +4417,38 @@ Process: \\"default\\" (0)" exports[`snapshots of selectors/profile matches the last stored run of selectedThreadSelector.getViewOptions 1`] = ` Object { - "expandedInvertedCallNodePaths": PathSet { - "_table": Map {}, - }, - "expandedNonInvertedCallNodePaths": PathSet { - "_table": Map { - "0" => Array [ - 0, - ], - "0-1" => Array [ - 0, - 1, - ], + "expandedCallNodePaths": Object { + "INVERTED_TREE": PathSet { + "_table": Map {}, + }, + "LOWER_WING": PathSet { + "_table": Map {}, + }, + "NON_INVERTED_TREE": PathSet { + "_table": Map { + "0" => Array [ + 0, + ], + "0-1" => Array [ + 0, + 1, + ], + }, + }, + "UPPER_WING": PathSet { + "_table": Map {}, }, }, "lastSeenTransformCount": 1, - "selectedFunctionIndex": null, - "selectedInvertedCallNodePath": Array [], + "selectedCallNodePaths": Object { + "INVERTED_TREE": Array [], + "LOWER_WING": Array [], + "NON_INVERTED_TREE": Array [ + 0, + 1, + ], + "UPPER_WING": Array [], + }, "selectedNetworkMarker": null, - "selectedNonInvertedCallNodePath": Array [ - 0, - 1, - ], } `; diff --git a/src/test/store/profile-view.test.ts b/src/test/store/profile-view.test.ts index 9fda33ec65..40793ddd1b 100644 --- a/src/test/store/profile-view.test.ts +++ b/src/test/store/profile-view.test.ts @@ -3601,6 +3601,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); }); @@ -3612,6 +3613,7 @@ describe('right clicked call node info', () => { expect(getRightClickedCallNodeInfo(getState())).toEqual({ threadsKey: 0, + area: 'NON_INVERTED_TREE', callNodePath: [0, 1], }); diff --git a/src/types/actions.ts b/src/types/actions.ts index 0e4c8535ba..941fa5314d 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -47,6 +47,7 @@ import type { ApiQueryError, TableViewOptions, DecodedInstruction, + CallNodeArea, } from './state'; import type { CssPixels, StartEndRange, Milliseconds } from './units'; import type { BrowserConnectionStatus } from '../app-logic/browser-connection'; @@ -187,7 +188,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; @@ -207,6 +208,7 @@ type ProfileAction = | { readonly type: 'CHANGE_RIGHT_CLICKED_CALL_NODE'; readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath | null; } | { @@ -220,7 +222,7 @@ type ProfileAction = | { readonly type: 'CHANGE_EXPANDED_CALL_NODES'; readonly threadsKey: ThreadsKey; - readonly isInverted: boolean; + readonly area: CallNodeArea; readonly expandedCallNodePaths: Array; } | { @@ -579,6 +581,16 @@ type UrlStateAction = readonly type: 'CHANGE_FUNCTION_LIST_SORT'; readonly sort: SingleColumnSortState[] | null; } + | { + readonly type: 'CHANGE_FUNCTION_LIST_SECTION_OPEN'; + readonly section: 'descendants' | 'ancestors' | 'self'; + readonly isOpen: boolean; + } + | { + readonly type: 'CHANGE_WING_VIEW'; + readonly wing: 'upper' | 'lower' | 'self'; + readonly view: 'flame-graph' | 'call-tree'; + } | { readonly type: 'CHANGE_NETWORK_SEARCH_STRING'; readonly searchString: string; diff --git a/src/types/state.ts b/src/types/state.ts index e4b68063cb..62bea6b086 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -57,12 +57,10 @@ export type SourceMapSymbolicationStatus = | 'INACTIVE' | 'FETCHING' | 'SYMBOLICATING'; + export type ThreadViewOptions = { - readonly selectedNonInvertedCallNodePath: CallNodePath; - readonly selectedInvertedCallNodePath: CallNodePath; - readonly expandedNonInvertedCallNodePaths: PathSet; - readonly expandedInvertedCallNodePaths: PathSet; - readonly selectedFunctionIndex: IndexIntoFuncTable | null; + readonly selectedCallNodePaths: Record; + readonly expandedCallNodePaths: Record; 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 @@ -80,8 +78,20 @@ export type TableViewOptions = { export type TableViewOptionsPerTab = { [K in TabSlug]: TableViewOptions }; +export type CallNodeArea = + | 'NON_INVERTED_TREE' + | 'INVERTED_TREE' + | 'LOWER_WING' + | 'UPPER_WING'; + +// State-bearing function-list wings: each has its own selected/expanded/ +// right-clicked call node path. The self wing has no such state of its own +// (it shares it with the main tree). +export type WingName = 'upper' | 'lower'; + export type RightClickedCallNode = { readonly threadsKey: ThreadsKey; + readonly area: CallNodeArea; readonly callNodePath: CallNodePath; }; @@ -99,6 +109,10 @@ export type SelectedMarkersPerThread = { [key: ThreadsKey]: MarkerIndex | null; }; +export type SelectedFunctionsPerThread = { + [key: ThreadsKey]: IndexIntoFuncTable | null; +}; + /** * Profile view state */ @@ -392,8 +406,25 @@ export type ProfileSpecificUrlState = { legacyThreadOrder: ThreadIndex[] | null; legacyHiddenThreads: ThreadIndex[] | null; selectedMarkers: SelectedMarkersPerThread; + selectedFunctions: SelectedFunctionsPerThread; markerTableSort: SingleColumnSortState[] | null; functionListSort: SingleColumnSortState[] | null; + functionListSectionsOpen: FunctionListSectionsOpenState; + wingViews: WingViewsState; +}; + +export type FunctionListSectionsOpenState = { + descendants: boolean; + ancestors: boolean; + self: boolean; +}; + +export type WingViewType = 'flame-graph' | 'call-tree'; + +export type WingViewsState = { + upper: WingViewType; + lower: WingViewType; + self: WingViewType; }; export type UrlState = {