Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion src/components/stack-chart/Canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* 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 { GREY_30, GREY_70 } from 'photon-colors';
import { BLUE_60, GREY_30, GREY_70 } from 'photon-colors';
import * as React from 'react';
import { TIMELINE_MARGIN_RIGHT } from '../../app-logic/constants';
import { withChartViewport, type Viewport } from '../shared/chart/Viewport';
Expand Down Expand Up @@ -78,6 +78,7 @@ type OwnProps = {
readonly displayStackType: boolean;
readonly useStackChartSameWidths: boolean;
readonly timelineUnit: TimelineUnit;
readonly searchStringsRegExp: RegExp | null;
};

type Props = Readonly<
Expand Down Expand Up @@ -183,6 +184,7 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {
getMarker,
marginLeft,
useStackChartSameWidths,
searchStringsRegExp,
viewport: {
containerWidth,
containerHeight,
Expand Down Expand Up @@ -359,6 +361,34 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {

const callNodeTable = callNodeInfo.getCallNodeTable();

// Pre-compute which call nodes match the search string, and which are
// ancestors of a match, so we can highlight the full call node paths.
let searchMatchedCallNodes: Set<IndexIntoCallNodeTable> | null = null;
let searchAncestorCallNodes: Set<IndexIntoCallNodeTable> | null = null;
if (searchStringsRegExp) {
searchMatchedCallNodes = new Set();
searchAncestorCallNodes = new Set();
for (
let callNodeIndex = 0;
callNodeIndex < callNodeTable.length;
callNodeIndex++
) {
const funcIndex = callNodeTable.func[callNodeIndex];
const funcNameIndex = thread.funcTable.name[funcIndex];
const funcName = thread.stringTable.getString(funcNameIndex);
searchStringsRegExp.lastIndex = 0;
if (searchStringsRegExp.test(funcName)) {
searchMatchedCallNodes.add(callNodeIndex);
// Walk up the prefix chain to mark all ancestors.
let ancestor = callNodeTable.prefix[callNodeIndex];
while (ancestor !== -1 && !searchAncestorCallNodes.has(ancestor)) {
searchAncestorCallNodes.add(ancestor);
ancestor = callNodeTable.prefix[ancestor];
}
}
}
}

// Only draw the stack frames that are vertically within view.
for (let depth = startDepth; depth < endDepth; depth++) {
// Get the timing information for a row of stack frames.
Expand Down Expand Up @@ -480,8 +510,10 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {

// Look up information about this stack frame.
let text, category, isSelected;
let currentCallNodeIndex: IndexIntoCallNodeTable | null = null;
if ('callNode' in stackTiming && stackTiming.callNode) {
const callNodeIndex = stackTiming.callNode[i];
currentCallNodeIndex = callNodeIndex;
const funcIndex = callNodeTable.func[callNodeIndex];
const funcNameIndex = thread.funcTable.name[funcIndex];
text = thread.stringTable.getString(funcNameIndex);
Expand Down Expand Up @@ -526,6 +558,28 @@ class StackChartCanvasImpl extends React.PureComponent<Props> {
intW + BORDER_OPACITY,
intH
);

// Draw a border around boxes that match the search string, or that
// are ancestors of a matching call node (to highlight the full path).
if (
searchMatchedCallNodes &&
searchAncestorCallNodes &&
currentCallNodeIndex !== null
) {
const isDirectMatch =
searchMatchedCallNodes.has(currentCallNodeIndex);
const isAncestorMatch =
searchAncestorCallNodes.has(currentCallNodeIndex);
if (isDirectMatch || isAncestorMatch) {
ctx.strokeStyle = BLUE_60;
ctx.lineWidth = isDirectMatch
? 2 * cssToDeviceScale
: cssToDeviceScale;
ctx.lineJoin = 'round';
ctx.strokeRect(intX + 1, intY + 1, intW - 2, intH - 2);
}
}

lastDrawnPixelX =
intX +
intW +
Expand Down
5 changes: 5 additions & 0 deletions src/components/stack-chart/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getStackChartSameWidths,
getShowUserTimings,
getSelectedThreadsKey,
getSearchStringsAsRegExp,
} from 'firefox-profiler/selectors/url-state';
import type { SameWidthsIndexToTimestampMap } from 'firefox-profiler/profile-logic/stack-timing';
import { selectedThreadSelectors } from '../../selectors/per-thread';
Expand Down Expand Up @@ -87,6 +88,7 @@ type StateProps = {
readonly hasFilteredCtssSamples: boolean;
readonly useStackChartSameWidths: boolean;
readonly timelineUnit: TimelineUnit;
readonly searchStringsRegExp: RegExp | null;
};

type DispatchProps = {
Expand Down Expand Up @@ -244,6 +246,7 @@ class StackChartImpl extends React.PureComponent<Props> {
hasFilteredCtssSamples,
useStackChartSameWidths,
timelineUnit,
searchStringsRegExp,
} = this.props;

const maxViewportHeight = combinedTimingRows.length * STACK_FRAME_HEIGHT;
Expand Down Expand Up @@ -304,6 +307,7 @@ class StackChartImpl extends React.PureComponent<Props> {
displayStackType: displayStackType,
useStackChartSameWidths,
timelineUnit,
searchStringsRegExp,
}}
/>
</div>
Expand Down Expand Up @@ -347,6 +351,7 @@ export const StackChart = explicitConnect<{}, StateProps, DispatchProps>({
selectedThreadSelectors.getHasFilteredCtssSamples(state),
useStackChartSameWidths: getStackChartSameWidths(state),
timelineUnit: getProfileTimelineUnit(state),
searchStringsRegExp: getSearchStringsAsRegExp(state),
};
},
mapDispatchToProps: {
Expand Down
51 changes: 51 additions & 0 deletions src/test/components/StackChart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
changeImplementationFilter,
changeCallTreeSummaryStrategy,
updatePreviewSelection,
changeCallTreeSearchString,
} from '../../actions/profile-view';
import { changeSelectedTab } from '../../actions/app';
import { selectedThreadSelectors } from '../../selectors/per-thread';
Expand Down Expand Up @@ -271,6 +272,56 @@ describe('StackChart', function () {
expect(drawnFrames).not.toContain('Z');
});

it('highlights boxes that match the search string', function () {
const { dispatch, flushRafCalls } = setupSamples();
flushDrawLog();

// Dispatch a search string that matches some function names.
act(() => {
dispatch(changeCallTreeSearchString('B'));
});
flushRafCalls();

const drawCalls = flushDrawLog();

// There should be strokeRect calls for the matched boxes.
const strokeRectCalls = drawCalls.filter(([fn]) => fn === 'strokeRect');
expect(strokeRectCalls.length).toBeGreaterThan(0);
});

it('highlights ancestor call nodes in the path of a search match', function () {
// Use a deeper call stack: A -> B -> C
const { dispatch, flushRafCalls } = setupSamples(`
A[cat:DOM]
B[cat:DOM]
C[cat:Graphics]
`);
flushDrawLog();

// Search for "C" which is at depth 2. Its ancestors A (depth 0) and
// B (depth 1) should also get highlighted as part of the full path.
act(() => {
dispatch(changeCallTreeSearchString('C'));
});
flushRafCalls();

const drawCalls = flushDrawLog();
const strokeRectCalls = drawCalls.filter(([fn]) => fn === 'strokeRect');

// Expect 3 strokeRect calls: one for A (ancestor), one for B (ancestor),
// and one for C (direct match).
expect(strokeRectCalls).toHaveLength(3);
});

it('does not highlight boxes when there is no search string', function () {
setupSamples();
const drawCalls = flushDrawLog();

// There should be no strokeRect calls when there is no search.
const strokeRectCalls = drawCalls.filter(([fn]) => fn === 'strokeRect');
expect(strokeRectCalls).toHaveLength(0);
});

describe('EmptyReasons', () => {
it('shows reasons when a profile has no samples', () => {
const profile = getEmptyProfile();
Expand Down
11 changes: 10 additions & 1 deletion src/test/fixtures/mocks/canvas-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export type SetFillStyleOperation = ['set fillStyle', string];
export type FillRectOperation = ['fillRect', number, number, number, number];
export type ClearRectOperation = ['clearRect', number, number, number, number];
export type FillTextOperation = ['fillText', string];
export type StrokeRectOperation = [
'strokeRect',
number,
number,
number,
number,
];

export type DrawOperation =
| BeginPathOperation
Expand All @@ -44,7 +51,8 @@ export type DrawOperation =
| SetFillStyleOperation
| FillRectOperation
| ClearRectOperation
| FillTextOperation;
| FillTextOperation
| StrokeRectOperation;

export function flushDrawLog(): DrawOperation[] {
return (window as any).__flushDrawLog();
Expand Down Expand Up @@ -95,6 +103,7 @@ function mockCanvasContext() {
moveTo: spyLog('moveTo'),
lineTo: spyLog('lineTo'),
stroke: spyLog('stroke'),
strokeRect: spyLog('strokeRect'),
rect: spyLog('rect'),
arc: spyLog('arc'),
measureText: spyLog('measureText', (text: string) => ({
Expand Down
Loading